@open-mercato/ai-assistant 0.6.1-develop.3291.1.6fad645fd0 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (135) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/AGENTS.md +30 -4
  3. package/dist/frontend/components/AiChatButton.js +3 -2
  4. package/dist/frontend/components/AiChatButton.js.map +2 -2
  5. package/dist/modules/ai_assistant/__integration__/TC-AI-AGENT-LOOP-001-006.spec.js +364 -0
  6. package/dist/modules/ai_assistant/__integration__/TC-AI-AGENT-LOOP-001-006.spec.js.map +7 -0
  7. package/dist/modules/ai_assistant/__integration__/TC-AI-RUNTIME-OVERRIDES-006-model-picker.spec.js +7 -7
  8. package/dist/modules/ai_assistant/__integration__/TC-AI-RUNTIME-OVERRIDES-006-model-picker.spec.js.map +2 -2
  9. package/dist/modules/ai_assistant/__integration__/TC-AI-TOKEN-USAGE-001-005.spec.js +182 -0
  10. package/dist/modules/ai_assistant/__integration__/TC-AI-TOKEN-USAGE-001-005.spec.js.map +7 -0
  11. package/dist/modules/ai_assistant/api/ai/agents/[agentId]/loop-override/route.js +316 -0
  12. package/dist/modules/ai_assistant/api/ai/agents/[agentId]/loop-override/route.js.map +7 -0
  13. package/dist/modules/ai_assistant/api/ai/agents/[agentId]/models/route.js +8 -7
  14. package/dist/modules/ai_assistant/api/ai/agents/[agentId]/models/route.js.map +2 -2
  15. package/dist/modules/ai_assistant/api/ai/chat/route.js +43 -20
  16. package/dist/modules/ai_assistant/api/ai/chat/route.js.map +2 -2
  17. package/dist/modules/ai_assistant/api/settings/route.js +4 -3
  18. package/dist/modules/ai_assistant/api/settings/route.js.map +2 -2
  19. package/dist/modules/ai_assistant/api/usage/daily/route.js +111 -0
  20. package/dist/modules/ai_assistant/api/usage/daily/route.js.map +7 -0
  21. package/dist/modules/ai_assistant/api/usage/sessions/[sessionId]/route.js +108 -0
  22. package/dist/modules/ai_assistant/api/usage/sessions/[sessionId]/route.js.map +7 -0
  23. package/dist/modules/ai_assistant/api/usage/sessions/route.js +153 -0
  24. package/dist/modules/ai_assistant/api/usage/sessions/route.js.map +7 -0
  25. package/dist/modules/ai_assistant/backend/config/ai-assistant/agents/AiAgentSettingsPageClient.js +335 -38
  26. package/dist/modules/ai_assistant/backend/config/ai-assistant/agents/AiAgentSettingsPageClient.js.map +2 -2
  27. package/dist/modules/ai_assistant/backend/config/ai-assistant/allowlist/AiTenantAllowlistPageClient.js +2 -7
  28. package/dist/modules/ai_assistant/backend/config/ai-assistant/allowlist/AiTenantAllowlistPageClient.js.map +2 -2
  29. package/dist/modules/ai_assistant/backend/config/ai-assistant/playground/AiPlaygroundPageClient.js +44 -35
  30. package/dist/modules/ai_assistant/backend/config/ai-assistant/playground/AiPlaygroundPageClient.js.map +2 -2
  31. package/dist/modules/ai_assistant/backend/config/ai-assistant/usage/AiUsageStatsPageClient.js +282 -0
  32. package/dist/modules/ai_assistant/backend/config/ai-assistant/usage/AiUsageStatsPageClient.js.map +7 -0
  33. package/dist/modules/ai_assistant/backend/config/ai-assistant/usage/page.js +10 -0
  34. package/dist/modules/ai_assistant/backend/config/ai-assistant/usage/page.js.map +7 -0
  35. package/dist/modules/ai_assistant/backend/config/ai-assistant/usage/page.meta.js +25 -0
  36. package/dist/modules/ai_assistant/backend/config/ai-assistant/usage/page.meta.js.map +7 -0
  37. package/dist/modules/ai_assistant/cli.js +12 -0
  38. package/dist/modules/ai_assistant/cli.js.map +2 -2
  39. package/dist/modules/ai_assistant/components/AiAssistantSettingsPageClient.js.map +1 -1
  40. package/dist/modules/ai_assistant/data/entities.js +177 -1
  41. package/dist/modules/ai_assistant/data/entities.js.map +2 -2
  42. package/dist/modules/ai_assistant/data/repositories/AiAgentRuntimeOverrideRepository.js +104 -2
  43. package/dist/modules/ai_assistant/data/repositories/AiAgentRuntimeOverrideRepository.js.map +2 -2
  44. package/dist/modules/ai_assistant/data/repositories/AiTokenUsageRepository.js +168 -0
  45. package/dist/modules/ai_assistant/data/repositories/AiTokenUsageRepository.js.map +7 -0
  46. package/dist/modules/ai_assistant/events.js +8 -0
  47. package/dist/modules/ai_assistant/events.js.map +2 -2
  48. package/dist/modules/ai_assistant/i18n/de.json +74 -1
  49. package/dist/modules/ai_assistant/i18n/en.json +74 -1
  50. package/dist/modules/ai_assistant/i18n/es.json +75 -2
  51. package/dist/modules/ai_assistant/i18n/pl.json +74 -1
  52. package/dist/modules/ai_assistant/lib/agent-policy.js.map +2 -2
  53. package/dist/modules/ai_assistant/lib/agent-runtime.js +588 -23
  54. package/dist/modules/ai_assistant/lib/agent-runtime.js.map +3 -3
  55. package/dist/modules/ai_assistant/lib/agent-tools.js +6 -1
  56. package/dist/modules/ai_assistant/lib/agent-tools.js.map +2 -2
  57. package/dist/modules/ai_assistant/lib/ai-agent-definition.js.map +2 -2
  58. package/dist/modules/ai_assistant/lib/model-factory.js +63 -22
  59. package/dist/modules/ai_assistant/lib/model-factory.js.map +2 -2
  60. package/dist/modules/ai_assistant/lib/token-usage-recorder.js +78 -0
  61. package/dist/modules/ai_assistant/lib/token-usage-recorder.js.map +7 -0
  62. package/dist/modules/ai_assistant/lib/usage-serialization.js +33 -0
  63. package/dist/modules/ai_assistant/lib/usage-serialization.js.map +7 -0
  64. package/dist/modules/ai_assistant/migrations/Migration20260508160000_ai_agent_loop_overrides.js +25 -0
  65. package/dist/modules/ai_assistant/migrations/Migration20260508160000_ai_agent_loop_overrides.js.map +7 -0
  66. package/dist/modules/ai_assistant/migrations/Migration20260508170000_ai_token_usage.js +88 -0
  67. package/dist/modules/ai_assistant/migrations/Migration20260508170000_ai_token_usage.js.map +7 -0
  68. package/dist/modules/ai_assistant/setup.js +34 -0
  69. package/dist/modules/ai_assistant/setup.js.map +2 -2
  70. package/dist/modules/ai_assistant/workers/ai-token-usage-prune.js +114 -0
  71. package/dist/modules/ai_assistant/workers/ai-token-usage-prune.js.map +7 -0
  72. package/generated/entities/ai_agent_runtime_override/index.ts +7 -0
  73. package/generated/entities/ai_token_usage_daily/index.ts +16 -0
  74. package/generated/entities/ai_token_usage_event/index.ts +19 -0
  75. package/generated/entities.ids.generated.ts +2 -0
  76. package/generated/entity-fields-registry.ts +47 -1
  77. package/package.json +15 -7
  78. package/src/frontend/components/AiChatButton.tsx +3 -2
  79. package/src/modules/ai_assistant/__integration__/TC-AI-AGENT-LOOP-001-006.spec.ts +521 -0
  80. package/src/modules/ai_assistant/__integration__/TC-AI-RUNTIME-OVERRIDES-006-model-picker.spec.ts +8 -8
  81. package/src/modules/ai_assistant/__integration__/TC-AI-TOKEN-USAGE-001-005.spec.ts +231 -0
  82. package/src/modules/ai_assistant/__tests__/events.test.ts +4 -3
  83. package/src/modules/ai_assistant/__tests__/settings-page-logic.test.ts +5 -5
  84. package/src/modules/ai_assistant/__tests__/token-usage-recorder.test.ts +109 -0
  85. package/src/modules/ai_assistant/api/ai/agents/[agentId]/loop-override/route.ts +388 -0
  86. package/src/modules/ai_assistant/api/ai/agents/[agentId]/models/__tests__/route.test.ts +5 -0
  87. package/src/modules/ai_assistant/api/ai/agents/[agentId]/models/route.ts +8 -7
  88. package/src/modules/ai_assistant/api/ai/chat/__tests__/route.test.ts +102 -5
  89. package/src/modules/ai_assistant/api/ai/chat/route.ts +55 -18
  90. package/src/modules/ai_assistant/api/settings/route.ts +5 -3
  91. package/src/modules/ai_assistant/api/usage/daily/__tests__/route.test.ts +159 -0
  92. package/src/modules/ai_assistant/api/usage/daily/route.ts +126 -0
  93. package/src/modules/ai_assistant/api/usage/sessions/[sessionId]/__tests__/route.test.ts +143 -0
  94. package/src/modules/ai_assistant/api/usage/sessions/[sessionId]/route.ts +130 -0
  95. package/src/modules/ai_assistant/api/usage/sessions/__tests__/route.test.ts +123 -0
  96. package/src/modules/ai_assistant/api/usage/sessions/route.ts +184 -0
  97. package/src/modules/ai_assistant/backend/config/ai-assistant/agents/AiAgentSettingsPageClient.tsx +372 -16
  98. package/src/modules/ai_assistant/backend/config/ai-assistant/allowlist/AiTenantAllowlistPageClient.tsx +1 -4
  99. package/src/modules/ai_assistant/backend/config/ai-assistant/playground/AiPlaygroundPageClient.tsx +26 -9
  100. package/src/modules/ai_assistant/backend/config/ai-assistant/usage/AiUsageStatsPageClient.tsx +469 -0
  101. package/src/modules/ai_assistant/backend/config/ai-assistant/usage/page.meta.ts +23 -0
  102. package/src/modules/ai_assistant/backend/config/ai-assistant/usage/page.tsx +12 -0
  103. package/src/modules/ai_assistant/cli.ts +18 -0
  104. package/src/modules/ai_assistant/components/AiAssistantSettingsPageClient.tsx +1 -1
  105. package/src/modules/ai_assistant/data/entities.ts +237 -0
  106. package/src/modules/ai_assistant/data/repositories/AiAgentRuntimeOverrideRepository.ts +135 -3
  107. package/src/modules/ai_assistant/data/repositories/AiTokenUsageRepository.ts +213 -0
  108. package/src/modules/ai_assistant/data/repositories/__tests__/AiAgentRuntimeOverrideRepository.test.ts +223 -0
  109. package/src/modules/ai_assistant/data/repositories/__tests__/AiTokenUsageRepository.test.ts +58 -0
  110. package/src/modules/ai_assistant/events.ts +8 -0
  111. package/src/modules/ai_assistant/i18n/de.json +74 -1
  112. package/src/modules/ai_assistant/i18n/en.json +74 -1
  113. package/src/modules/ai_assistant/i18n/es.json +75 -2
  114. package/src/modules/ai_assistant/i18n/pl.json +74 -1
  115. package/src/modules/ai_assistant/lib/__tests__/agent-runtime-loop-phase0.test.ts +439 -0
  116. package/src/modules/ai_assistant/lib/__tests__/agent-runtime-loop-phase1.test.ts +243 -0
  117. package/src/modules/ai_assistant/lib/__tests__/agent-runtime-loop-phase2.test.ts +388 -0
  118. package/src/modules/ai_assistant/lib/__tests__/agent-runtime-loop-phase3.test.ts +359 -0
  119. package/src/modules/ai_assistant/lib/__tests__/agent-runtime-phase4a.test.ts +2 -2
  120. package/src/modules/ai_assistant/lib/__tests__/agent-runtime.test.ts +2 -1
  121. package/src/modules/ai_assistant/lib/__tests__/max-steps-budget.integration.test.ts +12 -13
  122. package/src/modules/ai_assistant/lib/__tests__/model-factory.test.ts +77 -14
  123. package/src/modules/ai_assistant/lib/agent-policy.ts +9 -0
  124. package/src/modules/ai_assistant/lib/agent-runtime.ts +1148 -43
  125. package/src/modules/ai_assistant/lib/agent-tools.ts +5 -1
  126. package/src/modules/ai_assistant/lib/ai-agent-definition.ts +289 -2
  127. package/src/modules/ai_assistant/lib/model-factory.ts +128 -43
  128. package/src/modules/ai_assistant/lib/token-usage-recorder.ts +122 -0
  129. package/src/modules/ai_assistant/lib/usage-serialization.ts +29 -0
  130. package/src/modules/ai_assistant/migrations/.snapshot-open-mercato.json +791 -0
  131. package/src/modules/ai_assistant/migrations/Migration20260508160000_ai_agent_loop_overrides.ts +25 -0
  132. package/src/modules/ai_assistant/migrations/Migration20260508170000_ai_token_usage.ts +89 -0
  133. package/src/modules/ai_assistant/setup.ts +49 -0
  134. package/src/modules/ai_assistant/workers/__tests__/ai-token-usage-prune.test.ts +144 -0
  135. package/src/modules/ai_assistant/workers/ai-token-usage-prune.ts +188 -0
