From b22bb8e1ac5e19e8d8b4f457c5ab3af14e007ce2 Mon Sep 17 00:00:00 2001 From: 58fly Date: Mon, 11 May 2026 22:20:46 +0800 Subject: [PATCH] feat: add Feishu session controls and streaming summaries Expose historical session selection and resume commands in the IM adapter, and simplify Feishu streaming cards so users see concise status, tool activity, and final answers. Co-Authored-By: Claude Sonnet 4.6 --- adapters/common/__tests__/format.test.ts | 22 +- adapters/common/__tests__/http-client.test.ts | 27 ++ adapters/common/format.ts | 52 ++- adapters/common/http-client.ts | 32 ++ adapters/feishu/__tests__/feishu.test.ts | 267 ++++++++++++- .../feishu/__tests__/streaming-card.test.ts | 128 ++++-- adapters/feishu/index.ts | 366 +++++++++++++++++- adapters/feishu/streaming-card.ts | 93 +++-- docs/desktop/03-features.md | 7 +- docs/im/feishu.md | 28 +- 10 files changed, 910 insertions(+), 112 deletions(-) diff --git a/adapters/common/__tests__/format.test.ts b/adapters/common/__tests__/format.test.ts index 943ee6bbe6..68679a4d69 100644 --- a/adapters/common/__tests__/format.test.ts +++ b/adapters/common/__tests__/format.test.ts @@ -106,6 +106,8 @@ describe('formatImHelp', () => { const text = formatImHelp() expect(text).toContain('/new') expect(text).toContain('/projects') + expect(text).not.toContain('/sessions') + expect(text).not.toContain('/resume') expect(text).toContain('/status') expect(text).toContain('/clear') expect(text).toContain('/stop') @@ -113,6 +115,12 @@ describe('formatImHelp', () => { expect(text).toContain('项目列表') expect(text).toContain('/allow ') }) + + it('can include historical session commands for adapters that support them', () => { + const text = formatImHelp({ includeSessionCommands: true }) + expect(text).toContain('/sessions') + expect(text).toContain('/resume') + }) }) describe('formatImStatus', () => { @@ -133,12 +141,14 @@ describe('formatImStatus', () => { }, }) - expect(text).toContain('项目: claude-code-haha (main)') - expect(text).toContain('会话: abc12345…') - expect(text).toContain('模型: claude-sonnet') - expect(text).toContain('状态: 执行工具中 (Running tests)') - expect(text).toContain('审批: 1 个待确认') - expect(text).toContain('任务: 总计 4 · 进行中 2 · 待处理 1 · 已完成 1') + expect(text).toContain('当前状态面板') + expect(text).toContain('项目:claude-code-haha (main)') + expect(text).toContain('会话:abc12345…') + expect(text).toContain('模型:claude-sonnet') + expect(text).toContain('状态:正在执行工具 (Running tests)') + expect(text).toContain('审批:1 个待确认') + expect(text).toContain('任务:总计 4 · 进行中 2 · 待处理 1 · 已完成 1') + expect(text).toContain('/sessions') }) it('returns a friendly empty-session message when nothing is active', () => { diff --git a/adapters/common/__tests__/http-client.test.ts b/adapters/common/__tests__/http-client.test.ts index e4c83622f1..1edd32e075 100644 --- a/adapters/common/__tests__/http-client.test.ts +++ b/adapters/common/__tests__/http-client.test.ts @@ -56,6 +56,33 @@ describe('AdapterHttpClient', () => { expect(projects[0].projectName).toBe('my-app') }) + it('listSessions calls GET /api/sessions with filters', async () => { + const mockSessions = [{ + id: 'session-123', + title: 'Fix bug', + createdAt: '2026-01-01T00:00:00.000Z', + modifiedAt: '2026-01-02T00:00:00.000Z', + messageCount: 4, + projectPath: '-home-user-my-app', + workDir: '/home/user/my-app', + workDirExists: true, + }] + globalThis.fetch = mock(() => + Promise.resolve(new Response(JSON.stringify({ sessions: mockSessions, total: 1 }), { + headers: { 'Content-Type': 'application/json' }, + })) + ) as any + + const result = await client.listSessions({ project: '/home/user/my-app', limit: 10, offset: 20 }) + + expect(result.sessions[0]?.id).toBe('session-123') + const callUrl = new URL((globalThis.fetch as any).mock.calls[0][0]) + expect(`${callUrl.origin}${callUrl.pathname}`).toBe('http://127.0.0.1:3456/api/sessions') + expect(callUrl.searchParams.get('project')).toBe('/home/user/my-app') + expect(callUrl.searchParams.get('limit')).toBe('10') + expect(callUrl.searchParams.get('offset')).toBe('20') + }) + it('matchProject accepts an absolute local project path inside an allowed root without recent history', async () => { const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), 'im-root-')) const projectDir = fs.mkdtempSync(path.join(rootDir, 'project-')) diff --git a/adapters/common/format.ts b/adapters/common/format.ts index 09e62349c2..b8ff19e6e9 100644 --- a/adapters/common/format.ts +++ b/adapters/common/format.ts @@ -32,9 +32,15 @@ const IM_HELP_LINES = [ '/clear / 清空 — 清空当前会话上下文', '/stop / 停止 — 停止当前生成', '/help / 帮助 — 显示这份帮助', - '权限审批:/allow 、/always 、/deny ', ] +const IM_SESSION_HELP_LINES = [ + '/sessions [项目] / 会话列表 — 查看历史会话', + '/resume <编号|sessionId> / 切换会话 — 切换到历史会话', +] + +const IM_PERMISSION_HELP_LINE = '权限审批:/allow 、/always 、/deny ' + /** Split text into chunks that fit within a character limit, respecting paragraph/sentence boundaries. */ export function splitMessage(text: string, limit: number): string[] { if (text.length <= limit) return [text] @@ -159,35 +165,40 @@ export function escapeMarkdownV2(text: string): string { return text.replace(/([_*\[\]()~`>#+\-=|{}.!\\])/g, '\\$1') } -export function formatImHelp(): string { - return `可用命令:\n\n${IM_HELP_LINES.join('\n')}` +export function formatImHelp(options?: { includeSessionCommands?: boolean }): string { + const lines = options?.includeSessionCommands + ? [...IM_HELP_LINES, ...IM_SESSION_HELP_LINES, IM_PERMISSION_HELP_LINE] + : [...IM_HELP_LINES, IM_PERMISSION_HELP_LINE] + return `可用命令:\n\n${lines.join('\n')}` } export function formatImStatus(summary: ImStatusSummary | null): string { if (!summary?.sessionId) { - return '当前没有活动会话。\n\n发送 /new 新建会话,或发送 /projects 选择项目。' + return '📍 当前没有活动会话\n\n可执行:`/new` 新建会话,或发送 `/projects` 选择项目。' } - const lines = ['当前会话状态:'] + const lines = ['📍 **当前状态面板**'] if (summary.projectName) { - lines.push(`项目: ${summary.projectName}${summary.branch ? ` (${summary.branch})` : ''}`) + lines.push(`项目:${summary.projectName}${summary.branch ? ` (${summary.branch})` : ''}`) } else if (summary.branch) { - lines.push(`分支: ${summary.branch}`) + lines.push(`分支:${summary.branch}`) } - lines.push(`会话: ${shortSessionId(summary.sessionId)}`) + lines.push(`会话:${shortSessionId(summary.sessionId)}`) if (summary.model) { - lines.push(`模型: ${summary.model}`) + lines.push(`模型:${summary.model}`) } - lines.push(`状态: ${formatAdapterChatState(summary.state, summary.verb)}`) + lines.push(`状态:${formatAdapterChatState(summary.state, summary.verb)}`) const pendingPermissionCount = summary.pendingPermissionCount ?? 0 - if (pendingPermissionCount > 0) { - lines.push(`审批: ${pendingPermissionCount} 个待确认`) - } + lines.push( + pendingPermissionCount > 0 + ? `审批:${pendingPermissionCount} 个待确认` + : '审批:当前无待确认请求', + ) const taskCounts = summary.taskCounts if (taskCounts && taskCounts.total > 0) { @@ -195,9 +206,12 @@ export function formatImStatus(summary: ImStatusSummary | null): string { if (taskCounts.inProgress > 0) taskParts.push(`进行中 ${taskCounts.inProgress}`) if (taskCounts.pending > 0) taskParts.push(`待处理 ${taskCounts.pending}`) if (taskCounts.completed > 0) taskParts.push(`已完成 ${taskCounts.completed}`) - lines.push(`任务: ${taskParts.join(' · ')}`) + lines.push(`任务:${taskParts.join(' · ')}`) + } else { + lines.push('任务:当前无任务') } + lines.push('操作:`/clear` 清空上下文 · `/stop` 停止生成 · `/sessions` 查看历史会话') return lines.join('\n') } @@ -208,16 +222,16 @@ function formatAdapterChatState( const label = (() => { switch (state) { case 'thinking': - return '思考中' + return '正在理解问题' case 'streaming': - return '生成中' + return '正在整理回复' case 'tool_executing': - return '执行工具中' + return '正在执行工具' case 'permission_pending': - return '等待权限确认' + return '等待你确认权限' case 'idle': default: - return '空闲' + return '空闲,等待新消息' } })() diff --git a/adapters/common/http-client.ts b/adapters/common/http-client.ts index 61bd0bcbc0..5a2dfbf9dc 100644 --- a/adapters/common/http-client.ts +++ b/adapters/common/http-client.ts @@ -20,6 +20,17 @@ export type GitInfo = { changedFiles: number } +export type SessionListItem = { + id: string + title: string + createdAt: string + modifiedAt: string + messageCount: number + projectPath: string + workDir: string | null + workDirExists: boolean +} + export type SessionTask = { id: string subject: string @@ -88,6 +99,27 @@ export class AdapterHttpClient { } } + async listSessions(options?: { + project?: string + limit?: number + offset?: number + }): Promise<{ sessions: SessionListItem[]; total: number }> { + const { controller, timer } = this.createTimeoutController() + try { + const url = new URL(`${this.httpBaseUrl}/api/sessions`) + if (options?.project) url.searchParams.set('project', options.project) + if (options?.limit !== undefined) url.searchParams.set('limit', String(options.limit)) + if (options?.offset !== undefined) url.searchParams.set('offset', String(options.offset)) + const res = await fetch(url, { signal: controller.signal }) + if (!res.ok) { + throw new Error(`Failed to list sessions: ${res.statusText}`) + } + return (await res.json()) as { sessions: SessionListItem[]; total: number } + } finally { + clearTimeout(timer) + } + } + /** * Match a project by index (1-based) or fuzzy name from recent projects. * Returns { project, ambiguous[] } — ambiguous is set when multiple projects match. diff --git a/adapters/feishu/__tests__/feishu.test.ts b/adapters/feishu/__tests__/feishu.test.ts index cd13e58203..2eb0fbd384 100644 --- a/adapters/feishu/__tests__/feishu.test.ts +++ b/adapters/feishu/__tests__/feishu.test.ts @@ -53,6 +53,17 @@ type RecentProject = { sessionCount: number } +type SessionListItem = { + id: string + title: string + createdAt: string + modifiedAt: string + messageCount: number + projectPath: string + workDir: string | null + workDirExists: boolean +} + function prettyPath(realPath: string, maxLen = 64): string { const home = process.env.HOME let p = realPath @@ -139,7 +150,119 @@ function buildProjectPickerCard(projects: RecentProject[]): Record`', + content: '💡 点击右侧 **选择** 按钮会新建会话;要切换历史会话请发送 `/sessions`', + text_size: 'notation', + margin: '6px 0 0 0', + }, + ], + }, + } +} + +function buildSessionPickerCard( + sessions: SessionListItem[], + currentSessionId?: string, + projectLabel = '', + pagination?: { offset: number; limit: number; total: number }, +): Record { + const items = sessions.slice(0, 10) + const subtitleText = pagination + ? projectLabel + ? `${projectLabel} · 第 ${Math.floor(pagination.offset / pagination.limit) + 1} 页 · 共 ${pagination.total} 个可切换会话` + : `第 ${Math.floor(pagination.offset / pagination.limit) + 1} 页 · 共 ${pagination.total} 个可切换会话` + : projectLabel + ? `${projectLabel} · 共 ${sessions.length} 个可切换会话` + : `共 ${sessions.length} 个可切换会话` + + const rows = items.map((session, i) => { + const title = session.title || 'Untitled Session' + const isCurrent = currentSessionId === session.id + const projectName = path.basename(session.workDir || session.projectPath) || session.projectPath + const updated = new Date(session.modifiedAt).toLocaleString('zh-CN', { hour12: false }) + return { + tag: 'column_set', + flex_mode: 'stretch', + horizontal_spacing: '8px', + margin: i === 0 ? '0px 0 0 0' : '10px 0 0 0', + columns: [ + { + tag: 'column', + width: 'weighted', + weight: 1, + vertical_align: 'center', + elements: [ + { + tag: 'markdown', + content: `${isCurrent ? '🟢 ' : ''}**${title}**${isCurrent ? '(当前会话)' : ''}`, + }, + { + tag: 'markdown', + content: `${projectName} · 消息 ${session.messageCount} · ${updated}`, + text_size: 'notation', + margin: '2px 0 0 0', + }, + ...(isCurrent + ? [ + { + tag: 'markdown', + content: '当前聊天已连接到这个会话,重复点击不会重新切换。', + text_size: 'notation', + margin: '2px 0 0 0', + }, + ] + : []), + ], + }, + { + tag: 'column', + width: 'auto', + vertical_align: 'center', + elements: [ + { + tag: 'button', + text: { tag: 'plain_text', content: isCurrent ? '当前' : '切换' }, + type: isCurrent ? 'default' : i === 0 ? 'primary' : 'default', + size: 'small', + value: { + action: 'resume_session', + sessionId: session.id, + workDir: session.workDir, + title, + }, + }, + ], + }, + ], + } + }) + + const footerLines = ['💡 点击 **切换**,或回复编号 / 发送 `/resume <编号|sessionId>`', '🟢 标记项表示当前会话,不会重复切换'] + if (pagination) { + const page = Math.floor(pagination.offset / pagination.limit) + 1 + const totalPages = Math.max(1, Math.ceil(pagination.total / pagination.limit)) + if (page > 1 || page < totalPages) { + footerLines.push('翻页:发送 `/sessions -p 上一页` 或 `/sessions -n 下一页`') + } + } + + return { + schema: '2.0', + config: { + wide_screen_mode: true, + update_multi: true, + }, + header: { + title: { tag: 'plain_text', content: '💬 历史会话' }, + subtitle: { tag: 'plain_text', content: subtitleText }, + template: 'purple', + }, + body: { + elements: [ + ...rows, + { tag: 'hr', margin: '14px 0 0 0' }, + { + tag: 'markdown', + content: footerLines.join('\n'), text_size: 'notation', margin: '6px 0 0 0', }, @@ -210,11 +333,22 @@ function summarizeToolCall(toolName: string, input: unknown): ToolCallSummary { } function isOutsideWorkDir(filePath: string, workDir: string): boolean { - const abs = path.isAbsolute(filePath) - ? path.normalize(filePath) - : path.resolve(workDir, filePath) - const normWork = path.normalize(workDir).replace(/\/+$/, '') - return abs !== normWork && !abs.startsWith(normWork + path.sep) + const pathImpl = usesWindowsPath(filePath) || usesWindowsPath(workDir) ? path.win32 : path.posix + const abs = pathImpl.isAbsolute(filePath) + ? pathImpl.normalize(filePath) + : pathImpl.resolve(workDir, filePath) + const normAbs = comparablePath(abs, pathImpl === path.win32) + const normWork = comparablePath(pathImpl.normalize(workDir), pathImpl === path.win32) + return normAbs !== normWork && !normAbs.startsWith(normWork + '/') +} + +function usesWindowsPath(filePath: string): boolean { + return /^[a-zA-Z]:[\\/]/.test(filePath) || filePath.includes('\\') +} + +function comparablePath(filePath: string, caseInsensitive: boolean): string { + const normalized = filePath.replace(/\\/g, '/').replace(/\/+$/, '') + return caseInsensitive ? normalized.toLowerCase() : normalized } function truncateTarget(s: string, maxLen = 160): string { @@ -866,6 +1000,106 @@ describe('Feishu: project picker card', () => { }) }) +describe('Feishu: session picker card', () => { + const sampleSessions: SessionListItem[] = [ + { + id: 'session-1', + title: 'Fix bug', + createdAt: '2026-01-01T00:00:00.000Z', + modifiedAt: '2026-01-02T03:04:05.000Z', + messageCount: 4, + projectPath: '-Users-dev-claude-code-haha', + workDir: '/Users/dev/claude-code-haha', + workDirExists: true, + }, + { + id: 'session-2', + title: '', + createdAt: '2026-01-01T00:00:00.000Z', + modifiedAt: '2026-01-03T03:04:05.000Z', + messageCount: 2, + projectPath: '-Users-dev-desktop', + workDir: '/Users/dev/desktop', + workDirExists: true, + }, + ] + + function getRows(card: Record): any[] { + return (((card.body as any).elements ?? []) as any[]).filter((el) => el.tag === 'column_set') + } + + function getRowButton(row: any): any { + const buttonCol = row.columns.find((c: any) => + c.elements.some((e: any) => e.tag === 'button'), + ) + return buttonCol.elements.find((e: any) => e.tag === 'button') + } + + it('renders resumable session rows with resume_session buttons', () => { + const card = buildSessionPickerCard(sampleSessions, 'session-2', 'claude-code-haha') + const rows = getRows(card) + + expect(card.schema).toBe('2.0') + expect((card.header as any).title.content).toContain('历史会话') + expect((card.header as any).subtitle.content).toContain('claude-code-haha') + expect(rows.length).toBe(2) + + const firstButton = getRowButton(rows[0]) + expect(firstButton.text.content).toBe('切换') + expect(firstButton.type).toBe('primary') + expect(firstButton.value.action).toBe('resume_session') + expect(firstButton.value.sessionId).toBe('session-1') + expect(firstButton.value.workDir).toBe('/Users/dev/claude-code-haha') + expect(firstButton.value.title).toBe('Fix bug') + + const currentRowText = JSON.stringify(rows[1]) + expect(currentRowText).toContain('🟢') + expect(currentRowText).toContain('当前会话') + expect(currentRowText).toContain('不会重新切换') + + const currentButton = getRowButton(rows[1]) + expect(currentButton.text.content).toBe('当前') + expect(currentButton.value.title).toBe('Untitled Session') + }) + + it('caps to first 10 sessions', () => { + const many = Array.from({ length: 15 }, (_, i) => ({ + ...sampleSessions[0]!, + id: `session-${i}`, + title: `session ${i}`, + })) + const card = buildSessionPickerCard(many) + const rows = getRows(card) + + expect(rows.length).toBe(10) + expect(getRowButton(rows[9]).value.sessionId).toBe('session-9') + }) + + it('shows pagination info in subtitle when provided', () => { + const card = buildSessionPickerCard(sampleSessions, undefined, 'claude-code-haha', { + offset: 10, + limit: 10, + total: 25, + }) + + expect((card.header as any).subtitle.content).toContain('第 2 页') + expect((card.header as any).subtitle.content).toContain('共 25 个可切换会话') + }) + + it('shows paging hint in footer when there are multiple pages', () => { + const card = buildSessionPickerCard(sampleSessions, undefined, '', { + offset: 0, + limit: 10, + total: 25, + }) + const elements = ((card.body as any).elements ?? []) as any[] + const footer = elements[elements.length - 1] + + expect(footer.content).toContain('/sessions -n 下一页') + expect(footer.content).toContain('🟢 标记项表示当前会话') + }) +}) + describe('Feishu: card.action.trigger parsing', () => { it('parses permit action from event', () => { const event = { @@ -898,10 +1132,29 @@ describe('Feishu: card.action.trigger parsing', () => { expect(event.action.value.projectName).toBe('claude-code-haha') }) + it('parses resume_session action from event', () => { + const event = { + operator: { open_id: 'ou_user_1' }, + action: { + value: { + action: 'resume_session', + sessionId: 'session-123', + workDir: '/Users/dev/claude-code-haha', + title: 'Fix bug', + }, + }, + context: { open_chat_id: 'oc_chat_123' }, + } + + expect(event.action.value.action).toBe('resume_session') + expect(event.action.value.sessionId).toBe('session-123') + expect(event.action.value.workDir).toBe('/Users/dev/claude-code-haha') + }) + it('ignores non-handled actions', () => { const event = { action: { value: { action: 'other_action' } }, } - expect(['permit', 'pick_project']).not.toContain(event.action.value.action) + expect(['permit', 'pick_project', 'resume_session']).not.toContain(event.action.value.action) }) }) diff --git a/adapters/feishu/__tests__/streaming-card.test.ts b/adapters/feishu/__tests__/streaming-card.test.ts index bf83a8f0f8..21e7985084 100644 --- a/adapters/feishu/__tests__/streaming-card.test.ts +++ b/adapters/feishu/__tests__/streaming-card.test.ts @@ -102,27 +102,38 @@ describe('buildInitialStreamingCard', () => { const card = buildInitialStreamingCard() as any expect(card.schema).toBe('2.0') expect(card.config.streaming_mode).toBe(true) + expect(card.header.title.content).toBe('Claude Code') + expect(card.header.subtitle.content).toBe('正在生成回复') // 唯一元素:streaming_content,初始内容为 loading 提示 const elements = card.body.elements as any[] expect(elements.length).toBe(1) const streaming = elements[0] expect(streaming.tag).toBe('markdown') + expect(streaming.content).toContain('当前状态') expect(streaming.content).toContain('正在思考中') + expect(streaming.content).toContain('正在准备回复') expect(streaming.element_id).toBe(STREAMING_ELEMENT_ID) }) }) describe('buildRenderedCard', () => { - it('Schema 2.0, 无 streaming_mode, 单 markdown 元素', () => { + it('Schema 2.0, 无 streaming_mode, 带完成态 header 和操作区/footer', () => { const card = buildRenderedCard('hello world') as any expect(card.schema).toBe('2.0') expect(card.config.streaming_mode).toBeUndefined() - expect(card.body.elements.length).toBe(1) + expect(card.header.title.content).toBe('Claude Code') + expect(card.header.subtitle.content).toBe('已完成') + expect(card.header.template).toBe('green') + expect(card.body.elements.length).toBe(3) const el = card.body.elements[0] expect(el.tag).toBe('markdown') expect(el.content).toBe('hello world') - // 最终卡无需 element_id expect(el.element_id).toBeUndefined() + expect(card.body.elements[1].content).toContain('/new') + expect(card.body.elements[1].content).toContain('/status') + expect(card.body.elements[1].content).toContain('/sessions') + expect(card.body.elements[2].content).toContain('Claude Code · 已完成') + expect(card.body.elements[2].text_size).toBe('notation') }) it('空字符串保底为单空格', () => { @@ -132,11 +143,15 @@ describe('buildRenderedCard', () => { }) describe('buildErrorCard', () => { - it('红色 header + markdown body', () => { + it('红色 header + markdown body + 统一操作提示', () => { const card = buildErrorCard('oops') as any expect((card.header as any).template).toBe('red') expect((card.header as any).title.content).toContain('出错') + expect((card.header as any).subtitle.content).toContain('回复生成失败') expect(card.body.elements[0].content).toBe('oops') + expect(card.body.elements[1].content).toContain('/status') + expect(card.body.elements[1].content).toContain('/new') + expect(card.body.elements[1].content).toContain('/sessions') }) }) @@ -166,6 +181,8 @@ describe('StreamingCard: ensureCreated (CardKit 主路径)', () => { const cardJson = JSON.parse(calls[0]!.args.data.data) expect(cardJson.schema).toBe('2.0') expect(cardJson.config.streaming_mode).toBe(true) + expect(cardJson.header.title.content).toBe('Claude Code') + expect(cardJson.header.subtitle.content).toBe('正在生成回复') // 唯一元素即 streaming_content expect(cardJson.body.elements[0].element_id).toBe(STREAMING_ELEMENT_ID) @@ -416,7 +433,7 @@ describe('StreamingCard: finalize', () => { const lastMidFrame = calls .filter((c) => c.api === 'cardkit.v1.cardElement.content') .pop()!.args.data.content as string - expect(lastMidFrame).toContain('思考中') + expect(lastMidFrame).toContain('思考过程') expect(lastMidFrame).toContain('Read') expect(lastMidFrame).toContain('最终答复正文') @@ -431,11 +448,13 @@ describe('StreamingCard: finalize', () => { // H2 → 降级 H5 expect(finalContent).toContain('##### 答复') // reasoning + tools 都不应该出现在终态 - expect(finalContent).not.toContain('思考中') + expect(finalContent).not.toContain('当前状态') + expect(finalContent).not.toContain('思考过程') expect(finalContent).not.toContain('think about this problem') expect(finalContent).not.toContain('Read') expect(finalContent).not.toContain('🛠️') expect(finalContent).not.toContain('💭') + expect(finalContent).not.toContain('📍') }) it('finalize 边界: 没有 answerText 时退到组合渲染(保留推理)', async () => { @@ -453,8 +472,10 @@ describe('StreamingCard: finalize', () => { await sc.finalize() const updateCall = calls.filter((c) => c.api === 'cardkit.v1.card.update').pop()! const finalContent = JSON.parse(updateCall.args.data.card.data).body.elements[0].content as string - // 至少能看到推理内容 - expect(finalContent).toContain('thinking') + // 至少能看到中文思考摘要 + expect(finalContent).toContain('思考过程') + expect(finalContent).toContain('正在分析上下文并整理下一步操作。') + expect(finalContent).not.toContain('thinking') }) it('finalize 失败不抛出', async () => { @@ -577,7 +598,7 @@ describe('StreamingCard: abort', () => { // --------------------------------------------------------------------------- describe('StreamingCard: appendReasoning', () => { - it('累积 thinking delta 并渲染在卡片中(plain markdown,不用 blockquote)', async () => { + it('累积 thinking delta 时只渲染中文摘要,不直接显示英文 reasoning 原文', async () => { const { client, calls } = makeMockClient({ 'card.create': { code: 0, data: { card_id: 'ck_think' } }, 'im.message.create': { data: { message_id: 'om' } }, @@ -592,13 +613,14 @@ describe('StreamingCard: appendReasoning', () => { const contentCalls = calls.filter((c) => c.api === 'cardkit.v1.cardElement.content') expect(contentCalls.length).toBeGreaterThan(0) const last = contentCalls[contentCalls.length - 1]! + expect(last.args.data.content).toContain('📍') + expect(last.args.data.content).toContain('当前状态') + expect(last.args.data.content).toContain('☁️ 正在思考中') expect(last.args.data.content).toContain('💭') - expect(last.args.data.content).toContain('思考中') - expect(last.args.data.content).toContain('Analyzing the problem.') - expect(last.args.data.content).toContain('Let me check file A.') - // 没有 blockquote `>` 前缀 —— 这是新格式的关键 - expect(last.args.data.content).not.toContain('> Analyzing') - // 没有 appendText → 不应有普通正文 + expect(last.args.data.content).toContain('思考过程') + expect(last.args.data.content).toContain('正在分析上下文并整理下一步操作。') + expect(last.args.data.content).not.toContain('Analyzing the problem.') + expect(last.args.data.content).not.toContain('Let me check file A.') expect(sc._getAccumulatedReasoning()).toContain('Analyzing') expect(sc._getAccumulatedText()).toBe('') }) @@ -632,27 +654,28 @@ describe('StreamingCard: startTool / completeTool', () => { expect(steps[0]!.name).toBe('Read') expect(steps[0]!.status).toBe('running') - // 卡片也应显示 "🛠️ ⚙️ Read"(inline 形式) + // 卡片也应显示工具执行分区和 running 状态 const runningContent = calls .filter((c) => c.api === 'cardkit.v1.cardElement.content') .map((c) => c.args.data.content) .join('\n') - expect(runningContent).toContain('⚙️') - expect(runningContent).toContain('Read') + expect(runningContent).toContain('📍') + expect(runningContent).toContain('⚙️ 正在调用工具(1 个运行中)') expect(runningContent).toContain('🛠️') + expect(runningContent).toContain('工具执行') + expect(runningContent).toContain('共 1 个 · 已完成 0 个 · 运行中 1 个') + expect(runningContent).toContain('⚙️ 运行中 Read') sc.completeTool('tu_1', 'Read') await sleep(150) steps = sc._getToolSteps() expect(steps[0]!.status).toBe('done') - // 最新 flush 应显示 "✅ Read" 不再有 "⚙️" + // 最新 flush 应显示 done 状态,不再有 running 图标 const lastContent = calls .filter((c) => c.api === 'cardkit.v1.cardElement.content') .pop()!.args.data.content as string - expect(lastContent).toContain('✅') - expect(lastContent).toContain('Read') - // 这一行整体换成了 `✅ Read`,不该再出现 ⚙️ 图标 + expect(lastContent).toContain('✅ 已完成 Read') expect(lastContent).not.toContain('⚙️') }) @@ -713,6 +736,32 @@ describe('StreamingCard: startTool / completeTool', () => { sc.startTool('tu_1', '') expect(sc._getToolSteps().length).toBe(0) }) + + it('工具较多时只显示最近几条,并折叠更早的已完成工具', async () => { + const { client, calls } = makeMockClient({ + 'card.create': { code: 0, data: { card_id: 'ck_many_tools' } }, + 'im.message.create': { data: { message_id: 'om' } }, + }) + const sc = new StreamingCard({ larkClient: client, chatId: 'c' }) + await sc.ensureCreated() + + for (let i = 1; i <= 7; i++) { + sc.startTool(`tu_${i}`, `Tool${i}`) + sc.completeTool(`tu_${i}`, `Tool${i}`) + } + await sleep(150) + + const lastContent = calls + .filter((c) => c.api === 'cardkit.v1.cardElement.content') + .pop()!.args.data.content as string + + expect(lastContent).toContain('共 7 个 · 已完成 7 个 · 运行中 0 个') + expect(lastContent).toContain('另外还有 2 个已完成工具已折叠') + expect(lastContent).not.toContain('Tool1') + expect(lastContent).not.toContain('Tool2') + expect(lastContent).toContain('Tool3') + expect(lastContent).toContain('Tool7') + }) }) // 复刻用户的真实场景: 用户发消息 → 服务端 thinking → tool_use → 最终 text。 @@ -754,9 +803,11 @@ describe('StreamingCard: 真实事件流(用户场景回归)', () => { const lastReasoningContent = calls .filter((c) => c.api === 'cardkit.v1.cardElement.content') .pop()!.args.data.content as string - // 应该包含 reasoning 累积内容 - expect(lastReasoningContent).toContain('breaking changes') - expect(lastReasoningContent).toContain('git log first') + // 思考区应只显示中文摘要,不直接显示英文 reasoning + expect(lastReasoningContent).toContain('思考过程') + expect(lastReasoningContent).toContain('正在分析上下文并整理下一步操作。') + expect(lastReasoningContent).not.toContain('breaking changes') + expect(lastReasoningContent).not.toContain('git log first') // 4. 服务端: content_start{tool_use, name: 'Bash'} sc.startTool('tu_bash_1', 'Bash') @@ -765,9 +816,9 @@ describe('StreamingCard: 真实事件流(用户场景回归)', () => { const lastWithTool = calls .filter((c) => c.api === 'cardkit.v1.cardElement.content') .pop()!.args.data.content as string - expect(lastWithTool).toContain('Bash') - expect(lastWithTool).toContain('⚙️') expect(lastWithTool).toContain('🛠️') + expect(lastWithTool).toContain('工具执行') + expect(lastWithTool).toContain('⚙️ 运行中 Bash') // 5. 服务端: tool_use_complete sc.completeTool('tu_bash_1', 'Bash') @@ -776,10 +827,10 @@ describe('StreamingCard: 真实事件流(用户场景回归)', () => { const lastAfterToolDone = calls .filter((c) => c.api === 'cardkit.v1.cardElement.content') .pop()!.args.data.content as string - expect(lastAfterToolDone).toContain('Bash') - // ⚙️ 切到 ✅ —— 当前唯一一步已完成 - expect(lastAfterToolDone).toContain('✅') - expect(lastAfterToolDone).not.toContain('⚙️') + expect(lastAfterToolDone).toContain('📍') + expect(lastAfterToolDone).toContain('☁️ 正在思考中') + expect(lastAfterToolDone).toContain('✅ 已完成 Bash') + expect(lastAfterToolDone).toContain('共 1 个 · 已完成 1 个 · 运行中 0 个') // 6. 第二个 tool 序列 sc.startTool('tu_read_1', 'Read') @@ -798,8 +849,10 @@ describe('StreamingCard: 真实事件流(用户场景回归)', () => { const lastWithText = calls .filter((c) => c.api === 'cardkit.v1.cardElement.content') .pop()!.args.data.content as string - // 应该同时包含 reasoning, tools, answer - expect(lastWithText).toContain('git log first') // reasoning + // 应该同时包含中文思考摘要、tools、answer + expect(lastWithText).toContain('思考过程') + expect(lastWithText).toContain('正在分析上下文并整理下一步操作。') + expect(lastWithText).not.toContain('git log first') expect(lastWithText).toContain('Bash') // tool expect(lastWithText).toContain('Read') // tool expect(lastWithText).toContain('破坏性变更分析') // answer (post optimize: H2→H5) @@ -865,14 +918,19 @@ describe('StreamingCard: 组合渲染 (tools + reasoning + text)', () => { .filter((c) => c.api === 'cardkit.v1.cardElement.content') .pop()!.args.data.content as string + expect(lastContent).toContain('正在分析上下文并整理下一步操作。') + expect(lastContent).not.toContain('Should I read file A first?') + + const idxStatus = lastContent.indexOf('当前状态') const idxTools = lastContent.indexOf('🛠️') - const idxReasoning = lastContent.indexOf('思考中') + const idxReasoning = lastContent.indexOf('思考过程') const idxAnswer = lastContent.indexOf('Here is the answer') + expect(idxStatus).toBeGreaterThan(-1) expect(idxTools).toBeGreaterThan(-1) expect(idxReasoning).toBeGreaterThan(-1) expect(idxAnswer).toBeGreaterThan(-1) - // tools 在最顶部 → reasoning 居中 → answer 在底部 + expect(idxStatus).toBeLessThan(idxTools) expect(idxTools).toBeLessThan(idxReasoning) expect(idxReasoning).toBeLessThan(idxAnswer) }) diff --git a/adapters/feishu/index.ts b/adapters/feishu/index.ts index 9fa535e9b4..e626cbb126 100644 --- a/adapters/feishu/index.ts +++ b/adapters/feishu/index.ts @@ -28,7 +28,7 @@ import { type PermissionDecision, } from '../common/permission.js' import { SessionStore } from '../common/session-store.js' -import { AdapterHttpClient, type RecentProject } from '../common/http-client.js' +import { AdapterHttpClient, type RecentProject, type SessionListItem } from '../common/http-client.js' import { isAllowedUser, tryPair } from '../common/pairing.js' import { optimizeMarkdownForFeishu } from './markdown-style.js' import { extractInboundPayload } from './extract-payload.js' @@ -69,6 +69,14 @@ attachmentStore.gc().catch((err) => { // One streaming card lifecycle per chatId (CardKit main + patch fallback). const streamingCards = new Map() const pendingProjectSelection = new Map() +type PendingSessionSelectionState = { + sessions: SessionListItem[] + offset: number + limit: number + total: number + project?: RecentProject +} +const pendingSessionSelection = new Map() const runtimeStates = new Map() const pendingPermissions = new Map>() @@ -429,7 +437,119 @@ function buildProjectPickerCard(projects: RecentProject[]): Record`', + content: '💡 点击右侧 **选择** 按钮会新建会话;要切换历史会话请发送 `/sessions`', + text_size: 'notation', + margin: '6px 0 0 0', + }, + ], + }, + } +} + +function buildSessionPickerCard( + sessions: SessionListItem[], + currentSessionId?: string, + projectLabel = '', + pagination?: { offset: number; limit: number; total: number }, +): Record { + const items = sessions.slice(0, 10) + const subtitleText = pagination + ? projectLabel + ? `${projectLabel} · 第 ${Math.floor(pagination.offset / pagination.limit) + 1} 页 · 共 ${pagination.total} 个可切换会话` + : `第 ${Math.floor(pagination.offset / pagination.limit) + 1} 页 · 共 ${pagination.total} 个可切换会话` + : projectLabel + ? `${projectLabel} · 共 ${sessions.length} 个可切换会话` + : `共 ${sessions.length} 个可切换会话` + + const rows = items.map((session, i) => { + const title = session.title || 'Untitled Session' + const isCurrent = currentSessionId === session.id + const projectName = path.basename(session.workDir || session.projectPath) || session.projectPath + const updated = new Date(session.modifiedAt).toLocaleString('zh-CN', { hour12: false }) + return { + tag: 'column_set', + flex_mode: 'stretch', + horizontal_spacing: '8px', + margin: i === 0 ? '0px 0 0 0' : '10px 0 0 0', + columns: [ + { + tag: 'column', + width: 'weighted', + weight: 1, + vertical_align: 'center', + elements: [ + { + tag: 'markdown', + content: `${isCurrent ? '🟢 ' : ''}**${title}**${isCurrent ? '(当前会话)' : ''}`, + }, + { + tag: 'markdown', + content: `${projectName} · 消息 ${session.messageCount} · ${updated}`, + text_size: 'notation', + margin: '2px 0 0 0', + }, + ...(isCurrent + ? [ + { + tag: 'markdown', + content: '当前聊天已连接到这个会话,重复点击不会重新切换。', + text_size: 'notation', + margin: '2px 0 0 0', + }, + ] + : []), + ], + }, + { + tag: 'column', + width: 'auto', + vertical_align: 'center', + elements: [ + { + tag: 'button', + text: { tag: 'plain_text', content: isCurrent ? '当前' : '切换' }, + type: isCurrent ? 'default' : i === 0 ? 'primary' : 'default', + size: 'small', + value: { + action: 'resume_session', + sessionId: session.id, + workDir: session.workDir, + title, + }, + }, + ], + }, + ], + } + }) + + const footerLines = ['💡 点击 **切换**,或回复编号 / 发送 `/resume <编号|sessionId>`', '🟢 标记项表示当前会话,不会重复切换'] + if (pagination) { + const page = Math.floor(pagination.offset / pagination.limit) + 1 + const totalPages = Math.max(1, Math.ceil(pagination.total / pagination.limit)) + if (page > 1 || page < totalPages) { + footerLines.push('翻页:发送 `/sessions -p 上一页` 或 `/sessions -n 下一页`') + } + } + + return { + schema: '2.0', + config: { + wide_screen_mode: true, + update_multi: true, + }, + header: { + title: { tag: 'plain_text', content: '💬 历史会话' }, + subtitle: { tag: 'plain_text', content: subtitleText }, + template: 'purple', + }, + body: { + elements: [ + ...rows, + { tag: 'hr', margin: '14px 0 0 0' }, + { + tag: 'markdown', + content: footerLines.join('\n'), text_size: 'notation', margin: '6px 0 0 0', }, @@ -505,11 +625,22 @@ function summarizeToolCall(toolName: string, input: unknown): ToolCallSummary { /** True if `filePath` resolves to a location outside of `workDir`. * Relative paths are resolved against workDir first. */ function isOutsideWorkDir(filePath: string, workDir: string): boolean { - const abs = path.isAbsolute(filePath) - ? path.normalize(filePath) - : path.resolve(workDir, filePath) - const normWork = path.normalize(workDir).replace(/\/+$/, '') - return abs !== normWork && !abs.startsWith(normWork + path.sep) + const pathImpl = usesWindowsPath(filePath) || usesWindowsPath(workDir) ? path.win32 : path.posix + const abs = pathImpl.isAbsolute(filePath) + ? pathImpl.normalize(filePath) + : pathImpl.resolve(workDir, filePath) + const normAbs = comparablePath(abs, pathImpl === path.win32) + const normWork = comparablePath(pathImpl.normalize(workDir), pathImpl === path.win32) + return normAbs !== normWork && !normAbs.startsWith(normWork + '/') +} + +function usesWindowsPath(filePath: string): boolean { + return /^[a-zA-Z]:[\\/]/.test(filePath) || filePath.includes('\\') +} + +function comparablePath(filePath: string, caseInsensitive: boolean): string { + const normalized = filePath.replace(/\\/g, '/').replace(/\/+$/, '') + return caseInsensitive ? normalized.toLowerCase() : normalized } /** Truncate a single-line target preview (e.g. shell command) to maxLen. */ @@ -673,6 +804,33 @@ async function ensureSession(chatId: string): Promise { return false } +async function connectExistingSessionForChat( + chatId: string, + sessionId: string, + workDir: string, +): Promise { + bridge.resetSession(chatId) + const inflightCard = streamingCards.get(chatId) + if (inflightCard) { + streamingCards.delete(chatId) + void inflightCard.abort(new Error('session reset')).catch(() => {}) + } + imageWatchers.delete(chatId) + uploadedImageKeys.delete(chatId) + pendingPermissions.delete(chatId) + runtimeStates.delete(chatId) + + sessionStore.set(chatId, sessionId, workDir) + bridge.connectSession(chatId, sessionId) + bridge.onServerMessage(chatId, (msg) => handleServerMessage(chatId, msg)) + const opened = await bridge.waitForOpen(chatId) + if (!opened) { + await sendText(chatId, '⚠️ 连接服务器超时,请重试。') + return false + } + return true +} + async function createSessionForChat(chatId: string, workDir: string): Promise { try { // Always tear down any stale WS connection before creating a new session. @@ -718,13 +876,164 @@ async function showProjectPicker(chatId: string): Promise { const lines = projects.slice(0, 10).map((p, i) => `${i + 1}. **${p.projectName}**${p.branch ? ` (${p.branch})` : ''}\n ${p.realPath}` ) - await sendText(chatId, `选择项目(回复编号):\n\n${lines.join('\n\n')}\n\n💡 下次可直接 /new <编号、名称或绝对路径> 快速新建会话`) + await sendText(chatId, `选择项目并新建会话(回复编号):\n\n${lines.join('\n\n')}\n\n💡 下次可直接 /new <编号、名称或绝对路径> 快速新建会话;发送 /sessions 查看历史会话`) } } catch (err) { await sendText(chatId, `❌ 无法获取项目列表: ${err instanceof Error ? err.message : String(err)}`) } } +async function showSessionList(chatId: string, query?: string): Promise { + try { + const limit = 10 + const normalizedQuery = query?.trim() || '' + let project: RecentProject | undefined + let offset = 0 + + if (normalizedQuery === '-n' || normalizedQuery === '下一页') { + const pending = pendingSessionSelection.get(chatId) + if (!pending) { + await sendText(chatId, '请先发送 /sessions,再使用翻页命令。') + return + } + project = pending.project + offset = pending.offset + pending.limit + if (offset >= pending.total) { + await sendText(chatId, '已经是最后一页了。') + return + } + } else if (normalizedQuery === '-p' || normalizedQuery === '上一页') { + const pending = pendingSessionSelection.get(chatId) + if (!pending) { + await sendText(chatId, '请先发送 /sessions,再使用翻页命令。') + return + } + project = pending.project + offset = Math.max(0, pending.offset - pending.limit) + if (pending.offset === 0) { + await sendText(chatId, '已经是第一页了。') + return + } + } else if (normalizedQuery) { + const matched = await httpClient.matchProject(normalizedQuery) + if (matched.ambiguous) { + const list = matched.ambiguous.map((p, i) => `${i + 1}. **${p.projectName}** — ${p.realPath}`).join('\n') + await sendText(chatId, `匹配到多个项目,请更精确:\n\n${list}`) + return + } + project = matched.project + if (!project) { + await sendText(chatId, `未找到匹配 "${normalizedQuery}" 的项目。发送 /projects 查看项目列表。`) + return + } + } + + const projectLabel = project ? `(${project.projectName})` : '' + const result = await httpClient.listSessions({ + project: project?.realPath, + limit, + offset, + }) + const sessions = result.sessions.filter((session) => session.workDirExists && session.workDir) + if (sessions.length === 0) { + await sendText(chatId, `没有找到历史会话${projectLabel}。发送 /new${project ? ` ${project.projectName}` : ''} 新建会话。`) + return + } + + pendingSessionSelection.set(chatId, { + sessions, + offset, + limit, + total: result.total, + project, + }) + const currentSessionId = sessionStore.get(chatId)?.sessionId + const cardId = await sendCard( + chatId, + buildSessionPickerCard(sessions, currentSessionId, project?.projectName, { + offset, + limit, + total: result.total, + }), + ) + if (!cardId) { + const lines = sessions.map((session, index) => { + const currentMark = currentSessionId === session.id ? '(当前)' : '' + const title = `${session.title || 'Untitled Session'}${currentMark}` + const projectName = path.basename(session.workDir || session.projectPath) || session.projectPath + const updated = new Date(session.modifiedAt).toLocaleString('zh-CN', { hour12: false }) + return `${offset + index + 1}. **${title}**\n 项目: ${projectName} · 消息: ${session.messageCount} · 更新: ${updated}\n ${session.id}` + }) + const page = Math.floor(offset / limit) + 1 + const totalPages = Math.max(1, Math.ceil(result.total / limit)) + const pagingHint = + totalPages > 1 + ? `\n\n翻页:发送 /sessions -p 查看上一页,发送 /sessions -n 查看下一页。当前第 ${page}/${totalPages} 页。` + : '' + await sendText(chatId, `历史会话${projectLabel}(回复编号,或发送 /resume <编号|sessionId>):\n\n${lines.join('\n\n')}${pagingHint}`) + } + } catch (err) { + await sendText(chatId, `❌ 无法获取会话列表: ${err instanceof Error ? err.message : String(err)}`) + } +} + +async function resumeSession(chatId: string, query: string): Promise { + const trimmed = query.trim() + if (!trimmed) { + await showSessionList(chatId) + return + } + + let session: SessionListItem | undefined + const pending = pendingSessionSelection.get(chatId) + const index = parseInt(trimmed, 10) + if ( + pending && + !isNaN(index) && + index >= pending.offset + 1 && + index <= pending.offset + pending.sessions.length && + String(index) === trimmed + ) { + session = pending.sessions[index - pending.offset - 1] + } + + if (!session && pending && !isNaN(index) && index >= 1 && index <= pending.sessions.length && String(index) === trimmed) { + session = pending.sessions[index - 1] + } + + if (!session) { + const result = await httpClient.listSessions({ limit: 50 }) + const matched = result.sessions.filter((item) => item.id === trimmed || item.id.startsWith(trimmed)) + if (matched.length > 1) { + const lines = matched + .slice(0, 5) + .map((item, i) => `${i + 1}. **${item.title || 'Untitled Session'}**\n ${item.id}`) + .join('\n\n') + await sendText(chatId, `匹配到多个会话,请使用更长的 sessionId,或先发送 /sessions 后按编号切换:\n\n${lines}`) + return + } + session = matched[0] + } + + if (!session || !session.workDir || !session.workDirExists) { + await sendText(chatId, `未找到可切换的会话:${trimmed}。发送 /sessions 查看历史会话。`) + return + } + + const currentSessionId = sessionStore.get(chatId)?.sessionId + if (currentSessionId === session.id) { + await sendText(chatId, `当前已经是该会话:**${session.title || 'Untitled Session'}**\n会话: ${session.id}`) + return + } + + pendingProjectSelection.delete(chatId) + pendingSessionSelection.delete(chatId) + const ok = await connectExistingSessionForChat(chatId, session.id, session.workDir) + if (ok) { + await sendText(chatId, `✅ 已切换会话:**${session.title || 'Untitled Session'}**\n项目: ${path.basename(session.workDir) || session.workDir}\n会话: ${session.id}`) + } +} + async function startNewSession(chatId: string, query?: string): Promise { bridge.resetSession(chatId) sessionStore.delete(chatId) @@ -737,6 +1046,7 @@ async function startNewSession(chatId: string, query?: string): Promise { imageWatchers.delete(chatId) uploadedImageKeys.delete(chatId) pendingProjectSelection.delete(chatId) + pendingSessionSelection.delete(chatId) pendingPermissions.delete(chatId) runtimeStates.delete(chatId) @@ -1030,7 +1340,7 @@ async function handleMessage(data: any): Promise { return } if (!hasAttachments && (msgText === '/help' || msgText === '帮助')) { - await sendText(chatId, formatImHelp()) + await sendText(chatId, formatImHelp({ includeSessionCommands: true })) return } if (!hasAttachments && (msgText === '/status' || msgText === '状态')) { @@ -1066,12 +1376,26 @@ async function handleMessage(data: any): Promise { await showProjectPicker(chatId) return } + if (!hasAttachments && (msgText === '/sessions' || msgText === '会话列表' || msgText.startsWith('/sessions '))) { + const arg = msgText.startsWith('/sessions ') ? msgText.slice(10).trim() : '' + await showSessionList(chatId, arg || undefined) + return + } + if (!hasAttachments && (msgText === '/resume' || msgText === '切换会话' || msgText.startsWith('/resume '))) { + const arg = msgText.startsWith('/resume ') ? msgText.slice(8).trim() : '' + await resumeSession(chatId, arg) + return + } // User is replying to a project picker prompt if (!hasAttachments && pendingProjectSelection.has(chatId)) { await startNewSession(chatId, msgText.trim()) return } + if (!hasAttachments && pendingSessionSelection.has(chatId)) { + await resumeSession(chatId, msgText.trim()) + return + } // ----- Normal message flow (with optional inbound attachments) ----- @@ -1203,6 +1527,9 @@ async function handleCardAction(data: any): Promise { rule?: string realPath?: string projectName?: string + sessionId?: string + workDir?: string + title?: string } } context?: { open_chat_id?: string } @@ -1247,6 +1574,27 @@ async function handleCardAction(data: any): Promise { } return { toast: { type: 'info', content: `📁 ${projectName}` } } } + + if (action === 'resume_session') { + const sessionId = event.action?.value?.sessionId + const workDir = event.action?.value?.workDir + const title = event.action?.value?.title || 'Untitled Session' + if (!sessionId || !workDir) return + + const currentSessionId = sessionStore.get(chatId)?.sessionId + if (currentSessionId === sessionId) { + await sendText(chatId, `当前已经是该会话:**${title}**\n会话: ${sessionId}`) + return { toast: { type: 'info', content: '当前已在此会话' } } + } + + pendingProjectSelection.delete(chatId) + pendingSessionSelection.delete(chatId) + const ok = await connectExistingSessionForChat(chatId, sessionId, workDir) + if (ok) { + await sendText(chatId, `✅ 已切换会话:**${title}**\n项目: ${path.basename(workDir) || workDir}\n会话: ${sessionId}`) + } + return { toast: { type: 'info', content: `💬 ${title}` } } + } } // ---------- resolve bot identity ---------- diff --git a/adapters/feishu/streaming-card.ts b/adapters/feishu/streaming-card.ts index eadc8b9405..8b328833fa 100644 --- a/adapters/feishu/streaming-card.ts +++ b/adapters/feishu/streaming-card.ts @@ -43,11 +43,16 @@ export function buildInitialStreamingCard(): Record { streaming_mode: true, update_multi: true, }, + header: { + title: { tag: 'plain_text', content: 'Claude Code' }, + subtitle: { tag: 'plain_text', content: '正在生成回复' }, + template: 'blue', + }, body: { elements: [ { tag: 'markdown', - content: '☁️ *正在思考中...*', + content: '📍 **当前状态**\n\n☁️ 正在思考中\n\n收到消息,正在准备回复...', text_align: 'left', element_id: STREAMING_ELEMENT_ID, }, @@ -67,6 +72,11 @@ export function buildRenderedCard(renderedMarkdown: string): Record { + const content = message || '未知错误' return { schema: '2.0', config: { update_multi: true }, header: { title: { tag: 'plain_text', content: '❌ 出错了' }, + subtitle: { tag: 'plain_text', content: '回复生成失败' }, template: 'red', }, body: { elements: [ { tag: 'markdown', - content: message || '未知错误', + content, + }, + { + tag: 'markdown', + content: '后续操作:`/status` 查看状态 · `/new` 重新开始 · `/sessions` 查看历史会话', + text_size: 'notation', + margin: '12px 0 0 0', }, ], }, } } -/** 从末尾截取最多 maxLen 个字符;超过时前缀 "..." 保留最新 maxLen-3 个字。 - * - * 思考内容往往是"先分析 → 得出结论"的线性过程,截取末尾比截取开头更有用 —— 用户 - * 最关心的是"模型现在在想什么",不是"五千个 token 前在想什么"。 */ -function truncateReasoningPreview(text: string, maxLen: number): string { - if (text.length <= maxLen) return text - return '...' + text.slice(text.length - maxLen + 3) +function summarizeReasoningText(text: string): string { + const normalized = text.trim() + if (!normalized) return '正在分析上下文并整理下一步操作。' + if (normalized.length > REASONING_PREVIEW_CHARS) { + return '正在持续分析上下文、工具结果和回复结构。' + } + return '正在分析上下文并整理下一步操作。' } // --------------------------------------------------------------------------- @@ -136,6 +167,7 @@ type ToolStep = { /** 最多保留的 reasoning 预览字符数,超过则取末尾 + 省略号前缀。 */ const REASONING_PREVIEW_CHARS = 600 +const TOOL_LINES_LIMIT = 5 /** 连续 streamCardContent 失败多少次后才放弃 CardKit 流式。 * 设成 3 而不是 1,是为了避免单次抖动(网络、临时校验失败等)把整张卡片 @@ -433,36 +465,45 @@ export class StreamingCard { * 任意 section 为空则忽略;全部为空时返回等待提示。 */ private renderedText(): string { const sections: string[] = [] + const runningTools = this.toolSteps.filter((s) => s.status === 'running').length + const doneTools = this.toolSteps.filter((s) => s.status === 'done').length + + let statusLine = '☁️ 正在思考中' + if (runningTools > 0) { + statusLine = `⚙️ 正在调用工具(${runningTools} 个运行中)` + } else if (this.accumulatedText) { + statusLine = '📝 正在整理回复' + } + sections.push(`📍 **当前状态**\n\n${statusLine}`) if (this.toolSteps.length > 0) { - // 单行 inline 形式: ⚙️ Bash · ✅ Read · ⚙️ Glob ... - // 用中点分隔,比 markdown list 更不容易触发 Feishu 排版异常 - const inline = this.toolSteps - .map((s) => `${s.status === 'done' ? '✅' : '⚙️'} ${s.name}`) - .join(' · ') - sections.push(`🛠️ ${inline}`) + const hiddenDoneTools = Math.max(0, doneTools - TOOL_LINES_LIMIT) + const visibleSteps = + this.toolSteps.length > TOOL_LINES_LIMIT + ? this.toolSteps.slice(-TOOL_LINES_LIMIT) + : this.toolSteps + const lines = visibleSteps + .map((s) => `${s.status === 'done' ? '✅ 已完成' : '⚙️ 运行中'} ${s.name}`) + .join('\n') + const extraLine = + hiddenDoneTools > 0 ? `\n\n…… 另外还有 ${hiddenDoneTools} 个已完成工具已折叠` : '' + sections.push( + `🛠️ **工具执行**\n\n共 ${this.toolSteps.length} 个 · 已完成 ${doneTools} 个 · 运行中 ${runningTools} 个\n\n${lines}${extraLine}`, + ) } if (this.accumulatedReasoningText) { - const preview = truncateReasoningPreview( - this.accumulatedReasoningText, - REASONING_PREVIEW_CHARS, - ) - // openclaw 风格: 一行 header + 空行 + 原文。不引用 / 不缩进,让飞书 - // markdown 元素按普通段落渲染。 - sections.push(`💭 **思考中**\n\n${preview}`) + const preview = summarizeReasoningText(this.accumulatedReasoningText) + sections.push(`💭 **思考过程**\n\n${preview}`) } if (this.accumulatedText) { sections.push(this.accumulatedText) } - if (sections.length === 0) return '☁️ *正在思考中...*' + if (sections.length === 0) return '📍 **当前状态**\n\n☁️ 正在思考中' - // 用一行分隔符把 sections 分开,比单纯空行更稳定 const composed = sections.join('\n\n---\n\n') - - // 表格数限制在 optimize 之前做 —— sanitize 对原始 markdown 最准 const limited = sanitizeTextForCard(composed) return optimizeMarkdownForFeishu(limited, 2) } diff --git a/docs/desktop/03-features.md b/docs/desktop/03-features.md index 7c4984eb11..0c466dcc0a 100644 --- a/docs/desktop/03-features.md +++ b/docs/desktop/03-features.md @@ -200,10 +200,13 @@ |------|------| | 直接发文本 | 与 Claude Code 对话 | | `/new [项目]` 或 `新会话` | 开始新会话 | -| `/projects` 或 `项目列表` | 查看最近项目 | +| `/projects` 或 `项目列表` | 查看最近项目并新建会话 | +| `/sessions [项目]` 或 `会话列表` | 查看历史会话 | +| `/sessions -n` / `/sessions -p` | 查看下一页 / 上一页历史会话 | +| `/resume <编号|sessionId>` 或 `切换会话` | 切换到历史会话 | | `/stop` 或 `停止` | 停止生成 | -权限请求在 IM 中以按钮形式展示(Telegram Inline Keyboard / 飞书 Interactive Card)。 +权限请求在 IM 中以按钮形式展示(Telegram Inline Keyboard / 飞书 Interactive Card)。飞书流式卡片会额外展示“当前状态”、工具执行汇总和思考过程,完成后再收敛为纯正文卡片。 --- diff --git a/docs/im/feishu.md b/docs/im/feishu.md index cb447b92aa..cb45aa9881 100644 --- a/docs/im/feishu.md +++ b/docs/im/feishu.md @@ -22,7 +22,7 @@ 创建成功后,把 **App ID** 和 **App Secret** 保存下来,接着去配置机器人菜单。 -## 2. 配置自定义菜单(/projects /new /clear) +## 2. 配置自定义菜单(/projects /sessions /resume /new /clear) 进入[飞书开发者后台](https://open.feishu.cn/app?lang=zh-CN),选择刚创建的机器人,进入机器人配置页: @@ -32,12 +32,16 @@ ![进入菜单配置](../images/im/feishu/04-menu-enter.png) -依次添加 3 个命令: +建议添加这些常用命令: -**/projects** — 切换最近使用的项目 +**/projects** — 查看最近使用的项目,并在选中项目后新建会话 ![菜单 /projects](../images/im/feishu/05-menu-projects.png) +**/sessions** — 查看历史会话列表 + +**/resume** — 切换到历史会话(配合编号或 sessionId 使用) + **/new** — 开启新对话 ![菜单 /new](../images/im/feishu/06-menu-new.png) @@ -46,7 +50,7 @@ ![菜单 /clear](../images/im/feishu/07-menu-clear.png) -三个都配好后点击保存: +菜单配好后点击保存: ![保存菜单](../images/im/feishu/08-menu-save.png) @@ -56,8 +60,11 @@ **命令作用说明:** -- `/projects`:列出最近使用的项目,支持切换当前会话绑定的目录 -- `/new`:开启新对话 +- `/projects`:列出最近使用的项目,选中后在该项目中新建会话 +- `/sessions [项目]`:列出历史会话;可不带参数查看全部,也可带项目名筛选 +- `/sessions -n` / `/sessions -p`:查看下一页 / 上一页历史会话 +- `/resume <编号|sessionId>`:切换到 `/sessions` 列表中的历史会话;若 sessionId 前缀匹配到多个会话,会提示继续缩小范围 +- `/new [项目]`:开启新对话;可带项目名、编号或绝对路径 - `/clear`:清空当前会话上下文 ## 3. 在 Claude Code Haha 桌面端填写 @@ -98,7 +105,9 @@ - `/status` 或 `状态` - `/clear` 或 `清空` - `/projects` 或 `项目列表` -- `/new` 或 `新会话` +- `/sessions [项目]`、`/sessions -n`、`/sessions -p` 或 `会话列表` +- `/resume <编号|sessionId>` 或 `切换会话` +- `/new [项目]` 或 `新会话` - `/stop` 或 `停止` ## 权限审批 @@ -109,7 +118,10 @@ - 普通文本通过 `post` 消息发送 - 权限审批通过卡片发送 -- 流式内容优先 patch 同一条消息 +- 流式卡片会先显示“当前状态”,再逐步补充工具执行、思考过程和正文 +- 工具执行中会显示运行中/已完成状态与汇总计数 +- 完成后终态卡片只保留正文,过程态状态区不会保留 +- 普通文本回退路径仍优先 patch 同一条消息 - 完成后按 30000 字左右分片 ## 启动 adapter