forked from EduCraft/curriculum-project-hub
aaa098bb8b
Co-authored-by: Hong Jiarong <me@jrhim.com> Co-committed-by: Hong Jiarong <me@jrhim.com>
222 lines
8.1 KiB
TypeScript
222 lines
8.1 KiB
TypeScript
import type { PrismaClient } from "@prisma/client";
|
|
import { decimalToNumberOrNull, formatInteger, formatUsd } from "../agent/cost.js";
|
|
import { sendText, type FeishuRuntime, type SendMessageOptions } from "./client.js";
|
|
|
|
export interface SlashInvocation {
|
|
readonly name: string;
|
|
readonly args: readonly string[];
|
|
}
|
|
|
|
export interface SlashCommandRunContext {
|
|
readonly invocation: SlashInvocation;
|
|
readonly projectId: string;
|
|
readonly chatId: string;
|
|
readonly rt: FeishuRuntime;
|
|
readonly sendOptions?: SendMessageOptions | undefined;
|
|
}
|
|
|
|
export interface SlashCommandDefinition {
|
|
readonly name: "help" | "project" | "usage";
|
|
readonly usage: string;
|
|
readonly summary: string;
|
|
readonly details: readonly string[];
|
|
run(context: SlashCommandRunContext): Promise<void>;
|
|
}
|
|
|
|
const TERMINAL_RUN_STATUSES = ["COMPLETED", "FAILED", "TIMED_OUT", "CANCELED"] as const;
|
|
|
|
export function parseSlashInvocation(prompt: string): SlashInvocation | null {
|
|
const trimmed = prompt.trim();
|
|
if (!trimmed.startsWith("/")) return null;
|
|
const tokens = trimmed.split(/\s+/);
|
|
const rawCommand = tokens[0];
|
|
if (rawCommand === undefined || rawCommand.length <= 1) return null;
|
|
return { name: rawCommand.slice(1), args: tokens.slice(1) };
|
|
}
|
|
|
|
export function createSlashCommandRegistry(
|
|
deps: { readonly prisma: PrismaClient },
|
|
): ReadonlyMap<string, SlashCommandDefinition> {
|
|
const commands = new Map<string, SlashCommandDefinition>();
|
|
const add = (command: SlashCommandDefinition): void => {
|
|
if (commands.has(command.name)) throw new Error(`duplicate slash command definition: /${command.name}`);
|
|
commands.set(command.name, command);
|
|
};
|
|
|
|
add({
|
|
name: "help",
|
|
usage: "/help [project|usage]",
|
|
summary: "查看 Hub 命令。",
|
|
details: ["未知 slash 命令会明确报错,不会发送给 Agent。"],
|
|
run: async ({ invocation, chatId, rt, sendOptions }) => {
|
|
if (invocation.args.length > 1) {
|
|
await sendText(rt, chatId, "用法: /help [project|usage]", sendOptions);
|
|
return;
|
|
}
|
|
const target = invocation.args[0];
|
|
await sendText(rt, chatId, target === undefined
|
|
? formatSlashOverview(commands)
|
|
: formatSlashHelpTarget(target, commands), sendOptions);
|
|
},
|
|
});
|
|
|
|
add({
|
|
name: "project",
|
|
usage: "/project",
|
|
summary: "打开当前项目控制台,切换角色、管理会话和项目。",
|
|
details: ["不同区域按当前操作者的项目与组织权限显示。"],
|
|
run: async ({ chatId, rt, sendOptions }) => {
|
|
await sendText(rt, chatId, "请在绑定的项目群中使用 /project。", sendOptions);
|
|
},
|
|
});
|
|
|
|
add({
|
|
name: "usage",
|
|
usage: "/usage [current|project]",
|
|
summary: "查看 Hub 记录的真实 Agent 用量与成本。",
|
|
details: ["current 只统计当前角色的活跃会话;project 统计整个项目。"],
|
|
run: async ({ invocation, projectId, chatId, rt, sendOptions }) => {
|
|
const scope = invocation.args[0] ?? "current";
|
|
if (invocation.args.length > 1 || (scope !== "current" && scope !== "project")) {
|
|
await sendText(rt, chatId, "用法: /usage [current|project]", sendOptions);
|
|
return;
|
|
}
|
|
const sessionIds = scope === "project"
|
|
? undefined
|
|
: await currentRoleSessionIds(deps.prisma, projectId, chatId);
|
|
const runs = await deps.prisma.agentRun.findMany({
|
|
where: {
|
|
projectId,
|
|
...(sessionIds === undefined ? {} : { sessionId: { in: sessionIds } }),
|
|
status: { in: [...TERMINAL_RUN_STATUSES] },
|
|
finishedAt: { not: null },
|
|
},
|
|
orderBy: { finishedAt: "asc" },
|
|
select: {
|
|
usageFacts: {
|
|
select: {
|
|
provider: true,
|
|
model: true,
|
|
inputTokens: true,
|
|
outputTokens: true,
|
|
costUsd: true,
|
|
},
|
|
orderBy: { occurredAt: "asc" },
|
|
},
|
|
},
|
|
});
|
|
await sendText(rt, chatId, formatUsageReport(runs, scope), sendOptions);
|
|
},
|
|
});
|
|
|
|
return commands;
|
|
}
|
|
|
|
async function currentRoleSessionIds(
|
|
prisma: PrismaClient,
|
|
projectId: string,
|
|
chatId: string,
|
|
): Promise<string[]> {
|
|
const binding = await prisma.projectGroupBinding.findFirst({
|
|
where: { projectId, chatId, archivedAt: null },
|
|
select: { selectedRole: { select: { roleId: true } } },
|
|
});
|
|
if (binding === null) throw new Error("active project-group binding not found");
|
|
const sessions = await prisma.agentSession.findMany({
|
|
where: { projectId, roleId: binding.selectedRole.roleId, archivedAt: null },
|
|
select: { id: true },
|
|
});
|
|
return sessions.map((session) => session.id);
|
|
}
|
|
|
|
interface UsageRunWithFacts {
|
|
readonly usageFacts: readonly {
|
|
readonly provider: string;
|
|
readonly model: string | null;
|
|
readonly inputTokens: number | null;
|
|
readonly outputTokens: number | null;
|
|
readonly costUsd: unknown;
|
|
}[];
|
|
}
|
|
|
|
function formatUsageReport(runs: readonly UsageRunWithFacts[], scope: "current" | "project"): string {
|
|
if (runs.length === 0) return `${scope === "current" ? "当前角色会话" : "当前项目"}还没有已结束的 Agent run。`;
|
|
// Bucket by (fact.provider, fact.model) — ADR-0026: external capabilities
|
|
// carry their own provider/model and contribute their own meter, so a run
|
|
// with a main loop + an external call lands in two buckets. A run with no
|
|
// cost-bearing fact is "unrecorded" (ADR-0022: missing cost ≠ zero).
|
|
const buckets = new Map<string, {
|
|
provider: string; model: string; facts: number; inputTokens: number; outputTokens: number; costUsd: number;
|
|
}>();
|
|
let recordedRuns = 0;
|
|
let unrecordedRuns = 0;
|
|
let totalInputTokens = 0;
|
|
let totalOutputTokens = 0;
|
|
let totalCostUsd = 0;
|
|
for (const run of runs) {
|
|
const facts = run.usageFacts;
|
|
if (facts.length === 0 || !facts.some((f) => f.costUsd !== null && f.costUsd !== undefined)) {
|
|
unrecordedRuns++;
|
|
// Tokens from unrecorded runs still count toward totals (matches pre-0026).
|
|
for (const f of facts) {
|
|
totalInputTokens += f.inputTokens ?? 0;
|
|
totalOutputTokens += f.outputTokens ?? 0;
|
|
}
|
|
continue;
|
|
}
|
|
recordedRuns++;
|
|
for (const f of facts) {
|
|
const costUsd = decimalToNumberOrNull(f.costUsd);
|
|
if (costUsd === null) continue;
|
|
const model = f.model ?? "(unknown model)";
|
|
const key = `${f.provider}\u0000${model}`;
|
|
const bucket = buckets.get(key) ?? {
|
|
provider: f.provider, model, facts: 0, inputTokens: 0, outputTokens: 0, costUsd: 0,
|
|
};
|
|
bucket.facts += 1;
|
|
bucket.inputTokens += f.inputTokens ?? 0;
|
|
bucket.outputTokens += f.outputTokens ?? 0;
|
|
bucket.costUsd += costUsd;
|
|
buckets.set(key, bucket);
|
|
totalInputTokens += f.inputTokens ?? 0;
|
|
totalOutputTokens += f.outputTokens ?? 0;
|
|
totalCostUsd += costUsd;
|
|
}
|
|
}
|
|
const lines = [
|
|
`${scope === "current" ? "当前角色会话" : "当前项目"}用量`,
|
|
`已记录成本: ${formatInteger(recordedRuns)} runs · ${formatUsd(totalCostUsd)}`,
|
|
`Tokens: input ${formatInteger(totalInputTokens)} / output ${formatInteger(totalOutputTokens)}`,
|
|
];
|
|
if (unrecordedRuns > 0) lines.push(`另有 ${formatInteger(unrecordedRuns)} 个 run 未记录成本。`);
|
|
for (const bucket of buckets.values()) {
|
|
lines.push(`- ${bucket.provider} / ${bucket.model}: ${formatInteger(bucket.facts)} 次, ${formatUsd(bucket.costUsd)}`);
|
|
}
|
|
return lines.join("\n");
|
|
}
|
|
|
|
export function formatSlashHelpTarget(
|
|
target: string,
|
|
commands: ReadonlyMap<string, SlashCommandDefinition>,
|
|
): string {
|
|
const normalized = target.trim().replace(/^\/+/, "");
|
|
const command = commands.get(normalized);
|
|
if (command === undefined) return [`未知 slash 命令 /${normalized}。`, "", formatSlashOverview(commands)].join("\n");
|
|
return [
|
|
`/${command.name}`,
|
|
command.summary,
|
|
"",
|
|
`用法: ${command.usage}`,
|
|
...command.details.map((detail) => `- ${detail}`),
|
|
].join("\n");
|
|
}
|
|
|
|
function formatSlashOverview(commands: ReadonlyMap<string, SlashCommandDefinition>): string {
|
|
return [
|
|
"可用 slash 命令:",
|
|
...[...commands.values()].map((command) => `/${command.name} - ${command.summary}`),
|
|
"",
|
|
"普通消息会使用 /project 中选定的当前角色。",
|
|
].join("\n");
|
|
}
|