diff --git a/FEATURES.md b/FEATURES.md index 24583ce..628ca43 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -46,7 +46,7 @@ ### 工作管理、长期协作与工作流 -- [x] **任务、活动与成果**:集中管理任务状态、审计活动和成果文件;Token 用量按 OpenAI 兼容与 Anthropic Messages 的不同上报口径归一化展示缓存命中率;活动按会话分组并默认收起,避免长历史占满页面。 +- [x] **任务、活动与成果**:集中管理任务状态、审计活动和成果文件;Token 用量按 Runtime 与模型归类,并针对 OpenAI 兼容与 Anthropic Messages 的不同上报口径归一化展示缓存命中率;活动按会话分组并默认收起,避免长历史占满页面。 - [x] **记忆与智能心跳**:提供周期回顾、建议记忆、洞察、后续任务和可审计运行轨迹。 - [ ] **批量运行与对比实验室**(规划中):对模型、Prompt、角色和工作流配置执行批量对比,汇总质量、耗时、Token、费用、失败率和成果差异。 - [ ] **时态记忆与事实冲突检测**(规划中):为记忆和知识图谱增加有效期、当前事实、过期与矛盾检测、事实核验及证据回溯。 diff --git a/src/renderer/src/ActivityPanel.test.tsx b/src/renderer/src/ActivityPanel.test.tsx index eacae6f..dac866b 100644 --- a/src/renderer/src/ActivityPanel.test.tsx +++ b/src/renderer/src/ActivityPanel.test.tsx @@ -524,7 +524,7 @@ describe('ActivityPanel', () => { expect(within(stats).getByText('32%')).toBeInTheDocument() const projectRow = screen.getByRole('row', { - name: '项目甲gpt-5 · openai 100 20 10 40 40% 120' + name: '项目甲直连模型 · gpt-5 100 20 10 40 40% 120' }) expect(projectRow).toBeInTheDocument() expect( @@ -551,7 +551,36 @@ describe('ActivityPanel', () => { expect(screen.getByText('已删除会话')).toBeInTheDocument() fireEvent.click(screen.getByRole('button', { name: '按模型' })) - expect(screen.getByText('gpt-5')).toBeInTheDocument() - expect(screen.getByText('未知模型')).toBeInTheDocument() + expect(screen.getByText('直连模型 · gpt-5')).toBeInTheDocument() + expect( + screen.getByText('直连模型 · 未知模型') + ).toBeInTheDocument() + }) + + it('shows Runtime names instead of internal provider identifiers', () => { + const usage = makeTokenUsage() + usage.records.push({ + ...usage.records[0]!, + requestId: 'request-harness', + runtime: 'deepseek-harness', + provider: 'goodbuddy', + model: 'deepseek-v4-flash' + }) + + render( + + ) + fireEvent.click(screen.getByRole('tab', { name: '用量统计' })) + fireEvent.click(screen.getByRole('button', { name: '按模型' })) + + expect( + screen.getByText('DeepSeek Harness · deepseek-v4-flash') + ).toBeInTheDocument() + expect(screen.queryByText('goodbuddy')).not.toBeInTheDocument() }) }) diff --git a/src/renderer/src/ActivityPanel.tsx b/src/renderer/src/ActivityPanel.tsx index aaad481..56d159b 100644 --- a/src/renderer/src/ActivityPanel.tsx +++ b/src/renderer/src/ActivityPanel.tsx @@ -452,9 +452,18 @@ export function ActivityPanel({ const localizeTokenRow = ( row: (typeof tokenRows)[number] ): { label: string; detail?: string } => { - const missingModel = row.key.endsWith(':') - const modelProvider = - row.key.match(/model:([^:]*):$/u)?.[1] ?? '' + const modelLabel = + row.model || t('tokenUsage.fallbacks.unknownModel') + const runtimeLabel = + row.runtime === 'model' + ? t('tokenUsage.runtimes.model') + : row.runtime === 'opencode' + ? t('tokenUsage.runtimes.opencode') + : row.runtime === 'continue' + ? t('tokenUsage.runtimes.continue') + : row.runtime === 'deepseek-harness' + ? t('tokenUsage.runtimes.deepseekHarness') + : row.runtime || t('tokenUsage.fallbacks.unknownRuntime') const label = tokenGroup === 'project' && row.key.startsWith('project:unassigned:') @@ -462,18 +471,13 @@ export function ActivityPanel({ : tokenGroup === 'conversation' && row.key.startsWith('conversation:deleted:') ? t('tokenUsage.fallbacks.deletedConversation') - : tokenGroup === 'model' && missingModel - ? t('tokenUsage.fallbacks.unknownModel') + : tokenGroup === 'model' + ? `${runtimeLabel} · ${modelLabel}` : row.label const detail = - missingModel && tokenGroup !== 'model' - ? [ - t('tokenUsage.fallbacks.unknownModel'), - modelProvider - ] - .filter(Boolean) - .join(' · ') - : row.detail + tokenGroup === 'model' + ? undefined + : `${runtimeLabel} · ${modelLabel}` return { label, detail } } const renderRecordCard = ( diff --git a/src/renderer/src/i18n/locales/en-US/activity.ts b/src/renderer/src/i18n/locales/en-US/activity.ts index 1f1fc6c..cc7bd1a 100644 --- a/src/renderer/src/i18n/locales/en-US/activity.ts +++ b/src/renderer/src/i18n/locales/en-US/activity.ts @@ -53,6 +53,12 @@ export const activity = { conversation: 'By conversation', model: 'By model' }, + runtimes: { + model: 'Direct model', + opencode: 'OpenCode', + continue: 'Continue', + deepseekHarness: 'DeepSeek Harness' + }, columns: { project: 'Project', conversation: 'Conversation', @@ -69,7 +75,8 @@ export const activity = { fallbacks: { unassignedProject: 'Unassigned project', deletedConversation: 'Deleted conversation', - unknownModel: 'Unknown model' + unknownModel: 'Unknown model', + unknownRuntime: 'Unknown Runtime' } }, stats: { diff --git a/src/renderer/src/i18n/locales/zh-CN/activity.ts b/src/renderer/src/i18n/locales/zh-CN/activity.ts index a6fbbd5..d9a92e8 100644 --- a/src/renderer/src/i18n/locales/zh-CN/activity.ts +++ b/src/renderer/src/i18n/locales/zh-CN/activity.ts @@ -50,6 +50,12 @@ export const activity = { conversation: '按会话', model: '按模型' }, + runtimes: { + model: '直连模型', + opencode: 'OpenCode', + continue: 'Continue', + deepseekHarness: 'DeepSeek Harness' + }, columns: { project: '项目', conversation: '会话', @@ -66,7 +72,8 @@ export const activity = { fallbacks: { unassignedProject: '未归属项目', deletedConversation: '已删除会话', - unknownModel: '未知模型' + unknownModel: '未知模型', + unknownRuntime: '未知 Runtime' } }, stats: { diff --git a/src/renderer/src/token-usage.test.ts b/src/renderer/src/token-usage.test.ts index 04e703f..a92af1b 100644 --- a/src/renderer/src/token-usage.test.ts +++ b/src/renderer/src/token-usage.test.ts @@ -67,9 +67,10 @@ describe('token usage aggregation', () => { }) expect(groupTokenUsage(usage, 'project')).toEqual([ { - key: 'project:project-1:model:openai:gpt-5', + key: 'project:project-1:runtime:model:model:gpt-5', label: '项目一', - detail: 'gpt-5 · openai', + model: 'gpt-5', + runtime: 'model', inputTokens: 112, outputTokens: 23, cacheReadTokens: 40, @@ -83,7 +84,7 @@ describe('token usage aggregation', () => { expect(groupTokenUsage(usage, 'model')).toEqual([ expect.objectContaining({ label: 'gpt-5', - detail: 'openai', + runtime: 'model', totalTokens: 135 }) ]) @@ -156,4 +157,35 @@ describe('token usage aggregation', () => { expect(groupTokenUsage(usage, 'project')).toHaveLength(2) expect(groupTokenUsage(usage, 'conversation')).toHaveLength(3) }) + + it('groups identical model names by Runtime instead of provider', () => { + const usage = makeTokenUsage() + usage.records = [ + { + ...usage.records[0]!, + provider: 'openai', + runtime: 'model', + model: 'deepseek-v4-flash' + }, + { + ...usage.records[1]!, + provider: 'goodbuddy', + runtime: 'deepseek-harness', + model: 'deepseek-v4-flash' + } + ] + + expect(groupTokenUsage(usage, 'model')).toEqual([ + expect.objectContaining({ + key: 'runtime:model:model:deepseek-v4-flash', + label: 'deepseek-v4-flash', + runtime: 'model' + }), + expect.objectContaining({ + key: 'runtime:deepseek-harness:model:deepseek-v4-flash', + label: 'deepseek-v4-flash', + runtime: 'deepseek-harness' + }) + ]) + }) }) diff --git a/src/renderer/src/token-usage.ts b/src/renderer/src/token-usage.ts index 74d7cc4..706ea77 100644 --- a/src/renderer/src/token-usage.ts +++ b/src/renderer/src/token-usage.ts @@ -15,7 +15,8 @@ export type TokenUsageTotals = { export type TokenUsageGroupRow = TokenUsageTotals & { key: string label: string - detail?: string + model: string + runtime: string } type TokenUsageRecord = TokenUsageSummary['records'][number] @@ -69,25 +70,20 @@ function usageNumbers( function groupIdentity( record: TokenUsageRecord, group: TokenUsageGroup -): Pick { +): Pick { const model = record.model.trim() - const provider = record.provider.trim() - const modelKey = `${provider}:${model}` - const modelLabel = model - const modelDetail = provider - ? modelLabel - ? `${modelLabel} · ${provider}` - : provider - : modelLabel + const runtime = record.runtime.trim() + const runtimeModelKey = `runtime:${runtime}:model:${model}` if (group === 'project') { const projectKey = record.projectId ? `project:${record.projectId}` : 'project:unassigned' return { - key: `${projectKey}:model:${modelKey}`, + key: `${projectKey}:${runtimeModelKey}`, label: record.projectName?.trim() || '', - detail: modelDetail + model, + runtime } } @@ -96,16 +92,18 @@ function groupIdentity( ? `conversation:${record.conversationId}` : 'conversation:deleted' return { - key: `${conversationKey}:model:${modelKey}`, + key: `${conversationKey}:${runtimeModelKey}`, label: record.conversationTitle?.trim() || '', - detail: modelDetail + model, + runtime } } return { - key: `model:${modelKey}`, - label: modelLabel, - detail: provider || undefined + key: runtimeModelKey, + label: model, + model, + runtime } }