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;