diff --git a/app/api/ai/gemini/route.js b/app/api/ai/gemini/route.js index 0f27371..a4b079b 100644 --- a/app/api/ai/gemini/route.js +++ b/app/api/ai/gemini/route.js @@ -38,7 +38,7 @@ export async function POST(request) { generationConfig: { temperature: 0.8, topP: 0.9, - maxOutputTokens: maxTokens || 4000, + ...(maxTokens ? { maxOutputTokens: maxTokens } : {}), }, }; diff --git a/app/api/ai/route.js b/app/api/ai/route.js index 73aed98..ce38690 100644 --- a/app/api/ai/route.js +++ b/app/api/ai/route.js @@ -34,7 +34,7 @@ export async function POST(request) { { role: 'user', content: userPrompt } ], temperature: 0.8, - max_tokens: maxTokens || 4096, + ...(maxTokens ? { max_tokens: maxTokens } : {}), stream: true, }), }); diff --git a/app/components/AiSidebar.js b/app/components/AiSidebar.js index 2ed7b05..e0e0d41 100644 --- a/app/components/AiSidebar.js +++ b/app/components/AiSidebar.js @@ -1,7 +1,8 @@ 'use client'; import { useState, useRef, useEffect, useCallback, useMemo } from 'react'; -import { INPUT_TOKEN_BUDGET, buildContext, compileSystemPrompt } from '../lib/context-engine'; +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, @@ -35,7 +36,62 @@ function parseSettingsActions(content) { return { parts, actions }; } -// Removed static label maps in favor of i18n +// 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 }) { @@ -90,6 +146,8 @@ export default function AiSidebar({ onInsertText }) { const [showSessionList, setShowSessionList] = useState(false); // 设定操作卡片展开状态 const [expandedActions, setExpandedActions] = useState(new Set()); + // 统计刷新版本号 + const [statsVersion, setStatsVersion] = useState(0); const chatEndRef = useRef(null); const chatContainerRef = useRef(null); @@ -146,6 +204,7 @@ export default function AiSidebar({ onInsertText }) { // --- 通用 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' }, @@ -163,6 +222,7 @@ export default function AiSidebar({ onInsertText }) { let buffer = ''; let fullText = ''; let fullThinking = ''; + let usageData = null; while (true) { const { done, value } = await reader.read(); @@ -179,11 +239,41 @@ export default function AiSidebar({ onInsertText }) { 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); }, []); @@ -547,10 +637,14 @@ export default function AiSidebar({ onInsertText }) { 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 = { @@ -1124,6 +1218,199 @@ export default function AiSidebar({ onInsertText }) { )} + + {/* ==================== 📊 统计 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')}
+ + + + + + + + + + {[ + ['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]) => ( + + + + + + ))} + +
{t('aiSidebar.statsRateMetric')}{t('aiSidebar.statsRateDesc')}{t('aiSidebar.statsRateValue')}
{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) => ( +
+
+ {s.speed.toFixed(0)} +
+ )); + })()} +
+
+ )} + + {/* 消耗预估 */} +
+
{t('aiSidebar.statsProjections')}
+
{t('aiSidebar.statsProjectionsHint')}
+ + + + + + + + + + {[ + ['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.statsPeriod')}{t('aiSidebar.statsTokens')}{t('aiSidebar.statsRequests')}
{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 ( +
+
+
+ + + {m.provider} + + {m.model} +
+
+ {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 +
+ )} +
+
+ ); + })} +
+ )} + + {/* 清空按钮 */} +
+ +
+ + )} +
+
+ )}
); } diff --git a/app/components/SettingsItemEditor.js b/app/components/SettingsItemEditor.js index 0a0ccbf..79773a2 100644 --- a/app/components/SettingsItemEditor.js +++ b/app/components/SettingsItemEditor.js @@ -1,6 +1,6 @@ 'use client'; -import { useState } from 'react'; +import { useState, useRef, useEffect, useCallback } from 'react'; import { useI18n } from '../lib/useI18n'; // ==================== 分类配色 ==================== @@ -18,6 +18,80 @@ const CATEGORY_COLORS = { function TextField({ label, value, onChange, placeholder, multiline = false, rows = 3, aiBtn = false }) { const { t } = useI18n(); + const [localValue, setLocalValue] = useState(value || ''); + const isComposingRef = useRef(false); + const timerRef = useRef(null); + const onChangeRef = useRef(onChange); + const localValueRef = useRef(localValue); + onChangeRef.current = onChange; + + // 同步外部 prop 变化(切换节点时) + useEffect(() => { + setLocalValue(value || ''); + localValueRef.current = value || ''; + }, [value]); + + // 组件卸载时 flush 未保存的更改 + useEffect(() => { + return () => { + if (timerRef.current) { + clearTimeout(timerRef.current); + timerRef.current = null; + onChangeRef.current(localValueRef.current); + } + }; + }, []); + + const scheduleFlush = useCallback((newVal) => { + clearTimeout(timerRef.current); + timerRef.current = setTimeout(() => { + timerRef.current = null; + onChangeRef.current(newVal); + }, 500); + }, []); + + const handleChange = useCallback((e) => { + const newVal = e.target.value; + setLocalValue(newVal); + localValueRef.current = newVal; + if (!isComposingRef.current) { + scheduleFlush(newVal); + } + }, [scheduleFlush]); + + const handleCompositionStart = useCallback(() => { + isComposingRef.current = true; + }, []); + + const handleCompositionEnd = useCallback((e) => { + isComposingRef.current = false; + // compositionend 之后用最新值触发防抖 + const newVal = e.target.value; + setLocalValue(newVal); + localValueRef.current = newVal; + scheduleFlush(newVal); + }, [scheduleFlush]); + + const handleBlur = useCallback((e) => { + e.target.style.borderColor = 'var(--border-light)'; + // 失焦时立即 flush,防止切换节点丢数据 + if (timerRef.current) { + clearTimeout(timerRef.current); + timerRef.current = null; + onChangeRef.current(localValueRef.current); + } + }, []); + + const inputProps = { + value: localValue, + onChange: handleChange, + onCompositionStart: handleCompositionStart, + onCompositionEnd: handleCompositionEnd, + onFocus: e => e.target.style.borderColor = 'var(--accent)', + onBlur: handleBlur, + placeholder, + }; + return (
@@ -28,9 +102,7 @@ function TextField({ label, value, onChange, placeholder, multiline = false, row
{multiline ? (