feat: redesign Feishu project console

This commit is contained in:
2026-07-13 16:52:45 +08:00
parent 82f57317df
commit 69837bd50c
34 changed files with 1738 additions and 978 deletions
+281 -108
View File
@@ -52,22 +52,34 @@ import {
type StagedMessageResourceBatch,
} from "./resourceStaging.js";
import { TriggerQueue, triggerQueue as defaultTriggerQueue, type QueuedTrigger } from "./triggerQueue.js";
import { createSlashCommandRegistry, formatSlashHelpTarget, parseSlashHelpSubcommand, parseSlashInvocation } from "./slashCommands.js";
import { createSlashCommandRegistry, parseSlashInvocation } from "./slashCommands.js";
import { cphHubMcpToolsForRole, roleToolsAllow } from "../agent/roleTools.js";
import {
bindFeishuChatToProject,
createFolder,
createProjectFromFeishuChat,
ensureOrganizationProjectSettings,
moveProjectToFolder,
renameProjectForActor,
} from "../projectOnboarding.js";
import {
buildProjectManagementCard,
buildMoveProjectCard,
buildSessionHistoryCard,
buildProjectOnboardingResolvedCard,
buildUnboundChatOnboardingCard,
projectOnboardingActionFromValue,
type ProjectOnboardingActionValue,
type ProjectOnboardingView,
} from "./projectOnboardingCard.js";
import {
archiveCurrentRoleSession,
browseFolderDestinations,
listRoleSessionHistory,
loadProjectConsole,
resumeRoleSession,
selectProjectGroupRole,
} from "./projectConsole.js";
import { SiloFixedWindowRateLimiter } from "../deployment/siloRateLimit.js";
import { browseBindableFolder, discoverBindableProjects } from "../projectDiscovery.js";
@@ -113,6 +125,7 @@ interface TriggerRunContext {
readonly projectId: string;
readonly senderOpenId: string;
readonly actor: TriggerActor;
readonly roleId: string;
}
type StartAgentRunOutcome = "started" | "queued" | "rejected" | "locked" | "skipped";
@@ -150,9 +163,6 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
: new SiloFixedWindowRateLimiter(deps.maxFeishuEventsPerMinute, 60_000);
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,
@@ -178,10 +188,48 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
}
}, deps.messageBatcherOptions);
async function projectConsoleCard(
projectId: string,
chatId: string,
actorOpenId: string,
title?: string,
): Promise<Record<string, unknown>> {
const state = await loadProjectConsole(deps.prisma, { projectId, chatId });
const actor = { feishuOpenId: actorOpenId, chatId };
const [manageDecision, organization, roleDecisions] = await Promise.all([
authorizer.can({ actor, action: "collaborator.manage", resource: { type: "PROJECT", id: projectId } }),
resolveSingleActiveOrganizationForFeishuUser(actorOpenId),
Promise.all(state.roles.map(async (role) => ({
role,
decision: await authorizer.can({
actor,
action: "role.trigger",
resource: { type: "PROJECT", id: projectId },
roleId: role.roleId,
}),
}))),
]);
const visibleRoles = roleDecisions.filter(({ decision }) => decision.allowed).map(({ role }) => role);
const isOrgAdmin = organization.status === "ok" &&
organization.organizationId === state.organizationId && isOrgAdminRole(organization.role);
return buildProjectManagementCard({
organizationId: state.organizationId,
projectId: state.projectId,
projectName: state.projectName,
breadcrumb: state.breadcrumb,
selectedRole: state.selectedRole,
roles: visibleRoles,
currentSession: state.currentSession,
canManageProject: manageDecision.allowed || isOrgAdmin,
canCreateFolder: isOrgAdmin,
...(title !== undefined ? { title } : {}),
});
}
async function startAgentRun(
context: TriggerRunContext,
cleanPrompt: string,
options: { readonly queueIfLocked?: boolean } = {},
options: { readonly queueIfLocked?: boolean; readonly nativeSlash?: boolean } = {},
): Promise<StartAgentRunOutcome> {
const { msg, rt, chatId, projectId, senderOpenId, actor } = context;
const sendOptions = sendOptionsForTriggerMessage(msg);
@@ -195,7 +243,7 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
return "rejected";
}
if (!queueIfLocked) return "locked";
return enqueueLockedTrigger(context, cleanPrompt);
return enqueueLockedTrigger(context, cleanPrompt, options.nativeSlash === true ? "native_compact" : "conversation");
}
const project = await deps.prisma.project.findUnique({
@@ -207,15 +255,11 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
return "skipped";
}
// ADR-0017: role-as-data. Parse a leading `/<role>` command; unknown
// slashes are left as literal text (extractRole returns null). Falls back
// to "draft" when no role is named — the registry degrades gracefully.
const models = await deps.settings.modelRegistry({ projectId });
const { roleId: parsedRole, prompt: parsedAgentPrompt } = extractRole(cleanPrompt, models);
const roleId = parsedRole ?? "draft";
const roleId = context.roleId;
// ADR-0019: role trigger is the second gate after project agent.trigger.
// Unconfigured roles remain open for back-compat; configured roles require
// a matching active RoleTriggerGrant for any resolved principal.
// Configured roles require a matching active RoleTriggerGrant for any
// resolved principal; otherwise the role remains open within the project.
const roleDecision = await authorizer.can({
actor,
action: "role.trigger",
@@ -262,8 +306,10 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
projectWorkspaceRoot,
deps.resourceLimits,
);
const agentPrompt = appendStagedResourcePaths(parsedAgentPrompt, stagedResources, project.workspaceDir);
const promptForAgent = withFeishuTriggerContext(agentPrompt, feishuTriggerContext);
const agentPrompt = appendStagedResourcePaths(cleanPrompt, stagedResources, project.workspaceDir);
const promptForAgent = options.nativeSlash === true
? cleanPrompt
: withFeishuTriggerContext(agentPrompt, feishuTriggerContext);
// Linearization point for org lifecycle + session/run/lock admission.
// FOR SHARE on the Organization row conflicts with a concurrent status
@@ -623,10 +669,16 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
return "started";
}
async function enqueueLockedTrigger(context: TriggerRunContext, cleanPrompt: string): Promise<StartAgentRunOutcome> {
async function enqueueLockedTrigger(
context: TriggerRunContext,
cleanPrompt: string,
executionKind: QueuedTrigger["executionKind"] = "conversation",
): Promise<StartAgentRunOutcome> {
const position = triggerQueue.enqueue(context.projectId, {
chatId: context.chatId,
prompt: cleanPrompt,
roleId: context.roleId,
executionKind,
msg: context.msg,
senderOpenId: context.senderOpenId,
actor: context.actor,
@@ -664,7 +716,10 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
const next = triggerQueue.dequeue(projectId);
if (next === null) return;
const outcome = await startAgentRun(contextFromQueuedTrigger(next, rt), next.prompt, { queueIfLocked: false });
const outcome = await startAgentRun(contextFromQueuedTrigger(next, rt), next.prompt, {
queueIfLocked: false,
nativeSlash: next.executionKind === "native_compact",
});
if (outcome === "started") return;
if (outcome === "locked") {
requeueTrigger(next);
@@ -681,6 +736,7 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
projectId: trigger.projectId,
senderOpenId: trigger.senderOpenId,
actor: trigger.actor,
roleId: trigger.roleId,
};
}
@@ -688,6 +744,8 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
const position = triggerQueue.enqueue(trigger.projectId, {
chatId: trigger.chatId,
prompt: trigger.prompt,
roleId: trigger.roleId,
executionKind: trigger.executionKind,
msg: trigger.msg,
senderOpenId: trigger.senderOpenId,
actor: trigger.actor,
@@ -761,6 +819,169 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
if (organization.organizationId !== action.organization_id) {
throw new Error("project onboarding organization mismatch");
}
if (
action.action === "select_agent_role" ||
action.action === "new_agent_session" ||
action.action === "show_session_history" ||
action.action === "resume_agent_session" ||
action.action === "compact_agent_session"
) {
const projectId = action.project_id;
if (projectId === undefined) throw new Error(`${action.action} requires project_id`);
const binding = await deps.prisma.projectGroupBinding.findFirst({
where: { projectId, chatId, archivedAt: null },
select: { id: true },
});
if (binding === null) throw new Error("project console action requires the project to be bound to this chat");
const state = await loadProjectConsole(deps.prisma, { projectId, chatId });
const targetRole = action.action === "select_agent_role"
? state.roles.find((role) => role.id === action.agent_role_id)
: state.selectedRole;
if (targetRole === undefined) throw new Error("selected Agent role is unavailable");
const actor = { feishuOpenId: operatorOpenId, chatId };
const [triggerDecision, roleDecision] = await Promise.all([
authorizer.can({ actor, action: "agent.trigger", resource: { type: "PROJECT", id: projectId } }),
authorizer.can({
actor,
action: "role.trigger",
resource: { type: "PROJECT", id: projectId },
roleId: targetRole.roleId,
}),
]);
if (!triggerDecision.allowed || !roleDecision.allowed || roleDecision.actorUserId === undefined) {
throw new Error(`not authorized for Agent role ${targetRole.roleId}`);
}
if (action.action === "select_agent_role") {
await selectProjectGroupRole(deps.prisma, {
projectId,
chatId,
agentRoleId: targetRole.id,
actorUserId: roleDecision.actorUserId,
});
if (messageId === undefined) throw new Error("role selection requires message id");
await patchCard(rt, messageId, await projectConsoleCard(
projectId,
chatId,
operatorOpenId,
`已切换至 ${targetRole.label}`,
));
return;
}
if (action.action === "new_agent_session") {
await archiveCurrentRoleSession(deps.prisma, {
projectId,
chatId,
actorUserId: roleDecision.actorUserId,
});
if (messageId === undefined) throw new Error("new session requires message id");
await patchCard(rt, messageId, await projectConsoleCard(projectId, chatId, operatorOpenId, "已新开会话"));
return;
}
if (action.action === "show_session_history") {
const sessions = await listRoleSessionHistory(deps.prisma, { projectId, chatId });
if (messageId === undefined) throw new Error("session history requires message id");
await patchCard(rt, messageId, buildSessionHistoryCard({
organizationId: state.organizationId,
projectId,
roleLabel: state.selectedRole.label,
sessions,
}));
return;
}
if (action.action === "resume_agent_session") {
if (action.session_id === undefined) throw new Error("resume_agent_session requires session_id");
await resumeRoleSession(deps.prisma, {
projectId,
chatId,
sessionId: action.session_id,
actorUserId: roleDecision.actorUserId,
});
if (messageId === undefined) throw new Error("session resume requires message id");
await patchCard(rt, messageId, await projectConsoleCard(projectId, chatId, operatorOpenId, "已恢复历史会话"));
return;
}
if (state.currentSession === null || !state.currentSession.sdkSessionReady) {
throw new Error("当前角色还没有可压缩的 Claude 会话");
}
const commandMessage: MessageReceiveEvent["message"] = {
message_id: messageId ?? randomUUID(),
chat_id: chatId,
chat_type: "group",
message_type: "text",
content: JSON.stringify({ text: "/compact" }),
mentions: [],
};
await startAgentRun({
msg: commandMessage,
rt,
chatId,
projectId,
senderOpenId: operatorOpenId,
actor,
roleId: state.selectedRole.roleId,
}, "/compact", { nativeSlash: true });
return;
}
if (
action.action === "browse_move_destination" ||
action.action === "move_project" ||
action.action === "create_folder"
) {
const projectId = action.project_id;
if (projectId === undefined) throw new Error(`${action.action} requires project_id`);
const state = await loadProjectConsole(deps.prisma, { projectId, chatId });
const isOrgAdmin = isOrgAdminRole(organization.role);
const manageDecision = await authorizer.can({
actor: { feishuOpenId: operatorOpenId, chatId },
action: "collaborator.manage",
resource: { type: "PROJECT", id: projectId },
});
if (!isOrgAdmin && !manageDecision.allowed) throw new Error("project MANAGE permission is required");
if (action.action === "browse_move_destination") {
const page = await browseFolderDestinations(deps.prisma, {
organizationId: state.organizationId,
folderId: action.folder_id ?? null,
});
if (messageId === undefined) throw new Error("folder navigation requires message id");
await patchCard(rt, messageId, buildMoveProjectCard({
organizationId: state.organizationId,
projectId,
projectName: state.projectName,
...page,
}));
return;
}
if (action.action === "move_project") {
await moveProjectToFolder(deps.prisma, { projectId, folderId: action.folder_id ?? null });
await writeAudit(deps.prisma, {
projectId,
...(manageDecision.actorUserId !== undefined ? { actorUserId: manageDecision.actorUserId } : {}),
action: "project.moved_from_feishu",
metadata: { folderId: action.folder_id ?? null },
});
if (messageId === undefined) throw new Error("project move requires message id");
await patchCard(rt, messageId, await projectConsoleCard(projectId, chatId, operatorOpenId, "项目已移动"));
return;
}
if (!isOrgAdmin) throw new Error("creating an Organization folder requires OWNER or ADMIN");
const folderName = event.action.form_value?.["folder_name"];
if (typeof folderName !== "string") throw new Error("create folder form is missing folder_name");
const folder = await createFolder(deps.prisma, {
organizationId: state.organizationId,
name: folderName.slice(0, 100),
...(state.folderId !== null ? { parentId: state.folderId } : {}),
});
await writeAudit(deps.prisma, {
projectId,
action: "folder.created_from_feishu",
metadata: { folderId: folder.id, parentId: state.folderId, name: folder.name },
});
if (messageId === undefined) throw new Error("folder creation requires message id");
await patchCard(rt, messageId, await projectConsoleCard(projectId, chatId, operatorOpenId, "目录已创建"));
return;
}
if (action.action === "rename_project") {
const projectId = action.project_id;
if (projectId === undefined) throw new Error("rename_project action requires project_id");
@@ -778,12 +999,12 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
name: submittedName.slice(0, 100),
});
if (messageId === undefined) throw new Error("project rename requires message id");
await patchCard(rt, messageId, buildProjectManagementCard({
organizationId: organization.organizationId,
projectId: renamed.projectId,
projectName: renamed.name,
title: "项目名称已更新",
}));
await patchCard(rt, messageId, await projectConsoleCard(
renamed.projectId,
chatId,
operatorOpenId,
"项目名称已更新",
));
deps.logger.info({ chatId, projectId, operatorOpenId }, "project onboarding: project renamed");
return;
}
@@ -834,12 +1055,12 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
folderId: action.folder_id,
});
if (messageId !== undefined) {
await patchCard(rt, messageId, buildProjectManagementCard({
organizationId: organization.organizationId,
projectId: project.projectId,
projectName: name,
title: "已创建并绑定项目",
}));
await patchCard(rt, messageId, await projectConsoleCard(
project.projectId,
chatId,
operatorOpenId,
"已创建并绑定项目",
));
}
deps.logger.info({ chatId, projectId: project.projectId, operatorOpenId }, "project onboarding: created project from chat");
return;
@@ -863,16 +1084,12 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
chatId,
});
if (messageId !== undefined) {
const boundProject = await deps.prisma.project.findUniqueOrThrow({
where: { id: project.projectId },
select: { name: true },
});
await patchCard(rt, messageId, buildProjectManagementCard({
organizationId: organization.organizationId,
projectId: project.projectId,
projectName: boundProject.name,
title: "已绑定项目",
}));
await patchCard(rt, messageId, await projectConsoleCard(
project.projectId,
chatId,
operatorOpenId,
"已绑定项目",
));
}
deps.logger.info({ chatId, projectId: project.projectId, operatorOpenId }, "project onboarding: bound existing project");
} catch (e) {
@@ -905,7 +1122,7 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
}
const binding = await deps.prisma.projectGroupBinding.findFirst({
where: { chatId, archivedAt: null },
select: { projectId: true },
select: { projectId: true, selectedRole: { select: { roleId: true } } },
});
if (binding === null) {
deps.logger.debug({ chatId }, "feishu interrupt: chat not bound to any project");
@@ -1010,7 +1227,7 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
// ADR-0001: resolve chat → project. Unknown chat ⇒ not a project group.
const binding = await deps.prisma.projectGroupBinding.findFirst({
where: { chatId, archivedAt: null },
select: { projectId: true },
select: { projectId: true, selectedRole: { select: { roleId: true } } },
});
if (binding === null) {
deps.logger.debug({ chatId }, "feishu trigger: chat not bound to any project");
@@ -1082,53 +1299,33 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
}
}
if (cleanPrompt === null) return;
const runContext: TriggerRunContext = { msg, rt, chatId, projectId, senderOpenId, actor };
const runContext: TriggerRunContext = {
msg,
rt,
chatId,
projectId,
senderOpenId,
actor,
roleId: binding.selectedRole.roleId,
};
// Slash commands: session management, not agent runs. These bypass the
// 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.
// Hub owns a closed slash-command protocol. Unknown commands fail visibly;
// they are never downgraded to Agent text or forwarded to the SDK.
if (cleanPrompt.startsWith("/")) {
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), sendOptionsForTriggerMessage(msg));
return;
}
if (invocation !== null) {
if (invocation.name === "project") {
if (invocation.args.length > 0) {
await sendText(rt, chatId, "用法: /project(打开当前项目管理卡片)", sendOptionsForTriggerMessage(msg));
await sendText(rt, chatId, "用法: /project", sendOptionsForTriggerMessage(msg));
return;
}
const project = await deps.prisma.project.findUniqueOrThrow({
where: { id: projectId },
select: { id: true, name: true, organizationId: true },
});
const organization = await resolveSingleActiveOrganizationForFeishuUser(senderOpenId);
if (organization.status !== "ok" || organization.organizationId !== project.organizationId) {
await sendText(rt, chatId, "当前身份不属于该项目组织。", sendOptionsForTriggerMessage(msg));
return;
}
if (!isOrgAdminRole(organization.role)) {
const decision = await authorizer.can({
actor,
action: "collaborator.manage",
resource: { type: "PROJECT", id: projectId },
});
if (!decision.allowed) {
await sendText(rt, chatId, "你没有管理当前项目的权限。", sendOptionsForTriggerMessage(msg));
return;
}
}
await sendCard(rt, chatId, buildProjectManagementCard({
organizationId: project.organizationId,
projectId: project.id,
projectName: project.name,
}), sendOptionsForTriggerMessage(msg));
deps.logger.info({ chatId, projectId, senderOpenId }, "feishu project management card opened");
await sendCard(
rt,
chatId,
await projectConsoleCard(projectId, chatId, senderOpenId),
sendOptionsForTriggerMessage(msg),
);
deps.logger.info({ chatId, projectId, senderOpenId }, "feishu project console opened");
return;
}
const slashCommand = slashCommands.get(invocation.name);
@@ -1146,8 +1343,8 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
return;
}
}
await startAgentRun(runContext, cleanPrompt);
const name = invocation?.name ?? cleanPrompt.slice(1).trim();
await sendText(rt, chatId, `未知 slash 命令 /${name}。使用 /help 查看可用命令。`, sendOptionsForTriggerMessage(msg));
return;
}
@@ -1161,11 +1358,11 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
await enqueueLockedTrigger(runContext, cleanPrompt);
return;
}
const key = messageBatchKey(chatId, senderOpenId);
const key = messageBatchKey(chatId, senderOpenId, runContext.roleId);
if (!batchContexts.has(key)) {
batchContexts.set(key, runContext);
}
await messageBatcher.enqueue(chatId, senderOpenId, cleanPrompt);
await messageBatcher.enqueue(chatId, senderOpenId, cleanPrompt, runContext.roleId);
return;
}
@@ -1750,27 +1947,3 @@ async function compensateFailedResourceAdmission(
);
}
}
/**
* Parse a leading `/<role>` command from a prompt (e.g. `/draft 帮我写教案`).
* Returns the role id and the remaining prompt with the command stripped.
* 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
* resolution still happens via the registry's fallback.
*/
export function extractRole(
prompt: string,
registry: { role(id: string): unknown },
): { roleId: string | null; prompt: string } {
const m = /^\/(\S+)\s*/.exec(prompt);
if (m === null || m[1] === undefined) return { roleId: null, prompt };
const roleId = m[1];
if (registry.role(roleId) === undefined) {
// Unknown slash command — leave the prompt as-is (no silent role switch).
return { roleId: null, prompt };
}
return { roleId, prompt: prompt.slice(m[0].length) };
}