From 8130f01ad40695169983c62996125a464842958e Mon Sep 17 00:00:00 2001 From: HelloWorldU Date: Thu, 14 May 2026 14:00:58 +0800 Subject: [PATCH 1/2] feat(frontend): add SwarmConfirmModal, SwarmToast, useConfirm, useToast MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增全局确认弹窗和 Toast 通知组件: - SwarmConfirmModal.vue: 类型化确认弹窗(danger/warning/info) - SwarmToast.vue: 自动消失的 Toast 通知(error/success/info/warning) - useConfirm.ts: 命令式 confirm API,Promise 化 - useToast.ts: 命令式 toast API,支持自动定时移除 --- docs/ARCHITECTURE.md | 2 + docs/COMPONENT_PATTERNS.md | 7 ++ docs/FRONTEND.md | 4 + kimi-code-swarm/agent-engine/src/agent.ts | 2 +- .../agent-engine/src/github-api.ts | 26 ++++- kimi-code-swarm/src/App.vue | 6 + .../src/components/SwarmConfirmModal.vue | 109 ++++++++++++++++++ kimi-code-swarm/src/components/SwarmToast.vue | 88 ++++++++++++++ kimi-code-swarm/src/composables/useConfirm.ts | 51 ++++++++ kimi-code-swarm/src/composables/useToast.ts | 35 ++++++ 10 files changed, 326 insertions(+), 4 deletions(-) create mode 100644 kimi-code-swarm/src/components/SwarmConfirmModal.vue create mode 100644 kimi-code-swarm/src/components/SwarmToast.vue create mode 100644 kimi-code-swarm/src/composables/useConfirm.ts create mode 100644 kimi-code-swarm/src/composables/useToast.ts diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 413e137..40339c7 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -70,6 +70,8 @@ PR 创建时,Store 自动生成 `ReviewEntry[]`,包含所有其他 Agent 作 6. 用户点击"停止"→ `stopAgent()` **乐观更新**前端状态为 `stopped`,再 `await sendToEngine({ type: 'stop-agent' })` → Engine 调用 `agent.stop()` 等待进程退出 → 推送 `agent-status` 事件;后端失败时自动回滚状态 7. 用户发送指令 → `sendInstruction()` 执行完毕且检测到文件变更 → Engine **自动调用 `autoSubmitForReview()`**:git add/commit/push → 创建 PR;任何步骤失败时,Engine 将完整执行日志(stdout + stderr + exit code)全量回传给 Kimi CLI,由 Agent 自主判断并修复,然后重试(最多 3 轮)→ 状态变为 `reviewing` → **自动启动 CI 监控**:Engine 每 30s 轮询 GitHub Checks API,CI 失败时自动将失败日志回传给 Agent 修复并重新提交(最多 3 轮);CI 通过或超时后停止轮询 +**全局组件挂载**:`App.vue` 全局挂载 `SwarmConfirmModal`(确认弹窗)和 `SwarmToast`(Toast 通知),配合 `useConfirm` / `useToast` composables 提供命令式调用能力。 + **日志分流**: - `input` / `output` 及关键状态变更(执行完毕/已停止/Token耗尽等)通过 `log` 事件进入前端聊天面板 - `system` / `error` 技术日志(PID、命令行参数、文件变更数、内部异常等)输出到 **stderr**,由终端直接显示(带 `[Agent]`/`[Kimi]`/`[Git]` 组件前缀和颜色),不污染 UI diff --git a/docs/COMPONENT_PATTERNS.md b/docs/COMPONENT_PATTERNS.md index b762df9..395a0f7 100644 --- a/docs/COMPONENT_PATTERNS.md +++ b/docs/COMPONENT_PATTERNS.md @@ -33,11 +33,18 @@ const emit = defineEmits<{...}>() - `LoginView.vue` — 独立页面组件:登录表单 + API Key 验证 + Kimi Code 控制台外链 - `AgentDashboard.vue` — 列表容器组件:统计卡片 + Agent 网格 + 空状态 - `CreateTaskModal.vue` — 表单弹窗组件:新建 Agent 的信息收集与提交 +- `SwarmConfirmModal.vue` — 确认弹窗组件:类型化图标 + 双按钮确认/取消,配合 useConfirm composable +- `SwarmToast.vue` — Toast 通知组件:类型化图标 + 进度条 + 自动消失,配合 useToast composable - `AgentDetail.vue` — 聊天式多轮对话组件:Header + Info + PR 审阅 + 文件变更 + 聊天消息区(input/output/system/error 气泡)+ 输入框。日期字段通过 new Date(string).toLocaleTimeString() 显示 - `TaskCard.vue` — 卡片组件:Agent 状态 + Token 进度 + 审阅徽章 - `AnalyticsPanel.vue` — 数据展示型组件:状态分布、Token 排行、任务列表 - `SettingsPanel.vue` — 纯信息展示型设置面板,使用 lucide 图标 + code 标签展示命令指引 +## Composables + +- `useConfirm.ts` — 命令式确认弹窗:全局 reactive 状态,支持 Promise 化调用 +- `useToast.ts` — 命令式 Toast 通知:全局 reactive 数组,支持自动定时移除 + ## 视觉风格 - **白色简约主题**:全局白底(`bg-white` / `bg-gray-50`),深灰文字(`text-gray-900` / `text-gray-700`),浅灰边框(`border-gray-200`) diff --git a/docs/FRONTEND.md b/docs/FRONTEND.md index d8bd0b2..f5e2d6c 100644 --- a/docs/FRONTEND.md +++ b/docs/FRONTEND.md @@ -26,8 +26,12 @@ Vue 3 + TypeScript + Vite + Tailwind CSS + lucide-vue-next + Tauri v2 + @tauri-a - `components/LoginView.vue` — API Key 登录页(验证 + keyring 存储) - `components/AgentDashboard.vue` — Agent 卡片网格(最多 5 个,点击进入详情) - `components/CreateTaskModal.vue` — 新建 Agent 弹窗:收集 name / repoUrl / instruction / tokenBudget +- `components/SwarmConfirmModal.vue` — 确认弹窗组件:支持 danger/warning/info 类型,配合 useConfirm 使用 +- `components/SwarmToast.vue` — Toast 通知组件:支持 error/success/info/warning,自动消失 - `App.vue` — 主入口:布局框架 + 视图路由(dashboard/agent-detail/analytics)+ 全局事件处理(如文件 diff 查看) - `components/AgentDetail.vue` — Agent 详情:指令输入 + 日志流 + PR 审阅 + 文件变更(点击通过 engine 获取 diff) +- `composables/useConfirm.ts` — 全局确认弹窗状态管理(命令式 API) +- `composables/useToast.ts` — 全局 Toast 通知状态管理(命令式 API) - `components/SettingsPanel.vue` — 系统设置(GitHub Token + Kimi CLI 安装指引) - `components/AnalyticsPanel.vue` — 监控分析:状态分布、Token 排行、活跃/审阅任务 - `components/TaskCard.vue` — Agent 卡片:状态 + Token 进度 + 审阅徽章 diff --git a/kimi-code-swarm/agent-engine/src/agent.ts b/kimi-code-swarm/agent-engine/src/agent.ts index 35d4cc3..206a896 100644 --- a/kimi-code-swarm/agent-engine/src/agent.ts +++ b/kimi-code-swarm/agent-engine/src/agent.ts @@ -432,7 +432,7 @@ export class Agent { this.state.ciStatus = 'failure' this.log('error', `CI 失败: ${failedRun.name}`) - const logs = await getCheckRunLogs(githubToken, this.state.repoUrl, failedRun.id) + const logs = await getCheckRunLogs(githubToken, this.state.repoUrl, failedRun.id, failedRun.details_url) await this.fixBasedOnCiFailure(logs || `Check run "${failedRun.name}" failed. No logs available.`, githubToken) return } diff --git a/kimi-code-swarm/agent-engine/src/github-api.ts b/kimi-code-swarm/agent-engine/src/github-api.ts index d19c34a..9cfadad 100644 --- a/kimi-code-swarm/agent-engine/src/github-api.ts +++ b/kimi-code-swarm/agent-engine/src/github-api.ts @@ -110,6 +110,7 @@ export interface CheckRun { status: string conclusion: string | null html_url: string + details_url?: string started_at: string | null } @@ -147,18 +148,38 @@ export async function getCheckRuns( /** * 获取失败 check run 的日志文本 - * GitHub 返回的是 text/plain 的日志文件,跟随重定向获取 + * 优先使用 GitHub Actions job logs API(check-runs 的 logs_url 经常为 null) */ export async function getCheckRunLogs( token: string, repoUrl: string, checkRunId: number, + detailsUrl?: string, ): Promise { const repo = parseRepoUrl(repoUrl) if (!repo) return null - const url = `${GITHUB_API}/repos/${repo.owner}/${repo.repo}/check-runs/${checkRunId}/logs` + // 方案 A: 从 details_url 提取 job_id,调用 Actions job logs API + if (detailsUrl) { + const match = detailsUrl.match(/\/job\/(\d+)$/) + if (match) { + const jobId = match[1] + const url = `${GITHUB_API}/repos/${repo.owner}/${repo.repo}/actions/jobs/${jobId}/logs` + try { + const res = await fetch(url, { headers: getHeaders(token) }) + if (res.ok) { + const text = await res.text() + return text.length > 8000 ? text.slice(0, 8000) + '\n...[truncated]' : text + } + console.error(`[github-api] job logs ${res.status}`) + } catch (err) { + console.error(`[github-api] job logs 异常: ${String(err)}`) + } + } + } + // 方案 B: fallback 到 check-runs logs 端点 + const url = `${GITHUB_API}/repos/${repo.owner}/${repo.repo}/check-runs/${checkRunId}/logs` try { const res = await fetch(url, { headers: { ...getHeaders(token), Accept: 'application/vnd.github.v3+json' }, @@ -168,7 +189,6 @@ export async function getCheckRunLogs( console.error(`[github-api] getCheckRunLogs ${res.status}`) return null } - // 日志可能很大,截断到 8000 字符以内 const text = await res.text() return text.length > 8000 ? text.slice(0, 8000) + '\n...[truncated]' : text } catch (err) { diff --git a/kimi-code-swarm/src/App.vue b/kimi-code-swarm/src/App.vue index 7d53ad1..7f83591 100644 --- a/kimi-code-swarm/src/App.vue +++ b/kimi-code-swarm/src/App.vue @@ -10,6 +10,8 @@ import AgentDetail from './components/AgentDetail.vue' import CreateTaskModal from './components/CreateTaskModal.vue' import SettingsPanel from './components/SettingsPanel.vue' import AnalyticsPanel from './components/AnalyticsPanel.vue' +import SwarmConfirmModal from './components/SwarmConfirmModal.vue' +import SwarmToast from './components/SwarmToast.vue' import { useSwarmStore } from './store/useSwarmStore' const store = useSwarmStore() @@ -196,4 +198,8 @@ function handleLogout() { @create="store.createAgent" /> + + + + diff --git a/kimi-code-swarm/src/components/SwarmConfirmModal.vue b/kimi-code-swarm/src/components/SwarmConfirmModal.vue new file mode 100644 index 0000000..2ff15c9 --- /dev/null +++ b/kimi-code-swarm/src/components/SwarmConfirmModal.vue @@ -0,0 +1,109 @@ + + + diff --git a/kimi-code-swarm/src/components/SwarmToast.vue b/kimi-code-swarm/src/components/SwarmToast.vue new file mode 100644 index 0000000..8e6d459 --- /dev/null +++ b/kimi-code-swarm/src/components/SwarmToast.vue @@ -0,0 +1,88 @@ + + + diff --git a/kimi-code-swarm/src/composables/useConfirm.ts b/kimi-code-swarm/src/composables/useConfirm.ts new file mode 100644 index 0000000..ac86db9 --- /dev/null +++ b/kimi-code-swarm/src/composables/useConfirm.ts @@ -0,0 +1,51 @@ +import { reactive } from 'vue' + +export type ConfirmType = 'danger' | 'warning' | 'info' + +interface ConfirmState { + isOpen: boolean + type: ConfirmType + title: string + message: string + confirmText: string + cancelText: string + resolve: ((value: boolean) => void) | null +} + +const state = reactive({ + isOpen: false, + type: 'info', + title: '', + message: '', + confirmText: '确认', + cancelText: '取消', + resolve: null, +}) + +export function useConfirm() { + function confirm(options: { + type?: ConfirmType + title: string + message?: string + confirmText?: string + cancelText?: string + }): Promise { + return new Promise((resolve) => { + state.type = options.type ?? 'info' + state.title = options.title + state.message = options.message ?? '' + state.confirmText = options.confirmText ?? '确认' + state.cancelText = options.cancelText ?? '取消' + state.resolve = resolve + state.isOpen = true + }) + } + + function resolve(value: boolean) { + state.isOpen = false + state.resolve?.(value) + state.resolve = null + } + + return { state, confirm, resolve } +} diff --git a/kimi-code-swarm/src/composables/useToast.ts b/kimi-code-swarm/src/composables/useToast.ts new file mode 100644 index 0000000..2417f7f --- /dev/null +++ b/kimi-code-swarm/src/composables/useToast.ts @@ -0,0 +1,35 @@ +import { reactive } from 'vue' + +export type ToastType = 'error' | 'success' | 'info' | 'warning' + +export interface ToastItem { + id: string + type: ToastType + title: string + message?: string + duration?: number +} + +const toasts = reactive([]) + +let idCounter = 0 + +export function useToast() { + function add(toast: Omit) { + const id = `toast-${++idCounter}` + const item = { ...toast, id, duration: toast.duration ?? 5000 } + toasts.push(item) + + if (item.duration > 0) { + setTimeout(() => remove(id), item.duration) + } + return id + } + + function remove(id: string) { + const index = toasts.findIndex((t) => t.id === id) + if (index > -1) toasts.splice(index, 1) + } + + return { toasts, add, remove } +} From 8c9870189f0ad54d25fbf0364ba41d54ce646ebb Mon Sep 17 00:00:00 2001 From: HelloWorldU Date: Thu, 14 May 2026 14:20:34 +0800 Subject: [PATCH 2/2] test(frontend): add unit tests for useToast and useConfirm --- kimi-code-swarm/tests/unit/useConfirm.spec.ts | 57 +++++++++++++++ kimi-code-swarm/tests/unit/useToast.spec.ts | 70 +++++++++++++++++++ 2 files changed, 127 insertions(+) create mode 100644 kimi-code-swarm/tests/unit/useConfirm.spec.ts create mode 100644 kimi-code-swarm/tests/unit/useToast.spec.ts diff --git a/kimi-code-swarm/tests/unit/useConfirm.spec.ts b/kimi-code-swarm/tests/unit/useConfirm.spec.ts new file mode 100644 index 0000000..f0f7c3a --- /dev/null +++ b/kimi-code-swarm/tests/unit/useConfirm.spec.ts @@ -0,0 +1,57 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +describe('useConfirm', () => { + beforeEach(async () => { + vi.resetModules() + }) + + it('opens confirm dialog with default options', async () => { + const { useConfirm } = await import('../../src/composables/useConfirm') + const { state, confirm } = useConfirm() + + const promise = confirm({ title: 'Are you sure?' }) + + expect(state.isOpen).toBe(true) + expect(state.title).toBe('Are you sure?') + expect(state.type).toBe('info') + expect(state.message).toBe('') + expect(state.confirmText).toBe('确认') + expect(state.cancelText).toBe('取消') + + useConfirm().resolve(false) + await expect(promise).resolves.toBe(false) + }) + + it('opens confirm dialog with custom options', async () => { + const { useConfirm } = await import('../../src/composables/useConfirm') + const { state, confirm } = useConfirm() + + const promise = confirm({ + type: 'danger', + title: 'Delete?', + message: 'This cannot be undone', + confirmText: 'Delete', + cancelText: 'Keep', + }) + + expect(state.isOpen).toBe(true) + expect(state.title).toBe('Delete?') + expect(state.type).toBe('danger') + expect(state.message).toBe('This cannot be undone') + expect(state.confirmText).toBe('Delete') + expect(state.cancelText).toBe('Keep') + + useConfirm().resolve(true) + await expect(promise).resolves.toBe(true) + }) + + it('closes dialog on resolve and returns value', async () => { + const { useConfirm } = await import('../../src/composables/useConfirm') + const { confirm, resolve } = useConfirm() + + const promise = confirm({ title: 'Test' }) + resolve(true) + + await expect(promise).resolves.toBe(true) + }) +}) diff --git a/kimi-code-swarm/tests/unit/useToast.spec.ts b/kimi-code-swarm/tests/unit/useToast.spec.ts new file mode 100644 index 0000000..79f7bfd --- /dev/null +++ b/kimi-code-swarm/tests/unit/useToast.spec.ts @@ -0,0 +1,70 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +describe('useToast', () => { + beforeEach(async () => { + vi.resetModules() + vi.useFakeTimers() + }) + + it('adds a toast with default duration', async () => { + const { useToast } = await import('../../src/composables/useToast') + const { toasts, add } = useToast() + + const id = add({ type: 'info', title: 'Hello' }) + + expect(toasts.length).toBe(1) + expect(toasts[0].id).toBe(id) + expect(toasts[0].type).toBe('info') + expect(toasts[0].title).toBe('Hello') + expect(toasts[0].duration).toBe(5000) + }) + + it('adds a toast with custom duration', async () => { + const { useToast } = await import('../../src/composables/useToast') + const { toasts, add } = useToast() + + add({ type: 'success', title: 'Done', duration: 3000 }) + + expect(toasts[0].duration).toBe(3000) + }) + + it('auto-removes toast after duration', async () => { + const { useToast } = await import('../../src/composables/useToast') + const { toasts, add } = useToast() + + add({ type: 'error', title: 'Oops', duration: 1000 }) + expect(toasts.length).toBe(1) + + vi.advanceTimersByTime(1000) + expect(toasts.length).toBe(0) + }) + + it('removes toast manually', async () => { + const { useToast } = await import('../../src/composables/useToast') + const { toasts, add, remove } = useToast() + + const id = add({ type: 'warning', title: 'Careful' }) + expect(toasts.length).toBe(1) + + remove(id) + expect(toasts.length).toBe(0) + }) + + it('does not throw when removing non-existent toast', async () => { + const { useToast } = await import('../../src/composables/useToast') + const { remove } = useToast() + + expect(() => remove('non-existent')).not.toThrow() + }) + + it('does not auto-remove when duration is 0', async () => { + const { useToast } = await import('../../src/composables/useToast') + const { toasts, add } = useToast() + + add({ type: 'info', title: 'Sticky', duration: 0 }) + expect(toasts.length).toBe(1) + + vi.advanceTimersByTime(10000) + expect(toasts.length).toBe(1) + }) +})