'use client'; import { useState, useRef, useEffect, useCallback, useMemo } from 'react'; import { INPUT_TOKEN_BUDGET, buildContext, compileSystemPrompt, estimateTokens } from '../lib/context-engine'; import { addTokenRecord, getTokenStats, clearTokenStats } from '../lib/token-stats'; import { saveSessionStore, createSession, deleteSession as deleteSessionFn, renameSession, switchSession, getActiveSession, addMessage, editMessage as editMsgFn, deleteMessage as deleteMsgFn, createBranch, addVariant, switchVariant, replaceMessages } from '../lib/chat-sessions'; import { getProjectSettings, getActiveWorkId, getSettingsNodes, addSettingsNode, updateSettingsNode, deleteSettingsNode } from '../lib/settings'; import { useAppStore } from '../store/useAppStore'; import ChatMarkdown from './ChatMarkdown'; import { useI18n } from '../lib/useI18n'; // 解析消息中的 [SETTINGS_ACTION] 块(支持可选的代码围栏包裹) function parseSettingsActions(content) { if (!content) return { parts: [content || ''], actions: [] }; // 匹配带或不带 ``` 包裹的 [SETTINGS_ACTION] 块 const regex = /(?:```[^\n]*\n)?\[SETTINGS_ACTION\]\s*([\s\S]*?)\s*\[\/SETTINGS_ACTION\](?:\n```)?/g; const parts = []; const actions = []; let lastIndex = 0; let match; while ((match = regex.exec(content)) !== null) { if (match.index > lastIndex) parts.push(content.slice(lastIndex, match.index)); try { const action = JSON.parse(match[1].trim()); actions.push(action); parts.push({ _action: true, index: actions.length - 1 }); } catch { parts.push(match[0]); // parse failed, show raw } lastIndex = regex.lastIndex; } if (lastIndex < content.length) parts.push(content.slice(lastIndex)); return { parts, actions }; } // Helper to generate dynamic elegant gradients for providers function getProviderColor(provider, model) { const p = (provider || '').toLowerCase(); const m = (model || '').toLowerCase(); // Exact or strong matches taking model into account if (p.includes('openai') || m.includes('gpt') || m.includes('o1') || m.includes('o3')) return 'linear-gradient(135deg, #10a37f 0%, #0b7a5e 100%)'; if (p.includes('anthropic') || m.includes('claude')) return 'linear-gradient(135deg, #d97757 0%, #b85d3f 100%)'; if (p.includes('gemini') || p.includes('google') || m.includes('gemini')) return 'linear-gradient(135deg, #4285f4 0%, #8ab4f8 100%)'; if (p.includes('deepseek') || m.includes('deepseek')) return 'linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%)'; if (p.includes('qwen') || p.includes('dashscope') || p.includes('ali') || m.includes('qwen')) return 'linear-gradient(135deg, #8b5cf6 0%, #6d28d9 100%)'; if (p.includes('siliconflow') || m.includes('silicon')) return 'linear-gradient(135deg, #f59e0b 0%, #d97706 100%)'; if (p.includes('ollama') || m.includes('llama')) return 'linear-gradient(135deg, #14b8a6 0%, #0f766e 100%)'; if (p.includes('custom')) return 'linear-gradient(135deg, #4b5563 0%, #374151 100%)'; if (p.includes('openrouter')) return 'linear-gradient(135deg, #818cf8 0%, #6366f1 100%)'; // Hash-based dynamic fallback colors for anything else const colors = [ 'linear-gradient(135deg, #ec4899 0%, #be185d 100%)', // Pink 'linear-gradient(135deg, #06b6d4 0%, #0369a1 100%)', // Cyan 'linear-gradient(135deg, #a855f7 0%, #7e22ce 100%)', // Purple 'linear-gradient(135deg, #f97316 0%, #c2410c 100%)', // Orange 'linear-gradient(135deg, #84cc16 0%, #4d7c0f 100%)' // Lime ]; let hash = 0; const key = p + m; for (let i = 0; i < key.length; i++) hash = key.charCodeAt(i) + ((hash << 5) - hash); return colors[Math.abs(hash) % colors.length]; } // SVG Logos for Providers function ProviderLogo({ provider, model, className = '' }) { const p = (provider || '').toLowerCase(); const m = (model || '').toLowerCase(); // Default abstract Hex icon if no match let svg = ; if (p.includes('openai') || m.includes('gpt') || m.includes('o1') || m.includes('o3')) { svg = ; } else if (p.includes('anthropic') || m.includes('claude')) { svg = ; } else if (p.includes('gemini') || p.includes('google') || m.includes('gemini')) { svg = ; } else if (p.includes('deepseek') || m.includes('deepseek')) { svg = ; } else if (p.includes('ollama') || m.includes('llama')) { svg = ; } else if (p.includes('qwen') || m.includes('qwen')) { svg = ; } else if (p.includes('openrouter')) { svg = ; } return svg; } // ==================== AI 对话侧栏 ==================== export default function AiSidebar({ onInsertText }) { const { aiSidebarOpen: open, setAiSidebarOpen, setShowSettings, activeChapterId, sessionStore, setSessionStore, chatStreaming, setChatStreaming, generationArchive, contextItems, contextSelection, setContextSelection, showToast } = useAppStore(); const { t } = useI18n(); const onClose = useCallback(() => setAiSidebarOpen(false), [setAiSidebarOpen]); const onOpenSettings = useCallback(() => { setAiSidebarOpen(false); setShowSettings(true); }, [setAiSidebarOpen, setShowSettings]); // 派生状态 const activeSession = useMemo(() => getActiveSession(sessionStore), [sessionStore]); const chatHistory = useMemo(() => activeSession?.messages || [], [activeSession]); // 会话管理回调 const setChatHistory = useCallback((newMessages) => setSessionStore(prev => replaceMessages(prev, newMessages)), [setSessionStore]); const onNewSession = useCallback(() => setSessionStore(prev => createSession(prev)), [setSessionStore]); const onDeleteSession = useCallback((id) => setSessionStore(prev => deleteSessionFn(prev, id)), [setSessionStore]); const onRenameSession = useCallback((id, title) => setSessionStore(prev => renameSession(prev, id, title)), [setSessionStore]); const onSwitchSession = useCallback((id) => setSessionStore(prev => switchSession(prev, id)), [setSessionStore]); const onEditMessage = useCallback((msgId, newContent) => setSessionStore(prev => editMsgFn(prev, msgId, newContent)), [setSessionStore]); const onDeleteMessage = useCallback((msgId) => setSessionStore(prev => deleteMsgFn(prev, msgId)), [setSessionStore]); const onBranch = useCallback((msgId) => setSessionStore(prev => createBranch(prev, msgId)), [setSessionStore]); const onSwitchVariant = useCallback((msgId, variantIndex) => setSessionStore(prev => switchVariant(prev, msgId, variantIndex)), [setSessionStore]); const [activeTab, setActiveTab] = useState('chat'); const [inputText, setInputText] = useState(''); const [archiveSearch, setArchiveSearch] = useState(''); const [expandedArchive, setExpandedArchive] = useState(null); // 对话历史勾选状态 const [checkedHistory, setCheckedHistory] = useState(new Set()); const [slidingWindow, setSlidingWindow] = useState(false); const [slidingWindowSize, setSlidingWindowSize] = useState(8); // 总结编辑 const [summaryDraft, setSummaryDraft] = useState(null); // 参考 Tab 状态 const [contextSearch, setContextSearch] = useState(''); const [collapsedGroups, setCollapsedGroups] = useState(new Set()); // 消息编辑状态 const [editingMsgId, setEditingMsgId] = useState(null); const [editingContent, setEditingContent] = useState(''); // 会话重命名 const [renamingSessionId, setRenamingSessionId] = useState(null); const [renameTitle, setRenameTitle] = useState(''); // 显示会话列表 const [showSessionList, setShowSessionList] = useState(false); // 设定操作卡片展开状态 const [expandedActions, setExpandedActions] = useState(new Set()); // 统计刷新版本号 const [statsVersion, setStatsVersion] = useState(0); const chatEndRef = useRef(null); const chatContainerRef = useRef(null); const inputRef = useRef(null); // 新消息时只在用户已滚动到底部时才自动滚动(不劫持用户滚动) useEffect(() => { const container = chatContainerRef.current; if (!container) { chatEndRef.current?.scrollIntoView({ behavior: 'smooth' }); return; } const threshold = 80; const isNearBottom = container.scrollHeight - container.scrollTop - container.clientHeight < threshold; if (isNearBottom) { chatEndRef.current?.scrollIntoView({ behavior: 'smooth' }); } }, [chatHistory]); // 切到聊天 Tab 时聚焦输入框 useEffect(() => { if (activeTab === 'chat' && open) { setTimeout(() => inputRef.current?.focus(), 100); } }, [activeTab, open]); // 新消息自动加入 checkedHistory(仅追加,不全量重置) useEffect(() => { if (chatHistory.length === 0) { setCheckedHistory(new Set()); return; } setCheckedHistory(prev => { const next = new Set(prev); for (const m of chatHistory) { if (!next.has(m.id)) next.add(m.id); } // 清理已删除的消息 ID const currentIds = new Set(chatHistory.map(m => m.id)); for (const id of next) { if (!currentIds.has(id)) next.delete(id); } return next; }); }, [chatHistory]); // 滑动窗口联动 useEffect(() => { if (slidingWindow && chatHistory.length > 0) { const recent = chatHistory.slice(-slidingWindowSize); setCheckedHistory(new Set(recent.map(m => m.id))); } }, [slidingWindow, slidingWindowSize, chatHistory.length]); // --- 通用 SSE 流式读取,支持 text+thinking --- const streamResponse = useCallback(async (apiEndpoint, systemPrompt, userPrompt, apiConfig, onUpdate, onDone) => { const startTime = Date.now(); const res = await fetch(apiEndpoint, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ systemPrompt, userPrompt, apiConfig, ...(apiConfig?.useAdvancedParams ? { maxTokens: apiConfig.maxOutputTokens || 65536, temperature: apiConfig.temperature ?? 1, topP: apiConfig.topP ?? 0.95, reasoningEffort: apiConfig.reasoningEffort || 'auto', } : {}), }), }); const contentType = res.headers.get('content-type') || ''; if (contentType.includes('application/json')) { const data = await res.json(); throw new Error(data.error || '请求失败'); } const reader = res.body.getReader(); const decoder = new TextDecoder(); let buffer = ''; let fullText = ''; let fullThinking = ''; let usageData = null; while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); const events = buffer.split('\n\n'); buffer = events.pop() || ''; let hasUpdate = false; for (const event of events) { const trimmed = event.trim(); if (!trimmed || trimmed === 'data: [DONE]') continue; if (trimmed.startsWith('data: ')) { try { const json = JSON.parse(trimmed.slice(6)); if (json.thinking) { fullThinking += json.thinking; hasUpdate = true; } if (json.text) { fullText += json.text; hasUpdate = true; } if (json.usage) { usageData = json.usage; } } catch { } } } if (hasUpdate) onUpdate(fullText, fullThinking); } // 记录 token 统计 const durationMs = Date.now() - startTime; if (usageData) { addTokenRecord({ promptTokens: usageData.promptTokens || 0, completionTokens: usageData.completionTokens || 0, totalTokens: usageData.totalTokens || 0, durationMs, source: 'chat', provider: apiConfig?.provider || 'unknown', model: apiConfig?.model || 'unknown', }); } else { // API 未返回 usage,客户端估算 const estPrompt = estimateTokens(systemPrompt + userPrompt); const estCompletion = estimateTokens(fullText); addTokenRecord({ promptTokens: estPrompt, completionTokens: estCompletion, totalTokens: estPrompt + estCompletion, durationMs, source: 'chat', provider: apiConfig?.provider || 'unknown', model: apiConfig?.model || 'unknown', }); } setStatsVersion(v => v + 1); onDone(fullText, fullThinking); }, []); const onChatMessage = useCallback(async (text, selectedHistory) => { const userMsg = { id: `msg-${Date.now()}-u`, role: 'user', content: text, timestamp: Date.now() }; setSessionStore(prev => addMessage(prev, userMsg)); setChatStreaming(true); const aiMsgId = `msg-${Date.now()}-a`; try { const { apiConfig } = getProjectSettings(); const apiEndpoint = ['gemini-native', 'custom-gemini'].includes(apiConfig?.provider) ? '/api/ai/gemini' : apiConfig?.provider === 'openai-responses' ? '/api/ai/responses' : ['claude', 'custom-claude'].includes(apiConfig?.provider) ? '/api/ai/claude' : '/api/ai'; const context = await buildContext(activeChapterId, text, contextSelection.size > 0 ? contextSelection : null); const systemPrompt = compileSystemPrompt(context, 'chat'); const historyForApi = selectedHistory.map(m => `${m.role === 'user' ? t('aiSidebar.roleYou') : t('aiSidebar.roleAi')}: ${m.content}`).join('\n'); const userPrompt = historyForApi ? `${historyForApi}\n${t('aiSidebar.roleYou')}: ${text}` : text; const aiPlaceholder = { id: aiMsgId, role: 'assistant', content: '', thinking: '', timestamp: Date.now() }; setSessionStore(prev => addMessage(prev, aiPlaceholder)); await streamResponse(apiEndpoint, systemPrompt, userPrompt, apiConfig, (snapText, snapThinking) => { setSessionStore(prev => ({ ...prev, sessions: prev.sessions.map(s => { if (s.id !== prev.activeSessionId) return s; return { ...s, messages: s.messages.map(m => m.id === aiMsgId ? { ...m, content: snapText, thinking: snapThinking } : m) }; }), })); }, (finalText, finalThinking) => { setSessionStore(prev => { const finalStore = { ...prev, sessions: prev.sessions.map(s => { if (s.id !== prev.activeSessionId) return s; return { ...s, messages: s.messages.map(m => m.id === aiMsgId ? { ...m, content: finalText || '(AI 未返回内容)', thinking: finalThinking } : m), updatedAt: Date.now(), }; }), }; saveSessionStore(finalStore); return finalStore; }); } ); } catch (err) { const errorMsg = { id: `msg-${Date.now()}-e`, role: 'assistant', content: `❌ ${err.message}`, timestamp: Date.now() }; setSessionStore(prev => addMessage(prev, errorMsg)); } finally { setChatStreaming(false); } }, [activeChapterId, contextSelection, streamResponse, setSessionStore, setChatStreaming]); const onRegenerate = useCallback(async (aiMsgId) => { if (chatStreaming) return; console.log('[Regenerate] Starting for msg:', aiMsgId); const msgs = chatHistory; const aiIdx = msgs.findIndex(m => m.id === aiMsgId); if (aiIdx < 0) { console.log('[Regenerate] AI msg not found'); return; } let userMsgIdx = -1; for (let i = aiIdx - 1; i >= 0; i--) { if (msgs[i].role === 'user') { userMsgIdx = i; break; } } if (userMsgIdx < 0) { console.log('[Regenerate] User msg not found'); return; } const userMsg = msgs[userMsgIdx]; const priorHistory = msgs.slice(0, userMsgIdx); setChatStreaming(true); console.log('[Regenerate] User msg:', userMsg.content.slice(0, 50)); try { const { apiConfig } = getProjectSettings(); const apiEndpoint = ['gemini-native', 'custom-gemini'].includes(apiConfig?.provider) ? '/api/ai/gemini' : apiConfig?.provider === 'openai-responses' ? '/api/ai/responses' : ['claude', 'custom-claude'].includes(apiConfig?.provider) ? '/api/ai/claude' : '/api/ai'; const context = await buildContext(activeChapterId, userMsg.content, contextSelection.size > 0 ? contextSelection : null); const systemPrompt = compileSystemPrompt(context, 'chat'); const historyForApi = priorHistory .filter(m => m.role === 'user' || m.role === 'assistant') .map(m => `${m.role === 'user' ? t('aiSidebar.roleYou') : t('aiSidebar.roleAi')}: ${m.content}`).join('\n'); const userPrompt = historyForApi ? `${historyForApi}\n${t('aiSidebar.roleYou')}: ${userMsg.content}` : userMsg.content; setSessionStore(prev => ({ ...prev, sessions: prev.sessions.map(s => { if (s.id !== prev.activeSessionId) return s; return { ...s, messages: s.messages.map(m => { if (m.id !== aiMsgId) return m; const variants = m.variants || [{ content: m.content, thinking: m.thinking || '', timestamp: m.timestamp }]; console.log('[Regenerate] Initialized variants:', variants.length); return { ...m, variants, content: '', thinking: '' }; }), }; }), })); await streamResponse(apiEndpoint, systemPrompt, userPrompt, apiConfig, (snapText, snapThinking) => { setSessionStore(prev => ({ ...prev, sessions: prev.sessions.map(s => { if (s.id !== prev.activeSessionId) return s; return { ...s, messages: s.messages.map(m => m.id === aiMsgId ? { ...m, content: snapText, thinking: snapThinking } : m) }; }), })); }, (finalText, finalThinking) => { console.log('[Regenerate] Stream done, adding variant. Final text length:', finalText?.length); setSessionStore(prev => { const newStore = addVariant(prev, aiMsgId, { content: finalText || '(AI 未返回内容)', thinking: finalThinking, timestamp: Date.now() }); console.log('[Regenerate] After addVariant, checking msg:', newStore.sessions.find(s => s.id === newStore.activeSessionId)?.messages.find(m => m.id === aiMsgId)?.variants?.length, 'variants'); saveSessionStore(newStore); return newStore; }); } ); } catch (err) { setSessionStore(prev => ({ ...prev, sessions: prev.sessions.map(s => { if (s.id !== prev.activeSessionId) return s; return { ...s, messages: s.messages.map(m => m.id === aiMsgId ? { ...m, content: `❌ ${err.message}` } : m) }; }), })); } finally { setChatStreaming(false); } }, [chatHistory, chatStreaming, activeChapterId, contextSelection, streamResponse, setSessionStore, setChatStreaming]); const onApplySettingsAction = useCallback(async (action, actionKey) => { try { const nodes = await getSettingsNodes(); const workId = getActiveWorkId() || 'work-default'; const catToSuffix = { character: 'characters', world: 'world', location: 'locations', object: 'objects', plot: 'plot', rules: 'rules', custom: 'rules' }; const suffix = catToSuffix[action.category] || 'rules'; let parentId = `${workId}-${suffix}`; const parentNode = nodes.find(n => n.id === parentId); if (!parentNode) parentId = nodes.find(n => n.parentId === workId && n.category === action.category)?.id || parentId; const resolveNode = () => { if (action.nodeId) return nodes.find(n => n.id === action.nodeId); if (action.name) return nodes.find(n => n.name === action.name && n.category === action.category && n.type === 'item'); return null; }; if (action.action === 'add') { const existing = resolveNode(); if (existing) { const mergedContent = { ...(existing.content || {}), ...(action.content || {}) }; await updateSettingsNode(existing.id, { name: action.name || existing.name, content: mergedContent }); } else { await addSettingsNode({ name: action.name || '新条目', type: 'item', category: action.category || 'custom', parentId, content: action.content || {} }); } } else if (action.action === 'update') { const target = resolveNode(); if (target) { const updates = {}; if (action.name) updates.name = action.name; if (action.content) updates.content = { ...(target.content || {}), ...action.content }; await updateSettingsNode(target.id, updates); } else { await addSettingsNode({ name: action.name || '新条目', type: 'item', category: action.category || 'custom', parentId, content: action.content || {} }); } } else if (action.action === 'delete') { const target = resolveNode(); if (target) await deleteSettingsNode(target.id); } const msgIdFromKey = actionKey.split('-action-')[0].replace(/-v\d+$/, ''); setSessionStore(prev => { const newStore = { ...prev, sessions: prev.sessions.map(s => { if (s.id !== prev.activeSessionId) return s; return { ...s, messages: s.messages.map(m => m.id === msgIdFromKey ? { ...m, _appliedActions: [...(m._appliedActions || []), actionKey] } : m), updatedAt: Date.now() }; }), }; saveSessionStore(newStore); return newStore; }); showToast('应用设定成功', 'success'); } catch (err) { console.error('Settings action failed:', err); showToast('应用操作失败:' + err.message, 'error'); } }, [setSessionStore, showToast]); // 发送消息 const handleSend = useCallback(() => { const text = inputText.trim(); if (!text || chatStreaming) return; const selectedHistory = chatHistory.filter(m => checkedHistory.has(m.id)); onChatMessage?.(text, selectedHistory); setInputText(''); }, [inputText, chatStreaming, chatHistory, checkedHistory, onChatMessage]); // 重新发送某条用户消息 const handleResend = useCallback((msgId) => { const msg = chatHistory.find(m => m.id === msgId); if (!msg || msg.role !== 'user' || chatStreaming) return; const selectedHistory = chatHistory.filter(m => checkedHistory.has(m.id) && m.timestamp < msg.timestamp); onChatMessage?.(msg.content, selectedHistory); }, [chatHistory, checkedHistory, chatStreaming, onChatMessage]); // 思维链折叠状态 const [expandedThinking, setExpandedThinking] = useState(new Set()); const toggleThinking = useCallback((msgId) => { setExpandedThinking(prev => { const next = new Set(prev); if (next.has(msgId)) next.delete(msgId); else next.add(msgId); return next; }); }, []); // 开始编辑消息 const startEdit = useCallback((msg) => { setEditingMsgId(msg.id); setEditingContent(msg.content); }, []); // 确认编辑 const confirmEdit = useCallback(() => { if (editingMsgId && editingContent.trim()) { onEditMessage?.(editingMsgId, editingContent.trim()); } setEditingMsgId(null); setEditingContent(''); }, [editingMsgId, editingContent, onEditMessage]); // 取消编辑 const cancelEdit = useCallback(() => { setEditingMsgId(null); setEditingContent(''); }, []); // 切换单条历史勾选 const toggleCheck = (id) => { setCheckedHistory(prev => { const next = new Set(prev); if (next.has(id)) next.delete(id); else next.add(id); return next; }); }; // 总结历史 const handleSummarize = useCallback(() => { const checked = chatHistory.filter(m => checkedHistory.has(m.id)); if (checked.length < 2) return; const summaryLines = checked.map(m => `${m.role === 'user' ? t('aiSidebar.roleYou') : t('aiSidebar.roleAi')}: ${m.content.slice(0, 80)}${m.content.length > 80 ? '...' : ''}` ); setSummaryDraft(summaryLines.join('\n')); }, [chatHistory, checkedHistory, t]); // 确认总结 const confirmSummary = useCallback(() => { if (!summaryDraft) return; const checkedIds = new Set(checkedHistory); const unchecked = chatHistory.filter(m => !checkedIds.has(m.id)); const summaryMsg = { id: `summary-${Date.now()}`, role: 'system', content: `[对话摘要]\n${summaryDraft}`, timestamp: Date.now(), isSummary: true, }; setChatHistory?.([...unchecked, summaryMsg]); setSummaryDraft(null); }, [summaryDraft, checkedHistory, chatHistory, setChatHistory]); // 清空对话 const handleClearChat = () => { setChatHistory?.([]); setCheckedHistory(new Set()); }; // 存档过滤 const filteredArchive = archiveSearch ? generationArchive.filter(a => a.text?.includes(archiveSearch) || a.mode?.includes(archiveSearch) ) : generationArchive; // 参考 Tab 分组 const groupedItems = useMemo(() => { const groups = {}; const filteredItems = contextSearch ? contextItems.filter(it => it.name.toLowerCase().includes(contextSearch.toLowerCase())) : contextItems; for (const item of filteredItems) { if (item._empty) continue; // 不显示没有创建条目的空分类 if (item.tokens === 0 && item.name === '(暂无条目)') continue; const g = item.group || '其他'; if (!groups[g]) groups[g] = []; groups[g].push(item); } return groups; }, [contextItems, contextSearch]); // Token 统计 const totalSelectedTokens = useMemo(() => { return contextItems .filter(it => contextSelection?.has(it.id)) .reduce((sum, it) => sum + (it.tokens || 0), 0); }, [contextItems, contextSelection]); // 参考条目切换 const toggleContextItem = useCallback((itemId) => { setContextSelection(prev => { const next = new Set(prev); if (next.has(itemId)) next.delete(itemId); else next.add(itemId); return next; }); }, [setContextSelection]); const toggleGroup = useCallback((groupName) => { const items = groupedItems[groupName] || []; setContextSelection(prev => { const next = new Set(prev); const allChecked = items.every(it => prev.has(it.id)); items.forEach(it => { if (allChecked) next.delete(it.id); else next.add(it.id); }); return next; }); }, [groupedItems, contextSelection, setContextSelection]); const toggleCollapse = useCallback((groupName) => { setCollapsedGroups(prev => { const next = new Set(prev); if (next.has(groupName)) next.delete(groupName); else next.add(groupName); return next; }); }, []); const selectAll = useCallback(() => { if (!contextItems) return; setContextSelection(new Set(contextItems.map(it => it.id))); }, [contextItems, setContextSelection]); const selectNone = useCallback(() => { setContextSelection(new Set()); }, [setContextSelection]); const resetSelection = useCallback(() => { if (!contextItems) return; setContextSelection(new Set(contextItems.filter(it => it.enabled).map(it => it.id))); }, [contextItems, setContextSelection]); // Token 预算 const budgetPercent = Math.min(100, (totalSelectedTokens / INPUT_TOKEN_BUDGET) * 100); const isOverBudget = totalSelectedTokens > INPUT_TOKEN_BUDGET; // Token 统计 const tokenStats = useMemo(() => getTokenStats(), [statsVersion]); const tabs = [ { key: 'chat', label: t('aiSidebar.tabChat') }, { key: 'archive', label: t('aiSidebar.tabArchive') }, { key: 'reference', label: t('aiSidebar.tabReference') }, { key: 'stats', label: t('aiSidebar.tabStats') }, ]; const MODE_LABELS = { continue: '续写', rewrite: '改写', expand: '扩写', condense: '精简', dialogue: '对话', chat: '对话', }; const STATUS_LABELS = { accepted: '✓ 已接受', rejected: '✗ 已拒绝', pending: '⏳ 待确认', }; // 会话列表 const sessions = sessionStore?.sessions || []; const activeSessionId = sessionStore?.activeSessionId; if (!open) return null; return (
{/* 标题栏 */}
{t('aiSidebar.title')}
{/* 会话列表面板 */} {showSessionList && (
{t('aiSidebar.historyCount').replace('{count}', sessions.length)}
{[...sessions].reverse().map(s => (
{ onSwitchSession?.(s.id); setShowSessionList(false); }} > {renamingSessionId === s.id ? ( setRenameTitle(e.target.value)} onKeyDown={e => { if (e.key === 'Enter') { onRenameSession?.(s.id, renameTitle.trim() || s.title); setRenamingSessionId(null); } else if (e.key === 'Escape') { setRenamingSessionId(null); } }} onBlur={() => { onRenameSession?.(s.id, renameTitle.trim() || s.title); setRenamingSessionId(null); }} onClick={e => e.stopPropagation()} autoFocus /> ) : ( <>
{s.title} {s.messages?.length || 0} 条 · {new Date(s.updatedAt || s.createdAt).toLocaleDateString('zh-CN')}
e.stopPropagation()}> {sessions.length > 1 && ( )}
)}
))}
)} {/* Tab 切换 */}
{tabs.map(t => ( ))}
{/* ==================== 💬 对话 Tab ==================== */} {activeTab === 'chat' && (
{/* 对话控制栏 */}
{summaryDraft !== null && (
{t('aiSidebar.editSummary')}