feat: add token stats infographic UI with provider brand logos and model-based detection

This commit is contained in:
YuanShiJiLoong committed 2026-03-01 02:56:19 +09:00
1 parent 28e831fa23
commit ee3aa460e2
11 files changed
+1092 -27

No files matched your search

+1 -1
View File
@@ -38,7 +38,7 @@ export async function POST(request) {
generationConfig: {
temperature: 0.8,
topP: 0.9,
maxOutputTokens: maxTokens || 4000,
...(maxTokens ? { maxOutputTokens: maxTokens } : {}),
},
};
+1 -1
View File
@@ -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,
}),
});
+289 -2
View File
@@ -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 = <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" className={className}><polygon points="12 2 2 7 12 12 22 7 12 2"></polygon><polyline points="2 17 12 22 22 17"></polyline><polyline points="2 12 12 17 22 12"></polyline></svg>;
if (p.includes('openai') || m.includes('gpt') || m.includes('o1') || m.includes('o3')) {
svg = <svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" className={className}><path d="M22.28 12.37a7.02 7.02 0 0 0-1-6.15 7.08 7.08 0 0 0-8.81-2.9 6.95 6.95 0 0 0-4.64-1.2 7.09 7.09 0 0 0-5.74 8.24A7.03 7.03 0 0 0 3.32 18.2 7.07 7.07 0 0 0 12.5 21a6.95 6.95 0 0 0 4.25 1.48 7.1 7.1 0 0 0 5.6-8.23l-.07-1.88ZM11 20.4a4.96 4.96 0 0 1-4.04-2.07l5.96-3.44A1.36 1.36 0 0 0 13.6 14v-6.9l3.43 1.98a4.91 4.91 0 0 1-1.35 8.44L11 20.4Zm-6.52-3.8A4.95 4.95 0 0 1 3.5 11l5.96 3.44v6.87L5.5 19.1A4.9 4.9 0 0 1 4.48 16.6ZM3.5 11a4.95 4.95 0 0 1 3-4.52V13.8a1.36 1.36 0 0 0 .68 1.18l5.97 3.45-3.43 1.98a4.92 4.92 0 0 1-6.22-9.41ZM19.5 13.6a4.95 4.95 0 0 1-3 4.54V10.8a1.36 1.36 0 0 0-.68-1.18L9.85 6.17l3.43-1.98A4.93 4.93 0 0 1 19.5 13.6Zm-6.5-9.4a4.96 4.96 0 0 1 4.04 2.07l-5.96 3.44A1.36 1.36 0 0 0 10.4 10v6.89L6.97 14.9a4.9 4.9 0 0 1 1.35-8.43l4.68-2.27Zm6.5 3.8a4.95 4.95 0 0 1 .98 5.6H14.5v-6.87l3.96-2.21A4.9 4.9 0 0 1 19.5 8Z" /><circle cx="12" cy="12" r="2.5" /></svg>;
} else if (p.includes('anthropic') || m.includes('claude')) {
svg = <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" className={className}><path d="M12 2L2 22h3.5l1.5-3h10l1.5 3H22L12 2zm-5 14l5-10 5 10H7z" /></svg>;
} else if (p.includes('gemini') || p.includes('google') || m.includes('gemini')) {
svg = <svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" stroke="none" className={className}><path d="M12 2l2.4 7.6L22 12l-7.6 2.4L12 22l-2.4-7.6L2 12l7.6-2.4L12 2z" /></svg>;
} else if (p.includes('deepseek') || m.includes('deepseek')) {
svg = <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" className={className}><ellipse cx="12" cy="12" rx="10" ry="10"></ellipse><path d="M4.93 4.93l14.14 14.14"></path><path d="M19.07 4.93L4.93 19.07"></path></svg>;
} else if (p.includes('ollama') || m.includes('llama')) {
svg = <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" className={className}><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" /></svg>;
} else if (p.includes('qwen') || m.includes('qwen')) {
svg = <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" className={className}><circle cx="12" cy="12" r="10" /><path d="M12 16v-4" /><path d="M12 8h.01" /></svg>;
} else if (p.includes('openrouter')) {
svg = <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" className={className}><circle cx="12" cy="12" r="10" /><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" /><path d="M2 12h20" /></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 }) {
</div>
</div>
)}
{/* ==================== 📊 统计 Tab ==================== */}
{activeTab === 'stats' && (
<div className="ai-sidebar-body">
<div className="token-stats-panel">
{tokenStats.totalRequests === 0 ? (
<div className="chat-empty">
<div>📊</div>
<div>{t('aiSidebar.statsNoData')}</div>
<div className="chat-empty-hint">{t('aiSidebar.statsNoDataHint')}</div>
</div>
) : (
<>
{/* 汇总卡片 */}
<div className="stats-grid">
<div className="stats-card">
<div className="stats-card-value">{tokenStats.totalTokens.toLocaleString()}</div>
<div className="stats-card-label">{t('aiSidebar.statsTotalTokens')}</div>
</div>
<div className="stats-card">
<div className="stats-card-value">{tokenStats.totalPromptTokens.toLocaleString()}</div>
<div className="stats-card-label">{t('aiSidebar.statsTotalInput')}</div>
</div>
<div className="stats-card">
<div className="stats-card-value">{tokenStats.totalCompletionTokens.toLocaleString()}</div>
<div className="stats-card-label">{t('aiSidebar.statsTotalOutput')}</div>
</div>
<div className="stats-card">
<div className="stats-card-value">{tokenStats.totalRequests}</div>
<div className="stats-card-label">{t('aiSidebar.statsTotalRequests')}</div>
</div>
<div className="stats-card">
<div className="stats-card-value">{tokenStats.trackedDays}</div>
<div className="stats-card-label">{t('aiSidebar.statsTrackedDays')}</div>
</div>
</div>
{/* 消耗速率 */}
<div className="stats-section">
<div className="stats-section-title">{t('aiSidebar.statsRates')}</div>
<div className="stats-section-hint">{t('aiSidebar.statsRatesHint')}</div>
<table className="projection-table">
<thead>
<tr>
<th>{t('aiSidebar.statsRateMetric')}</th>
<th>{t('aiSidebar.statsRateDesc')}</th>
<th>{t('aiSidebar.statsRateValue')}</th>
</tr>
</thead>
<tbody>
{[
['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]) => (
<tr key={key} title={label}>
<td><strong>{key}</strong></td>
<td className="stats-rate-desc">{label}</td>
<td>{value < 1 ? value.toFixed(2) : value < 10 ? value.toFixed(1) : Math.round(value).toLocaleString()}</td>
</tr>
))}
</tbody>
</table>
</div>
{/* 近期请求速度 */}
{tokenStats.recentSpeeds.length > 0 && (
<div className="stats-section">
<div className="stats-section-title">{t('aiSidebar.statsRecentSpeeds')}</div>
<div className="speed-chart">
{(() => {
const maxSpeed = Math.max(...tokenStats.recentSpeeds.map(s => s.speed));
return tokenStats.recentSpeeds.map((s, i) => (
<div key={i} className="speed-bar-wrapper" title={`${s.speed.toFixed(1)} tokens/s · ${s.tokens} tokens`}>
<div
className="speed-bar"
style={{ height: `${Math.max(4, (s.speed / maxSpeed) * 100)}%` }}
/>
<span className="speed-bar-label">{s.speed.toFixed(0)}</span>
</div>
));
})()}
</div>
</div>
)}
{/* 消耗预估 */}
<div className="stats-section">
<div className="stats-section-title">{t('aiSidebar.statsProjections')}</div>
<div className="stats-section-hint">{t('aiSidebar.statsProjectionsHint')}</div>
<table className="projection-table">
<thead>
<tr>
<th>{t('aiSidebar.statsPeriod')}</th>
<th>{t('aiSidebar.statsTokens')}</th>
<th>{t('aiSidebar.statsRequests')}</th>
</tr>
</thead>
<tbody>
{[
['statsPeriodDay', tokenStats.projections.perDay],
['statsPeriodWeek', tokenStats.projections.perWeek],
['statsPeriodMonth', tokenStats.projections.perMonth],
['statsPeriodQuarter', tokenStats.projections.perQuarter],
['statsPeriodYear', tokenStats.projections.perYear],
].map(([key, data]) => (
<tr key={key}>
<td>{t(`aiSidebar.${key}`)}</td>
<td>{data.tokens.toLocaleString()}</td>
<td>{data.requests}</td>
</tr>
))}
</tbody>
</table>
</div>
{/* 渠道/模型分类统计 */}
{tokenStats.modelBreakdown.length > 0 && (
<div className="stats-section">
<div className="stats-section-title">{t('aiSidebar.statsModelBreakdown')}</div>
<div className="stats-section-hint">{t('aiSidebar.statsModelBreakdownHint')}</div>
{tokenStats.modelBreakdown.map((m, idx) => {
const bgGradient = getProviderColor(m.provider, m.model);
return (
<div key={idx} className="model-info-card">
<div className="model-info-header">
<div className="model-info-title">
<span className="model-info-badge" style={{ background: bgGradient }}>
<ProviderLogo provider={m.provider} model={m.model} className="provider-logo-svg" />
{m.provider}
</span>
<span className="model-info-name" title={m.model}>{m.model}</span>
</div>
<div className="model-info-percent">
{Math.round(m.tokenPercent)}<span>%</span>
</div>
</div>
<div className="model-info-bar-track">
<div className="model-info-bar-fill" style={{ width: `${m.tokenPercent}%`, background: bgGradient }} />
</div>
<div className="model-info-stats">
<div className="info-stat-group">
<span className="info-stat-value">{m.tokens.toLocaleString()}</span>
<span className="info-stat-label">Tokens</span>
</div>
<div className="info-stat-group">
<span className="info-stat-value">{m.requests}</span>
<span className="info-stat-label">{t('aiSidebar.statsRequests')}</span>
</div>
<div className="info-stat-group" title={`${t('aiSidebar.statsTotalInput')}: ${m.promptTokens} / ${t('aiSidebar.statsTotalOutput')}: ${m.completionTokens}`}>
<span className="info-stat-value">
{m.promptTokens > 1000 ? (m.promptTokens / 1000).toFixed(1) + 'k' : m.promptTokens} / {m.completionTokens > 1000 ? (m.completionTokens / 1000).toFixed(1) + 'k' : m.completionTokens}
</span>
<span className="info-stat-label">In / Out</span>
</div>
{m.avgSpeed > 0 && (
<div className="info-stat-group">
<span className="info-stat-value">{m.avgSpeed.toFixed(1)}</span>
<span className="info-stat-label">t/s</span>
</div>
)}
</div>
</div>
);
})}
</div>
)}
{/* 清空按钮 */}
<div className="stats-actions">
<button
className="btn-mini danger"
onClick={() => {
if (confirm(t('aiSidebar.statsClearConfirm'))) {
clearTokenStats();
setStatsVersion(v => v + 1);
}
}}
>
{t('aiSidebar.statsClearBtn')}
</button>
</div>
</>
)}
</div>
</div>
)}
</div>
);
}
+77 -11
View File
@@ -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 (
<div style={{ marginBottom: 12 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 6 }}>
@@ -28,9 +102,7 @@ function TextField({ label, value, onChange, placeholder, multiline = false, row
</div>
{multiline ? (
<textarea
value={value || ''}
onChange={e => onChange(e.target.value)}
placeholder={placeholder}
{...inputProps}
rows={rows}
style={{
width: '100%', padding: '8px 12px', border: '1px solid var(--border-light)',
@@ -38,22 +110,16 @@ function TextField({ label, value, onChange, placeholder, multiline = false, row
fontSize: 13, fontFamily: 'var(--font-ui)', resize: 'vertical', outline: 'none',
lineHeight: 1.6, transition: 'border-color 0.15s',
}}
onFocus={e => e.target.style.borderColor = 'var(--accent)'}
onBlur={e => e.target.style.borderColor = 'var(--border-light)'}
/>
) : (
<input
type="text"
value={value || ''}
onChange={e => onChange(e.target.value)}
placeholder={placeholder}
{...inputProps}
style={{
width: '100%', padding: '8px 12px', border: '1px solid var(--border-light)',
borderRadius: 'var(--radius-sm)', background: 'var(--bg-primary)', color: 'var(--text-primary)',
fontSize: 13, fontFamily: 'var(--font-ui)', outline: 'none', transition: 'border-color 0.15s',
}}
onFocus={e => e.target.style.borderColor = 'var(--accent)'}
onBlur={e => e.target.style.borderColor = 'var(--border-light)'}
/>
)}
</div>
+373
View File
@@ -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;
}
+5 -6
View File
@@ -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 `润色和改写指定文本,提升文学质量。
+191
View File
@@ -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,
};
}
+40 -1
View File
@@ -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",
+40 -1
View File
@@ -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": "💬 Чат",
+40 -1
View File
@@ -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": "💬 对话",
+35 -3
View File
@@ -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');