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
}
}