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')}
+
+
+
+ | {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) => (
+
+
+
{s.speed.toFixed(0)}
+
+ ));
+ })()}
+
+
+ )}
+
+ {/* 消耗预估 */}
+
+
{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 (
+
+
+
+
+
+ {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 ? (
diff --git a/app/globals.css b/app/globals.css
index e281527..3d73321 100644
--- a/app/globals.css
+++ b/app/globals.css
@@ -4843,4 +4843,377 @@ h5.chat-md-heading {
transform: translateY(0);
opacity: 1;
}
+}
+
+/* ==================== Token 统计面板 ==================== */
+
+.token-stats-panel {
+ padding: 16px 12px;
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+ overflow-y: auto;
+ flex: 1;
+}
+
+.stats-grid {
+ display: grid;
+ grid-template-columns: repeat(2, 1fr);
+ gap: 12px;
+}
+
+/* Let the first card span full width for emphasis */
+.stats-card:first-child {
+ grid-column: 1 / -1;
+}
+
+.stats-card {
+ background: var(--bg-card);
+ border: 1px solid var(--border-light);
+ border-radius: var(--radius-md);
+ padding: 16px 12px;
+ text-align: center;
+ transition: all var(--transition-fast);
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.02);
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ align-items: center;
+ position: relative;
+ overflow: hidden;
+}
+
+/* Subtle gradient top border for modern feel */
+.stats-card::before {
+ content: '';
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ height: 3px;
+ background: linear-gradient(90deg, var(--accent), var(--accent-hover));
+ opacity: 0.7;
+}
+
+.stats-card:hover {
+ border-color: var(--accent-glow);
+ box-shadow: 0 6px 16px rgba(0, 0, 0, 0.05);
+ transform: translateY(-2px);
+}
+
+.stats-card-value {
+ font-size: 22px;
+ font-weight: 800;
+ background: linear-gradient(135deg, var(--text-primary), var(--accent));
+ -webkit-background-clip: text;
+ background-clip: text;
+ -webkit-text-fill-color: transparent;
+ line-height: 1.2;
+ font-family: var(--font-mono, monospace);
+ margin-bottom: 6px;
+}
+
+.stats-card-label {
+ font-size: 11px;
+ color: var(--text-secondary);
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+}
+
+.stats-detail-row {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 8px 12px;
+ font-size: 13px;
+ background: var(--bg-secondary);
+ border-radius: var(--radius-sm);
+ margin-bottom: 4px;
+}
+
+.stats-detail-label {
+ color: var(--text-secondary);
+}
+
+.stats-detail-value {
+ font-weight: 700;
+ color: var(--text-primary);
+ font-family: var(--font-mono, monospace);
+ font-size: 13px;
+}
+
+.stats-section {
+ padding: 4px 0;
+ margin-top: 8px;
+}
+
+.stats-section-title {
+ font-size: 14px;
+ font-weight: 700;
+ color: var(--text-primary);
+ margin-bottom: 4px;
+ padding: 0 2px;
+ display: flex;
+ align-items: center;
+ gap: 6px;
+}
+
+.stats-section-title::before {
+ content: '';
+ display: block;
+ width: 3px;
+ height: 14px;
+ background: var(--accent);
+ border-radius: 2px;
+}
+
+.stats-section-hint {
+ font-size: 11px;
+ color: var(--text-muted);
+ padding: 0 2px;
+ margin-bottom: 12px;
+ line-height: 1.5;
+}
+
+.stats-rate-desc {
+ font-family: var(--font-ui) !important;
+ color: var(--text-muted) !important;
+ font-size: 11px !important;
+ font-weight: 400 !important;
+}
+
+/* 速度柱状图 */
+.speed-chart {
+ display: flex;
+ align-items: flex-end;
+ gap: 6px;
+ height: 90px;
+ padding: 16px 8px 4px 8px;
+ background: var(--bg-secondary);
+ border-radius: var(--radius-md);
+ border: 1px solid var(--border-light);
+}
+
+.speed-bar-wrapper {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ height: 100%;
+ justify-content: flex-end;
+ cursor: pointer;
+}
+
+.speed-bar {
+ width: 100%;
+ min-height: 4px;
+ background: linear-gradient(180deg, var(--accent-light, var(--accent)), var(--accent-hover));
+ border-radius: 4px 4px 0 0;
+ transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
+ box-shadow: 0 -2px 8px var(--accent-glow);
+}
+
+.speed-bar-wrapper:hover .speed-bar {
+ opacity: 1;
+ filter: brightness(1.2);
+}
+
+.speed-bar-label {
+ font-size: 9px;
+ color: var(--text-muted);
+ margin-top: 6px;
+ font-family: var(--font-mono, monospace);
+ font-weight: 600;
+}
+
+/* 预估/速率表格 */
+.projection-table {
+ width: 100%;
+ border-collapse: separate;
+ border-spacing: 0;
+ font-size: 12px;
+ border: 1px solid var(--border-light);
+ border-radius: var(--radius-md);
+ overflow: hidden;
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.01);
+}
+
+.projection-table th {
+ font-size: 11px;
+ font-weight: 700;
+ color: var(--text-secondary);
+ text-align: left;
+ padding: 10px 12px;
+ background: var(--bg-header);
+ border-bottom: 1px solid var(--border-light);
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+}
+
+.projection-table td {
+ padding: 10px 12px;
+ color: var(--text-primary);
+ border-bottom: 1px solid var(--border-light);
+ font-family: var(--font-mono, monospace);
+ background: var(--bg-card);
+}
+
+.projection-table tr:last-child td {
+ border-bottom: none;
+}
+
+.projection-table tr:hover td {
+ background: var(--bg-hover);
+}
+
+.projection-table td:first-child {
+ font-family: var(--font-ui);
+ color: var(--text-primary);
+ font-weight: 600;
+}
+
+.stats-actions {
+ display: flex;
+ justify-content: center;
+ padding: 16px 0 8px 0;
+ margin-top: 8px;
+ border-top: 1px dashed var(--border-light);
+}
+
+.stats-actions button {
+ padding: 8px 16px;
+ border-radius: 20px;
+ font-weight: 600;
+ letter-spacing: 0.5px;
+}
+
+/* 渠道/模型信息图表卡片 */
+.model-info-card {
+ background: var(--bg-card);
+ border: 1px solid var(--border-light);
+ border-radius: var(--radius-lg, 12px);
+ padding: 20px 18px;
+ margin-bottom: 16px;
+ transition: all var(--transition-fast);
+ box-shadow: 0 4px 14px rgba(0, 0, 0, 0.03);
+ position: relative;
+ overflow: hidden;
+}
+
+.model-info-card:hover {
+ border-color: var(--accent-glow);
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.06);
+ transform: translateY(-2px);
+}
+
+.model-info-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 14px;
+}
+
+.model-info-title {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ overflow: hidden;
+}
+
+.model-info-badge {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ font-size: 11px;
+ font-weight: 800;
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+ color: #ffffff;
+ padding: 5px 12px;
+ border-radius: 20px;
+ flex-shrink: 0;
+ box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15);
+}
+
+.provider-logo-svg {
+ width: 14px;
+ height: 14px;
+ stroke-width: 2.5px;
+ opacity: 0.9;
+}
+
+.model-info-name {
+ font-size: 14px;
+ font-weight: 700;
+ color: var(--text-primary);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ font-family: var(--font-mono, monospace);
+}
+
+.model-info-percent {
+ font-size: 24px;
+ font-weight: 900;
+ color: var(--text-primary);
+ font-family: var(--font-ui);
+ line-height: 1;
+}
+
+.model-info-percent span {
+ font-size: 14px;
+ color: var(--text-muted);
+ font-weight: 600;
+ margin-left: 2px;
+}
+
+.model-info-bar-track {
+ width: 100%;
+ height: 14px;
+ background: var(--bg-secondary);
+ border-radius: 7px;
+ overflow: hidden;
+ margin-bottom: 20px;
+ box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.05);
+}
+
+.model-info-bar-fill {
+ height: 100%;
+ border-radius: 6px;
+ min-width: 4px;
+ transition: width 0.8s cubic-bezier(0.4, 0, 0.2, 1);
+ box-shadow: inset 0 -2px 4px rgba(0, 0, 0, 0.1);
+}
+
+.model-info-stats {
+ display: flex;
+ justify-content: space-between;
+ align-items: flex-end;
+ gap: 12px;
+ padding-top: 16px;
+ border-top: 1px dashed var(--border-light);
+}
+
+.info-stat-group {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 4px;
+}
+
+.info-stat-value {
+ font-size: 13px;
+ font-weight: 800;
+ color: var(--text-primary);
+ font-family: var(--font-mono, monospace);
+ line-height: 1.2;
+}
+
+.info-stat-label {
+ font-size: 10px;
+ font-weight: 600;
+ text-transform: uppercase;
+ color: var(--text-muted);
+ letter-spacing: 0.5px;
}
\ No newline at end of file
diff --git a/app/lib/context-engine.js b/app/lib/context-engine.js
index aaa61d9..1f5aa22 100644
--- a/app/lib/context-engine.js
+++ b/app/lib/context-engine.js
@@ -468,12 +468,12 @@ export function compileUserPrompt(mode, text, instruction) {
case 'continue':
if (!text || !text.trim()) {
return instruction
- ? `请根据以下要求开始创作新内容(约300-500字):\n要求:${instruction}`
- : '请根据设定集信息,开始撰写新的章节内容(约300-500字)。';
+ ? `请根据以下要求开始创作新内容:\n要求:${instruction}`
+ : '请根据设定集信息,开始撰写新的章节内容。';
}
return instruction
- ? `请续写以下内容(约300-500字),保持风格和情节的连贯性:\n要求:${instruction}\n\n「${text}」`
- : `请续写以下内容(约300-500字),保持风格和情节的连贯性:\n\n「${text}」`;
+ ? `请续写以下内容,保持风格和情节的连贯性:\n要求:${instruction}\n\n「${text}」`
+ : `请续写以下内容,保持风格和情节的连贯性:\n\n「${text}」`;
case 'rewrite':
return instruction
? `按照以下要求改写文本:\n要求:${instruction}\n\n原文:\n「${text}」`
@@ -655,8 +655,7 @@ function getModeInstruction(mode) {
- 续写内容必须与前文情节逻辑严格连贯,不能出现矛盾
- 如果涉及已有角色,必须符合其性格设定和说话风格
- 场景描写要符合世界观设定
-- 情节推进要符合大纲规划的方向
-- 约300-500字`;
+- 情节推进要符合大纲规划的方向`;
case 'rewrite':
return `润色和改写指定文本,提升文学质量。
diff --git a/app/lib/token-stats.js b/app/lib/token-stats.js
new file mode 100644
index 0000000..c44298e
--- /dev/null
+++ b/app/lib/token-stats.js
@@ -0,0 +1,191 @@
+// Token 使用统计模块 — 记录每次 AI 请求的 token 用量、速度,并计算消耗预估
+
+const STORAGE_KEY = 'author-token-stats';
+
+/**
+ * 读取所有记录
+ */
+export function getTokenRecords() {
+ if (typeof window === 'undefined') return [];
+ try {
+ const raw = localStorage.getItem(STORAGE_KEY);
+ if (!raw) return [];
+ const data = JSON.parse(raw);
+ return Array.isArray(data.records) ? data.records : [];
+ } catch {
+ return [];
+ }
+}
+
+/**
+ * 添加一条 token 使用记录
+ * @param {{
+ * promptTokens: number,
+ * completionTokens: number,
+ * totalTokens: number,
+ * durationMs: number,
+ * source: 'chat' | 'inline',
+ * provider?: string,
+ * model?: string
+ * }} record
+ */
+export function addTokenRecord(record) {
+ if (typeof window === 'undefined') return;
+ const records = getTokenRecords();
+ records.push({
+ timestamp: Date.now(),
+ promptTokens: record.promptTokens || 0,
+ completionTokens: record.completionTokens || 0,
+ totalTokens: record.totalTokens || 0,
+ durationMs: record.durationMs || 0,
+ source: record.source || 'chat',
+ provider: record.provider || 'unknown',
+ model: record.model || 'unknown',
+ });
+ localStorage.setItem(STORAGE_KEY, JSON.stringify({ records }));
+}
+
+/**
+ * 清空所有统计数据
+ */
+export function clearTokenStats() {
+ if (typeof window === 'undefined') return;
+ localStorage.setItem(STORAGE_KEY, JSON.stringify({ records: [] }));
+}
+
+/**
+ * 计算汇总统计
+ * @returns {{
+ * totalRequests: number,
+ * totalTokens: number,
+ * totalPromptTokens: number,
+ * totalCompletionTokens: number,
+ * avgSpeed: number,
+ * recentSpeeds: {speed: number, tokens: number, timestamp: number}[],
+ * projections: {
+ * perDay: {tokens: number, requests: number},
+ * perWeek: {tokens: number, requests: number},
+ * perMonth: {tokens: number, requests: number},
+ * perQuarter: {tokens: number, requests: number},
+ * perYear: {tokens: number, requests: number}
+ * },
+ * firstRecordTime: number | null,
+ * trackedDays: number
+ * }}
+ */
+export function getTokenStats() {
+ const records = getTokenRecords();
+
+ if (records.length === 0) {
+ return {
+ totalRequests: 0,
+ totalTokens: 0,
+ totalPromptTokens: 0,
+ totalCompletionTokens: 0,
+ avgSpeed: 0,
+ rates: { tps: 0, tpm: 0, tph: 0, tpd: 0, rpm: 0, rph: 0, rpd: 0 },
+ recentSpeeds: [],
+ projections: {
+ perDay: { tokens: 0, requests: 0 },
+ perWeek: { tokens: 0, requests: 0 },
+ perMonth: { tokens: 0, requests: 0 },
+ perQuarter: { tokens: 0, requests: 0 },
+ perYear: { tokens: 0, requests: 0 },
+ },
+ modelBreakdown: [],
+ firstRecordTime: null,
+ trackedDays: 0,
+ };
+ }
+
+ const totalRequests = records.length;
+ const totalTokens = records.reduce((s, r) => s + r.totalTokens, 0);
+ const totalPromptTokens = records.reduce((s, r) => s + r.promptTokens, 0);
+ const totalCompletionTokens = records.reduce((s, r) => s + r.completionTokens, 0);
+
+ // 平均速度(tokens/s):用 completionTokens / durationMs
+ const validSpeedRecords = records.filter(r => r.durationMs > 0 && r.completionTokens > 0);
+ const avgSpeed = validSpeedRecords.length > 0
+ ? validSpeedRecords.reduce((s, r) => s + (r.completionTokens / r.durationMs * 1000), 0) / validSpeedRecords.length
+ : 0;
+
+ // 最近 10 次请求速度
+ const recentSpeeds = validSpeedRecords.slice(-10).map(r => ({
+ speed: r.completionTokens / r.durationMs * 1000,
+ tokens: r.completionTokens,
+ timestamp: r.timestamp,
+ }));
+
+ // 时间跨度计算
+ const firstRecordTime = records[0].timestamp;
+ const now = Date.now();
+ const trackedMs = now - firstRecordTime;
+ const trackedMinutes = Math.max(1, trackedMs / (60 * 1000));
+ const trackedHours = Math.max(1 / 60, trackedMs / (60 * 60 * 1000));
+ const trackedDays = Math.max(1 / 24, trackedMs / (24 * 60 * 60 * 1000));
+
+ // 消耗速率(基于实际统计时间)
+ const rates = {
+ tps: avgSpeed, // tokens per second(生成速度,已有)
+ tpm: totalTokens / trackedMinutes,
+ tph: totalTokens / trackedHours,
+ tpd: totalTokens / trackedDays,
+ rpm: totalRequests / trackedMinutes,
+ rph: totalRequests / trackedHours,
+ rpd: totalRequests / trackedDays,
+ };
+
+ // 消耗预估:基于日均值
+ const dailyTokens = totalTokens / Math.max(1, trackedDays);
+ const dailyRequests = totalRequests / Math.max(1, trackedDays);
+
+ const projections = {
+ perDay: { tokens: Math.round(dailyTokens), requests: Math.round(dailyRequests * 10) / 10 },
+ perWeek: { tokens: Math.round(dailyTokens * 7), requests: Math.round(dailyRequests * 7 * 10) / 10 },
+ perMonth: { tokens: Math.round(dailyTokens * 30), requests: Math.round(dailyRequests * 30 * 10) / 10 },
+ perQuarter: { tokens: Math.round(dailyTokens * 90), requests: Math.round(dailyRequests * 90 * 10) / 10 },
+ perYear: { tokens: Math.round(dailyTokens * 365), requests: Math.round(dailyRequests * 365 * 10) / 10 },
+ };
+
+ // 按 provider + model 分组统计
+ const modelMap = {};
+ for (const r of records) {
+ const key = `${r.provider || 'unknown'}||${r.model || 'unknown'}`;
+ if (!modelMap[key]) {
+ modelMap[key] = { provider: r.provider || 'unknown', model: r.model || 'unknown', tokens: 0, promptTokens: 0, completionTokens: 0, requests: 0, totalDurationMs: 0, validSpeedCount: 0, speedSum: 0 };
+ }
+ const m = modelMap[key];
+ m.tokens += r.totalTokens;
+ m.promptTokens += r.promptTokens;
+ m.completionTokens += r.completionTokens;
+ m.requests += 1;
+ if (r.durationMs > 0 && r.completionTokens > 0) {
+ m.speedSum += r.completionTokens / r.durationMs * 1000;
+ m.validSpeedCount += 1;
+ }
+ }
+ const modelBreakdown = Object.values(modelMap).map(m => ({
+ provider: m.provider,
+ model: m.model,
+ tokens: m.tokens,
+ promptTokens: m.promptTokens,
+ completionTokens: m.completionTokens,
+ requests: m.requests,
+ avgSpeed: m.validSpeedCount > 0 ? m.speedSum / m.validSpeedCount : 0,
+ tokenPercent: totalTokens > 0 ? Math.round(m.tokens / totalTokens * 1000) / 10 : 0,
+ })).sort((a, b) => b.tokens - a.tokens);
+
+ return {
+ totalRequests,
+ totalTokens,
+ totalPromptTokens,
+ totalCompletionTokens,
+ avgSpeed,
+ rates,
+ recentSpeeds,
+ projections,
+ modelBreakdown,
+ firstRecordTime,
+ trackedDays: trackedDays < 1 ? '< 1' : Math.round(trackedDays * 10) / 10,
+ };
+}
diff --git a/app/locales/en.json b/app/locales/en.json
index 5c179df..42d35df 100644
--- a/app/locales/en.json
+++ b/app/locales/en.json
@@ -238,7 +238,46 @@
"accepted": "✓ Accepted",
"rejected": "✗ Rejected",
"pending": "⏳ Pending"
- }
+ },
+ "tabStats": "📊 Stats",
+ "statsNoData": "No statistics yet",
+ "statsNoDataHint": "Statistics will be recorded automatically after sending messages or using AI features",
+ "statsTotalTokens": "Total Tokens",
+ "statsTotalInput": "Total Input",
+ "statsTotalOutput": "Total Output",
+ "statsTotalRequests": "Total Requests",
+ "statsTokensPerSec": "tokens/s",
+ "statsPromptTokens": "Prompt Tokens",
+ "statsCompletionTokens": "Completion Tokens",
+ "statsTrackedDays": "Tracked Period",
+ "statsDays": "days",
+ "statsRates": "Consumption Rates",
+ "statsRatesHint": "Calculated over total wall-clock time since first request, reflecting consumption intensity under continuous use",
+ "statsRateMetric": "Metric",
+ "statsRateDesc": "Description",
+ "statsRateValue": "Value",
+ "statsRateTPS": "Tokens / Second (Generation Speed)",
+ "statsRateTPM": "Tokens / Minute",
+ "statsRateTPH": "Tokens / Hour",
+ "statsRateTPD": "Tokens / Day",
+ "statsRateRPM": "Requests / Minute",
+ "statsRateRPH": "Requests / Hour",
+ "statsRateRPD": "Requests / Day",
+ "statsRecentSpeeds": "Recent Request Speeds",
+ "statsProjections": "Usage Projections",
+ "statsProjectionsHint": "Extrapolated linearly from historical daily average, based only on actual usage volume",
+ "statsPeriod": "Period",
+ "statsTokens": "Tokens",
+ "statsRequests": "Requests",
+ "statsPeriodDay": "Daily",
+ "statsPeriodWeek": "Weekly",
+ "statsPeriodMonth": "Monthly",
+ "statsPeriodQuarter": "Quarterly",
+ "statsPeriodYear": "Yearly",
+ "statsClearBtn": "🗑 Clear Stats",
+ "statsClearConfirm": "Are you sure you want to clear all token usage statistics? This cannot be undone.",
+ "statsModelBreakdown": "Channel / Model Stats",
+ "statsModelBreakdownHint": "Usage breakdown by API provider and model"
},
"sidebar": {
"chat": "💬 Chat",
diff --git a/app/locales/ru.json b/app/locales/ru.json
index ed3ccac..3a81c0c 100644
--- a/app/locales/ru.json
+++ b/app/locales/ru.json
@@ -238,7 +238,46 @@
"accepted": "✓ Принято",
"rejected": "✗ Отклонено",
"pending": "⏳ Ожидание"
- }
+ },
+ "tabStats": "📊 Статистика",
+ "statsNoData": "Нет данных статистики",
+ "statsNoDataHint": "Статистика будет записываться автоматически после отправки сообщений или использования ИИ",
+ "statsTotalTokens": "Всего Tokens",
+ "statsTotalInput": "Всего ввод",
+ "statsTotalOutput": "Всего вывод",
+ "statsTotalRequests": "Всего запросов",
+ "statsTokensPerSec": "tokens/с",
+ "statsPromptTokens": "Tokens запроса",
+ "statsCompletionTokens": "Tokens ответа",
+ "statsTrackedDays": "Период отслеживания",
+ "statsDays": "дн.",
+ "statsRates": "Скорость потребления",
+ "statsRatesHint": "Рассчитано по общему времени с первого запроса, отражает интенсивность потребления при непрерывном использовании",
+ "statsRateMetric": "Метрика",
+ "statsRateDesc": "Описание",
+ "statsRateValue": "Значение",
+ "statsRateTPS": "Tokens / секунду (скорость генерации)",
+ "statsRateTPM": "Tokens / минуту",
+ "statsRateTPH": "Tokens / час",
+ "statsRateTPD": "Tokens / день",
+ "statsRateRPM": "Запросов / минуту",
+ "statsRateRPH": "Запросов / час",
+ "statsRateRPD": "Запросов / день",
+ "statsRecentSpeeds": "Скорость последних запросов",
+ "statsProjections": "Прогноз потребления",
+ "statsProjectionsHint": "Линейная экстраполяция на основе среднесуточного потребления, учитывает только фактический объём использования",
+ "statsPeriod": "Период",
+ "statsTokens": "Tokens",
+ "statsRequests": "Запросы",
+ "statsPeriodDay": "В день",
+ "statsPeriodWeek": "В неделю",
+ "statsPeriodMonth": "В месяц",
+ "statsPeriodQuarter": "В квартал",
+ "statsPeriodYear": "В год",
+ "statsClearBtn": "🗑 Очистить статистику",
+ "statsClearConfirm": "Вы уверены, что хотите очистить всю статистику использования токенов? Это действие нельзя отменить.",
+ "statsModelBreakdown": "Канал / Модель",
+ "statsModelBreakdownHint": "Детализация потребления по провайдеру API и модели"
},
"sidebar": {
"chat": "💬 Чат",
diff --git a/app/locales/zh.json b/app/locales/zh.json
index f7e8fb3..edda224 100644
--- a/app/locales/zh.json
+++ b/app/locales/zh.json
@@ -238,7 +238,46 @@
"accepted": "✓ 已接受",
"rejected": "✗ 已拒绝",
"pending": "⏳ 待确认"
- }
+ },
+ "tabStats": "📊 统计",
+ "statsNoData": "暂无统计数据",
+ "statsNoDataHint": "发送消息或使用 AI 功能后,统计数据将自动记录",
+ "statsTotalTokens": "总 Tokens",
+ "statsTotalInput": "总输入",
+ "statsTotalOutput": "总输出",
+ "statsTotalRequests": "总请求数",
+ "statsTokensPerSec": "tokens/s",
+ "statsPromptTokens": "提示词 Tokens",
+ "statsCompletionTokens": "回复 Tokens",
+ "statsTrackedDays": "统计天数",
+ "statsDays": "天",
+ "statsRates": "消耗速率",
+ "statsRatesHint": "按首次请求至今的总时长计算,反映不间断使用假设下的消耗强度",
+ "statsRateMetric": "指标",
+ "statsRateDesc": "说明",
+ "statsRateValue": "数值",
+ "statsRateTPS": "Tokens / 秒(生成速度)",
+ "statsRateTPM": "Tokens / 分钟",
+ "statsRateTPH": "Tokens / 小时",
+ "statsRateTPD": "Tokens / 天",
+ "statsRateRPM": "请求 / 分钟",
+ "statsRateRPH": "请求 / 小时",
+ "statsRateRPD": "请求 / 天",
+ "statsRecentSpeeds": "近期请求速度",
+ "statsProjections": "消耗预估",
+ "statsProjectionsHint": "根据历史日均用量线性外推,仅统计实际产生请求的消耗量",
+ "statsPeriod": "周期",
+ "statsTokens": "Tokens",
+ "statsRequests": "请求数",
+ "statsPeriodDay": "每天",
+ "statsPeriodWeek": "每周",
+ "statsPeriodMonth": "每月",
+ "statsPeriodQuarter": "每季度",
+ "statsPeriodYear": "每年",
+ "statsClearBtn": "🗑 清空统计",
+ "statsClearConfirm": "确定清空所有 Token 使用统计?此操作不可撤销。",
+ "statsModelBreakdown": "渠道 / 模型统计",
+ "statsModelBreakdownHint": "按 API 提供商和模型分别统计的消耗明细"
},
"sidebar": {
"chat": "💬 对话",
diff --git a/app/page.js b/app/page.js
index f4f1f0a..f8a32cd 100644
--- a/app/page.js
+++ b/app/page.js
@@ -14,7 +14,8 @@ import {
migrateGlobalChapters,
saveChapters,
} from './lib/storage';
-import { buildContext, compileSystemPrompt, compileUserPrompt, OUTPUT_TOKEN_BUDGET, getContextItems, estimateTokens } from './lib/context-engine';
+import { buildContext, compileSystemPrompt, compileUserPrompt, getContextItems, estimateTokens } from './lib/context-engine';
+import { addTokenRecord } from './lib/token-stats';
import { getProjectSettings, WRITING_MODES, getWritingMode, addSettingsNode, updateSettingsNode, deleteSettingsNode, getSettingsNodes, getActiveWorkId } from './lib/settings';
import {
loadSessionStore, createSession, getActiveSession,
@@ -196,6 +197,9 @@ export default function Home() {
// Inline AI 回调:编辑器调用此函数发起 AI 请求
const handleInlineAiRequest = useCallback(async ({ mode, text, instruction, signal, onChunk }) => {
+ const startTime = Date.now();
+ let usageData = null;
+ let fullText = '';
try {
// 使用上下文引擎收集项目信息
const context = await buildContext(activeChapterId, text, contextSelection.size > 0 ? contextSelection : null);
@@ -210,7 +214,7 @@ export default function Home() {
const res = await fetch(apiEndpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ systemPrompt, userPrompt, apiConfig, maxTokens: OUTPUT_TOKEN_BUDGET }),
+ body: JSON.stringify({ systemPrompt, userPrompt, apiConfig }),
signal,
});
@@ -242,13 +246,41 @@ export default function Home() {
if (trimmed.startsWith('data: ')) {
try {
const json = JSON.parse(trimmed.slice(6));
- if (json.text) onChunk(json.text);
+ if (json.text) { fullText += json.text; onChunk(json.text); }
+ if (json.usage) { usageData = json.usage; }
} catch {
// 解析失败跳过
}
}
}
}
+
+ // 记录 token 统计
+ const durationMs = Date.now() - startTime;
+ if (usageData) {
+ addTokenRecord({
+ promptTokens: usageData.promptTokens || 0,
+ completionTokens: usageData.completionTokens || 0,
+ totalTokens: usageData.totalTokens || 0,
+ durationMs,
+ source: 'inline',
+ 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: 'inline',
+ provider: apiConfig?.provider || 'unknown',
+ model: apiConfig?.model || 'unknown',
+ });
+ }
} catch (err) {
if (err.name === 'AbortError') {
showToast(t('page.toastStopped'), 'info');