import type { PrismaClient } from "@prisma/client"; import type { FastifyBaseLogger } from "fastify"; import { decimalToNumberOrNull, formatInteger, formatUsd } from "../agent/cost.js"; import type { ModelRegistry, RoleEntry } from "../agent/models.js"; import type { RuntimeSettings } from "../settings/runtime.js"; import { lockActiveProjectOrganization } from "../org/status.js"; import { sendText, type FeishuRuntime, type SendMessageOptions } from "./client.js"; import type { TriggerQueue } from "./triggerQueue.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: string; readonly usage: string; readonly summary: string; readonly details: readonly string[]; run(context: SlashCommandRunContext): Promise; } export interface SlashCommandRegistryDeps { readonly prisma: PrismaClient; readonly settings: RuntimeSettings; readonly logger: FastifyBaseLogger; readonly triggerQueue: TriggerQueue; } 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 parseSlashHelpSubcommand(invocation: SlashInvocation): string | null { if (invocation.name === "help") return null; return invocation.args.length === 1 && invocation.args[0] === "help" ? invocation.name : null; } export function createSlashCommandRegistry(deps: SlashCommandRegistryDeps): ReadonlyMap { const commands = new Map(); 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 [command]", summary: "查看可用 slash 命令或单个命令说明。", details: [ "不创建 agent run,也不改变当前会话。", "支持 /help new 和 /new help 两种写法。", ], run: async ({ invocation, projectId, chatId, rt, sendOptions }) => { const registry = await deps.settings.modelRegistry({ projectId }); await sendText(rt, chatId, formatHelpCommandInvocation(invocation, registry, commands), sendOptions); }, }); add({ name: "new", usage: "/new", summary: "开新会话,下次 @bot 将从头开始。", details: [ "归档当前未归档的 agent session。", "不会清空当前项目的等待队列。", ], run: async ({ projectId, chatId, rt, sendOptions }) => { await deps.prisma.$transaction(async (tx) => { await lockActiveProjectOrganization(tx, projectId); await tx.agentSession.updateMany({ where: { projectId, archivedAt: null }, data: { archivedAt: new Date() }, }); }); await sendText(rt, chatId, "已开新会话,下次 @bot 将从头开始。", sendOptions); }, }); add({ name: "project", usage: "/project", summary: "打开当前项目管理卡片,可修改项目名称。", details: [ "要求操作者拥有当前项目的 MANAGE 权限。", "实际卡片由飞书 trigger adapter 渲染,不创建 agent run。", ], run: async ({ chatId, rt, sendOptions }) => { await sendText(rt, chatId, "请在飞书项目群中使用 @bot /project 打开项目管理卡片。", sendOptions); }, }); add({ name: "resume", usage: "/resume", summary: "恢复最近一次已归档的会话。", details: [ "只恢复当前项目最近归档的 agent session。", "没有可恢复会话时只回复提示,不会创建 agent run。", ], run: async ({ projectId, chatId, rt, sendOptions }) => { const resumed = await deps.prisma.$transaction(async (tx) => { await lockActiveProjectOrganization(tx, projectId); const latest = await tx.agentSession.findFirst({ where: { projectId, archivedAt: { not: null } }, orderBy: { archivedAt: "desc" }, select: { id: true }, }); if (latest === null) return false; await tx.agentSession.update({ where: { id: latest.id }, data: { archivedAt: null }, }); return true; }); if (!resumed) { await sendText(rt, chatId, "没有可恢复的会话。", sendOptions); return; } await sendText(rt, chatId, "已恢复上一个会话。", sendOptions); }, }); add({ name: "cost", usage: "/cost", summary: "查看当前会话已记录的 agent 成本。", details: [ "只读取当前项目未归档 agent session 下已经结束的 run,不创建 agent run。", "只统计运行时真实记录到 AgentRun.costUsd 的成本;未记录成本的 run 会单独列出。", ], run: async ({ invocation, projectId, chatId, rt, sendOptions }) => { if (invocation.args.length > 0) { await sendText(rt, chatId, ["用法错误: /cost 暂不接受参数。", "", formatBuiltinSlashCommandHelp(commands.get("cost")!)].join("\n"), sendOptions); return; } const sessions = await deps.prisma.agentSession.findMany({ where: { projectId, archivedAt: null }, orderBy: { updatedAt: "asc" }, select: { id: true }, }); if (sessions.length === 0) { await sendText(rt, chatId, "当前会话还没有 agent session。", sendOptions); return; } const runs = await deps.prisma.agentRun.findMany({ where: { projectId, sessionId: { in: sessions.map((session) => session.id) }, 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, formatCostReport(runs), sendOptions); }, }); add({ name: "reset", usage: "/reset", summary: "重置当前会话并清空等待队列。", details: [ "归档当前未归档的 agent session。", "清空当前项目已经排队、尚未开始的触发请求。", ], run: async ({ projectId, chatId, rt, sendOptions }) => { await deps.prisma.$transaction(async (tx) => { await lockActiveProjectOrganization(tx, projectId); await tx.agentSession.updateMany({ where: { projectId, archivedAt: null }, data: { archivedAt: new Date() }, }); }); const cleared = deps.triggerQueue.clear(projectId); if (cleared > 0) { deps.logger.info({ projectId, cleared }, "feishu trigger: cleared queued triggers on reset"); } await sendText(rt, chatId, "已重置,下次 @bot 将从头开始。", sendOptions); }, }); return commands; } interface CostReportRun { readonly model: string; readonly provider: string; readonly inputTokens: number | null; readonly outputTokens: number | null; readonly costUsd: unknown; } interface CostReportBucket { readonly provider: string; readonly model: string; runs: number; inputTokens: number; outputTokens: number; costUsd: number; } function formatCostReport(runs: readonly CostReportRun[]): string { if (runs.length === 0) { return "当前会话还没有已结束的 agent run。"; } const buckets = new Map(); 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 inputTokens = run.inputTokens ?? 0; const outputTokens = run.outputTokens ?? 0; totalInputTokens += inputTokens; totalOutputTokens += outputTokens; totalCostUsd += costUsd; const key = `${run.provider}\u0000${run.model}`; let bucket = buckets.get(key); if (bucket === undefined) { bucket = { provider: run.provider, model: run.model, runs: 0, inputTokens: 0, outputTokens: 0, costUsd: 0, }; buckets.set(key, bucket); } bucket.runs++; bucket.inputTokens += inputTokens; bucket.outputTokens += outputTokens; bucket.costUsd += costUsd; } if (recordedRuns === 0) { return [ "当前会话已有已结束 agent run,但还没有任何 run 记录到真实成本。", `未记录成本: ${formatInteger(unrecordedRuns)} runs。`, "后续 run 需要 SDK 返回 total_cost_usd 才会进入 /cost 合计。", ].join("\n"); } const lines = [ "当前会话已记录 agent 成本:", `总计: ${formatUsd(totalCostUsd)}`, `Runs: ${formatInteger(recordedRuns)} 已记录${unrecordedRuns > 0 ? ` / ${formatInteger(unrecordedRuns)} 未记录` : ""}`, `Tokens: input ${formatInteger(totalInputTokens)} / output ${formatInteger(totalOutputTokens)}`, "", "按模型:", ]; const sortedBuckets = [...buckets.values()].sort((a, b) => b.costUsd - a.costUsd); for (const bucket of sortedBuckets) { lines.push( `- ${bucket.provider} / ${bucket.model}: ${formatInteger(bucket.runs)} runs, ${formatUsd(bucket.costUsd)}, input ${formatInteger(bucket.inputTokens)} / output ${formatInteger(bucket.outputTokens)}`, ); } return lines.join("\n"); } function formatHelpCommandInvocation( invocation: SlashInvocation, registry: ModelRegistry, slashCommands: ReadonlyMap, ): string { if (invocation.args.length > 1) { const helpCommand = slashCommands.get("help"); if (helpCommand === undefined) { throw new Error("slash command registry is missing /help"); } return [ "用法错误: /help 只接受一个命令名。", "", formatBuiltinSlashCommandHelp(helpCommand), ].join("\n"); } const target = invocation.args[0]; if (target === undefined) return formatSlashOverview(registry, slashCommands); const normalizedTarget = normalizeHelpTarget(target); if (normalizedTarget === "") return formatSlashOverview(registry, slashCommands); return formatSlashHelpTarget(normalizedTarget, registry, slashCommands); } export function formatSlashHelpTarget( target: string, registry: ModelRegistry, slashCommands: ReadonlyMap, ): string { const normalizedTarget = normalizeHelpTarget(target); if (normalizedTarget === "") return formatSlashOverview(registry, slashCommands); return formatSlashCommandHelp(normalizedTarget, registry, slashCommands) ?? formatUnknownSlashHelp(normalizedTarget, registry, slashCommands); } function normalizeHelpTarget(target: string): string { return target.trim().replace(/^\/+/, ""); } function formatSlashOverview( registry: ModelRegistry, slashCommands: ReadonlyMap, ): string { const lines = [ "可用 slash 命令:", ...[...slashCommands.values()].map((command) => `/${command.name} - ${command.summary}`), ]; const roles = visibleRoleCommands(registry, slashCommands); if (roles.length > 0) { lines.push("", "角色命令:"); for (const role of roles) { lines.push(`/${role.id} <需求> - 使用“${role.label}”角色发起请求。`); } } else { lines.push("", "当前没有配置角色命令。"); } lines.push("", "查看单个命令: /help new 或 /new help。"); return lines.join("\n"); } function formatSlashCommandHelp( commandName: string, registry: ModelRegistry, slashCommands: ReadonlyMap, ): string | null { const normalizedName = normalizeHelpTarget(commandName); const slashCommand = slashCommands.get(normalizedName); if (slashCommand !== undefined) return formatBuiltinSlashCommandHelp(slashCommand); const role = registry.role(normalizedName); if (role === undefined) return null; if (slashCommands.has(role.id)) return null; return formatRoleSlashCommandHelp(role); } function formatBuiltinSlashCommandHelp(command: SlashCommandDefinition): string { const helpUsage = command.name === "help" ? "帮助: /help help" : `帮助: /help ${command.name} 或 /${command.name} help`; return [ `/${command.name}`, command.summary, "", `用法: ${command.usage}`, ...command.details.map((detail) => `- ${detail}`), "", helpUsage, ].join("\n"); } function formatRoleSlashCommandHelp(role: RoleEntry): string { const lines = [ `/${role.id}`, `使用“${role.label}”角色发起一次 agent run。`, "", `用法: /${role.id} <需求>`, "- 真正运行时仍会按当前项目的 role.trigger 授权检查。", ]; if (role.defaultModel !== undefined) { lines.push(`- 默认模型: ${role.defaultModel}`); } lines.push( `- 工具范围: ${roleToolsDescription(role)}`, `- Skills: ${roleSkillsDescription(role)}`, "", `帮助: /help ${role.id} 或 /${role.id} help`, ); return lines.join("\n"); } function roleSkillsDescription(role: RoleEntry): string { if (role.skills === undefined || role.skills.length === 0) return "无"; return role.skills.map((skill) => `${skill.name}@${skill.version}`).join(", "); } function roleToolsDescription(role: RoleEntry): string { if (role.tools === undefined) return "全部已注册工具"; if (role.tools.length === 0) return "无"; return role.tools.join(", "); } function formatUnknownSlashHelp( commandName: string, registry: ModelRegistry, slashCommands: ReadonlyMap, ): string { const normalizedName = normalizeHelpTarget(commandName); return [ `未知 slash 命令 /${normalizedName}。`, "", formatSlashOverview(registry, slashCommands), ].join("\n"); } function visibleRoleCommands( registry: ModelRegistry, slashCommands: ReadonlyMap, ): readonly RoleEntry[] { return registry .listRoles() .filter((role) => !slashCommands.has(role.id)); }