forked from bai/curriculum-project-hub
192 lines
7.1 KiB
TypeScript
192 lines
7.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: { model: true, provider: true, inputTokens: true, outputTokens: true, costUsd: true },
|
|
});
|
|
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 UsageRun {
|
|
readonly model: string;
|
|
readonly provider: string;
|
|
readonly inputTokens: number | null;
|
|
readonly outputTokens: number | null;
|
|
readonly costUsd: unknown;
|
|
}
|
|
|
|
function formatUsageReport(runs: readonly UsageRun[], scope: "current" | "project"): string {
|
|
if (runs.length === 0) return `${scope === "current" ? "当前角色会话" : "当前项目"}还没有已结束的 Agent run。`;
|
|
const buckets = new Map<string, {
|
|
provider: string; model: string; runs: 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 costUsd = decimalToNumberOrNull(run.costUsd);
|
|
if (costUsd === null) { unrecordedRuns++; continue; }
|
|
recordedRuns++;
|
|
const key = `${run.provider}\u0000${run.model}`;
|
|
const bucket = buckets.get(key) ?? {
|
|
provider: run.provider, model: run.model, runs: 0, inputTokens: 0, outputTokens: 0, costUsd: 0,
|
|
};
|
|
bucket.runs++;
|
|
bucket.inputTokens += run.inputTokens ?? 0;
|
|
bucket.outputTokens += run.outputTokens ?? 0;
|
|
bucket.costUsd += costUsd;
|
|
buckets.set(key, bucket);
|
|
totalInputTokens += run.inputTokens ?? 0;
|
|
totalOutputTokens += run.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.runs)} runs, ${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");
|
|
}
|