Explorar el Código

feat(workspace): 新增智能体 Token 用量统计

- 在运行时从 Hermes 会话用量增量记录每日 token 消耗(agent-token-day)
- 新增 GET /api/app/agents/:id/usage 接口,聚合今日、本月与累计用量及近 31 天明细
- BotProfileDialog 新增“用量”页面展示统计结果
- 补充接口聚合逻辑的测试覆盖
Samien hace 1 mes
padre
commit
f000cd7e39

+ 32 - 4
src/client/components/workspace/BotProfileDialog.vue

@@ -1,20 +1,29 @@
 <script setup lang="ts">
-import {computed, reactive, ref} from 'vue'
+import {computed, onMounted, reactive, ref} from 'vue'
 import StandaloneDialog from '@/components/common/StandaloneDialog.vue'
 import AgentAvatar from '@/components/common/AgentAvatar.vue'
 import AppIcon from '@/components/common/AppIcon.vue'
 import AgentIdentityPanel from '@/components/app/AgentIdentityPanel.vue'
 import ModelServicesPanel from '@/components/app/ModelServicesPanel.vue'
 import WorkspaceKnowledgePanel from './WorkspaceKnowledgePanel.vue'
-import type {WorkspaceAgent, WorkspaceApprovalPolicy, WorkspaceConversation, WorkspaceSource} from '@shared/workspace'
+import {apiRequest} from '@/api/client'
+import type {WorkspaceAgent, WorkspaceAgentUsage, WorkspaceApprovalPolicy, WorkspaceConversation, WorkspaceSource} from '@shared/workspace'
 import type {Profile} from '@shared/types'
 
 interface Draft {name:string;avatar:string;instructions:string;source:string;canManageTeam:boolean;canCollaborate:boolean;memoryEnabled:boolean;approvalPolicy?:WorkspaceApprovalPolicy}
-type Page='overview'|'identity'|'rules'|'models'|'memory'|'permissions'
+type Page='overview'|'identity'|'rules'|'usage'|'models'|'memory'|'permissions'
 const props=defineProps<{agent:WorkspaceAgent;draft:Draft;sources:WorkspaceSource[];profiles:Profile[];agents:WorkspaceAgent[];conversations:WorkspaceConversation[];conversationId?:string;isAdmin?:boolean;knowledgeEnabled?:boolean;busy?:boolean;error?:string}>()
 const emit=defineEmits<{close:[];save:[draft:Draft & {approvalPolicy:WorkspaceApprovalPolicy}];changed:[]}>()
 const draft=reactive({...props.draft,approvalPolicy:props.draft.approvalPolicy??'ask'})
 const page=ref<Page>('overview'),query=ref(''),localError=ref(''),modelDirty=ref(false),memoryDirty=ref(false)
+const usage=ref<WorkspaceAgentUsage|null>(null),usageLoading=ref(false),usageError=ref('')
+const formatTokens=(value:number)=>value.toLocaleString('zh-CN')
+onMounted(async()=>{
+  usageLoading.value=true
+  try{usage.value=await apiRequest<WorkspaceAgentUsage>(`/api/app/agents/${props.agent.id}/usage`)}
+  catch{usageError.value='暂时无法读取 Token 用量'}
+  finally{usageLoading.value=false}
+})
 const readOnly=computed(()=>!!props.agent.remoteAgentId)
 const snapshot=()=>({name:draft.name,avatar:draft.avatar,instructions:draft.instructions,source:draft.source,canManageTeam:draft.canManageTeam,canCollaborate:draft.canCollaborate,memoryEnabled:draft.memoryEnabled,approvalPolicy:draft.approvalPolicy})
 const baseline=JSON.stringify(snapshot())
