forked from EduCraft/curriculum-project-hub
435 lines
14 KiB
TypeScript
435 lines
14 KiB
TypeScript
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<void>;
|
|
}
|
|
|
|
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<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 [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: "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<string, CostReportBucket>();
|
|
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, SlashCommandDefinition>,
|
|
): 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, SlashCommandDefinition>,
|
|
): 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, SlashCommandDefinition>,
|
|
): 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, SlashCommandDefinition>,
|
|
): 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, SlashCommandDefinition>,
|
|
): string {
|
|
const normalizedName = normalizeHelpTarget(commandName);
|
|
return [
|
|
`未知 slash 命令 /${normalizedName}。`,
|
|
"",
|
|
formatSlashOverview(registry, slashCommands),
|
|
].join("\n");
|
|
}
|
|
|
|
function visibleRoleCommands(
|
|
registry: ModelRegistry,
|
|
slashCommands: ReadonlyMap<string, SlashCommandDefinition>,
|
|
): readonly RoleEntry[] {
|
|
return registry
|
|
.listRoles()
|
|
.filter((role) => !slashCommands.has(role.id));
|
|
}
|