Compare commits
No commits in common. "6e37e6142020a1e8b4c5de0cca86a0dc6b2e7a99" and "fbac280be330c5e254fd2e895be2f358bd4a858f" have entirely different histories.
6e37e61420
...
fbac280be3
@ -128,7 +128,7 @@ export async function POST(request: NextRequest) {
|
|||||||
cchUrl: process.env.CCH_DEFAULT_URL || 'https://claude.leocoder.cn/',
|
cchUrl: process.env.CCH_DEFAULT_URL || 'https://claude.leocoder.cn/',
|
||||||
cchApiKey: null,
|
cchApiKey: null,
|
||||||
cchApiKeyConfigured: false,
|
cchApiKeyConfigured: false,
|
||||||
defaultModel: 'claude-sonnet-4-5-20250929',
|
defaultModel: 'claude-sonnet-4-20250514',
|
||||||
defaultTools: ['web_search', 'code_execution', 'web_fetch'],
|
defaultTools: ['web_search', 'code_execution', 'web_fetch'],
|
||||||
theme: 'light',
|
theme: 'light',
|
||||||
language: 'zh-CN',
|
language: 'zh-CN',
|
||||||
|
|||||||
@ -72,11 +72,6 @@ function isCodexModel(modelId: string): boolean {
|
|||||||
return modelId.startsWith('gpt-') && modelId.includes('codex');
|
return modelId.startsWith('gpt-') && modelId.includes('codex');
|
||||||
}
|
}
|
||||||
|
|
||||||
// 规范化 URL(移除末尾斜杠,避免拼接时出现双斜杠)
|
|
||||||
function normalizeBaseUrl(url: string): string {
|
|
||||||
return url.replace(/\/+$/, '');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 默认系统提示词 - 用于生成更详细、更有结构的回复
|
// 默认系统提示词 - 用于生成更详细、更有结构的回复
|
||||||
const DEFAULT_SYSTEM_PROMPT = `你是一个专业、友好的 AI 助手。请遵循以下规则来回复用户:
|
const DEFAULT_SYSTEM_PROMPT = `你是一个专业、友好的 AI 助手。请遵循以下规则来回复用户:
|
||||||
|
|
||||||
@ -287,7 +282,6 @@ export async function POST(request: Request) {
|
|||||||
async start(controller) {
|
async start(controller) {
|
||||||
try {
|
try {
|
||||||
const cchUrl = settings.cchUrl || process.env.CCH_DEFAULT_URL || 'https://claude.leocoder.cn/';
|
const cchUrl = settings.cchUrl || process.env.CCH_DEFAULT_URL || 'https://claude.leocoder.cn/';
|
||||||
const apiFormat = (settings.apiFormat as 'claude' | 'openai') || 'claude';
|
|
||||||
|
|
||||||
// 获取系统提示词(叠加模式)
|
// 获取系统提示词(叠加模式)
|
||||||
// 1. 始终使用 DEFAULT_SYSTEM_PROMPT 作为基础
|
// 1. 始终使用 DEFAULT_SYSTEM_PROMPT 作为基础
|
||||||
@ -318,41 +312,8 @@ export async function POST(request: Request) {
|
|||||||
let totalInputTokens = 0;
|
let totalInputTokens = 0;
|
||||||
let totalOutputTokens = 0;
|
let totalOutputTokens = 0;
|
||||||
|
|
||||||
// 【重要】处理器选择优先级说明:
|
if (isCodex) {
|
||||||
// 1. 首先检查 apiFormat === 'openai':如果用户选择了 "OpenAI 兼容" 格式,
|
// ==================== Codex 模型处理(OpenAI 格式) ====================
|
||||||
// 则所有模型(包括 Codex 模型)都统一使用 /v1/chat/completions 端点
|
|
||||||
// 这是因为第三方中转站通常只支持 OpenAI 兼容的 /v1/chat/completions 端点
|
|
||||||
// 2. 然后检查 isCodex:如果是 Claude 原生格式 + Codex 模型,才使用 /v1/responses 端点
|
|
||||||
// 3. 最后是普通的 Claude 原生格式,使用 /v1/messages 端点
|
|
||||||
|
|
||||||
if (apiFormat === 'openai') {
|
|
||||||
// ==================== OpenAI 兼容格式处理 ====================
|
|
||||||
// 当用户选择 "OpenAI 兼容" 时,无论什么模型都走这个分支
|
|
||||||
// 第三方中转站统一使用 /v1/chat/completions 端点
|
|
||||||
console.log('[API/chat] 使用 OpenAI 兼容格式,模型:', useModel, '(isCodex:', isCodex, ')');
|
|
||||||
const result = await handleOpenAICompatibleChat({
|
|
||||||
cchUrl,
|
|
||||||
apiKey: decryptedApiKey,
|
|
||||||
model: useModel,
|
|
||||||
systemPrompt,
|
|
||||||
temperature,
|
|
||||||
historyMessages,
|
|
||||||
message,
|
|
||||||
tools: tools || (conversation.tools as string[]) || [],
|
|
||||||
controller,
|
|
||||||
encoder,
|
|
||||||
images,
|
|
||||||
});
|
|
||||||
|
|
||||||
fullContent = result.fullContent;
|
|
||||||
thinkingContent = result.thinkingContent;
|
|
||||||
totalInputTokens = result.inputTokens;
|
|
||||||
totalOutputTokens = result.outputTokens;
|
|
||||||
} else if (isCodex) {
|
|
||||||
// ==================== Codex 模型处理(使用 Codex Response API) ====================
|
|
||||||
// 仅当使用 Claude 原生格式 + Codex 模型时,才使用 /v1/responses 端点
|
|
||||||
// 这是 CCH 项目特有的 Codex Response API
|
|
||||||
console.log('[API/chat] 使用 Codex Response API (Claude 原生格式 + Codex 模型)');
|
|
||||||
const result = await handleCodexChat({
|
const result = await handleCodexChat({
|
||||||
cchUrl,
|
cchUrl,
|
||||||
apiKey: decryptedApiKey,
|
apiKey: decryptedApiKey,
|
||||||
@ -371,8 +332,7 @@ export async function POST(request: Request) {
|
|||||||
totalInputTokens = result.inputTokens;
|
totalInputTokens = result.inputTokens;
|
||||||
totalOutputTokens = result.outputTokens;
|
totalOutputTokens = result.outputTokens;
|
||||||
} else {
|
} else {
|
||||||
// ==================== Claude 原生格式处理 ====================
|
// ==================== Claude 模型处理(原有逻辑) ====================
|
||||||
console.log('[API/chat] 使用 Claude 原生格式 (/v1/messages)');
|
|
||||||
const result = await handleClaudeChat({
|
const result = await handleClaudeChat({
|
||||||
cchUrl,
|
cchUrl,
|
||||||
apiKey: decryptedApiKey,
|
apiKey: decryptedApiKey,
|
||||||
@ -516,11 +476,9 @@ async function handleCodexChat(params: CodexChatParams): Promise<{
|
|||||||
images,
|
images,
|
||||||
} = params;
|
} = params;
|
||||||
|
|
||||||
// 构建 Codex Response API 格式的输入(过滤空内容的消息)
|
// 构建 Codex Response API 格式的输入
|
||||||
const inputItems: CodexInputItem[] = [
|
const inputItems: CodexInputItem[] = [
|
||||||
...historyMessages
|
...historyMessages.map((msg) => ({
|
||||||
.filter((msg) => msg.content && msg.content.trim() !== '')
|
|
||||||
.map((msg) => ({
|
|
||||||
type: 'message' as const,
|
type: 'message' as const,
|
||||||
role: msg.role as 'user' | 'assistant',
|
role: msg.role as 'user' | 'assistant',
|
||||||
content: msg.content,
|
content: msg.content,
|
||||||
@ -598,7 +556,7 @@ async function handleCodexChat(params: CodexChatParams): Promise<{
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 使用 Codex Response API 端点
|
// 使用 Codex Response API 端点
|
||||||
const response = await fetch(`${normalizeBaseUrl(cchUrl)}/v1/responses`, {
|
const response = await fetch(`${cchUrl}/v1/responses`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
@ -838,10 +796,8 @@ async function handleClaudeChat(params: ClaudeChatParams): Promise<{
|
|||||||
images,
|
images,
|
||||||
} = params;
|
} = params;
|
||||||
|
|
||||||
// 构建消息历史(过滤空内容的消息)
|
// 构建消息历史
|
||||||
const messageHistory: APIMessage[] = historyMessages
|
const messageHistory: APIMessage[] = historyMessages.map((msg) => ({
|
||||||
.filter((msg) => msg.content && msg.content.trim() !== '')
|
|
||||||
.map((msg) => ({
|
|
||||||
role: msg.role as 'user' | 'assistant',
|
role: msg.role as 'user' | 'assistant',
|
||||||
content: msg.content,
|
content: msg.content,
|
||||||
}));
|
}));
|
||||||
@ -944,7 +900,7 @@ async function handleClaudeChat(params: ClaudeChatParams): Promise<{
|
|||||||
requestBody.temperature = temperature;
|
requestBody.temperature = temperature;
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetch(`${normalizeBaseUrl(cchUrl)}/v1/messages`, {
|
const response = await fetch(`${cchUrl}/v1/messages`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
@ -1170,462 +1126,6 @@ async function handleClaudeChat(params: ClaudeChatParams): Promise<{
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== OpenAI 兼容格式处理函数 ====================
|
|
||||||
interface OpenAICompatibleChatParams {
|
|
||||||
cchUrl: string;
|
|
||||||
apiKey: string;
|
|
||||||
model: string;
|
|
||||||
systemPrompt: string;
|
|
||||||
temperature: number;
|
|
||||||
historyMessages: { role: string; content: string }[];
|
|
||||||
message: string;
|
|
||||||
tools: string[];
|
|
||||||
controller: ReadableStreamDefaultController;
|
|
||||||
encoder: TextEncoder;
|
|
||||||
images?: {
|
|
||||||
type: 'image';
|
|
||||||
media_type: string;
|
|
||||||
data: string;
|
|
||||||
}[];
|
|
||||||
}
|
|
||||||
|
|
||||||
// OpenAI 消息格式
|
|
||||||
interface OpenAIMessageContent {
|
|
||||||
type: 'text' | 'image_url';
|
|
||||||
text?: string;
|
|
||||||
image_url?: {
|
|
||||||
url: string;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
interface OpenAICompatibleMessage {
|
|
||||||
role: 'system' | 'user' | 'assistant' | 'tool';
|
|
||||||
content: string | OpenAIMessageContent[] | null;
|
|
||||||
tool_calls?: {
|
|
||||||
id: string;
|
|
||||||
type: 'function';
|
|
||||||
function: {
|
|
||||||
name: string;
|
|
||||||
arguments: string;
|
|
||||||
};
|
|
||||||
}[];
|
|
||||||
tool_call_id?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleOpenAICompatibleChat(params: OpenAICompatibleChatParams): Promise<{
|
|
||||||
fullContent: string;
|
|
||||||
thinkingContent: string;
|
|
||||||
inputTokens: number;
|
|
||||||
outputTokens: number;
|
|
||||||
}> {
|
|
||||||
const {
|
|
||||||
cchUrl,
|
|
||||||
apiKey,
|
|
||||||
model,
|
|
||||||
systemPrompt,
|
|
||||||
temperature,
|
|
||||||
historyMessages,
|
|
||||||
message,
|
|
||||||
tools,
|
|
||||||
controller,
|
|
||||||
encoder,
|
|
||||||
images,
|
|
||||||
} = params;
|
|
||||||
|
|
||||||
// 构建 OpenAI 格式的消息历史(过滤空内容的消息)
|
|
||||||
const openaiMessages: OpenAICompatibleMessage[] = [
|
|
||||||
{ role: 'system', content: systemPrompt },
|
|
||||||
...historyMessages
|
|
||||||
.filter((msg) => msg.content && msg.content.trim() !== '')
|
|
||||||
.map((msg) => ({
|
|
||||||
role: msg.role as 'user' | 'assistant',
|
|
||||||
content: msg.content,
|
|
||||||
})),
|
|
||||||
];
|
|
||||||
|
|
||||||
// 添加当前用户消息(支持多模态)
|
|
||||||
if (images && images.length > 0) {
|
|
||||||
console.log('[handleOpenAICompatibleChat] Building multimodal message with', images.length, 'images');
|
|
||||||
const multimodalContent: OpenAIMessageContent[] = [];
|
|
||||||
|
|
||||||
// 添加图片
|
|
||||||
for (const img of images) {
|
|
||||||
console.log('[handleOpenAICompatibleChat] Adding image:', {
|
|
||||||
type: img.type,
|
|
||||||
media_type: img.media_type,
|
|
||||||
dataLength: img.data?.length || 0,
|
|
||||||
});
|
|
||||||
multimodalContent.push({
|
|
||||||
type: 'image_url',
|
|
||||||
image_url: {
|
|
||||||
url: `data:${img.media_type};base64,${img.data}`,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 添加文本
|
|
||||||
if (message) {
|
|
||||||
multimodalContent.push({
|
|
||||||
type: 'text',
|
|
||||||
text: message,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
openaiMessages.push({
|
|
||||||
role: 'user',
|
|
||||||
content: multimodalContent,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
console.log('[handleOpenAICompatibleChat] No images, using simple text message');
|
|
||||||
openaiMessages.push({
|
|
||||||
role: 'user',
|
|
||||||
content: message,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 构建 OpenAI 格式的工具定义
|
|
||||||
const openaiTools = buildOpenAIToolDefinitions(tools);
|
|
||||||
|
|
||||||
let fullContent = '';
|
|
||||||
let thinkingContent = ''; // 用于收集 <think> 标签中的思考内容
|
|
||||||
let totalInputTokens = 0;
|
|
||||||
let totalOutputTokens = 0;
|
|
||||||
let loopCount = 0;
|
|
||||||
const maxLoops = 10;
|
|
||||||
|
|
||||||
// 用于处理 <think> 标签的状态变量(跨 chunk 处理)
|
|
||||||
let isInThinkingMode = false;
|
|
||||||
let pendingBuffer = ''; // 用于处理标签可能跨 chunk 的情况
|
|
||||||
|
|
||||||
while (loopCount < maxLoops) {
|
|
||||||
loopCount++;
|
|
||||||
|
|
||||||
// 构建请求体
|
|
||||||
const requestBody: Record<string, unknown> = {
|
|
||||||
model,
|
|
||||||
messages: openaiMessages,
|
|
||||||
stream: true,
|
|
||||||
temperature,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (openaiTools.length > 0) {
|
|
||||||
requestBody.tools = openaiTools;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('[handleOpenAICompatibleChat] Sending to OpenAI-compatible API:', {
|
|
||||||
model,
|
|
||||||
messagesCount: openaiMessages.length,
|
|
||||||
url: `${normalizeBaseUrl(cchUrl)}/v1/chat/completions`,
|
|
||||||
});
|
|
||||||
|
|
||||||
const response = await fetch(`${normalizeBaseUrl(cchUrl)}/v1/chat/completions`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'Authorization': `Bearer ${apiKey}`,
|
|
||||||
},
|
|
||||||
body: JSON.stringify(requestBody),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorText = await response.text();
|
|
||||||
throw new Error(`OpenAI-compatible API error: ${response.status} - ${errorText}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const reader = response.body?.getReader();
|
|
||||||
if (!reader) {
|
|
||||||
throw new Error('No response body');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 收集当前轮次的内容
|
|
||||||
let currentTextContent = '';
|
|
||||||
const toolCalls: { id: string; name: string; arguments: string }[] = [];
|
|
||||||
const currentToolCallsMap: Map<number, { id: string; name: string; arguments: string }> = new Map();
|
|
||||||
let stopReason: string | null = null;
|
|
||||||
|
|
||||||
const decoder = new TextDecoder();
|
|
||||||
let buffer = '';
|
|
||||||
|
|
||||||
// 处理流式响应
|
|
||||||
while (true) {
|
|
||||||
const { done, value } = await reader.read();
|
|
||||||
if (done) break;
|
|
||||||
|
|
||||||
buffer += decoder.decode(value, { stream: true });
|
|
||||||
const lines = buffer.split('\n');
|
|
||||||
buffer = lines.pop() || '';
|
|
||||||
|
|
||||||
for (const line of lines) {
|
|
||||||
if (line.startsWith('data: ')) {
|
|
||||||
const data = line.slice(6).trim();
|
|
||||||
if (data === '[DONE]') continue;
|
|
||||||
if (!data) continue;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const event = JSON.parse(data);
|
|
||||||
|
|
||||||
// 处理文本内容(包含 <think> 标签过滤)
|
|
||||||
if (event.choices?.[0]?.delta?.content) {
|
|
||||||
const rawContent = event.choices[0].delta.content;
|
|
||||||
|
|
||||||
// 将新内容追加到待处理缓冲区
|
|
||||||
pendingBuffer += rawContent;
|
|
||||||
|
|
||||||
// 处理缓冲区中的内容
|
|
||||||
while (pendingBuffer.length > 0) {
|
|
||||||
if (isInThinkingMode) {
|
|
||||||
// 在 thinking 模式中,查找 </think> 结束标签
|
|
||||||
const endTagIndex = pendingBuffer.indexOf('</think>');
|
|
||||||
|
|
||||||
if (endTagIndex !== -1) {
|
|
||||||
// 找到结束标签,提取 thinking 内容
|
|
||||||
const thinkPart = pendingBuffer.slice(0, endTagIndex);
|
|
||||||
if (thinkPart) {
|
|
||||||
thinkingContent += thinkPart;
|
|
||||||
// 发送 thinking 事件(让前端可以展示折叠的思考内容)
|
|
||||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify({
|
|
||||||
type: 'thinking',
|
|
||||||
content: thinkPart,
|
|
||||||
})}\n\n`));
|
|
||||||
}
|
|
||||||
// 移除已处理的内容和结束标签
|
|
||||||
pendingBuffer = pendingBuffer.slice(endTagIndex + 8); // 8 = '</think>'.length
|
|
||||||
isInThinkingMode = false;
|
|
||||||
} else {
|
|
||||||
// 没找到结束标签,检查是否可能是部分标签
|
|
||||||
// 保留最后 7 个字符以防 </think> 被截断(7 = '</think'.length - 1)
|
|
||||||
if (pendingBuffer.length > 7) {
|
|
||||||
const safePart = pendingBuffer.slice(0, -7);
|
|
||||||
const keepPart = pendingBuffer.slice(-7);
|
|
||||||
|
|
||||||
if (safePart) {
|
|
||||||
thinkingContent += safePart;
|
|
||||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify({
|
|
||||||
type: 'thinking',
|
|
||||||
content: safePart,
|
|
||||||
})}\n\n`));
|
|
||||||
}
|
|
||||||
pendingBuffer = keepPart;
|
|
||||||
}
|
|
||||||
// 等待更多数据
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// 不在 thinking 模式,查找 <think> 开始标签
|
|
||||||
const startTagIndex = pendingBuffer.indexOf('<think>');
|
|
||||||
|
|
||||||
if (startTagIndex !== -1) {
|
|
||||||
// 找到开始标签,先输出标签前的普通文本
|
|
||||||
const textPart = pendingBuffer.slice(0, startTagIndex);
|
|
||||||
if (textPart) {
|
|
||||||
currentTextContent += textPart;
|
|
||||||
fullContent += textPart;
|
|
||||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify({
|
|
||||||
type: 'text',
|
|
||||||
content: textPart,
|
|
||||||
})}\n\n`));
|
|
||||||
}
|
|
||||||
// 移除已处理的内容和开始标签
|
|
||||||
pendingBuffer = pendingBuffer.slice(startTagIndex + 7); // 7 = '<think>'.length
|
|
||||||
isInThinkingMode = true;
|
|
||||||
} else {
|
|
||||||
// 没找到开始标签,检查是否可能是部分标签
|
|
||||||
// 保留最后 6 个字符以防 <think> 被截断(6 = '<think'.length - 1)
|
|
||||||
const potentialTagStart = pendingBuffer.lastIndexOf('<');
|
|
||||||
|
|
||||||
if (potentialTagStart !== -1 && potentialTagStart > pendingBuffer.length - 7) {
|
|
||||||
// 可能是部分的 <think> 标签,保留这部分
|
|
||||||
const safePart = pendingBuffer.slice(0, potentialTagStart);
|
|
||||||
const keepPart = pendingBuffer.slice(potentialTagStart);
|
|
||||||
|
|
||||||
if (safePart) {
|
|
||||||
currentTextContent += safePart;
|
|
||||||
fullContent += safePart;
|
|
||||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify({
|
|
||||||
type: 'text',
|
|
||||||
content: safePart,
|
|
||||||
})}\n\n`));
|
|
||||||
}
|
|
||||||
pendingBuffer = keepPart;
|
|
||||||
// 等待更多数据
|
|
||||||
break;
|
|
||||||
} else {
|
|
||||||
// 安全输出所有内容
|
|
||||||
currentTextContent += pendingBuffer;
|
|
||||||
fullContent += pendingBuffer;
|
|
||||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify({
|
|
||||||
type: 'text',
|
|
||||||
content: pendingBuffer,
|
|
||||||
})}\n\n`));
|
|
||||||
pendingBuffer = '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 处理工具调用
|
|
||||||
if (event.choices?.[0]?.delta?.tool_calls) {
|
|
||||||
for (const toolCall of event.choices[0].delta.tool_calls) {
|
|
||||||
const index = toolCall.index ?? 0;
|
|
||||||
|
|
||||||
if (!currentToolCallsMap.has(index)) {
|
|
||||||
// 新的工具调用开始
|
|
||||||
currentToolCallsMap.set(index, {
|
|
||||||
id: toolCall.id || '',
|
|
||||||
name: toolCall.function?.name || '',
|
|
||||||
arguments: '',
|
|
||||||
});
|
|
||||||
|
|
||||||
if (toolCall.id) {
|
|
||||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify({
|
|
||||||
type: 'tool_use_start',
|
|
||||||
id: toolCall.id,
|
|
||||||
name: toolCall.function?.name || '',
|
|
||||||
})}\n\n`));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const current = currentToolCallsMap.get(index)!;
|
|
||||||
|
|
||||||
// 更新工具调用信息
|
|
||||||
if (toolCall.id) current.id = toolCall.id;
|
|
||||||
if (toolCall.function?.name) current.name = toolCall.function.name;
|
|
||||||
if (toolCall.function?.arguments) {
|
|
||||||
current.arguments += toolCall.function.arguments;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 处理结束原因
|
|
||||||
if (event.choices?.[0]?.finish_reason) {
|
|
||||||
stopReason = event.choices[0].finish_reason;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 处理 usage 信息
|
|
||||||
if (event.usage) {
|
|
||||||
totalInputTokens = event.usage.prompt_tokens || 0;
|
|
||||||
totalOutputTokens = event.usage.completion_tokens || 0;
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error('[handleOpenAICompatibleChat] Parse error:', e, 'Line:', line);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 流结束后,处理可能残留在 pendingBuffer 中的内容
|
|
||||||
if (pendingBuffer.length > 0) {
|
|
||||||
if (isInThinkingMode) {
|
|
||||||
// 如果还在 thinking 模式,说明 </think> 标签没有正常闭合
|
|
||||||
// 将剩余内容作为 thinking 内容处理
|
|
||||||
thinkingContent += pendingBuffer;
|
|
||||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify({
|
|
||||||
type: 'thinking',
|
|
||||||
content: pendingBuffer,
|
|
||||||
})}\n\n`));
|
|
||||||
} else {
|
|
||||||
// 普通文本模式,输出剩余内容
|
|
||||||
currentTextContent += pendingBuffer;
|
|
||||||
fullContent += pendingBuffer;
|
|
||||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify({
|
|
||||||
type: 'text',
|
|
||||||
content: pendingBuffer,
|
|
||||||
})}\n\n`));
|
|
||||||
}
|
|
||||||
pendingBuffer = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
// 收集所有工具调用
|
|
||||||
for (const [, tc] of currentToolCallsMap) {
|
|
||||||
if (tc.id && tc.name) {
|
|
||||||
toolCalls.push(tc);
|
|
||||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify({
|
|
||||||
type: 'tool_use_complete',
|
|
||||||
id: tc.id,
|
|
||||||
name: tc.name,
|
|
||||||
input: JSON.parse(tc.arguments || '{}'),
|
|
||||||
})}\n\n`));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 检查是否需要执行工具
|
|
||||||
if ((stopReason === 'tool_calls' || toolCalls.length > 0) && toolCalls.length > 0) {
|
|
||||||
// 将助手消息添加到历史
|
|
||||||
const assistantMessage: OpenAICompatibleMessage = {
|
|
||||||
role: 'assistant',
|
|
||||||
content: currentTextContent || null,
|
|
||||||
tool_calls: toolCalls.map((tc) => ({
|
|
||||||
id: tc.id,
|
|
||||||
type: 'function' as const,
|
|
||||||
function: {
|
|
||||||
name: tc.name,
|
|
||||||
arguments: tc.arguments,
|
|
||||||
},
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
openaiMessages.push(assistantMessage);
|
|
||||||
|
|
||||||
// 执行所有工具并收集结果
|
|
||||||
for (const tc of toolCalls) {
|
|
||||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify({
|
|
||||||
type: 'tool_execution_start',
|
|
||||||
id: tc.id,
|
|
||||||
name: tc.name,
|
|
||||||
})}\n\n`));
|
|
||||||
|
|
||||||
// 解析工具参数
|
|
||||||
let toolInput: Record<string, unknown> = {};
|
|
||||||
try {
|
|
||||||
toolInput = JSON.parse(tc.arguments || '{}');
|
|
||||||
} catch {
|
|
||||||
console.error('[handleOpenAICompatibleChat] Failed to parse tool arguments');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 执行工具
|
|
||||||
const result = await executeTool(tc.name, toolInput);
|
|
||||||
|
|
||||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify({
|
|
||||||
type: 'tool_execution_result',
|
|
||||||
id: tc.id,
|
|
||||||
name: tc.name,
|
|
||||||
success: result.success,
|
|
||||||
result: result.displayResult,
|
|
||||||
images: result.images,
|
|
||||||
})}\n\n`));
|
|
||||||
|
|
||||||
// 将工具结果显示给用户
|
|
||||||
const toolDisplayText = `\n\n${result.displayResult}\n\n`;
|
|
||||||
fullContent += toolDisplayText;
|
|
||||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify({
|
|
||||||
type: 'text',
|
|
||||||
content: toolDisplayText,
|
|
||||||
})}\n\n`));
|
|
||||||
|
|
||||||
// 将工具结果添加到消息历史(OpenAI 格式)
|
|
||||||
openaiMessages.push({
|
|
||||||
role: 'tool',
|
|
||||||
content: result.fullResult,
|
|
||||||
tool_call_id: tc.id,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 继续循环,让 AI 基于工具结果继续回复
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 如果没有工具调用,结束循环
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
fullContent,
|
|
||||||
thinkingContent,
|
|
||||||
inputTokens: totalInputTokens,
|
|
||||||
outputTokens: totalOutputTokens,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// 构建 Claude 工具定义
|
// 构建 Claude 工具定义
|
||||||
function buildClaudeToolDefinitions(toolIds: string[]) {
|
function buildClaudeToolDefinitions(toolIds: string[]) {
|
||||||
const toolMap: Record<string, object> = {
|
const toolMap: Record<string, object> = {
|
||||||
|
|||||||
@ -57,7 +57,7 @@ export async function POST(request: Request) {
|
|||||||
.values({
|
.values({
|
||||||
conversationId,
|
conversationId,
|
||||||
title: title || '新对话',
|
title: title || '新对话',
|
||||||
model: model || 'claude-sonnet-4-5-20250929',
|
model: model || 'claude-sonnet-4-20250514',
|
||||||
tools: tools || [],
|
tools: tools || [],
|
||||||
enableThinking: enableThinking || false,
|
enableThinking: enableThinking || false,
|
||||||
userId: user.userId, // 关联当前用户
|
userId: user.userId, // 关联当前用户
|
||||||
|
|||||||
@ -9,8 +9,7 @@ import { encryptApiKey } from '@/lib/crypto';
|
|||||||
const DEFAULT_SETTINGS = {
|
const DEFAULT_SETTINGS = {
|
||||||
cchUrl: process.env.CCH_DEFAULT_URL || 'https://claude.leocoder.cn/',
|
cchUrl: process.env.CCH_DEFAULT_URL || 'https://claude.leocoder.cn/',
|
||||||
cchApiKeyConfigured: false,
|
cchApiKeyConfigured: false,
|
||||||
apiFormat: 'claude' as 'claude' | 'openai', // API 格式:claude(原生)| openai(兼容)
|
defaultModel: 'claude-sonnet-4-20250514',
|
||||||
defaultModel: 'claude-sonnet-4-5-20250929',
|
|
||||||
defaultTools: ['web_search', 'code_execution', 'web_fetch'],
|
defaultTools: ['web_search', 'code_execution', 'web_fetch'],
|
||||||
systemPrompt: '',
|
systemPrompt: '',
|
||||||
temperature: '0.7',
|
temperature: '0.7',
|
||||||
@ -30,7 +29,6 @@ function formatSettingsResponse(settings: typeof userSettings.$inferSelect | nul
|
|||||||
return {
|
return {
|
||||||
cchUrl: settings.cchUrl || DEFAULT_SETTINGS.cchUrl,
|
cchUrl: settings.cchUrl || DEFAULT_SETTINGS.cchUrl,
|
||||||
cchApiKeyConfigured: settings.cchApiKeyConfigured || false,
|
cchApiKeyConfigured: settings.cchApiKeyConfigured || false,
|
||||||
apiFormat: (settings.apiFormat as 'claude' | 'openai') || DEFAULT_SETTINGS.apiFormat,
|
|
||||||
defaultModel: settings.defaultModel || DEFAULT_SETTINGS.defaultModel,
|
defaultModel: settings.defaultModel || DEFAULT_SETTINGS.defaultModel,
|
||||||
defaultTools: settings.defaultTools || DEFAULT_SETTINGS.defaultTools,
|
defaultTools: settings.defaultTools || DEFAULT_SETTINGS.defaultTools,
|
||||||
systemPrompt: settings.systemPrompt || '',
|
systemPrompt: settings.systemPrompt || '',
|
||||||
@ -104,7 +102,6 @@ export async function PUT(request: Request) {
|
|||||||
const {
|
const {
|
||||||
cchUrl,
|
cchUrl,
|
||||||
cchApiKey,
|
cchApiKey,
|
||||||
apiFormat,
|
|
||||||
defaultModel,
|
defaultModel,
|
||||||
defaultTools,
|
defaultTools,
|
||||||
systemPrompt,
|
systemPrompt,
|
||||||
@ -138,11 +135,6 @@ export async function PUT(request: Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// API 格式类型
|
|
||||||
if (apiFormat !== undefined) {
|
|
||||||
updateData.apiFormat = apiFormat;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (defaultModel !== undefined) {
|
if (defaultModel !== undefined) {
|
||||||
updateData.defaultModel = defaultModel;
|
updateData.defaultModel = defaultModel;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -152,7 +152,7 @@ export default function AssistantsPage() {
|
|||||||
|
|
||||||
// 创建新对话
|
// 创建新对话
|
||||||
const newConversation = await createConversation({
|
const newConversation = await createConversation({
|
||||||
model: settings?.defaultModel || 'claude-sonnet-4-5-20250929',
|
model: settings?.defaultModel || 'claude-sonnet-4-20250514',
|
||||||
tools: settings?.defaultTools || [],
|
tools: settings?.defaultTools || [],
|
||||||
enableThinking: settings?.enableThinking || false,
|
enableThinking: settings?.enableThinking || false,
|
||||||
assistantId: assistant.id,
|
assistantId: assistant.id,
|
||||||
|
|||||||
@ -52,7 +52,6 @@ export default function SettingsPage() {
|
|||||||
// CCH 配置状态
|
// CCH 配置状态
|
||||||
const [cchUrl, setCchUrl] = useState('');
|
const [cchUrl, setCchUrl] = useState('');
|
||||||
const [cchApiKey, setCchApiKey] = useState('');
|
const [cchApiKey, setCchApiKey] = useState('');
|
||||||
const [apiFormat, setApiFormat] = useState<'claude' | 'openai'>('claude');
|
|
||||||
const [showApiKey, setShowApiKey] = useState(false);
|
const [showApiKey, setShowApiKey] = useState(false);
|
||||||
const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle');
|
const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle');
|
||||||
|
|
||||||
@ -71,7 +70,6 @@ export default function SettingsPage() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (settings) {
|
if (settings) {
|
||||||
setCchUrl(settings.cchUrl || '');
|
setCchUrl(settings.cchUrl || '');
|
||||||
setApiFormat((settings.apiFormat as 'claude' | 'openai') || 'claude');
|
|
||||||
setSystemPrompt(settings.systemPrompt || '');
|
setSystemPrompt(settings.systemPrompt || '');
|
||||||
setTemperature(settings.temperature || '0.7');
|
setTemperature(settings.temperature || '0.7');
|
||||||
}
|
}
|
||||||
@ -84,7 +82,6 @@ export default function SettingsPage() {
|
|||||||
const updates: Record<string, string> = {};
|
const updates: Record<string, string> = {};
|
||||||
if (cchUrl) updates.cchUrl = cchUrl;
|
if (cchUrl) updates.cchUrl = cchUrl;
|
||||||
if (cchApiKey) updates.cchApiKey = cchApiKey;
|
if (cchApiKey) updates.cchApiKey = cchApiKey;
|
||||||
updates.apiFormat = apiFormat;
|
|
||||||
|
|
||||||
if (Object.keys(updates).length > 0) {
|
if (Object.keys(updates).length > 0) {
|
||||||
await updateSettings(updates);
|
await updateSettings(updates);
|
||||||
@ -336,83 +333,6 @@ export default function SettingsPage() {
|
|||||||
/>
|
/>
|
||||||
</SettingsItem>
|
</SettingsItem>
|
||||||
|
|
||||||
{/* API 格式选择 */}
|
|
||||||
<div className="px-5 py-4 border-b border-[var(--color-border-light)]">
|
|
||||||
<div className="text-sm font-medium text-[var(--color-text-primary)] mb-1">
|
|
||||||
API 格式
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-[var(--color-text-tertiary)] mb-4">
|
|
||||||
选择中转站的 API 接口格式
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-3">
|
|
||||||
{/* Claude 原生选项 */}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setApiFormat('claude')}
|
|
||||||
className={cn(
|
|
||||||
'flex-1 p-4 rounded-lg border-2 text-left transition-all',
|
|
||||||
apiFormat === 'claude'
|
|
||||||
? 'border-[var(--color-primary)] bg-[var(--color-primary)]/5'
|
|
||||||
: 'border-[var(--color-border)] hover:border-[var(--color-border-dark)] bg-[var(--color-bg-primary)]'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2 mb-1">
|
|
||||||
<div className={cn(
|
|
||||||
'w-4 h-4 rounded-full border-2 flex items-center justify-center',
|
|
||||||
apiFormat === 'claude'
|
|
||||||
? 'border-[var(--color-primary)]'
|
|
||||||
: 'border-[var(--color-border)]'
|
|
||||||
)}>
|
|
||||||
{apiFormat === 'claude' && (
|
|
||||||
<div className="w-2 h-2 rounded-full bg-[var(--color-primary)]" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<span className={cn(
|
|
||||||
'font-medium',
|
|
||||||
apiFormat === 'claude'
|
|
||||||
? 'text-[var(--color-primary)]'
|
|
||||||
: 'text-[var(--color-text-primary)]'
|
|
||||||
)}>
|
|
||||||
Claude 原生
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* OpenAI 兼容选项 */}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setApiFormat('openai')}
|
|
||||||
className={cn(
|
|
||||||
'flex-1 p-4 rounded-lg border-2 text-left transition-all',
|
|
||||||
apiFormat === 'openai'
|
|
||||||
? 'border-[var(--color-primary)] bg-[var(--color-primary)]/5'
|
|
||||||
: 'border-[var(--color-border)] hover:border-[var(--color-border-dark)] bg-[var(--color-bg-primary)]'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2 mb-1">
|
|
||||||
<div className={cn(
|
|
||||||
'w-4 h-4 rounded-full border-2 flex items-center justify-center',
|
|
||||||
apiFormat === 'openai'
|
|
||||||
? 'border-[var(--color-primary)]'
|
|
||||||
: 'border-[var(--color-border)]'
|
|
||||||
)}>
|
|
||||||
{apiFormat === 'openai' && (
|
|
||||||
<div className="w-2 h-2 rounded-full bg-[var(--color-primary)]" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<span className={cn(
|
|
||||||
'font-medium',
|
|
||||||
apiFormat === 'openai'
|
|
||||||
? 'text-[var(--color-primary)]'
|
|
||||||
: 'text-[var(--color-text-primary)]'
|
|
||||||
)}>
|
|
||||||
OpenAI 兼容
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<SettingsItem
|
<SettingsItem
|
||||||
label="API Key"
|
label="API Key"
|
||||||
description={
|
description={
|
||||||
|
|||||||
@ -111,7 +111,7 @@ export function NewChatModal({ isOpen, onClose }: NewChatModalProps) {
|
|||||||
setIsCreating(true);
|
setIsCreating(true);
|
||||||
try {
|
try {
|
||||||
const newConversation = await createConversation({
|
const newConversation = await createConversation({
|
||||||
model: settings?.defaultModel || 'claude-sonnet-4-5-20250929',
|
model: settings?.defaultModel || 'claude-sonnet-4-20250514',
|
||||||
tools: settings?.defaultTools || [],
|
tools: settings?.defaultTools || [],
|
||||||
enableThinking: settings?.enableThinking || false,
|
enableThinking: settings?.enableThinking || false,
|
||||||
assistantId: assistant?.id,
|
assistantId: assistant?.id,
|
||||||
|
|||||||
@ -1 +0,0 @@
|
|||||||
ALTER TABLE "user_settings" ADD COLUMN "api_format" varchar(20) DEFAULT 'claude';
|
|
||||||
File diff suppressed because it is too large
Load Diff
@ -57,13 +57,6 @@
|
|||||||
"when": 1766299055211,
|
"when": 1766299055211,
|
||||||
"tag": "0007_fantastic_molten_man",
|
"tag": "0007_fantastic_molten_man",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
},
|
|
||||||
{
|
|
||||||
"idx": 8,
|
|
||||||
"version": "7",
|
|
||||||
"when": 1766314954003,
|
|
||||||
"tag": "0008_flat_star_brand",
|
|
||||||
"breakpoints": true
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@ -63,10 +63,8 @@ export const userSettings = pgTable('user_settings', {
|
|||||||
cchUrl: varchar('cch_url', { length: 512 }).notNull().default('http://localhost:13500'),
|
cchUrl: varchar('cch_url', { length: 512 }).notNull().default('http://localhost:13500'),
|
||||||
cchApiKey: varchar('cch_api_key', { length: 512 }),
|
cchApiKey: varchar('cch_api_key', { length: 512 }),
|
||||||
cchApiKeyConfigured: boolean('cch_api_key_configured').default(false),
|
cchApiKeyConfigured: boolean('cch_api_key_configured').default(false),
|
||||||
// API 格式类型:claude(原生)| openai(兼容)
|
|
||||||
apiFormat: varchar('api_format', { length: 20 }).default('claude'),
|
|
||||||
// 默认设置
|
// 默认设置
|
||||||
defaultModel: varchar('default_model', { length: 64 }).default('claude-sonnet-4-5-20250929'),
|
defaultModel: varchar('default_model', { length: 64 }).default('claude-sonnet-4-20250514'),
|
||||||
defaultTools: jsonb('default_tools').$type<string[]>().default(['web_search', 'code_execution', 'web_fetch']),
|
defaultTools: jsonb('default_tools').$type<string[]>().default(['web_search', 'code_execution', 'web_fetch']),
|
||||||
// AI 行为设置
|
// AI 行为设置
|
||||||
systemPrompt: text('system_prompt'), // 系统提示词
|
systemPrompt: text('system_prompt'), // 系统提示词
|
||||||
|
|||||||
@ -5,7 +5,6 @@ import { useState, useEffect, useCallback } from 'react';
|
|||||||
export interface Settings {
|
export interface Settings {
|
||||||
cchUrl: string;
|
cchUrl: string;
|
||||||
cchApiKeyConfigured: boolean;
|
cchApiKeyConfigured: boolean;
|
||||||
apiFormat: 'claude' | 'openai';
|
|
||||||
defaultModel: string;
|
defaultModel: string;
|
||||||
defaultTools: string[];
|
defaultTools: string[];
|
||||||
systemPrompt: string;
|
systemPrompt: string;
|
||||||
@ -51,8 +50,7 @@ export interface Model {
|
|||||||
const defaultSettings: Settings = {
|
const defaultSettings: Settings = {
|
||||||
cchUrl: 'http://localhost:13500',
|
cchUrl: 'http://localhost:13500',
|
||||||
cchApiKeyConfigured: false,
|
cchApiKeyConfigured: false,
|
||||||
apiFormat: 'claude',
|
defaultModel: 'claude-sonnet-4-20250514',
|
||||||
defaultModel: 'claude-sonnet-4-5-20250929',
|
|
||||||
defaultTools: ['web_search', 'code_execution', 'web_fetch'],
|
defaultTools: ['web_search', 'code_execution', 'web_fetch'],
|
||||||
systemPrompt: '',
|
systemPrompt: '',
|
||||||
temperature: '0.7',
|
temperature: '0.7',
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user