@@ -0,0 +1,469 @@
1
+ 'use client'
2
+
3
+ import * as React from 'react'
4
+ import { useQuery } from '@tanstack/react-query'
5
+ import { BarChart2, ChevronRight, Loader2 } from 'lucide-react'
6
+ import { useT } from '@open-mercato/shared/lib/i18n/context'
7
+ import { Button } from '@open-mercato/ui/primitives/button'
8
+ import { Input } from '@open-mercato/ui/primitives/input'
9
+ import { Label } from '@open-mercato/ui/primitives/label'
10
+ import {
11
+ Dialog,
12
+ DialogContent,
13
+ DialogHeader,
14
+ DialogTitle,
15
+ } from '@open-mercato/ui/primitives/dialog'
16
+ import { apiCallOrThrow } from '@open-mercato/ui/backend/utils/apiCall'
17
+
18
+ type DailyRow = {
19
+ id: string
20
+ tenantId: string
21
+ organizationId: string | null
22
+ day: string
23
+ agentId: string
24
+ modelId: string
25
+ providerId: string
26
+ inputTokens: string
27
+ outputTokens: string
28
+ cachedInputTokens: string
29
+ reasoningTokens: string
30
+ stepCount: string
31
+ turnCount: string
32
+ sessionCount: string
33
+ createdAt: string
34
+ updatedAt: string
35
+ }
36
+
37
+ type DailyResponse = {
38
+ rows: DailyRow[]
39
+ total: number
40
+ }
41
+
42
+ type SessionSummary = {
43
+ sessionId: string
44
+ agentId: string
45
+ moduleId: string
46
+ userId: string
47
+ startedAt: string
48
+ lastEventAt: string
49
+ stepCount: number
50
+ turnCount: number
51
+ inputTokens: number
52
+ outputTokens: number
53
+ cachedInputTokens: number
54
+ reasoningTokens: number
55
+ }
56
+
57
+ type SessionsResponse = {
58
+ sessions: SessionSummary[]
59
+ total: number
60
+ limit: number
61
+ offset: number
62
+ }
63
+
64
+ type StepEvent = {
65
+ id: string
66
+ tenantId: string
67
+ organizationId: string | null
68
+ userId: string
69
+ agentId: string
70
+ moduleId: string
71
+ sessionId: string
72
+ turnId: string
73
+ stepIndex: number
74
+ providerId: string
75
+ modelId: string
76
+ inputTokens: number
77
+ outputTokens: number
78
+ cachedInputTokens: number | null
79
+ reasoningTokens: number | null
80
+ finishReason: string | null
81
+ loopAbortReason: string | null
82
+ createdAt: string
83
+ updatedAt: string
84
+ }
85
+
86
+ type SessionDetailResponse = {
87
+ events: StepEvent[]
88
+ total: number
89
+ sessionId: string
90
+ }
91
+
92
+ function todayIso(): string {
93
+ return new Date().toISOString().slice(0, 10)
94
+ }
95
+
96
+ function daysAgoIso(days: number): string {
97
+ const date = new Date()
98
+ date.setDate(date.getDate() - days)
99
+ return date.toISOString().slice(0, 10)
100
+ }
101
+
102
+ async function fetchDailyRollup(from: string, to: string): Promise<DailyResponse> {
103
+ const params = new URLSearchParams({ from, to })
104
+ const { result, status } = await apiCallOrThrow<DailyResponse>(
105
+ `/api/ai_assistant/usage/daily?${params}`,
106
+ { method: 'GET', credentials: 'include' },
107
+ { errorMessage: 'Failed to load daily token usage' },
108
+ )
109
+ if (!result) throw new Error(`Failed to load daily usage (${status})`)
110
+ return result
111
+ }
112
+
113
+ async function fetchSessions(from: string, to: string, offset: number): Promise<SessionsResponse> {
114
+ const params = new URLSearchParams({ from, to, limit: '50', offset: String(offset) })
115
+ const { result, status } = await apiCallOrThrow<SessionsResponse>(
116
+ `/api/ai_assistant/usage/sessions?${params}`,
117
+ { method: 'GET', credentials: 'include' },
118
+ { errorMessage: 'Failed to load session list' },
119
+ )
120
+ if (!result) throw new Error(`Failed to load sessions (${status})`)
121
+ return result
122
+ }
123
+
124
+ async function fetchSessionDetail(sessionId: string): Promise<SessionDetailResponse> {
125
+ const { result, status } = await apiCallOrThrow<SessionDetailResponse>(
126
+ `/api/ai_assistant/usage/sessions/${encodeURIComponent(sessionId)}`,
127
+ { method: 'GET', credentials: 'include' },
128
+ { errorMessage: 'Failed to load session detail' },
129
+ )
130
+ if (!result) throw new Error(`Failed to load session detail (${status})`)
131
+ return result
132
+ }
133
+
134
+ function sumBigintRows(rows: DailyRow[], field: keyof DailyRow): number {
135
+ return rows.reduce((acc, row) => acc + parseInt(String(row[field] ?? '0'), 10), 0)
136
+ }
137
+
138
+ function formatNumber(value: number): string {
139
+ return value.toLocaleString()
140
+ }
141
+
142
+ function formatDate(iso: string): string {
143
+ return new Date(iso).toLocaleString()
144
+ }
145
+
146
+ function shortId(id: string): string {
147
+ return id.slice(0, 8)
148
+ }
149
+
150
+ export function AiUsageStatsPageClient() {
151
+ const t = useT()
152
+
153
+ const defaultFrom = daysAgoIso(30)
154
+ const defaultTo = todayIso()
155
+
156
+ const [from, setFrom] = React.useState(defaultFrom)
157
+ const [to, setTo] = React.useState(defaultTo)
158
+ const [appliedFrom, setAppliedFrom] = React.useState(defaultFrom)
159
+ const [appliedTo, setAppliedTo] = React.useState(defaultTo)
160
+ const [sessionsOffset, setSessionsOffset] = React.useState(0)
161
+ const [selectedSessionId, setSelectedSessionId] = React.useState<string | null>(null)
162
+
163
+ const dailyQuery = useQuery({
164
+ queryKey: ['ai-usage-daily', appliedFrom, appliedTo],
165
+ queryFn: () => fetchDailyRollup(appliedFrom, appliedTo),
166
+ })
167
+
168
+ const sessionsQuery = useQuery({
169
+ queryKey: ['ai-usage-sessions', appliedFrom, appliedTo, sessionsOffset],
170
+ queryFn: () => fetchSessions(appliedFrom, appliedTo, sessionsOffset),
171
+ })
172
+
173
+ const sessionDetailQuery = useQuery({
174
+ queryKey: ['ai-usage-session-detail', selectedSessionId],
175
+ queryFn: () => fetchSessionDetail(selectedSessionId!),
176
+ enabled: selectedSessionId !== null,
177
+ })
178
+
179
+ function applyFilter() {
180
+ setSessionsOffset(0)
181
+ setAppliedFrom(from)
182
+ setAppliedTo(to)
183
+ }
184
+
185
+ const dailyRows = dailyQuery.data?.rows ?? []
186
+ const totalInputTokens = sumBigintRows(dailyRows, 'inputTokens')
187
+ const totalOutputTokens = sumBigintRows(dailyRows, 'outputTokens')
188
+ const totalSteps = sumBigintRows(dailyRows, 'stepCount')
189
+ const totalSessions = sumBigintRows(dailyRows, 'sessionCount')
190
+
191
+ return (
192
+ <div className="space-y-6 max-w-5xl">
193
+ <div className="flex items-center gap-2">
194
+ <BarChart2 className="text-muted-foreground" size={20} />
195
+ <h2 className="text-lg font-semibold">
196
+ {t('ai_assistant.usage.title', 'Token Usage Statistics')}
197
+ </h2>
198
+ </div>
199
+
200
+ {/* Date range filter */}
201
+ <div className="flex items-end gap-4 flex-wrap">
202
+ <div className="flex flex-col gap-1.5">
203
+ <Label htmlFor="usage-from">
204
+ {t('ai_assistant.usage.from', 'From')}
205
+ </Label>
206
+ <Input
207
+ id="usage-from"
208
+ type="date"
209
+ value={from}
210
+ onChange={(e) => setFrom(e.target.value)}
211
+ className="w-40"
212
+ />
213
+ </div>
214
+ <div className="flex flex-col gap-1.5">
215
+ <Label htmlFor="usage-to">
216
+ {t('ai_assistant.usage.to', 'To')}
217
+ </Label>
218
+ <Input
219
+ id="usage-to"
220
+ type="date"
221
+ value={to}
222
+ onChange={(e) => setTo(e.target.value)}
223
+ className="w-40"
224
+ />
225
+ </div>
226
+ <Button variant="secondary" onClick={applyFilter}>
227
+ {t('ai_assistant.usage.apply', 'Apply')}
228
+ </Button>
229
+ </div>
230
+
231
+ {/* Summary tiles */}
232
+ {dailyQuery.isLoading && (
233
+ <div className="flex items-center gap-2 text-muted-foreground text-sm">
234
+ <Loader2 size={14} className="animate-spin" />
235
+ {t('ai_assistant.usage.loading', 'Loading usage data...')}
236
+ </div>
237
+ )}
238
+ {dailyQuery.isError && (
239
+ <p className="text-status-error-text text-sm">
240
+ {t('ai_assistant.usage.error', 'Failed to load usage data.')}
241
+ </p>
242
+ )}
243
+ {dailyQuery.isSuccess && (
244
+ <div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
245
+ {[
246
+ { label: t('ai_assistant.usage.inputTokens', 'Input tokens'), value: formatNumber(totalInputTokens) },
247
+ { label: t('ai_assistant.usage.outputTokens', 'Output tokens'), value: formatNumber(totalOutputTokens) },
248
+ { label: t('ai_assistant.usage.steps', 'Steps'), value: formatNumber(totalSteps) },
249
+ { label: t('ai_assistant.usage.sessions', 'Sessions'), value: formatNumber(totalSessions) },
250
+ ].map((tile) => (
251
+ <div key={tile.label} className="rounded-lg border border-border p-4 space-y-1">
252
+ <p className="text-muted-foreground text-xs">{tile.label}</p>
253
+ <p className="font-semibold text-xl">{tile.value}</p>
254
+ </div>
255
+ ))}
256
+ </div>
257
+ )}
258
+
259
+ {/* Daily breakdown table */}
260
+ {dailyQuery.isSuccess && dailyRows.length > 0 && (
261
+ <div className="space-y-2">
262
+ <h3 className="text-sm font-medium text-muted-foreground">
263
+ {t('ai_assistant.usage.dailyBreakdown', 'Daily breakdown')}
264
+ </h3>
265
+ <div className="overflow-x-auto rounded-lg border border-border">
266
+ <table className="min-w-full text-sm">
267
+ <thead>
268
+ <tr className="border-b border-border bg-muted/40">
269
+ <th className="px-3 py-2 text-left font-medium text-muted-foreground">
270
+ {t('ai_assistant.usage.col.day', 'Day')}
271
+ </th>
272
+ <th className="px-3 py-2 text-left font-medium text-muted-foreground">
273
+ {t('ai_assistant.usage.col.agent', 'Agent')}
274
+ </th>
275
+ <th className="px-3 py-2 text-right font-medium text-muted-foreground">
276
+ {t('ai_assistant.usage.col.inputTokens', 'Input')}
277
+ </th>
278
+ <th className="px-3 py-2 text-right font-medium text-muted-foreground">
279
+ {t('ai_assistant.usage.col.outputTokens', 'Output')}
280
+ </th>
281
+ <th className="px-3 py-2 text-right font-medium text-muted-foreground">
282
+ {t('ai_assistant.usage.col.sessions', 'Sessions')}
283
+ </th>
284
+ </tr>
285
+ </thead>
286
+ <tbody>
287
+ {dailyRows.map((row) => (
288
+ <tr key={row.id} className="border-b border-border last:border-b-0">
289
+ <td className="px-3 py-2 tabular-nums">{row.day}</td>
290
+ <td className="px-3 py-2 font-mono text-xs">{row.agentId}</td>
291
+ <td className="px-3 py-2 text-right tabular-nums">{formatNumber(parseInt(row.inputTokens, 10))}</td>
292
+ <td className="px-3 py-2 text-right tabular-nums">{formatNumber(parseInt(row.outputTokens, 10))}</td>
293
+ <td className="px-3 py-2 text-right tabular-nums">{row.sessionCount}</td>
294
+ </tr>
295
+ ))}
296
+ </tbody>
297
+ </table>
298
+ </div>
299
+ </div>
300
+ )}
301
+
302
+ {/* Sessions list */}
303
+ <div className="space-y-2">
304
+ <h3 className="text-sm font-medium text-muted-foreground">
305
+ {t('ai_assistant.usage.sessionsList', 'Sessions')}
306
+ </h3>
307
+ {sessionsQuery.isLoading && (
308
+ <div className="flex items-center gap-2 text-muted-foreground text-sm">
309
+ <Loader2 size={14} className="animate-spin" />
310
+ {t('ai_assistant.usage.loadingSessions', 'Loading sessions...')}
311
+ </div>
312
+ )}
313
+ {sessionsQuery.isError && (
314
+ <p className="text-status-error-text text-sm">
315
+ {t('ai_assistant.usage.errorSessions', 'Failed to load sessions.')}
316
+ </p>
317
+ )}
318
+ {sessionsQuery.isSuccess && (sessionsQuery.data?.sessions ?? []).length === 0 && (
319
+ <p className="text-muted-foreground text-sm">
320
+ {t('ai_assistant.usage.noSessions', 'No sessions found for the selected period.')}
321
+ </p>
322
+ )}
323
+ {sessionsQuery.isSuccess && (sessionsQuery.data?.sessions ?? []).length > 0 && (
324
+ <>
325
+ <div className="overflow-x-auto rounded-lg border border-border">
326
+ <table className="min-w-full text-sm">
327
+ <thead>
328
+ <tr className="border-b border-border bg-muted/40">
329
+ <th className="px-3 py-2 text-left font-medium text-muted-foreground">
330
+ {t('ai_assistant.usage.col.session', 'Session')}
331
+ </th>
332
+ <th className="px-3 py-2 text-left font-medium text-muted-foreground">
333
+ {t('ai_assistant.usage.col.agent', 'Agent')}
334
+ </th>
335
+ <th className="px-3 py-2 text-left font-medium text-muted-foreground">
336
+ {t('ai_assistant.usage.col.startedAt', 'Started')}
337
+ </th>
338
+ <th className="px-3 py-2 text-right font-medium text-muted-foreground">
339
+ {t('ai_assistant.usage.col.inputTokens', 'Input')}
340
+ </th>
341
+ <th className="px-3 py-2 text-right font-medium text-muted-foreground">
342
+ {t('ai_assistant.usage.col.outputTokens', 'Output')}
343
+ </th>
344
+ <th className="px-3 py-2 text-right font-medium text-muted-foreground">
345
+ {t('ai_assistant.usage.col.steps', 'Steps')}
346
+ </th>
347
+ <th className="w-8" />
348
+ </tr>
349
+ </thead>
350
+ <tbody>
351
+ {(sessionsQuery.data?.sessions ?? []).map((session) => (
352
+ <tr
353
+ key={session.sessionId}
354
+ className="border-b border-border last:border-b-0 cursor-pointer hover:bg-muted/30 transition-colors"
355
+ onClick={() => setSelectedSessionId(session.sessionId)}
356
+ >
357
+ <td className="px-3 py-2 font-mono text-xs">{shortId(session.sessionId)}…</td>
358
+ <td className="px-3 py-2 font-mono text-xs">{session.agentId}</td>
359
+ <td className="px-3 py-2 text-muted-foreground text-xs">{formatDate(session.startedAt)}</td>
360
+ <td className="px-3 py-2 text-right tabular-nums">{formatNumber(session.inputTokens)}</td>
361
+ <td className="px-3 py-2 text-right tabular-nums">{formatNumber(session.outputTokens)}</td>
362
+ <td className="px-3 py-2 text-right tabular-nums">{session.stepCount}</td>
363
+ <td className="px-3 py-2 text-muted-foreground">
364
+ <ChevronRight size={14} />
365
+ </td>
366
+ </tr>
367
+ ))}
368
+ </tbody>
369
+ </table>
370
+ </div>
371
+ <div className="flex items-center gap-2">
372
+ <Button
373
+ variant="secondary"
374
+ size="sm"
375
+ disabled={sessionsOffset === 0}
376
+ onClick={() => setSessionsOffset(Math.max(0, sessionsOffset - 50))}
377
+ >
378
+ {t('ai_assistant.usage.prev', 'Previous')}
379
+ </Button>
380
+ <span className="text-muted-foreground text-sm">
381
+ {sessionsOffset + 1}–{sessionsOffset + (sessionsQuery.data?.sessions.length ?? 0)}
382
+ {sessionsQuery.data?.total !== undefined ? ` / ${sessionsQuery.data.total}` : ''}
383
+ </span>
384
+ <Button
385
+ variant="secondary"
386
+ size="sm"
387
+ disabled={
388
+ (sessionsQuery.data?.sessions.length ?? 0) < 50 ||
389
+ sessionsOffset + 50 >= (sessionsQuery.data?.total ?? 0)
390
+ }
391
+ onClick={() => setSessionsOffset(sessionsOffset + 50)}
392
+ >
393
+ {t('ai_assistant.usage.next', 'Next')}
394
+ </Button>
395
+ </div>
396
+ </>
397
+ )}
398
+ </div>
399
+
400
+ {/* Session drill-down dialog */}
401
+ <Dialog
402
+ open={selectedSessionId !== null}
403
+ onOpenChange={(open) => { if (!open) setSelectedSessionId(null) }}
404
+ >
405
+ <DialogContent className="max-w-3xl max-h-[80vh] overflow-y-auto">
406
+ <DialogHeader>
407
+ <DialogTitle>
408
+ {t('ai_assistant.usage.sessionDetail', 'Session detail')}
409
+ {selectedSessionId && (
410
+ <span className="ml-2 font-mono text-sm text-muted-foreground">
411
+ {shortId(selectedSessionId)}…
412
+ </span>
413
+ )}
414
+ </DialogTitle>
415
+ </DialogHeader>
416
+ <div className="mt-4">
417
+ {sessionDetailQuery.isLoading && (
418
+ <div className="flex items-center gap-2 text-muted-foreground text-sm">
419
+ <Loader2 size={14} className="animate-spin" />
420
+ {t('ai_assistant.usage.loadingDetail', 'Loading session events...')}
421
+ </div>
422
+ )}
423
+ {sessionDetailQuery.isError && (
424
+ <p className="text-status-error-text text-sm">
425
+ {t('ai_assistant.usage.errorDetail', 'Failed to load session events.')}
426
+ </p>
427
+ )}
428
+ {sessionDetailQuery.isSuccess && (
429
+ <div className="overflow-x-auto rounded-lg border border-border">
430
+ <table className="min-w-full text-sm">
431
+ <thead>
432
+ <tr className="border-b border-border bg-muted/40">
433
+ <th className="px-3 py-2 text-left font-medium text-muted-foreground">
434
+ {t('ai_assistant.usage.col.step', 'Step')}
435
+ </th>
436
+ <th className="px-3 py-2 text-left font-medium text-muted-foreground">
437
+ {t('ai_assistant.usage.col.model', 'Model')}
438
+ </th>
439
+ <th className="px-3 py-2 text-right font-medium text-muted-foreground">
440
+ {t('ai_assistant.usage.col.inputTokens', 'Input')}
441
+ </th>
442
+ <th className="px-3 py-2 text-right font-medium text-muted-foreground">
443
+ {t('ai_assistant.usage.col.outputTokens', 'Output')}
444
+ </th>
445
+ <th className="px-3 py-2 text-left font-medium text-muted-foreground">
446
+ {t('ai_assistant.usage.col.finishReason', 'Finish')}
447
+ </th>
448
+ </tr>
449
+ </thead>
450
+ <tbody>
451
+ {(sessionDetailQuery.data?.events ?? []).map((event) => (
452
+ <tr key={event.id} className="border-b border-border last:border-b-0">
453
+ <td className="px-3 py-2 tabular-nums">{event.stepIndex}</td>
454
+ <td className="px-3 py-2 font-mono text-xs">{event.modelId}</td>
455
+ <td className="px-3 py-2 text-right tabular-nums">{formatNumber(event.inputTokens)}</td>
456
+ <td className="px-3 py-2 text-right tabular-nums">{formatNumber(event.outputTokens)}</td>
457
+ <td className="px-3 py-2 text-muted-foreground text-xs">{event.finishReason ?? '—'}</td>
458
+ </tr>
459
+ ))}
460
+ </tbody>
461
+ </table>
462
+ </div>
463
+ )}
464
+ </div>
465
+ </DialogContent>
466
+ </Dialog>
467
+ </div>
468
+ )
469
+ }
@@ -0,0 +1,23 @@
1
+ import React from 'react'
2
+
3
+ const usageIcon = React.createElement(
4
+ 'svg',
5
+ { width: 16, height: 16, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 2, strokeLinecap: 'round', strokeLinejoin: 'round' },
6
+ React.createElement('path', { d: 'M3 3v18h18' }),
7
+ React.createElement('path', { d: 'M7 16l4-4 4 4 4-4' }),
8
+ )
9
+
10
+ export const metadata = {
11
+ requireAuth: true,
12
+ requireFeatures: ['ai_assistant.settings.manage'],
13
+ pageTitle: 'AI Usage',
14
+ pageTitleKey: 'ai_assistant.usage.navTitle',
15
+ pageGroup: 'Module Configs',
16
+ pageGroupKey: 'settings.sections.moduleConfigs',
17
+ pageOrder: 431,
18
+ icon: usageIcon,
19
+ pageContext: 'settings' as const,
20
+ breadcrumb: [
21
+ { label: 'AI Usage', labelKey: 'ai_assistant.usage.navTitle' },
22
+ ],
23
+ } as const
@@ -0,0 +1,12 @@
1
+ import { Page, PageBody } from '@open-mercato/ui/backend/Page'
2
+ import { AiUsageStatsPageClient } from './AiUsageStatsPageClient'
3
+
4
+ export default async function AiUsageStatsPage() {
5
+ return (
6
+ <Page>
7
+ <PageBody>
8
+ <AiUsageStatsPageClient />
9
+ </PageBody>
10
+ </Page>
11
+ )
12
+ }
@@ -338,6 +338,23 @@ const testTools: ModuleCli = {
338
338
  },
