'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')}
)}
{/* 对话消息列表 */}
{chatHistory.length === 0 && (
{t('aiSidebar.emptyChatIcon')}
{t('aiSidebar.emptyChatTitle')}
{t('aiSidebar.emptyChatHint')}
)}
{chatHistory.map(msg => {
const isStreaming = chatStreaming && msg.role === 'assistant' && msg === chatHistory[chatHistory.length - 1];
const hasVariants = msg.variants && msg.variants.length > 1;
const variantIdx = msg.activeVariant ?? 0;
const variantTotal = msg.variants?.length || 1;
return (
toggleCheck(msg.id)}
className="chat-check"
// TODO: Make this tooltip translate string and optional
// title="勾选以包含在下次请求中"
/>
{msg.role === 'user' ? t('aiSidebar.roleYou') : msg.isSummary ? t('aiSidebar.roleSummary') : t('aiSidebar.roleAi')}
{new Date(msg.timestamp).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })}
{msg.editedAt &&
{t('aiSidebar.edited')}}
{msg.role === 'user' && (
)}
{msg.role === 'assistant' && (
)}
{/* 思维链折叠显示 */}
{msg.thinking && (
{expandedThinking.has(msg.id) && (
{msg.thinking}
)}
)}
{/* 消息内容 / 编辑模式 */}
{editingMsgId === msg.id ? (
) : (
{(() => {
const { parts, actions } = parseSettingsActions(msg.content || '正在思考…');
return parts.map((part, pi) => {
if (typeof part === 'object' && part._action) {
const action = actions[part.index];
const actionKey = `${msg.id}-v${msg.activeVariant || 0}-action-${part.index}`;
return (
setExpandedActions(prev => {
const next = new Set(prev);
next.has(actionKey) ? next.delete(actionKey) : next.add(actionKey);
return next;
})}
style={{ cursor: 'pointer' }}
>
{t(`aiSidebar.actions.${action.action}`) || action.action}
{t(`aiSidebar.categories.${action.category}`) || action.category || ''}
{action.name || action.nodeId || ''}
{expandedActions.has(actionKey) ? '▲...' : '▼...'}
{action.content && expandedActions.has(actionKey) && (
{Object.entries(action.content).map(([k, v]) => (
{k}:
{String(v)}
))}
)}
);
}
return
;
});
})()}
)}
{/* 变体导航 < 1/3 > */}
{hasVariants && !isStreaming && (
{variantIdx + 1} / {variantTotal}
)}
{/* AI 消息:一键插入正文 */}
{msg.role === 'assistant' && !isStreaming && msg.content && (
)
}
);
})}
{/* 输入框 */}
)
}
{/* ==================== 📋 存档 Tab ==================== */}
{
activeTab === 'archive' && (
setArchiveSearch(e.target.value)}
/>
{filteredArchive.length === 0 && (
{t('aiSidebar.emptyArchiveIcon')}
{t('aiSidebar.emptyArchiveTitle')}
{t('aiSidebar.emptyArchiveHint')}
)}
{[...filteredArchive].reverse().map(item => (
setExpandedArchive(expandedArchive === item.id ? null : item.id)}
>
{t(`aiSidebar.statuses.${item.status}`) || item.status}
{t(`aiSidebar.modes.${item.mode}`) || item.mode}
{new Date(item.timestamp).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })}
{item.text?.slice(0, 60)}…
{expandedArchive === item.id && (
{item.text}
)}
))}
)
}
{/* ==================== 📚 参考 Tab ==================== */}
{
activeTab === 'reference' && (
{/* Token 预算进度条 */}
{t('aiSidebar.tokenUsage')}
{totalSelectedTokens.toLocaleString()} / {(INPUT_TOKEN_BUDGET / 1000).toFixed(0)}k
{/* 搜索框 */}
setContextSearch(e.target.value)}
/>
{/* 分组列表 */}
{Object.entries(groupedItems).length === 0 && (
{t('aiSidebar.emptyContextIcon')}
{t('aiSidebar.emptyContextTitle')}
{contextSearch ? t('aiSidebar.emptyContextHint1') : t('aiSidebar.emptyContextHint2')}
)}
{Object.entries(groupedItems).map(([groupName, items]) => {
const isCollapsed = collapsedGroups.has(groupName);
const checkedCount = items.filter(it => contextSelection?.has(it.id)).length;
const groupTokens = items
.filter(it => contextSelection?.has(it.id))
.reduce((sum, it) => sum + it.tokens, 0);
const allGroupChecked = checkedCount === items.length;
return (
);
})}
{/* 批量操作 */}
)
}
{/* ==================== 📊 统计 Tab ==================== */}
{
activeTab === 'stats' && (
{tokenStats.totalRequests === 0 ? (
📊
{t('aiSidebar.statsNoData')}
{t('aiSidebar.statsNoDataHint')}
) : (
<>
{/* 汇总卡片 */}
{tokenStats.totalTokens.toLocaleString()}
{t('aiSidebar.statsTotalTokens')}
{tokenStats.totalPromptTokens.toLocaleString()}
{t('aiSidebar.statsTotalInput')}
{tokenStats.totalCompletionTokens.toLocaleString()}
{t('aiSidebar.statsTotalOutput')}
{tokenStats.totalRequests}
{t('aiSidebar.statsTotalRequests')}
{tokenStats.trackedDays}
{t('aiSidebar.statsTrackedDays')}
{/* 消耗速率 */}
{t('aiSidebar.statsRates')}
{t('aiSidebar.statsRatesHint')}
| {t('aiSidebar.statsRateMetric')} |
{t('aiSidebar.statsRateDesc')} |
{t('aiSidebar.statsRateValue')} |
{[
['TPS', tokenStats.rates.tps, t('aiSidebar.statsRateTPS')],
['TPM', tokenStats.rates.tpm, t('aiSidebar.statsRateTPM')],
['TPH', tokenStats.rates.tph, t('aiSidebar.statsRateTPH')],
['TPD', tokenStats.rates.tpd, t('aiSidebar.statsRateTPD')],
['RPM', tokenStats.rates.rpm, t('aiSidebar.statsRateRPM')],
['RPH', tokenStats.rates.rph, t('aiSidebar.statsRateRPH')],
['RPD', tokenStats.rates.rpd, t('aiSidebar.statsRateRPD')],
].map(([key, value, label]) => (
| {key} |
{label} |
{value < 1 ? value.toFixed(2) : value < 10 ? value.toFixed(1) : Math.round(value).toLocaleString()} |
))}
{/* 近期请求速度 */}
{tokenStats.recentSpeeds.length > 0 && (
{t('aiSidebar.statsRecentSpeeds')}
{(() => {
const maxSpeed = Math.max(...tokenStats.recentSpeeds.map(s => s.speed));
return tokenStats.recentSpeeds.map((s, i) => (
));
})()}
)}
{/* 消耗预估 */}
{t('aiSidebar.statsProjections')}
{t('aiSidebar.statsProjectionsHint')}
| {t('aiSidebar.statsPeriod')} |
{t('aiSidebar.statsTokens')} |
{t('aiSidebar.statsRequests')} |
{[
['statsPeriodDay', tokenStats.projections.perDay],
['statsPeriodWeek', tokenStats.projections.perWeek],
['statsPeriodMonth', tokenStats.projections.perMonth],
['statsPeriodQuarter', tokenStats.projections.perQuarter],
['statsPeriodYear', tokenStats.projections.perYear],
].map(([key, data]) => (
| {t(`aiSidebar.${key}`)} |
{data.tokens.toLocaleString()} |
{data.requests} |
))}
{/* 渠道/模型分类统计 */}
{tokenStats.modelBreakdown.length > 0 && (
{t('aiSidebar.statsModelBreakdown')}
{t('aiSidebar.statsModelBreakdownHint')}
{tokenStats.modelBreakdown.map((m, idx) => {
const bgGradient = getProviderColor(m.provider, m.model);
return (
{Math.round(m.tokenPercent)}%
{m.tokens.toLocaleString()}
Tokens
{m.requests}
{t('aiSidebar.statsRequests')}
{m.promptTokens > 1000 ? (m.promptTokens / 1000).toFixed(1) + 'k' : m.promptTokens} / {m.completionTokens > 1000 ? (m.completionTokens / 1000).toFixed(1) + 'k' : m.completionTokens}
In / Out
{m.avgSpeed > 0 && (
{m.avgSpeed.toFixed(1)}
t/s
)}
);
})}
)}
{/* 清空按钮 */}
>
)}
)
}
);
}