From 82014ae64c4814f9cb29f4805f8b831b99313c47 Mon Sep 17 00:00:00 2001 From: YuanShiJiLoong <2450829984@qq.com> Date: Mon, 2 Mar 2026 06:33:28 +0900 Subject: [PATCH] v1.1.8: feat: settings fullscreen + field expand modal, Claude API support, UI fixes --- app/api/ai/claude/route.js | 176 ++++++++ app/api/ai/models/route.js | 19 + app/api/ai/test/route.js | 45 ++ app/components/AiSidebar.js | 9 +- app/components/Editor.js | 7 +- app/components/SettingsConflictModal.js | 3 +- app/components/SettingsItemEditor.js | 93 ++++- app/components/SettingsPanel.js | 11 +- app/components/Sidebar.js | 534 +++++++++++++----------- app/globals.css | 417 ++++++++++++++++-- app/page.js | 368 ++++------------ package.json | 2 +- 12 files changed, 1124 insertions(+), 560 deletions(-) create mode 100644 app/api/ai/claude/route.js diff --git a/app/api/ai/claude/route.js b/app/api/ai/claude/route.js new file mode 100644 index 0000000..0482676 --- /dev/null +++ b/app/api/ai/claude/route.js @@ -0,0 +1,176 @@ +// Claude/Anthropic Messages API — SSE 流式转发(Edge Runtime 确保流式不被缓冲) +// 使用 Anthropic Messages API 格式 (/v1/messages) + +export const runtime = 'edge'; + +export async function POST(request) { + try { + const { systemPrompt, userPrompt, apiConfig, maxTokens } = await request.json(); + + const apiKey = apiConfig?.apiKey; + const baseUrl = (apiConfig?.baseUrl || 'https://api.anthropic.com').replace(/\/$/, ''); + const model = apiConfig?.model || 'claude-sonnet-4-20250514'; + + if (!apiKey) { + return new Response( + JSON.stringify({ error: '请先配置 API Key。点击左下角 ⚙️ → API配置,填入你的 Anthropic Key' }), + { status: 400, headers: { 'Content-Type': 'application/json' } } + ); + } + + const url = `${baseUrl}/v1/messages`; + + // 构造 Messages API 请求体 + const requestBody = { + model, + max_tokens: maxTokens || 2000, + system: systemPrompt, + messages: [ + { role: 'user', content: userPrompt } + ], + stream: true, + }; + + // 如果模型支持 extended thinking (claude-3-7-sonnet),可以启用 + // 但默认不启用,因为需要额外的 budget 参数 + + const response = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-api-key': apiKey, + 'anthropic-version': '2023-06-01', + }, + body: JSON.stringify(requestBody), + }); + + if (!response.ok) { + const errorText = await response.text(); + console.error('Claude API 错误:', response.status, errorText); + + const errorMessages = { + 401: 'API Key 无效或已过期,请检查后重新填写', + 403: 'API Key 无权限或已被禁用', + 429: '请求频率过高或额度不足,请稍后再试', + 529: 'Anthropic API 过载,请稍后再试', + }; + + let errMsg = errorMessages[response.status]; + if (!errMsg) { + try { + const errObj = JSON.parse(errorText); + errMsg = errObj?.error?.message || `Claude 服务返回错误(${response.status})`; + } catch { + errMsg = `Claude 服务返回错误(${response.status}),请检查 API 配置`; + } + } + + return new Response( + JSON.stringify({ error: errMsg }), + { status: response.status, headers: { 'Content-Type': 'application/json' } } + ); + } + + // 将 Claude SSE 流解析并转发为统一的 {text, thinking} 格式 + const encoder = new TextEncoder(); + const decoder = new TextDecoder(); + + const stream = new ReadableStream({ + async start(controller) { + const reader = response.body.getReader(); + let buffer = ''; + + try { + 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) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith(':')) continue; + + // Claude SSE: event: xxx\ndata: {...} + if (trimmed.startsWith('data: ')) { + try { + const json = JSON.parse(trimmed.slice(6)); + const eventType = json.type; + + // 文本内容 delta + if (eventType === 'content_block_delta') { + const delta = json.delta; + if (delta?.type === 'text_delta' && delta.text) { + controller.enqueue(encoder.encode(`data: ${JSON.stringify({ text: delta.text })}\n\n`)); + } + // 思维链 delta (extended thinking) + if (delta?.type === 'thinking_delta' && delta.thinking) { + controller.enqueue(encoder.encode(`data: ${JSON.stringify({ thinking: delta.thinking })}\n\n`)); + } + } + + // 消息结束 — 提取 usage + if (eventType === 'message_delta') { + const usage = json.usage; + if (usage) { + controller.enqueue(encoder.encode(`data: ${JSON.stringify({ + usage: { + promptTokens: 0, // message_delta 只有 output_tokens + completionTokens: usage.output_tokens || 0, + totalTokens: usage.output_tokens || 0, + } + })}\n\n`)); + } + } + + // message_start 事件中包含 input tokens + if (eventType === 'message_start') { + const usage = json.message?.usage; + if (usage?.input_tokens) { + controller.enqueue(encoder.encode(`data: ${JSON.stringify({ + usage: { + promptTokens: usage.input_tokens || 0, + completionTokens: 0, + totalTokens: usage.input_tokens || 0, + } + })}\n\n`)); + } + } + + // 消息停止 + if (eventType === 'message_stop') { + controller.enqueue(encoder.encode('data: [DONE]\n\n')); + } + } catch { + // 解析失败的行直接跳过 + } + } + } + } + } catch (err) { + console.error('Claude Stream 读取错误:', err.message); + } finally { + controller.close(); + reader.releaseLock(); + } + } + }); + + return new Response(stream, { + headers: { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + 'Connection': 'keep-alive', + }, + }); + + } catch (error) { + console.error('Claude 接口错误:', error); + return new Response( + JSON.stringify({ error: '网络连接失败,请检查 API 地址是否正确' }), + { status: 500, headers: { 'Content-Type': 'application/json' } } + ); + } +} diff --git a/app/api/ai/models/route.js b/app/api/ai/models/route.js index ea22339..62b8072 100644 --- a/app/api/ai/models/route.js +++ b/app/api/ai/models/route.js @@ -17,6 +17,11 @@ export async function POST(request) { return await fetchGeminiModels(apiKey, baseUrl, embedOnly); } + // Claude/Anthropic(无 /models 端点,返回预定义列表) + if (provider === 'claude') { + return fetchClaudeModels(); + } + // OpenAI 兼容格式(适用于所有其他供应商) return await fetchOpenAIModels(apiKey, baseUrl, embedOnly); @@ -119,3 +124,17 @@ async function handleFetchError(response) { { status: response.status } ); } + +// Claude/Anthropic 模型列表(预定义,无 API 端点) +function fetchClaudeModels() { + const models = [ + { id: 'claude-sonnet-4-20250514', displayName: 'Claude Sonnet 4' }, + { id: 'claude-3-7-sonnet-20250219', displayName: 'Claude 3.7 Sonnet' }, + { id: 'claude-3-5-haiku-20241022', displayName: 'Claude 3.5 Haiku' }, + { id: 'claude-3-5-sonnet-20241022', displayName: 'Claude 3.5 Sonnet v2' }, + { id: 'claude-3-5-sonnet-20240620', displayName: 'Claude 3.5 Sonnet' }, + { id: 'claude-3-opus-20240229', displayName: 'Claude 3 Opus' }, + { id: 'claude-3-haiku-20240307', displayName: 'Claude 3 Haiku' }, + ]; + return NextResponse.json({ models }); +} diff --git a/app/api/ai/test/route.js b/app/api/ai/test/route.js index 9dbe579..066546a 100644 --- a/app/api/ai/test/route.js +++ b/app/api/ai/test/route.js @@ -23,6 +23,11 @@ export async function POST(request) { return await testResponsesAPI(apiKey, baseUrl, model); } + // Claude/Anthropic 格式的测试 + if (provider === 'claude') { + return await testClaude(apiKey, baseUrl, model); + } + // OpenAI 兼容格式的测试 return await testOpenAICompat(apiKey, baseUrl, model); @@ -149,3 +154,43 @@ async function testResponsesAPI(apiKey, baseUrl, model) { reply: reply.trim(), }); } + +async function testClaude(apiKey, baseUrl, model) { + const base = (baseUrl || 'https://api.anthropic.com').replace(/\/$/, ''); + const m = model || 'claude-sonnet-4-20250514'; + const url = `${base}/v1/messages`; + + const response = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-api-key': apiKey, + 'anthropic-version': '2023-06-01', + }, + body: JSON.stringify({ + model: m, + max_tokens: 20, + messages: [{ role: 'user', content: '说"连接成功"' }], + }), + }); + + if (!response.ok) { + const errText = await response.text(); + let errMsg = `连接失败(${response.status})`; + try { + const errObj = JSON.parse(errText); + errMsg = errObj?.error?.message || errMsg; + } catch { /* ignore parse error */ } + return NextResponse.json({ success: false, error: errMsg }); + } + + const data = await response.json(); + const reply = data.content?.[0]?.text || ''; + + return NextResponse.json({ + success: true, + message: `✅ Claude API 连接成功!`, + model: m, + reply: reply.trim(), + }); +} diff --git a/app/components/AiSidebar.js b/app/components/AiSidebar.js index e0e0d41..cf654ea 100644 --- a/app/components/AiSidebar.js +++ b/app/components/AiSidebar.js @@ -287,7 +287,8 @@ export default function AiSidebar({ onInsertText }) { const { apiConfig } = getProjectSettings(); const apiEndpoint = apiConfig?.provider === 'gemini-native' ? '/api/ai/gemini' : apiConfig?.provider === 'openai-responses' ? '/api/ai/responses' - : '/api/ai'; + : apiConfig?.provider === 'claude' ? '/api/ai/claude' + : '/api/ai'; const context = await buildContext(activeChapterId, text, contextSelection.size > 0 ? contextSelection : null); const systemPrompt = compileSystemPrompt(context, 'chat'); @@ -353,7 +354,8 @@ export default function AiSidebar({ onInsertText }) { const { apiConfig } = getProjectSettings(); const apiEndpoint = apiConfig?.provider === 'gemini-native' ? '/api/ai/gemini' : apiConfig?.provider === 'openai-responses' ? '/api/ai/responses' - : '/api/ai'; + : apiConfig?.provider === 'claude' ? '/api/ai/claude' + : '/api/ai'; const context = await buildContext(activeChapterId, userMsg.content, contextSelection.size > 0 ? contextSelection : null); const systemPrompt = compileSystemPrompt(context, 'chat'); @@ -669,7 +671,7 @@ export default function AiSidebar({ onInsertText }) { if (!open) return null; return ( -
+
{/* 标题栏 */}
{t('aiSidebar.title')} @@ -997,7 +999,6 @@ export default function AiSidebar({ onInsertText }) { // 提取正文:去掉系统块、markdown标记、编辑点评 let text = (msg.content || '') .replace(/\[SETTINGS_ACTION\][\s\S]*?\[\\?\/SETTINGS_ACTION\]/g, '') - .replace(/\n---[\s\S]*$/g, '') // 去掉 --- 分隔线以后的编辑点评 .replace(/^#{1,6}\s+/gm, '') // 去掉标题 # .replace(/\*\*(.+?)\*\*/g, '$1') // **粗体** → 粗体 .replace(/\*(.+?)\*/g, '$1') // *斜体* → 斜体 diff --git a/app/components/Editor.js b/app/components/Editor.js index ddf4630..f98c22f 100644 --- a/app/components/Editor.js +++ b/app/components/Editor.js @@ -156,8 +156,9 @@ const Editor = forwardRef(function Editor({ content, onUpdate, editable = true, } }, [editor]); - // 通过 ref 暴露插入方法给父组件(侧栏存档插入用) + // 通过 ref 暴露方法给父组件(侧栏存档插入 + 大纲读取用) useImperativeHandle(ref, () => ({ + getEditor: () => editor, insertText: (text) => { if (!editor) return; // 规范化换行 @@ -1093,7 +1094,7 @@ function EditorToolbar({ editor, margins, setMargins }) { Aa ▾ {showTypeset && ( -
+
▾ {showMargins && ( -
+
{ + setIsExpanded(false); + if (timerRef.current) { + clearTimeout(timerRef.current); + timerRef.current = null; + } + onChangeRef.current(localValueRef.current); + }, []); + const inputProps = { value: localValue, onChange: handleChange, @@ -103,9 +114,26 @@ function TextField({ label, value, onChange, placeholder, multiline = false, row
- {aiBtn && ( - - )} +
+ {multiline && ( + + )} + {aiBtn && ( + + )} +
{multiline ? (