From c91971ed9c48c6279730919e37ef20446e54e700 Mon Sep 17 00:00:00 2001 From: unclee Date: Wed, 4 Mar 2026 14:38:47 +0800 Subject: [PATCH 1/3] =?UTF-8?q?ci:=20=E5=A2=9E=E5=8A=A0=20CI=20=E6=B5=8B?= =?UTF-8?q?=E8=AF=95=E9=97=A8=E7=A6=81=20+=20=E6=96=87=E6=A1=A3=E5=8F=98?= =?UTF-8?q?=E6=9B=B4=E8=B7=B3=E8=BF=87=E9=83=A8=E7=BD=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - deploy.yml: 加 paths-ignore 跳过 docs/md 等纯文档变更 - deploy.yml: 新增 test job (ubuntu-latest) 作为部署前门禁,typecheck + 单元测试 - 新建 ci.yml: PR 级别测试 (typecheck + lint + unit tests) - 测试排除 quality.test.ts 和 integration.test.ts (外部 API 依赖) - 新增 docs/ci-improvements.md 改进方案文档 Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/ci.yml | 34 +++++ .github/workflows/deploy.yml | 25 +++ docs/ci-improvements.md | 286 +++++++++++++++++++++++++++++++++++ 3 files changed, 345 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 docs/ci-improvements.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..d31bacca --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,34 @@ +name: CI + +on: + pull_request: + paths-ignore: + - 'docs/**' + - '*.md' + +concurrency: + group: ci-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + test: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + + - run: npm ci + + - name: Type check + run: npm run typecheck + + - name: Lint + run: npm run lint + + - name: Unit tests + run: npx vitest run --exclude='**/quality.test.ts' --exclude='**/integration.test.ts' diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 3133af70..3dccaca2 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -3,9 +3,34 @@ name: Deploy on: push: branches: [main] + paths-ignore: + - 'docs/**' + - '*.md' + - '.github/workflows/pr-review.yml' + - '.github/workflows/claude-comment.yml' + - '.github/workflows/ci.yml' jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + + - run: npm ci + + - name: Type check + run: npm run typecheck + + - name: Unit tests + run: npx vitest run --exclude='**/quality.test.ts' --exclude='**/integration.test.ts' + deploy: + needs: test runs-on: self-hosted concurrency: group: deploy-production diff --git a/docs/ci-improvements.md b/docs/ci-improvements.md new file mode 100644 index 00000000..c1ff957f --- /dev/null +++ b/docs/ci-improvements.md @@ -0,0 +1,286 @@ +# CI/CD 改进方案 + +## 1. 纯文档变更跳过 CI + +当 PR 或 push 仅修改 `docs/`、`*.md` 等文档文件时,跳过构建、测试和部署,节省 runner 资源和部署风险。 + +### 实现方式 + +在 `deploy.yml` 和 `pr-review.yml` 中加入路径过滤或 docs-scope 检测 job。 + +#### 方案 A:路径过滤(推荐,最简单) + +`deploy.yml` 增加 `paths-ignore`: + +```yaml +on: + push: + branches: [main] + paths-ignore: + - 'docs/**' + - '*.md' + - '.github/workflows/pr-review.yml' + - '.github/workflows/claude-comment.yml' +``` + +`pr-review.yml` 同理: + +```yaml +on: + pull_request: + types: [opened, synchronize, ready_for_review, reopened] + paths-ignore: + - 'docs/**' + - '*.md' +``` + +**优点:** 零代码,GitHub 原生支持,一行搞定。 +**缺点:** 无法处理混合变更(文档 + 代码同时改),混合变更会正常触发 CI(这通常是期望行为)。 + +#### 方案 B:docs-scope 检测 job(更精细) + +适用于未来需要"文档改了也跑 docs lint 但不跑部署"的场景: + +```yaml +jobs: + docs-scope: + runs-on: ubuntu-latest + outputs: + docs_only: ${{ steps.check.outputs.docs_only }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - id: check + run: | + if [ "${{ github.event_name }}" = "push" ]; then + BASE="${{ github.event.before }}" + else + BASE="origin/${{ github.base_ref }}" + fi + FILES=$(git diff --name-only "$BASE" HEAD 2>/dev/null || echo "UNKNOWN") + if [ "$FILES" = "UNKNOWN" ]; then + echo "docs_only=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + NON_DOCS=$(echo "$FILES" | grep -cvE '^docs/|\.md$' || true) + if [ "$NON_DOCS" -eq 0 ]; then + echo "docs_only=true" >> "$GITHUB_OUTPUT" + else + echo "docs_only=false" >> "$GITHUB_OUTPUT" + fi + + deploy: + needs: docs-scope + if: needs.docs-scope.outputs.docs_only != 'true' + # ... 原有 deploy 步骤 +``` + +--- + +## 2. CI 测试门禁 + +当前 push 到 main 后直接部署,不运行测试。加入 test + typecheck 作为部署前的必通过门禁。 + +### 方案 A:在 deploy.yml 中增加 test job(推荐) + +```yaml +jobs: + docs-scope: + # ... 同上 + + test: + needs: docs-scope + if: needs.docs-scope.outputs.docs_only != 'true' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + + - run: npm ci + + - name: Type check + run: npm run typecheck + + - name: Run tests + run: npm test + + deploy: + needs: [docs-scope, test] + if: needs.docs-scope.outputs.docs_only != 'true' + runs-on: self-hosted + concurrency: + group: deploy-production + cancel-in-progress: true + steps: + # ... 原有部署步骤不变 +``` + +**关键点:** +- `test` job 运行在 `ubuntu-latest`(GitHub-hosted),不占用生产服务器资源 +- `deploy` job 依赖 `test` 成功后才执行 +- 测试失败 → 部署不执行 → 生产环境不受影响 + +### 方案 B:额外增加 PR 级别的测试 check + +在 PR 阶段就拦截问题,而不是等到 merge 后: + +```yaml +# .github/workflows/ci.yml(新文件) +name: CI + +on: + pull_request: + paths-ignore: + - 'docs/**' + - '*.md' + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + + - run: npm ci + + - name: Type check + run: npm run typecheck + + - name: Lint + run: npm run lint + + - name: Run tests + run: npm test +``` + +然后在 GitHub repo settings → Branch protection rules → `main` 分支设置 "Require status checks to pass" 勾选 `test` job,这样 PR 必须绿灯才能 merge。 + +### 推荐组合 + +两个方案叠加使用效果最佳: + +``` +PR 阶段: + ci.yml (test + typecheck + lint) ← 拦截问题 + pr-review.yml (Claude review) ← AI 审查 + +Merge 到 main: + deploy.yml: + docs-scope → test → deploy ← 双重保险 +``` + +--- + +## 完整的改进后 deploy.yml 参考 + +```yaml +name: Deploy + +on: + push: + branches: [main] + +jobs: + docs-scope: + runs-on: ubuntu-latest + outputs: + docs_only: ${{ steps.check.outputs.docs_only }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - id: check + run: | + BASE="${{ github.event.before }}" + FILES=$(git diff --name-only "$BASE" HEAD 2>/dev/null || echo "UNKNOWN") + if [ "$FILES" = "UNKNOWN" ]; then + echo "docs_only=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + NON_DOCS=$(echo "$FILES" | grep -cvE '^docs/|\.md$' || true) + if [ "$NON_DOCS" -eq 0 ]; then + echo "docs_only=true" >> "$GITHUB_OUTPUT" + else + echo "docs_only=false" >> "$GITHUB_OUTPUT" + fi + + test: + needs: docs-scope + if: needs.docs-scope.outputs.docs_only != 'true' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + + - run: npm ci + + - name: Type check + run: npm run typecheck + + - name: Run tests + run: npm test + + deploy: + needs: [docs-scope, test] + if: needs.docs-scope.outputs.docs_only != 'true' + runs-on: self-hosted + concurrency: + group: deploy-production + cancel-in-progress: true + steps: + - name: Pull latest code + run: | + cd /root/dev/anywhere-code + git fetch origin main + git reset --hard origin/main + + - name: Install dependencies + run: | + export NVM_DIR="$HOME/.nvm" + . "$NVM_DIR/nvm.sh" + cd /root/dev/anywhere-code + npm ci + + - name: Build + run: | + export NVM_DIR="$HOME/.nvm" + . "$NVM_DIR/nvm.sh" + cd /root/dev/anywhere-code + npm run build + + - name: Wait for running queries to finish (direct push only) + if: ${{ github.event.head_commit.committer.username != 'web-flow' }} + run: | + echo "Direct push to main detected, waiting 60s for running queries to finish..." + sleep 60 + + - name: Restart service + run: | + export NVM_DIR="$HOME/.nvm" + . "$NVM_DIR/nvm.sh" + pm2 restart feishu-claude || pm2 start /root/dev/anywhere-code/ecosystem.config.cjs +``` + +--- + +## 改进优先级 + +| 优先级 | 改进项 | 复杂度 | 收益 | +|--------|--------|--------|------| +| P0 | deploy.yml 加 `paths-ignore` | 3 行 | 避免文档改动触发不必要的部署重启 | +| P1 | deploy.yml 加 test job 门禁 | 中 | 防止坏代码部署到生产 | +| P2 | 新建 ci.yml PR 级别测试 | 中 | 在 PR 阶段拦截问题 | +| P3 | Branch protection 设置 | 配置 | 强制 PR 测试通过才能 merge | From 1c8a87da92504c5683ef29f27d6d49dd3c6e1211 Mon Sep 17 00:00:00 2001 From: unclee Date: Wed, 4 Mar 2026 14:39:27 +0800 Subject: [PATCH 2/3] feat: add text content card to display agent output in threads (#110) Previously in thread mode, only the last turn's text was shown in the result card. Intermediate turns' text content was accumulated in memory but never sent to Feishu, causing users to miss detailed conclusions. Add a continuously-updated text content card between the progress card (tool calls) and the result card (status). Also fix pre-existing bug where the normal completion path dropped the last turn's tool calls from the progress card. Co-authored-by: Claude Opus 4.6 (1M context) --- src/feishu/__tests__/message-builder.test.ts | 49 +++++++++++++- src/feishu/event-handler.ts | 52 +++++++++++++-- src/feishu/message-builder.ts | 68 ++++++++++++++++++++ 3 files changed, 164 insertions(+), 5 deletions(-) diff --git a/src/feishu/__tests__/message-builder.test.ts b/src/feishu/__tests__/message-builder.test.ts index 48938ec6..1a838de8 100644 --- a/src/feishu/__tests__/message-builder.test.ts +++ b/src/feishu/__tests__/message-builder.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { buildProgressCard, buildResultCard, buildStreamingCard, buildPipelineCard, buildStatusCard, buildTurnCard, buildToolProgressCard, buildOverviewCard, buildSimpleResultCard } from '../message-builder.js'; +import { buildProgressCard, buildResultCard, buildStreamingCard, buildPipelineCard, buildStatusCard, buildTurnCard, buildToolProgressCard, buildTextContentCard, buildOverviewCard, buildSimpleResultCard } from '../message-builder.js'; import type { TurnInfo, ToolCallInfo, ActivityStatus } from '../../claude/types.js'; describe('buildProgressCard', () => { @@ -502,3 +502,50 @@ describe('buildToolProgressCard', () => { expect(body).toContain('_(无工具调用)_'); }); }); + +describe('buildTextContentCard', () => { + it('should show text with wathet header when in progress', () => { + const card = buildTextContentCard('这是 agent 的输出', 3) as any; + expect(card.header.template).toBe('wathet'); + expect(card.header.title.content).toContain('生成中'); + const body = card.elements[0].text.content as string; + expect(body).toBe('这是 agent 的输出'); + const note = card.elements[2].elements[0].content as string; + expect(note).toContain('⏳ 生成中'); + expect(note).toContain('3 轮'); + }); + + it('should show turquoise header when completed', () => { + const card = buildTextContentCard('最终结果', 5, true) as any; + expect(card.header.template).toBe('turquoise'); + expect(card.header.title.content).toBe('💬 Agent 输出'); + expect(card.header.title.content).not.toContain('生成中'); + const note = card.elements[2].elements[0].content as string; + expect(note).not.toContain('⏳'); + expect(note).toContain('5 轮'); + }); + + it('should not truncate short text', () => { + const shortText = '短文本内容'; + const card = buildTextContentCard(shortText, 1) as any; + const body = card.elements[0].text.content as string; + expect(body).toBe(shortText); + expect(body).not.toContain('已省略'); + }); + + it('should truncate long text keeping tail and adding prefix', () => { + const longText = '前'.repeat(5000) + '后'.repeat(5000); + const card = buildTextContentCard(longText, 2) as any; + const body = card.elements[0].text.content as string; + expect(body).toContain('已省略'); + expect(body).toContain('后后后'); + const serialized = JSON.stringify(card); + expect(Buffer.byteLength(serialized, 'utf-8')).toBeLessThan(30720); + }); + + it('should show placeholder for empty text', () => { + const card = buildTextContentCard('', 1) as any; + const body = card.elements[0].text.content as string; + expect(body).toContain('暂无输出'); + }); +}); diff --git a/src/feishu/event-handler.ts b/src/feishu/event-handler.ts index ae0235b4..7299f2c0 100644 --- a/src/feishu/event-handler.ts +++ b/src/feishu/event-handler.ts @@ -6,7 +6,7 @@ import { taskQueue } from '../session/queue.js'; import { claudeExecutor } from '../claude/executor.js'; import { DEFAULT_IMAGE_PROMPT } from '../claude/types.js'; import type { TurnInfo, ToolCallInfo, ImageAttachment } from '../claude/types.js'; -import { buildResultCard, buildStatusCard, buildCancelledCard, buildPipelineCard, buildPipelineConfirmCard, buildProgressCard, buildToolProgressCard, buildSimpleResultCard } from './message-builder.js'; +import { buildResultCard, buildStatusCard, buildCancelledCard, buildPipelineCard, buildPipelineConfirmCard, buildProgressCard, buildToolProgressCard, buildTextContentCard, buildSimpleResultCard } from './message-builder.js'; import { TOTAL_PHASES } from '../pipeline/types.js'; import { feishuClient, runWithAccountId } from './client.js'; import { config, isMultiBotMode } from '../config.js'; @@ -1130,16 +1130,48 @@ async function executeClaudeTask( // 构造逐条 turn 回调 // 策略:缓冲最后一个 turn,收到新 turn 时将前一个 turn 的 tool calls 刷入累积器, - // 原地更新进度卡片。结束时最后一个 turn 合并进结果卡片。 + // 原地更新进度卡片。文本内容同步刷入文本卡片。结束时最后一个 turn 合并进结果卡片。 let turnCount = 0; let pendingTurn: TurnInfo | undefined; const accumulatedToolCalls: ToolCallInfo[] = []; + let accumulatedText = ''; + let textCardMsgId: string | undefined; + let textCardFailed = false; + + /** 将文本追加到累积文本 */ + const appendText = (text: string) => { + accumulatedText += (accumulatedText ? '\n\n' : '') + text; + }; + + /** 追加文本(可选)并创建/更新文本卡片 */ + const flushTextCard = async (extraText?: string, completed: boolean = false) => { + if (extraText) appendText(extraText); + if (!accumulatedText || !threadReplyMsgId || textCardFailed) return; + try { + if (!textCardMsgId) { + textCardMsgId = await feishuClient.replyCardInThread( + threadReplyMsgId, + buildTextContentCard(accumulatedText, turnCount, completed), + ) ?? undefined; + if (!textCardMsgId) textCardFailed = true; + } else { + await feishuClient.updateCard( + textCardMsgId, + buildTextContentCard(accumulatedText, turnCount, completed), + ); + } + } catch (err) { + logger.warn({ err }, 'Failed to update text content card'); + textCardFailed = true; + } + }; const onTurn = async (turn: TurnInfo) => { turnCount = turn.turnIndex; - // 将前一个 turn 的 tool calls 刷入累积器,原地更新进度卡片 + // 将前一个 turn 的 tool calls 和文本刷入累积器,原地更新进度卡片和文本卡片 if (pendingTurn) { accumulatedToolCalls.push(...pendingTurn.toolCalls); + if (pendingTurn.textContent) appendText(pendingTurn.textContent); if (progressCardMsgId && !progressCardFailed) { try { await feishuClient.updateCard( @@ -1151,6 +1183,7 @@ async function executeClaudeTask( progressCardFailed = true; } } + await flushTextCard(); } // 缓冲当前 turn pendingTurn = turn; @@ -1313,6 +1346,9 @@ async function executeClaudeTask( ); } + // 将最后一个 turn 的文本也刷入文本卡片并标记完成 + await flushTextCard(pendingTurn?.textContent, true); + await sendResultCard( prompt, restartResult, totalDurationMs, totalCostUsd, threadReplyMsgId, chatId, threadReplyMsgId ? pendingTurn : undefined, turnCount, @@ -1369,12 +1405,18 @@ async function executeClaudeTask( // 进度卡片切换为完成态 if (progressCardMsgId) { + const allToolCalls = pendingTurn + ? [...accumulatedToolCalls, ...pendingTurn.toolCalls] + : accumulatedToolCalls; await feishuClient.updateCard( progressCardMsgId, - buildToolProgressCard(accumulatedToolCalls, turnCount, undefined, true), + buildToolProgressCard(allToolCalls, turnCount, undefined, true), ); } + // 将最后一个 turn 的文本也刷入文本卡片并标记完成 + await flushTextCard(pendingTurn?.textContent, true); + await sendResultCard( prompt, result, result.durationMs, result.costUsd, threadReplyMsgId, chatId, threadReplyMsgId ? pendingTurn : undefined, turnCount, @@ -1399,6 +1441,8 @@ async function executeClaudeTask( buildToolProgressCard(allToolCalls, turnCount, undefined, true), ).catch(() => {}); } + // 文本卡片 best-effort 刷新 + await flushTextCard(pendingTurn?.textContent, true).catch(() => {}); const errorReply = `❌ 执行出错: ${(err as Error).message}`; if (threadReplyMsgId) { await feishuClient.replyTextInThread(threadReplyMsgId, errorReply); diff --git a/src/feishu/message-builder.ts b/src/feishu/message-builder.ts index f70e71a7..4a55001a 100644 --- a/src/feishu/message-builder.ts +++ b/src/feishu/message-builder.ts @@ -553,6 +553,74 @@ export function buildToolProgressCard( }; } +/** + * 将文本截断到指定 UTF-8 字节上限,保留尾部(最新内容)。 + * 超限时从头部截断,保证完整 UTF-8 字符边界。 + */ +function truncateToByteLimit(text: string, maxBytes: number): { text: string; truncated: boolean } { + // 快速路径:byteLength 是 O(n) 扫描但不分配 Buffer + if (Buffer.byteLength(text, 'utf-8') <= maxBytes) return { text, truncated: false }; + + const buf = Buffer.from(text, 'utf-8'); + // 从尾部保留 maxBytes,找到合法的 UTF-8 字符起始位置 + let start = buf.length - maxBytes; + // UTF-8 continuation bytes: 10xxxxxx (0x80-0xBF), 跳到下一个 leading byte + while (start < buf.length && (buf[start] & 0xc0) === 0x80) start++; + return { text: buf.subarray(start).toString('utf-8'), truncated: true }; +} + +/** 飞书卡片 content 字节上限(留 2KB 给 JSON 结构开销) */ +const CARD_TEXT_MAX_BYTES = 28000; + +/** 构建累积文本内容卡片(原地更新,显示 agent 输出文本) */ +export function buildTextContentCard( + text: string, + turnCount: number, + completed: boolean = false, +): Record { + const { text: displayText, truncated } = truncateToByteLimit(text, CARD_TEXT_MAX_BYTES); + + const content = truncated + ? `_(前部分内容已省略)_\n\n${displayText}` + : displayText; + + const headerTitle = completed + ? '💬 Agent 输出' + : '💬 Agent 输出 - 生成中'; + const headerTemplate = completed ? 'turquoise' : 'wathet'; + + const footerParts: string[] = []; + if (!completed) footerParts.push('⏳ 生成中'); + footerParts.push(`🔄 ${turnCount} 轮`); + + return { + config: { wide_screen_mode: true }, + header: { + title: { tag: 'plain_text', content: headerTitle }, + template: headerTemplate, + }, + elements: [ + { + tag: 'div', + text: { + tag: 'lark_md', + content: content || '_(暂无输出)_', + }, + }, + { tag: 'hr' }, + { + tag: 'note', + elements: [ + { + tag: 'plain_text', + content: footerParts.join(' | '), + }, + ], + }, + ], + }; +} + /** 构建单轮 turn 消息卡片(逐条展示) */ export function buildTurnCard(turn: TurnInfo): Record { const parts: string[] = []; From 4c419f6497a858e6836ac951656da3ab9d0af77d Mon Sep 17 00:00:00 2001 From: unclee Date: Wed, 4 Mar 2026 14:49:35 +0800 Subject: [PATCH 3/3] =?UTF-8?q?fix:=20CI=20=E7=A7=BB=E9=99=A4=20lint=20?= =?UTF-8?q?=E6=AD=A5=E9=AA=A4=20=E2=80=94=20ESLint=20v10=20=E7=BC=BA?= =?UTF-8?q?=E5=B0=91=20flat=20config?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ESLint v10 要求 eslint.config.js (flat config),项目尚未配置, 本地和 CI 均无法运行 npm run lint。先移除 CI lint 步骤, 待后续 PR 单独修复 ESLint 配置。 Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/ci.yml | 3 --- 1 file changed, 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d31bacca..fff709a6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,8 +27,5 @@ jobs: - name: Type check run: npm run typecheck - - name: Lint - run: npm run lint - - name: Unit tests run: npx vitest run --exclude='**/quality.test.ts' --exclude='**/integration.test.ts'