339
339
  }
340
340
 
341
+ const runTokenUsagePrune: ModuleCli = {
342
+ command: 'run-token-usage-prune',
343
+ async run() {
344
+ await ensureBootstrap()
345
+ const container = await createRequestContainer()
346
+
347
+ const { runTokenUsagePrune: runPrune } = await import(
348
+ './workers/ai-token-usage-prune'
349
+ )
350
+
351
+ const em = container.resolve<import('@mikro-orm/postgresql').EntityManager>('em')
352
+ const summary = await runPrune({ em })
353
+
354
+ console.log('[ai-token-usage-prune] Prune complete:', summary)
355
+ },
356
+ }
357
+
341
358
  export default [
342
359
  mcpServe,
343
360
  mcpServeHttp,
@@ -345,5 +362,6 @@ export default [
345
362
  listTools,
346
363
  entityGraph,
347
364
  runPendingActionCleanup,
365
+ runTokenUsagePrune,
348
366
  testTools,
349
367
  ]
@@ -67,7 +67,7 @@ type SettingsResponse = {
67
67
  type AgentResolution = {
68
68
  agentId: string
69
69
  moduleId: string
70
- allowRuntimeModelOverride: boolean
70
+ allowRuntimeOverride: boolean
71
71
  providerId: string
72
72
  modelId: string
73
73
  baseURL: string | null