Compare commits

..

3 Commits

Author SHA1 Message Date
gaoziman
6e37e61420 feat(聊天): 支持 OpenAI 兼容格式的 API 调用
- 新增 handleOpenAICompatibleChat 处理函数
- 支持第三方中转站的 /v1/chat/completions 端点
- 优化处理器选择逻辑(apiFormat -> isCodex -> Claude原生)
- 过滤空内容消息避免 API 错误
- 规范化 URL 处理,避免双斜杠问题
- 支持多模态消息和工具调用
2025-12-21 21:15:07 +08:00
gaoziman
99ca472dd2 feat(设置): 添加 API 格式选择功能
支持在 Claude 原生格式和 OpenAI 兼容格式之间切换:
- 新增 api_format 数据库字段和迁移脚本
- 更新设置 Hook 类型定义
- 扩展设置 API 支持 apiFormat 读写
- 添加设置页面 API 格式选择 UI 组件
2025-12-21 21:14:41 +08:00
gaoziman
c341b0d67d chore: 更新默认模型版本为 claude-sonnet-4-5-20250929
- 更新注册接口默认模型配置
- 更新会话创建默认模型
- 更新助手页面默认模型
- 更新新对话弹窗默认模型
2025-12-21 21:14:06 +08:00
12 changed files with 1751 additions and 23 deletions

View File

@ -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-20250514', defaultModel: 'claude-sonnet-4-5-20250929',
defaultTools: ['web_search', 'code_execution', 'web_fetch'], defaultTools: ['web_search', 'code_execution', 'web_fetch'],
theme: 'light', theme: 'light',
language: 'zh-CN', language: 'zh-CN',

View File

@ -72,6 +72,11 @@ 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 助手。请遵循以下规则来回复用户:
@ -282,6 +287,7 @@ 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 作为基础
@ -312,8 +318,41 @@ export async function POST(request: Request) {
let totalInputTokens = 0; let totalInputTokens = 0;
let totalOutputTokens = 0; let totalOutputTokens = 0;
if (isCodex) { // 【重要】处理器选择优先级说明:
// ==================== Codex 模型处理OpenAI 格式) ==================== // 1. 首先检查 apiFormat === 'openai':如果用户选择了 "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,
@ -332,7 +371,8 @@ 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,
@ -476,13 +516,15 @@ async function handleCodexChat(params: CodexChatParams): Promise<{
images, images,
} = params; } = params;
// 构建 Codex Response API 格式的输入 // 构建 Codex Response API 格式的输入(过滤空内容的消息)
const inputItems: CodexInputItem[] = [ const inputItems: CodexInputItem[] = [
...historyMessages.map((msg) => ({ ...historyMessages
type: 'message' as const, .filter((msg) => msg.content && msg.content.trim() !== '')
role: msg.role as 'user' | 'assistant', .map((msg) => ({
content: msg.content, type: 'message' as const,
})), role: msg.role as 'user' | 'assistant',
content: msg.content,
})),
]; ];
// 添加当前用户消息(支持多模态内容) // 添加当前用户消息(支持多模态内容)
@ -556,7 +598,7 @@ async function handleCodexChat(params: CodexChatParams): Promise<{
} }
// 使用 Codex Response API 端点 // 使用 Codex Response API 端点
const response = await fetch(`${cchUrl}/v1/responses`, { const response = await fetch(`${normalizeBaseUrl(cchUrl)}/v1/responses`, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@ -796,11 +838,13 @@ async function handleClaudeChat(params: ClaudeChatParams): Promise<{
images, images,
} = params; } = params;
// 构建消息历史 // 构建消息历史(过滤空内容的消息)
const messageHistory: APIMessage[] = historyMessages.map((msg) => ({ const messageHistory: APIMessage[] = historyMessages
role: msg.role as 'user' | 'assistant', .filter((msg) => msg.content && msg.content.trim() !== '')
content: msg.content, .map((msg) => ({
})); role: msg.role as 'user' | 'assistant',
content: msg.content,
}));
// 添加当前用户消息(支持多模态内容) // 添加当前用户消息(支持多模态内容)
if (images && images.length > 0) { if (images && images.length > 0) {
@ -900,7 +944,7 @@ async function handleClaudeChat(params: ClaudeChatParams): Promise<{
requestBody.temperature = temperature; requestBody.temperature = temperature;
} }
const response = await fetch(`${cchUrl}/v1/messages`, { const response = await fetch(`${normalizeBaseUrl(cchUrl)}/v1/messages`, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@ -1126,6 +1170,462 @@ 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> = {

View File

@ -57,7 +57,7 @@ export async function POST(request: Request) {
.values({ .values({
conversationId, conversationId,
title: title || '新对话', title: title || '新对话',
model: model || 'claude-sonnet-4-20250514', model: model || 'claude-sonnet-4-5-20250929',
tools: tools || [], tools: tools || [],
enableThinking: enableThinking || false, enableThinking: enableThinking || false,
userId: user.userId, // 关联当前用户 userId: user.userId, // 关联当前用户

View File

@ -9,7 +9,8 @@ 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,
defaultModel: 'claude-sonnet-4-20250514', apiFormat: 'claude' as 'claude' | 'openai', // API 格式claude原生| openai兼容
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',
@ -29,6 +30,7 @@ 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 || '',
@ -102,6 +104,7 @@ export async function PUT(request: Request) {
const { const {
cchUrl, cchUrl,
cchApiKey, cchApiKey,
apiFormat,
defaultModel, defaultModel,
defaultTools, defaultTools,
systemPrompt, systemPrompt,
@ -135,6 +138,11 @@ 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;
} }

View File

@ -152,7 +152,7 @@ export default function AssistantsPage() {
// 创建新对话 // 创建新对话
const newConversation = await createConversation({ const newConversation = await createConversation({
model: settings?.defaultModel || 'claude-sonnet-4-20250514', model: settings?.defaultModel || 'claude-sonnet-4-5-20250929',
tools: settings?.defaultTools || [], tools: settings?.defaultTools || [],
enableThinking: settings?.enableThinking || false, enableThinking: settings?.enableThinking || false,
assistantId: assistant.id, assistantId: assistant.id,

View File

@ -52,6 +52,7 @@ 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');
@ -70,6 +71,7 @@ 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');
} }
@ -82,6 +84,7 @@ 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);
@ -333,6 +336,83 @@ 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={

View File

@ -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-20250514', model: settings?.defaultModel || 'claude-sonnet-4-5-20250929',
tools: settings?.defaultTools || [], tools: settings?.defaultTools || [],
enableThinking: settings?.enableThinking || false, enableThinking: settings?.enableThinking || false,
assistantId: assistant?.id, assistantId: assistant?.id,

View File

@ -0,0 +1 @@
ALTER TABLE "user_settings" ADD COLUMN "api_format" varchar(20) DEFAULT 'claude';

File diff suppressed because it is too large Load Diff

View File

@ -57,6 +57,13 @@
"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
} }
] ]
} }

View File

@ -63,8 +63,10 @@ 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-20250514'), defaultModel: varchar('default_model', { length: 64 }).default('claude-sonnet-4-5-20250929'),
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'), // 系统提示词

View File

@ -5,6 +5,7 @@ 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;
@ -50,7 +51,8 @@ export interface Model {
const defaultSettings: Settings = { const defaultSettings: Settings = {
cchUrl: 'http://localhost:13500', cchUrl: 'http://localhost:13500',
cchApiKeyConfigured: false, cchApiKeyConfigured: false,
defaultModel: 'claude-sonnet-4-20250514', apiFormat: 'claude',
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',