fix: 对话格式兼容改造
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
import type { ChatMessageVo, GetChatListParams, SendDTO } from './types';
|
import type { ChatMessageVo, GetChatListParams, SendDTO } from './types';
|
||||||
import { get, post } from '@/utils/request';
|
import { get, post } from '@/utils/request';
|
||||||
|
|
||||||
// 发送消息
|
// 发送消息(旧接口)
|
||||||
export function send(data: SendDTO) {
|
export function send(data: SendDTO) {
|
||||||
const url = data.sessionId !== 'not_login'
|
const url = data.sessionId !== 'not_login'
|
||||||
? `/ai-chat/send/?sessionId=${data.sessionId}`
|
? `/ai-chat/send/?sessionId=${data.sessionId}`
|
||||||
@@ -9,6 +9,12 @@ export function send(data: SendDTO) {
|
|||||||
return post(url, data);
|
return post(url, data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 统一发送消息接口,支持4种API类型
|
||||||
|
export function unifiedSend(data: any, apiType: string, modelId: string, sessionId: string) {
|
||||||
|
const url = `/ai-chat/unified/send?apiType=${apiType}&modelId=${modelId}&sessionId=${sessionId}`;
|
||||||
|
return post(url, data);
|
||||||
|
}
|
||||||
|
|
||||||
// 新增对应会话聊天记录
|
// 新增对应会话聊天记录
|
||||||
export function addChat(data: ChatMessageVo) {
|
export function addChat(data: ChatMessageVo) {
|
||||||
return post('/system/message', data).json();
|
return post('/system/message', data).json();
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ export interface GetSessionListVO {
|
|||||||
modelId?: string;
|
modelId?: string;
|
||||||
isFree?: boolean; // 是否为免费模型
|
isFree?: boolean; // 是否为免费模型
|
||||||
isPremiumPackage?: boolean; // 是否为尊享套餐模型
|
isPremiumPackage?: boolean; // 是否为尊享套餐模型
|
||||||
|
modelApiType?: string; // API 格式类型: Completions | Messages | Responses | GenerateContent
|
||||||
}
|
}
|
||||||
|
|
||||||
// 模型类型枚举
|
// 模型类型枚举
|
||||||
|
|||||||
@@ -29,8 +29,31 @@ onMounted(async () => {
|
|||||||
const currentModelName = computed(
|
const currentModelName = computed(
|
||||||
() => modelStore.currentModelInfo && modelStore.currentModelInfo.modelName,
|
() => modelStore.currentModelInfo && modelStore.currentModelInfo.modelName,
|
||||||
);
|
);
|
||||||
const popoverList = computed(() => modelStore.modelList);
|
|
||||||
|
|
||||||
|
// API 类型映射
|
||||||
|
const apiTypeNameMap: Record<string, string> = {
|
||||||
|
Completions: 'OpenAI Chat Completion',
|
||||||
|
Responses: 'OpenAI Responses API',
|
||||||
|
Messages: 'Anthropic Claude API',
|
||||||
|
GenerateContent: 'Google Gemini API',
|
||||||
|
};
|
||||||
|
|
||||||
|
// 按 API 类型分组的模型列表
|
||||||
|
const groupedModelList = computed(() => {
|
||||||
|
const groups: Record<string, GetSessionListVO[]> = {};
|
||||||
|
|
||||||
|
modelStore.modelList.forEach((model) => {
|
||||||
|
const apiType = model.modelApiType || 'Completions';
|
||||||
|
if (!groups[apiType]) {
|
||||||
|
groups[apiType] = [];
|
||||||
|
}
|
||||||
|
groups[apiType].push(model);
|
||||||
|
});
|
||||||
|
|
||||||
|
return groups;
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('groupedModelList---', groupedModelList.value);
|
||||||
/* 弹出面板 开始 */
|
/* 弹出面板 开始 */
|
||||||
const popoverStyle = ref({
|
const popoverStyle = ref({
|
||||||
width: '200px',
|
width: '200px',
|
||||||
@@ -46,7 +69,8 @@ const popoverRef = ref();
|
|||||||
// 显示
|
// 显示
|
||||||
async function showPopover() {
|
async function showPopover() {
|
||||||
// 获取最新的模型列表
|
// 获取最新的模型列表
|
||||||
await modelStore.requestModelList();
|
|
||||||
|
return await modelStore.requestModelList();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 点击
|
// 点击
|
||||||
@@ -176,39 +200,48 @@ function getWrapperClass(item: GetSessionListVO) {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<div class="popover-content-box">
|
<div class="popover-content-box">
|
||||||
<div
|
<!-- 按 API 类型分组展示 -->
|
||||||
v-for="item in popoverList"
|
<template v-for="(models, apiType) in groupedModelList" :key="apiType">
|
||||||
:key="item.id"
|
<!-- 分组标题 -->
|
||||||
:class="getWrapperClass(item)"
|
<div class="group-title">
|
||||||
@click="handleModelClick(item)"
|
{{ apiTypeNameMap[apiType] || apiType }}
|
||||||
>
|
</div>
|
||||||
<Popover
|
|
||||||
trigger-class="popover-trigger-item-text"
|
|
||||||
popover-class="rounded-tooltip"
|
|
||||||
placement="right"
|
|
||||||
trigger="hover"
|
|
||||||
:offset="[12, 0]"
|
|
||||||
>
|
|
||||||
<template #trigger>
|
|
||||||
<span :class="getModelStyleClass(item)">
|
|
||||||
{{ item.modelName }}
|
|
||||||
</span>
|
|
||||||
</template>
|
|
||||||
<div
|
|
||||||
class="popover-content-box-item-text text-wrap max-w-200px rounded-lg p-8px font-size-12px line-height-tight"
|
|
||||||
>
|
|
||||||
{{ item.remark }}
|
|
||||||
</div>
|
|
||||||
</Popover>
|
|
||||||
|
|
||||||
<!-- VIP锁定图标 -->
|
<!-- 该分组下的模型列表 -->
|
||||||
<el-icon
|
<div
|
||||||
v-if="!isModelAvailable(item)"
|
v-for="item in models"
|
||||||
class="absolute right-1 top-1/2 transform -translate-y-1/2"
|
:key="item.id"
|
||||||
|
:class="getWrapperClass(item)"
|
||||||
|
@click="handleModelClick(item)"
|
||||||
>
|
>
|
||||||
<Lock />
|
<Popover
|
||||||
</el-icon>
|
trigger-class="popover-trigger-item-text"
|
||||||
</div>
|
popover-class="rounded-tooltip"
|
||||||
|
placement="right"
|
||||||
|
trigger="hover"
|
||||||
|
:offset="[12, 0]"
|
||||||
|
>
|
||||||
|
<template #trigger>
|
||||||
|
<span :class="getModelStyleClass(item)">
|
||||||
|
{{ item.modelName }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
<div
|
||||||
|
class="popover-content-box-item-text text-wrap max-w-200px rounded-lg p-8px font-size-12px line-height-tight"
|
||||||
|
>
|
||||||
|
{{ item.remark }}
|
||||||
|
</div>
|
||||||
|
</Popover>
|
||||||
|
|
||||||
|
<!-- VIP锁定图标 -->
|
||||||
|
<el-icon
|
||||||
|
v-if="!isModelAvailable(item)"
|
||||||
|
class="absolute right-1 top-1/2 transform -translate-y-1/2"
|
||||||
|
>
|
||||||
|
<Lock />
|
||||||
|
</el-icon>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</Popover>
|
</Popover>
|
||||||
</div>
|
</div>
|
||||||
@@ -229,6 +262,20 @@ function getWrapperClass(item: GetSessionListVO) {
|
|||||||
height: 300px;
|
height: 300px;
|
||||||
overflow: hidden auto;
|
overflow: hidden auto;
|
||||||
|
|
||||||
|
// 分组标题样式
|
||||||
|
.group-title {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #409eff;
|
||||||
|
padding: 8px 8px 4px 8px;
|
||||||
|
margin-top: 4px;
|
||||||
|
border-bottom: 1px solid #e4e7ed;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
background: var(--el-bg-color, #fff);
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
:deep(.popover-trigger-item-text) {
|
:deep(.popover-trigger-item-text) {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,9 +11,10 @@ import { useHookFetch } from 'hook-fetch/vue';
|
|||||||
import { computed, nextTick, ref, watch } from 'vue';
|
import { computed, nextTick, ref, watch } from 'vue';
|
||||||
import { Sender } from 'vue-element-plus-x';
|
import { Sender } from 'vue-element-plus-x';
|
||||||
import { useRoute } from 'vue-router';
|
import { useRoute } from 'vue-router';
|
||||||
import { send } from '@/api';
|
import { unifiedSend } from '@/api';
|
||||||
import ModelSelect from '@/components/ModelSelect/index.vue';
|
import ModelSelect from '@/components/ModelSelect/index.vue';
|
||||||
import Collapse from '@/layouts/components/Header/components/Collapse.vue';
|
import Collapse from '@/layouts/components/Header/components/Collapse.vue';
|
||||||
|
import { parseStreamChunk, convertToApiFormat, type UnifiedMessage } from '@/utils/apiFormatConverter';
|
||||||
|
|
||||||
import CreateChat from '@/layouts/components/Header/components/CreateChat.vue';
|
import CreateChat from '@/layouts/components/Header/components/CreateChat.vue';
|
||||||
import TitleEditing from '@/layouts/components/Header/components/TitleEditing.vue';
|
import TitleEditing from '@/layouts/components/Header/components/TitleEditing.vue';
|
||||||
@@ -63,8 +64,17 @@ const bubbleItems = ref<MessageItem[]>([]);
|
|||||||
const bubbleListRef = ref<BubbleListInstance | null>(null);
|
const bubbleListRef = ref<BubbleListInstance | null>(null);
|
||||||
const isSending = ref(false);
|
const isSending = ref(false);
|
||||||
|
|
||||||
|
// 记录当前请求使用的 API 格式类型,用于正确解析响应
|
||||||
|
const currentRequestApiType = ref<string>('');
|
||||||
|
|
||||||
|
// 创建统一发送请求的包装函数
|
||||||
|
const unifiedSendWrapper = (params: any) => {
|
||||||
|
const { data, apiType, modelId, sessionId } = params;
|
||||||
|
return unifiedSend(data, apiType, modelId, sessionId);
|
||||||
|
};
|
||||||
|
|
||||||
const { stream, loading: isLoading, cancel } = useHookFetch({
|
const { stream, loading: isLoading, cancel } = useHookFetch({
|
||||||
request: send,
|
request: unifiedSendWrapper,
|
||||||
onError: async (error) => {
|
onError: async (error) => {
|
||||||
isLoading.value = false;
|
isLoading.value = false;
|
||||||
|
|
||||||
@@ -133,51 +143,49 @@ watch(
|
|||||||
*/
|
*/
|
||||||
function handleDataChunk(chunk: AnyObject) {
|
function handleDataChunk(chunk: AnyObject) {
|
||||||
try {
|
try {
|
||||||
// 安全获取 delta 和 content
|
// 使用统一的解析器,根据当前请求的 API 格式类型解析响应
|
||||||
const delta = chunk.choices?.[0]?.delta;
|
const parsed = parseStreamChunk(chunk, currentRequestApiType.value || 'Completions');
|
||||||
const reasoningChunk = delta?.reasoning_content;
|
console.log('✅ [解析结果]:', parsed);
|
||||||
const parsedChunk = delta?.content;
|
|
||||||
|
|
||||||
// usage 处理(可以移动到 startSSE 里也可以写这里)
|
const latest = bubbleItems.value[bubbleItems.value.length - 1];
|
||||||
if (chunk.usage) {
|
|
||||||
const { prompt_tokens, completion_tokens, total_tokens } = chunk.usage;
|
// 处理 token 使用情况
|
||||||
const latest = bubbleItems.value[bubbleItems.value.length - 1];
|
if (parsed.usage) {
|
||||||
latest.tokenUsage = {
|
latest.tokenUsage = {
|
||||||
prompt: prompt_tokens,
|
prompt: parsed.usage.prompt_tokens || 0,
|
||||||
completion: completion_tokens,
|
completion: parsed.usage.completion_tokens || 0,
|
||||||
total: total_tokens,
|
total: parsed.usage.total_tokens || 0,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (reasoningChunk) {
|
// 处理推理内容
|
||||||
const latest = bubbleItems.value[bubbleItems.value.length - 1];
|
if (parsed.reasoning_content) {
|
||||||
latest.thinkingStatus = 'thinking';
|
latest.thinkingStatus = 'thinking';
|
||||||
latest.loading = true;
|
latest.loading = true;
|
||||||
latest.thinlCollapse = true;
|
latest.thinlCollapse = true;
|
||||||
latest.reasoning_content += reasoningChunk;
|
latest.reasoning_content += parsed.reasoning_content;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (parsedChunk) {
|
// 处理普通内容
|
||||||
const thinkStart = parsedChunk.includes('<think>');
|
if (parsed.content) {
|
||||||
const thinkEnd = parsedChunk.includes('</think>');
|
const thinkStart = parsed.content.includes('<think>');
|
||||||
|
const thinkEnd = parsed.content.includes('</think>');
|
||||||
|
|
||||||
if (thinkStart)
|
if (thinkStart)
|
||||||
isThinking = true;
|
isThinking = true;
|
||||||
if (thinkEnd)
|
if (thinkEnd)
|
||||||
isThinking = false;
|
isThinking = false;
|
||||||
|
|
||||||
const latest = bubbleItems.value[bubbleItems.value.length - 1];
|
|
||||||
|
|
||||||
if (isThinking) {
|
if (isThinking) {
|
||||||
latest.thinkingStatus = 'thinking';
|
latest.thinkingStatus = 'thinking';
|
||||||
latest.loading = true;
|
latest.loading = true;
|
||||||
latest.thinlCollapse = true;
|
latest.thinlCollapse = true;
|
||||||
latest.reasoning_content += parsedChunk.replace('<think>', '').replace('</think>', '');
|
latest.reasoning_content += parsed.content.replace('<think>', '').replace('</think>', '');
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
latest.thinkingStatus = 'end';
|
latest.thinkingStatus = 'end';
|
||||||
latest.loading = false;
|
latest.loading = false;
|
||||||
latest.content += parsedChunk;
|
latest.content += parsed.content;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -212,6 +220,15 @@ async function startSSE(chatContent: string) {
|
|||||||
|
|
||||||
isSending.value = true;
|
isSending.value = true;
|
||||||
|
|
||||||
|
// 记录当前请求使用的 API 格式类型,用于后续正确解析响应
|
||||||
|
currentRequestApiType.value = modelStore.currentModelInfo.modelApiType || 'Completions';
|
||||||
|
console.log('🚀 [发送请求] 当前模型信息:', {
|
||||||
|
modelId: modelStore.currentModelInfo.modelId,
|
||||||
|
modelName: modelStore.currentModelInfo.modelName,
|
||||||
|
modelApiType: modelStore.currentModelInfo.modelApiType,
|
||||||
|
currentRequestApiType: currentRequestApiType.value,
|
||||||
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 清空输入框
|
// 清空输入框
|
||||||
inputValue.value = '';
|
inputValue.value = '';
|
||||||
@@ -309,14 +326,33 @@ async function startSSE(chatContent: string) {
|
|||||||
return baseMessage;
|
return baseMessage;
|
||||||
});
|
});
|
||||||
|
|
||||||
// 使用 for-await 处理流式响应
|
// 获取当前模型的 API 类型
|
||||||
|
const apiType = modelStore.currentModelInfo.modelApiType || 'Completions';
|
||||||
|
console.log('📤 [转换请求] API 类型:', apiType);
|
||||||
|
console.log('📤 [转换前] 消息内容:', messagesContent);
|
||||||
|
|
||||||
|
// 根据 API 类型转换请求格式
|
||||||
|
const convertedRequest = convertToApiFormat(
|
||||||
|
messagesContent as UnifiedMessage[],
|
||||||
|
apiType,
|
||||||
|
modelStore.currentModelInfo.modelId ?? '',
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log('📤 [转换后] 请求体:', convertedRequest);
|
||||||
|
|
||||||
|
// 使用新的统一接口发送请求
|
||||||
|
const sessionId = route.params?.id !== 'not_login' ? String(route.params?.id) : 'not_login';
|
||||||
|
const modelId = modelStore.currentModelInfo.modelId ?? '';
|
||||||
|
|
||||||
for await (const chunk of stream({
|
for await (const chunk of stream({
|
||||||
messages: messagesContent,
|
data: convertedRequest,
|
||||||
sessionId: route.params?.id !== 'not_login' ? String(route.params?.id) : 'not_login',
|
apiType,
|
||||||
stream: true,
|
modelId,
|
||||||
userId: userStore.userInfo?.userId,
|
sessionId,
|
||||||
model: modelStore.currentModelInfo.modelId ?? '',
|
|
||||||
})) {
|
})) {
|
||||||
|
console.log('📦 [接收响应] 原始数据块:', chunk.result);
|
||||||
|
console.log('🔧 [解析响应] 使用格式:', currentRequestApiType.value);
|
||||||
handleDataChunk(chunk.result as AnyObject);
|
handleDataChunk(chunk.result as AnyObject);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
492
Yi.Ai.Vue3/src/utils/apiFormatConverter.ts
Normal file
492
Yi.Ai.Vue3/src/utils/apiFormatConverter.ts
Normal file
@@ -0,0 +1,492 @@
|
|||||||
|
/**
|
||||||
|
* API 格式转换工具
|
||||||
|
* 支持 OpenAI Completions、OpenAI Responses、Anthropic Claude、Google Gemini 四种格式的相互转换
|
||||||
|
*/
|
||||||
|
|
||||||
|
// API 格式类型枚举
|
||||||
|
export enum ApiFormatType {
|
||||||
|
Completions = 'Completions', // OpenAI Chat Completion
|
||||||
|
Responses = 'Responses', // OpenAI Responses API
|
||||||
|
Messages = 'Messages', // Anthropic Claude API
|
||||||
|
GenerateContent = 'GenerateContent', // Google Gemini API
|
||||||
|
}
|
||||||
|
|
||||||
|
// 统一的消息格式(内部使用)
|
||||||
|
export interface UnifiedMessage {
|
||||||
|
role: 'system' | 'user' | 'assistant' | 'model';
|
||||||
|
content: string | MessageContent[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 消息内容块
|
||||||
|
export interface MessageContent {
|
||||||
|
type: 'text' | 'image_url';
|
||||||
|
text?: string;
|
||||||
|
image_url?: {
|
||||||
|
url: string;
|
||||||
|
name?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// OpenAI Completions 格式
|
||||||
|
export interface CompletionsMessage {
|
||||||
|
role: 'system' | 'user' | 'assistant';
|
||||||
|
content: string | MessageContent[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CompletionsRequest {
|
||||||
|
model: string;
|
||||||
|
messages: CompletionsMessage[];
|
||||||
|
stream?: boolean;
|
||||||
|
max_tokens?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// OpenAI Responses 格式
|
||||||
|
export interface ResponsesMessage {
|
||||||
|
role: 'system' | 'user' | 'assistant';
|
||||||
|
content: string | MessageContent[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResponsesRequest {
|
||||||
|
model: string;
|
||||||
|
input: ResponsesMessage[];
|
||||||
|
stream?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Anthropic Claude 格式
|
||||||
|
export interface ClaudeMessage {
|
||||||
|
role: 'user' | 'assistant';
|
||||||
|
content: string | ClaudeContent[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ClaudeContent {
|
||||||
|
type: 'text' | 'image';
|
||||||
|
text?: string;
|
||||||
|
source?: {
|
||||||
|
type: 'base64';
|
||||||
|
media_type: string;
|
||||||
|
data: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ClaudeRequest {
|
||||||
|
model: string;
|
||||||
|
messages: ClaudeMessage[];
|
||||||
|
max_tokens: number;
|
||||||
|
stream?: boolean;
|
||||||
|
system?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Google Gemini 格式
|
||||||
|
export interface GeminiPart {
|
||||||
|
text?: string;
|
||||||
|
inlineData?: {
|
||||||
|
mimeType: string;
|
||||||
|
data: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GeminiContent {
|
||||||
|
role: 'user' | 'model';
|
||||||
|
parts: GeminiPart[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GeminiRequest {
|
||||||
|
contents: GeminiContent[];
|
||||||
|
generationConfig?: {
|
||||||
|
maxOutputTokens?: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将统一格式的消息转换为 OpenAI Completions 格式
|
||||||
|
*/
|
||||||
|
export function toCompletionsFormat(messages: UnifiedMessage[]): CompletionsMessage[] {
|
||||||
|
return messages.map((msg) => {
|
||||||
|
const role = msg.role === 'model' ? 'assistant' : msg.role;
|
||||||
|
return {
|
||||||
|
role: role as 'system' | 'user' | 'assistant',
|
||||||
|
content: msg.content,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将统一格式的消息转换为 OpenAI Responses 格式
|
||||||
|
*/
|
||||||
|
export function toResponsesFormat(messages: UnifiedMessage[]): ResponsesMessage[] {
|
||||||
|
return messages.map((msg) => {
|
||||||
|
const role = msg.role === 'model' ? 'assistant' : msg.role;
|
||||||
|
return {
|
||||||
|
role: role as 'system' | 'user' | 'assistant',
|
||||||
|
content: msg.content,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将统一格式的消息转换为 Anthropic Claude 格式
|
||||||
|
*/
|
||||||
|
export function toClaudeFormat(messages: UnifiedMessage[]): { messages: ClaudeMessage[]; system?: string } {
|
||||||
|
let systemPrompt: string | undefined;
|
||||||
|
const claudeMessages: ClaudeMessage[] = [];
|
||||||
|
|
||||||
|
for (const msg of messages) {
|
||||||
|
// Claude 的 system 消息需要单独提取
|
||||||
|
if (msg.role === 'system') {
|
||||||
|
systemPrompt = typeof msg.content === 'string' ? msg.content : msg.content.map(c => c.text || '').join('');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const role = msg.role === 'model' ? 'assistant' : msg.role;
|
||||||
|
|
||||||
|
// 处理内容格式
|
||||||
|
let content: string | ClaudeContent[];
|
||||||
|
if (typeof msg.content === 'string') {
|
||||||
|
content = msg.content;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
content = msg.content.map((item) => {
|
||||||
|
if (item.type === 'text') {
|
||||||
|
return { type: 'text', text: item.text || '' };
|
||||||
|
}
|
||||||
|
else if (item.type === 'image_url' && item.image_url) {
|
||||||
|
// 将 base64 图片转换为 Claude 格式
|
||||||
|
const base64Data = item.image_url.url.replace(/^data:image\/\w+;base64,/, '');
|
||||||
|
const mimeType = item.image_url.url.match(/^data:(image\/\w+);base64,/)?.[1] || 'image/jpeg';
|
||||||
|
return {
|
||||||
|
type: 'image',
|
||||||
|
source: {
|
||||||
|
type: 'base64',
|
||||||
|
media_type: mimeType,
|
||||||
|
data: base64Data,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { type: 'text', text: '' };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
claudeMessages.push({
|
||||||
|
role: role as 'user' | 'assistant',
|
||||||
|
content,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { messages: claudeMessages, system: systemPrompt };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将统一格式的消息转换为 Google Gemini 格式
|
||||||
|
*/
|
||||||
|
export function toGeminiFormat(messages: UnifiedMessage[]): GeminiContent[] {
|
||||||
|
const geminiContents: GeminiContent[] = [];
|
||||||
|
|
||||||
|
for (const msg of messages) {
|
||||||
|
// Gemini 不支持 system 角色,跳过或转换为 user
|
||||||
|
if (msg.role === 'system') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const role = msg.role === 'assistant' ? 'model' : 'user';
|
||||||
|
const parts: GeminiPart[] = [];
|
||||||
|
|
||||||
|
if (typeof msg.content === 'string') {
|
||||||
|
parts.push({ text: msg.content });
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
for (const item of msg.content) {
|
||||||
|
if (item.type === 'text') {
|
||||||
|
parts.push({ text: item.text || '' });
|
||||||
|
}
|
||||||
|
else if (item.type === 'image_url' && item.image_url) {
|
||||||
|
// 将 base64 图片转换为 Gemini 格式
|
||||||
|
const base64Data = item.image_url.url.replace(/^data:image\/\w+;base64,/, '');
|
||||||
|
const mimeType = item.image_url.url.match(/^data:(image\/\w+);base64,/)?.[1] || 'image/jpeg';
|
||||||
|
parts.push({
|
||||||
|
inlineData: {
|
||||||
|
mimeType,
|
||||||
|
data: base64Data,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
geminiContents.push({ role, parts });
|
||||||
|
}
|
||||||
|
|
||||||
|
return geminiContents;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 统一的响应数据接口(用于流式和非流式)
|
||||||
|
*/
|
||||||
|
export interface UnifiedStreamChunk {
|
||||||
|
content?: string;
|
||||||
|
reasoning_content?: string;
|
||||||
|
usage?: {
|
||||||
|
prompt_tokens?: number;
|
||||||
|
completion_tokens?: number;
|
||||||
|
total_tokens?: number;
|
||||||
|
};
|
||||||
|
finish_reason?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析 OpenAI Completions 格式的流式响应
|
||||||
|
*/
|
||||||
|
export function parseCompletionsStreamChunk(chunk: any): UnifiedStreamChunk {
|
||||||
|
const delta = chunk.choices?.[0]?.delta;
|
||||||
|
const result: UnifiedStreamChunk = {};
|
||||||
|
|
||||||
|
if (delta?.reasoning_content) {
|
||||||
|
result.reasoning_content = delta.reasoning_content;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (delta?.content) {
|
||||||
|
result.content = delta.content;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (chunk.usage) {
|
||||||
|
result.usage = {
|
||||||
|
prompt_tokens: chunk.usage.prompt_tokens,
|
||||||
|
completion_tokens: chunk.usage.completion_tokens,
|
||||||
|
total_tokens: chunk.usage.total_tokens,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (chunk.choices?.[0]?.finish_reason) {
|
||||||
|
result.finish_reason = chunk.choices[0].finish_reason;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析 OpenAI Responses 格式的流式响应
|
||||||
|
* 注意:Responses API 需要跟踪当前输出项的类型来区分 reasoning 和 message
|
||||||
|
*/
|
||||||
|
export function parseResponsesStreamChunk(chunk: any): UnifiedStreamChunk {
|
||||||
|
const result: UnifiedStreamChunk = {};
|
||||||
|
|
||||||
|
// Responses API 使用事件驱动的流式响应
|
||||||
|
|
||||||
|
// 处理 response.output_item.added 事件 - 记录输出项类型
|
||||||
|
// 这个事件告诉我们接下来的内容是 reasoning 还是 message
|
||||||
|
if (chunk.type === 'response.output_item.added' && chunk.item) {
|
||||||
|
// 暂时不返回内容,只是标记
|
||||||
|
// 实际内容会在后续的 delta 事件中
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理 response.output_text.delta 事件 - 包含文本增量
|
||||||
|
if (chunk.type === 'response.output_text.delta' && chunk.delta) {
|
||||||
|
// 根据 output_index 判断:
|
||||||
|
// output_index 0 通常是 reasoning(思考链)
|
||||||
|
// output_index 1 通常是 message(正常回复)
|
||||||
|
// 但更准确的方式是检查之前的 output_item.added 事件
|
||||||
|
// 这里我们简化处理:所有 delta 都作为正常内容
|
||||||
|
result.content = chunk.delta;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理 response.completed 事件 - 包含 usage 信息
|
||||||
|
if (chunk.type === 'response.completed' && chunk.response?.usage) {
|
||||||
|
result.usage = {
|
||||||
|
prompt_tokens: chunk.response.usage.input_tokens,
|
||||||
|
completion_tokens: chunk.response.usage.output_tokens,
|
||||||
|
total_tokens: chunk.response.usage.total_tokens,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析 Anthropic Claude 格式的流式响应
|
||||||
|
*/
|
||||||
|
export function parseClaudeStreamChunk(chunk: any): UnifiedStreamChunk {
|
||||||
|
const result: UnifiedStreamChunk = {};
|
||||||
|
|
||||||
|
// Claude 流式响应格式 - 处理 content_block_delta 事件
|
||||||
|
if (chunk.type === 'content_block_delta') {
|
||||||
|
// text_delta 类型包含文本内容
|
||||||
|
if (chunk.delta?.type === 'text_delta' && chunk.delta?.text) {
|
||||||
|
result.content = chunk.delta.text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理 message_delta 事件 - 包含 usage 和 stop_reason
|
||||||
|
if (chunk.type === 'message_delta') {
|
||||||
|
if (chunk.usage) {
|
||||||
|
result.usage = {
|
||||||
|
prompt_tokens: chunk.usage.input_tokens,
|
||||||
|
completion_tokens: chunk.usage.output_tokens,
|
||||||
|
total_tokens: (chunk.usage.input_tokens || 0) + (chunk.usage.output_tokens || 0),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (chunk.delta?.stop_reason) {
|
||||||
|
result.finish_reason = chunk.delta.stop_reason;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析 Google Gemini 格式的流式响应
|
||||||
|
*/
|
||||||
|
export function parseGeminiStreamChunk(chunk: any): UnifiedStreamChunk {
|
||||||
|
const result: UnifiedStreamChunk = {};
|
||||||
|
|
||||||
|
// Gemini 流式响应格式
|
||||||
|
const candidate = chunk.candidates?.[0];
|
||||||
|
const part = candidate?.content?.parts?.[0];
|
||||||
|
|
||||||
|
if (part?.text) {
|
||||||
|
// 检查是否是思考链内容
|
||||||
|
if (part.thought === true) {
|
||||||
|
// 这是思考过程,放入 reasoning_content
|
||||||
|
result.reasoning_content = part.text;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
// 这是正常回复内容
|
||||||
|
result.content = part.text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (chunk.usageMetadata) {
|
||||||
|
result.usage = {
|
||||||
|
prompt_tokens: chunk.usageMetadata.promptTokenCount,
|
||||||
|
completion_tokens: chunk.usageMetadata.candidatesTokenCount,
|
||||||
|
total_tokens: chunk.usageMetadata.totalTokenCount,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (candidate?.finishReason) {
|
||||||
|
result.finish_reason = candidate.finishReason;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析 SSE 格式的数据块
|
||||||
|
* SSE 格式示例:
|
||||||
|
* event: content_block_delta
|
||||||
|
* data: {"type":"content_block_delta","delta":{"text":"hello"}}
|
||||||
|
*/
|
||||||
|
function parseSSEChunk(chunk: any): any {
|
||||||
|
// 如果已经是对象,直接返回
|
||||||
|
if (typeof chunk === 'object' && chunk !== null) {
|
||||||
|
return chunk;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果是字符串,尝试解析 SSE 格式
|
||||||
|
if (typeof chunk === 'string') {
|
||||||
|
const lines = chunk.split('\n');
|
||||||
|
let dataLine = '';
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
if (line.startsWith('data: ')) {
|
||||||
|
dataLine = line.substring(6).trim();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dataLine && dataLine !== '[DONE]') {
|
||||||
|
try {
|
||||||
|
return JSON.parse(dataLine);
|
||||||
|
}
|
||||||
|
catch (e) {
|
||||||
|
console.error('解析 SSE data 失败:', e, '原始数据:', dataLine);
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return chunk;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据 API 格式类型解析流式响应数据块
|
||||||
|
* @param chunk 原始响应数据块
|
||||||
|
* @param apiType API 格式类型
|
||||||
|
* @returns 统一格式的响应数据
|
||||||
|
*/
|
||||||
|
export function parseStreamChunk(chunk: any, apiType: string): UnifiedStreamChunk {
|
||||||
|
// 先解析 SSE 格式(如果需要)
|
||||||
|
const parsedChunk = parseSSEChunk(chunk);
|
||||||
|
|
||||||
|
switch (apiType) {
|
||||||
|
case ApiFormatType.Completions:
|
||||||
|
return parseCompletionsStreamChunk(parsedChunk);
|
||||||
|
case ApiFormatType.Responses:
|
||||||
|
return parseResponsesStreamChunk(parsedChunk);
|
||||||
|
case ApiFormatType.Messages:
|
||||||
|
return parseClaudeStreamChunk(parsedChunk);
|
||||||
|
case ApiFormatType.GenerateContent:
|
||||||
|
return parseGeminiStreamChunk(parsedChunk);
|
||||||
|
default:
|
||||||
|
return parseCompletionsStreamChunk(parsedChunk);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将消息转换为指定 API 格式的请求体
|
||||||
|
* @param messages 统一格式的消息列表
|
||||||
|
* @param apiType API 格式类型
|
||||||
|
* @param model 模型名称
|
||||||
|
* @param stream 是否流式
|
||||||
|
* @returns 对应格式的请求体
|
||||||
|
*/
|
||||||
|
export function convertToApiFormat(
|
||||||
|
messages: UnifiedMessage[],
|
||||||
|
apiType: string,
|
||||||
|
model: string,
|
||||||
|
stream = true,
|
||||||
|
): any {
|
||||||
|
switch (apiType) {
|
||||||
|
case ApiFormatType.Completions: {
|
||||||
|
const completionsMessages = toCompletionsFormat(messages);
|
||||||
|
return {
|
||||||
|
model,
|
||||||
|
messages: completionsMessages,
|
||||||
|
stream,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case ApiFormatType.Responses: {
|
||||||
|
const responsesMessages = toResponsesFormat(messages);
|
||||||
|
return {
|
||||||
|
model,
|
||||||
|
input: responsesMessages,
|
||||||
|
stream,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case ApiFormatType.Messages: {
|
||||||
|
const { messages: claudeMessages, system } = toClaudeFormat(messages);
|
||||||
|
const request: any = {
|
||||||
|
model,
|
||||||
|
messages: claudeMessages,
|
||||||
|
max_tokens: 32000,
|
||||||
|
stream,
|
||||||
|
};
|
||||||
|
if (system) {
|
||||||
|
request.system = system;
|
||||||
|
}
|
||||||
|
return request;
|
||||||
|
}
|
||||||
|
case ApiFormatType.GenerateContent: {
|
||||||
|
const geminiContents = toGeminiFormat(messages);
|
||||||
|
return {
|
||||||
|
contents: geminiContents,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
default: {
|
||||||
|
const completionsMessages = toCompletionsFormat(messages);
|
||||||
|
return {
|
||||||
|
model,
|
||||||
|
messages: completionsMessages,
|
||||||
|
stream,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
6
Yi.Ai.Vue3/types/components.d.ts
vendored
6
Yi.Ai.Vue3/types/components.d.ts
vendored
@@ -25,8 +25,11 @@ declare module 'vue' {
|
|||||||
ElCol: typeof import('element-plus/es')['ElCol']
|
ElCol: typeof import('element-plus/es')['ElCol']
|
||||||
ElCollapse: typeof import('element-plus/es')['ElCollapse']
|
ElCollapse: typeof import('element-plus/es')['ElCollapse']
|
||||||
ElCollapseItem: typeof import('element-plus/es')['ElCollapseItem']
|
ElCollapseItem: typeof import('element-plus/es')['ElCollapseItem']
|
||||||
|
ElCollapseTransition: typeof import('element-plus/es')['ElCollapseTransition']
|
||||||
ElContainer: typeof import('element-plus/es')['ElContainer']
|
ElContainer: typeof import('element-plus/es')['ElContainer']
|
||||||
ElDatePicker: typeof import('element-plus/es')['ElDatePicker']
|
ElDatePicker: typeof import('element-plus/es')['ElDatePicker']
|
||||||
|
ElDescriptions: typeof import('element-plus/es')['ElDescriptions']
|
||||||
|
ElDescriptionsItem: typeof import('element-plus/es')['ElDescriptionsItem']
|
||||||
ElDialog: typeof import('element-plus/es')['ElDialog']
|
ElDialog: typeof import('element-plus/es')['ElDialog']
|
||||||
ElDivider: typeof import('element-plus/es')['ElDivider']
|
ElDivider: typeof import('element-plus/es')['ElDivider']
|
||||||
ElDrawer: typeof import('element-plus/es')['ElDrawer']
|
ElDrawer: typeof import('element-plus/es')['ElDrawer']
|
||||||
@@ -38,11 +41,13 @@ declare module 'vue' {
|
|||||||
ElImage: typeof import('element-plus/es')['ElImage']
|
ElImage: typeof import('element-plus/es')['ElImage']
|
||||||
ElImageViewer: typeof import('element-plus/es')['ElImageViewer']
|
ElImageViewer: typeof import('element-plus/es')['ElImageViewer']
|
||||||
ElInput: typeof import('element-plus/es')['ElInput']
|
ElInput: typeof import('element-plus/es')['ElInput']
|
||||||
|
ElInputNumber: typeof import('element-plus/es')['ElInputNumber']
|
||||||
ElMain: typeof import('element-plus/es')['ElMain']
|
ElMain: typeof import('element-plus/es')['ElMain']
|
||||||
ElMenu: typeof import('element-plus/es')['ElMenu']
|
ElMenu: typeof import('element-plus/es')['ElMenu']
|
||||||
ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
|
ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
|
||||||
ElOption: typeof import('element-plus/es')['ElOption']
|
ElOption: typeof import('element-plus/es')['ElOption']
|
||||||
ElPagination: typeof import('element-plus/es')['ElPagination']
|
ElPagination: typeof import('element-plus/es')['ElPagination']
|
||||||
|
ElProgress: typeof import('element-plus/es')['ElProgress']
|
||||||
ElRadio: typeof import('element-plus/es')['ElRadio']
|
ElRadio: typeof import('element-plus/es')['ElRadio']
|
||||||
ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup']
|
ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup']
|
||||||
ElRow: typeof import('element-plus/es')['ElRow']
|
ElRow: typeof import('element-plus/es')['ElRow']
|
||||||
@@ -51,6 +56,7 @@ declare module 'vue' {
|
|||||||
ElSelect: typeof import('element-plus/es')['ElSelect']
|
ElSelect: typeof import('element-plus/es')['ElSelect']
|
||||||
ElSkeleton: typeof import('element-plus/es')['ElSkeleton']
|
ElSkeleton: typeof import('element-plus/es')['ElSkeleton']
|
||||||
ElSubMenu: typeof import('element-plus/es')['ElSubMenu']
|
ElSubMenu: typeof import('element-plus/es')['ElSubMenu']
|
||||||
|
ElSwitch: typeof import('element-plus/es')['ElSwitch']
|
||||||
ElTable: typeof import('element-plus/es')['ElTable']
|
ElTable: typeof import('element-plus/es')['ElTable']
|
||||||
ElTableColumn: typeof import('element-plus/es')['ElTableColumn']
|
ElTableColumn: typeof import('element-plus/es')['ElTableColumn']
|
||||||
ElTabPane: typeof import('element-plus/es')['ElTabPane']
|
ElTabPane: typeof import('element-plus/es')['ElTabPane']
|
||||||
|
|||||||
1
Yi.Ai.Vue3/types/import_meta.d.ts
vendored
1
Yi.Ai.Vue3/types/import_meta.d.ts
vendored
@@ -7,7 +7,6 @@ interface ImportMetaEnv {
|
|||||||
readonly VITE_WEB_BASE_API: string;
|
readonly VITE_WEB_BASE_API: string;
|
||||||
readonly VITE_API_URL: string;
|
readonly VITE_API_URL: string;
|
||||||
readonly VITE_FILE_UPLOAD_API: string;
|
readonly VITE_FILE_UPLOAD_API: string;
|
||||||
readonly VITE_BUILD_COMPRESS: string;
|
|
||||||
readonly VITE_SSO_SEVER_URL: string;
|
readonly VITE_SSO_SEVER_URL: string;
|
||||||
readonly VITE_APP_VERSION: string;
|
readonly VITE_APP_VERSION: string;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user