feat: add token stats infographic UI with provider brand logos and model-based detection
This commit is contained in:
1 parent
28e831fa23
commit
ee3aa460e2
11 files changed
+1092
-27
No files matched your search
@@ -38,7 +38,7 @@ export async function POST(request) {
|
||||
generationConfig: {
|
||||
temperature: 0.8,
|
||||
topP: 0.9,
|
||||
maxOutputTokens: maxTokens || 4000,
|
||||
...(maxTokens ? { maxOutputTokens: maxTokens } : {}),
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
+1
-1
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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 `润色和改写指定文本,提升文学质量。
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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');
|
||||
|
||||
Reference in new issue
Block a user