From 465a727be2bda21099b2b449307b29dbb23e04d1 Mon Sep 17 00:00:00 2001 From: matevip Date: Tue, 12 May 2026 14:35:17 +0800 Subject: [PATCH] feat(wiki): record per-run token usage so operators can see what each template burns --- .../model/WikiTransformationRunEntity.java | 9 ++++ .../service/WikiTransformationExecutor.java | 45 +++++++++++++++---- .../V110__wiki_transformation_run_tokens.sql | 8 ++++ .../V110__wiki_transformation_run_tokens.sql | 19 ++++++++ .../Wiki/components/TransformationsPanel.vue | 14 ++++++ 5 files changed, 87 insertions(+), 8 deletions(-) create mode 100644 mateclaw-server/src/main/resources/db/migration/h2/V110__wiki_transformation_run_tokens.sql create mode 100644 mateclaw-server/src/main/resources/db/migration/mysql/V110__wiki_transformation_run_tokens.sql diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/model/WikiTransformationRunEntity.java b/mateclaw-server/src/main/java/vip/mate/wiki/model/WikiTransformationRunEntity.java index 25ef9f99..2f7dc63b 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/model/WikiTransformationRunEntity.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/model/WikiTransformationRunEntity.java @@ -58,6 +58,15 @@ public class WikiTransformationRunEntity { */ private Long outputPageId; + /** Prompt-side tokens reported by the provider (Spring AI Usage). */ + private Long inputTokens; + + /** Completion-side tokens reported by the provider. */ + private Long outputTokens; + + /** Provider's own total (usually input + output, but providers vary). */ + private Long totalTokens; + @TableField(fill = FieldFill.INSERT) private LocalDateTime createTime; diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiTransformationExecutor.java b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiTransformationExecutor.java index 44e764b7..444b2a2a 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiTransformationExecutor.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiTransformationExecutor.java @@ -227,9 +227,10 @@ public class WikiTransformationExecutor { ChatModel chatModel = buildChatModel(resolvedModelId); run.setModelId(resolvedModelId); - String output = callOnce(chatModel, systemPrompt, userPrompt); + CallResult first = callOnce(chatModel, systemPrompt, userPrompt); + accumulateUsage(run, first); if (wantJson) { - String coerced = coerceToJson(output); + String coerced = coerceToJson(first.text()); if (coerced != null) { // Wrap in a fenced block so UI rendering and save-as-page // keep the existing markdown contract. The raw JSON is the @@ -240,18 +241,22 @@ public class WikiTransformationExecutor { log.info("[WikiTransformation] JSON parse failed for template={}; retrying with stricter reminder", transformation.getName()); String retryUserPrompt = userPrompt + "\n\n---\n\n上一次回复不是合法 JSON。请只返回一个合法 JSON 文档,前后不要有任何文字或代码块标记。"; - String retry = callOnce(chatModel, systemPrompt, retryUserPrompt); - String coercedRetry = coerceToJson(retry); + CallResult retry = callOnce(chatModel, systemPrompt, retryUserPrompt); + accumulateUsage(run, retry); + String coercedRetry = coerceToJson(retry.text()); if (coercedRetry != null) { return "```json\n" + coercedRetry + "\n```"; } throw new IllegalStateException("LLM output is not valid JSON after one retry"); } - return output; + return first.text(); } - /** One LLM call, returns the cleaned output. Throws when the call yields blank. */ - private String callOnce(ChatModel chatModel, String systemPrompt, String userPrompt) { + /** Tuple returned from a single LLM call: cleaned text + usage (null when provider didn't surface usage). */ + private record CallResult(String text, Long inputTokens, Long outputTokens, Long totalTokens) {} + + /** One LLM call, returns the cleaned output + provider usage. Throws when the call yields blank. */ + private CallResult callOnce(ChatModel chatModel, String systemPrompt, String userPrompt) { ChatResponse resp = chatModel.call(new Prompt(List.of( new SystemMessage(systemPrompt), new UserMessage(userPrompt)))); String rawOutput = (resp == null || resp.getResult() == null @@ -264,7 +269,31 @@ public class WikiTransformationExecutor { if (output.isBlank()) { throw new IllegalStateException("LLM output was empty after cleanup"); } - return output; + Long in = null, out = null, total = null; + try { + if (resp.getMetadata() != null && resp.getMetadata().getUsage() != null) { + var u = resp.getMetadata().getUsage(); + in = u.getPromptTokens() == null ? null : u.getPromptTokens().longValue(); + out = u.getCompletionTokens() == null ? null : u.getCompletionTokens().longValue(); + total = u.getTotalTokens() == null ? null : u.getTotalTokens().longValue(); + } + } catch (Exception ignored) { + // Usage extraction is best-effort — different providers expose it differently. + } + return new CallResult(output, in, out, total); + } + + /** Add provider-reported usage onto the run row (accumulates across retries). */ + private static void accumulateUsage(WikiTransformationRunEntity run, CallResult call) { + if (call.inputTokens() != null) { + run.setInputTokens((run.getInputTokens() == null ? 0L : run.getInputTokens()) + call.inputTokens()); + } + if (call.outputTokens() != null) { + run.setOutputTokens((run.getOutputTokens() == null ? 0L : run.getOutputTokens()) + call.outputTokens()); + } + if (call.totalTokens() != null) { + run.setTotalTokens((run.getTotalTokens() == null ? 0L : run.getTotalTokens()) + call.totalTokens()); + } } private static final com.fasterxml.jackson.databind.ObjectMapper JSON_MAPPER = diff --git a/mateclaw-server/src/main/resources/db/migration/h2/V110__wiki_transformation_run_tokens.sql b/mateclaw-server/src/main/resources/db/migration/h2/V110__wiki_transformation_run_tokens.sql new file mode 100644 index 00000000..c00ee60c --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/h2/V110__wiki_transformation_run_tokens.sql @@ -0,0 +1,8 @@ +-- Record per-run token usage so operators can see which templates burn the +-- most tokens and which models produce the most expensive output. Spring AI +-- surfaces the values via ChatResponseMetadata.getUsage(); the executor +-- snapshots them into the run row after the LLM call. + +ALTER TABLE mate_wiki_transformation_run ADD COLUMN IF NOT EXISTS input_tokens BIGINT NULL; +ALTER TABLE mate_wiki_transformation_run ADD COLUMN IF NOT EXISTS output_tokens BIGINT NULL; +ALTER TABLE mate_wiki_transformation_run ADD COLUMN IF NOT EXISTS total_tokens BIGINT NULL; diff --git a/mateclaw-server/src/main/resources/db/migration/mysql/V110__wiki_transformation_run_tokens.sql b/mateclaw-server/src/main/resources/db/migration/mysql/V110__wiki_transformation_run_tokens.sql new file mode 100644 index 00000000..97288351 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/mysql/V110__wiki_transformation_run_tokens.sql @@ -0,0 +1,19 @@ +-- Record per-run token usage. See h2 sibling for prose explanation. + +SET @c := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'mate_wiki_transformation_run' + AND COLUMN_NAME = 'input_tokens'); +SET @s := IF(@c = 0, 'ALTER TABLE mate_wiki_transformation_run ADD COLUMN input_tokens BIGINT NULL', 'SELECT 1'); +PREPARE stmt FROM @s; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @c := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'mate_wiki_transformation_run' + AND COLUMN_NAME = 'output_tokens'); +SET @s := IF(@c = 0, 'ALTER TABLE mate_wiki_transformation_run ADD COLUMN output_tokens BIGINT NULL', 'SELECT 1'); +PREPARE stmt FROM @s; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @c := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'mate_wiki_transformation_run' + AND COLUMN_NAME = 'total_tokens'); +SET @s := IF(@c = 0, 'ALTER TABLE mate_wiki_transformation_run ADD COLUMN total_tokens BIGINT NULL', 'SELECT 1'); +PREPARE stmt FROM @s; EXECUTE stmt; DEALLOCATE PREPARE stmt; diff --git a/mateclaw-ui/src/views/Wiki/components/TransformationsPanel.vue b/mateclaw-ui/src/views/Wiki/components/TransformationsPanel.vue index 80e046fe..c6073d15 100644 --- a/mateclaw-ui/src/views/Wiki/components/TransformationsPanel.vue +++ b/mateclaw-ui/src/views/Wiki/components/TransformationsPanel.vue @@ -95,6 +95,9 @@ {{ rawTitleFor(run.rawId) }} · {{ formatTimestamp(run.completedAt || run.startedAt || run.createTime) }} · {{ formatDuration(run.durationMs) }} + + · {{ formatTokens(run.inputTokens) }}↑ / {{ formatTokens(run.outputTokens) }}↓ + {{ t('wiki.transformations.savedAsPage') }} #{{ run.outputPageId }} @@ -299,6 +302,9 @@ interface WikiTransformationRun { createTime: string triggeredBy: string outputPageId: number | null + inputTokens: number | null + outputTokens: number | null + totalTokens: number | null } const { t } = useI18n() @@ -373,6 +379,13 @@ function formatDuration(ms: number | null): string { return `${(ms / 1000).toFixed(1)}s` } +function formatTokens(n: number | null): string { + if (n == null) return '—' + if (n < 1000) return String(n) + if (n < 1_000_000) return (n / 1000).toFixed(1).replace(/\.0$/, '') + 'k' + return (n / 1_000_000).toFixed(1).replace(/\.0$/, '') + 'm' +} + async function loadAll() { if (!store.currentKB) return loading.value = true @@ -746,6 +759,7 @@ onMounted(async () => { .run-status--running, .run-status--pending { background: var(--mc-bg-muted); color: var(--mc-text-secondary); } .run-status--cancelled { background: var(--mc-bg-muted); color: var(--mc-text-tertiary); } .run-meta { color: var(--mc-text-tertiary); } +.run-tokens { color: var(--mc-text-tertiary); font-family: var(--mc-font-mono, ui-monospace, Menlo, monospace); font-size: 11px; } .run-output { margin-top: 6px; padding: 10px 12px;