import type { ProjectDiscoveryPage, ProjectFolderPage } from "../projectDiscovery.js"; export type ProjectOnboardingView = | { readonly mode: "search"; readonly result: ProjectDiscoveryPage } | { readonly mode: "browse"; readonly result: ProjectFolderPage }; export interface ProjectOnboardingActionValue { readonly action: | "create_project_from_chat" | "bind_project" | "browse_folder" | "search_page" | "rename_project" | "select_agent_role" | "new_agent_session" | "show_session_history" | "resume_agent_session" | "compact_agent_session" | "browse_move_destination" | "move_project" | "create_folder"; readonly organization_id: string; readonly project_id?: string | undefined; readonly folder_id?: string | undefined; readonly search_query?: string | undefined; readonly page?: number | undefined; readonly agent_role_id?: string | undefined; readonly session_id?: string | undefined; } export function buildUnboundChatOnboardingCard(params: { readonly organizationId: string; readonly organizationName: string; readonly canCreateProject: boolean; readonly view: ProjectOnboardingView; }): Record { const elements: unknown[] = [{ tag: "markdown", content: onboardingSummary(params.organizationName, params.view), }]; if (params.view.mode === "browse") { appendFolderNavigation(elements, params.organizationId, params.view.result); } appendProjectResults( elements, params.organizationId, params.view.mode === "search" ? params.view.result.items : params.view.result.projects, ); appendPagination(elements, params.organizationId, params.view); appendCreation(elements, params.organizationId, params.canCreateProject, params.view); if (elements.length === 1) { elements.push({ tag: "markdown", content: "当前目录没有可浏览内容。" }); } return { config: { wide_screen_mode: true }, header: { title: { tag: "plain_text", content: "绑定项目" }, template: "blue" }, elements, }; } export function buildProjectOnboardingResolvedCard(params: { readonly title: string; readonly body: string; readonly template: "green" | "red"; }): Record { return { config: { wide_screen_mode: true }, header: { title: { tag: "plain_text", content: params.title }, template: params.template }, elements: [{ tag: "markdown", content: params.body }], }; } export function buildProjectManagementCard(params: { readonly organizationId: string; readonly projectId: string; readonly projectName: string; readonly title?: string | undefined; readonly breadcrumb?: string | undefined; readonly selectedRole?: { readonly id: string; readonly roleId: string; readonly label: string } | undefined; readonly roles?: readonly { readonly id: string; readonly roleId: string; readonly label: string }[] | undefined; readonly currentSession?: { readonly id: string; readonly title: string | null; readonly updatedAt: Date; readonly runCount: number; readonly sdkSessionReady: boolean; } | null | undefined; readonly canManageProject?: boolean | undefined; }): Record { const elements: unknown[] = [{ tag: "markdown", content: [ `当前项目: **${escapeMarkdown(params.projectName)}**`, `所在目录: **${escapeMarkdown(params.breadcrumb ?? "根目录")}**`, params.selectedRole === undefined ? "当前角色: **未配置**" : `当前角色: **${escapeMarkdown(params.selectedRole.label)}**`, sessionSummary(params.currentSession), ].join("\n"), }]; const roles = params.roles ?? []; if (roles.length > 0) { elements.push({ tag: "markdown", content: "**切换角色**" }); for (let index = 0; index < roles.length; index += 5) { elements.push({ tag: "action", actions: roles.slice(index, index + 5).map((role) => actionButton( role.id === params.selectedRole?.id ? `✓ ${role.label}` : role.label, { action: "select_agent_role", organization_id: params.organizationId, project_id: params.projectId, agent_role_id: role.id, }, role.id === params.selectedRole?.id ? "primary" : "default", )), }); } } const sessionActions = [ actionButton("新开会话", { action: "new_agent_session", organization_id: params.organizationId, project_id: params.projectId, }), actionButton("历史会话", { action: "show_session_history", organization_id: params.organizationId, project_id: params.projectId, }), ]; if (params.currentSession?.sdkSessionReady === true) { sessionActions.push(actionButton("压缩上下文", { action: "compact_agent_session", organization_id: params.organizationId, project_id: params.projectId, })); } elements.push( { tag: "markdown", content: "**当前角色会话**" }, { tag: "action", actions: sessionActions }, ); if (params.canManageProject === true) { elements.push( { tag: "markdown", content: "**项目管理**" }, { tag: "action", actions: [actionButton("移动项目", { action: "browse_move_destination", organization_id: params.organizationId, project_id: params.projectId, page: 1, })] }, renameProjectForm(params), ); } return { config: { wide_screen_mode: true }, header: { title: { tag: "plain_text", content: params.title ?? "项目管理" }, template: "green", }, elements, }; } export function buildSessionHistoryCard(params: { readonly organizationId: string; readonly projectId: string; readonly roleLabel: string; readonly sessions: readonly { readonly id: string; readonly title: string | null; readonly updatedAt: Date; readonly runCount: number; }[]; }): Record { const elements: unknown[] = [{ tag: "markdown", content: `角色: **${escapeMarkdown(params.roleLabel)}**` }]; if (params.sessions.length === 0) elements.push({ tag: "markdown", content: "没有可恢复的历史会话。" }); for (const session of params.sessions) { elements.push( { tag: "markdown", content: `**${escapeMarkdown(session.title ?? "未命名会话")}**\n${session.runCount} runs · ${formatDate(session.updatedAt)}`, }, { tag: "action", actions: [actionButton("恢复此会话", { action: "resume_agent_session", organization_id: params.organizationId, project_id: params.projectId, session_id: session.id, }, "primary")] }, ); } return { config: { wide_screen_mode: true }, header: { title: { tag: "plain_text", content: "历史会话" }, template: "blue" }, elements, }; } export function buildMoveProjectCard(params: { readonly organizationId: string; readonly projectId: string; readonly projectName: string; readonly folderId: string | null; readonly parentFolderId: string | null; readonly breadcrumb: string; readonly childFolders: readonly { readonly id: string; readonly name: string }[]; readonly canCreateFolder: boolean; }): Record { const navigation: unknown[] = []; if (params.folderId !== null) { navigation.push(actionButton("⬆ 上一级", { action: "browse_move_destination", organization_id: params.organizationId, project_id: params.projectId, ...(params.parentFolderId !== null ? { folder_id: params.parentFolderId } : {}), })); } for (const folder of params.childFolders) { navigation.push(actionButton(`📁 ${buttonLabel(folder.name, 22)}`, { action: "browse_move_destination", organization_id: params.organizationId, project_id: params.projectId, folder_id: folder.id, })); } const elements: unknown[] = [ { tag: "markdown", content: `移动 **${escapeMarkdown(params.projectName)}**\n当前位置: **${escapeMarkdown(params.breadcrumb)}**` }, ]; for (let index = 0; index < navigation.length; index += 5) { elements.push({ tag: "action", actions: navigation.slice(index, index + 5) }); } elements.push({ tag: "action", actions: [actionButton("移动到这里", { action: "move_project", organization_id: params.organizationId, project_id: params.projectId, ...(params.folderId !== null ? { folder_id: params.folderId } : {}), }, "primary")] }); if (params.canCreateFolder) { elements.push(createFolderAndMoveForm({ organizationId: params.organizationId, projectId: params.projectId, parentFolderId: params.folderId, })); } return { config: { wide_screen_mode: true }, header: { title: { tag: "plain_text", content: "移动项目" }, template: "blue" }, elements, }; } export function projectOnboardingActionFromValue(value: unknown): ProjectOnboardingActionValue | null { const raw = unwrapValue(value); if (typeof raw !== "object" || raw === null || Array.isArray(raw) || !("project_onboarding" in raw)) return null; const action = raw.project_onboarding; if (typeof action !== "object" || action === null || Array.isArray(action)) return null; const fields = action as Record; const rawAction = fields.action; const organizationId = fields.organization_id; const projectId = fields.project_id; const folderId = fields.folder_id; const searchQuery = fields.search_query; const page = fields.page; const agentRoleId = fields.agent_role_id; const sessionId = fields.session_id; if (!isAction(rawAction) || typeof organizationId !== "string" || organizationId === "") return null; if ((rawAction === "bind_project" || rawAction === "rename_project") && (typeof projectId !== "string" || projectId === "")) return null; if (rawAction === "search_page" && (typeof searchQuery !== "string" || !validPage(page))) return null; if (folderId !== undefined && (typeof folderId !== "string" || folderId === "")) return null; if (page !== undefined && !validPage(page)) return null; if (rawAction === "select_agent_role" && (typeof agentRoleId !== "string" || agentRoleId === "")) return null; if (rawAction === "resume_agent_session" && (typeof sessionId !== "string" || sessionId === "")) return null; return { action: rawAction, organization_id: organizationId, ...(typeof projectId === "string" && projectId !== "" ? { project_id: projectId } : {}), ...(typeof folderId === "string" && folderId !== "" ? { folder_id: folderId } : {}), ...(typeof searchQuery === "string" ? { search_query: searchQuery.slice(0, 100) } : {}), ...(typeof page === "number" ? { page } : {}), ...(typeof agentRoleId === "string" && agentRoleId !== "" ? { agent_role_id: agentRoleId } : {}), ...(typeof sessionId === "string" && sessionId !== "" ? { session_id: sessionId } : {}), }; } function onboardingSummary(organizationName: string, view: ProjectOnboardingView): string { if (view.mode === "search") { const result = view.result; return [ "这个飞书群还没有绑定项目。", "", `组织: **${escapeMarkdown(organizationName)}**`, `搜索: **${escapeMarkdown(result.query)}**`, result.totalItems === 0 ? "没有匹配的可绑定项目,请换一个关键词。" : `共 **${result.totalItems}** 个匹配结果 · 第 **${result.page}/${result.totalPages}** 页`, ].join("\n"); } const location = view.result.breadcrumb === "" ? "根目录" : view.result.breadcrumb; return [ "这个飞书群还没有绑定项目。", "", `组织: **${escapeMarkdown(organizationName)}**`, `当前位置: **${escapeMarkdown(location)}**`, "可以进入 folder 浏览,或选择有管理权限的未绑定项目。", ].join("\n"); } function appendFolderNavigation(elements: unknown[], organizationId: string, result: ProjectFolderPage): void { const navigation: unknown[] = []; if (result.folderId !== null) { navigation.push(actionButton("⬆ 上一级", { action: "browse_folder", organization_id: organizationId, ...(result.parentFolderId !== null ? { folder_id: result.parentFolderId } : {}), page: 1, })); } for (const folder of result.childFolders) { navigation.push(actionButton(`📁 ${buttonLabel(folder.name, 22)}`, { action: "browse_folder", organization_id: organizationId, folder_id: folder.folderId, page: 1, })); } for (let index = 0; index < navigation.length; index += 5) { elements.push({ tag: "action", actions: navigation.slice(index, index + 5) }); } } function appendProjectResults( elements: unknown[], organizationId: string, projects: readonly ProjectDiscoveryPage["items"][number][], ): void { for (const project of projects) { const path = project.breadcrumb === "" ? "根目录" : project.breadcrumb; elements.push({ tag: "markdown", content: `**${escapeMarkdown(project.name)}**\n${escapeMarkdown(path)}`, }); elements.push({ tag: "action", actions: [actionButton("绑定这个项目", { action: "bind_project", organization_id: organizationId, project_id: project.projectId, })], }); } } function appendPagination(elements: unknown[], organizationId: string, view: ProjectOnboardingView): void { const result = projectPage(view); if (result.totalPages <= 1) return; const actions: unknown[] = []; if (result.page > 1) actions.push(pageButton("上一页", organizationId, view, result.page - 1)); if (result.page < result.totalPages) actions.push(pageButton("下一页", organizationId, view, result.page + 1)); elements.push({ tag: "action", actions }); } function appendCreation( elements: unknown[], organizationId: string, canCreateProject: boolean, view: ProjectOnboardingView, ): void { if (!canCreateProject || view.mode !== "browse") return; elements.push({ tag: "action", actions: [actionButton(view.result.folderId === null ? "新建项目" : "在此 folder 新建项目", { action: "create_project_from_chat", organization_id: organizationId, ...(view.result.folderId !== null ? { folder_id: view.result.folderId } : {}), }, "primary")], }); } function pageButton(label: string, organizationId: string, view: ProjectOnboardingView, page: number): unknown { if (view.mode === "search") { return actionButton(label, { action: "search_page", organization_id: organizationId, search_query: view.result.query, page, }); } return actionButton(label, { action: "browse_folder", organization_id: organizationId, ...(view.result.folderId !== null ? { folder_id: view.result.folderId } : {}), page, }); } function projectPage(view: ProjectOnboardingView): ProjectDiscoveryPage { if (view.mode === "search") return view.result; return { query: "", page: view.result.page, pageSize: view.result.pageSize, totalItems: view.result.totalFolders + view.result.totalProjects, totalPages: view.result.totalPages, items: view.result.projects, }; } function renameProjectForm(params: { readonly organizationId: string; readonly projectId: string; readonly projectName: string; }): unknown { return { tag: "form", name: "project_rename_form", fallback: { tag: "fallback_text", text: { tag: "plain_text", content: "请升级飞书客户端后修改项目名称。" }, }, elements: [ { tag: "input", name: "project_name", required: true, max_length: 100, default_value: params.projectName, placeholder: { tag: "plain_text", content: "请输入项目名称" }, }, { tag: "button", name: "project_rename_submit", text: { tag: "plain_text", content: "保存项目名称" }, type: "primary", complex_interaction: true, action_type: "form_submit", value: { project_onboarding: { action: "rename_project", organization_id: params.organizationId, project_id: params.projectId, } }, }, ], }; } function createFolderAndMoveForm(params: { readonly organizationId: string; readonly projectId: string; readonly parentFolderId: string | null; }): unknown { return { tag: "form", name: "folder_create_form", elements: [ { tag: "input", name: "folder_name", required: true, max_length: 100, placeholder: { tag: "plain_text", content: "在当前目标目录下新建 Folder" }, }, { tag: "button", name: "folder_create_submit", text: { tag: "plain_text", content: "新建并移动" }, type: "default", complex_interaction: true, action_type: "form_submit", value: { project_onboarding: { action: "create_folder", organization_id: params.organizationId, project_id: params.projectId, ...(params.parentFolderId !== null ? { folder_id: params.parentFolderId } : {}), } }, }, ], }; } function sessionSummary(session: { readonly title: string | null; readonly updatedAt: Date; readonly runCount: number; } | null | undefined): string { if (session === null || session === undefined) return "当前会话: **尚未开始**"; return `当前会话: **${escapeMarkdown(session.title ?? "未命名会话")}** · ${session.runCount} runs · ${formatDate(session.updatedAt)}`; } function formatDate(value: Date): string { return new Intl.DateTimeFormat("zh-CN", { timeZone: "Asia/Shanghai", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", hour12: false, }).format(value); } function actionButton( label: string, value: ProjectOnboardingActionValue, type: "default" | "primary" = "default", ): unknown { return { tag: "button", text: { tag: "plain_text", content: label }, type, value: { project_onboarding: value }, }; } function isAction(value: unknown): value is ProjectOnboardingActionValue["action"] { return value === "create_project_from_chat" || value === "bind_project" || value === "browse_folder" || value === "search_page" || value === "rename_project" || value === "select_agent_role" || value === "new_agent_session" || value === "show_session_history" || value === "resume_agent_session" || value === "compact_agent_session" || value === "browse_move_destination" || value === "move_project" || value === "create_folder"; } function validPage(value: unknown): value is number { return typeof value === "number" && Number.isSafeInteger(value) && value >= 1 && value <= 10_000; } function unwrapValue(value: unknown): unknown { if (typeof value !== "string") return value; try { return JSON.parse(value); } catch { return value; } } function buttonLabel(label: string, maxLength: number): string { return label.length <= maxLength ? label : `${label.slice(0, maxLength - 3)}...`; } function escapeMarkdown(value: string): string { return value.replace(/([`*_{}[\]<>])/g, "\\$1"); }