@@ -34,6 +43,7 @@ const allPages=computed(()=>[
   {id:'overview' as const,label:'概览',icon:'panel' as const},
   {id:'identity' as const,label:'身份与头像',icon:'users' as const},
   {id:'rules' as const,label:'角色与规则',icon:'files' as const},
+  {id:'usage' as const,label:'Token 用量',icon:'history' as const},
   ...(props.isAdmin?[{id:'models' as const,label:'模型与 Provider',icon:'model' as const}]:[]),
   ...(props.knowledgeEnabled&&!readOnly.value?[{id:'memory' as const,label:'记忆',icon:'brain' as const}]:[]),
   {id:'permissions' as const,label:'权限',icon:'settings' as const},
@@ -72,10 +82,27 @@ function submit(){
           <p v-if="readOnly" class="profile-note">名称、头像与角色规则由远端管理;权限审批可以在这里单独设置。</p>
           <template v-if="page==='overview'">
             <section class="profile-card"><div class="profile-who"><AgentAvatar :name="draft.name" :avatar="draft.avatar" :size="52"/><div><h4>{{draft.name}}</h4><p>{{draft.instructions.trim().slice(0,180)||'尚未填写角色规则。'}}</p></div></div><button type="button" @click="selectPage('identity')">编辑身份与头像</button></section>
-            <section class="profile-card"><h4>运行与协作</h4><dl><div><dt>基础机器人</dt><dd>{{sourceLabel}} / {{sourceProfile}}</dd></div><div><dt>执行环境</dt><dd>{{environment}}</dd></div><div><dt>Bot 协作</dt><dd>{{draft.canCollaborate?'已允许':'未开启'}}</dd></div><div><dt>组建团队</dt><dd>{{draft.canManageTeam?'已允许':'未开启'}}</dd></div></dl></section>
+            <section class="profile-card"><h4>运行与协作</h4><dl><div><dt>基础机器人</dt><dd>{{sourceLabel}} / {{sourceProfile}}</dd></div><div><dt>执行环境</dt><dd>{{environment}}</dd></div><div><dt>Token 用量</dt><dd>{{usageLoading?'读取中…':usage?`今日 ${formatTokens(usage.todayUsage.total)} · 本月 ${formatTokens(usage.monthUsage.total)}`:'暂无统计'}}</dd></div><div><dt>Bot 协作</dt><dd>{{draft.canCollaborate?'已允许':'未开启'}}</dd></div><div><dt>组建团队</dt><dd>{{draft.canManageTeam?'已允许':'未开启'}}</dd></div></dl><button type="button" @click="selectPage('usage')">查看 Token 用量</button></section>
           </template>
           <form v-else-if="page==='identity'" class="profile-form" @submit.prevent="submit"><label>名称<input v-model="draft.name" required maxlength="100" :readonly="readOnly" :disabled="busy"/></label><AgentIdentityPanel v-if="!readOnly" :profile="avatarProfile" embedded :show-name="false" :show-default-model="false" :show-actions="false" :busy="busy" @avatar-change="draft.avatar=$event"/><AgentAvatar v-else :name="draft.name" :avatar="draft.avatar" :size="96"/></form>
           <form v-else-if="page==='rules'" class="profile-form" @submit.prevent="submit"><label>角色提示词与规则<textarea v-model="draft.instructions" rows="12" maxlength="24000" :readonly="readOnly" :disabled="busy" placeholder="描述这个 Bot 的职责、回答方式和工作规则。"/></label><p class="profile-note">这些规则属于当前 Bot,会在它的单聊和群聊中使用。</p></form>
+          <template v-else-if="page==='usage'">
+            <p v-if="usageError" class="profile-note">{{usageError}}</p>
+            <section v-if="usage" class="profile-card">
+              <h4>Token 用量</h4>
+              <div class="usage-stats">
+                <div class="usage-stat"><small>今日({{usage.today}})</small><strong>{{formatTokens(usage.todayUsage.total)}}</strong><span>输入 {{formatTokens(usage.todayUsage.input)}} · 输出 {{formatTokens(usage.todayUsage.output)}}</span></div>
+                <div class="usage-stat"><small>本月({{usage.month}})</small><strong>{{formatTokens(usage.monthUsage.total)}}</strong><span>输入 {{formatTokens(usage.monthUsage.input)}} · 输出 {{formatTokens(usage.monthUsage.output)}}</span></div>
+                <div class="usage-stat"><small>累计</small><strong>{{formatTokens(usage.totalUsage.total)}}</strong><span>输入 {{formatTokens(usage.totalUsage.input)}} · 输出 {{formatTokens(usage.totalUsage.output)}}</span></div>
+              </div>
+            </section>
+            <section v-if="usage" class="profile-card">
+              <h4>每日明细</h4>
+              <p v-if="!usage.daily.length" class="profile-note">还没有记录。与这个 Bot 对话后,这里会按天累计输入和输出的 Token。</p>
+              <table v-else class="usage-table"><thead><tr><th>日期</th><th>输入</th><th>输出</th><th>合计</th></tr></thead><tbody><tr v-for="day in usage.daily" :key="day.date"><td>{{day.date}}</td><td>{{formatTokens(day.input)}}</td><td>{{formatTokens(day.output)}}</td><td>{{formatTokens(day.total)}}</td></tr></tbody></table>
+            </section>
+            <p class="profile-note">用量在每次运行结束时从模型会话统计(输入 + 输出 Token)。从本版本起开始累计;远程 Bot 或个别模型不回报用量时无法统计。</p>
+          </template>
           <template v-else-if="page==='models' && isAdmin">
             <p v-if="readOnly||agent.nodeId!=='local'" class="profile-note">模型与 Provider 由远端执行节点管理,请在对应节点配置。</p>
             <p v-else-if="sourceChanged" class="profile-note">请先保存基础机器人的变更,再配置对应的模型与 Provider。</p>
@@ -106,5 +133,6 @@ function submit(){
 </style>
 <style scoped>
 .bot-profile__sidebar{min-width:0}@media(max-width:600px){.bot-profile{grid-template-columns:minmax(0,1fr)}}
+.usage-stats{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:12px}.usage-stat{display:grid;gap:6px;padding:14px;border:1px solid var(--line);border-radius:10px;background:var(--surface)}.usage-stat small{color:var(--text-muted);font-size:11px}.usage-stat strong{font-size:20px;font-weight:650;font-variant-numeric:tabular-nums}.usage-stat span{color:var(--text-secondary);font-size:11px}.usage-table{width:100%;border-collapse:collapse;font-size:12px;font-variant-numeric:tabular-nums}.usage-table th{color:var(--text-muted);font-weight:500;text-align:right;padding:6px 4px;border-bottom:1px solid var(--line)}.usage-table th:first-child,.usage-table td:first-child{text-align:left}.usage-table td{padding:7px 4px;border-bottom:1px solid var(--line);text-align:right}.usage-table td:first-child{color:var(--text-secondary)}
 .profile-source .profile-source__picker{position:relative;min-height:48px;padding:6px 8px;margin:0;border:1px solid var(--line);border-radius:8px;background:var(--settings-panel);box-sizing:border-box}.profile-source__identity{flex:1}.profile-source__picker>select{position:absolute;inset:0;width:100%;height:100%;opacity:0;cursor:pointer}.profile-source__picker:focus-within{outline:2px solid var(--accent);outline-offset:2px}@media(max-width:600px){.profile-source{display:block}.profile-source .profile-source__picker{max-width:100%}}
 </style>

+ 25 - 0
src/server/workspaceRoutes.ts

@@ -26,6 +26,8 @@ import { compareWorkspaceConversations } from '../shared/workspace.js'
 import type {
   WorkspaceMessage,
   WorkspaceAgent,
+  WorkspaceAgentUsage,
+  WorkspaceAgentUsageSummary,
   WorkspaceConversation,
   WorkspaceRun,
   WorkspaceInteraction,
@@ -145,6 +147,29 @@ export function workspaceRouter(
     store.deleteAgent(owner(ctx), ctx.params.id)
     ctx.body = { ok: true }
   })
+  router.get('/api/app/agents/:id/usage', (ctx) => {
+    const user = owner(ctx), agent = store.require<WorkspaceAgent>(user, 'agent', ctx.params.id)
+    const days = store
+      .list<WorkspaceAgentUsageSummary & { agentId: string; date: string; updatedAt: number }>(user, 'agent-token-day')
+      .filter(day => day.agentId === agent.id)
+      .sort((a, b) => b.date.localeCompare(a.date))
+    const now = new Date(),
+      today = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`,
+      month = today.slice(0, 7)
+    const summarize = (rows: typeof days): WorkspaceAgentUsageSummary =>
+      rows.reduce<WorkspaceAgentUsageSummary>((sum, row) => ({
+        input: sum.input + (row.input ?? 0), output: sum.output + (row.output ?? 0), total: sum.total + (row.total ?? 0),
+      }), { input: 0, output: 0, total: 0 })
+    const usage: WorkspaceAgentUsage = {
+      agentId: agent.id,
+      today, month,
+      todayUsage: summarize(days.filter(day => day.date === today)),
+      monthUsage: summarize(days.filter(day => day.date.startsWith(month))),
+      totalUsage: summarize(days),
+      daily: days.slice(0, 31).map(day => ({ date: day.date, input: day.input ?? 0, output: day.output ?? 0, total: day.total ?? 0 })),
+    }
+    ctx.body = usage
+  })
   router.delete('/api/app/conversations/:id', (ctx) => {
     store.deleteConversation(owner(ctx), ctx.params.id)
     ctx.body = { ok: true }

+ 38 - 0
src/server/workspaceRuntime.ts

@@ -15,6 +15,7 @@ import type {
   WorkspaceMessage as Message,
   WorkspaceRun,
   WorkspaceInteraction,
+  WorkspaceAgentUsageSummary,
 } from '../shared/workspace.js'
 
 import { WorkspaceScheduler, type Work, NO_REPLY, HOST_FALLBACK, WORKSPACE_CONCURRENCY_LIMIT } from './workspaceScheduler.js'
@@ -495,6 +496,7 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
           ])
             .then((usage) => {
               if (usage && !this.closing && this.store.get<{ taskId: string }>(owner, 'binding', key)?.taskId === run.id) {
+                this.recordAgentUsage(owner, key, agent.id, usage)
                 const used = usage.context_used ?? usage.used_tokens,
                   limit = usage.context_max ?? usage.context_limit
                 const contextKey = run.conversationTaskId ?? c.id,
@@ -529,6 +531,7 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
         finish(new HttpError(502, String(p.message ?? p.error ?? '运行失败'), 'run_failed'))
         return
       } else if (['session.usage', 'usage.update', 'context.update'].includes(type)) {
+        if (type !== 'context.update') this.recordAgentUsage(owner, key, agent.id, p)
         const context = { ...p, conversationTaskId: run.conversationTaskId }
         this.store.put(owner, 'context', run.conversationTaskId ?? c.id, context)
         this.store.event(owner, 'context.changed', context, c.id)
@@ -912,6 +915,41 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
       if (task.conversationId === conversationId) this.tasks.cancel(owner, task.id)
     await Promise.all([super.stopConversation(owner, conversationId), this.tasks.cancelOrigin(owner, conversationId)])
   }
+  /**
+   * Hermes reports cumulative per-session token counters (input/output/total) alongside
+   * context occupancy. Attribute the growth since the last snapshot to the agent's daily
+   * usage bucket; a session reset (counters dropping) only re-baselines.
+   */
+  private recordAgentUsage(owner: string, key: string, agentId: string, usage: unknown): void {
+    if (!usage || typeof usage !== 'object') return
+    const value = usage as Record<string, unknown>
+    const counter = (...names: string[]) => {
+      for (const name of names) {
+        const raw = value[name]
+        if (typeof raw === 'number' && Number.isFinite(raw) && raw >= 0) return Math.round(raw)
+      }
+      return undefined
+    }
+    const input = counter('input', 'prompt', 'input_tokens'),
+      output = counter('output', 'completion', 'output_tokens'),
+      total = counter('total', 'total_tokens') ?? (input === undefined && output === undefined ? undefined : (input ?? 0) + (output ?? 0))
+    if (total === undefined) return
+    this.store.atomic(() => {
+      const baseline = this.store.get<{ input?: number; output?: number; total?: number }>(owner, 'usage-baseline', key) ?? {}
+      const grew = (current: number | undefined, previous: number | undefined) => (current === undefined ? 0 : Math.max(0, current - (previous ?? 0)))
+      const inputDelta = grew(input, baseline.input), outputDelta = grew(output, baseline.output), totalDelta = grew(total, baseline.total)
+      this.store.put(owner, 'usage-baseline', key, { input, output, total })
+      if (!inputDelta && !outputDelta && !totalDelta) return
+      const now = new Date(),
+        date = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`,
+        id = `${agentId}:${date}`
+      const day = this.store.get<WorkspaceAgentUsageSummary & { agentId: string; date: string; updatedAt: number }>(owner, 'agent-token-day', id)
+        ?? { agentId, date, input: 0, output: 0, total: 0, updatedAt: 0 }
+      day.input += inputDelta; day.output += outputDelta; day.total += Math.max(totalDelta, inputDelta + outputDelta)
+      day.updatedAt = now.getTime()
+      this.store.put(owner, 'agent-token-day', id, day)
+    })
+  }
   close(): void {
     this.collaboration.close()
     this.tasks.close()

+ 15 - 0
src/shared/workspace.ts

@@ -226,3 +226,18 @@ export interface WorkspaceSource {
   profile: string
   name: string
 }
+/** Per-agent token accounting derived from Hermes session usage deltas. */
+export interface WorkspaceAgentUsageSummary {
+  input: number
+  output: number
+  total: number
+}
+export interface WorkspaceAgentUsage {
+  agentId: string
+  today: string
+  month: string
+  todayUsage: WorkspaceAgentUsageSummary
+  monthUsage: WorkspaceAgentUsageSummary
+  totalUsage: WorkspaceAgentUsageSummary
+  daily: Array<{ date: string } & WorkspaceAgentUsageSummary>
+}

+ 23 - 0
tests/server/workspaceRoutes.test.ts

@@ -595,3 +595,26 @@ it('prepares CSRF with capabilities and returns an idempotent committed send rec
   expect(b.run.id).toBe(a.run.id)
   expect(store.messages('first', conversation.id).filter(m => m.role === 'user')).toHaveLength(1)
 })
+
+it('aggregates per-agent daily token usage for today, month and lifetime', async () => {
+  const store = runtime.workspace
+  const agent = store.createAgent('first', { name: '用量统计', profile: 'default' })
+  const other = store.createAgent('first', { name: '用量其他', profile: 'default' })
+  const now = new Date(),
+    pad = (n: number) => String(n).padStart(2, '0'),
+    today = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`,
+    month = today.slice(0, 7),
+    older = `${month}-01`
+  const seed = (agentId: string, date: string, input: number, output: number) =>
+    store.put('first', 'agent-token-day', `${agentId}:${date}`, { agentId, date, input, output, total: input + output, updatedAt: 1 })
+  seed(agent.id, today, 300, 100)
+  seed(agent.id, older, 2000, 1000)
+  seed(other.id, today, 99999, 99999)
+  const usage = (await req('get', `/api/app/agents/${agent.id}/usage`).expect(200)).body
+  expect(usage.today).toBe(today)
+  expect(usage.todayUsage).toEqual({ input: 300, output: 100, total: 400 })
+  expect(usage.monthUsage).toEqual({ input: 2300, output: 1100, total: 3400 })
+  expect(usage.totalUsage).toEqual({ input: 2300, output: 1100, total: 3400 })
+  expect(usage.daily.map((d: any) => d.date)).toEqual([today, older])
+  await req('get', `/api/app/agents/${randomUUID()}/usage`).expect(404)
+})