Files
curriculum-project-hub/hub/src/feishu/slashCommands.ts
T

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");
}