fix: 修复参考章节截断/RAG逻辑/设定操作渲染/embedding限速/幽灵依赖

This commit is contained in:
YuanShiJiLoong committed 2026-03-03 21:42:15 +09:00
1 parent cd7a9129ec
commit a2943f83c8
6 files changed
+423 -429

No files matched your search

+352 -343
View File
@@ -13,10 +13,11 @@ import { useAppStore } from '../store/useAppStore';
import ChatMarkdown from './ChatMarkdown';
import { useI18n } from '../lib/useI18n';
// 解析消息中的 [SETTINGS_ACTION] 块
// 解析消息中的 [SETTINGS_ACTION] 块(支持可选的代码围栏包裹)
function parseSettingsActions(content) {
if (!content) return { parts: [content || ''], actions: [] };
const regex = /\[SETTINGS_ACTION\]\s*([\s\S]*?)\s*\[\/SETTINGS_ACTION\]/g;
// 匹配带或不带 ``` 包裹的 [SETTINGS_ACTION] 块
const regex = /(?:```[^\n]*\n)?\[SETTINGS_ACTION\]\s*([\s\S]*?)\s*\[\/SETTINGS_ACTION\](?:\n```)?/g;
const parts = [];
const actions = [];
let lastIndex = 0;
@@ -1006,7 +1007,7 @@ export default function AiSidebar({ onInsertText }) {
e.stopPropagation();
// 提取正文:去掉系统块、markdown标记、编辑点评
let text = (msg.content || '')
.replace(/\[SETTINGS_ACTION\][\s\S]*?\[\\?\/SETTINGS_ACTION\]/g, '')
.replace(/(?:```[^\n]*\n)?\[SETTINGS_ACTION\][\s\S]*?\[\\?\/SETTINGS_ACTION\](?:\n```)?/g, '')
.replace(/^#{1,6}\s+/gm, '') // 去掉标题 #
.replace(/\*\*(.+?)\*\*/g, '$1') // **粗体** → 粗体
.replace(/\*(.+?)\*/g, '$1') // *斜体* → 斜体
@@ -1025,7 +1026,8 @@ export default function AiSidebar({ onInsertText }) {
}}
>{t('aiSidebar.copy')}</button>
</div>
)}
)
}
</div>
);
})}
@@ -1058,368 +1060,375 @@ export default function AiSidebar({ onInsertText }) {
</button>
</div>
</div>
)}
)
}
{/* ==================== 📋 存档 Tab ==================== */}
{activeTab === 'archive' && (
<div className="ai-sidebar-body">
<div className="archive-search-bar">
<input
className="archive-search-input"
placeholder={t('aiSidebar.searchArchive')}
value={archiveSearch}
onChange={e => setArchiveSearch(e.target.value)}
/>
</div>
<div className="archive-list">
{filteredArchive.length === 0 && (
<div className="chat-empty">
<div>{t('aiSidebar.emptyArchiveIcon')}</div>
<div>{t('aiSidebar.emptyArchiveTitle')}</div>
<div className="chat-empty-hint">{t('aiSidebar.emptyArchiveHint')}</div>
</div>
)}
{[...filteredArchive].reverse().map(item => (
<div
key={item.id}
className={`archive-item ${item.status}`}
onClick={() => setExpandedArchive(expandedArchive === item.id ? null : item.id)}
>
<div className="archive-item-header">
<span className={`archive-status ${item.status}`}>
{t(`aiSidebar.statuses.${item.status}`) || item.status}
</span>
<span className="archive-mode">{t(`aiSidebar.modes.${item.mode}`) || item.mode}</span>
<span className="archive-time">
{new Date(item.timestamp).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })}
</span>
</div>
<div className="archive-preview">
{item.text?.slice(0, 60)}…
</div>
{expandedArchive === item.id && (
<div className="archive-expanded">
<pre className="archive-full-text">{item.text}</pre>
<div className="archive-actions">
<button className="btn-mini" onClick={(e) => { e.stopPropagation(); onInsertText?.(item.text); }}>
{t('aiSidebar.insertEditor')}
</button>
<button className="btn-mini" onClick={(e) => { e.stopPropagation(); navigator.clipboard.writeText(item.text); }}>
{t('aiSidebar.copy')}
</button>
</div>
</div>
)}
</div>
))}
</div>
</div>
)}
{/* ==================== 📚 参考 Tab ==================== */}
{activeTab === 'reference' && (
<div className="ai-sidebar-body">
{/* Token 预算进度条 */}
<div className="context-budget-bar">
<div className="context-budget-label">
<span>{t('aiSidebar.tokenUsage')}</span>
<span className={isOverBudget ? 'context-over-budget' : ''}>
{totalSelectedTokens.toLocaleString()} / {(INPUT_TOKEN_BUDGET / 1000).toFixed(0)}k
</span>
</div>
<div className="context-budget-track">
<div
className={`context-budget-fill ${isOverBudget ? 'over' : ''}`}
style={{ width: `${Math.min(100, budgetPercent)}%` }}
{
activeTab === 'archive' && (
<div className="ai-sidebar-body">
<div className="archive-search-bar">
<input
className="archive-search-input"
placeholder={t('aiSidebar.searchArchive')}
value={archiveSearch}
onChange={e => setArchiveSearch(e.target.value)}
/>
</div>
</div>
{/* 搜索框 */}
<div className="context-search-bar">
<input
className="context-search-input"
placeholder={t('aiSidebar.searchContext')}
value={contextSearch}
onChange={e => setContextSearch(e.target.value)}
/>
</div>
{/* 分组列表 */}
<div className="context-groups">
{Object.entries(groupedItems).length === 0 && (
<div className="chat-empty">
<div>{t('aiSidebar.emptyContextIcon')}</div>
<div>{t('aiSidebar.emptyContextTitle')}</div>
<div className="chat-empty-hint">
{contextSearch ? t('aiSidebar.emptyContextHint1') : t('aiSidebar.emptyContextHint2')}
<div className="archive-list">
{filteredArchive.length === 0 && (
<div className="chat-empty">
<div>{t('aiSidebar.emptyArchiveIcon')}</div>
<div>{t('aiSidebar.emptyArchiveTitle')}</div>
<div className="chat-empty-hint">{t('aiSidebar.emptyArchiveHint')}</div>
</div>
</div>
)}
{Object.entries(groupedItems).map(([groupName, items]) => {
const isCollapsed = collapsedGroups.has(groupName);
const checkedCount = items.filter(it => contextSelection?.has(it.id)).length;
const groupTokens = items
.filter(it => contextSelection?.has(it.id))
.reduce((sum, it) => sum + it.tokens, 0);
const allGroupChecked = checkedCount === items.length;
return (
<div key={groupName} className="context-group">
<div
className="context-group-header"
onClick={() => toggleCollapse(groupName)}
>
<span className="context-collapse-icon">
{isCollapsed ? '▶' : '▼'}
)}
{[...filteredArchive].reverse().map(item => (
<div
key={item.id}
className={`archive-item ${item.status}`}
onClick={() => setExpandedArchive(expandedArchive === item.id ? null : item.id)}
>
<div className="archive-item-header">
<span className={`archive-status ${item.status}`}>
{t(`aiSidebar.statuses.${item.status}`) || item.status}
</span>
<input
type="checkbox"
checked={allGroupChecked && items.length > 0}
ref={el => {
if (el) el.indeterminate = checkedCount > 0 && checkedCount < items.length;
}}
onChange={(e) => {
e.stopPropagation();
toggleGroup(groupName);
}}
onClick={e => e.stopPropagation()}
className="context-group-check"
/>
<span className="context-group-name">
{groupName} ({checkedCount}/{items.length})
</span>
<span className="context-group-tokens">
{groupTokens > 0 ? `${groupTokens.toLocaleString()}t` : '—'}
<span className="archive-mode">{t(`aiSidebar.modes.${item.mode}`) || item.mode}</span>
<span className="archive-time">
{new Date(item.timestamp).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })}
</span>
</div>
{!isCollapsed && (
<div className="context-group-items">
{items.map(item => (
<label key={item.id} className="context-item">
<input
type="checkbox"
checked={contextSelection?.has(item.id) || false}
onChange={() => toggleContextItem(item.id)}
className="context-item-check"
/>
<span className="context-item-name" title={item.name}>
{item.name}
</span>
<span className="context-item-tokens">
{item.tokens > 0 ? `${item.tokens.toLocaleString()}t` : '—'}
</span>
</label>
))}
<div className="archive-preview">
{item.text?.slice(0, 60)}…
</div>
{expandedArchive === item.id && (
<div className="archive-expanded">
<pre className="archive-full-text">{item.text}</pre>
<div className="archive-actions">
<button className="btn-mini" onClick={(e) => { e.stopPropagation(); onInsertText?.(item.text); }}>
{t('aiSidebar.insertEditor')}
</button>
<button className="btn-mini" onClick={(e) => { e.stopPropagation(); navigator.clipboard.writeText(item.text); }}>
{t('aiSidebar.copy')}
</button>
</div>
</div>
)}
</div>
);
})}
))}
</div>
</div>
)
}
{/* 批量操作 */}
<div className="context-actions">
<button className="btn-mini" onClick={selectAll}>{t('aiSidebar.selectAll')}</button>
<button className="btn-mini" onClick={selectNone}>{t('aiSidebar.selectNone')}</button>
<button className="btn-mini" onClick={resetSelection}>{t('aiSidebar.reset')}</button>
<button className="btn-mini" onClick={onOpenSettings}>{t('aiSidebar.settings')}</button>
{/* ==================== 📚 参考 Tab ==================== */}
{
activeTab === 'reference' && (
<div className="ai-sidebar-body">
{/* Token 预算进度条 */}
<div className="context-budget-bar">
<div className="context-budget-label">
<span>{t('aiSidebar.tokenUsage')}</span>
<span className={isOverBudget ? 'context-over-budget' : ''}>
{totalSelectedTokens.toLocaleString()} / {(INPUT_TOKEN_BUDGET / 1000).toFixed(0)}k
</span>
</div>
<div className="context-budget-track">
<div
className={`context-budget-fill ${isOverBudget ? 'over' : ''}`}
style={{ width: `${Math.min(100, budgetPercent)}%` }}
/>
</div>
</div>
{/* 搜索框 */}
<div className="context-search-bar">
<input
className="context-search-input"
placeholder={t('aiSidebar.searchContext')}
value={contextSearch}
onChange={e => setContextSearch(e.target.value)}
/>
</div>
{/* 分组列表 */}
<div className="context-groups">
{Object.entries(groupedItems).length === 0 && (
<div className="chat-empty">
<div>{t('aiSidebar.emptyContextIcon')}</div>
<div>{t('aiSidebar.emptyContextTitle')}</div>
<div className="chat-empty-hint">
{contextSearch ? t('aiSidebar.emptyContextHint1') : t('aiSidebar.emptyContextHint2')}
</div>
</div>
)}
{Object.entries(groupedItems).map(([groupName, items]) => {
const isCollapsed = collapsedGroups.has(groupName);
const checkedCount = items.filter(it => contextSelection?.has(it.id)).length;
const groupTokens = items
.filter(it => contextSelection?.has(it.id))
.reduce((sum, it) => sum + it.tokens, 0);
const allGroupChecked = checkedCount === items.length;
return (
<div key={groupName} className="context-group">
<div
className="context-group-header"
onClick={() => toggleCollapse(groupName)}
>
<span className="context-collapse-icon">
{isCollapsed ? '▶' : '▼'}
</span>
<input
type="checkbox"
checked={allGroupChecked && items.length > 0}
ref={el => {
if (el) el.indeterminate = checkedCount > 0 && checkedCount < items.length;
}}
onChange={(e) => {
e.stopPropagation();
toggleGroup(groupName);
}}
onClick={e => e.stopPropagation()}
className="context-group-check"
/>
<span className="context-group-name">
{groupName} ({checkedCount}/{items.length})
</span>
<span className="context-group-tokens">
{groupTokens > 0 ? `${groupTokens.toLocaleString()}t` : '—'}
</span>
</div>
{!isCollapsed && (
<div className="context-group-items">
{items.map(item => (
<label key={item.id} className="context-item">
<input
type="checkbox"
checked={contextSelection?.has(item.id) || false}
onChange={() => toggleContextItem(item.id)}
className="context-item-check"
/>
<span className="context-item-name" title={item.name}>
{item.name}
</span>
<span className="context-item-tokens">
{item.tokens > 0 ? `${item.tokens.toLocaleString()}t` : '—'}
</span>
</label>
))}
</div>
)}
</div>
);
})}
</div>
{/* 批量操作 */}
<div className="context-actions">
<button className="btn-mini" onClick={selectAll}>{t('aiSidebar.selectAll')}</button>
<button className="btn-mini" onClick={selectNone}>{t('aiSidebar.selectNone')}</button>
<button className="btn-mini" onClick={resetSelection}>{t('aiSidebar.reset')}</button>
<button className="btn-mini" onClick={onOpenSettings}>{t('aiSidebar.settings')}</button>
</div>
</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>
{
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-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 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.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 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>
)}
{/* 清空按钮 */}
<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>
</>
)}
{/* 近期请求速度 */}
{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>
)}
</div>
)
}
</div >
);
}
+46 -72
View File
@@ -134,7 +134,7 @@ export async function getContextItems(activeChapterId) {
id: `chapter-${ch.id}`,
group: '📑 章节',
name: `第${i + 1}章 ${ch.title}${i > currentIndex ? '(后续)' : ''}`,
tokens: estimateTokens(text ? text.slice(0, 1500) : ''),
tokens: estimateTokens(text),
category: 'chapter',
enabled: i < currentIndex, // 前文章节默认启用,后续章节默认不启用
});
@@ -180,65 +180,60 @@ export async function buildContext(activeChapterId, selectedText, selectedIds =
// 获取所有有效的设定条目
const allValidItemNodes = nodes.filter(n => n.type === 'item' && n.enabled !== false);
// 如果有 selectedIds,提取手动勾选的设定
const manualItemNodes = [];
const unselectedItemNodes = [];
let finalItemNodes;
for (const n of allValidItemNodes) {
if (selectedIds && selectedIds.has(`setting-${n.id}`)) {
manualItemNodes.push(n);
} else if (!selectedIds) {
// selectedIds 为 null — 兼容旧逻辑,全部走 RAG
unselectedItemNodes.push(n);
} else {
unselectedItemNodes.push(n);
}
}
if (!selectedIds) {
// selectedIds 为 null — 用户没有手动勾选任何参考
// 包含所有启用的设定条目,再用 RAG 补充排序(如果可用)
finalItemNodes = [...allValidItemNodes];
} else {
// 用户手动勾选了参考条目
const manualItemNodes = [];
const unselectedItemNodes = [];
// --- RAG 自动检索(仅当开启嵌入功能时) ---
let autoRetrievedNodes = [];
const queryText = (selectedText || '').trim();
if (settings.apiConfig?.useCustomEmbed && queryText && unselectedItemNodes.length > 0) {
try {
// 获取 Query 的 Embedding
// 此处的文本可以是光标前的一小段文字,为了简便使用 selectedText
// TODO: 这里如果是空文本,可能需要补当前章节最近的内容
let ragSourceText = queryText;
if (ragSourceText.length < 50 && currentChapter) {
// 文本太短,用当前章节末尾补充
const stripChapText = stripHtml(currentChapter.content || '').slice(-200);
ragSourceText = ragSourceText + '\n' + stripChapText;
for (const n of allValidItemNodes) {
if (selectedIds.has(`setting-${n.id}`)) {
manualItemNodes.push(n);
} else {
unselectedItemNodes.push(n);
}
const queryVector = await getEmbedding(ragSourceText, settings.apiConfig);
if (queryVector) {
// 计算相似度
const scoredNodes = unselectedItemNodes.map(n => {
if (!n.embedding) return { node: n, score: -1 };
return { node: n, score: cosineSimilarity(queryVector, n.embedding) };
}).filter(x => x.score > 0.3); // 设置一个基础阈值 0.3
// 按分数降序
scoredNodes.sort((a, b) => b.score - a.score);
// 取 Top-K (比如最多取 5 个)
autoRetrievedNodes = scoredNodes.slice(0, 5).map(x => x.node);
}
} catch (e) {
console.error('RAG Retrieval failed:', e);
}
}
// 合并手动与自动检索的节点
const itemNodes = [...manualItemNodes, ...autoRetrievedNodes];
// 去重
const finalItemNodes = Array.from(new Set(itemNodes));
// --- RAG 自动检索(仅当有手动勾选时,对未勾选项做 RAG 补充) ---
let autoRetrievedNodes = [];
const queryText = (selectedText || '').trim();
if (settings.apiConfig?.useCustomEmbed && queryText && unselectedItemNodes.length > 0) {
try {
let ragSourceText = queryText;
if (ragSourceText.length < 50 && currentChapter) {
const stripChapText = stripHtml(currentChapter.content || '').slice(-200);
ragSourceText = ragSourceText + '\n' + stripChapText;
}
const queryVector = await getEmbedding(ragSourceText, settings.apiConfig);
if (queryVector) {
const scoredNodes = unselectedItemNodes.map(n => {
if (!n.embedding) return { node: n, score: -1 };
return { node: n, score: cosineSimilarity(queryVector, n.embedding) };
}).filter(x => x.score > 0.3);
scoredNodes.sort((a, b) => b.score - a.score);
autoRetrievedNodes = scoredNodes.slice(0, 5).map(x => x.node);
}
} catch (e) {
console.error('RAG Retrieval failed:', e);
}
}
// 合并手动与自动检索的节点,去重
finalItemNodes = Array.from(new Set([...manualItemNodes, ...autoRetrievedNodes]));
}
const writingMode = getWritingMode();
// 先构建各模块的原始文本
const rawModules = {
bookInfo: (selectedIds && selectedIds.has('bookinfo')) ? buildBookInfoContext(settings.bookInfo) : '',
bookInfo: (!selectedIds || selectedIds.has('bookinfo')) ? buildBookInfoContext(settings.bookInfo) : '',
characters: buildCharactersContext(finalItemNodes.filter(n => n.category === 'character')),
locations: buildLocationsContext(finalItemNodes.filter(n => n.category === 'location'), nodes),
worldbuilding: buildWorldContext(finalItemNodes.filter(n => n.category === 'world'), nodes),
@@ -596,17 +591,6 @@ function buildPreviousContext(chapters, currentIndex) {
return prevChapters.map((ch, i) => {
const text = stripHtml(ch.content || '');
if (!text) return `第${i + 1}章「${ch.title}」:(空)`;
if (i === currentIndex - 1) {
// 前一章:保留更多内容
const excerpt = text.length > 1500 ? '...' + text.slice(-1500) : text;
return `第${i + 1}章「${ch.title}」(前一章完整回顾):\n${excerpt}`;
}
// 远距章节:只保留简要摘要
if (text.length > 300) {
return `第${i + 1}章「${ch.title}」:\n${text.slice(0, 150)}…(中间省略)…${text.slice(-100)}`;
}
return `第${i + 1}章「${ch.title}」:\n${text}`;
}).join('\n\n');
}
@@ -620,15 +604,6 @@ function buildPreviousContextFiltered(chapters, currentIndex, selectedIds) {
const i = chapters.indexOf(ch);
const text = stripHtml(ch.content || '');
if (!text) return `第${i + 1}章「${ch.title}」:(空)`;
if (i === currentIndex - 1) {
const excerpt = text.length > 1500 ? '...' + text.slice(-1500) : text;
return `第${i + 1}章「${ch.title}」(前一章完整回顾):\n${excerpt}`;
}
if (text.length > 300) {
return `第${i + 1}章「${ch.title}」:\n${text.slice(0, 150)}…(中间省略)…${text.slice(-100)}`;
}
return `第${i + 1}章「${ch.title}」:\n${text}`;
}).join('\n\n');
}
@@ -699,11 +674,9 @@ function getModeInstruction(mode) {
【设定集管理能力】
当用户要求你创建、修改或删除设定集条目时,你可以在回复中嵌入操作指令块。格式如下:
\`\`\`
[SETTINGS_ACTION]
{"action":"add","category":"character","name":"角色姓名","content":{"role":"主角","personality":"...","background":"..."}}
[/SETTINGS_ACTION]
\`\`\`
可用的 action: "add"(新增)、"update"(更新,需提供 nodeId)、"delete"(删除,需提供 nodeId)
@@ -720,6 +693,7 @@ function getModeInstruction(mode) {
- 每个操作块只包含一个 JSON 对象
- 如果需要多个操作,使用多个 [SETTINGS_ACTION] 块
- 操作块前后必须有正常的文字说明
- 不要用代码围栏(\`\`\`)包裹操作块,直接使用 [SETTINGS_ACTION] 标签
- update 示例:{"action":"update","nodeId":"具体id","name":"新名称","content":{...}}
- delete 示例:{"action":"delete","nodeId":"具体id"}
- 在正文中已有角色/设定出现时,如果用户要求,可以从正文分析内容并自动创建设定`;
+11 -2
View File
@@ -499,8 +499,12 @@ export async function addSettingsNode({ name, type, category, parentId, icon, co
if (node.type === 'item') {
const { apiConfig } = getProjectSettings();
if (apiConfig.useCustomEmbed) {
const textToEmbed = extractTextForEmbedding(node);
node.embedding = await getEmbedding(textToEmbed, apiConfig);
try {
const textToEmbed = extractTextForEmbedding(node);
node.embedding = await getEmbedding(textToEmbed, apiConfig);
} catch (e) {
console.warn('[Settings] Embedding failed for new node, will retry later:', e.message);
}
}
}
@@ -594,6 +598,7 @@ export async function moveSettingsNode(id, newParentId) {
}
// 重新计算所有条目的 embedding (Async)
// 每次请求间隔 500ms 以避免超出 TPM 限制
export async function rebuildAllEmbeddings(onProgress) {
const nodes = await getSettingsNodes();
const { apiConfig } = getProjectSettings();
@@ -616,6 +621,10 @@ export async function rebuildAllEmbeddings(onProgress) {
}
done++;
onProgress?.(done, items.length, failed);
// 请求间隔:避免超出 TPM / RPM 限制
if (done < items.length) {
await new Promise(r => setTimeout(r, 700));
}
}
await saveSettingsNodes(nodes);
+2 -2
View File
@@ -2,8 +2,8 @@
const nextConfig = {
output: 'standalone',
devIndicators: false,
// pdf-parse 仅在服务端 API 路由中使用,将其标记为外部包避免打包解析
serverExternalPackages: ['pdf-parse'],
// pdf-parse / word-extractor 仅在服务端 API 路由中使用,标记为外部包避免打包解析
serverExternalPackages: ['pdf-parse', 'word-extractor'],
};
export default nextConfig;
+10 -9
View File
@@ -1,13 +1,14 @@
{
"name": "author-app",
"version": "1.1.4",
"version": "1.1.10",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "author-app",
"version": "1.1.4",
"version": "1.1.10",
"dependencies": {
"@tiptap/core": "^3.20.0",
"@tiptap/extension-character-count": "^3.19.0",
"@tiptap/extension-color": "^3.19.0",
"@tiptap/extension-font-family": "^3.19.0",
@@ -2300,16 +2301,16 @@
}
},
"node_modules/@tiptap/core": {
"version": "3.19.0",
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.19.0.tgz",
"integrity": "sha512-bpqELwPW+DG8gWiD8iiFtSl4vIBooG5uVJod92Qxn3rA9nFatyXRr4kNbMJmOZ66ezUvmCjXVe/5/G4i5cyzKA==",
"version": "3.20.0",
"resolved": "https://registry.npmmirror.com/@tiptap/core/-/core-3.20.0.tgz",
"integrity": "sha512-aC9aROgia/SpJqhsXFiX9TsligL8d+oeoI8W3u00WI45s0VfsqjgeKQLDLF7Tu7hC+7F02teC84SAHuup003VQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/pm": "^3.19.0"
"@tiptap/pm": "^3.20.0"
}
},
"node_modules/@tiptap/extension-blockquote": {
@@ -2786,9 +2787,9 @@
}
},
"node_modules/@tiptap/pm": {
"version": "3.19.0",
"resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.19.0.tgz",
"integrity": "sha512-789zcnM4a8OWzvbD2DL31d0wbSm9BVeO/R7PLQwLIGysDI3qzrcclyZ8yhqOEVuvPitRRwYLq+mY14jz7kY4cw==",
"version": "3.20.0",
"resolved": "https://registry.npmmirror.com/@tiptap/pm/-/pm-3.20.0.tgz",
"integrity": "sha512-jn+2KnQZn+b+VXr8EFOJKsnjVNaA4diAEr6FOazupMt8W8ro1hfpYtZ25JL87Kao/WbMze55sd8M8BDXLUKu1A==",
"license": "MIT",
"dependencies": {
"prosemirror-changeset": "^2.3.0",
+2 -1
View File
@@ -13,6 +13,7 @@
"electron:start": "npx electron ."
},
"dependencies": {
"@tiptap/core": "^3.20.0",
"@tiptap/extension-character-count": "^3.19.0",
"@tiptap/extension-color": "^3.19.0",
"@tiptap/extension-font-family": "^3.19.0",
@@ -107,4 +108,4 @@
"createStartMenuShortcut": true
}
}
}
}