feat: separate system task token usage and add usage refresh
Add a System tasks tab to run history that splits background usage (supervisor, auto-supervision, magic notes, knowledge base, delegation) from conversation usage, with a manual refresh button and automatic refresh when usage is recorded. Record previously dropped usage from supervisor reviews, image regeneration, knowledge graph extraction, and remote embeddings.
This commit is contained in:
@@ -115,7 +115,10 @@ export class ImageGenerationService {
|
|||||||
|
|
||||||
regenerate(context: ImageRequestContext, operationId: string, currentWorkMode?: () => 'ask' | 'execute'): Promise<ImageOperation> {
|
regenerate(context: ImageRequestContext, operationId: string, currentWorkMode?: () => 'ask' | 'execute'): Promise<ImageOperation> {
|
||||||
const previous = this.getOperation(context.conversationId, operationId)
|
const previous = this.getOperation(context.conversationId, operationId)
|
||||||
return this.bind(context, currentWorkMode).call({ ...previous.input, modelProfileId: previous.modelProfileId }, randomUUID())
|
// Keep the originating request so usage stays attached to its task row;
|
||||||
|
// the fresh call ID keeps the regenerated operation distinct.
|
||||||
|
return this.bind({ ...context, requestId: previous.requestId }, currentWorkMode)
|
||||||
|
.call({ ...previous.input, modelProfileId: previous.modelProfileId }, randomUUID())
|
||||||
}
|
}
|
||||||
|
|
||||||
cancelConversation(conversationId: string): void {
|
cancelConversation(conversationId: string): void {
|
||||||
|
|||||||
@@ -6900,16 +6900,19 @@ describe('AssistantDatabase', () => {
|
|||||||
cacheWrite: 12
|
cacheWrite: 12
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const usageTotals = {
|
||||||
|
callCount: 1,
|
||||||
|
input: 125,
|
||||||
|
output: 25,
|
||||||
|
cacheRead: 40,
|
||||||
|
cacheWrite: 12,
|
||||||
|
cacheInput: 177,
|
||||||
|
totalTokens: 150
|
||||||
|
}
|
||||||
expect(database.getTokenUsageSummary()).toEqual({
|
expect(database.getTokenUsageSummary()).toEqual({
|
||||||
totals: {
|
totals: usageTotals,
|
||||||
callCount: 1,
|
conversationTotals: usageTotals,
|
||||||
input: 125,
|
systemTotals: expect.objectContaining({ callCount: 0, totalTokens: 0 }),
|
||||||
output: 25,
|
|
||||||
cacheRead: 40,
|
|
||||||
cacheWrite: 12,
|
|
||||||
cacheInput: 177,
|
|
||||||
totalTokens: 150
|
|
||||||
},
|
|
||||||
records: [
|
records: [
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
requestId: taskId,
|
requestId: taskId,
|
||||||
@@ -7525,18 +7528,54 @@ describe('AssistantDatabase', () => {
|
|||||||
expect(database.listTasks()).toEqual([])
|
expect(database.listTasks()).toEqual([])
|
||||||
expect(database.listArtifacts(project.id)).toEqual([])
|
expect(database.listArtifacts(project.id)).toEqual([])
|
||||||
expect(database.listMagicNotes()).toEqual([])
|
expect(database.listMagicNotes()).toEqual([])
|
||||||
|
const emptyTotals = {
|
||||||
|
callCount: 0,
|
||||||
|
input: 0,
|
||||||
|
output: 0,
|
||||||
|
cacheRead: 0,
|
||||||
|
cacheWrite: 0,
|
||||||
|
cacheInput: 0,
|
||||||
|
totalTokens: 0
|
||||||
|
}
|
||||||
expect(database.getTokenUsageSummary()).toEqual({
|
expect(database.getTokenUsageSummary()).toEqual({
|
||||||
totals: {
|
totals: emptyTotals,
|
||||||
callCount: 0,
|
conversationTotals: emptyTotals,
|
||||||
input: 0,
|
systemTotals: emptyTotals,
|
||||||
output: 0,
|
|
||||||
cacheRead: 0,
|
|
||||||
cacheWrite: 0,
|
|
||||||
cacheInput: 0,
|
|
||||||
totalTokens: 0
|
|
||||||
},
|
|
||||||
records: []
|
records: []
|
||||||
})
|
})
|
||||||
database.close()
|
database.close()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('separates system task usage from conversation usage', async () => {
|
||||||
|
const database = await createDatabase()
|
||||||
|
const usage = { runtime: 'model', provider: 'openai', model: 'gpt', input: 10, output: 5, cacheRead: 0, cacheWrite: 0 }
|
||||||
|
const conversationTask = '00000000-0000-4000-8000-000000000401'
|
||||||
|
database.createTask({ id: conversationTask, conversationId: 'chat-1', title: 'Chat', instructions: 'Reply', workMode: 'ask' })
|
||||||
|
database.upsertModelUsageCall({ ...usage, requestId: conversationTask, callId: 'chat' })
|
||||||
|
const systemTasks = [
|
||||||
|
['00000000-0000-4000-8000-000000000402', 'heartbeat:a', 'heartbeat'],
|
||||||
|
['00000000-0000-4000-8000-000000000403', 'supervision:b', 'supervision'],
|
||||||
|
['00000000-0000-4000-8000-000000000404', undefined, 'magic-notes']
|
||||||
|
] as const
|
||||||
|
for (const [id, conversationId] of systemTasks) {
|
||||||
|
database.createTask({ id, conversationId, title: 'System', instructions: 'Run', workMode: 'ask', origin: 'assistant', visible: false })
|
||||||
|
database.upsertModelUsageCall({ ...usage, requestId: id, callId: 'system' })
|
||||||
|
}
|
||||||
|
database.recordSystemModelUsage({ ...usage, runtime: 'embedding', source: 'knowledge', bucket: 'embedding', output: 0 })
|
||||||
|
database.recordSystemModelUsage({ ...usage, runtime: 'embedding', source: 'knowledge', bucket: 'embedding', output: 0 })
|
||||||
|
|
||||||
|
const summary = database.getTokenUsageSummary()
|
||||||
|
expect(summary.conversationTotals).toMatchObject({ callCount: 1, input: 10, output: 5 })
|
||||||
|
expect(summary.systemTotals).toMatchObject({ callCount: 5, input: 50, output: 15 })
|
||||||
|
expect(summary.totals).toMatchObject({ callCount: 6, input: 60, output: 20 })
|
||||||
|
expect(summary.records.find(record => record.requestId === conversationTask)?.systemSource).toBeUndefined()
|
||||||
|
for (const [id, , source] of systemTasks) {
|
||||||
|
expect(summary.records.find(record => record.requestId === id)?.systemSource).toBe(source)
|
||||||
|
}
|
||||||
|
const knowledge = summary.records.filter(record => record.systemSource === 'knowledge')
|
||||||
|
expect(knowledge).toHaveLength(1)
|
||||||
|
expect(knowledge[0]).toMatchObject({ callCount: 2, input: 20, output: 0 })
|
||||||
|
expect(database.listTasks().map(task => task.id)).toEqual([conversationTask])
|
||||||
|
database.close()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -58,9 +58,13 @@ import type {
|
|||||||
ProjectChannel,
|
ProjectChannel,
|
||||||
ProjectCreateInput,
|
ProjectCreateInput,
|
||||||
ScheduleCreateInput,
|
ScheduleCreateInput,
|
||||||
|
SystemModelUsageInput,
|
||||||
TokenUsageRecord,
|
TokenUsageRecord,
|
||||||
TokenUsageSummary
|
TokenUsageSummary,
|
||||||
|
TokenUsageSystemSource,
|
||||||
|
TokenUsageTotals
|
||||||
} from '../../shared/assistant-contracts'
|
} from '../../shared/assistant-contracts'
|
||||||
|
import { tokenUsageSystemSources } from '../../shared/assistant-contracts'
|
||||||
import type { StoredSupervisionResult, SupervisionCandidate } from './supervisor-service'
|
import type { StoredSupervisionResult, SupervisionCandidate } from './supervisor-service'
|
||||||
import { supervisionEntitySchema, supervisionResultViewSchema, type SupervisionTarget, type SupervisionGraphRequest, type SupervisionRunRequest, type SupervisionActivity } from '../../shared/supervision-contracts'
|
import { supervisionEntitySchema, supervisionResultViewSchema, type SupervisionTarget, type SupervisionGraphRequest, type SupervisionRunRequest, type SupervisionActivity } from '../../shared/supervision-contracts'
|
||||||
import type { AgentEvent } from '../../shared/contracts'
|
import type { AgentEvent } from '../../shared/contracts'
|
||||||
@@ -466,6 +470,7 @@ type TokenUsageRecordRow = {
|
|||||||
project_name: string | null
|
project_name: string | null
|
||||||
conversation_id: string | null
|
conversation_id: string | null
|
||||||
conversation_title: string | null
|
conversation_title: string | null
|
||||||
|
system_source: string | null
|
||||||
runtime: string
|
runtime: string
|
||||||
provider: string
|
provider: string
|
||||||
model: string
|
model: string
|
||||||
@@ -1824,6 +1829,7 @@ export class AssistantDatabase {
|
|||||||
private subagentProgress?: SubagentProgressStorage
|
private subagentProgress?: SubagentProgressStorage
|
||||||
private channelEventWrites = 0
|
private channelEventWrites = 0
|
||||||
private channelOutboxWrites = 0
|
private channelOutboxWrites = 0
|
||||||
|
private modelUsageNotificationQueued = false
|
||||||
private readonly executionStatsCache = new Map<
|
private readonly executionStatsCache = new Map<
|
||||||
string,
|
string,
|
||||||
{ value: ExecutionStats; cachedAt: number; dataVersion: number; totalChanges: number }
|
{ value: ExecutionStats; cachedAt: number; dataVersion: number; totalChanges: number }
|
||||||
@@ -1834,6 +1840,7 @@ export class AssistantDatabase {
|
|||||||
private readonly options: {
|
private readonly options: {
|
||||||
onMagicNotesChanged?: () => void
|
onMagicNotesChanged?: () => void
|
||||||
onMagicTodosChanged?: () => void
|
onMagicTodosChanged?: () => void
|
||||||
|
onModelUsageChanged?: () => void
|
||||||
} = {}
|
} = {}
|
||||||
) {
|
) {
|
||||||
this.noteStorage = new MagicNoteStorage(databasePath)
|
this.noteStorage = new MagicNoteStorage(databasePath)
|
||||||
@@ -5577,53 +5584,121 @@ export class AssistantDatabase {
|
|||||||
now,
|
now,
|
||||||
now
|
now
|
||||||
)
|
)
|
||||||
|
this.notifyModelUsageChanged()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Records usage for background work that has no request of its own, such as
|
||||||
|
* knowledge graph extraction or remote embeddings. Calls are grouped into one
|
||||||
|
* hidden task per source, bucket and UTC day so the foreign key to tasks holds.
|
||||||
|
*/
|
||||||
|
recordSystemModelUsage(input: SystemModelUsageInput): void {
|
||||||
|
const day = new Date().toISOString().slice(0, 10)
|
||||||
|
const taskId = `${input.source}:${input.bucket}:${day}`
|
||||||
|
const now = new Date().toISOString()
|
||||||
|
this.requireDatabase()
|
||||||
|
.prepare(
|
||||||
|
`INSERT OR IGNORE INTO tasks
|
||||||
|
(id, project_id, conversation_id, title, instructions, origin,
|
||||||
|
status, priority, work_mode, created_at, started_at, completed_at,
|
||||||
|
visible)
|
||||||
|
VALUES (?, NULL, ?, ?, ?, 'assistant', 'completed', 0, 'ask', ?, ?, ?, 0)`
|
||||||
|
)
|
||||||
|
.run(
|
||||||
|
taskId,
|
||||||
|
taskId,
|
||||||
|
input.bucket === 'embedding' ? '知识库向量化' : '知识图谱抽取',
|
||||||
|
'后台知识库模型用量汇总',
|
||||||
|
now,
|
||||||
|
now,
|
||||||
|
now
|
||||||
|
)
|
||||||
|
this.upsertModelUsageCall({
|
||||||
|
...input,
|
||||||
|
requestId: taskId,
|
||||||
|
callId: `system-call:${randomUUID()}`
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private notifyModelUsageChanged(): void {
|
||||||
|
if (this.modelUsageNotificationQueued || !this.options.onModelUsageChanged) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.modelUsageNotificationQueued = true
|
||||||
|
// Coalesce bursts of per-call upserts into one renderer refresh.
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
this.modelUsageNotificationQueued = false
|
||||||
|
this.options.onModelUsageChanged?.()
|
||||||
|
}, 500)
|
||||||
|
timer.unref?.()
|
||||||
}
|
}
|
||||||
|
|
||||||
getTokenUsageSummary(): TokenUsageSummary {
|
getTokenUsageSummary(): TokenUsageSummary {
|
||||||
const rows = this.requireDatabase()
|
const systemSourceSql = `CASE
|
||||||
.prepare(
|
WHEN tasks.conversation_id LIKE 'heartbeat:%' THEN 'heartbeat'
|
||||||
`SELECT
|
WHEN tasks.conversation_id LIKE 'supervision:%' THEN 'supervision'
|
||||||
usage.request_id,
|
WHEN tasks.conversation_id LIKE 'knowledge:%' THEN 'knowledge'
|
||||||
tasks.project_id,
|
WHEN tasks.conversation_id LIKE 'delegation:%' THEN 'delegation'
|
||||||
projects.name AS project_name,
|
WHEN tasks.origin = 'assistant' AND tasks.conversation_id IS NULL
|
||||||
tasks.conversation_id,
|
THEN 'magic-notes'
|
||||||
conversations.title AS conversation_title,
|
END`
|
||||||
usage.runtime,
|
const database = this.requireDatabase()
|
||||||
usage.provider,
|
// Conversation and system rows are limited separately so frequent chat
|
||||||
usage.model,
|
// usage cannot hide background usage (or the reverse).
|
||||||
COUNT(*) AS call_count,
|
const selectRows = (system: boolean): TokenUsageRecordRow[] =>
|
||||||
SUM(usage.input_tokens) AS input_tokens,
|
database
|
||||||
SUM(usage.output_tokens) AS output_tokens,
|
.prepare(
|
||||||
SUM(usage.cache_read_tokens) AS cache_read_tokens,
|
`SELECT
|
||||||
SUM(usage.cache_write_tokens) AS cache_write_tokens,
|
usage.request_id,
|
||||||
SUM(
|
tasks.project_id,
|
||||||
usage.input_tokens +
|
projects.name AS project_name,
|
||||||
CASE
|
tasks.conversation_id,
|
||||||
WHEN LOWER(usage.provider) LIKE '%anthropic%'
|
conversations.title AS conversation_title,
|
||||||
THEN usage.cache_read_tokens + usage.cache_write_tokens
|
${systemSourceSql} AS system_source,
|
||||||
ELSE 0
|
usage.runtime,
|
||||||
END
|
usage.provider,
|
||||||
) AS cache_input_tokens
|
usage.model,
|
||||||
FROM model_usage_calls usage
|
COUNT(*) AS call_count,
|
||||||
JOIN tasks ON tasks.id = usage.request_id
|
SUM(usage.input_tokens) AS input_tokens,
|
||||||
LEFT JOIN projects ON projects.id = tasks.project_id
|
SUM(usage.output_tokens) AS output_tokens,
|
||||||
LEFT JOIN conversations
|
SUM(usage.cache_read_tokens) AS cache_read_tokens,
|
||||||
ON conversations.id = tasks.conversation_id
|
SUM(usage.cache_write_tokens) AS cache_write_tokens,
|
||||||
GROUP BY
|
SUM(
|
||||||
usage.request_id,
|
usage.input_tokens +
|
||||||
tasks.project_id,
|
CASE
|
||||||
projects.name,
|
WHEN LOWER(usage.provider) LIKE '%anthropic%'
|
||||||
tasks.conversation_id,
|
THEN usage.cache_read_tokens + usage.cache_write_tokens
|
||||||
conversations.title,
|
ELSE 0
|
||||||
usage.runtime,
|
END
|
||||||
usage.provider,
|
) AS cache_input_tokens
|
||||||
usage.model
|
FROM model_usage_calls usage
|
||||||
ORDER BY MAX(usage.updated_at) DESC
|
JOIN tasks ON tasks.id = usage.request_id
|
||||||
LIMIT 500`
|
LEFT JOIN projects ON projects.id = tasks.project_id
|
||||||
)
|
LEFT JOIN conversations
|
||||||
.all() as TokenUsageRecordRow[]
|
ON conversations.id = tasks.conversation_id
|
||||||
const records: TokenUsageRecord[] = rows.map((row) => ({
|
WHERE (${systemSourceSql}) IS ${system ? 'NOT ' : ''}NULL
|
||||||
|
GROUP BY
|
||||||
|
usage.request_id,
|
||||||
|
tasks.project_id,
|
||||||
|
projects.name,
|
||||||
|
tasks.conversation_id,
|
||||||
|
conversations.title,
|
||||||
|
usage.runtime,
|
||||||
|
usage.provider,
|
||||||
|
usage.model
|
||||||
|
ORDER BY MAX(usage.updated_at) DESC
|
||||||
|
LIMIT 500`
|
||||||
|
)
|
||||||
|
.all() as TokenUsageRecordRow[]
|
||||||
|
const knownSources = new Set<string>(tokenUsageSystemSources)
|
||||||
|
const records: TokenUsageRecord[] = [
|
||||||
|
...selectRows(false),
|
||||||
|
...selectRows(true)
|
||||||
|
].map((row) => ({
|
||||||
requestId: row.request_id,
|
requestId: row.request_id,
|
||||||
|
...(row.system_source && knownSources.has(row.system_source)
|
||||||
|
? { systemSource: row.system_source as TokenUsageSystemSource }
|
||||||
|
: {}),
|
||||||
projectId: row.project_id ?? undefined,
|
projectId: row.project_id ?? undefined,
|
||||||
projectName: row.project_name ?? undefined,
|
projectName: row.project_name ?? undefined,
|
||||||
conversationId: row.conversation_id ?? undefined,
|
conversationId: row.conversation_id ?? undefined,
|
||||||
@@ -5639,28 +5714,8 @@ export class AssistantDatabase {
|
|||||||
cacheInput: row.cache_input_tokens,
|
cacheInput: row.cache_input_tokens,
|
||||||
totalTokens: row.input_tokens + row.output_tokens
|
totalTokens: row.input_tokens + row.output_tokens
|
||||||
}))
|
}))
|
||||||
const totalRow = this.requireDatabase()
|
type TotalRow = {
|
||||||
.prepare(
|
is_system: number | null
|
||||||
`SELECT
|
|
||||||
COUNT(*) AS call_count,
|
|
||||||
COALESCE(SUM(input_tokens), 0) AS input_tokens,
|
|
||||||
COALESCE(SUM(output_tokens), 0) AS output_tokens,
|
|
||||||
COALESCE(SUM(cache_read_tokens), 0) AS cache_read_tokens,
|
|
||||||
COALESCE(SUM(cache_write_tokens), 0) AS cache_write_tokens,
|
|
||||||
COALESCE(
|
|
||||||
SUM(
|
|
||||||
input_tokens +
|
|
||||||
CASE
|
|
||||||
WHEN LOWER(provider) LIKE '%anthropic%'
|
|
||||||
THEN cache_read_tokens + cache_write_tokens
|
|
||||||
ELSE 0
|
|
||||||
END
|
|
||||||
),
|
|
||||||
0
|
|
||||||
) AS cache_input_tokens
|
|
||||||
FROM model_usage_calls`
|
|
||||||
)
|
|
||||||
.get() as {
|
|
||||||
call_count: number
|
call_count: number
|
||||||
input_tokens: number
|
input_tokens: number
|
||||||
output_tokens: number
|
output_tokens: number
|
||||||
@@ -5668,16 +5723,51 @@ export class AssistantDatabase {
|
|||||||
cache_write_tokens: number
|
cache_write_tokens: number
|
||||||
cache_input_tokens: number
|
cache_input_tokens: number
|
||||||
}
|
}
|
||||||
const totals = {
|
// Usage rows always reference a task (foreign key), so the join is lossless.
|
||||||
callCount: totalRow.call_count,
|
const totalRows = database
|
||||||
input: totalRow.input_tokens,
|
.prepare(
|
||||||
output: totalRow.output_tokens,
|
`SELECT
|
||||||
cacheRead: totalRow.cache_read_tokens,
|
(${systemSourceSql}) IS NOT NULL AS is_system,
|
||||||
cacheWrite: totalRow.cache_write_tokens,
|
COUNT(*) AS call_count,
|
||||||
cacheInput: totalRow.cache_input_tokens,
|
COALESCE(SUM(usage.input_tokens), 0) AS input_tokens,
|
||||||
totalTokens: totalRow.input_tokens + totalRow.output_tokens
|
COALESCE(SUM(usage.output_tokens), 0) AS output_tokens,
|
||||||
|
COALESCE(SUM(usage.cache_read_tokens), 0) AS cache_read_tokens,
|
||||||
|
COALESCE(SUM(usage.cache_write_tokens), 0) AS cache_write_tokens,
|
||||||
|
COALESCE(
|
||||||
|
SUM(
|
||||||
|
usage.input_tokens +
|
||||||
|
CASE
|
||||||
|
WHEN LOWER(usage.provider) LIKE '%anthropic%'
|
||||||
|
THEN usage.cache_read_tokens + usage.cache_write_tokens
|
||||||
|
ELSE 0
|
||||||
|
END
|
||||||
|
),
|
||||||
|
0
|
||||||
|
) AS cache_input_tokens
|
||||||
|
FROM model_usage_calls usage
|
||||||
|
LEFT JOIN tasks ON tasks.id = usage.request_id
|
||||||
|
GROUP BY is_system`
|
||||||
|
)
|
||||||
|
.all() as TotalRow[]
|
||||||
|
const toTotals = (rows: TotalRow[]): TokenUsageTotals => {
|
||||||
|
const sum = (key: Exclude<keyof TotalRow, 'is_system'>): number =>
|
||||||
|
rows.reduce((total, row) => total + row[key], 0)
|
||||||
|
return {
|
||||||
|
callCount: sum('call_count'),
|
||||||
|
input: sum('input_tokens'),
|
||||||
|
output: sum('output_tokens'),
|
||||||
|
cacheRead: sum('cache_read_tokens'),
|
||||||
|
cacheWrite: sum('cache_write_tokens'),
|
||||||
|
cacheInput: sum('cache_input_tokens'),
|
||||||
|
totalTokens: sum('input_tokens') + sum('output_tokens')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
totals: toTotals(totalRows),
|
||||||
|
conversationTotals: toTotals(totalRows.filter((row) => !row.is_system)),
|
||||||
|
systemTotals: toTotals(totalRows.filter((row) => row.is_system)),
|
||||||
|
records
|
||||||
}
|
}
|
||||||
return { totals, records }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
updateTaskStatus(
|
updateTaskStatus(
|
||||||
|
|||||||
@@ -131,6 +131,24 @@ function fixture(content = 'Atlas and Beacon are separate projects.') {
|
|||||||
return { db, request, respond, service }
|
return { db, request, respond, service }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
it('records supervisor model usage under a hidden supervision task', async () => {
|
||||||
|
const f = fixture()
|
||||||
|
const run: AgentRuntime['run'] = async function* (input) {
|
||||||
|
yield { type: 'text', requestId: input.requestId, delta: JSON.stringify(f.respond(input.prompt)) }
|
||||||
|
yield { type: 'model-usage', requestId: input.requestId, callId: 'call-1', runtime: 'model', provider: 'openai',
|
||||||
|
model: 'gpt-review', inputTokens: 40, outputTokens: 8, cacheReadTokens: 0, cacheWriteTokens: 0 }
|
||||||
|
yield { type: 'done', requestId: input.requestId }
|
||||||
|
}
|
||||||
|
const service = createProductionSupervisorService(f.db, async () => ({ supervisorModelConcurrency: 1,
|
||||||
|
supervisionReview: { pageSize: 10, batchCharacters: 1000, batchMessages: 10, executionSeconds: 300, responseKiB: 1024 } }),
|
||||||
|
async () => ({ runtimeId: 'model', capability: 'chat', run } as AgentRuntime), new SupervisionModelPool())
|
||||||
|
await service.run(f.request)
|
||||||
|
const summary = f.db.getTokenUsageSummary()
|
||||||
|
expect(summary.records).toEqual([expect.objectContaining({ systemSource: 'supervision', model: 'gpt-review', input: 40, output: 8 })])
|
||||||
|
expect(summary.systemTotals).toMatchObject({ callCount: 1, input: 40 })
|
||||||
|
expect(f.db.listTasks()).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
it('production factory normalizes two absent identities, then reuses exact candidate UUIDs without extra calls', async () => {
|
it('production factory normalizes two absent identities, then reuses exact candidate UUIDs without extra calls', async () => {
|
||||||
const f = fixture()
|
const f = fixture()
|
||||||
const first = await f.service().run(f.request)
|
const first = await f.service().run(f.request)
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { randomUUID } from 'node:crypto'
|
|||||||
import type { ApplicationSettings } from '../../shared/application-settings-contracts'
|
import type { ApplicationSettings } from '../../shared/application-settings-contracts'
|
||||||
import { defaultSupervisionTimeoutSeconds, defaultSupervisorModelConcurrency } from '../../shared/application-settings-contracts'
|
import { defaultSupervisionTimeoutSeconds, defaultSupervisorModelConcurrency } from '../../shared/application-settings-contracts'
|
||||||
import { supervisionReviewSettingsSchema } from '../../shared/supervision-review-contracts'
|
import { supervisionReviewSettingsSchema } from '../../shared/supervision-review-contracts'
|
||||||
import type { AgentRuntime } from '../agent/runtime'
|
import type { AgentRuntime, RuntimeModelUsageEvent } from '../agent/runtime'
|
||||||
import type { AssistantDatabase } from './assistant-database'
|
import type { AssistantDatabase } from './assistant-database'
|
||||||
import type { SupervisionModelPool } from './supervision-model-pool'
|
import type { SupervisionModelPool } from './supervision-model-pool'
|
||||||
import { SupervisorService } from './supervisor-service'
|
import { SupervisorService } from './supervisor-service'
|
||||||
@@ -11,7 +11,11 @@ export function createProductionSupervisorService(
|
|||||||
database: AssistantDatabase,
|
database: AssistantDatabase,
|
||||||
getSettings: () => Promise<Partial<ApplicationSettings> | undefined>,
|
getSettings: () => Promise<Partial<ApplicationSettings> | undefined>,
|
||||||
resolveRuntime: () => Promise<AgentRuntime>,
|
resolveRuntime: () => Promise<AgentRuntime>,
|
||||||
pool: SupervisionModelPool
|
pool: SupervisionModelPool,
|
||||||
|
persistUsage: (event: RuntimeModelUsageEvent) => void = event => database.upsertModelUsageCall({
|
||||||
|
requestId: event.requestId, callId: event.callId, runtime: event.runtime, provider: event.provider, model: event.model,
|
||||||
|
input: event.inputTokens, output: event.outputTokens, cacheRead: event.cacheReadTokens, cacheWrite: event.cacheWriteTokens
|
||||||
|
})
|
||||||
): SupervisorService {
|
): SupervisorService {
|
||||||
return new SupervisorService({ collect: async () => { throw new Error('Paged review collector required') } }, {
|
return new SupervisorService({ collect: async () => { throw new Error('Paged review collector required') } }, {
|
||||||
summarize: async request => {
|
summarize: async request => {
|
||||||
@@ -30,9 +34,13 @@ export function createProductionSupervisorService(
|
|||||||
}, timeoutSeconds * 1000)
|
}, timeoutSeconds * 1000)
|
||||||
const responseKiB = settings?.supervisionReview?.responseKiB ?? 1024
|
const responseKiB = settings?.supervisionReview?.responseKiB ?? 1024
|
||||||
let output = '', completed = false
|
let output = '', completed = false
|
||||||
const conversationId = `supervision:${randomUUID()}`
|
const requestId = randomUUID()
|
||||||
|
const conversationId = `supervision:${requestId}`
|
||||||
|
// Hidden task row so model usage can be attributed to supervision.
|
||||||
|
database.createTask({ id: requestId, conversationId, title: '监督者回顾', instructions: '根据有界证据整理监督者回顾',
|
||||||
|
workMode: 'ask', origin: 'assistant', visible: false })
|
||||||
try {
|
try {
|
||||||
for await (const event of runtime.run({ requestId: randomUUID(), conversationId, workMode: 'ask',
|
for await (const event of runtime.run({ requestId, conversationId, workMode: 'ask',
|
||||||
prompt: [request.systemInstruction, 'OUTPUT CONTRACT:', request.outputContract,
|
prompt: [request.systemInstruction, 'OUTPUT CONTRACT:', request.outputContract,
|
||||||
'REVIEW TIME RANGE:', JSON.stringify(request.request.timeRange), 'KNOWN ENTITIES:', JSON.stringify(request.candidates.map((candidate, index) => ({
|
'REVIEW TIME RANGE:', JSON.stringify(request.request.timeRange), 'KNOWN ENTITIES:', JSON.stringify(request.candidates.map((candidate, index) => ({
|
||||||
candidateRef: `known_${index + 1}`, label: candidate.label, description: candidate.description
|
candidateRef: `known_${index + 1}`, label: candidate.label, description: candidate.description
|
||||||
@@ -48,14 +56,23 @@ export function createProductionSupervisorService(
|
|||||||
modelSignal.throwIfAborted()
|
modelSignal.throwIfAborted()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (event.type === 'model-usage') persistUsage(event)
|
||||||
if (event.type === 'tool') throw new Error('监督者只允许只读模型摘要,不允许工具调用')
|
if (event.type === 'tool') throw new Error('监督者只允许只读模型摘要,不允许工具调用')
|
||||||
if (event.type === 'error') throw new Error(event.message)
|
if (event.type === 'error') throw new Error(event.message)
|
||||||
if (event.type === 'done') completed = true
|
if (event.type === 'done') completed = true
|
||||||
}
|
}
|
||||||
modelSignal.throwIfAborted()
|
modelSignal.throwIfAborted()
|
||||||
if (!completed) throw new Error('监督者模型未报告完成')
|
if (!completed) throw new Error('监督者模型未报告完成')
|
||||||
|
database.updateTaskStatus(requestId, 'completed')
|
||||||
return output
|
return output
|
||||||
} catch (error) { throw timeoutError ?? error }
|
} catch (error) {
|
||||||
|
const failure = timeoutError ?? error
|
||||||
|
try {
|
||||||
|
database.updateTaskStatus(requestId, modelSignal.aborted ? 'cancelled' : 'failed',
|
||||||
|
failure instanceof Error ? failure.message.slice(0, 2_000) : '监督者模型调用失败')
|
||||||
|
} catch { /* status bookkeeping must not mask the model failure */ }
|
||||||
|
throw failure
|
||||||
|
}
|
||||||
finally { clearTimeout(timeout); await runtime.releaseConversation?.(conversationId) }
|
finally { clearTimeout(timeout); await runtime.releaseConversation?.(conversationId) }
|
||||||
}, request.signal)
|
}, request.signal)
|
||||||
}
|
}
|
||||||
|
|||||||
+53
-2
@@ -86,6 +86,7 @@ import {
|
|||||||
import { FeedbackService } from './feedback/feedback-service'
|
import { FeedbackService } from './feedback/feedback-service'
|
||||||
import { registerFeedbackIpcHandler } from './feedback/feedback-ipc'
|
import { registerFeedbackIpcHandler } from './feedback/feedback-ipc'
|
||||||
import type { AgentRuntimeSelection } from '../shared/runtime-selection-contracts'
|
import type { AgentRuntimeSelection } from '../shared/runtime-selection-contracts'
|
||||||
|
import type { SystemModelUsageInput } from '../shared/assistant-contracts'
|
||||||
import {
|
import {
|
||||||
runCleanupBeforeDeadline,
|
runCleanupBeforeDeadline,
|
||||||
settleCleanupPhases
|
settleCleanupPhases
|
||||||
@@ -237,6 +238,28 @@ let managedRemoteExecutionServices:
|
|||||||
let directModelSubagentScheduler: SubagentScheduler | undefined
|
let directModelSubagentScheduler: SubagentScheduler | undefined
|
||||||
let localToolEnvironmentService: LocalToolEnvironmentService | undefined
|
let localToolEnvironmentService: LocalToolEnvironmentService | undefined
|
||||||
|
|
||||||
|
function recordKnowledgeUsage(
|
||||||
|
bucket: SystemModelUsageInput['bucket'],
|
||||||
|
usage: Omit<SystemModelUsageInput, 'source' | 'bucket'>
|
||||||
|
): void {
|
||||||
|
try {
|
||||||
|
assistantDatabase?.recordSystemModelUsage({
|
||||||
|
...usage,
|
||||||
|
source: 'knowledge',
|
||||||
|
bucket
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
void desktopDiagnostics
|
||||||
|
.recordFailure({
|
||||||
|
component: 'desktop',
|
||||||
|
stage: 'knowledge',
|
||||||
|
code: 'knowledge.usage.persist-failed',
|
||||||
|
error
|
||||||
|
})
|
||||||
|
.catch(() => undefined)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
type ManagedEmbeddingProvider = EmbeddingProvider & {
|
type ManagedEmbeddingProvider = EmbeddingProvider & {
|
||||||
dispose?: () => void | Promise<void>
|
dispose?: () => void | Promise<void>
|
||||||
}
|
}
|
||||||
@@ -259,7 +282,17 @@ async function createEmbeddingProvider(
|
|||||||
return new OpenAIEmbeddingClient({
|
return new OpenAIEmbeddingClient({
|
||||||
endpoint: connection.baseUrl,
|
endpoint: connection.baseUrl,
|
||||||
model: connection.modelName,
|
model: connection.modelName,
|
||||||
apiKey: connection.apiKey
|
apiKey: connection.apiKey,
|
||||||
|
onUsage: ({ model, inputTokens }) =>
|
||||||
|
recordKnowledgeUsage('embedding', {
|
||||||
|
runtime: 'embedding',
|
||||||
|
provider: 'openai-compatible',
|
||||||
|
model,
|
||||||
|
input: inputTokens,
|
||||||
|
output: 0,
|
||||||
|
cacheRead: 0,
|
||||||
|
cacheWrite: 0
|
||||||
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
const snapshot = await manager.getSnapshot()
|
const snapshot = await manager.getSnapshot()
|
||||||
@@ -899,7 +932,16 @@ if (hasSingleInstanceLock) {
|
|||||||
credentialCipher: secureCipher,
|
credentialCipher: secureCipher,
|
||||||
databasePath: join(app.getPath('userData'), 'knowledge.sqlite'),
|
databasePath: join(app.getPath('userData'), 'knowledge.sqlite'),
|
||||||
managedRoot: join(app.getPath('userData'), 'knowledge'),
|
managedRoot: join(app.getPath('userData'), 'knowledge'),
|
||||||
extractStructured: createModelGraphExtractor(settingsStore),
|
extractStructured: createModelGraphExtractor(
|
||||||
|
settingsStore,
|
||||||
|
fetch,
|
||||||
|
undefined,
|
||||||
|
(usage) =>
|
||||||
|
recordKnowledgeUsage('graph-extraction', {
|
||||||
|
runtime: 'model',
|
||||||
|
...usage
|
||||||
|
})
|
||||||
|
),
|
||||||
parseDocument: documentParsingService.parse
|
parseDocument: documentParsingService.parse
|
||||||
})
|
})
|
||||||
knowledgeService = startupKnowledgeService
|
knowledgeService = startupKnowledgeService
|
||||||
@@ -935,6 +977,15 @@ if (hasSingleInstanceLock) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
},
|
||||||
|
onModelUsageChanged: () => {
|
||||||
|
if (
|
||||||
|
mainWindow &&
|
||||||
|
!mainWindow.isDestroyed() &&
|
||||||
|
!mainWindow.webContents.isDestroyed()
|
||||||
|
) {
|
||||||
|
mainWindow.webContents.send(ipcChannels.tokenUsageChanged)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -5796,6 +5796,9 @@ describe('registerIpcHandlers agent terminal state', () => {
|
|||||||
const regenerated = await regenerate(event, { conversationId, operationId: original.id }) as typeof original
|
const regenerated = await regenerate(event, { conversationId, operationId: original.id }) as typeof original
|
||||||
expect(regenerated.id).not.toBe(original.id)
|
expect(regenerated.id).not.toBe(original.id)
|
||||||
expect(regenerated.messageId).toBe(messageId)
|
expect(regenerated.messageId).toBe(messageId)
|
||||||
|
// Regeneration keeps the originating task so its usage satisfies the task foreign key.
|
||||||
|
expect(regenerated.requestId).toBe(original.requestId)
|
||||||
|
expect(regenerated.callId).not.toBe(original.callId)
|
||||||
expect(fetcher).toHaveBeenCalledTimes(2)
|
expect(fetcher).toHaveBeenCalledTimes(2)
|
||||||
database.saveLocalConversations([{ header: { ...header, workMode: 'ask' }, messages: [] }])
|
database.saveLocalConversations([{ header: { ...header, workMode: 'ask' }, messages: [] }])
|
||||||
await expect(regenerate(event, { conversationId, operationId: original.id })).rejects.toThrow('Execute')
|
await expect(regenerate(event, { conversationId, operationId: original.id })).rejects.toThrow('Execute')
|
||||||
@@ -11358,6 +11361,47 @@ describe('registerIpcHandlers agent terminal state', () => {
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it('sends the work-mode instruction only once to runtimes that consume trusted instructions', async () => {
|
||||||
|
let receivedRequest:
|
||||||
|
| { requestId: string; prompt: string; trustedInstructions?: string }
|
||||||
|
| undefined
|
||||||
|
const runtime = {
|
||||||
|
runtimeId: 'opencode',
|
||||||
|
capability: 'chat',
|
||||||
|
requiresToolApproval: false,
|
||||||
|
supportsToolExecution: true,
|
||||||
|
consumesTrustedInstructions: true,
|
||||||
|
getStatus: vi.fn(),
|
||||||
|
dispose: vi.fn(),
|
||||||
|
async *run(request: {
|
||||||
|
requestId: string
|
||||||
|
prompt: string
|
||||||
|
trustedInstructions?: string
|
||||||
|
}) {
|
||||||
|
receivedRequest = request
|
||||||
|
yield { requestId: request.requestId, type: 'done' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const harness = createHarness(runtime)
|
||||||
|
const requestId = '5c3e1f7a-2b8d-4e6f-9a1c-7d2b4e6f8a10'
|
||||||
|
|
||||||
|
harness.handler?.(trustedEvent(harness.webContents), {
|
||||||
|
requestId,
|
||||||
|
conversationId: 'conversation-1',
|
||||||
|
prompt: 'fix the bug',
|
||||||
|
workMode: 'execute'
|
||||||
|
})
|
||||||
|
|
||||||
|
await vi.waitFor(() =>
|
||||||
|
expect(
|
||||||
|
harness.assistantDatabase.updateTaskStatus
|
||||||
|
).toHaveBeenCalledWith(requestId, 'completed')
|
||||||
|
)
|
||||||
|
expect(receivedRequest?.prompt).toBe('fix the bug')
|
||||||
|
expect(receivedRequest?.trustedInstructions).toContain('Work mode: Execute.')
|
||||||
|
await harness.dispose()
|
||||||
|
})
|
||||||
|
|
||||||
it('routes eligible Ask requests through the persisted smart expert service and publishes child events', async () => {
|
it('routes eligible Ask requests through the persisted smart expert service and publishes child events', async () => {
|
||||||
const runtime = {
|
const runtime = {
|
||||||
capability: 'chat',
|
capability: 'chat',
|
||||||
|
|||||||
+7
-4
@@ -2396,7 +2396,9 @@ export function registerIpcHandlers(
|
|||||||
conversationId: runtimeConversationId,
|
conversationId: runtimeConversationId,
|
||||||
projectId: schedule.projectId,
|
projectId: schedule.projectId,
|
||||||
workMode: schedule.workMode,
|
workMode: schedule.workMode,
|
||||||
prompt: `${trustedInstructions}\n\n${schedule.prompt}`,
|
prompt: requestRuntime.consumesTrustedInstructions === true
|
||||||
|
? schedule.prompt
|
||||||
|
: `${trustedInstructions}\n\n${schedule.prompt}`,
|
||||||
knowledgeLibraryIds: [],
|
knowledgeLibraryIds: [],
|
||||||
...(remoteContext?.contextIds?.length
|
...(remoteContext?.contextIds?.length
|
||||||
? { contextIds: remoteContext.contextIds }
|
? { contextIds: remoteContext.contextIds }
|
||||||
@@ -4317,7 +4319,7 @@ export function registerIpcHandlers(
|
|||||||
markManagedSshAccepted()
|
markManagedSshAccepted()
|
||||||
}
|
}
|
||||||
return selectedRuntime.run(
|
return selectedRuntime.run(
|
||||||
modeInstruction
|
modeInstruction && selectedRuntime.consumesTrustedInstructions !== true
|
||||||
? {
|
? {
|
||||||
...executionRequest,
|
...executionRequest,
|
||||||
prompt: `${modeInstruction}\n\n${executionRequest.prompt}`
|
prompt: `${modeInstruction}\n\n${executionRequest.prompt}`
|
||||||
@@ -6413,7 +6415,8 @@ export function registerIpcHandlers(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
const supervisorService = createProductionSupervisorService(assistantDatabase,
|
const supervisorService = createProductionSupervisorService(assistantDatabase,
|
||||||
async () => applicationSettingsStore?.get(), () => resolveRequestRuntime({ workMode: 'ask' }), supervisionModelPool)
|
async () => applicationSettingsStore?.get(), () => resolveRequestRuntime({ workMode: 'ask' }), supervisionModelPool,
|
||||||
|
persistModelUsage)
|
||||||
|
|
||||||
registerHandler(
|
registerHandler(
|
||||||
ipcChannels.conversationsBranchLocal,
|
ipcChannels.conversationsBranchLocal,
|
||||||
@@ -6478,7 +6481,7 @@ export function registerIpcHandlers(
|
|||||||
const currentWorkMode = (): 'ask' | 'execute' => normalizeInteractiveWorkMode(assistantDatabase.getConversation(target.conversationId).workMode)
|
const currentWorkMode = (): 'ask' | 'execute' => normalizeInteractiveWorkMode(assistantDatabase.getConversation(target.conversationId).workMode)
|
||||||
return imageGenerationService.regenerate({
|
return imageGenerationService.regenerate({
|
||||||
conversationId: target.conversationId, messageId: previous.messageId,
|
conversationId: target.conversationId, messageId: previous.messageId,
|
||||||
requestId: randomUUID(), workMode: currentWorkMode()
|
requestId: previous.requestId, workMode: currentWorkMode()
|
||||||
}, target.operationId, currentWorkMode)
|
}, target.operationId, currentWorkMode)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -96,6 +96,25 @@ describe('createModelGraphExtractor', () => {
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['anthropic-messages', { content: [{ type: 'text', text: '{}' }], model: 'claude-x',
|
||||||
|
usage: { input_tokens: 11, output_tokens: 3, cache_read_input_tokens: 2, cache_creation_input_tokens: 1 } },
|
||||||
|
{ provider: 'anthropic', model: 'claude-x', input: 11, output: 3, cacheRead: 2, cacheWrite: 1 }],
|
||||||
|
['openai-chat-completions', { choices: [{ message: { content: '{}' } }],
|
||||||
|
usage: { prompt_tokens: 9, completion_tokens: 4, prompt_tokens_details: { cached_tokens: 5 } } },
|
||||||
|
{ provider: 'openai', model: 'intranet-model', input: 9, output: 4, cacheRead: 5, cacheWrite: 0 }]
|
||||||
|
] as const)('reports %s token usage', async (protocol, payload, expected) => {
|
||||||
|
const onUsage = vi.fn()
|
||||||
|
const extract = createModelGraphExtractor(
|
||||||
|
store({ modelProtocol: protocol }),
|
||||||
|
vi.fn<typeof fetch>(async () => jsonResponse(payload)),
|
||||||
|
undefined,
|
||||||
|
onUsage
|
||||||
|
)
|
||||||
|
await expect(extract('extract this')).resolves.toEqual({})
|
||||||
|
expect(onUsage).toHaveBeenCalledWith(expected)
|
||||||
|
})
|
||||||
|
|
||||||
it('uses an unauthenticated Anthropic endpoint with its path and query', async () => {
|
it('uses an unauthenticated Anthropic endpoint with its path and query', async () => {
|
||||||
const fetcher = vi.fn(async () =>
|
const fetcher = vi.fn(async () =>
|
||||||
jsonResponse({
|
jsonResponse({
|
||||||
|
|||||||
@@ -158,10 +158,64 @@ function completionDetail(payload: unknown): string | undefined {
|
|||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type GraphExtractionUsage = {
|
||||||
|
provider: 'anthropic' | 'openai'
|
||||||
|
model: string
|
||||||
|
input: number
|
||||||
|
output: number
|
||||||
|
cacheRead: number
|
||||||
|
cacheWrite: number
|
||||||
|
}
|
||||||
|
|
||||||
|
function tokenCount(value: unknown): number {
|
||||||
|
return Number.isSafeInteger(value) && (value as number) >= 0
|
||||||
|
? value as number
|
||||||
|
: 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reads provider-reported token usage from a non-streaming response. */
|
||||||
|
export function readGraphExtractionUsage(
|
||||||
|
payload: unknown,
|
||||||
|
protocol: string,
|
||||||
|
fallbackModel: string
|
||||||
|
): GraphExtractionUsage | undefined {
|
||||||
|
const response = record(payload)
|
||||||
|
const usage = record(response?.usage)
|
||||||
|
if (!usage) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
const model =
|
||||||
|
typeof response?.model === 'string' && response.model.length > 0
|
||||||
|
? response.model.slice(0, 500)
|
||||||
|
: fallbackModel.slice(0, 500)
|
||||||
|
if (protocol === 'anthropic-messages') {
|
||||||
|
return {
|
||||||
|
provider: 'anthropic',
|
||||||
|
model,
|
||||||
|
input: tokenCount(usage.input_tokens),
|
||||||
|
output: tokenCount(usage.output_tokens),
|
||||||
|
cacheRead: tokenCount(usage.cache_read_input_tokens),
|
||||||
|
cacheWrite: tokenCount(usage.cache_creation_input_tokens)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const details = record(
|
||||||
|
usage.prompt_tokens_details ?? usage.input_tokens_details
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
provider: 'openai',
|
||||||
|
model,
|
||||||
|
input: tokenCount(usage.prompt_tokens ?? usage.input_tokens),
|
||||||
|
output: tokenCount(usage.completion_tokens ?? usage.output_tokens),
|
||||||
|
cacheRead: tokenCount(details?.cached_tokens),
|
||||||
|
cacheWrite: 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function createModelGraphExtractor(
|
export function createModelGraphExtractor(
|
||||||
settingsStore: RuntimeSettingsStore,
|
settingsStore: RuntimeSettingsStore,
|
||||||
fetcher: typeof fetch = fetch,
|
fetcher: typeof fetch = fetch,
|
||||||
requestTimeoutMilliseconds = defaultGraphRequestTimeoutMilliseconds
|
requestTimeoutMilliseconds = defaultGraphRequestTimeoutMilliseconds,
|
||||||
|
onUsage?: (usage: GraphExtractionUsage) => void
|
||||||
): ExtractStructured {
|
): ExtractStructured {
|
||||||
return async (prompt, signal) => {
|
return async (prompt, signal) => {
|
||||||
const settings = await settingsStore.getResolvedSettings()
|
const settings = await settingsStore.getResolvedSettings()
|
||||||
@@ -289,6 +343,18 @@ export function createModelGraphExtractor(
|
|||||||
{ cause: error }
|
{ cause: error }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
const usage = readGraphExtractionUsage(
|
||||||
|
payload,
|
||||||
|
protocol,
|
||||||
|
settings.modelName
|
||||||
|
)
|
||||||
|
if (usage && onUsage) {
|
||||||
|
try {
|
||||||
|
onUsage(usage)
|
||||||
|
} catch {
|
||||||
|
// Usage accounting must never fail a successful extraction.
|
||||||
|
}
|
||||||
|
}
|
||||||
const text =
|
const text =
|
||||||
protocol === 'anthropic-messages'
|
protocol === 'anthropic-messages'
|
||||||
? anthropicText(payload)
|
? anthropicText(payload)
|
||||||
|
|||||||
@@ -42,6 +42,16 @@ describe('OpenAIEmbeddingClient', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('reports provider token usage per batch without failing on observer errors', async () => {
|
||||||
|
const transport = vi.fn<typeof fetch>(async () =>
|
||||||
|
new Response(JSON.stringify({ data: [{ index: 0, embedding: [1, 2] }], usage: { prompt_tokens: 7, total_tokens: 7 } }))
|
||||||
|
)
|
||||||
|
const onUsage = vi.fn(() => { throw new Error('observer failed') })
|
||||||
|
const client = new OpenAIEmbeddingClient({ endpoint: 'http://127.0.0.1/v1/embeddings', model: 'embed', fetch: transport, onUsage })
|
||||||
|
await expect(client.embed(['alpha'])).resolves.toEqual([[1, 2]])
|
||||||
|
expect(onUsage).toHaveBeenCalledWith({ model: 'embed', inputTokens: 7 })
|
||||||
|
})
|
||||||
|
|
||||||
it('accepts unauthenticated endpoints and restores response index order', async () => {
|
it('accepts unauthenticated endpoints and restores response index order', async () => {
|
||||||
const transport = vi.fn<typeof fetch>(async () =>
|
const transport = vi.fn<typeof fetch>(async () =>
|
||||||
new Response(
|
new Response(
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ export interface OpenAIEmbeddingClientOptions {
|
|||||||
batchSize?: number
|
batchSize?: number
|
||||||
timeoutMs?: number
|
timeoutMs?: number
|
||||||
fetch?: typeof fetch
|
fetch?: typeof fetch
|
||||||
|
/** Receives provider-reported input tokens for each successful batch. */
|
||||||
|
onUsage?: (usage: { model: string; inputTokens: number }) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export type EmbeddingInputRole = 'query' | 'document'
|
export type EmbeddingInputRole = 'query' | 'document'
|
||||||
@@ -268,8 +270,10 @@ export class OpenAIEmbeddingClient implements EmbeddingProvider {
|
|||||||
private readonly batchSize: number
|
private readonly batchSize: number
|
||||||
private readonly timeoutMs: number
|
private readonly timeoutMs: number
|
||||||
private readonly transport: typeof fetch
|
private readonly transport: typeof fetch
|
||||||
|
private readonly onUsage?: OpenAIEmbeddingClientOptions['onUsage']
|
||||||
|
|
||||||
constructor(options: OpenAIEmbeddingClientOptions) {
|
constructor(options: OpenAIEmbeddingClientOptions) {
|
||||||
|
this.onUsage = options.onUsage
|
||||||
this.endpoint = normalizedEndpoint(options.endpoint)
|
this.endpoint = normalizedEndpoint(options.endpoint)
|
||||||
this.model = requiredString(options.model, 'model', MAX_MODEL_LENGTH)
|
this.model = requiredString(options.model, 'model', MAX_MODEL_LENGTH)
|
||||||
this.apiKey = options.apiKey?.trim() || undefined
|
this.apiKey = options.apiKey?.trim() || undefined
|
||||||
@@ -395,6 +399,26 @@ export class OpenAIEmbeddingClient implements EmbeddingProvider {
|
|||||||
return embeddings
|
return embeddings
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private reportUsage(payload: unknown): void {
|
||||||
|
if (!this.onUsage || typeof payload !== 'object' || payload === null) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const usage = (payload as { usage?: unknown }).usage
|
||||||
|
if (typeof usage !== 'object' || usage === null) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const values = usage as Record<string, unknown>
|
||||||
|
const tokens = values.prompt_tokens ?? values.total_tokens
|
||||||
|
if (!Number.isSafeInteger(tokens) || (tokens as number) <= 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
this.onUsage({ model: this.model, inputTokens: tokens as number })
|
||||||
|
} catch {
|
||||||
|
// Usage accounting must never fail an embedding request.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async embedBatch(
|
private async embedBatch(
|
||||||
input: readonly string[],
|
input: readonly string[],
|
||||||
signal?: AbortSignal
|
signal?: AbortSignal
|
||||||
@@ -439,10 +463,9 @@ export class OpenAIEmbeddingClient implements EmbeddingProvider {
|
|||||||
`Embedding request failed with HTTP ${response.status}`
|
`Embedding request failed with HTTP ${response.status}`
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
const vectors = validateEmbeddings(
|
const payload = await readBoundedJson(response)
|
||||||
await readBoundedJson(response),
|
const vectors = validateEmbeddings(payload, input.length)
|
||||||
input.length
|
this.reportUsage(payload)
|
||||||
)
|
|
||||||
if (
|
if (
|
||||||
this.dimensions !== undefined &&
|
this.dimensions !== undefined &&
|
||||||
vectors.some((vector) => vector.length !== this.dimensions)
|
vectors.some((vector) => vector.length !== this.dimensions)
|
||||||
|
|||||||
@@ -1283,7 +1283,13 @@ const desktopApi: DesktopApi = {
|
|||||||
getTokenSummary: () =>
|
getTokenSummary: () =>
|
||||||
ipcRenderer.invoke(
|
ipcRenderer.invoke(
|
||||||
ipcChannels.tokenUsageSummary
|
ipcChannels.tokenUsageSummary
|
||||||
) as Promise<TokenUsageSummary>
|
) as Promise<TokenUsageSummary>,
|
||||||
|
onChanged: (listener) => {
|
||||||
|
const handler = (): void => listener()
|
||||||
|
ipcRenderer.on(ipcChannels.tokenUsageChanged, handler)
|
||||||
|
return () =>
|
||||||
|
ipcRenderer.removeListener(ipcChannels.tokenUsageChanged, handler)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
artifacts: {
|
artifacts: {
|
||||||
list: (projectId?: string) =>
|
list: (projectId?: string) =>
|
||||||
|
|||||||
@@ -932,4 +932,53 @@ describe('ActivityPanel', () => {
|
|||||||
).toBeInTheDocument()
|
).toBeInTheDocument()
|
||||||
expect(screen.queryByText('goodbuddy')).not.toBeInTheDocument()
|
expect(screen.queryByText('goodbuddy')).not.toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('moves system task usage into its own tab with separate totals', () => {
|
||||||
|
const usage = makeTokenUsage()
|
||||||
|
const systemRecord = (requestId: string, systemSource: 'heartbeat' | 'supervision', input: number) => ({
|
||||||
|
requestId, systemSource, conversationId: `${systemSource}:x`, runtime: 'model', provider: 'openai', model: 'gpt-sys',
|
||||||
|
callCount: 1, input, output: 1, cacheRead: 0, cacheWrite: 0, totalTokens: input + 1
|
||||||
|
})
|
||||||
|
usage.records.push(systemRecord('sys-1', 'heartbeat', 7), systemRecord('sys-2', 'supervision', 9))
|
||||||
|
usage.conversationTotals = usage.totals
|
||||||
|
usage.systemTotals = { callCount: 2, input: 16, output: 2, cacheRead: 0, cacheWrite: 0, totalTokens: 18 }
|
||||||
|
|
||||||
|
render(<ActivityPanel onClear={vi.fn()} onOpenConversation={vi.fn()} records={[]} tokenUsage={usage} />)
|
||||||
|
fireEvent.click(screen.getByRole('tab', { name: '用量统计' }))
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '按会话' }))
|
||||||
|
expect(screen.queryByText('heartbeat:x')).not.toBeInTheDocument()
|
||||||
|
expect(within(screen.getByLabelText('Token 用量统计')).getByText('150')).toBeInTheDocument()
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('tab', { name: '系统任务' }))
|
||||||
|
const stats = screen.getByLabelText('系统任务用量统计')
|
||||||
|
expect(within(stats).getByText('18')).toBeInTheDocument()
|
||||||
|
expect(screen.getByRole('row', { name: '自动监督 7 1 0 0 0% 8' })).toBeInTheDocument()
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '监督者' }))
|
||||||
|
expect(screen.getByRole('row', { name: '直连模型 · gpt-sys 9 1 0 0 0% 10' })).toBeInTheDocument()
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '按模型' }))
|
||||||
|
expect(screen.getByRole('row', { name: '直连模型 · gpt-sys 16 2 0 0 0% 18' })).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows an empty system tab and refreshes usage on demand', async () => {
|
||||||
|
let resolveRefresh!: () => void
|
||||||
|
const onRefreshTokenUsage = vi.fn(() => new Promise<void>((resolve) => { resolveRefresh = resolve }))
|
||||||
|
render(<ActivityPanel onClear={vi.fn()} onOpenConversation={vi.fn()} onRefreshTokenUsage={onRefreshTokenUsage}
|
||||||
|
records={[]} tokenUsage={makeTokenUsage()} />)
|
||||||
|
fireEvent.click(screen.getByRole('tab', { name: '系统任务' }))
|
||||||
|
expect(screen.getByText('暂无系统任务 Token 用量')).toBeInTheDocument()
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '刷新' }))
|
||||||
|
expect(onRefreshTokenUsage).toHaveBeenCalledOnce()
|
||||||
|
const busy = screen.getByRole('button', { name: '正在刷新' })
|
||||||
|
expect(busy).toBeDisabled()
|
||||||
|
fireEvent.click(busy)
|
||||||
|
expect(onRefreshTokenUsage).toHaveBeenCalledOnce()
|
||||||
|
resolveRefresh()
|
||||||
|
expect(await screen.findByRole('button', { name: '刷新' })).toBeEnabled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('hides the refresh button when no refresh handler is provided', () => {
|
||||||
|
render(<ActivityPanel onClear={vi.fn()} onOpenConversation={vi.fn()} records={[]} tokenUsage={makeTokenUsage()} />)
|
||||||
|
fireEvent.click(screen.getByRole('tab', { name: '用量统计' }))
|
||||||
|
expect(screen.queryByRole('button', { name: '刷新' })).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
+269
-162
@@ -1,4 +1,4 @@
|
|||||||
import { Activity, ChevronRight, Trash2 } from 'lucide-react'
|
import { Activity, ChevronRight, RefreshCw, Trash2 } from 'lucide-react'
|
||||||
import { memo, useMemo, useState } from 'react'
|
import { memo, useMemo, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { InlineHelp } from './InlineHelp'
|
import { InlineHelp } from './InlineHelp'
|
||||||
@@ -11,7 +11,9 @@ import { type ActivityRecord } from './activity-store'
|
|||||||
import {
|
import {
|
||||||
getTokenUsageTotals,
|
getTokenUsageTotals,
|
||||||
groupTokenUsage,
|
groupTokenUsage,
|
||||||
type TokenUsageGroup
|
splitTokenUsage,
|
||||||
|
type TokenUsageGroup,
|
||||||
|
type TokenUsageGroupRow
|
||||||
} from './token-usage'
|
} from './token-usage'
|
||||||
import {
|
import {
|
||||||
DestructiveConfirmActions,
|
DestructiveConfirmActions,
|
||||||
@@ -25,7 +27,9 @@ import {
|
|||||||
import { getProjectDisplayText } from './project-display'
|
import { getProjectDisplayText } from './project-display'
|
||||||
|
|
||||||
type ActivityFilter = 'all' | 'active' | 'failed'
|
type ActivityFilter = 'all' | 'active' | 'failed'
|
||||||
type ActivityView = 'tasks' | 'timeline' | 'usage'
|
type ActivityView = 'tasks' | 'timeline' | 'usage' | 'system'
|
||||||
|
type SystemTokenUsageGroup = Extract<TokenUsageGroup, 'source' | 'model'>
|
||||||
|
type TokenUsageView = Extract<ActivityView, 'usage' | 'system'>
|
||||||
type ActivityActorKind =
|
type ActivityActorKind =
|
||||||
| 'user'
|
| 'user'
|
||||||
| 'assistant'
|
| 'assistant'
|
||||||
@@ -41,6 +45,8 @@ export type ActivityPanelProps = {
|
|||||||
tokenUsage: TokenUsageSummary
|
tokenUsage: TokenUsageSummary
|
||||||
onClear: () => void
|
onClear: () => void
|
||||||
onOpenConversation: (conversationId: string) => void
|
onOpenConversation: (conversationId: string) => void
|
||||||
|
/** Re-reads token usage from storage; the button is hidden when omitted. */
|
||||||
|
onRefreshTokenUsage?: () => Promise<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
type ConversationActivityGroup = {
|
type ConversationActivityGroup = {
|
||||||
@@ -244,7 +250,8 @@ export const ActivityPanel = memo(function ActivityPanel({
|
|||||||
records,
|
records,
|
||||||
tokenUsage,
|
tokenUsage,
|
||||||
onClear,
|
onClear,
|
||||||
onOpenConversation
|
onOpenConversation,
|
||||||
|
onRefreshTokenUsage
|
||||||
}: ActivityPanelProps): React.JSX.Element {
|
}: ActivityPanelProps): React.JSX.Element {
|
||||||
const { t, i18n } = useTranslation('activity')
|
const { t, i18n } = useTranslation('activity')
|
||||||
const { t: tWorkspace } = useTranslation('workspace')
|
const { t: tWorkspace } = useTranslation('workspace')
|
||||||
@@ -252,7 +259,10 @@ export const ActivityPanel = memo(function ActivityPanel({
|
|||||||
useState<ActivityView>('tasks')
|
useState<ActivityView>('tasks')
|
||||||
const [filter, setFilter] = useState<ActivityFilter>('all')
|
const [filter, setFilter] = useState<ActivityFilter>('all')
|
||||||
const [tokenGroup, setTokenGroup] =
|
const [tokenGroup, setTokenGroup] =
|
||||||
useState<TokenUsageGroup>('project')
|
useState<Exclude<TokenUsageGroup, 'source'>>('project')
|
||||||
|
const [systemTokenGroup, setSystemTokenGroup] =
|
||||||
|
useState<SystemTokenUsageGroup>('source')
|
||||||
|
const [refreshingTokenUsage, setRefreshingTokenUsage] = useState(false)
|
||||||
const [expandedTokenRows, setExpandedTokenRows] = useState<Set<string>>(
|
const [expandedTokenRows, setExpandedTokenRows] = useState<Set<string>>(
|
||||||
() => new Set()
|
() => new Set()
|
||||||
)
|
)
|
||||||
@@ -311,10 +321,27 @@ export const ActivityPanel = memo(function ActivityPanel({
|
|||||||
}> = [
|
}> = [
|
||||||
{ id: 'tasks', label: t('tabs.tasks') },
|
{ id: 'tasks', label: t('tabs.tasks') },
|
||||||
{ id: 'timeline', label: t('tabs.timeline') },
|
{ id: 'timeline', label: t('tabs.timeline') },
|
||||||
{ id: 'usage', label: t('tabs.usage') }
|
{ id: 'usage', label: t('tabs.usage') },
|
||||||
|
{ id: 'system', label: t('tabs.system') }
|
||||||
|
]
|
||||||
|
const systemTokenGroups: ReadonlyArray<{
|
||||||
|
value: SystemTokenUsageGroup
|
||||||
|
label: string
|
||||||
|
columnLabel: string
|
||||||
|
}> = [
|
||||||
|
{
|
||||||
|
value: 'source',
|
||||||
|
label: t('systemUsage.groups.source'),
|
||||||
|
columnLabel: t('systemUsage.columns.source')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'model',
|
||||||
|
label: t('tokenUsage.groups.model'),
|
||||||
|
columnLabel: t('tokenUsage.columns.model')
|
||||||
|
}
|
||||||
]
|
]
|
||||||
const tokenGroups: ReadonlyArray<{
|
const tokenGroups: ReadonlyArray<{
|
||||||
value: TokenUsageGroup
|
value: Exclude<TokenUsageGroup, 'source'>
|
||||||
label: string
|
label: string
|
||||||
columnLabel: string
|
columnLabel: string
|
||||||
}> = [
|
}> = [
|
||||||
@@ -452,17 +479,26 @@ export const ActivityPanel = memo(function ActivityPanel({
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
const splitUsage = useMemo(
|
||||||
|
() => splitTokenUsage(displayTokenUsage),
|
||||||
|
[displayTokenUsage]
|
||||||
|
)
|
||||||
const tokenTotals = useMemo(
|
const tokenTotals = useMemo(
|
||||||
() => getTokenUsageTotals(tokenUsage),
|
() => getTokenUsageTotals(splitUsage.conversation),
|
||||||
[tokenUsage]
|
[splitUsage]
|
||||||
)
|
)
|
||||||
const tokenRows = useMemo(
|
const tokenRows = useMemo(
|
||||||
() => groupTokenUsage(displayTokenUsage, tokenGroup),
|
() => groupTokenUsage(splitUsage.conversation, tokenGroup),
|
||||||
[displayTokenUsage, tokenGroup]
|
[splitUsage, tokenGroup]
|
||||||
|
)
|
||||||
|
const systemTokenTotals = useMemo(
|
||||||
|
() => getTokenUsageTotals(splitUsage.system),
|
||||||
|
[splitUsage]
|
||||||
|
)
|
||||||
|
const systemTokenRows = useMemo(
|
||||||
|
() => groupTokenUsage(splitUsage.system, systemTokenGroup),
|
||||||
|
[splitUsage, systemTokenGroup]
|
||||||
)
|
)
|
||||||
const tokenGroupLabel =
|
|
||||||
tokenGroups.find((item) => item.value === tokenGroup)?.columnLabel ??
|
|
||||||
t('tokenUsage.columns.project')
|
|
||||||
const emptyDescription =
|
const emptyDescription =
|
||||||
filter === 'active'
|
filter === 'active'
|
||||||
? t('empty.active')
|
? t('empty.active')
|
||||||
@@ -530,8 +566,25 @@ export const ActivityPanel = memo(function ActivityPanel({
|
|||||||
{ kind: 'tool', label: t('timeline.actors.tool') },
|
{ kind: 'tool', label: t('timeline.actors.tool') },
|
||||||
{ kind: 'approval', label: t('timeline.actors.approval') }
|
{ kind: 'approval', label: t('timeline.actors.approval') }
|
||||||
]
|
]
|
||||||
|
const systemSourceLabel = (key: string): string => {
|
||||||
|
switch (key) {
|
||||||
|
case 'source:heartbeat':
|
||||||
|
return t('systemUsage.sources.heartbeat')
|
||||||
|
case 'source:supervision':
|
||||||
|
return t('systemUsage.sources.supervision')
|
||||||
|
case 'source:magic-notes':
|
||||||
|
return t('systemUsage.sources.magicNotes')
|
||||||
|
case 'source:knowledge':
|
||||||
|
return t('systemUsage.sources.knowledge')
|
||||||
|
case 'source:delegation':
|
||||||
|
return t('systemUsage.sources.delegation')
|
||||||
|
default:
|
||||||
|
return t('systemUsage.sources.unknown')
|
||||||
|
}
|
||||||
|
}
|
||||||
const localizeTokenRow = (
|
const localizeTokenRow = (
|
||||||
row: (typeof tokenRows)[number]
|
row: TokenUsageGroupRow,
|
||||||
|
group: TokenUsageGroup
|
||||||
): { label: string } => {
|
): { label: string } => {
|
||||||
const modelLabel =
|
const modelLabel =
|
||||||
row.model || t('tokenUsage.fallbacks.unknownModel')
|
row.model || t('tokenUsage.fallbacks.unknownModel')
|
||||||
@@ -546,17 +599,30 @@ export const ActivityPanel = memo(function ActivityPanel({
|
|||||||
? t('tokenUsage.runtimes.deepseekHarness')
|
? t('tokenUsage.runtimes.deepseekHarness')
|
||||||
: row.runtime || t('tokenUsage.fallbacks.unknownRuntime')
|
: row.runtime || t('tokenUsage.fallbacks.unknownRuntime')
|
||||||
const label =
|
const label =
|
||||||
tokenGroup === 'project' &&
|
group === 'source' && row.children
|
||||||
row.key === 'project:unassigned'
|
? systemSourceLabel(row.key)
|
||||||
? t('tokenUsage.fallbacks.unassignedProject')
|
: group === 'project' &&
|
||||||
: tokenGroup === 'conversation' &&
|
row.key === 'project:unassigned'
|
||||||
row.key === 'conversation:deleted'
|
? t('tokenUsage.fallbacks.unassignedProject')
|
||||||
? t('tokenUsage.fallbacks.deletedConversation')
|
: group === 'conversation' &&
|
||||||
: !row.children
|
row.key === 'conversation:deleted'
|
||||||
? `${runtimeLabel} · ${modelLabel}`
|
? t('tokenUsage.fallbacks.deletedConversation')
|
||||||
: row.label
|
: !row.children
|
||||||
|
? `${runtimeLabel} · ${modelLabel}`
|
||||||
|
: row.label
|
||||||
return { label }
|
return { label }
|
||||||
}
|
}
|
||||||
|
const refreshTokenUsage = async (): Promise<void> => {
|
||||||
|
if (!onRefreshTokenUsage || refreshingTokenUsage) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setRefreshingTokenUsage(true)
|
||||||
|
try {
|
||||||
|
await onRefreshTokenUsage()
|
||||||
|
} finally {
|
||||||
|
setRefreshingTokenUsage(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
const renderRecordCard = (
|
const renderRecordCard = (
|
||||||
record: ActivityRecord,
|
record: ActivityRecord,
|
||||||
showContext: boolean
|
showContext: boolean
|
||||||
@@ -984,135 +1050,195 @@ export const ActivityPanel = memo(function ActivityPanel({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{activeView === 'usage' && (
|
{activeView === 'usage' &&
|
||||||
<div
|
renderTokenUsagePanel({
|
||||||
aria-labelledby="activity-view-tab-usage"
|
view: 'usage',
|
||||||
className="activity-panel__tab-panel"
|
title: t('tokenUsage.title'),
|
||||||
id="activity-view-panel-usage"
|
description: undefined,
|
||||||
role="tabpanel"
|
group: tokenGroup,
|
||||||
tabIndex={0}
|
groups: tokenGroups,
|
||||||
>
|
onGroupChange: setTokenGroup,
|
||||||
<section
|
totals: tokenTotals,
|
||||||
aria-labelledby="token-usage-title"
|
rows: tokenRows,
|
||||||
className="token-usage"
|
empty: t('tokenUsage.empty')
|
||||||
>
|
})}
|
||||||
<header className="token-usage__header">
|
|
||||||
<h2 id="token-usage-title">{t('tokenUsage.title')}</h2>
|
|
||||||
<SegmentedControl
|
|
||||||
ariaLabel={t('tokenUsage.groupAriaLabel')}
|
|
||||||
onChange={setTokenGroup}
|
|
||||||
options={tokenGroups}
|
|
||||||
value={tokenGroup}
|
|
||||||
/>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<dl
|
{activeView === 'system' &&
|
||||||
aria-label={t('tokenUsage.statsAriaLabel')}
|
renderTokenUsagePanel({
|
||||||
className="token-usage__stats"
|
view: 'system',
|
||||||
>
|
title: t('systemUsage.title'),
|
||||||
<div>
|
description: t('systemUsage.description'),
|
||||||
<dt>{t('tokenUsage.columns.input')}</dt>
|
group: systemTokenGroup,
|
||||||
<dd>
|
groups: systemTokenGroups,
|
||||||
{tokenCountFormatter.format(tokenTotals.inputTokens)}
|
onGroupChange: setSystemTokenGroup,
|
||||||
</dd>
|
totals: systemTokenTotals,
|
||||||
</div>
|
rows: systemTokenRows,
|
||||||
<div>
|
empty: t('systemUsage.empty')
|
||||||
<dt>{t('tokenUsage.columns.output')}</dt>
|
})}
|
||||||
<dd>
|
</section>
|
||||||
{tokenCountFormatter.format(tokenTotals.outputTokens)}
|
)
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt>{t('tokenUsage.columns.cacheWrite')}</dt>
|
|
||||||
<dd>
|
|
||||||
{tokenCountFormatter.format(
|
|
||||||
tokenTotals.cacheWriteTokens
|
|
||||||
)}
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt>{t('tokenUsage.columns.cacheRead')}</dt>
|
|
||||||
<dd>
|
|
||||||
{tokenCountFormatter.format(
|
|
||||||
tokenTotals.cacheReadTokens
|
|
||||||
)}
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt>{t('tokenUsage.columns.cacheHitRate')}</dt>
|
|
||||||
<dd>
|
|
||||||
{formatCacheHitRate(tokenTotals.cacheHitRate)}
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt>{t('tokenUsage.columns.total')}</dt>
|
|
||||||
<dd>
|
|
||||||
{tokenCountFormatter.format(tokenTotals.totalTokens)}
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
</dl>
|
|
||||||
|
|
||||||
<div className="token-usage__table-scroll">
|
function renderTokenUsagePanel<Group extends TokenUsageGroup>({
|
||||||
<table
|
view,
|
||||||
aria-label={t('tokenUsage.detailAriaLabel', {
|
title,
|
||||||
group: tokenGroupLabel
|
description,
|
||||||
})}
|
group,
|
||||||
|
groups,
|
||||||
|
onGroupChange,
|
||||||
|
totals,
|
||||||
|
rows,
|
||||||
|
empty
|
||||||
|
}: {
|
||||||
|
view: TokenUsageView
|
||||||
|
title: string
|
||||||
|
description: string | undefined
|
||||||
|
group: Group
|
||||||
|
groups: ReadonlyArray<{ value: Group; label: string; columnLabel: string }>
|
||||||
|
onGroupChange: (group: Group) => void
|
||||||
|
totals: ReturnType<typeof getTokenUsageTotals>
|
||||||
|
rows: TokenUsageGroupRow[]
|
||||||
|
empty: string
|
||||||
|
}): React.JSX.Element {
|
||||||
|
const titleId = view === 'usage' ? 'token-usage-title' : 'system-usage-title'
|
||||||
|
const groupLabel =
|
||||||
|
groups.find((item) => item.value === group)?.columnLabel ??
|
||||||
|
groups[0]!.columnLabel
|
||||||
|
const expansionPrefix = `${view}:`
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
aria-labelledby={`activity-view-tab-${view}`}
|
||||||
|
className="activity-panel__tab-panel"
|
||||||
|
id={`activity-view-panel-${view}`}
|
||||||
|
role="tabpanel"
|
||||||
|
tabIndex={0}
|
||||||
|
>
|
||||||
|
<section aria-labelledby={titleId} className="token-usage">
|
||||||
|
<header className="token-usage__header">
|
||||||
|
<h2 id={titleId}>{title}</h2>
|
||||||
|
<SegmentedControl
|
||||||
|
ariaLabel={
|
||||||
|
view === 'usage'
|
||||||
|
? t('tokenUsage.groupAriaLabel')
|
||||||
|
: t('systemUsage.groupAriaLabel')
|
||||||
|
}
|
||||||
|
onChange={onGroupChange}
|
||||||
|
options={groups}
|
||||||
|
value={group}
|
||||||
|
/>
|
||||||
|
{onRefreshTokenUsage && (
|
||||||
|
<button
|
||||||
|
aria-busy={refreshingTokenUsage}
|
||||||
|
className="secondary-button token-usage__refresh"
|
||||||
|
disabled={refreshingTokenUsage}
|
||||||
|
onClick={() => void refreshTokenUsage()}
|
||||||
|
title={t('tokenUsage.refreshHint')}
|
||||||
|
type="button"
|
||||||
>
|
>
|
||||||
<thead>
|
<RefreshCw aria-hidden="true" size={13} />
|
||||||
|
{refreshingTokenUsage
|
||||||
|
? t('tokenUsage.refreshing')
|
||||||
|
: t('tokenUsage.refresh')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</header>
|
||||||
|
{description && (
|
||||||
|
<p className="token-usage__description">{description}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<dl
|
||||||
|
aria-label={
|
||||||
|
view === 'usage'
|
||||||
|
? t('tokenUsage.statsAriaLabel')
|
||||||
|
: t('systemUsage.statsAriaLabel')
|
||||||
|
}
|
||||||
|
className="token-usage__stats"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<dt>{t('tokenUsage.columns.input')}</dt>
|
||||||
|
<dd>{tokenCountFormatter.format(totals.inputTokens)}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>{t('tokenUsage.columns.output')}</dt>
|
||||||
|
<dd>{tokenCountFormatter.format(totals.outputTokens)}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>{t('tokenUsage.columns.cacheWrite')}</dt>
|
||||||
|
<dd>{tokenCountFormatter.format(totals.cacheWriteTokens)}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>{t('tokenUsage.columns.cacheRead')}</dt>
|
||||||
|
<dd>{tokenCountFormatter.format(totals.cacheReadTokens)}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>{t('tokenUsage.columns.cacheHitRate')}</dt>
|
||||||
|
<dd>{formatCacheHitRate(totals.cacheHitRate)}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>{t('tokenUsage.columns.total')}</dt>
|
||||||
|
<dd>{tokenCountFormatter.format(totals.totalTokens)}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<div className="token-usage__table-scroll">
|
||||||
|
<table
|
||||||
|
aria-label={t('tokenUsage.detailAriaLabel', {
|
||||||
|
group: groupLabel
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col">{groupLabel}</th>
|
||||||
|
<th scope="col">{t('tokenUsage.columns.input')}</th>
|
||||||
|
<th scope="col">{t('tokenUsage.columns.output')}</th>
|
||||||
|
<th scope="col">{t('tokenUsage.columns.cacheWrite')}</th>
|
||||||
|
<th scope="col">{t('tokenUsage.columns.cacheRead')}</th>
|
||||||
|
<th scope="col">{t('tokenUsage.columns.cacheHitRate')}</th>
|
||||||
|
<th scope="col">{t('tokenUsage.columns.total')}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{rows.length === 0 ? (
|
||||||
<tr>
|
<tr>
|
||||||
<th scope="col">{tokenGroupLabel}</th>
|
<td className="token-usage__empty" colSpan={7}>
|
||||||
<th scope="col">{t('tokenUsage.columns.input')}</th>
|
{empty}
|
||||||
<th scope="col">{t('tokenUsage.columns.output')}</th>
|
</td>
|
||||||
<th scope="col">
|
|
||||||
{t('tokenUsage.columns.cacheWrite')}
|
|
||||||
</th>
|
|
||||||
<th scope="col">
|
|
||||||
{t('tokenUsage.columns.cacheRead')}
|
|
||||||
</th>
|
|
||||||
<th scope="col">
|
|
||||||
{t('tokenUsage.columns.cacheHitRate')}
|
|
||||||
</th>
|
|
||||||
<th scope="col">
|
|
||||||
{t('tokenUsage.columns.total')}
|
|
||||||
</th>
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
) : (
|
||||||
<tbody>
|
rows
|
||||||
{tokenRows.length === 0 ? (
|
.flatMap((parent) => [
|
||||||
<tr>
|
|
||||||
<td className="token-usage__empty" colSpan={7}>
|
|
||||||
{t('tokenUsage.empty')}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
) : (
|
|
||||||
tokenRows.flatMap((parent) => [
|
|
||||||
{ row: parent, isChild: false },
|
{ row: parent, isChild: false },
|
||||||
...(expandedTokenRows.has(parent.key)
|
...(expandedTokenRows.has(expansionPrefix + parent.key)
|
||||||
? (parent.children ?? []).map((row) => ({
|
? (parent.children ?? []).map((row) => ({
|
||||||
row: { ...row, key: `${parent.key}:${row.key}` },
|
row: { ...row, key: `${parent.key}:${row.key}` },
|
||||||
isChild: true
|
isChild: true
|
||||||
}))
|
}))
|
||||||
: [])
|
: [])
|
||||||
]).map(({ row, isChild }) => {
|
])
|
||||||
const localizedRow = localizeTokenRow(row)
|
.map(({ row, isChild }) => {
|
||||||
|
const localizedRow = localizeTokenRow(row, group)
|
||||||
|
const expansionKey = expansionPrefix + row.key
|
||||||
return (
|
return (
|
||||||
<tr
|
<tr
|
||||||
key={row.key}
|
key={row.key}
|
||||||
className={isChild ? 'token-usage__model-row' : undefined}
|
className={
|
||||||
|
isChild ? 'token-usage__model-row' : undefined
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<th scope="row">
|
<th scope="row">
|
||||||
{row.children ? (
|
{row.children ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="token-usage__expand"
|
className="token-usage__expand"
|
||||||
aria-expanded={expandedTokenRows.has(row.key)}
|
aria-expanded={expandedTokenRows.has(
|
||||||
|
expansionKey
|
||||||
|
)}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
setExpandedTokenRows((current) => {
|
setExpandedTokenRows((current) => {
|
||||||
const next = new Set(current)
|
const next = new Set(current)
|
||||||
if (next.has(row.key)) {
|
if (next.has(expansionKey)) {
|
||||||
next.delete(row.key)
|
next.delete(expansionKey)
|
||||||
} else {
|
} else {
|
||||||
next.add(row.key)
|
next.add(expansionKey)
|
||||||
}
|
}
|
||||||
return next
|
return next
|
||||||
})
|
})
|
||||||
@@ -1125,44 +1251,25 @@ export const ActivityPanel = memo(function ActivityPanel({
|
|||||||
<span>{localizedRow.label}</span>
|
<span>{localizedRow.label}</span>
|
||||||
)}
|
)}
|
||||||
</th>
|
</th>
|
||||||
|
<td>{tokenCountFormatter.format(row.inputTokens)}</td>
|
||||||
|
<td>{tokenCountFormatter.format(row.outputTokens)}</td>
|
||||||
<td>
|
<td>
|
||||||
{tokenCountFormatter.format(
|
{tokenCountFormatter.format(row.cacheWriteTokens)}
|
||||||
row.inputTokens
|
|
||||||
)}
|
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{tokenCountFormatter.format(
|
{tokenCountFormatter.format(row.cacheReadTokens)}
|
||||||
row.outputTokens
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
{tokenCountFormatter.format(
|
|
||||||
row.cacheWriteTokens
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
{tokenCountFormatter.format(
|
|
||||||
row.cacheReadTokens
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
{formatCacheHitRate(row.cacheHitRate)}
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
{tokenCountFormatter.format(
|
|
||||||
row.totalTokens
|
|
||||||
)}
|
|
||||||
</td>
|
</td>
|
||||||
|
<td>{formatCacheHitRate(row.cacheHitRate)}</td>
|
||||||
|
<td>{tokenCountFormatter.format(row.totalTokens)}</td>
|
||||||
</tr>
|
</tr>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
)}
|
)}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)
|
||||||
</section>
|
}
|
||||||
)
|
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -640,6 +640,7 @@ const api: DesktopApi & RuntimeNativeClientApi = {
|
|||||||
},
|
},
|
||||||
records: [],
|
records: [],
|
||||||
})),
|
})),
|
||||||
|
onChanged: vi.fn(() => () => undefined),
|
||||||
},
|
},
|
||||||
artifacts: {
|
artifacts: {
|
||||||
list: vi.fn(async () => []),
|
list: vi.fn(async () => []),
|
||||||
|
|||||||
@@ -6096,9 +6096,28 @@ function App(): React.JSX.Element {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}, 0);
|
}, 0);
|
||||||
return () => clearTimeout(timeout);
|
// Background work (supervisor, heartbeat, knowledge, notes) records usage
|
||||||
|
// without a renderer request; main notifies so the view stays current.
|
||||||
|
const removeUsageListener = window.goodbuddy.usage.onChanged?.(() => {
|
||||||
|
void refreshTokenUsage().catch(() => undefined);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
removeUsageListener?.();
|
||||||
|
};
|
||||||
}, [refreshTokenUsage, view]);
|
}, [refreshTokenUsage, view]);
|
||||||
|
|
||||||
|
const manualRefreshTokenUsage = useCallback(async (): Promise<void> => {
|
||||||
|
try {
|
||||||
|
await refreshTokenUsage();
|
||||||
|
} catch {
|
||||||
|
notify({
|
||||||
|
tone: "error",
|
||||||
|
message: tRef.current("notices.tokenUsageReadFailed"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [notify, refreshTokenUsage]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void window.goodbuddy.artifacts
|
void window.goodbuddy.artifacts
|
||||||
.list()
|
.list()
|
||||||
@@ -11406,6 +11425,7 @@ function App(): React.JSX.Element {
|
|||||||
<ActivityPanel
|
<ActivityPanel
|
||||||
onClear={clearActivity}
|
onClear={clearActivity}
|
||||||
onOpenConversation={openActivityConversation}
|
onOpenConversation={openActivityConversation}
|
||||||
|
onRefreshTokenUsage={manualRefreshTokenUsage}
|
||||||
projects={projects}
|
projects={projects}
|
||||||
records={activityRecords}
|
records={activityRecords}
|
||||||
tokenUsage={tokenUsage}
|
tokenUsage={tokenUsage}
|
||||||
|
|||||||
@@ -11,7 +11,8 @@ export const activity = {
|
|||||||
ariaLabel: 'Run history views',
|
ariaLabel: 'Run history views',
|
||||||
tasks: 'Tasks and conversations',
|
tasks: 'Tasks and conversations',
|
||||||
timeline: 'Activity timeline',
|
timeline: 'Activity timeline',
|
||||||
usage: 'Usage analytics'
|
usage: 'Usage analytics',
|
||||||
|
system: 'System tasks'
|
||||||
},
|
},
|
||||||
clear: {
|
clear: {
|
||||||
confirmAriaLabel: 'Confirm clearing activity history',
|
confirmAriaLabel: 'Confirm clearing activity history',
|
||||||
@@ -71,6 +72,9 @@ export const activity = {
|
|||||||
},
|
},
|
||||||
detailAriaLabel: 'Token usage details by {{group}}',
|
detailAriaLabel: 'Token usage details by {{group}}',
|
||||||
empty: 'No token usage',
|
empty: 'No token usage',
|
||||||
|
refresh: 'Refresh',
|
||||||
|
refreshing: 'Refreshing',
|
||||||
|
refreshHint: 'Reload saved token usage',
|
||||||
fallbacks: {
|
fallbacks: {
|
||||||
unassignedProject: 'Unassigned project',
|
unassignedProject: 'Unassigned project',
|
||||||
deletedConversation: 'Deleted conversation',
|
deletedConversation: 'Deleted conversation',
|
||||||
@@ -78,6 +82,28 @@ export const activity = {
|
|||||||
unknownRuntime: 'Unknown Runtime'
|
unknownRuntime: 'Unknown Runtime'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
systemUsage: {
|
||||||
|
title: 'System task usage',
|
||||||
|
description:
|
||||||
|
'Tokens used by the supervisor, auto-supervision, magic notes, knowledge base, and background delegation. Not counted in conversation usage.',
|
||||||
|
groupAriaLabel: 'Group system task usage',
|
||||||
|
statsAriaLabel: 'System task usage totals',
|
||||||
|
empty: 'No system task token usage',
|
||||||
|
groups: {
|
||||||
|
source: 'By source'
|
||||||
|
},
|
||||||
|
columns: {
|
||||||
|
source: 'Source'
|
||||||
|
},
|
||||||
|
sources: {
|
||||||
|
heartbeat: 'Auto-supervision',
|
||||||
|
supervision: 'Supervisor',
|
||||||
|
magicNotes: 'Magic notes',
|
||||||
|
knowledge: 'Knowledge base',
|
||||||
|
delegation: 'Background delegation',
|
||||||
|
unknown: 'Other system tasks'
|
||||||
|
}
|
||||||
|
},
|
||||||
timeline: {
|
timeline: {
|
||||||
ariaLabel: 'Parallel activity tracks grouped by project and conversation',
|
ariaLabel: 'Parallel activity tracks grouped by project and conversation',
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -8,7 +8,8 @@ export const activity = {
|
|||||||
ariaLabel: '运行记录视图',
|
ariaLabel: '运行记录视图',
|
||||||
tasks: '任务与会话',
|
tasks: '任务与会话',
|
||||||
timeline: '活动时间线',
|
timeline: '活动时间线',
|
||||||
usage: '用量统计'
|
usage: '用量统计',
|
||||||
|
system: '系统任务'
|
||||||
},
|
},
|
||||||
clear: {
|
clear: {
|
||||||
confirmAriaLabel: '确认清空 {{formattedCount}} 条活动记录',
|
confirmAriaLabel: '确认清空 {{formattedCount}} 条活动记录',
|
||||||
@@ -68,6 +69,9 @@ export const activity = {
|
|||||||
},
|
},
|
||||||
detailAriaLabel: 'Token 用量{{group}}明细',
|
detailAriaLabel: 'Token 用量{{group}}明细',
|
||||||
empty: '暂无 Token 用量',
|
empty: '暂无 Token 用量',
|
||||||
|
refresh: '刷新',
|
||||||
|
refreshing: '正在刷新',
|
||||||
|
refreshHint: '重新读取已保存的 Token 用量',
|
||||||
fallbacks: {
|
fallbacks: {
|
||||||
unassignedProject: '未归属项目',
|
unassignedProject: '未归属项目',
|
||||||
deletedConversation: '已删除会话',
|
deletedConversation: '已删除会话',
|
||||||
@@ -75,6 +79,28 @@ export const activity = {
|
|||||||
unknownRuntime: '未知 Runtime'
|
unknownRuntime: '未知 Runtime'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
systemUsage: {
|
||||||
|
title: '系统任务用量',
|
||||||
|
description:
|
||||||
|
'监督者、自动监督、魔法笔记、知识库和后台委派等非会话任务消耗的 Token,不计入会话用量。',
|
||||||
|
groupAriaLabel: '系统任务用量分组',
|
||||||
|
statsAriaLabel: '系统任务用量统计',
|
||||||
|
empty: '暂无系统任务 Token 用量',
|
||||||
|
groups: {
|
||||||
|
source: '按来源'
|
||||||
|
},
|
||||||
|
columns: {
|
||||||
|
source: '来源'
|
||||||
|
},
|
||||||
|
sources: {
|
||||||
|
heartbeat: '自动监督',
|
||||||
|
supervision: '监督者',
|
||||||
|
magicNotes: '魔法笔记',
|
||||||
|
knowledge: '知识库',
|
||||||
|
delegation: '后台委派',
|
||||||
|
unknown: '其他系统任务'
|
||||||
|
}
|
||||||
|
},
|
||||||
timeline: {
|
timeline: {
|
||||||
ariaLabel: '按项目和会话分组的并行活动轨道',
|
ariaLabel: '按项目和会话分组的并行活动轨道',
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -14957,6 +14957,19 @@ textarea.field-control {
|
|||||||
font-size: var(--font-section-title);
|
font-size: var(--font-section-title);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.token-usage__refresh {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.token-usage__description {
|
||||||
|
margin: 8px 0 0;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: var(--font-caption);
|
||||||
|
}
|
||||||
|
|
||||||
.token-usage__stats {
|
.token-usage__stats {
|
||||||
display: grid;
|
display: grid;
|
||||||
margin: 12px 0;
|
margin: 12px 0;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { TokenUsageSummary } from '../../shared/assistant-contracts'
|
import type { TokenUsageSummary } from '../../shared/assistant-contracts'
|
||||||
|
|
||||||
export type TokenUsageGroup = 'project' | 'conversation' | 'model'
|
export type TokenUsageGroup = 'project' | 'conversation' | 'model' | 'source'
|
||||||
|
|
||||||
export type TokenUsageTotals = {
|
export type TokenUsageTotals = {
|
||||||
inputTokens: number
|
inputTokens: number
|
||||||
@@ -88,6 +88,15 @@ function groupIdentity(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (group === 'source') {
|
||||||
|
return {
|
||||||
|
key: `source:${record.systemSource ?? 'unknown'}`,
|
||||||
|
label: '',
|
||||||
|
model: '',
|
||||||
|
runtime: ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (group === 'conversation') {
|
if (group === 'conversation') {
|
||||||
const conversationKey = record.conversationId
|
const conversationKey = record.conversationId
|
||||||
? `conversation:${record.conversationId}`
|
? `conversation:${record.conversationId}`
|
||||||
@@ -114,6 +123,41 @@ export function getTokenUsageTotals(
|
|||||||
return usageNumbers(tokenUsage.totals)
|
return usageNumbers(tokenUsage.totals)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isSystemTokenUsageRecord(record: TokenUsageRecord): boolean {
|
||||||
|
return record.systemSource !== undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Splits a summary into conversation-only and system-only views. */
|
||||||
|
export function splitTokenUsage(tokenUsage: TokenUsageSummary): {
|
||||||
|
conversation: TokenUsageSummary
|
||||||
|
system: TokenUsageSummary
|
||||||
|
} {
|
||||||
|
const conversationRecords = tokenUsage.records.filter(
|
||||||
|
(record) => !isSystemTokenUsageRecord(record)
|
||||||
|
)
|
||||||
|
const systemRecords = tokenUsage.records.filter(isSystemTokenUsageRecord)
|
||||||
|
const emptyTotals = {
|
||||||
|
callCount: 0,
|
||||||
|
input: 0,
|
||||||
|
output: 0,
|
||||||
|
cacheRead: 0,
|
||||||
|
cacheWrite: 0,
|
||||||
|
cacheInput: 0,
|
||||||
|
totalTokens: 0
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
// Legacy summaries without split totals keep their overall totals.
|
||||||
|
conversation: {
|
||||||
|
totals: tokenUsage.conversationTotals ?? tokenUsage.totals,
|
||||||
|
records: conversationRecords
|
||||||
|
},
|
||||||
|
system: {
|
||||||
|
totals: tokenUsage.systemTotals ?? emptyTotals,
|
||||||
|
records: systemRecords
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function groupTokenUsage(
|
export function groupTokenUsage(
|
||||||
tokenUsage: TokenUsageSummary,
|
tokenUsage: TokenUsageSummary,
|
||||||
group: TokenUsageGroup
|
group: TokenUsageGroup
|
||||||
|
|||||||
@@ -781,8 +781,38 @@ export type ModelUsageCallInput = {
|
|||||||
cacheWrite: number
|
cacheWrite: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const tokenUsageSystemSources = [
|
||||||
|
'heartbeat',
|
||||||
|
'supervision',
|
||||||
|
'magic-notes',
|
||||||
|
'knowledge',
|
||||||
|
'delegation'
|
||||||
|
] as const
|
||||||
|
|
||||||
|
export type TokenUsageSystemSource = (typeof tokenUsageSystemSources)[number]
|
||||||
|
|
||||||
|
export type SystemModelUsageInput = Omit<
|
||||||
|
ModelUsageCallInput,
|
||||||
|
'requestId' | 'callId'
|
||||||
|
> & {
|
||||||
|
source: Extract<TokenUsageSystemSource, 'knowledge'>
|
||||||
|
bucket: 'graph-extraction' | 'embedding'
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TokenUsageTotals = {
|
||||||
|
callCount: number
|
||||||
|
input: number
|
||||||
|
output: number
|
||||||
|
cacheRead: number
|
||||||
|
cacheWrite: number
|
||||||
|
cacheInput?: number
|
||||||
|
totalTokens: number
|
||||||
|
}
|
||||||
|
|
||||||
export type TokenUsageRecord = {
|
export type TokenUsageRecord = {
|
||||||
requestId: string
|
requestId: string
|
||||||
|
/** Set when the usage came from background system work instead of a conversation. */
|
||||||
|
systemSource?: TokenUsageSystemSource
|
||||||
projectId?: string
|
projectId?: string
|
||||||
projectName?: string
|
projectName?: string
|
||||||
conversationId?: string
|
conversationId?: string
|
||||||
@@ -800,15 +830,12 @@ export type TokenUsageRecord = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type TokenUsageSummary = {
|
export type TokenUsageSummary = {
|
||||||
totals: {
|
/** Totals across all recorded usage. */
|
||||||
callCount: number
|
totals: TokenUsageTotals
|
||||||
input: number
|
/** Totals for conversation work only; absent in legacy summaries. */
|
||||||
output: number
|
conversationTotals?: TokenUsageTotals
|
||||||
cacheRead: number
|
/** Totals for background system work only; absent in legacy summaries. */
|
||||||
cacheWrite: number
|
systemTotals?: TokenUsageTotals
|
||||||
cacheInput?: number
|
|
||||||
totalTokens: number
|
|
||||||
}
|
|
||||||
records: TokenUsageRecord[]
|
records: TokenUsageRecord[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2159,6 +2159,8 @@ export type DesktopApi = {
|
|||||||
}
|
}
|
||||||
usage: {
|
usage: {
|
||||||
getTokenSummary: () => Promise<TokenUsageSummary>
|
getTokenSummary: () => Promise<TokenUsageSummary>
|
||||||
|
/** Fires (debounced) after any model usage is recorded. */
|
||||||
|
onChanged: (listener: () => void) => () => void
|
||||||
}
|
}
|
||||||
artifacts: {
|
artifacts: {
|
||||||
list: (projectId?: string) => Promise<AssistantArtifact[]>
|
list: (projectId?: string) => Promise<AssistantArtifact[]>
|
||||||
|
|||||||
@@ -249,6 +249,7 @@ export const ipcChannels = {
|
|||||||
activityHistoryGet: 'activity-history:get',
|
activityHistoryGet: 'activity-history:get',
|
||||||
activityHistoryReplace: 'activity-history:replace',
|
activityHistoryReplace: 'activity-history:replace',
|
||||||
tokenUsageSummary: 'usage:token-summary',
|
tokenUsageSummary: 'usage:token-summary',
|
||||||
|
tokenUsageChanged: 'usage:token-changed',
|
||||||
artifactsList: 'artifacts:list',
|
artifactsList: 'artifacts:list',
|
||||||
artifactsGet: 'artifacts:get',
|
artifactsGet: 'artifacts:get',
|
||||||
artifactsImportFiles: 'artifacts:import-files',
|
artifactsImportFiles: 'artifacts:import-files',
|
||||||
|
|||||||
Reference in New Issue
Block a user