forked from EduCraft/curriculum-project-hub
feat: add slash command help
This commit is contained in:
@@ -0,0 +1,274 @@
|
||||
import type { PrismaClient } from "@prisma/client";
|
||||
import type { FastifyBaseLogger } from "fastify";
|
||||
import type { ModelRegistry, RoleEntry } from "../agent/models.js";
|
||||
import type { RuntimeSettings } from "../settings/runtime.js";
|
||||
import { sendText, type FeishuRuntime } 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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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 }) => {
|
||||
const registry = await deps.settings.modelRegistry({ projectId });
|
||||
await sendText(rt, chatId, formatHelpCommandInvocation(invocation, registry, commands));
|
||||
},
|
||||
});
|
||||
|
||||
add({
|
||||
name: "new",
|
||||
usage: "/new",
|
||||
summary: "开新会话,下次 @bot 将从头开始。",
|
||||
details: [
|
||||
"归档当前未归档的 agent session。",
|
||||
"不会清空当前项目的等待队列。",
|
||||
],
|
||||
run: async ({ projectId, chatId, rt }) => {
|
||||
await deps.prisma.agentSession.updateMany({
|
||||
where: { projectId, archivedAt: null },
|
||||
data: { archivedAt: new Date() },
|
||||
});
|
||||
await sendText(rt, chatId, "已开新会话,下次 @bot 将从头开始。");
|
||||
},
|
||||
});
|
||||
|
||||
add({
|
||||
name: "resume",
|
||||
usage: "/resume",
|
||||
summary: "恢复最近一次已归档的会话。",
|
||||
details: [
|
||||
"只恢复当前项目最近归档的 agent session。",
|
||||
"没有可恢复会话时只回复提示,不会创建 agent run。",
|
||||
],
|
||||
run: async ({ projectId, chatId, rt }) => {
|
||||
const latest = await deps.prisma.agentSession.findFirst({
|
||||
where: { projectId, archivedAt: { not: null } },
|
||||
orderBy: { archivedAt: "desc" },
|
||||
select: { id: true },
|
||||
});
|
||||
if (latest === null) {
|
||||
await sendText(rt, chatId, "没有可恢复的会话。");
|
||||
return;
|
||||
}
|
||||
await deps.prisma.agentSession.update({
|
||||
where: { id: latest.id },
|
||||
data: { archivedAt: null },
|
||||
});
|
||||
await sendText(rt, chatId, "已恢复上一个会话。");
|
||||
},
|
||||
});
|
||||
|
||||
add({
|
||||
name: "reset",
|
||||
usage: "/reset",
|
||||
summary: "重置当前会话并清空等待队列。",
|
||||
details: [
|
||||
"归档当前未归档的 agent session。",
|
||||
"清空当前项目已经排队、尚未开始的触发请求。",
|
||||
],
|
||||
run: async ({ projectId, chatId, rt }) => {
|
||||
await deps.prisma.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 将从头开始。");
|
||||
},
|
||||
});
|
||||
|
||||
return commands;
|
||||
}
|
||||
|
||||
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)}`, "", `帮助: /help ${role.id} 或 /${role.id} help`);
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
+26
-43
@@ -38,6 +38,7 @@ import { MessageBatcher, messageBatchKey, type MessageBatcherOptions } from "./m
|
||||
import { ApprovalManager } from "./approval.js";
|
||||
import { SenderNameCache } from "./senderCache.js";
|
||||
import { TriggerQueue, triggerQueue as defaultTriggerQueue, type QueuedTrigger } from "./triggerQueue.js";
|
||||
import { createSlashCommandRegistry, formatSlashHelpTarget, parseSlashHelpSubcommand, parseSlashInvocation } from "./slashCommands.js";
|
||||
|
||||
export { ApprovalManager } from "./approval.js";
|
||||
export type { ApprovalResult, PendingApproval } from "./approval.js";
|
||||
@@ -87,6 +88,12 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
|
||||
const runAgent = deps.runAgent ?? defaultRunAgent;
|
||||
const approvalManager = new ApprovalManager();
|
||||
const triggerQueue = deps.triggerQueue ?? defaultTriggerQueue;
|
||||
const slashCommands = createSlashCommandRegistry({
|
||||
prisma: deps.prisma,
|
||||
settings: deps.settings,
|
||||
logger: deps.logger,
|
||||
triggerQueue,
|
||||
});
|
||||
const batchContexts = new Map<string, TriggerRunContext>();
|
||||
// runId → AbortController for live runs. Registered when a run starts,
|
||||
// used by the interrupt card action to abort the SDK query, cleared in the
|
||||
@@ -676,48 +683,23 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
|
||||
// batcher. Session commands bypass the lock because they don't create runs;
|
||||
// role/unknown slash prompts still use the normal run path and queue if locked.
|
||||
if (cleanPrompt.startsWith("/")) {
|
||||
const cmd = cleanPrompt.split(/\s+/)[0];
|
||||
switch (cmd) {
|
||||
case "/new": {
|
||||
await deps.prisma.agentSession.updateMany({
|
||||
where: { projectId, archivedAt: null },
|
||||
data: { archivedAt: new Date() },
|
||||
});
|
||||
await sendText(rt, chatId, "已开新会话,下次 @bot 将从头开始。");
|
||||
return;
|
||||
}
|
||||
case "/resume": {
|
||||
const latest = await deps.prisma.agentSession.findFirst({
|
||||
where: { projectId, archivedAt: { not: null } },
|
||||
orderBy: { archivedAt: "desc" },
|
||||
select: { id: true },
|
||||
});
|
||||
if (latest === null) {
|
||||
await sendText(rt, chatId, "没有可恢复的会话。");
|
||||
return;
|
||||
}
|
||||
await deps.prisma.agentSession.update({
|
||||
where: { id: latest.id },
|
||||
data: { archivedAt: null },
|
||||
});
|
||||
await sendText(rt, chatId, "已恢复上一个会话。");
|
||||
return;
|
||||
}
|
||||
case "/reset": {
|
||||
await deps.prisma.agentSession.updateMany({
|
||||
where: { projectId, archivedAt: null },
|
||||
data: { archivedAt: new Date() },
|
||||
});
|
||||
const cleared = triggerQueue.clear(projectId);
|
||||
if (cleared > 0) {
|
||||
deps.logger.info({ projectId, cleared }, "feishu trigger: cleared queued triggers on reset");
|
||||
}
|
||||
await sendText(rt, chatId, "已重置,下次 @bot 将从头开始。");
|
||||
return;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
const invocation = parseSlashInvocation(cleanPrompt);
|
||||
const helpSubcommandTarget = invocation === null ? null : parseSlashHelpSubcommand(invocation);
|
||||
if (helpSubcommandTarget !== null) {
|
||||
// Help is generated on demand because role/tool configuration is runtime data.
|
||||
const models = await deps.settings.modelRegistry({ projectId });
|
||||
await sendText(rt, chatId, formatSlashHelpTarget(helpSubcommandTarget, models, slashCommands));
|
||||
return;
|
||||
}
|
||||
|
||||
if (invocation !== null) {
|
||||
const slashCommand = slashCommands.get(invocation.name);
|
||||
if (slashCommand !== undefined) {
|
||||
await slashCommand.run({ invocation, projectId, chatId, rt });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await startAgentRun(runContext, cleanPrompt);
|
||||
return;
|
||||
}
|
||||
@@ -1037,11 +1019,12 @@ async function downloadPostMessageFiles(args: {
|
||||
await downloadMessageFile(args.rt, args.msg.message_id, fileKey, savePath);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a leading `/<role>` command from a prompt (e.g. `/draft 帮我写教案`).
|
||||
* Returns the role id and the remaining prompt with the command stripped.
|
||||
* Control commands already handled upstream (`/new`, `/resume`, `/reset`) are
|
||||
* ignored here — they never reach this function.
|
||||
* Control/help commands already handled upstream are ignored here — they never
|
||||
* reach this function.
|
||||
*
|
||||
* Unknown `/foo` that isn't a registered role: returns `null` role and the
|
||||
* original prompt unchanged (the slash is treated as literal text). Role
|
||||
|
||||
Reference in New Issue
Block a user