diff --git a/adapters/common/__tests__/format.test.ts b/adapters/common/__tests__/format.test.ts index 943ee6bbe..68679a4d6 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 e4c83622f..1edd32e07 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 09e62349c..b8ff19e6e 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 61bd0bcbc..5a2dfbf9d 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 cd13e5820..2eb0fbd38 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 bf83a8f0f..21e798508 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 9fa535e9b..e626cbb12 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 eadc8b940..8b328833f 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 7c4984eb1..0c466dcc0 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 cb447b92a..cb45aa988 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