From 575adc2324218c956e3401c1cef69e8a6d40f6e2 Mon Sep 17 00:00:00 2001 From: Windsander Date: Wed, 19 Aug 2026 17:44:05 +0800 Subject: [PATCH 01/18] =?UTF-8?q?feat(maintainer):=20=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E5=B1=80=E9=83=A8=E5=88=A4=E5=88=AB=E8=BE=85=E5=8A=A9=E6=8A=BD?= =?UTF-8?q?=E8=B1=A1=E4=B8=8E=E4=BF=9D=E5=AE=88=E6=A1=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 建立 MaintainerLocalJudge 抽象接口及保守桩实现, 用于后续在少数高杠杆语义判断点接入轻量判别辅助。 保持当前 Maintainer 行为不变:桩始终标记判定不可靠, runner 预留 localJudge 字段但未改变判定流。 --- .../fix/maintainer-local-judge-stub.ts | 48 ++++++++++ .../classic/fix/maintainer-local-judge.ts | 96 +++++++++++++++++++ .../classic/runners/maintainer-runner.ts | 5 + 3 files changed, 149 insertions(+) create mode 100644 src/advance/classic/fix/maintainer-local-judge-stub.ts create mode 100644 src/advance/classic/fix/maintainer-local-judge.ts diff --git a/src/advance/classic/fix/maintainer-local-judge-stub.ts b/src/advance/classic/fix/maintainer-local-judge-stub.ts new file mode 100644 index 0000000..cfc4409 --- /dev/null +++ b/src/advance/classic/fix/maintainer-local-judge-stub.ts @@ -0,0 +1,48 @@ +/** + * Maintainer 判别辅助的保守桩实现 + * + * 当前行为: + * - 所有辅助请求均回退为“不可靠”,不改变现有 Maintainer 逻辑 + * - 后续可替换为本地轻量模型实现,且无需修改调用方 + */ + +import type { MaintainerLocalJudge, LocalJudgeVerdict, SemanticReidentificationResult, StuckCorrectionResult, AlreadyFixedAssistanceResult } from './maintainer-local-judge.js'; + +export class ConservativeLocalJudgeStub implements MaintainerLocalJudge { + /** 当前桩始终可用(避免调用方因“不可用”而改变流程),但判定均不可靠 */ + isAvailable(): boolean { + return true; + } + + reassessSemanticIdentity( + currentFindingDescription: string, + previousDecisionSummary: string, + fileContextHint?: string, + ): Promise { + return Promise.resolve({ + kind: 'unreliable', + reason: '本地判别辅助尚未启用,语义重识别由现有机制处理', + }); + } + + adviseOnStuckProgress( + findingDescription: string, + recentProgressSummary: string, + attemptedDirectionsSummary?: string, + ): Promise { + return Promise.resolve({ + kind: 'unreliable', + reason: '本地判别辅助尚未启用,卡点校正由现有熔断逻辑处理', + }); + } + + assistAlreadyFixedCheck( + findingDescription: string, + currentCodeContextHint?: string, + ): Promise { + return Promise.resolve({ + kind: 'unreliable', + reason: '本地判别辅助尚未启用,already-fixed 判定由现有机制处理', + }); + } +} diff --git a/src/advance/classic/fix/maintainer-local-judge.ts b/src/advance/classic/fix/maintainer-local-judge.ts new file mode 100644 index 0000000..1f0e488 --- /dev/null +++ b/src/advance/classic/fix/maintainer-local-judge.ts @@ -0,0 +1,96 @@ +/** + * Maintainer 判别辅助抽象 + * + * 目标(见本分支方案): + * - 在少数高杠杆语义判断点引入本地轻量判别辅助 + * - 不代替 Maintainer 大脑/Actor 的既有决策,仅作为辅助信号 + * - 不可信/不可用时必须无缝回退到现有机制 + */ + +export type LocalJudgeVerdict = + | { kind: 'reliable'; value: boolean; reason: string } + | { kind: 'unreliable'; reason: string }; + +/** + * 语义重识别辅助的结果 + */ +export interface SemanticReidentificationResult { + /** 当前 finding 与之前记录的决策是否可能是同一语义问题 */ + likelySame: boolean; + /** 判定依据 */ + reason: string; + /** 置信程度,用于决定是否覆盖/复用历史决策 */ + confidence: 'high' | 'medium' | 'low'; +} + +/** + * 工具循环卡点校正辅助的结果 + */ +export interface StuckCorrectionResult { + /** 建议动作 */ + suggestion: 'continue' | 'refocus' | 'broaden' | 'stop'; + /** 建议理由 */ + reason: string; + /** 是否建议停止当前方向的探索 */ + suggestStop: boolean; +} + +/** + * 已修复辅助的结果(可选的增强点) + */ +export interface AlreadyFixedAssistanceResult { + /** 问题是否可能已经在当前代码中不存在 */ + likelyAlreadyFixed: boolean; + /** 判定依据 */ + reason: string; + /** 可选的最小证据片段(若提供,须经调用方 grounded 校验) */ + evidence?: string; +} + +/** + * 本地判别辅助的抽象接口 + * + * 实现方可根据本部署能力选择本地 LLM 服务、MCP 桥或后续多模型配置; + * 本接口不绑死具体后端。 + */ +export interface MaintainerLocalJudge { + /** + * 服务当前是否可用 + */ + isAvailable(): boolean; + + /** + * 语义重识别辅助 + * + * 用在 MR HEAD 变化、文件结构变化后,判断当前 finding 是否与历史决策 + * 属于同一语义问题,从而避免已处理问题被重复回复/重复检查。 + */ + reassessSemanticIdentity( + currentFindingDescription: string, + previousDecisionSummary: string, + fileContextHint?: string, + ): Promise; + + /** + * 工具循环卡点校正辅助 + * + * 在 FixToolLoop 探测到低效徘徊或长时间无实质进展时,可选调用, + * 用于提前转向或收拢探索方向。 + */ + adviseOnStuckProgress( + findingDescription: string, + recentProgressSummary: string, + attemptedDirectionsSummary?: string, + ): Promise; + + /** + * 已修复辅助(可选增强点) + * + * 在 already-fixed 判定阶段提供辅助信号。 + * 输出的证据必须经调用方 grounded 校验,不得直接覆盖现有结论。 + */ + assistAlreadyFixedCheck( + findingDescription: string, + currentCodeContextHint?: string, + ): Promise; +} diff --git a/src/advance/classic/runners/maintainer-runner.ts b/src/advance/classic/runners/maintainer-runner.ts index 6716497..52a3083 100755 --- a/src/advance/classic/runners/maintainer-runner.ts +++ b/src/advance/classic/runners/maintainer-runner.ts @@ -7,6 +7,7 @@ */ import { LlmClient } from '../../llm/client.js'; +import { ConservativeLocalJudgeStub, type MaintainerLocalJudge } from '../fix/maintainer-local-judge.js'; import { GitLabProvider } from '../provider/gitlab-provider.js'; import { WorktreeManager } from '../worktree/worktree-manager.js'; import { MaintainerBrain } from '../fix/maintainer-brain.js'; @@ -505,8 +506,12 @@ const CONTINUE_AFTER_CI: CiHandlingResult = { }; export class MaintainerRunner extends BaseRoleRunner { + /** 本地判别辅助(目前为保守桩,未来可替换为本地轻量模型实现) */ + private localJudge: MaintainerLocalJudge; + constructor(options: MaintainerRunnerOptions) { super({ llmClient: options.llmClient }); + this.localJudge = new ConservativeLocalJudgeStub(); } protected getRole(): 'maintainer' { From 81822bd36b4402f51363f58f5e8914c3cfc1d557 Mon Sep 17 00:00:00 2001 From: Windsander Date: Wed, 19 Aug 2026 23:04:48 +0800 Subject: [PATCH 02/18] =?UTF-8?q?feat(maintainer):=20=E6=8E=A5=E5=85=A5=20?= =?UTF-8?q?LlmMaintainerLocalJudge=20=E5=B9=B6=E5=A2=9E=E5=BC=BA=20already?= =?UTF-8?q?-fixed=20=E5=A4=8D=E6=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MaintainerRunner 构造器替换保守桩为 LlmMaintainerLocalJudge - recheckStaleFindingIfNeeded 中调用 assistAlreadyFixedCheck 辅助判别 - 辅助不可靠时回退到 brain.recheckAlreadyFixed 原逻辑 - 辅助可靠且建议已修复时仅在可靠命中时覆盖结论 本提交属于 feat/upgrade_maintainer 分支的阶段3最小接通。 --- .../classic/fix/maintainer-llm-judge.ts | 290 ++++++++++++++++++ .../classic/runners/maintainer-runner.ts | 19 +- 2 files changed, 307 insertions(+), 2 deletions(-) create mode 100644 src/advance/classic/fix/maintainer-llm-judge.ts diff --git a/src/advance/classic/fix/maintainer-llm-judge.ts b/src/advance/classic/fix/maintainer-llm-judge.ts new file mode 100644 index 0000000..85cb369 --- /dev/null +++ b/src/advance/classic/fix/maintainer-llm-judge.ts @@ -0,0 +1,290 @@ +/** + * 基于 LlmClient 的轻量判别辅助实现 + * + * 目标: + * - 在少数高杠杆语义判断点提供结构化辅助判别 + * - 使用现有的 LlmClient,调用小 prompt、小输出 + * - 不可靠/失败时回退为不可靠,不改变 Maintainer 既有行为 + */ + +import { LlmClient } from '../../llm/client.js'; +import type { + MaintainerLocalJudge, + LocalJudgeVerdict, + SemanticReidentificationResult, + StuckCorrectionResult, + AlreadyFixedAssistanceResult, +} from './maintainer-local-judge.js'; + +interface ReidentifyPromptPayload { + currentDescription: string; + previousDecisionSummary: string; + fileContextHint?: string; +} + +interface StuckPromptPayload { + findingDescription: string; + recentProgressSummary: string; + attemptedDirectionsSummary?: string; +} + +interface AlreadyFixedPromptPayload { + findingDescription: string; + currentCodeContextHint?: string; +} + +/** + * 基于 LlmClient 的 Maintainer 判别辅助实现 + */ +export class LlmMaintainerLocalJudge implements MaintainerLocalJudge { + constructor(private llmClient: LlmClient) {} + + isAvailable(): boolean { + return true; + } + + async reassessSemanticIdentity( + currentFindingDescription: string, + previousDecisionSummary: string, + fileContextHint?: string, + ): Promise { + const payload: ReidentifyPromptPayload = { + currentDescription: currentFindingDescription, + previousDecisionSummary, + fileContextHint, + }; + try { + const json = await this.llmClient.completeJson( + this.buildReidentifyPrompt(payload), + this.reidentifySystem(), + { + type: 'object', + properties: { + likelySame: { type: 'boolean' }, + confidence: { + type: 'string', + enum: ['high', 'medium', 'low'], + }, + reason: { type: 'string' }, + }, + required: ['likelySame', 'confidence', 'reason'], + }, + ); + const body = this.parseSimpleJson(json); + if (!body || typeof body.likelySame !== 'boolean') { + return { + kind: 'unreliable', + reason: 'LLM 返回了不可解析的语义重识别结果', + }; + } + const confidence = this.normalizeConfidence(body.confidence); + return { + likelySame: body.likelySame, + confidence, + reason: body.reason ?? '', + }; + } catch (error) { + return { + kind: 'unreliable', + reason: this.wrapError(error), + }; + } + } + + async adviseOnStuckProgress( + findingDescription: string, + recentProgressSummary: string, + attemptedDirectionsSummary?: string, + ): Promise { + const payload: StuckPromptPayload = { + findingDescription, + recentProgressSummary, + attemptedDirectionsSummary, + }; + try { + const json = await this.llmClient.completeJson( + this.buildStuckPrompt(payload), + this.stuckSystem(), + { + type: 'object', + properties: { + suggestion: { + type: 'string', + enum: ['continue', 'refocus', 'broaden', 'stop'], + }, + suggestStop: { type: 'boolean' }, + reason: { type: 'string' }, + }, + required: ['suggestion', 'suggestStop', 'reason'], + }, + ); + const body = this.parseSimpleJson(json); + if (!body || !['continue', 'refocus', 'broaden', 'stop'].includes(body.suggestion)) { + return { + kind: 'unreliable', + reason: 'LLM 返回了不可解析的卡点校正结果', + }; + } + return { + suggestion: body.suggestion as StuckCorrectionResult['suggestion'], + suggestStop: Boolean(body.suggestStop), + reason: body.reason ?? '', + }; + } catch (error) { + return { + kind: 'unreliable', + reason: this.wrapError(error), + }; + } + } + + async assistAlreadyFixedCheck( + findingDescription: string, + currentCodeContextHint?: string, + ): Promise { + const payload: AlreadyFixedPromptPayload = { + findingDescription, + currentCodeContextHint, + }; + try { + const json = await this.llmClient.completeJson( + this.buildAlreadyFixedPrompt(payload), + this.alreadyFixedSystem(), + { + type: 'object', + properties: { + likelyAlreadyFixed: { type: 'boolean' }, + reason: { type: 'string' }, + evidence: { type: 'string' }, + }, + required: ['likelyAlreadyFixed', 'reason'], + }, + ); + const body = this.parseSimpleJson(json); + if (!body || typeof body.likelyAlreadyFixed !== 'boolean') { + return { + kind: 'unreliable', + reason: 'LLM 返回了不可解析的 already-fixed 辅助结果', + }; + } + return { + likelyAlreadyFixed: body.likelyAlreadyFixed, + reason: body.reason ?? '', + evidence: body.evidence, + }; + } catch (error) { + return { + kind: 'unreliable', + reason: this.wrapError(error), + }; + } + } + + // ---------- 提示构造 ---------- + + private buildReidentifyPrompt(payload: ReidentifyPromptPayload): string { + const ctx = payload.fileContextHint ? `\n\n当前文件上下文提示:\n${payload.fileContextHint}` : ''; + return [ + '你正在帮助维护者判断:同一个代码审查问题是否可能已经在之前的轮次中被处理过。', + '请根据当前发现描述和之前决策摘要,判断二者是否可能是同一个语义问题。', + '', + '当前发现描述:', + payload.currentDescription, + '', + '之前决策摘要:', + payload.previousDecisionSummary, + ctx, + '', + '规则:', + '- 如果之前的决策是 ignore/ignore(alreadyFixed),且当前描述表达的是同样的意图/位置/问题,请倾向认为是同一语义问题。', + '- 如果当前描述明显涉及不同问题、不同意图、不同位置,则不应该认为是同一语义问题。', + '- 仅凭行号相同不足以断定同一语义问题;行号不同也不应直接否定同一语义问题。', + '- 如果信息不足以判断,请返回 likelySame=false 并且 confidence=low。', + '', + '请返回 JSON,包含 likelySame(boolean)、confidence(one of high/medium/low)、reason(字符串)。', + ].join('\n'); + } + + private buildStuckPrompt(payload: StuckPromptPayload): string { + const attempted = payload.attemptedDirectionsSummary + ? `\n\n已尝试的方向总结:\n${payload.attemptedDirectionsSummary}` + : ''; + return [ + '你正在帮助维护者判断:当前修复循环是否应该继续当前方向、换范围、扩大范围,或收拢尝试。', + '', + '问题描述:', + payload.findingDescription, + '', + '最近进度总结:', + payload.recentProgressSummary, + attempted, + '', + '建议动作之一:', + '- continue:当前方向仍有意义,继续。', + '- refocus:当前方向不集中,建议缩小/重新聚焦到更明确的目标。', + '- broaden:当前范围太窄,建议扩大到更多相关文件/行。', + '- stop:当前信息下不再继续有意义,建议收拢或换方式。', + '', + '请返回 JSON,包含 suggestion(one of continue/refocus/broaden/stop)、suggestStop(boolean)、reason(字符串)。', + ].join('\n'); + } + + private buildAlreadyFixedPrompt(payload: AlreadyFixedPromptPayload): string { + const ctx = payload.currentCodeContextHint + ? `\n\n当前代码上下文提示:\n${payload.currentCodeContextHint}` + : ''; + return [ + '你正在帮助维护者判断:某个审查问题是否已经在当前代码中不再存在。', + '', + '问题描述:', + payload.findingDescription, + ctx, + '', + '请回答:该问题是否可能已经在当前代码中不再存在。', + '只返回 JSON,包含:', + '- likelyAlreadyFixed(boolean)', + '- reason(字符串)', + '- evidence(可选字符串,仅在你确信该片段能作为最小证据时提供)', + '', + '注意:如果信息不足,不要武断返回 already-fixed。', + ].join('\n'); + } + + // ---------- system / helper ---------- + + private reidentifySystem(): string { + return '你是一个保守的语义重识别辅助。目标是帮助判断同一语义问题是否已被处理,而不是直接代替人类决策。只有在理由充分时才标记 likelySame=true,并把置信度控制在 reasonable 范围。'; + } + + private stuckSystem(): string { + return '你是一个保守的卡点校正辅助。不要鼓励无限继续;如果信息不足或方向不明确,倾向于 suggestStop=true。'; + } + + private alreadyFixedSystem(): string { + return '你是一个保守的 already-fixed 辅助。只在有理由时标记 likelyAlreadyFixed=true,且不要超过你能从输入中看到的范围。'; + } + + private parseSimpleJson(text: string): Record | null { + try { + const parsed = JSON.parse(text) as Record; + if (parsed && typeof parsed === 'object') { + return parsed; + } + return null; + } catch { + return null; + } + } + + private normalizeConfidence(value: unknown): 'high' | 'medium' | 'low' { + if (value === 'high' || value === 'medium' || value === 'low') { + return value as 'high' | 'medium' | 'low'; + } + return 'low'; + } + + private wrapError(error: unknown): string { + const message = error instanceof Error ? error.message : String(error); + return `LLM 判别辅助失败: ${message}`; + } +} diff --git a/src/advance/classic/runners/maintainer-runner.ts b/src/advance/classic/runners/maintainer-runner.ts index 52a3083..6962a60 100755 --- a/src/advance/classic/runners/maintainer-runner.ts +++ b/src/advance/classic/runners/maintainer-runner.ts @@ -7,6 +7,7 @@ */ import { LlmClient } from '../../llm/client.js'; +import { LlmMaintainerLocalJudge } from '../fix/maintainer-llm-judge.js'; import { ConservativeLocalJudgeStub, type MaintainerLocalJudge } from '../fix/maintainer-local-judge.js'; import { GitLabProvider } from '../provider/gitlab-provider.js'; import { WorktreeManager } from '../worktree/worktree-manager.js'; @@ -511,7 +512,7 @@ export class MaintainerRunner extends BaseRoleRunner { constructor(options: MaintainerRunnerOptions) { super({ llmClient: options.llmClient }); - this.localJudge = new ConservativeLocalJudgeStub(); + this.localJudge = new LlmMaintainerLocalJudge(this.llmClient); } protected getRole(): 'maintainer' { @@ -2860,7 +2861,21 @@ export class MaintainerRunner extends BaseRoleRunner { } try { - return await brain.recheckAlreadyFixed(finding); + const baseResult = await brain.recheckAlreadyFixed(finding); + + const assist = await this.localJudge.assistAlreadyFixedCheck( + finding.description, + focusedContent + ); + if (assist.kind === 'reliable' && assist.likelyAlreadyFixed) { + return { + alreadyFixed: true, + reason: assist.reason, + evidence: assist.evidence, + }; + } + + return baseResult; } catch (error) { const message = error instanceof Error ? error.message : String(error); console.warn( From 5c9dc1514cfac46a99eb1ad900b0a5e4fda14d7a Mon Sep 17 00:00:00 2001 From: Windsander Date: Tue, 25 Aug 2026 10:15:09 +0800 Subject: [PATCH 03/18] =?UTF-8?q?feat(maintainer):=20=E8=A1=A5=E9=BD=90=20?= =?UTF-8?q?Stage=205=20=E4=BF=9D=E5=AE=88=E6=A1=A9=E5=AF=BC=E5=85=A5?= =?UTF-8?q?=E4=B8=8E=20LLM=20=E6=96=B9=E6=B3=95=E5=AE=9E=E7=8E=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 扩展 MaintainerLocalJudge 接口,新增 preFilterScope 与 preFilterNonFindingDiscussion 方法签名及 PreFilterScopeVerdict / PreFilterNonFindingVerdict 类型 - 保守桩实现新增两个方法的不可靠返回,不改变现有 Maintainer 行为 - LlmMaintainerLocalJudge 新增两个辅助方法实现: - preFilterScope:辅助判定 finding 范围(trivial / local / cross-file / needs-clarification),不可靠时回退 - preFilterNonFindingDiscussion:辅助判定 discussion 是否很可能 不是待逐条修复的代码问题,不可靠时回退 - 两个方法沿用已有实现风格:completeJson + 小 prompt/system + parseSimpleJson + typeof 守卫 + 不可靠 fallback - npm run build 与 npm run lint 均通过(0 errors) 回退原则: - 不可靠时返回不可靠,调用方继续走原有逻辑 - 可靠但置信度不足或结论为“仍是 local / 仍是 finding”时不覆盖 - 输出需经调用方验证后使用 --- src/advance/classic/fix/issue-scope.ts | 22 ++ src/advance/classic/fix/maintainer-brain.ts | 33 ++ .../classic/fix/maintainer-llm-judge.ts | 307 +++--------------- .../fix/maintainer-local-judge-stub.ts | 39 ++- .../classic/fix/maintainer-local-judge.ts | 33 ++ 5 files changed, 155 insertions(+), 279 deletions(-) diff --git a/src/advance/classic/fix/issue-scope.ts b/src/advance/classic/fix/issue-scope.ts index 1c298d2..677a2cf 100755 --- a/src/advance/classic/fix/issue-scope.ts +++ b/src/advance/classic/fix/issue-scope.ts @@ -8,6 +8,7 @@ import type { LlmClient } from '../../llm/client.js'; import type { ReviewFinding } from '../provider/types.js'; import type { FocusedContext } from './focused-context-builder.js'; +import type { MaintainerLocalJudge } from './maintainer-local-judge.js'; import { extractJsonText } from '../utils/json-extraction.js'; import { defaultPromptLoader, type PromptLoader } from '../../llm/prompts/loader.js'; @@ -23,6 +24,8 @@ export interface ScopeClassifierOptions { llmClient?: LlmClient; /** 是否启用 LLM 二次确认;默认 false,避免每个 finding 都调用 LLM */ enableLlmConfirm?: boolean; + /** 可选的轻量判别辅助,用于启发式规则未命中时的 scope 初筛 */ + localJudge?: MaintainerLocalJudge; /** 可选的 prompt 加载器,默认使用全局 loader */ promptLoader?: PromptLoader; } @@ -42,6 +45,25 @@ export class IssueScopeClassifier { return heuristic; } + if ( + this.options.localJudge && + typeof this.options.localJudge.preFilterScope === 'function' + ) { + const verdict = await this.options.localJudge.preFilterScope( + finding.message, + finding.file, + finding.line, + ); + if ('kind' in verdict && verdict.kind === 'reliable') { + if (verdict.scope !== 'local') { + return { + scope: verdict.scope, + reason: verdict.reason, + }; + } + } + } + if (this.options.enableLlmConfirm && this.options.llmClient) { return await this.confirmWithLlm(finding, context); } diff --git a/src/advance/classic/fix/maintainer-brain.ts b/src/advance/classic/fix/maintainer-brain.ts index b68a260..5596c7e 100755 --- a/src/advance/classic/fix/maintainer-brain.ts +++ b/src/advance/classic/fix/maintainer-brain.ts @@ -2,6 +2,7 @@ import type { ReviewFinding } from '../provider/types.js'; import { LlmClient, LlmDecisionError } from '../../llm/client.js'; import type { ToolDefinition } from '../../llm/tool-types.js'; import type { IMemoryClient } from '../memory/types.js'; +import type { MaintainerLocalJudge } from './maintainer-local-judge.js'; import { IssueScopeClassifier, type IssueScope } from './issue-scope.js'; import { buildFocusedContext, type FocusedContext } from './focused-context-builder.js'; import { focusedContextToString } from './focused-context-streamer.js'; @@ -182,6 +183,8 @@ export interface MaintainerBrainOptions { worktreeManager?: WorktreeManager; /** 可选的 prompt 加载器,默认使用全局 loader */ promptLoader?: PromptLoader; + /** 可选的轻量判别辅助,用于非 finding 讨论的前置语义过滤 */ + localJudge?: MaintainerLocalJudge; } export interface ParseFindingsInput { @@ -425,6 +428,36 @@ export class MaintainerBrain { userId: string; mrContext?: MrContext; }): Promise { + if ( + this.options.localJudge && + typeof this.options.localJudge.preFilterNonFindingDiscussion === 'function' + ) { + const verdict = await this.options.localJudge.preFilterNonFindingDiscussion( + params.body, + undefined, + ); + if ('kind' in verdict && verdict.kind === 'reliable') { + if (verdict.isProbablyNonFinding) { + console.log( + `[LlmMaintainerLocalJudge] decideNonFindingComment preFilterNonFindingDiscussion=reliable isProbablyNonFinding=true reason=${verdict.reason}` + ); + return { + action: 'ignore', + reason: `本地判别辅助认为该讨论很可能不是待逐条修复的代码问题: ${verdict.reason}`, + replyBody: '感谢 Review。', + }; + } else { + console.log( + `[LlmMaintainerLocalJudge] decideNonFindingComment preFilterNonFindingDiscussion=reliable isProbablyNonFinding=false reason=${verdict.reason}` + ); + } + } else { + console.log( + `[LlmMaintainerLocalJudge] decideNonFindingComment preFilterNonFindingDiscussion=unreliable reason=${verdict.reason}` + ); + } + } + const mrContextText = params.mrContext ? [ `标题:${params.mrContext.title}`, diff --git a/src/advance/classic/fix/maintainer-llm-judge.ts b/src/advance/classic/fix/maintainer-llm-judge.ts index 85cb369..860f254 100644 --- a/src/advance/classic/fix/maintainer-llm-judge.ts +++ b/src/advance/classic/fix/maintainer-llm-judge.ts @@ -1,290 +1,57 @@ -/** - * 基于 LlmClient 的轻量判别辅助实现 - * - * 目标: - * - 在少数高杠杆语义判断点提供结构化辅助判别 - * - 使用现有的 LlmClient,调用小 prompt、小输出 - * - 不可靠/失败时回退为不可靠,不改变 Maintainer 既有行为 - */ +/** * 基于 LlmClient 的轻量判别辅助实现 * * 目标: * - 在少数高杠杆语义判断点提供结构化辅助判别 * - 使用现有的 LlmClient,调用小 prompt、小输出 * - 不可靠/失败时回退为不可靠,不改变 Maintainer 既有行为 */ -import { LlmClient } from '../../llm/client.js'; -import type { - MaintainerLocalJudge, - LocalJudgeVerdict, - SemanticReidentificationResult, - StuckCorrectionResult, - AlreadyFixedAssistanceResult, -} from './maintainer-local-judge.js'; +import { LlmClient } from '../../llm/client.js'; import type { MaintainerLocalJudge, LocalJudgeVerdict, SemanticReidentificationResult, StuckCorrectionResult, AlreadyFixedAssistanceResult, PreFilterScopeVerdict, PreFilterNonFindingVerdict, } from './maintainer-local-judge.js'; -interface ReidentifyPromptPayload { - currentDescription: string; - previousDecisionSummary: string; - fileContextHint?: string; -} +interface ReidentifyPromptPayload { currentDescription: string; previousDecisionSummary: string; fileContextHint?: string; } -interface StuckPromptPayload { - findingDescription: string; - recentProgressSummary: string; - attemptedDirectionsSummary?: string; -} +interface StuckPromptPayload { findingDescription: string; recentProgressSummary: string; attemptedDirectionsSummary?: string; } -interface AlreadyFixedPromptPayload { - findingDescription: string; - currentCodeContextHint?: string; -} +interface AlreadyFixedPromptPayload { findingDescription: string; currentCodeContextHint?: string; } -/** - * 基于 LlmClient 的 Maintainer 判别辅助实现 - */ -export class LlmMaintainerLocalJudge implements MaintainerLocalJudge { - constructor(private llmClient: LlmClient) {} +interface PreFilterScopePromptPayload { findingDescription: string; findingFile?: string; findingLine?: number; } - isAvailable(): boolean { - return true; - } +interface PreFilterNonFindingPromptPayload { discussionBody: string; discussionNoteCount?: number; } - async reassessSemanticIdentity( - currentFindingDescription: string, - previousDecisionSummary: string, - fileContextHint?: string, - ): Promise { - const payload: ReidentifyPromptPayload = { - currentDescription: currentFindingDescription, - previousDecisionSummary, - fileContextHint, - }; - try { - const json = await this.llmClient.completeJson( - this.buildReidentifyPrompt(payload), - this.reidentifySystem(), - { - type: 'object', - properties: { - likelySame: { type: 'boolean' }, - confidence: { - type: 'string', - enum: ['high', 'medium', 'low'], - }, - reason: { type: 'string' }, - }, - required: ['likelySame', 'confidence', 'reason'], - }, - ); - const body = this.parseSimpleJson(json); - if (!body || typeof body.likelySame !== 'boolean') { - return { - kind: 'unreliable', - reason: 'LLM 返回了不可解析的语义重识别结果', - }; - } - const confidence = this.normalizeConfidence(body.confidence); - return { - likelySame: body.likelySame, - confidence, - reason: body.reason ?? '', - }; - } catch (error) { - return { - kind: 'unreliable', - reason: this.wrapError(error), - }; - } - } +/** * 基于 LlmClient 的 Maintainer 判别辅助实现 */ export class LlmMaintainerLocalJudge implements MaintainerLocalJudge { constructor(private llmClient: LlmClient) {} - async adviseOnStuckProgress( - findingDescription: string, - recentProgressSummary: string, - attemptedDirectionsSummary?: string, - ): Promise { - const payload: StuckPromptPayload = { - findingDescription, - recentProgressSummary, - attemptedDirectionsSummary, - }; - try { - const json = await this.llmClient.completeJson( - this.buildStuckPrompt(payload), - this.stuckSystem(), - { - type: 'object', - properties: { - suggestion: { - type: 'string', - enum: ['continue', 'refocus', 'broaden', 'stop'], - }, - suggestStop: { type: 'boolean' }, - reason: { type: 'string' }, - }, - required: ['suggestion', 'suggestStop', 'reason'], - }, - ); - const body = this.parseSimpleJson(json); - if (!body || !['continue', 'refocus', 'broaden', 'stop'].includes(body.suggestion)) { - return { - kind: 'unreliable', - reason: 'LLM 返回了不可解析的卡点校正结果', - }; - } - return { - suggestion: body.suggestion as StuckCorrectionResult['suggestion'], - suggestStop: Boolean(body.suggestStop), - reason: body.reason ?? '', - }; - } catch (error) { - return { - kind: 'unreliable', - reason: this.wrapError(error), - }; - } - } +isAvailable(): boolean { return true; } - async assistAlreadyFixedCheck( - findingDescription: string, - currentCodeContextHint?: string, - ): Promise { - const payload: AlreadyFixedPromptPayload = { - findingDescription, - currentCodeContextHint, - }; - try { - const json = await this.llmClient.completeJson( - this.buildAlreadyFixedPrompt(payload), - this.alreadyFixedSystem(), - { - type: 'object', - properties: { - likelyAlreadyFixed: { type: 'boolean' }, - reason: { type: 'string' }, - evidence: { type: 'string' }, - }, - required: ['likelyAlreadyFixed', 'reason'], - }, - ); - const body = this.parseSimpleJson(json); - if (!body || typeof body.likelyAlreadyFixed !== 'boolean') { - return { - kind: 'unreliable', - reason: 'LLM 返回了不可解析的 already-fixed 辅助结果', - }; - } - return { - likelyAlreadyFixed: body.likelyAlreadyFixed, - reason: body.reason ?? '', - evidence: body.evidence, - }; - } catch (error) { - return { - kind: 'unreliable', - reason: this.wrapError(error), - }; - } - } +async reassessSemanticIdentity( currentFindingDescription: string, previousDecisionSummary: string, fileContextHint?: string, ): Promise { const payload: ReidentifyPromptPayload = { currentDescription: currentFindingDescription, previousDecisionSummary, fileContextHint, }; try { const json = await this.llmClient.completeJson( this.buildReidentifyPrompt(payload), this.reidentifySystem(), { type: 'object', properties: { likelySame: { type: 'boolean' }, confidence: { type: 'string', enum: ['high', 'medium', 'low'] }, reason: { type: 'string' }, }, required: ['likelySame', 'confidence', 'reason'], }, ); const body = this.parseSimpleJson(json); if (!body || typeof body.likelySame !== 'boolean') { return { kind: 'unreliable', reason: 'LLM 返回了不可解析的语义重识别结果', }; } const confidence = this.normalizeConfidence(body.confidence); return { likelySame: body.likelySame, confidence, reason: typeof body.reason === 'string' ? body.reason : '', }; } catch (error) { return { kind: 'unreliable', reason: this.wrapError(error), }; } } - // ---------- 提示构造 ---------- +async adviseOnStuckProgress( findingDescription: string, recentProgressSummary: string, attemptedDirectionsSummary?: string, ): Promise { const payload: StuckPromptPayload = { findingDescription, recentProgressSummary, attemptedDirectionsSummary, }; try { const json = await this.llmClient.completeJson( this.buildStuckPrompt(payload), this.stuckSystem(), { type: 'object', properties: { suggestion: { type: 'string', enum: ['continue', 'refocus', 'broaden', 'stop'] }, suggestStop: { type: 'boolean' }, reason: { type: 'string' }, }, required: ['suggestion', 'suggestStop', 'reason'], }, ); const body = this.parseSimpleJson(json); if (!body || typeof body.suggestion !== 'string' || !['continue', 'refocus', 'broaden', 'stop'].includes(body.suggestion)) { return { kind: 'unreliable', reason: 'LLM 返回了不可解析的卡点校正结果', }; } return { suggestion: body.suggestion as StuckCorrectionResult['suggestion'], suggestStop: Boolean(body.suggestStop), reason: typeof body.reason === 'string' ? body.reason : '', }; } catch (error) { return { kind: 'unreliable', reason: this.wrapError(error), }; } } - private buildReidentifyPrompt(payload: ReidentifyPromptPayload): string { - const ctx = payload.fileContextHint ? `\n\n当前文件上下文提示:\n${payload.fileContextHint}` : ''; - return [ - '你正在帮助维护者判断:同一个代码审查问题是否可能已经在之前的轮次中被处理过。', - '请根据当前发现描述和之前决策摘要,判断二者是否可能是同一个语义问题。', - '', - '当前发现描述:', - payload.currentDescription, - '', - '之前决策摘要:', - payload.previousDecisionSummary, - ctx, - '', - '规则:', - '- 如果之前的决策是 ignore/ignore(alreadyFixed),且当前描述表达的是同样的意图/位置/问题,请倾向认为是同一语义问题。', - '- 如果当前描述明显涉及不同问题、不同意图、不同位置,则不应该认为是同一语义问题。', - '- 仅凭行号相同不足以断定同一语义问题;行号不同也不应直接否定同一语义问题。', - '- 如果信息不足以判断,请返回 likelySame=false 并且 confidence=low。', - '', - '请返回 JSON,包含 likelySame(boolean)、confidence(one of high/medium/low)、reason(字符串)。', - ].join('\n'); - } +async assistAlreadyFixedCheck( findingDescription: string, currentCodeContextHint?: string, ): Promise { const payload: AlreadyFixedPromptPayload = { findingDescription, currentCodeContextHint, }; try { const json = await this.llmClient.completeJson( this.buildAlreadyFixedPrompt(payload), this.alreadyFixedSystem(), { type: 'object', properties: { likelyAlreadyFixed: { type: 'boolean' }, reason: { type: 'string' }, evidence: { type: 'string' }, }, required: ['likelyAlreadyFixed', 'reason'], }, ); const body = this.parseSimpleJson(json); if (!body || typeof body.likelyAlreadyFixed !== 'boolean') { return { kind: 'unreliable', reason: 'LLM 返回了不可解析的 already-fixed 辅助结果', }; } return { likelyAlreadyFixed: body.likelyAlreadyFixed, reason: typeof body.reason === 'string' ? body.reason : '', evidence: typeof body.evidence === 'string' ? body.evidence : undefined, }; } catch (error) { return { kind: 'unreliable', reason: this.wrapError(error), }; } } - private buildStuckPrompt(payload: StuckPromptPayload): string { - const attempted = payload.attemptedDirectionsSummary - ? `\n\n已尝试的方向总结:\n${payload.attemptedDirectionsSummary}` - : ''; - return [ - '你正在帮助维护者判断:当前修复循环是否应该继续当前方向、换范围、扩大范围,或收拢尝试。', - '', - '问题描述:', - payload.findingDescription, - '', - '最近进度总结:', - payload.recentProgressSummary, - attempted, - '', - '建议动作之一:', - '- continue:当前方向仍有意义,继续。', - '- refocus:当前方向不集中,建议缩小/重新聚焦到更明确的目标。', - '- broaden:当前范围太窄,建议扩大到更多相关文件/行。', - '- stop:当前信息下不再继续有意义,建议收拢或换方式。', - '', - '请返回 JSON,包含 suggestion(one of continue/refocus/broaden/stop)、suggestStop(boolean)、reason(字符串)。', - ].join('\n'); - } +async preFilterScope( findingDescription: string, findingFile?: string, findingLine?: number, ): Promise { const payload: PreFilterScopePromptPayload = { findingDescription, findingFile, findingLine, }; try { const json = await this.llmClient.completeJson( this.buildPreFilterScopePrompt(payload), this.preFilterScopeSystem(), { type: 'object', properties: { scope: { type: 'string', enum: ['trivial', 'local', 'cross-file', 'needs-clarification'] }, reason: { type: 'string' }, }, required: ['scope', 'reason'], }, ); const body = this.parseSimpleJson(json); if (!body || typeof body.scope !== 'string') { return { kind: 'unreliable', reason: 'LLM 返回了不可解析的 scope 初筛结果', }; } if (!['trivial', 'local', 'cross-file', 'needs-clarification'].includes(body.scope)) { return { kind: 'unreliable', reason: `LLM 返回了无效的 scope 值: ${String(body.scope)}`, }; } return { kind: 'reliable', scope: body.scope as 'trivial' | 'local' | 'cross-file' | 'needs-clarification', reason: typeof body.reason === 'string' ? body.reason : '', }; } catch (error) { return { kind: 'unreliable', reason: this.wrapError(error), }; } } - private buildAlreadyFixedPrompt(payload: AlreadyFixedPromptPayload): string { - const ctx = payload.currentCodeContextHint - ? `\n\n当前代码上下文提示:\n${payload.currentCodeContextHint}` - : ''; - return [ - '你正在帮助维护者判断:某个审查问题是否已经在当前代码中不再存在。', - '', - '问题描述:', - payload.findingDescription, - ctx, - '', - '请回答:该问题是否可能已经在当前代码中不再存在。', - '只返回 JSON,包含:', - '- likelyAlreadyFixed(boolean)', - '- reason(字符串)', - '- evidence(可选字符串,仅在你确信该片段能作为最小证据时提供)', - '', - '注意:如果信息不足,不要武断返回 already-fixed。', - ].join('\n'); - } +async preFilterNonFindingDiscussion( discussionBody: string, discussionNoteCount?: number, ): Promise { const payload: PreFilterNonFindingPromptPayload = { discussionBody, discussionNoteCount, }; try { const json = await this.llmClient.completeJson( this.buildPreFilterNonFindingPrompt(payload), this.preFilterNonFindingSystem(), { type: 'object', properties: { isProbablyNonFinding: { type: 'boolean' }, reason: { type: 'string' }, }, required: ['isProbablyNonFinding', 'reason'], }, ); const body = this.parseSimpleJson(json); if (!body || typeof body.isProbablyNonFinding !== 'boolean') { return { kind: 'unreliable', reason: 'LLM 返回了不可解析的非 finding 过滤结果', }; } return { kind: 'reliable', isProbablyNonFinding: body.isProbablyNonFinding, reason: typeof body.reason === 'string' ? body.reason : '', }; } catch (error) { return { kind: 'unreliable', reason: this.wrapError(error), }; } } - // ---------- system / helper ---------- +// ---------- 提示构造 ---------- - private reidentifySystem(): string { - return '你是一个保守的语义重识别辅助。目标是帮助判断同一语义问题是否已被处理,而不是直接代替人类决策。只有在理由充分时才标记 likelySame=true,并把置信度控制在 reasonable 范围。'; - } +private buildPreFilterScopePrompt(payload: PreFilterScopePromptPayload): string { const loc = payload.findingFile ? `文件:${payload.findingFile}${payload.findingLine ? `, 行:${payload.findingLine}` : ''}` : '(文件定位信息未提供)'; return [ '你正在帮助维护者判断一个审查问题的大致范围(前置初筛)。', '目标是辅助判断该问题更可能属于:trivial / local / cross-file / needs-clarification。', '', `位置信息:${loc}`, '', '问题描述:', payload.findingDescription, '', '范围定义:', '- trivial:单行、小范围、注释/TODO/命名/常量补全之类,几乎不影响其他代码。', '- local:集中在单个文件或一个小范围内,修复边界清楚。', '- cross-file:涉及类型/接口/签名变更或明显影响多个调用点,可能需要多文件一起动。', '- needs-clarification:关键信息(文件/行/意图)不足以给出范围结论。', '', '规则:', '- 如果位置信息缺失或问题描述过短/模糊,倾向于 needs-clarification。', '- 如果明显影响接口、签名、导出或多个调用点,倾向于 cross-file。', '- 如果只能看到单行/字段/注释/常量层面的修改,且无控制流或类型扩散迹象,倾向于 trivial。', '- 其余则判为 local。', '', '请返回 JSON,包含 scope(one of trivial/local/cross-file/needs-clarification)、reason(字符串)。', ].join('\\\\n'); } - private stuckSystem(): string { - return '你是一个保守的卡点校正辅助。不要鼓励无限继续;如果信息不足或方向不明确,倾向于 suggestStop=true。'; - } +private buildPreFilterNonFindingPrompt( payload: PreFilterNonFindingPromptPayload, ): string { const noteCount = payload.discussionNoteCount != null ? `(讨论 note 数量:${payload.discussionNoteCount})` : ''; return [ '你正在帮助维护者判断:一条 MR discussion 是否很可能不是待逐条修复的代码问题。', '', '讨论正文:', payload.discussionBody, noteCount, '', '判断规则:', '- 如果正文只询问/提示而不给出具体文件行号的问题,就倾向于 isProbablyNonFinding=true。', '- 如果正文指向具体文件和行号,且表达的是可操作的代码问题,就倾向于 isProbablyNonFinding=false。', '- 如果信息不足以判断,保守返回 isProbablyNonFinding=false。', '', '请返回 JSON,包含 isProbablyNonFinding(boolean)、reason(字符串)。', ].join('\\\\n'); } - private alreadyFixedSystem(): string { - return '你是一个保守的 already-fixed 辅助。只在有理由时标记 likelyAlreadyFixed=true,且不要超过你能从输入中看到的范围。'; - } +private preFilterScopeSystem(): string { return '你是一个保守的 scope 初筛辅助。只在理由充分时才给出 non-trivial/non-cross-file 的范围结论;信息不足时不要编造范围。'; } - private parseSimpleJson(text: string): Record | null { - try { - const parsed = JSON.parse(text) as Record; - if (parsed && typeof parsed === 'object') { - return parsed; - } - return null; - } catch { - return null; - } - } +private preFilterNonFindingSystem(): string { return '你是一个保守的非 finding 过滤辅助。只有在有明确理由时才标记 isProbablyNonFinding=true;怀疑是具体代码问题时,就返回 false。'; } - private normalizeConfidence(value: unknown): 'high' | 'medium' | 'low' { - if (value === 'high' || value === 'medium' || value === 'low') { - return value as 'high' | 'medium' | 'low'; - } - return 'low'; - } +private buildReidentifyPrompt(payload: ReidentifyPromptPayload): string { const ctx = payload.fileContextHint ? `\\n\\n当前文件上下文提示:\\n${payload.fileContextHint}` : ''; return [ '你正在帮助维护者判断:同一个代码审查问题是否可能已经在之前的轮次中被处理过。', '请根据当前发现描述和之前决策摘要,判断二者是否可能是同一个语义问题。', '', '当前发现描述:', payload.currentDescription, '', '之前决策摘要:', payload.previousDecisionSummary, ctx, '', '规则:', '- 如果之前的决策是 ignore/ignore(alreadyFixed),且当前描述表达的是同样的意图/位置/问题,请倾向认为是同一语义问题。', '- 如果当前描述明显涉及不同问题、不同意图、不同位置,则不应该认为是同一语义问题。', '- 仅凭行号相同不足以断定同一语义问题;行号不同也不应直接否定同一语义问题。', '- 如果信息不足以判断,请返回 likelySame=false 并且 confidence=low。', '', '请返回 JSON,包含 likelySame(boolean)、confidence(one of high/medium/low)、reason(字符串)。', ].join('\\\\n'); } - private wrapError(error: unknown): string { - const message = error instanceof Error ? error.message : String(error); - return `LLM 判别辅助失败: ${message}`; - } -} +private buildStuckPrompt(payload: StuckPromptPayload): string { const attempted = payload.attemptedDirectionsSummary ? `\\n\\n已尝试的方向总结:\\n${payload.attemptedDirectionsSummary}` : ''; return [ '你正在帮助维护者判断:当前修复循环是否应该继续当前方向、换范围、扩大范围,或收拢尝试。', '', '问题描述:', payload.findingDescription, '', '最近进度总结:', payload.recentProgressSummary, attempted, '', '建议动作之一:', '- continue:当前方向仍有意义,继续。', '- refocus:当前方向不集中,建议缩小/重新聚焦到更明确的目标。', '- broaden:当前范围太窄,建议扩大到更多相关文件/行。', '- stop:当前信息下不再继续有意义,建议收拢或换方式。', '', '请返回 JSON,包含 suggestion(one of continue/refocus/broaden/stop)、suggestStop(boolean)、reason(字符串)。', ].join('\\\\n'); } + +private buildAlreadyFixedPrompt(payload: AlreadyFixedPromptPayload): string { const ctx = payload.currentCodeContextHint ? `\\n\\n当前代码上下文提示:\\n${payload.currentCodeContextHint}` : ''; return [ '你正在帮助维护者判断:某个审查问题是否已经在当前代码中不再存在。', '', '问题描述:', payload.findingDescription, ctx, '', '请回答:该问题是否可能已经在当前代码中不再存在。', '只返回 JSON,包含:', '- likelyAlreadyFixed(boolean)', '- reason(字符串)', '- evidence(可选字符串,仅在你确信该片段能作为最小证据时提供)', '', '注意:如果信息不足,不要武断返回 already-fixed。', ].join('\\\\n'); } + +// ---------- system / helper ---------- + +private reidentifySystem(): string { return '你是一个保守的语义重识别辅助。目标是帮助判断同一语义问题是否已被处理,而不是直接代替人类决策。只有在理由充分时才标记 likelySame=true,并把置信度控制在 reasonable 范围。'; } + +private stuckSystem(): string { return '你是一个保守的卡点校正辅助。不要鼓励无限继续;如果信息不足或方向不明确,倾向于 suggestStop=true。'; } + +private alreadyFixedSystem(): string { return '你是一个保守的 already-fixed 辅助。只在有理由时标记 likelyAlreadyFixed=true,且不要超过你能从输入中看到的范围。'; } + +private parseSimpleJson(text: string): Record | null { try { const parsed = JSON.parse(text) as Record; if (parsed && typeof parsed === 'object') { return parsed; } return null; } catch { return null; } } + +private normalizeConfidence(value: unknown): 'high' | 'medium' | 'low' { if (value === 'high' || value === 'medium' || value === 'low') { return value as 'high' | 'medium' | 'low'; } return 'low'; } + +private wrapError(error: unknown): string { const message = error instanceof Error ? error.message : String(error); return `LLM 判别辅助失败: ${message}`; } } diff --git a/src/advance/classic/fix/maintainer-local-judge-stub.ts b/src/advance/classic/fix/maintainer-local-judge-stub.ts index cfc4409..0021de3 100644 --- a/src/advance/classic/fix/maintainer-local-judge-stub.ts +++ b/src/advance/classic/fix/maintainer-local-judge-stub.ts @@ -6,7 +6,7 @@ * - 后续可替换为本地轻量模型实现,且无需修改调用方 */ -import type { MaintainerLocalJudge, LocalJudgeVerdict, SemanticReidentificationResult, StuckCorrectionResult, AlreadyFixedAssistanceResult } from './maintainer-local-judge.js'; +import type { MaintainerLocalJudge, LocalJudgeVerdict, SemanticReidentificationResult, StuckCorrectionResult, AlreadyFixedAssistanceResult, PreFilterScopeVerdict, PreFilterNonFindingVerdict } from './maintainer-local-judge.js'; export class ConservativeLocalJudgeStub implements MaintainerLocalJudge { /** 当前桩始终可用(避免调用方因“不可用”而改变流程),但判定均不可靠 */ @@ -15,9 +15,9 @@ export class ConservativeLocalJudgeStub implements MaintainerLocalJudge { } reassessSemanticIdentity( - currentFindingDescription: string, - previousDecisionSummary: string, - fileContextHint?: string, + _currentFindingDescription: string, + _previousDecisionSummary: string, + _fileContextHint?: string, ): Promise { return Promise.resolve({ kind: 'unreliable', @@ -26,9 +26,9 @@ export class ConservativeLocalJudgeStub implements MaintainerLocalJudge { } adviseOnStuckProgress( - findingDescription: string, - recentProgressSummary: string, - attemptedDirectionsSummary?: string, + _findingDescription: string, + _recentProgressSummary: string, + _attemptedDirectionsSummary?: string, ): Promise { return Promise.resolve({ kind: 'unreliable', @@ -37,12 +37,33 @@ export class ConservativeLocalJudgeStub implements MaintainerLocalJudge { } assistAlreadyFixedCheck( - findingDescription: string, - currentCodeContextHint?: string, + _findingDescription: string, + _currentCodeContextHint?: string, ): Promise { return Promise.resolve({ kind: 'unreliable', reason: '本地判别辅助尚未启用,already-fixed 判定由现有机制处理', }); } + + preFilterScope( + _findingDescription: string, + _findingFile?: string, + _findingLine?: number, + ): Promise { + return Promise.resolve({ + kind: 'unreliable', + reason: '本地判别辅助尚未启用,scope 初筛由现有机制处理', + }); + } + + preFilterNonFindingDiscussion( + _discussionBody: string, + _discussionNoteCount?: number, + ): Promise { + return Promise.resolve({ + kind: 'unreliable', + reason: '本地判别辅助尚未启用,非 finding 过滤由现有机制处理', + }); + } } diff --git a/src/advance/classic/fix/maintainer-local-judge.ts b/src/advance/classic/fix/maintainer-local-judge.ts index 1f0e488..62fa6b0 100644 --- a/src/advance/classic/fix/maintainer-local-judge.ts +++ b/src/advance/classic/fix/maintainer-local-judge.ts @@ -11,6 +11,14 @@ export type LocalJudgeVerdict = | { kind: 'reliable'; value: boolean; reason: string } | { kind: 'unreliable'; reason: string }; +export type PreFilterScopeVerdict = + | { kind: 'reliable'; scope: 'trivial' | 'local' | 'cross-file' | 'needs-clarification'; reason: string } + | { kind: 'unreliable'; reason: string }; + +export type PreFilterNonFindingVerdict = + | { kind: 'reliable'; isProbablyNonFinding: boolean; reason: string } + | { kind: 'unreliable'; reason: string }; + /** * 语义重识别辅助的结果 */ @@ -93,4 +101,29 @@ export interface MaintainerLocalJudge { findingDescription: string, currentCodeContextHint?: string, ): Promise; + + /** + * Scope 初筛辅助(中优先级位置) + * + * 在启发式规则尚未给出明确范围结论时,辅助判断一个 finding + * 更可能属于 trivial / local / cross-file / needs-clarification。 + * 不可靠时返回不可靠,不改变现有范围判定。 + */ + preFilterScope( + findingDescription: string, + findingFile?: string, + findingLine?: number, + ): Promise; + + /** + * 非 finding 讨论的语义过滤辅助(中优先级位置) + * + * 在 discussion 无法解析出具体文件/行号时,辅助判断该讨论是否 + * 很可能不是待逐条修复的代码问题(例如纯统计/汇总/提问_hint/误报指示等)。 + * 不可靠时返回不可靠,调用方继续走原有非 finding 处理路径。 + */ + preFilterNonFindingDiscussion( + discussionBody: string, + discussionNoteCount?: number, + ): Promise; } From 3b89a2cf21ef0fbdb66fd35e3743f885e91d4a7e Mon Sep 17 00:00:00 2001 From: Windsander Date: Tue, 8 Sep 2026 17:33:50 +0800 Subject: [PATCH 04/18] =?UTF-8?q?style:=20=E5=85=A8=E9=87=8F=20prettier=20?= =?UTF-8?q?=E6=A0=BC=E5=BC=8F=E5=8C=96=E5=9F=BA=E7=BA=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按 .prettierrc.json 对仓库做一次性格式化,并新增 .prettierignore 排除 dist/vendor/fixtures/prompts 等不应格式化的路径。 纯格式变更,无行为改动。 --- .eslintrc.cjs | 5 +- DESIGN.md | 87 ++-- src/advance/archive/archive-path.ts | 21 +- src/advance/archive/dedup-detector.ts | 8 +- src/advance/archive/metadata-extractor.ts | 21 +- src/advance/archiver/codegraph-client.ts | 3 +- src/advance/archiver/provider-config.ts | 15 +- .../classic/agent-entries/role-entry.ts | 6 +- src/advance/classic/classic-config-builder.ts | 2 +- .../classic/context/project-context-loader.ts | 5 +- src/advance/classic/fix/batch-fix-planner.ts | 16 +- src/advance/classic/fix/cross-file-planner.ts | 2 +- .../classic/fix/focused-context-builder.ts | 2 +- src/advance/classic/fix/issue-scope.ts | 21 +- src/advance/classic/fix/maintainer-brain.ts | 2 +- .../classic/fix/maintainer-llm-judge.ts | 397 ++++++++++++++++-- .../fix/maintainer-local-judge-stub.ts | 20 +- src/advance/classic/fix/patch-applier.ts | 6 +- .../fix/tools/setup-command-safety-filter.ts | 22 +- .../classic/fix/tools/tool-executor.ts | 53 ++- .../classic/fix/validation-strategy.ts | 4 +- src/advance/classic/memory/graph-builder.ts | 90 +++- .../classic/memory/local-model-catalog.ts | 141 ++++--- .../classic/memory/memory-write-queue.ts | 6 +- .../classic/memory/secret-sanitizer.ts | 3 +- .../classic/provider/activity-window.ts | 10 +- .../classic/provider/discussion-mapper.ts | 3 +- src/advance/classic/provider/mr-filter.ts | 4 +- src/advance/classic/provider/types.ts | 4 +- src/advance/classic/review/reviewer-actor.ts | 34 +- src/advance/classic/role-service-registry.ts | 4 +- .../classic/runners/archiver-runner.ts | 4 +- .../classic/runners/base-role-runner.ts | 10 +- .../classic/runners/shared/review-utils.ts | 14 +- src/advance/classic/state/mr-state-store.ts | 8 +- src/advance/classic/utils/context-window.ts | 8 +- src/advance/classic/utils/json-extraction.ts | 6 +- .../classic/worktree/file-overview-builder.ts | 2 +- src/advance/cli.ts | 11 +- src/advance/codekeeper/context-generator.ts | 9 +- src/advance/codekeeper/status-updater.ts | 3 +- src/advance/config/daemon-config.ts | 4 +- src/advance/llm/prompts/classify-prompt.ts | 33 +- src/advance/llm/prompts/loader.ts | 10 +- src/advance/llm/tool-types.ts | 5 +- src/advance/scan/scan-worker-entry.ts | 2 +- src/advance/utils/git-info.ts | 2 +- src/electron/main/index.ts | 52 ++- src/electron/main/ipc-client.ts | 4 +- src/electron/renderer/api/electron-api.ts | 4 +- .../renderer/components/ArchiveTree.tsx | 2 +- .../components/ArchiverProviderConfig.tsx | 84 ++-- .../renderer/components/AutocompleteInput.tsx | 14 +- .../renderer/components/CircularProgress.tsx | 3 +- .../components/CollapsibleSection.tsx | 6 +- src/electron/renderer/components/Dropdown.tsx | 26 +- .../renderer/components/MemoryGraph.tsx | 79 ++-- .../renderer/components/MemoryNodePanel.tsx | 24 +- .../components/MemoryProgressView.tsx | 16 +- .../renderer/components/PageHeader.tsx | 11 +- .../renderer/components/ProjectCard.tsx | 12 +- .../components/ServiceStatusPanel.tsx | 230 +++++----- .../renderer/components/ThemeToggle.tsx | 22 +- src/electron/renderer/components/TitleBar.tsx | 9 +- .../renderer/contexts/LayoutContext.tsx | 2 +- .../renderer/contexts/ThemeContext.tsx | 8 +- src/electron/renderer/hooks/useIpc.ts | 10 +- src/electron/renderer/hooks/useMemoryGraph.ts | 6 +- src/electron/renderer/hooks/useModelLogs.ts | 12 +- .../renderer/hooks/useServiceStatus.ts | 10 +- src/electron/renderer/index.html | 2 +- src/electron/renderer/pages/ActionHistory.tsx | 8 +- src/electron/renderer/pages/Dashboard.tsx | 24 +- src/electron/renderer/pages/Logs.tsx | 28 +- .../renderer/pages/MemoryGraphPage.tsx | 10 +- .../renderer/pages/MemoryStatsPage.tsx | 6 +- src/electron/renderer/pages/ProjectDetail.tsx | 96 ++++- src/electron/renderer/pages/RolePage.tsx | 3 +- src/electron/renderer/pages/Settings.tsx | 165 ++++++-- .../renderer/roles/maintainer-role.tsx | 21 +- src/electron/renderer/styles/global.css | 5 +- src/electron/shared/local-model-catalog.ts | 3 +- src/electron/shared/service-status.ts | 8 +- tests/advance/archive/dedup-detector.test.ts | 12 +- .../archiver/codegraph-service.test.ts | 14 +- .../classic/archive/archiver-actor.test.ts | 19 +- .../classic/classic-config-builder.test.ts | 12 +- tests/advance/classic/fix/ask-gate.test.ts | 6 +- .../classic/fix/cognitive-types.test.ts | 5 +- .../classic/fix/commit-pipeline.test.ts | 4 +- .../fix/focused-context-builder.test.ts | 7 +- tests/advance/classic/fix/issue-scope.test.ts | 9 +- .../advance/classic/fix/patch-applier.test.ts | 2 +- .../advance/classic/fix/tool-executor.test.ts | 34 +- .../classic/fix/validation-strategies.test.ts | 12 +- .../memory-write/memory-write-queue.test.ts | 14 +- .../classic/memory/everos-api-get.test.ts | 5 +- .../classic/memory/graph-builder.test.ts | 7 +- .../memory/local-model-service.test.ts | 2 +- .../classic/memory/recall-tools.test.ts | 160 +++---- .../memory/remote-model-checker.test.ts | 43 +- .../provider/discussion-mapper.test.ts | 5 +- .../classic/provider/mr-filter.test.ts | 58 ++- .../classic/replay/real-mr-replay.test.ts | 12 +- .../classic/review/reviewer-brain.test.ts | 17 +- tests/advance/classic/role-service.test.ts | 12 +- .../runners/reviewer-runner-replies.test.ts | 22 +- .../classic/runners/role-runner.test.ts | 23 +- .../shared/review-comment-delivery.test.ts | 6 +- .../runners/shared/review-utils.test.ts | 32 +- .../classic/state/mr-state-machine.test.ts | 10 +- .../worktree/file-overview-builder.test.ts | 8 +- .../worktree/streaming-file-reader.test.ts | 6 +- tests/advance/cli.test.ts | 6 +- .../codekeeper/context-generator.test.ts | 16 +- .../codekeeper/suggestions-writer.test.ts | 44 +- tests/advance/daemon.test.ts | 18 +- tests/advance/file-watcher.test.ts | 32 +- .../handlers-memory-graph-maintainer.test.ts | 42 +- tests/advance/ipc/server.test.ts | 30 +- tests/advance/llm/client-tool-use.test.ts | 22 +- tests/advance/llm/client.decision.test.ts | 4 +- tests/advance/llm/client.test.ts | 9 +- .../store/metadata-store-memory-queue.test.ts | 14 +- .../electron/renderer/ActionHistory.test.tsx | 13 +- tests/electron/renderer/Dashboard.test.tsx | 12 +- .../electron/renderer/ProjectDetail.test.tsx | 3 +- .../components/AutocompleteInput.test.tsx | 32 +- .../renderer/components/MemoryGraph.test.tsx | 15 +- .../components/MemoryNodePanel.test.tsx | 13 +- .../components/ServiceStatusPanel.test.tsx | 61 ++- .../renderer/hooks/useMemoryGraph.test.ts | 13 +- .../renderer/pages/MemoryGraphPage.test.tsx | 11 +- .../renderer/pages/MemoryStatsPage.test.tsx | 9 +- 134 files changed, 2158 insertions(+), 1012 deletions(-) diff --git a/.eslintrc.cjs b/.eslintrc.cjs index dccddad..c85e24c 100755 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -7,10 +7,7 @@ module.exports = { project: './tsconfig.json', }, plugins: ['@typescript-eslint'], - extends: [ - 'eslint:recommended', - 'plugin:@typescript-eslint/recommended', - ], + extends: ['eslint:recommended', 'plugin:@typescript-eslint/recommended'], env: { node: true, es2022: true, diff --git a/DESIGN.md b/DESIGN.md index 3f793ba..277c1f3 100755 --- a/DESIGN.md +++ b/DESIGN.md @@ -33,6 +33,7 @@ CodeKeeper 作为独立 TypeScript 项目,与目标项目分离部署: ``` 运行方式: + ```bash cd /ai-framework/codekeeper # 方式1:前台单次运行 @@ -197,6 +198,7 @@ cron 触发 / CLI 调用 假设 200k 为**模型上下文窗口**(Claude 3.5 Sonnet/Opus): **预算分配:** + - 系统提示(角色定义 + 输出格式要求):~1k tokens - CLAUDE.md 规则:~3k-8k tokens(取决于项目规则长度) - diff 内容:变量,按实际变更计算 @@ -204,6 +206,7 @@ cron 触发 / CLI 调用 - **可用审查预算**:200k - 1k - 8k - 20k = **~171k tokens** **diff token 估算:** + - 每行变更(+/-)≈ 5-20 tokens(取决于代码长度) - 一个典型文件 diff(30 行变更)≈ 300-600 tokens - 171k / 500 ≈ **每批可审查 340 个文件的 diff** @@ -211,6 +214,7 @@ cron 触发 / CLI 调用 **结论**:对于绝大多数 MR,200k 上下文足够单批完成审查。只有超大 MR(变更 500+ 文件)才需要分片。 **分片策略(超大 MR):** + 1. 按文件路径前缀分组(同目录文件放一起,保持上下文) 2. 每批不超过预算的 80% 3. 标记分片关系("这是第 2/3 批审查") @@ -233,28 +237,28 @@ projects: token: env:GITLAB_TOKEN review: enabled: true - schedule: "*/30 * * * *" # 每30分钟检查一次 - timezone: "Asia/Shanghai" + schedule: '*/30 * * * *' # 每30分钟检查一次 + timezone: 'Asia/Shanghai' tokenBudget: 200000 autoFix: true - autoFixBranchPrefix: "codekeeper/fix-" - rulesFile: CLAUDE.md # 相对于项目根目录 + autoFixBranchPrefix: 'codekeeper/fix-' + rulesFile: CLAUDE.md # 相对于项目根目录 astGrepConfig: .claude/rules/sgconfig.yml filter: excludeAuthors: [bot, ci] excludeDrafts: true minChanges: 1 - maxChanges: 300 # 超过则跳过,避免超大 MR 耗尽预算 - excludePaths: # 不审查的路径模式 - - "**/*.md" - - "docs/**" + maxChanges: 300 # 超过则跳过,避免超大 MR 耗尽预算 + excludePaths: # 不审查的路径模式 + - '**/*.md' + - 'docs/**' learning: enabled: true - schedule: "0 2 * * *" # 每天凌晨2点运行学习循环 - patternThreshold: 3 # Boris 的 3 次规则 + schedule: '0 2 * * *' # 每天凌晨2点运行学习循环 + patternThreshold: 3 # Boris 的 3 次规则 updateClaudeMd: true createAstGrepRules: true - lookbackDays: 7 # 扫描最近7天的已合并 MR + lookbackDays: 7 # 扫描最近7天的已合并 MR - id: another-project name: Another Project @@ -264,7 +268,7 @@ projects: ### 审查 Prompt 模板 -``` +```` 你是一个严格的代码审查员,请审查以下代码变更。 ## 项目规则 @@ -276,9 +280,10 @@ projects: ## 变更内容(git diff) ```diff {diff_content} -``` +```` ## 审查要求 + 1. 检查是否违反上述项目规则 2. 检查安全性问题(注入、泄露、越权等) 3. 检查代码质量问题(命名、类型、重复、冗余) @@ -286,24 +291,26 @@ projects: 对每个问题,按以下 JSON 格式输出: { - "findings": [ - { - "severity": "CRITICAL|HIGH|MEDIUM|LOW", - "file": "文件路径", - "line": 行号, - "ruleId": "规则ID(如存在)", - "message": "问题描述", - "suggestion": "具体的修复建议或代码片段" - } - ], - "summary": "审查总结", - "autoFixable": ["可自动修复的问题索引"] +"findings": [ +{ +"severity": "CRITICAL|HIGH|MEDIUM|LOW", +"file": "文件路径", +"line": 行号, +"ruleId": "规则ID(如存在)", +"message": "问题描述", +"suggestion": "具体的修复建议或代码片段" +} +], +"summary": "审查总结", +"autoFixable": ["可自动修复的问题索引"] } + ``` ### 自动修复流程 ``` + 1. reviewer.ts 返回 findings 2. fixer.ts 筛选 autoFixable 的问题 3. 对每个可修复问题: @@ -318,7 +325,8 @@ projects: c. 失败则丢弃修复,记录日志 5. git commit + push 6. 在 MR 评论中回复:"已创建修复分支 {branch},包含自动修复" -``` + +```` ### 与现有基础设施的衔接 @@ -357,23 +365,26 @@ $ launchctl load ~/Library/LaunchAgents/com.codekeeper.review.plist # 6. 查看日志 $ tail -f ~/Logs/codekeeper/codekeeper.log -``` +```` ## Implementation Tasks ### Task 1: 项目脚手架 + - 初始化 npm 项目(TypeScript + Node.js >=22) - 配置 tsconfig、eslint、prettier - 安装依赖:simple-git, node-cron, pino, zod, dotenv - 目录结构创建 ### Task 2: 配置系统 + - Zod schema 定义 projects.yaml 结构 - 配置加载器(支持环境变量插值如 `env:GITLAB_TOKEN`) - 配置验证(本地路径存在性、token 有效性检查) - CLI 命令:`codekeeper register ` 自动扫描项目生成配置 ### Task 3: GitLab API 客户端 + - 封装 GitLab REST API(fetch-based) - MR 查询(列表、详情、diff、changes) - 评论发布(创建 MR note/discussion) @@ -381,18 +392,21 @@ $ tail -f ~/Logs/codekeeper/codekeeper.log - 错误处理和重试 ### Task 4: 项目同步 + - simple-git 封装:clone、fetch、checkout - SSH key 管理(支持 per-project SSH key) - 工作目录隔离(每个项目在独立目录) - 并发控制(避免同时操作同一仓库) ### Task 5: Diff 分析器 + Token 预算 + - git diff 解析(提取变更文件、行号、内容) - Token 估算(基于字符数 × 经验系数) - 分片算法(按路径前缀分组,预算内最大化文件数) - 风险评分(核心文件权重更高) ### Task 6: Prompt 构建器 + AI 审查引擎 + - CLAUDE.md 读取与格式化 - ast-grep 预检结果集成 - Prompt 组装(规则 + diff + 格式要求) @@ -401,6 +415,7 @@ $ tail -f ~/Logs/codekeeper/codekeeper.log - 错误处理(API 失败、解析失败回退) ### Task 7: 自动修复 + - 修复分支创建(基于 source branch) - 文件修改 prompt(原文 + 修改要求 → 新内容) - 本地验证(lint / typecheck 运行) @@ -408,6 +423,7 @@ $ tail -f ~/Logs/codekeeper/codekeeper.log - MR 评论回复 ### Task 8: 学习循环 + - 已合并 MR 评论获取 - 评论分类(关键词匹配 + 简单 NLP) - 频次计数器(SQLite 或 JSON 文件持久化) @@ -416,18 +432,21 @@ $ tail -f ~/Logs/codekeeper/codekeeper.log - 规则更新 MR 创建 ### Task 9: Scheduler + CLI + - node-cron 调度(per-project schedule) - CLI 接口:`codekeeper run`(单次)、`codekeeper daemon`(后台)、`codekeeper status` - 优雅退出(SIGTERM 处理) - 运行状态报告 ### Task 10: Mac 部署 + - install.sh 安装脚本 - launchd plist 模板 - 日志轮转配置 - 健康检查端点(可选 HTTP) ### Task 11: 与 your-project 集成 + - 复用现有 `.claude/rules/security/*.yml` - 复用 `mr-review.sh` 的 diff 获取逻辑 - 适配 your-project 的 CLAUDE.md 格式 @@ -450,11 +469,11 @@ $ tail -f ~/Logs/codekeeper/codekeeper.log ## Risks & Mitigations -| Risk | Mitigation | -|------|-----------| +| Risk | Mitigation | +| ---------------------------- | ----------------------------------------- | | Token 预算耗尽导致审查不完整 | 分片策略 + 预算预警日志 + maxChanges 过滤 | -| 自动修复引入新 bug | 修复后强制运行 lint/typecheck,失败则丢弃 | -| GitLab API 速率限制 | 请求队列 + 指数退避重试 + 速率限制跟踪 | -| CLAUDE.md 规则冲突 | 学习循环只追加不覆盖,人工审核规则更新 MR | -| 多项目并发导致资源争抢 | per-project 队列,全局并发限制 | -| 敏感信息泄露(API key) | 环境变量管理,不提交到 git,日志脱敏 | +| 自动修复引入新 bug | 修复后强制运行 lint/typecheck,失败则丢弃 | +| GitLab API 速率限制 | 请求队列 + 指数退避重试 + 速率限制跟踪 | +| CLAUDE.md 规则冲突 | 学习循环只追加不覆盖,人工审核规则更新 MR | +| 多项目并发导致资源争抢 | per-project 队列,全局并发限制 | +| 敏感信息泄露(API key) | 环境变量管理,不提交到 git,日志脱敏 | diff --git a/src/advance/archive/archive-path.ts b/src/advance/archive/archive-path.ts index f04da42..0ad877b 100755 --- a/src/advance/archive/archive-path.ts +++ b/src/advance/archive/archive-path.ts @@ -28,7 +28,13 @@ export function computeArchivePath(params: ComputeArchivePathParams): string { const ext = extname(safeName); const nameWithoutExt = basename(safeName, ext); while (existingPaths.has(archivePath.toLowerCase())) { - archivePath = join(archiveRoot, safeCategory, safeDocType, yearMonth, `${nameWithoutExt}_${counter}${ext}`); + archivePath = join( + archiveRoot, + safeCategory, + safeDocType, + yearMonth, + `${nameWithoutExt}_${counter}${ext}` + ); counter++; } } @@ -47,12 +53,13 @@ export function toFlaggedArchivePath(archivePath: string): string { } export function sanitizePathSegment(segment: string): string { - return segment - .toLowerCase() - .replace(/[^a-z0-9_-]/g, '-') - .replace(/^-+|-+$/g, '') - .replace(/-+/g, '-') - || 'unknown'; + return ( + segment + .toLowerCase() + .replace(/[^a-z0-9_-]/g, '-') + .replace(/^-+|-+$/g, '') + .replace(/-+/g, '-') || 'unknown' + ); } export function sanitizeFileName(name: string): string { diff --git a/src/advance/archive/dedup-detector.ts b/src/advance/archive/dedup-detector.ts index bf11d32..ba10107 100755 --- a/src/advance/archive/dedup-detector.ts +++ b/src/advance/archive/dedup-detector.ts @@ -39,7 +39,7 @@ export class DedupDetector { const threshold = this.options.threshold ?? 0.7; // 快速路径:哈希完全相同的直接判定为重复 - const exactMatch = candidates.find((c) => c.contentHash === source.contentHash); + const exactMatch = candidates.find(c => c.contentHash === source.contentHash); if (exactMatch) { return { relation: 'duplicate', @@ -52,7 +52,11 @@ export class DedupDetector { // 按与 source 内容长度差异升序预排序,优先对比更可能相关的候选 const sorted = candidates .slice() - .sort((a, b) => Math.abs(a.content.length - source.content.length) - Math.abs(b.content.length - source.content.length)); + .sort( + (a, b) => + Math.abs(a.content.length - source.content.length) - + Math.abs(b.content.length - source.content.length) + ); const limited = sorted.slice(0, maxCandidates); let best: DedupResult = { relation: 'unrelated', reason: '未找到相关候选', confidence: 0 }; diff --git a/src/advance/archive/metadata-extractor.ts b/src/advance/archive/metadata-extractor.ts index a18af64..88e59c4 100755 --- a/src/advance/archive/metadata-extractor.ts +++ b/src/advance/archive/metadata-extractor.ts @@ -74,7 +74,10 @@ function readHeader(filePath: string, maxBytes: number): string { } } -function openFile(filePath: string): { read: (b: Buffer, o: number, l: number, p: number) => { bytesRead: number }; close: () => void } | null { +function openFile(filePath: string): { + read: (b: Buffer, o: number, l: number, p: number) => { bytesRead: number }; + close: () => void; +} | null { try { const fd = openSync(filePath, 'r'); return { @@ -92,8 +95,8 @@ function openFile(filePath: string): { read: (b: Buffer, o: number, l: number, p function tokenizePath(filePath: string): string[] { const parts = filePath.split(/[/\\_-]+/); return parts - .map((p) => p.toLowerCase().replace(/\.[^.]+$/, '')) - .filter((p) => p.length > 1 && !/^[0-9]+$/.test(p)); + .map(p => p.toLowerCase().replace(/\.[^.]+$/, '')) + .filter(p => p.length > 1 && !/^[0-9]+$/.test(p)); } function extractDateHints(fileName: string, _tokens: string[]): string[] { @@ -123,10 +126,18 @@ function classifyByPath(filePath: string, fileName: string, _tokens: string[]): if (lowerPath.includes('weekly') || lowerName.includes('weekly')) { return { category: 'weekly', docType: 'weekly', confidence: 0.9 }; } - if (lowerPath.includes('/docs/design') || lowerPath.includes('\\docs\\design') || lowerPath.includes('/design/')) { + if ( + lowerPath.includes('/docs/design') || + lowerPath.includes('\\docs\\design') || + lowerPath.includes('/design/') + ) { return { category: 'design', docType: 'design', confidence: 0.85 }; } - if (lowerPath.includes('/docs/spec') || lowerPath.includes('\\docs\\spec') || lowerName.includes('spec')) { + if ( + lowerPath.includes('/docs/spec') || + lowerPath.includes('\\docs\\spec') || + lowerName.includes('spec') + ) { return { category: 'design', docType: 'spec', confidence: 0.8 }; } if (lowerPath.includes('memory')) { diff --git a/src/advance/archiver/codegraph-client.ts b/src/advance/archiver/codegraph-client.ts index a77a451..b5b35dd 100644 --- a/src/advance/archiver/codegraph-client.ts +++ b/src/advance/archiver/codegraph-client.ts @@ -138,7 +138,8 @@ export class CodeGraphClient implements ArchiverProviderCoordinator { }); const payload = (await response.json()) as CodeGraphRpcResponse; if (!response.ok || payload.ok !== true) { - const detail = typeof payload.error === 'string' ? payload.error : `HTTP ${response.status}`; + const detail = + typeof payload.error === 'string' ? payload.error : `HTTP ${response.status}`; throw new Error(`CodeGraph Server 请求失败:${detail}`); } return payload.result as T; diff --git a/src/advance/archiver/provider-config.ts b/src/advance/archiver/provider-config.ts index fbab633..bcd7d4e 100644 --- a/src/advance/archiver/provider-config.ts +++ b/src/advance/archiver/provider-config.ts @@ -66,10 +66,7 @@ export function normalizeArchiverProviderStrategy(value: unknown): ArchiverProvi raw.synthesis && typeof raw.synthesis === 'object' ? (raw.synthesis as Record) : undefined; - const synthesisProvider = normalizeText( - synthesisRaw?.provider, - defaults.synthesis.provider - ); + const synthesisProvider = normalizeText(synthesisRaw?.provider, defaults.synthesis.provider); const sources = normalizeProviderIds(raw.sources).filter(id => id !== synthesisProvider); const sourceSet = new Set(sources); const augmenters = normalizeProviderIds(raw.augmenters).filter( @@ -113,8 +110,7 @@ export function toArchiverProviderExecutionStrategy( fallbacks: sources.slice(1), enrichers, builtinFallback: - synthesisProvider === BUILTIN_ARCHIVER_PROVIDER_ID && - knowledge.synthesis.policy !== 'off', + synthesisProvider === BUILTIN_ARCHIVER_PROVIDER_ID && knowledge.synthesis.policy !== 'off', overrides: {}, }; } @@ -144,11 +140,8 @@ export function normalizeArchiverConfig(value: unknown): ArchiverConfig { enabled: typeof automation.enabled === 'boolean' ? automation.enabled - : legacyEnabled ?? defaults.automation.enabled, - cron: normalizeText( - automation.cron, - normalizeText(legacyCron, defaults.automation.cron) - ), + : (legacyEnabled ?? defaults.automation.enabled), + cron: normalizeText(automation.cron, normalizeText(legacyCron, defaults.automation.cron)), }, }; } diff --git a/src/advance/classic/agent-entries/role-entry.ts b/src/advance/classic/agent-entries/role-entry.ts index 5529057..631f86c 100755 --- a/src/advance/classic/agent-entries/role-entry.ts +++ b/src/advance/classic/agent-entries/role-entry.ts @@ -130,7 +130,7 @@ async function main() { syncing = true; try { const enabledProjects = store.getRoleEnabledProjects(config.role); - const enabledIds = new Set(enabledProjects.map((p) => p.id)); + const enabledIds = new Set(enabledProjects.map(p => p.id)); // 停止已禁用或已移除的项目 for (const [projectId, entry] of activeProjects) { @@ -147,7 +147,7 @@ async function main() { console.log(`[Role Agent] 项目 ${project.name} 已启用,启动循环`); activeProjects.set(project.id, { project }); // 异步启动,避免阻塞本次同步 - runner.startProjectLoop(project).catch((err) => { + runner.startProjectLoop(project).catch(err => { console.error(`[Role Agent] 项目 ${project.name} 启动循环失败:`, err); activeProjects.delete(project.id); }); @@ -188,7 +188,7 @@ const isMainModule = process.argv[1] && (process.argv[1].endsWith('role-entry.ts') || process.argv[1].endsWith('role-entry.js')); if (isMainModule) { - main().catch((err) => { + main().catch(err => { console.error(err); process.exit(1); }); diff --git a/src/advance/classic/classic-config-builder.ts b/src/advance/classic/classic-config-builder.ts index 38ca43c..0e5f7d1 100755 --- a/src/advance/classic/classic-config-builder.ts +++ b/src/advance/classic/classic-config-builder.ts @@ -41,7 +41,7 @@ export function buildMrAgentEnv( headers: string; } ): MrAgentEnv { - const mrProjects = projects.filter((p) => p.mrReview?.enabled && p.gitlab); + const mrProjects = projects.filter(p => p.mrReview?.enabled && p.gitlab); return { CK_LLM_API_KEY: daemonConfig.apiKey, CK_LLM_PROVIDER: daemonConfig.provider, diff --git a/src/advance/classic/context/project-context-loader.ts b/src/advance/classic/context/project-context-loader.ts index 20722d5..4eeac54 100755 --- a/src/advance/classic/context/project-context-loader.ts +++ b/src/advance/classic/context/project-context-loader.ts @@ -9,10 +9,7 @@ const DEFAULT_MAX_LENGTH = 3000; * 默认读取归档目录中的 context.md,截取前 N 个字符作为 prompt 上下文。 * 若文件不存在或为空,返回空字符串。 */ -export function loadProjectContext( - archiveRoot: string, - maxLength = DEFAULT_MAX_LENGTH -): string { +export function loadProjectContext(archiveRoot: string, maxLength = DEFAULT_MAX_LENGTH): string { const path = join(archiveRoot, 'context.md'); if (!existsSync(path)) { return ''; diff --git a/src/advance/classic/fix/batch-fix-planner.ts b/src/advance/classic/fix/batch-fix-planner.ts index 485d9d9..4cca228 100755 --- a/src/advance/classic/fix/batch-fix-planner.ts +++ b/src/advance/classic/fix/batch-fix-planner.ts @@ -64,10 +64,7 @@ export class BatchFixPlanner { .join('\n\n'); const fileSections = Object.entries(input.fileContents) - .map( - ([path, content]) => - `## 文件:${path}\n\`\`\`\n${content}\n\`\`\`` - ) + .map(([path, content]) => `## 文件:${path}\n\`\`\`\n${content}\n\`\`\``) .join('\n\n'); return this.promptLoader.load('batch-fix-plan', { @@ -86,11 +83,14 @@ export class BatchFixPlanner { }; const patches = (parsed.patches ?? []) - .filter((p): p is { filePath: string; patch: string } => - typeof p.filePath === 'string' && p.filePath.length > 0 && - typeof p.patch === 'string' && p.patch.length > 0 + .filter( + (p): p is { filePath: string; patch: string } => + typeof p.filePath === 'string' && + p.filePath.length > 0 && + typeof p.patch === 'string' && + p.patch.length > 0 ) - .map((p) => ({ + .map(p => ({ filePath: p.filePath.replace(/\\/g, '/'), patch: p.patch, })); diff --git a/src/advance/classic/fix/cross-file-planner.ts b/src/advance/classic/fix/cross-file-planner.ts index 0aa4645..3c04940 100755 --- a/src/advance/classic/fix/cross-file-planner.ts +++ b/src/advance/classic/fix/cross-file-planner.ts @@ -67,7 +67,7 @@ export class CrossFilePlanner { .filter((p): p is { filePath: string; description: string } => Boolean(p.filePath && p.description) ) - .map((p) => ({ + .map(p => ({ filePath: p.filePath, description: p.description, })); diff --git a/src/advance/classic/fix/focused-context-builder.ts b/src/advance/classic/fix/focused-context-builder.ts index e330efb..1b216df 100755 --- a/src/advance/classic/fix/focused-context-builder.ts +++ b/src/advance/classic/fix/focused-context-builder.ts @@ -130,7 +130,7 @@ function isImportOrRequire(line: string): boolean { return ( trimmed.startsWith('import ') || trimmed.startsWith('require(') || - trimmed.startsWith('const ') && trimmed.includes(' = require(') + (trimmed.startsWith('const ') && trimmed.includes(' = require(')) ); } diff --git a/src/advance/classic/fix/issue-scope.ts b/src/advance/classic/fix/issue-scope.ts index 677a2cf..317347b 100755 --- a/src/advance/classic/fix/issue-scope.ts +++ b/src/advance/classic/fix/issue-scope.ts @@ -30,7 +30,8 @@ export interface ScopeClassifierOptions { promptLoader?: PromptLoader; } -const CONTROL_FLOW_KEYWORDS = /\b(if|else|for|while|switch|case|try|catch|finally|return|throw|async|await|function|class|interface|type)\b/; +const CONTROL_FLOW_KEYWORDS = + /\b(if|else|for|while|switch|case|try|catch|finally|return|throw|async|await|function|class|interface|type)\b/; export class IssueScopeClassifier { private readonly promptLoader: PromptLoader; @@ -45,14 +46,11 @@ export class IssueScopeClassifier { return heuristic; } - if ( - this.options.localJudge && - typeof this.options.localJudge.preFilterScope === 'function' - ) { + if (this.options.localJudge && typeof this.options.localJudge.preFilterScope === 'function') { const verdict = await this.options.localJudge.preFilterScope( finding.message, finding.file, - finding.line, + finding.line ); if ('kind' in verdict && verdict.kind === 'reliable') { if (verdict.scope !== 'local') { @@ -113,7 +111,8 @@ export class IssueScopeClassifier { if (/添加\s*todo|todo\s*注释|缺少\s*注释|加注释/.test(text)) return true; // 缓存环境变量或常量 if (/缓存\s*(环境变量|常量|变量值)|const\s+\w+\s*=\s*!!?process\.env/.test(text)) return true; - if (text.includes('缓存') && (text.includes('环境变量') || text.includes('process.env'))) return true; + if (text.includes('缓存') && (text.includes('环境变量') || text.includes('process.env'))) + return true; // 添加可选字段 / 单个字段补全 if (/添加\s*(可选的?)?\s*字段|加\s*(可选的?)?\s*字段|\?\s*:/.test(text)) return true; // 拼写 / 命名 @@ -131,7 +130,11 @@ export class IssueScopeClassifier { // 规则 ID 直接提示是文档/注释类 const ruleId = (finding.ruleId ?? '').toUpperCase(); - if (['TODO', 'DOCUMENTATION', 'COMMENT', 'CHANGE-MANAGEMENT', 'PERFORMANCE'].some((r) => ruleId.includes(r))) { + if ( + ['TODO', 'DOCUMENTATION', 'COMMENT', 'CHANGE-MANAGEMENT', 'PERFORMANCE'].some(r => + ruleId.includes(r) + ) + ) { return true; } @@ -145,7 +148,7 @@ export class IssueScopeClassifier { if (/多个文件|跨文件|cross.file/.test(text)) return true; const ruleId = (finding.ruleId ?? '').toUpperCase(); - if (['TYPE-SAFETY', 'API-COMPATIBILITY', 'BREAKING-CHANGE'].some((r) => ruleId.includes(r))) { + if (['TYPE-SAFETY', 'API-COMPATIBILITY', 'BREAKING-CHANGE'].some(r => ruleId.includes(r))) { return true; } diff --git a/src/advance/classic/fix/maintainer-brain.ts b/src/advance/classic/fix/maintainer-brain.ts index 5596c7e..faacced 100755 --- a/src/advance/classic/fix/maintainer-brain.ts +++ b/src/advance/classic/fix/maintainer-brain.ts @@ -434,7 +434,7 @@ export class MaintainerBrain { ) { const verdict = await this.options.localJudge.preFilterNonFindingDiscussion( params.body, - undefined, + undefined ); if ('kind' in verdict && verdict.kind === 'reliable') { if (verdict.isProbablyNonFinding) { diff --git a/src/advance/classic/fix/maintainer-llm-judge.ts b/src/advance/classic/fix/maintainer-llm-judge.ts index 860f254..1e91ff3 100644 --- a/src/advance/classic/fix/maintainer-llm-judge.ts +++ b/src/advance/classic/fix/maintainer-llm-judge.ts @@ -1,57 +1,398 @@ /** * 基于 LlmClient 的轻量判别辅助实现 * * 目标: * - 在少数高杠杆语义判断点提供结构化辅助判别 * - 使用现有的 LlmClient,调用小 prompt、小输出 * - 不可靠/失败时回退为不可靠,不改变 Maintainer 既有行为 */ -import { LlmClient } from '../../llm/client.js'; import type { MaintainerLocalJudge, LocalJudgeVerdict, SemanticReidentificationResult, StuckCorrectionResult, AlreadyFixedAssistanceResult, PreFilterScopeVerdict, PreFilterNonFindingVerdict, } from './maintainer-local-judge.js'; +import { LlmClient } from '../../llm/client.js'; +import type { + MaintainerLocalJudge, + LocalJudgeVerdict, + SemanticReidentificationResult, + StuckCorrectionResult, + AlreadyFixedAssistanceResult, + PreFilterScopeVerdict, + PreFilterNonFindingVerdict, +} from './maintainer-local-judge.js'; -interface ReidentifyPromptPayload { currentDescription: string; previousDecisionSummary: string; fileContextHint?: string; } +interface ReidentifyPromptPayload { + currentDescription: string; + previousDecisionSummary: string; + fileContextHint?: string; +} -interface StuckPromptPayload { findingDescription: string; recentProgressSummary: string; attemptedDirectionsSummary?: string; } +interface StuckPromptPayload { + findingDescription: string; + recentProgressSummary: string; + attemptedDirectionsSummary?: string; +} -interface AlreadyFixedPromptPayload { findingDescription: string; currentCodeContextHint?: string; } +interface AlreadyFixedPromptPayload { + findingDescription: string; + currentCodeContextHint?: string; +} -interface PreFilterScopePromptPayload { findingDescription: string; findingFile?: string; findingLine?: number; } +interface PreFilterScopePromptPayload { + findingDescription: string; + findingFile?: string; + findingLine?: number; +} -interface PreFilterNonFindingPromptPayload { discussionBody: string; discussionNoteCount?: number; } +interface PreFilterNonFindingPromptPayload { + discussionBody: string; + discussionNoteCount?: number; +} -/** * 基于 LlmClient 的 Maintainer 判别辅助实现 */ export class LlmMaintainerLocalJudge implements MaintainerLocalJudge { constructor(private llmClient: LlmClient) {} +/** * 基于 LlmClient 的 Maintainer 判别辅助实现 */ export class LlmMaintainerLocalJudge implements MaintainerLocalJudge { + constructor(private llmClient: LlmClient) {} -isAvailable(): boolean { return true; } + isAvailable(): boolean { + return true; + } -async reassessSemanticIdentity( currentFindingDescription: string, previousDecisionSummary: string, fileContextHint?: string, ): Promise { const payload: ReidentifyPromptPayload = { currentDescription: currentFindingDescription, previousDecisionSummary, fileContextHint, }; try { const json = await this.llmClient.completeJson( this.buildReidentifyPrompt(payload), this.reidentifySystem(), { type: 'object', properties: { likelySame: { type: 'boolean' }, confidence: { type: 'string', enum: ['high', 'medium', 'low'] }, reason: { type: 'string' }, }, required: ['likelySame', 'confidence', 'reason'], }, ); const body = this.parseSimpleJson(json); if (!body || typeof body.likelySame !== 'boolean') { return { kind: 'unreliable', reason: 'LLM 返回了不可解析的语义重识别结果', }; } const confidence = this.normalizeConfidence(body.confidence); return { likelySame: body.likelySame, confidence, reason: typeof body.reason === 'string' ? body.reason : '', }; } catch (error) { return { kind: 'unreliable', reason: this.wrapError(error), }; } } + async reassessSemanticIdentity( + currentFindingDescription: string, + previousDecisionSummary: string, + fileContextHint?: string + ): Promise { + const payload: ReidentifyPromptPayload = { + currentDescription: currentFindingDescription, + previousDecisionSummary, + fileContextHint, + }; + try { + const json = await this.llmClient.completeJson( + this.buildReidentifyPrompt(payload), + this.reidentifySystem(), + { + type: 'object', + properties: { + likelySame: { type: 'boolean' }, + confidence: { type: 'string', enum: ['high', 'medium', 'low'] }, + reason: { type: 'string' }, + }, + required: ['likelySame', 'confidence', 'reason'], + } + ); + const body = this.parseSimpleJson(json); + if (!body || typeof body.likelySame !== 'boolean') { + return { kind: 'unreliable', reason: 'LLM 返回了不可解析的语义重识别结果' }; + } + const confidence = this.normalizeConfidence(body.confidence); + return { + likelySame: body.likelySame, + confidence, + reason: typeof body.reason === 'string' ? body.reason : '', + }; + } catch (error) { + return { kind: 'unreliable', reason: this.wrapError(error) }; + } + } -async adviseOnStuckProgress( findingDescription: string, recentProgressSummary: string, attemptedDirectionsSummary?: string, ): Promise { const payload: StuckPromptPayload = { findingDescription, recentProgressSummary, attemptedDirectionsSummary, }; try { const json = await this.llmClient.completeJson( this.buildStuckPrompt(payload), this.stuckSystem(), { type: 'object', properties: { suggestion: { type: 'string', enum: ['continue', 'refocus', 'broaden', 'stop'] }, suggestStop: { type: 'boolean' }, reason: { type: 'string' }, }, required: ['suggestion', 'suggestStop', 'reason'], }, ); const body = this.parseSimpleJson(json); if (!body || typeof body.suggestion !== 'string' || !['continue', 'refocus', 'broaden', 'stop'].includes(body.suggestion)) { return { kind: 'unreliable', reason: 'LLM 返回了不可解析的卡点校正结果', }; } return { suggestion: body.suggestion as StuckCorrectionResult['suggestion'], suggestStop: Boolean(body.suggestStop), reason: typeof body.reason === 'string' ? body.reason : '', }; } catch (error) { return { kind: 'unreliable', reason: this.wrapError(error), }; } } + async adviseOnStuckProgress( + findingDescription: string, + recentProgressSummary: string, + attemptedDirectionsSummary?: string + ): Promise { + const payload: StuckPromptPayload = { + findingDescription, + recentProgressSummary, + attemptedDirectionsSummary, + }; + try { + const json = await this.llmClient.completeJson( + this.buildStuckPrompt(payload), + this.stuckSystem(), + { + type: 'object', + properties: { + suggestion: { type: 'string', enum: ['continue', 'refocus', 'broaden', 'stop'] }, + suggestStop: { type: 'boolean' }, + reason: { type: 'string' }, + }, + required: ['suggestion', 'suggestStop', 'reason'], + } + ); + const body = this.parseSimpleJson(json); + if ( + !body || + typeof body.suggestion !== 'string' || + !['continue', 'refocus', 'broaden', 'stop'].includes(body.suggestion) + ) { + return { kind: 'unreliable', reason: 'LLM 返回了不可解析的卡点校正结果' }; + } + return { + suggestion: body.suggestion as StuckCorrectionResult['suggestion'], + suggestStop: Boolean(body.suggestStop), + reason: typeof body.reason === 'string' ? body.reason : '', + }; + } catch (error) { + return { kind: 'unreliable', reason: this.wrapError(error) }; + } + } -async assistAlreadyFixedCheck( findingDescription: string, currentCodeContextHint?: string, ): Promise { const payload: AlreadyFixedPromptPayload = { findingDescription, currentCodeContextHint, }; try { const json = await this.llmClient.completeJson( this.buildAlreadyFixedPrompt(payload), this.alreadyFixedSystem(), { type: 'object', properties: { likelyAlreadyFixed: { type: 'boolean' }, reason: { type: 'string' }, evidence: { type: 'string' }, }, required: ['likelyAlreadyFixed', 'reason'], }, ); const body = this.parseSimpleJson(json); if (!body || typeof body.likelyAlreadyFixed !== 'boolean') { return { kind: 'unreliable', reason: 'LLM 返回了不可解析的 already-fixed 辅助结果', }; } return { likelyAlreadyFixed: body.likelyAlreadyFixed, reason: typeof body.reason === 'string' ? body.reason : '', evidence: typeof body.evidence === 'string' ? body.evidence : undefined, }; } catch (error) { return { kind: 'unreliable', reason: this.wrapError(error), }; } } + async assistAlreadyFixedCheck( + findingDescription: string, + currentCodeContextHint?: string + ): Promise { + const payload: AlreadyFixedPromptPayload = { findingDescription, currentCodeContextHint }; + try { + const json = await this.llmClient.completeJson( + this.buildAlreadyFixedPrompt(payload), + this.alreadyFixedSystem(), + { + type: 'object', + properties: { + likelyAlreadyFixed: { type: 'boolean' }, + reason: { type: 'string' }, + evidence: { type: 'string' }, + }, + required: ['likelyAlreadyFixed', 'reason'], + } + ); + const body = this.parseSimpleJson(json); + if (!body || typeof body.likelyAlreadyFixed !== 'boolean') { + return { kind: 'unreliable', reason: 'LLM 返回了不可解析的 already-fixed 辅助结果' }; + } + return { + likelyAlreadyFixed: body.likelyAlreadyFixed, + reason: typeof body.reason === 'string' ? body.reason : '', + evidence: typeof body.evidence === 'string' ? body.evidence : undefined, + }; + } catch (error) { + return { kind: 'unreliable', reason: this.wrapError(error) }; + } + } -async preFilterScope( findingDescription: string, findingFile?: string, findingLine?: number, ): Promise { const payload: PreFilterScopePromptPayload = { findingDescription, findingFile, findingLine, }; try { const json = await this.llmClient.completeJson( this.buildPreFilterScopePrompt(payload), this.preFilterScopeSystem(), { type: 'object', properties: { scope: { type: 'string', enum: ['trivial', 'local', 'cross-file', 'needs-clarification'] }, reason: { type: 'string' }, }, required: ['scope', 'reason'], }, ); const body = this.parseSimpleJson(json); if (!body || typeof body.scope !== 'string') { return { kind: 'unreliable', reason: 'LLM 返回了不可解析的 scope 初筛结果', }; } if (!['trivial', 'local', 'cross-file', 'needs-clarification'].includes(body.scope)) { return { kind: 'unreliable', reason: `LLM 返回了无效的 scope 值: ${String(body.scope)}`, }; } return { kind: 'reliable', scope: body.scope as 'trivial' | 'local' | 'cross-file' | 'needs-clarification', reason: typeof body.reason === 'string' ? body.reason : '', }; } catch (error) { return { kind: 'unreliable', reason: this.wrapError(error), }; } } + async preFilterScope( + findingDescription: string, + findingFile?: string, + findingLine?: number + ): Promise { + const payload: PreFilterScopePromptPayload = { findingDescription, findingFile, findingLine }; + try { + const json = await this.llmClient.completeJson( + this.buildPreFilterScopePrompt(payload), + this.preFilterScopeSystem(), + { + type: 'object', + properties: { + scope: { + type: 'string', + enum: ['trivial', 'local', 'cross-file', 'needs-clarification'], + }, + reason: { type: 'string' }, + }, + required: ['scope', 'reason'], + } + ); + const body = this.parseSimpleJson(json); + if (!body || typeof body.scope !== 'string') { + return { kind: 'unreliable', reason: 'LLM 返回了不可解析的 scope 初筛结果' }; + } + if (!['trivial', 'local', 'cross-file', 'needs-clarification'].includes(body.scope)) { + return { kind: 'unreliable', reason: `LLM 返回了无效的 scope 值: ${String(body.scope)}` }; + } + return { + kind: 'reliable', + scope: body.scope as 'trivial' | 'local' | 'cross-file' | 'needs-clarification', + reason: typeof body.reason === 'string' ? body.reason : '', + }; + } catch (error) { + return { kind: 'unreliable', reason: this.wrapError(error) }; + } + } -async preFilterNonFindingDiscussion( discussionBody: string, discussionNoteCount?: number, ): Promise { const payload: PreFilterNonFindingPromptPayload = { discussionBody, discussionNoteCount, }; try { const json = await this.llmClient.completeJson( this.buildPreFilterNonFindingPrompt(payload), this.preFilterNonFindingSystem(), { type: 'object', properties: { isProbablyNonFinding: { type: 'boolean' }, reason: { type: 'string' }, }, required: ['isProbablyNonFinding', 'reason'], }, ); const body = this.parseSimpleJson(json); if (!body || typeof body.isProbablyNonFinding !== 'boolean') { return { kind: 'unreliable', reason: 'LLM 返回了不可解析的非 finding 过滤结果', }; } return { kind: 'reliable', isProbablyNonFinding: body.isProbablyNonFinding, reason: typeof body.reason === 'string' ? body.reason : '', }; } catch (error) { return { kind: 'unreliable', reason: this.wrapError(error), }; } } + async preFilterNonFindingDiscussion( + discussionBody: string, + discussionNoteCount?: number + ): Promise { + const payload: PreFilterNonFindingPromptPayload = { discussionBody, discussionNoteCount }; + try { + const json = await this.llmClient.completeJson( + this.buildPreFilterNonFindingPrompt(payload), + this.preFilterNonFindingSystem(), + { + type: 'object', + properties: { isProbablyNonFinding: { type: 'boolean' }, reason: { type: 'string' } }, + required: ['isProbablyNonFinding', 'reason'], + } + ); + const body = this.parseSimpleJson(json); + if (!body || typeof body.isProbablyNonFinding !== 'boolean') { + return { kind: 'unreliable', reason: 'LLM 返回了不可解析的非 finding 过滤结果' }; + } + return { + kind: 'reliable', + isProbablyNonFinding: body.isProbablyNonFinding, + reason: typeof body.reason === 'string' ? body.reason : '', + }; + } catch (error) { + return { kind: 'unreliable', reason: this.wrapError(error) }; + } + } -// ---------- 提示构造 ---------- + // ---------- 提示构造 ---------- -private buildPreFilterScopePrompt(payload: PreFilterScopePromptPayload): string { const loc = payload.findingFile ? `文件:${payload.findingFile}${payload.findingLine ? `, 行:${payload.findingLine}` : ''}` : '(文件定位信息未提供)'; return [ '你正在帮助维护者判断一个审查问题的大致范围(前置初筛)。', '目标是辅助判断该问题更可能属于:trivial / local / cross-file / needs-clarification。', '', `位置信息:${loc}`, '', '问题描述:', payload.findingDescription, '', '范围定义:', '- trivial:单行、小范围、注释/TODO/命名/常量补全之类,几乎不影响其他代码。', '- local:集中在单个文件或一个小范围内,修复边界清楚。', '- cross-file:涉及类型/接口/签名变更或明显影响多个调用点,可能需要多文件一起动。', '- needs-clarification:关键信息(文件/行/意图)不足以给出范围结论。', '', '规则:', '- 如果位置信息缺失或问题描述过短/模糊,倾向于 needs-clarification。', '- 如果明显影响接口、签名、导出或多个调用点,倾向于 cross-file。', '- 如果只能看到单行/字段/注释/常量层面的修改,且无控制流或类型扩散迹象,倾向于 trivial。', '- 其余则判为 local。', '', '请返回 JSON,包含 scope(one of trivial/local/cross-file/needs-clarification)、reason(字符串)。', ].join('\\\\n'); } + private buildPreFilterScopePrompt(payload: PreFilterScopePromptPayload): string { + const loc = payload.findingFile + ? `文件:${payload.findingFile}${payload.findingLine ? `, 行:${payload.findingLine}` : ''}` + : '(文件定位信息未提供)'; + return [ + '你正在帮助维护者判断一个审查问题的大致范围(前置初筛)。', + '目标是辅助判断该问题更可能属于:trivial / local / cross-file / needs-clarification。', + '', + `位置信息:${loc}`, + '', + '问题描述:', + payload.findingDescription, + '', + '范围定义:', + '- trivial:单行、小范围、注释/TODO/命名/常量补全之类,几乎不影响其他代码。', + '- local:集中在单个文件或一个小范围内,修复边界清楚。', + '- cross-file:涉及类型/接口/签名变更或明显影响多个调用点,可能需要多文件一起动。', + '- needs-clarification:关键信息(文件/行/意图)不足以给出范围结论。', + '', + '规则:', + '- 如果位置信息缺失或问题描述过短/模糊,倾向于 needs-clarification。', + '- 如果明显影响接口、签名、导出或多个调用点,倾向于 cross-file。', + '- 如果只能看到单行/字段/注释/常量层面的修改,且无控制流或类型扩散迹象,倾向于 trivial。', + '- 其余则判为 local。', + '', + '请返回 JSON,包含 scope(one of trivial/local/cross-file/needs-clarification)、reason(字符串)。', + ].join('\\\\n'); + } -private buildPreFilterNonFindingPrompt( payload: PreFilterNonFindingPromptPayload, ): string { const noteCount = payload.discussionNoteCount != null ? `(讨论 note 数量:${payload.discussionNoteCount})` : ''; return [ '你正在帮助维护者判断:一条 MR discussion 是否很可能不是待逐条修复的代码问题。', '', '讨论正文:', payload.discussionBody, noteCount, '', '判断规则:', '- 如果正文只询问/提示而不给出具体文件行号的问题,就倾向于 isProbablyNonFinding=true。', '- 如果正文指向具体文件和行号,且表达的是可操作的代码问题,就倾向于 isProbablyNonFinding=false。', '- 如果信息不足以判断,保守返回 isProbablyNonFinding=false。', '', '请返回 JSON,包含 isProbablyNonFinding(boolean)、reason(字符串)。', ].join('\\\\n'); } + private buildPreFilterNonFindingPrompt(payload: PreFilterNonFindingPromptPayload): string { + const noteCount = + payload.discussionNoteCount != null + ? `(讨论 note 数量:${payload.discussionNoteCount})` + : ''; + return [ + '你正在帮助维护者判断:一条 MR discussion 是否很可能不是待逐条修复的代码问题。', + '', + '讨论正文:', + payload.discussionBody, + noteCount, + '', + '判断规则:', + '- 如果正文只询问/提示而不给出具体文件行号的问题,就倾向于 isProbablyNonFinding=true。', + '- 如果正文指向具体文件和行号,且表达的是可操作的代码问题,就倾向于 isProbablyNonFinding=false。', + '- 如果信息不足以判断,保守返回 isProbablyNonFinding=false。', + '', + '请返回 JSON,包含 isProbablyNonFinding(boolean)、reason(字符串)。', + ].join('\\\\n'); + } -private preFilterScopeSystem(): string { return '你是一个保守的 scope 初筛辅助。只在理由充分时才给出 non-trivial/non-cross-file 的范围结论;信息不足时不要编造范围。'; } + private preFilterScopeSystem(): string { + return '你是一个保守的 scope 初筛辅助。只在理由充分时才给出 non-trivial/non-cross-file 的范围结论;信息不足时不要编造范围。'; + } -private preFilterNonFindingSystem(): string { return '你是一个保守的非 finding 过滤辅助。只有在有明确理由时才标记 isProbablyNonFinding=true;怀疑是具体代码问题时,就返回 false。'; } + private preFilterNonFindingSystem(): string { + return '你是一个保守的非 finding 过滤辅助。只有在有明确理由时才标记 isProbablyNonFinding=true;怀疑是具体代码问题时,就返回 false。'; + } -private buildReidentifyPrompt(payload: ReidentifyPromptPayload): string { const ctx = payload.fileContextHint ? `\\n\\n当前文件上下文提示:\\n${payload.fileContextHint}` : ''; return [ '你正在帮助维护者判断:同一个代码审查问题是否可能已经在之前的轮次中被处理过。', '请根据当前发现描述和之前决策摘要,判断二者是否可能是同一个语义问题。', '', '当前发现描述:', payload.currentDescription, '', '之前决策摘要:', payload.previousDecisionSummary, ctx, '', '规则:', '- 如果之前的决策是 ignore/ignore(alreadyFixed),且当前描述表达的是同样的意图/位置/问题,请倾向认为是同一语义问题。', '- 如果当前描述明显涉及不同问题、不同意图、不同位置,则不应该认为是同一语义问题。', '- 仅凭行号相同不足以断定同一语义问题;行号不同也不应直接否定同一语义问题。', '- 如果信息不足以判断,请返回 likelySame=false 并且 confidence=low。', '', '请返回 JSON,包含 likelySame(boolean)、confidence(one of high/medium/low)、reason(字符串)。', ].join('\\\\n'); } + private buildReidentifyPrompt(payload: ReidentifyPromptPayload): string { + const ctx = payload.fileContextHint + ? `\\n\\n当前文件上下文提示:\\n${payload.fileContextHint}` + : ''; + return [ + '你正在帮助维护者判断:同一个代码审查问题是否可能已经在之前的轮次中被处理过。', + '请根据当前发现描述和之前决策摘要,判断二者是否可能是同一个语义问题。', + '', + '当前发现描述:', + payload.currentDescription, + '', + '之前决策摘要:', + payload.previousDecisionSummary, + ctx, + '', + '规则:', + '- 如果之前的决策是 ignore/ignore(alreadyFixed),且当前描述表达的是同样的意图/位置/问题,请倾向认为是同一语义问题。', + '- 如果当前描述明显涉及不同问题、不同意图、不同位置,则不应该认为是同一语义问题。', + '- 仅凭行号相同不足以断定同一语义问题;行号不同也不应直接否定同一语义问题。', + '- 如果信息不足以判断,请返回 likelySame=false 并且 confidence=low。', + '', + '请返回 JSON,包含 likelySame(boolean)、confidence(one of high/medium/low)、reason(字符串)。', + ].join('\\\\n'); + } -private buildStuckPrompt(payload: StuckPromptPayload): string { const attempted = payload.attemptedDirectionsSummary ? `\\n\\n已尝试的方向总结:\\n${payload.attemptedDirectionsSummary}` : ''; return [ '你正在帮助维护者判断:当前修复循环是否应该继续当前方向、换范围、扩大范围,或收拢尝试。', '', '问题描述:', payload.findingDescription, '', '最近进度总结:', payload.recentProgressSummary, attempted, '', '建议动作之一:', '- continue:当前方向仍有意义,继续。', '- refocus:当前方向不集中,建议缩小/重新聚焦到更明确的目标。', '- broaden:当前范围太窄,建议扩大到更多相关文件/行。', '- stop:当前信息下不再继续有意义,建议收拢或换方式。', '', '请返回 JSON,包含 suggestion(one of continue/refocus/broaden/stop)、suggestStop(boolean)、reason(字符串)。', ].join('\\\\n'); } + private buildStuckPrompt(payload: StuckPromptPayload): string { + const attempted = payload.attemptedDirectionsSummary + ? `\\n\\n已尝试的方向总结:\\n${payload.attemptedDirectionsSummary}` + : ''; + return [ + '你正在帮助维护者判断:当前修复循环是否应该继续当前方向、换范围、扩大范围,或收拢尝试。', + '', + '问题描述:', + payload.findingDescription, + '', + '最近进度总结:', + payload.recentProgressSummary, + attempted, + '', + '建议动作之一:', + '- continue:当前方向仍有意义,继续。', + '- refocus:当前方向不集中,建议缩小/重新聚焦到更明确的目标。', + '- broaden:当前范围太窄,建议扩大到更多相关文件/行。', + '- stop:当前信息下不再继续有意义,建议收拢或换方式。', + '', + '请返回 JSON,包含 suggestion(one of continue/refocus/broaden/stop)、suggestStop(boolean)、reason(字符串)。', + ].join('\\\\n'); + } -private buildAlreadyFixedPrompt(payload: AlreadyFixedPromptPayload): string { const ctx = payload.currentCodeContextHint ? `\\n\\n当前代码上下文提示:\\n${payload.currentCodeContextHint}` : ''; return [ '你正在帮助维护者判断:某个审查问题是否已经在当前代码中不再存在。', '', '问题描述:', payload.findingDescription, ctx, '', '请回答:该问题是否可能已经在当前代码中不再存在。', '只返回 JSON,包含:', '- likelyAlreadyFixed(boolean)', '- reason(字符串)', '- evidence(可选字符串,仅在你确信该片段能作为最小证据时提供)', '', '注意:如果信息不足,不要武断返回 already-fixed。', ].join('\\\\n'); } + private buildAlreadyFixedPrompt(payload: AlreadyFixedPromptPayload): string { + const ctx = payload.currentCodeContextHint + ? `\\n\\n当前代码上下文提示:\\n${payload.currentCodeContextHint}` + : ''; + return [ + '你正在帮助维护者判断:某个审查问题是否已经在当前代码中不再存在。', + '', + '问题描述:', + payload.findingDescription, + ctx, + '', + '请回答:该问题是否可能已经在当前代码中不再存在。', + '只返回 JSON,包含:', + '- likelyAlreadyFixed(boolean)', + '- reason(字符串)', + '- evidence(可选字符串,仅在你确信该片段能作为最小证据时提供)', + '', + '注意:如果信息不足,不要武断返回 already-fixed。', + ].join('\\\\n'); + } -// ---------- system / helper ---------- + // ---------- system / helper ---------- -private reidentifySystem(): string { return '你是一个保守的语义重识别辅助。目标是帮助判断同一语义问题是否已被处理,而不是直接代替人类决策。只有在理由充分时才标记 likelySame=true,并把置信度控制在 reasonable 范围。'; } + private reidentifySystem(): string { + return '你是一个保守的语义重识别辅助。目标是帮助判断同一语义问题是否已被处理,而不是直接代替人类决策。只有在理由充分时才标记 likelySame=true,并把置信度控制在 reasonable 范围。'; + } -private stuckSystem(): string { return '你是一个保守的卡点校正辅助。不要鼓励无限继续;如果信息不足或方向不明确,倾向于 suggestStop=true。'; } + private stuckSystem(): string { + return '你是一个保守的卡点校正辅助。不要鼓励无限继续;如果信息不足或方向不明确,倾向于 suggestStop=true。'; + } -private alreadyFixedSystem(): string { return '你是一个保守的 already-fixed 辅助。只在有理由时标记 likelyAlreadyFixed=true,且不要超过你能从输入中看到的范围。'; } + private alreadyFixedSystem(): string { + return '你是一个保守的 already-fixed 辅助。只在有理由时标记 likelyAlreadyFixed=true,且不要超过你能从输入中看到的范围。'; + } -private parseSimpleJson(text: string): Record | null { try { const parsed = JSON.parse(text) as Record; if (parsed && typeof parsed === 'object') { return parsed; } return null; } catch { return null; } } + private parseSimpleJson(text: string): Record | null { + try { + const parsed = JSON.parse(text) as Record; + if (parsed && typeof parsed === 'object') { + return parsed; + } + return null; + } catch { + return null; + } + } -private normalizeConfidence(value: unknown): 'high' | 'medium' | 'low' { if (value === 'high' || value === 'medium' || value === 'low') { return value as 'high' | 'medium' | 'low'; } return 'low'; } + private normalizeConfidence(value: unknown): 'high' | 'medium' | 'low' { + if (value === 'high' || value === 'medium' || value === 'low') { + return value as 'high' | 'medium' | 'low'; + } + return 'low'; + } -private wrapError(error: unknown): string { const message = error instanceof Error ? error.message : String(error); return `LLM 判别辅助失败: ${message}`; } } + private wrapError(error: unknown): string { + const message = error instanceof Error ? error.message : String(error); + return `LLM 判别辅助失败: ${message}`; + } +} diff --git a/src/advance/classic/fix/maintainer-local-judge-stub.ts b/src/advance/classic/fix/maintainer-local-judge-stub.ts index 0021de3..9bcbe57 100644 --- a/src/advance/classic/fix/maintainer-local-judge-stub.ts +++ b/src/advance/classic/fix/maintainer-local-judge-stub.ts @@ -6,7 +6,15 @@ * - 后续可替换为本地轻量模型实现,且无需修改调用方 */ -import type { MaintainerLocalJudge, LocalJudgeVerdict, SemanticReidentificationResult, StuckCorrectionResult, AlreadyFixedAssistanceResult, PreFilterScopeVerdict, PreFilterNonFindingVerdict } from './maintainer-local-judge.js'; +import type { + MaintainerLocalJudge, + LocalJudgeVerdict, + SemanticReidentificationResult, + StuckCorrectionResult, + AlreadyFixedAssistanceResult, + PreFilterScopeVerdict, + PreFilterNonFindingVerdict, +} from './maintainer-local-judge.js'; export class ConservativeLocalJudgeStub implements MaintainerLocalJudge { /** 当前桩始终可用(避免调用方因“不可用”而改变流程),但判定均不可靠 */ @@ -17,7 +25,7 @@ export class ConservativeLocalJudgeStub implements MaintainerLocalJudge { reassessSemanticIdentity( _currentFindingDescription: string, _previousDecisionSummary: string, - _fileContextHint?: string, + _fileContextHint?: string ): Promise { return Promise.resolve({ kind: 'unreliable', @@ -28,7 +36,7 @@ export class ConservativeLocalJudgeStub implements MaintainerLocalJudge { adviseOnStuckProgress( _findingDescription: string, _recentProgressSummary: string, - _attemptedDirectionsSummary?: string, + _attemptedDirectionsSummary?: string ): Promise { return Promise.resolve({ kind: 'unreliable', @@ -38,7 +46,7 @@ export class ConservativeLocalJudgeStub implements MaintainerLocalJudge { assistAlreadyFixedCheck( _findingDescription: string, - _currentCodeContextHint?: string, + _currentCodeContextHint?: string ): Promise { return Promise.resolve({ kind: 'unreliable', @@ -49,7 +57,7 @@ export class ConservativeLocalJudgeStub implements MaintainerLocalJudge { preFilterScope( _findingDescription: string, _findingFile?: string, - _findingLine?: number, + _findingLine?: number ): Promise { return Promise.resolve({ kind: 'unreliable', @@ -59,7 +67,7 @@ export class ConservativeLocalJudgeStub implements MaintainerLocalJudge { preFilterNonFindingDiscussion( _discussionBody: string, - _discussionNoteCount?: number, + _discussionNoteCount?: number ): Promise { return Promise.resolve({ kind: 'unreliable', diff --git a/src/advance/classic/fix/patch-applier.ts b/src/advance/classic/fix/patch-applier.ts index ffc522b..9724b7a 100755 --- a/src/advance/classic/fix/patch-applier.ts +++ b/src/advance/classic/fix/patch-applier.ts @@ -157,8 +157,8 @@ export function applyPatch(originalContent: string, filePatch: FilePatch): Apply }; } - const oldLines = hunk.lines.filter((l) => l.type !== 'add').map((l) => l.content); - const replacement = hunk.lines.filter((l) => l.type !== 'remove').map((l) => l.content); + const oldLines = hunk.lines.filter(l => l.type !== 'add').map(l => l.content); + const replacement = hunk.lines.filter(l => l.type !== 'remove').map(l => l.content); result.splice(pos, oldLines.length, ...replacement); offset += replacement.length - oldLines.length; } @@ -181,7 +181,7 @@ function findHunkPosition( hintIndex: number, windowSize = 20 ): number { - const oldLines = hunk.lines.filter((l) => l.type !== 'add').map((l) => l.content); + const oldLines = hunk.lines.filter(l => l.type !== 'add').map(l => l.content); if (oldLines.length === 0) { // 没有旧内容需要匹配时,直接返回 hint return Math.max(0, Math.min(hintIndex, fileLines.length)); diff --git a/src/advance/classic/fix/tools/setup-command-safety-filter.ts b/src/advance/classic/fix/tools/setup-command-safety-filter.ts index c34d212..925f025 100755 --- a/src/advance/classic/fix/tools/setup-command-safety-filter.ts +++ b/src/advance/classic/fix/tools/setup-command-safety-filter.ts @@ -5,15 +5,25 @@ */ export class SetupCommandSafetyFilter { private readonly dangerousPatterns = [ - /;/, /&&/, /\|/, />/, //, + / MAX_READ_OUTPUT_FILE_SIZE && tailLines === undefined) { - throw new Error( - `输出文件过大(${stats.size} bytes),请使用 tailLines 参数读取尾部关键内容` - ); + throw new Error(`输出文件过大(${stats.size} bytes),请使用 tailLines 参数读取尾部关键内容`); } if (tailLines === undefined || tailLines <= 0) { @@ -191,7 +196,11 @@ function formatValidateResult( lint: result.lint, typecheck: result.typecheck, lintSummary: lintLong - ? truncateText(result.lintReason ?? '', MAX_OUTPUT_PREVIEW_LENGTH, result.lint ? 'head' : 'tail') + ? truncateText( + result.lintReason ?? '', + MAX_OUTPUT_PREVIEW_LENGTH, + result.lint ? 'head' : 'tail' + ) : result.lintReason, typecheckSummary: typecheckLong ? truncateText( @@ -244,7 +253,11 @@ function formatToolResult( }; } } - if (toolName === 'read_file' && typeof result === 'string' && result.length > MAX_FILE_CONTENT_LENGTH) { + if ( + toolName === 'read_file' && + typeof result === 'string' && + result.length > MAX_FILE_CONTENT_LENGTH + ) { const outputFile = writeToolOutputFile(worktreePath, toolCallId, result); return { content: truncateText(result, MAX_FILE_CONTENT_LENGTH, 'head'), @@ -266,7 +279,9 @@ export class ToolExecutor { this.worktreeManager = options.worktreeManager; this.memoryClient = options.memoryClient; this.recallPlanner = options.recallPlanner; - this.allowedScripts = new Set(options.allowedScripts ?? ['lint', 'typecheck', 'build', 'test', 'compile:packages']); + this.allowedScripts = new Set( + options.allowedScripts ?? ['lint', 'typecheck', 'build', 'test', 'compile:packages'] + ); } /** @@ -324,7 +339,9 @@ export class ToolExecutor { } } - private async readOutputFile(input: Record): Promise<{ content: string; tailLines?: number }> { + private async readOutputFile( + input: Record + ): Promise<{ content: string; tailLines?: number }> { const outputFile = this.requireString(input, 'outputFile'); const tailLines = this.optionalNumber(input, 'tailLines'); const worktreePath = this.worktreeManager.getWorktreePath(); @@ -395,7 +412,9 @@ export class ToolExecutor { return this.worktreeManager.searchWorkspace(keyword); } - private async writeFile(input: Record): Promise<{ written: true; unchanged: boolean; appended: boolean }> { + private async writeFile( + input: Record + ): Promise<{ written: true; unchanged: boolean; appended: boolean }> { const relPath = this.requireString(input, 'relPath'); // content 允许为空字符串:清空文件是合法的修复操作 const content = this.requireContent(input); @@ -448,7 +467,9 @@ export class ToolExecutor { return { applied: true }; } - private async runSetupCommand(input: Record): Promise<{ success: boolean; reason?: string }> { + private async runSetupCommand( + input: Record + ): Promise<{ success: boolean; reason?: string }> { const command = this.requireString(input, 'command'); const cwd = this.optionalString(input, 'cwd'); @@ -460,13 +481,19 @@ export class ToolExecutor { return this.worktreeManager.runSetupCommand(command, cwd); } - private async runScript(input: Record): Promise<{ success: boolean; reason?: string }> { + private async runScript( + input: Record + ): Promise<{ success: boolean; reason?: string }> { const script = this.requireString(input, 'script'); if (!this.allowedScripts.has(script)) { - throw new Error(`脚本 ${script} 不在白名单内,只允许: ${Array.from(this.allowedScripts).join(', ')}`); + throw new Error( + `脚本 ${script} 不在白名单内,只允许: ${Array.from(this.allowedScripts).join(', ')}` + ); } const args = this.optionalStringArray(input, 'args'); - return args ? this.worktreeManager.runScript(script, args) : this.worktreeManager.runScript(script); + return args + ? this.worktreeManager.runScript(script, args) + : this.worktreeManager.runScript(script); } private async validate(): Promise<{ diff --git a/src/advance/classic/fix/validation-strategy.ts b/src/advance/classic/fix/validation-strategy.ts index c60a772..b796a05 100755 --- a/src/advance/classic/fix/validation-strategy.ts +++ b/src/advance/classic/fix/validation-strategy.ts @@ -48,9 +48,7 @@ export class WorkspaceValidationStrategy implements ValidationStrategy { const passed = raw.lint === true && raw.typecheck === true; return { passed, - reason: passed - ? 'lint 和 typecheck 均通过' - : `lint=${raw.lint}, typecheck=${raw.typecheck}`, + reason: passed ? 'lint 和 typecheck 均通过' : `lint=${raw.lint}, typecheck=${raw.typecheck}`, details: raw, }; } diff --git a/src/advance/classic/memory/graph-builder.ts b/src/advance/classic/memory/graph-builder.ts index 61eff30..9fc4304 100755 --- a/src/advance/classic/memory/graph-builder.ts +++ b/src/advance/classic/memory/graph-builder.ts @@ -61,10 +61,31 @@ export function buildMemoryGraph(input: BuildMemoryGraphInput): MemoryGraph { const projectNodeId = projectNodeMap.get(projectId); if (!projectNodeId) continue; for (const item of result.episodes ?? []) { - processEntry(item, 'episode', projectId, projectNodeId, nodes, edges, userTopics, projectUsers, displayNames, episodeNodeMap); + processEntry( + item, + 'episode', + projectId, + projectNodeId, + nodes, + edges, + userTopics, + projectUsers, + displayNames, + episodeNodeMap + ); } for (const item of result.agent_cases ?? []) { - processEntry(item, 'agent_case', projectId, projectNodeId, nodes, edges, userTopics, projectUsers, displayNames); + processEntry( + item, + 'agent_case', + projectId, + projectNodeId, + nodes, + edges, + userTopics, + projectUsers, + displayNames + ); } } @@ -73,7 +94,17 @@ export function buildMemoryGraph(input: BuildMemoryGraphInput): MemoryGraph { const projectNodeId = projectNodeMap.get(projectId); if (!projectNodeId) continue; for (const item of result.agent_skills ?? []) { - processSkill(item, projectId, projectNodeId, nodes, edges, caseProjects, skillCases, systemSkills, displayNames); + processSkill( + item, + projectId, + projectNodeId, + nodes, + edges, + caseProjects, + skillCases, + systemSkills, + displayNames + ); } for (const item of result.profiles ?? []) { processProfile(item, projectId, projectNodeId, nodes, edges, profileProjects, displayNames); @@ -134,11 +165,17 @@ export function parseTopicId(sessionId: string): { topicId: string; label: strin } const discussionMatch = sessionId.match(/(?:discussion|interaction)-([^-]+)$/); if (discussionMatch) { - return { topicId: `discussion:${discussionMatch[1]}`, label: `Discussion #${discussionMatch[1]}` }; + return { + topicId: `discussion:${discussionMatch[1]}`, + label: `Discussion #${discussionMatch[1]}`, + }; } const archiveMatch = sessionId.match(/archiver-(.+?)-(\d{4}-\d{2}-\d{2})-(\d)$/); if (archiveMatch) { - return { topicId: `archive:${archiveMatch[2]}-${archiveMatch[3]}`, label: `${archiveMatch[2]}-${archiveMatch[3]}` }; + return { + topicId: `archive:${archiveMatch[2]}-${archiveMatch[3]}`, + label: `${archiveMatch[2]}-${archiveMatch[3]}`, + }; } return null; } @@ -252,7 +289,11 @@ function processEntry( const ownerGroup = group === 'episode' ? inferEpisodeOwnerGroup(ownerId) : 'agent'; const ownerNodeId = `${ownerGroup}:${ownerId}`; if (!nodes.has(ownerNodeId)) { - nodes.set(ownerNodeId, { id: ownerNodeId, label: getOwnerLabel(ownerId, displayNames), group: ownerGroup }); + nodes.set(ownerNodeId, { + id: ownerNodeId, + label: getOwnerLabel(ownerId, displayNames), + group: ownerGroup, + }); } addEdge(edges, ownerNodeId, nodeId, 'authored'); if (ownerGroup === 'agent') { @@ -281,8 +322,12 @@ function processSkill( ): void { const rawId = typeof item.id === 'string' ? item.id : `skill-${cryptoRandomId()}`; const skillNodeId = `agent_skill:${rawId}`; - const sourceCaseIds = ((item.source_case_ids as string[]) ?? []).filter((id): id is string => typeof id === 'string'); - const sourceProjects = new Set(sourceCaseIds.map((id) => caseProjects.get(id)).filter((p): p is string => !!p)); + const sourceCaseIds = ((item.source_case_ids as string[]) ?? []).filter( + (id): id is string => typeof id === 'string' + ); + const sourceProjects = new Set( + sourceCaseIds.map(id => caseProjects.get(id)).filter((p): p is string => !!p) + ); const isSystem = sourceProjects.size >= 2; const parentId = isSystem ? 'system' : projectNodeId; @@ -304,7 +349,11 @@ function processSkill( if (agentId) { const agentNodeId = `agent:${agentId}`; if (!nodes.has(agentNodeId)) { - nodes.set(agentNodeId, { id: agentNodeId, label: getOwnerLabel(agentId, displayNames), group: 'agent' }); + nodes.set(agentNodeId, { + id: agentNodeId, + label: getOwnerLabel(agentId, displayNames), + group: 'agent', + }); } addEdge(edges, agentNodeId, skillNodeId, 'authored'); } @@ -341,7 +390,11 @@ function processProfile( profileProjects.get(userId)?.add(projectId); if (!nodes.has(ownerNodeId)) { - nodes.set(ownerNodeId, { id: ownerNodeId, label: getOwnerLabel(userId, displayNames), group: ownerGroup }); + nodes.set(ownerNodeId, { + id: ownerNodeId, + label: getOwnerLabel(userId, displayNames), + group: ownerGroup, + }); } addEdge(edges, ownerNodeId, profileNodeId, 'has_profile'); } @@ -369,7 +422,7 @@ function addRelatedSkillEdges( for (let j = i + 1; j < skills.length; j++) { const [idA, casesA] = skills[i]; const [idB, casesB] = skills[j]; - const overlap = [...casesA].some((id) => casesB.has(id)); + const overlap = [...casesA].some(id => casesB.has(id)); if (overlap) { addEdge(edges, idA, idB, 'related_skill'); } @@ -388,7 +441,7 @@ function addProjectShareEdges( const pidB = projectIds[j]; const usersA = projectUsers.get(pidA) ?? new Set(); const usersB = projectUsers.get(pidB) ?? new Set(); - const sharedUser = [...usersA].some((u) => usersB.has(u)); + const sharedUser = [...usersA].some(u => usersB.has(u)); if (sharedUser) { addEdge(edges, `project:${pidA}`, `project:${pidB}`, 'shares'); } @@ -396,14 +449,11 @@ function addProjectShareEdges( } } -function buildStats( - nodes: MemoryGraphNode[], - edges: MemoryGraphEdge[] -): MemoryGraphStats { +function buildStats(nodes: MemoryGraphNode[], edges: MemoryGraphEdge[]): MemoryGraphStats { const memoryGroups = ['episode', 'agent_case', 'agent_skill', 'profile'] as const; let totalMemories = 0; for (const node of nodes) { - if (memoryGroups.some((group) => group === node.group)) totalMemories++; + if (memoryGroups.some(group => group === node.group)) totalMemories++; } // 与日历时间线使用同一批已去重节点,避免同一 session 下的多条记忆被压成一条 @@ -419,7 +469,7 @@ function buildStats( totalNodes: nodes.length, totalEdges: edges.length, totalMemories, - projectCount: nodes.filter((n) => n.group === 'project').length, + projectCount: nodes.filter(n => n.group === 'project').length, activeDays: dailyMap.size, dailyGrowth, }; @@ -466,5 +516,7 @@ function stableHash(str: string): string { h1 = Math.imul(h1 ^ ch, 2654435761); h2 = Math.imul(h2 ^ ch, 1597334677); } - return ((h1 >>> 0).toString(16).padStart(8, '0') + (h2 >>> 0).toString(16).padStart(8, '0')).slice(0, 16); + return ( + (h1 >>> 0).toString(16).padStart(8, '0') + (h2 >>> 0).toString(16).padStart(8, '0') + ).slice(0, 16); } diff --git a/src/advance/classic/memory/local-model-catalog.ts b/src/advance/classic/memory/local-model-catalog.ts index b5159b1..e561678 100755 --- a/src/advance/classic/memory/local-model-catalog.ts +++ b/src/advance/classic/memory/local-model-catalog.ts @@ -1,70 +1,71 @@ -export const DEFAULT_EMBEDDING_MODEL = 'intfloat/multilingual-e5-small'; -export const DEFAULT_RERANK_MODEL = 'BAAI/bge-reranker-base'; - -export const EMBEDDING_MODELS = [ - DEFAULT_EMBEDDING_MODEL, - 'BAAI/bge-small-en-v1.5', - 'BAAI/bge-small-zh-v1.5', - 'sentence-transformers/all-MiniLM-L6-v2', -]; - -export const RERANK_MODELS = [ - DEFAULT_RERANK_MODEL, - 'BAAI/bge-reranker-v2-m3', - 'mixedbread-ai/mxbai-rerank-xsmall-v1', -]; - -/** - * 已知 embedding 模型的输出维度。 - * - * 用于向 EverOS 注入 EVEROS_EMBEDDING__DIM,使 LanceDB vector 列维度 - * 与当前模型实际输出维度一致,避免维度不匹配报错。 - */ -export const EMBEDDING_MODEL_DIMENSIONS: Record = { - [DEFAULT_EMBEDDING_MODEL]: 384, - 'BAAI/bge-small-en-v1.5': 384, - 'BAAI/bge-small-zh-v1.5': 384, - 'sentence-transformers/all-MiniLM-L6-v2': 384, -}; - -/** - * 获取指定 embedding 模型的输出维度;未知模型默认 384。 - */ -export function getEmbeddingModelDimension(model: string): number { - return EMBEDDING_MODEL_DIMENSIONS[model] ?? 384; -} - -/** - * 从 HuggingFace 拉取 embedding 模型的 config.json,解析 hidden_size 作为维度。 - * - * 对 sentence-transformers 类模型,hidden_size 通常就是输出向量维度。 - * 拉取失败时返回 null,调用方应回退到本地缓存或默认值。 - */ -export async function fetchEmbeddingModelDimension(modelId: string): Promise { - const cached = EMBEDDING_MODEL_DIMENSIONS[modelId]; - if (cached !== undefined) { - return cached; - } - - const url = `https://huggingface.co/${modelId}/raw/main/config.json`; - try { - const res = await fetch(url, { headers: { Accept: 'application/json' } }); - if (!res.ok) { - return null; - } - const config = await res.json() as { hidden_size?: number }; - if (typeof config.hidden_size === 'number' && config.hidden_size > 0) { - return config.hidden_size; - } - } catch { - // 网络失败或 JSON 解析失败都静默回退 - } - return null; -} - -// HuggingFace repo id 规则:namespace/model-name,允许字母、数字、-、_、. -const HF_MODEL_ID_RE = /^[a-zA-Z0-9](?:[a-zA-Z0-9._-]*[a-zA-Z0-9])?\/[a-zA-Z0-9](?:[a-zA-Z0-9._-]*[a-zA-Z0-9])?$/; - -export function isValidHuggingFaceModelId(model: string): boolean { - return HF_MODEL_ID_RE.test(model); -} +export const DEFAULT_EMBEDDING_MODEL = 'intfloat/multilingual-e5-small'; +export const DEFAULT_RERANK_MODEL = 'BAAI/bge-reranker-base'; + +export const EMBEDDING_MODELS = [ + DEFAULT_EMBEDDING_MODEL, + 'BAAI/bge-small-en-v1.5', + 'BAAI/bge-small-zh-v1.5', + 'sentence-transformers/all-MiniLM-L6-v2', +]; + +export const RERANK_MODELS = [ + DEFAULT_RERANK_MODEL, + 'BAAI/bge-reranker-v2-m3', + 'mixedbread-ai/mxbai-rerank-xsmall-v1', +]; + +/** + * 已知 embedding 模型的输出维度。 + * + * 用于向 EverOS 注入 EVEROS_EMBEDDING__DIM,使 LanceDB vector 列维度 + * 与当前模型实际输出维度一致,避免维度不匹配报错。 + */ +export const EMBEDDING_MODEL_DIMENSIONS: Record = { + [DEFAULT_EMBEDDING_MODEL]: 384, + 'BAAI/bge-small-en-v1.5': 384, + 'BAAI/bge-small-zh-v1.5': 384, + 'sentence-transformers/all-MiniLM-L6-v2': 384, +}; + +/** + * 获取指定 embedding 模型的输出维度;未知模型默认 384。 + */ +export function getEmbeddingModelDimension(model: string): number { + return EMBEDDING_MODEL_DIMENSIONS[model] ?? 384; +} + +/** + * 从 HuggingFace 拉取 embedding 模型的 config.json,解析 hidden_size 作为维度。 + * + * 对 sentence-transformers 类模型,hidden_size 通常就是输出向量维度。 + * 拉取失败时返回 null,调用方应回退到本地缓存或默认值。 + */ +export async function fetchEmbeddingModelDimension(modelId: string): Promise { + const cached = EMBEDDING_MODEL_DIMENSIONS[modelId]; + if (cached !== undefined) { + return cached; + } + + const url = `https://huggingface.co/${modelId}/raw/main/config.json`; + try { + const res = await fetch(url, { headers: { Accept: 'application/json' } }); + if (!res.ok) { + return null; + } + const config = (await res.json()) as { hidden_size?: number }; + if (typeof config.hidden_size === 'number' && config.hidden_size > 0) { + return config.hidden_size; + } + } catch { + // 网络失败或 JSON 解析失败都静默回退 + } + return null; +} + +// HuggingFace repo id 规则:namespace/model-name,允许字母、数字、-、_、. +const HF_MODEL_ID_RE = + /^[a-zA-Z0-9](?:[a-zA-Z0-9._-]*[a-zA-Z0-9])?\/[a-zA-Z0-9](?:[a-zA-Z0-9._-]*[a-zA-Z0-9])?$/; + +export function isValidHuggingFaceModelId(model: string): boolean { + return HF_MODEL_ID_RE.test(model); +} diff --git a/src/advance/classic/memory/memory-write-queue.ts b/src/advance/classic/memory/memory-write-queue.ts index b255ccf..182fe4a 100755 --- a/src/advance/classic/memory/memory-write-queue.ts +++ b/src/advance/classic/memory/memory-write-queue.ts @@ -76,11 +76,7 @@ export class MemoryWriteQueue implements IMemoryWriteQueue { */ enqueue(ctx: MemoryContext, kind: 'add_messages', messages: EverOSAddMessage[]): void; - enqueue( - ctx: MemoryContext, - kind: PendingMemoryWriteKind, - messages?: EverOSAddMessage[] - ): void { + enqueue(ctx: MemoryContext, kind: PendingMemoryWriteKind, messages?: EverOSAddMessage[]): void { const msgs = messages ?? []; const contentHash = this.computeContentHash(ctx.sessionId, kind, msgs); const id = this.buildId(ctx.projectId, ctx.sessionId, contentHash); diff --git a/src/advance/classic/memory/secret-sanitizer.ts b/src/advance/classic/memory/secret-sanitizer.ts index 6a42812..048e29e 100755 --- a/src/advance/classic/memory/secret-sanitizer.ts +++ b/src/advance/classic/memory/secret-sanitizer.ts @@ -9,7 +9,8 @@ export class SecretSanitizer { { name: 'AWS Access Key', regex: /AKIA[0-9A-Z]{16}/g, mask: '' }, { name: 'Private Key Header', - regex: /-----BEGIN (RSA |EC |OPENSSH )?PRIVATE KEY-----[\s\S]*?-----END (RSA |EC |OPENSSH )?PRIVATE KEY-----/g, + regex: + /-----BEGIN (RSA |EC |OPENSSH )?PRIVATE KEY-----[\s\S]*?-----END (RSA |EC |OPENSSH )?PRIVATE KEY-----/g, mask: '', }, ]; diff --git a/src/advance/classic/provider/activity-window.ts b/src/advance/classic/provider/activity-window.ts index 97609de..51732b9 100755 --- a/src/advance/classic/provider/activity-window.ts +++ b/src/advance/classic/provider/activity-window.ts @@ -16,18 +16,12 @@ export function getCommentUpdatedAt(comment: ReviewerComment): number { /** 返回评论最近一次创建或编辑的时间。 */ export function getCommentActivityAt(comment: ReviewerComment): number { - return Math.max( - parseActivityTime(comment.createdAt), - parseActivityTime(comment.updatedAt) - ); + return Math.max(parseActivityTime(comment.createdAt), parseActivityTime(comment.updatedAt)); } /** 返回 discussion 内任一 note 最近一次创建或编辑的时间。 */ export function getDiscussionActivityAt(discussion: Discussion): number { - return discussion.notes.reduce( - (latest, note) => Math.max(latest, getCommentActivityAt(note)), - 0 - ); + return discussion.notes.reduce((latest, note) => Math.max(latest, getCommentActivityAt(note)), 0); } /** 按最近活动时间选取评论窗口,不修改输入数组。 */ diff --git a/src/advance/classic/provider/discussion-mapper.ts b/src/advance/classic/provider/discussion-mapper.ts index 9c7503a..babf531 100755 --- a/src/advance/classic/provider/discussion-mapper.ts +++ b/src/advance/classic/provider/discussion-mapper.ts @@ -20,8 +20,7 @@ export function buildDiffPosition( shaInfo: MrShaInfo ): GitLabDiffPosition | null { const diff = diffs.find( - (d) => d.newPath === finding.file || - (d.deletedFile && d.oldPath === finding.file) + d => d.newPath === finding.file || (d.deletedFile && d.oldPath === finding.file) ); if (!diff) { return null; diff --git a/src/advance/classic/provider/mr-filter.ts b/src/advance/classic/provider/mr-filter.ts index c67a125..a5edd80 100755 --- a/src/advance/classic/provider/mr-filter.ts +++ b/src/advance/classic/provider/mr-filter.ts @@ -18,10 +18,10 @@ export function matchesFilter(mr: MergeRequest, filter?: MrReviewFilter): boolea if (!filter || filter.conditions.length === 0) return true; for (const condition of filter.conditions) { - const values = condition.values.filter((v) => v.trim() !== ''); + const values = condition.values.filter(v => v.trim() !== ''); if (values.length === 0) continue; - const matched = values.some((value) => matchesCondition(mr, condition.field, value)); + const matched = values.some(value => matchesCondition(mr, condition.field, value)); if (!matched) return false; } diff --git a/src/advance/classic/provider/types.ts b/src/advance/classic/provider/types.ts index 14d3dd9..69d9b51 100755 --- a/src/advance/classic/provider/types.ts +++ b/src/advance/classic/provider/types.ts @@ -265,7 +265,9 @@ export interface IGitProvider { getReviewerCommentSnapshot(iid: number): Promise; /** 获取指定 MR 的 CI 状态 */ - getCIStatus(iid: number): Promise<'pending' | 'running' | 'success' | 'failed' | 'skipped' | 'unknown'>; + getCIStatus( + iid: number + ): Promise<'pending' | 'running' | 'success' | 'failed' | 'skipped' | 'unknown'>; /** 获取指定 MR 的概览(生命周期终态感知) */ getMROverview(iid: number): Promise; diff --git a/src/advance/classic/review/reviewer-actor.ts b/src/advance/classic/review/reviewer-actor.ts index 552b16b..6719de3 100755 --- a/src/advance/classic/review/reviewer-actor.ts +++ b/src/advance/classic/review/reviewer-actor.ts @@ -53,7 +53,11 @@ export class ReviewerActor { * 发表评审 summary 评论,并按配置创建严重 finding 的 discussion threads * 返回 summary 评论的 note ID */ - async postReview(mr: MergeRequest, result: ReviewResult, options?: PostReviewOptions): Promise { + async postReview( + mr: MergeRequest, + result: ReviewResult, + options?: PostReviewOptions + ): Promise { const comment = this.getPendingDeliveryBody(options, 'summary') ?? formatReviewComment(mr, result, this.options.reviewerName); @@ -103,7 +107,13 @@ export class ReviewerActor { findings: ReviewFinding[], options?: PostReviewOptions ): Promise { - if (!options?.diffs || !options.shaInfo || !options.stateKey || !options.state || !this.options.project) { + if ( + !options?.diffs || + !options.shaInfo || + !options.stateKey || + !options.state || + !this.options.project + ) { console.log('[ReviewerActor] 缺少创建 discussion threads 的必要上下文,跳过'); return; } @@ -111,18 +121,22 @@ export class ReviewerActor { const { diffs, shaInfo, stateKey, state } = options; const posted = state.discussions[stateKey] ?? []; const severities = new Set(this.threadRiskLevels); - const candidates = findings.filter((f) => severities.has(f.severity)); + const candidates = findings.filter(f => severities.has(f.severity)); - console.log(`[ReviewerActor] MR !${mr.iid} 存在 ${candidates.length} 个 CRITICAL/HIGH finding,已发布 ${posted.length} 个`); + console.log( + `[ReviewerActor] MR !${mr.iid} 存在 ${candidates.length} 个 CRITICAL/HIGH finding,已发布 ${posted.length} 个` + ); for (const finding of findings) { if (!severities.has(finding.severity)) continue; const key = getFindingKey(finding); - if (posted.some((p) => p.findingKey === key)) continue; + if (posted.some(p => p.findingKey === key)) continue; const position = buildDiffPosition(finding, diffs, shaInfo); if (!position) { - console.warn(`[ReviewerActor] 无法为 finding ${finding.file}:${finding.line} 构造 diff position`); + console.warn( + `[ReviewerActor] 无法为 finding ${finding.file}:${finding.line} 构造 diff position` + ); continue; } @@ -137,10 +151,14 @@ export class ReviewerActor { severity: finding.severity, resolved: false, }); - console.log(`[ReviewerActor] 已为 finding ${finding.file}:${finding.line} 创建 discussion ${discussionId}`); + console.log( + `[ReviewerActor] 已为 finding ${finding.file}:${finding.line} 创建 discussion ${discussionId}` + ); } catch (error) { const message = error instanceof Error ? error.message : String(error); - console.error(`[ReviewerActor] 创建 finding thread 失败 ${finding.file}:${finding.line}: ${message}`); + console.error( + `[ReviewerActor] 创建 finding thread 失败 ${finding.file}:${finding.line}: ${message}` + ); } } diff --git a/src/advance/classic/role-service-registry.ts b/src/advance/classic/role-service-registry.ts index e70c11e..d8a64ec 100755 --- a/src/advance/classic/role-service-registry.ts +++ b/src/advance/classic/role-service-registry.ts @@ -14,7 +14,7 @@ export class RoleServiceRegistry { constructor( context: HandlerContext, private runnerPath: string, - private options: RoleServiceOptions = {}, + private options: RoleServiceOptions = {} ) { this.context = context; } @@ -47,7 +47,7 @@ export class RoleServiceRegistry { if (Date.now() - start > timeoutMs) { throw new Error('等待 EverOS MCP URL 超时,无法启动角色服务'); } - await new Promise((resolve) => setTimeout(resolve, 500)); + await new Promise(resolve => setTimeout(resolve, 500)); } } diff --git a/src/advance/classic/runners/archiver-runner.ts b/src/advance/classic/runners/archiver-runner.ts index 46d5690..b72f9e6 100755 --- a/src/advance/classic/runners/archiver-runner.ts +++ b/src/advance/classic/runners/archiver-runner.ts @@ -9,9 +9,7 @@ import { MemoryClient } from '../memory/memory-client.js'; import type { Project, RoleConfig } from '../../types.js'; import type { LlmClient } from '../../llm/client.js'; import { getArchiveRoot, isRoleConfigEnabled } from '../../types.js'; -import { - normalizeArchiverConfig, -} from '../../archiver/provider-config.js'; +import { normalizeArchiverConfig } from '../../archiver/provider-config.js'; import { type ArchiverProviderCoordinator, type ArchiverProviderExecution, diff --git a/src/advance/classic/runners/base-role-runner.ts b/src/advance/classic/runners/base-role-runner.ts index 3f5ec30..e20a1a2 100755 --- a/src/advance/classic/runners/base-role-runner.ts +++ b/src/advance/classic/runners/base-role-runner.ts @@ -2,11 +2,7 @@ import { schedule, validate as validateCron } from 'node-cron'; import { existsSync } from 'node:fs'; import { LlmClient } from '../../llm/client.js'; import type { Project, GitlabConfig, RoleConfig, Role } from '../../types.js'; -import { - getArchiveRoot, - getRoleConfigSchedule, - isRoleConfigEnabled, -} from '../../types.js'; +import { getArchiveRoot, getRoleConfigSchedule, isRoleConfigEnabled } from '../../types.js'; import { loadSoulContent, type SoulContent } from '../soul/soul-loader.js'; import { loadProjectContext } from '../context/project-context-loader.js'; import { @@ -104,7 +100,9 @@ export abstract class BaseRoleRunner implements IRoleRunner { */ private async runOnce(project: Project): Promise { if (this.runningProjects.has(project.id)) { - console.log(`[${this.getRoleName()}] 项目 ${project.name} 的上一次循环尚未完成,跳过本次调度`); + console.log( + `[${this.getRoleName()}] 项目 ${project.name} 的上一次循环尚未完成,跳过本次调度` + ); return; } this.runningProjects.add(project.id); diff --git a/src/advance/classic/runners/shared/review-utils.ts b/src/advance/classic/runners/shared/review-utils.ts index aee78db..0f68d51 100755 --- a/src/advance/classic/runners/shared/review-utils.ts +++ b/src/advance/classic/runners/shared/review-utils.ts @@ -45,7 +45,7 @@ export function groupFindingsBySeverity( export function formatSummary(summary: string): string[] { let formatted = summary.replace(/(\d+)\)\s*/g, '$1. '); formatted = formatted.replace(/([^\n])(\d+\.\s)/g, '$1\n$2'); - return formatted.split('\n').map((line) => `> ${line}`); + return formatted.split('\n').map(line => `> ${line}`); } /** @@ -140,7 +140,11 @@ export function formatFindingDiscussionBody(finding: ReviewFinding, agentName?: * * 仅用于 reviewer 角色,汇总所有 findings。 */ -export function formatReviewComment(mr: MergeRequest, result: ReviewResult, agentName?: string): string { +export function formatReviewComment( + mr: MergeRequest, + result: ReviewResult, + agentName?: string +): string { const severityOrder: ReviewFinding['severity'][] = ['CRITICAL', 'HIGH', 'MEDIUM', 'LOW']; const groups = groupFindingsBySeverity(result.findings); const total = result.findings.length; @@ -183,7 +187,11 @@ export function formatReviewComment(mr: MergeRequest, result: ReviewResult, agen * * 当 MR 有新 commit 或发现新问题时,在原 summary 下追加一条补充说明。 */ -export function formatSupplementaryReviewComment(mr: MergeRequest, newFindings: ReviewFinding[], agentName?: string): string { +export function formatSupplementaryReviewComment( + mr: MergeRequest, + newFindings: ReviewFinding[], + agentName?: string +): string { const severityOrder: ReviewFinding['severity'][] = ['CRITICAL', 'HIGH', 'MEDIUM', 'LOW']; const groups = groupFindingsBySeverity(newFindings); const total = newFindings.length; diff --git a/src/advance/classic/state/mr-state-store.ts b/src/advance/classic/state/mr-state-store.ts index 2dbdee9..9f1741c 100755 --- a/src/advance/classic/state/mr-state-store.ts +++ b/src/advance/classic/state/mr-state-store.ts @@ -8,9 +8,7 @@ import type { MetadataStore } from '../../store/metadata-store'; /** * 允许局部更新的字段类型(排除不可变字段) */ -export type MrStatePatch = Partial< - Omit ->; +export type MrStatePatch = Partial>; /** * MR 状态存储封装类 @@ -40,7 +38,9 @@ export class MrStateStore { * 按 updatedAt 降序排列 */ listByProject(projectId: string): MrReviewState[] { - return this.store.listMrStatesByProject(projectId).filter((s): s is MrReviewState => s !== undefined); + return this.store + .listMrStatesByProject(projectId) + .filter((s): s is MrReviewState => s !== undefined); } /** diff --git a/src/advance/classic/utils/context-window.ts b/src/advance/classic/utils/context-window.ts index 99161df..c735e38 100755 --- a/src/advance/classic/utils/context-window.ts +++ b/src/advance/classic/utils/context-window.ts @@ -80,7 +80,7 @@ export async function summarizeThreadNotes( return { recentNotesText: '', summarizedCount: 0, summarized: false }; } - const normalizedNotes = notes.map((n) => ({ + const normalizedNotes = notes.map(n => ({ ...n, body: truncateBody(n.body, maxCharsPerItem), })); @@ -114,7 +114,11 @@ export async function summarizeThreadNotes( }; } -async function summarizeNotes(llmClient: LlmClient, notes: ThreadNote[], promptLoader?: PromptLoader): Promise { +async function summarizeNotes( + llmClient: LlmClient, + notes: ThreadNote[], + promptLoader?: PromptLoader +): Promise { if (notes.length === 0) return ''; const notesText = notes.map((n, idx) => formatNote(n, idx + 1)).join('\n\n'); diff --git a/src/advance/classic/utils/json-extraction.ts b/src/advance/classic/utils/json-extraction.ts index 6712f53..da38767 100755 --- a/src/advance/classic/utils/json-extraction.ts +++ b/src/advance/classic/utils/json-extraction.ts @@ -43,7 +43,11 @@ export function extractJsonText(text: string): string { return trimmed; } -function findBalancedRange(text: string, openChar: string, closeChar: string): { start: number; end: number } | null { +function findBalancedRange( + text: string, + openChar: string, + closeChar: string +): { start: number; end: number } | null { const start = text.indexOf(openChar); if (start === -1) return null; diff --git a/src/advance/classic/worktree/file-overview-builder.ts b/src/advance/classic/worktree/file-overview-builder.ts index dc2ad7e..b6aad7b 100755 --- a/src/advance/classic/worktree/file-overview-builder.ts +++ b/src/advance/classic/worktree/file-overview-builder.ts @@ -31,7 +31,7 @@ export interface FileOverview { /** * 符号识别规则,按语言扩展 */ -const SYMBOL_PATTERNS: Array<{ kind: SymbolEntry['kind']; regex: RegExp } > = [ +const SYMBOL_PATTERNS: Array<{ kind: SymbolEntry['kind']; regex: RegExp }> = [ { kind: 'function', regex: /^(?:export\s+)?(?:async\s+)?function\s+([A-Za-z_$][\w$]*)/ }, { kind: 'class', regex: /^(?:export\s+)?class\s+([A-Za-z_$][\w$]*)/ }, { kind: 'interface', regex: /^(?:export\s+)?interface\s+([A-Za-z_$][\w$]*)/ }, diff --git a/src/advance/cli.ts b/src/advance/cli.ts index 8b2208a..17dcd44 100755 --- a/src/advance/cli.ts +++ b/src/advance/cli.ts @@ -48,7 +48,10 @@ export async function main(): Promise { const archiveRoot = parseFlag(args, '--archive-root'); const { store, registry } = getDeps(); try { - const project = registry.register(resolve(rootPath), archiveRoot ? resolve(archiveRoot) : undefined); + const project = registry.register( + resolve(rootPath), + archiveRoot ? resolve(archiveRoot) : undefined + ); console.log(`已注册项目: ${project.name} (${project.id})`); if (project.archiveRoot) { console.log(`归档位置: ${project.archiveRoot}`); @@ -150,7 +153,7 @@ export async function main(): Promise { const projects = registry.list(); console.log(`已注册项目数: ${projects.length}`); for (const p of projects) { - const pending = store.listPendingEvents(1000).filter((e) => e.projectId === p.id).length; + const pending = store.listPendingEvents(1000).filter(e => e.projectId === p.id).length; console.log(` ${p.name}: 待处理事件 ${pending}`); } } finally { @@ -211,6 +214,8 @@ export async function main(): Promise { } console.log(`未知命令: ${command}`); - console.log('用法: codekeeper-advance [register|unregister|list|start|process|status|history|undo]'); + console.log( + '用法: codekeeper-advance [register|unregister|list|start|process|status|history|undo]' + ); process.exit(1); } diff --git a/src/advance/codekeeper/context-generator.ts b/src/advance/codekeeper/context-generator.ts index c527762..b43f497 100755 --- a/src/advance/codekeeper/context-generator.ts +++ b/src/advance/codekeeper/context-generator.ts @@ -71,7 +71,9 @@ export function generateContext(options: ContextGeneratorOptions): void { ? relative(options.archiveRoot, item.archivePath).replace(/\\/g, '/') : relative(options.projectRoot, item.filePath).replace(/\\/g, '/'); const statusBadge = item.status ? ` (${statusLabel(item.status)})` : ''; - lines.push(`- **${item.docType}** [${linkPath}](${encodePath(linkPath)})${statusBadge} — ${item.summary}`); + lines.push( + `- **${item.docType}** [${linkPath}](${encodePath(linkPath)})${statusBadge} — ${item.summary}` + ); if (item.tags.length > 0) { lines.push(` - 标签:${item.tags.join(', ')}`); } @@ -93,7 +95,10 @@ export function generateContext(options: ContextGeneratorOptions): void { } function anchor(text: string): string { - return text.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9一-龥-]/g, ''); + return text + .toLowerCase() + .replace(/\s+/g, '-') + .replace(/[^a-z0-9一-龥-]/g, ''); } function encodePath(filePath: string): string { diff --git a/src/advance/codekeeper/status-updater.ts b/src/advance/codekeeper/status-updater.ts index 3ce7726..b8a07da 100755 --- a/src/advance/codekeeper/status-updater.ts +++ b/src/advance/codekeeper/status-updater.ts @@ -35,7 +35,8 @@ export function buildProjectStatus(params: { organizedCount: number; flaggedCount: number; }): ProjectStatus { - const total = params.pendingCount + params.archivedCount + params.ignoredCount + params.orphanedCount; + const total = + params.pendingCount + params.archivedCount + params.ignoredCount + params.orphanedCount; const resolved = params.archivedCount + params.ignoredCount + params.orphanedCount; const healthScore = total === 0 ? 1 : Math.round((resolved / total) * 100) / 100; return { diff --git a/src/advance/config/daemon-config.ts b/src/advance/config/daemon-config.ts index 84efadc..6cf32fe 100755 --- a/src/advance/config/daemon-config.ts +++ b/src/advance/config/daemon-config.ts @@ -64,14 +64,14 @@ export function saveDaemonConfig(config: DaemonPersistedConfig): void { merged.everos = { ...existing.everos, ...config.everos }; } // 删除 undefined 字段 - (Object.keys(merged) as Array).forEach((key) => { + (Object.keys(merged) as Array).forEach(key => { if (merged[key] === undefined) { delete merged[key]; } }); const everosConfig = merged.everos; if (everosConfig) { - (Object.keys(everosConfig) as Array).forEach((key) => { + (Object.keys(everosConfig) as Array).forEach(key => { if (everosConfig[key] === undefined) { delete everosConfig[key]; } diff --git a/src/advance/llm/prompts/classify-prompt.ts b/src/advance/llm/prompts/classify-prompt.ts index 5630f6a..7b480ba 100755 --- a/src/advance/llm/prompts/classify-prompt.ts +++ b/src/advance/llm/prompts/classify-prompt.ts @@ -1,12 +1,30 @@ /** * 默认分类列表 */ -export const DEFAULT_CATEGORIES = ['memory', 'sync', 'skill', 'review', 'design', 'weekly', 'other']; +export const DEFAULT_CATEGORIES = [ + 'memory', + 'sync', + 'skill', + 'review', + 'design', + 'weekly', + 'other', +]; /** * 默认文档类型列表 */ -export const DEFAULT_DOC_TYPES = ['design', 'spec', 'weekly', 'review', 'note', 'code', 'config', 'snippet', 'other']; +export const DEFAULT_DOC_TYPES = [ + 'design', + 'spec', + 'weekly', + 'review', + 'note', + 'code', + 'config', + 'snippet', + 'other', +]; export interface ClassifyPromptOptions { categories?: string[]; @@ -37,8 +55,12 @@ export function buildClassifyPrompt( contentPreview: string, options: ClassifyPromptOptions = {} ): string { - const categoryList = options.categories?.length ? options.categories.join(', ') : DEFAULT_CATEGORIES.join(', '); - const docTypeList = options.docTypes?.length ? options.docTypes.join(', ') : DEFAULT_DOC_TYPES.join(', '); + const categoryList = options.categories?.length + ? options.categories.join(', ') + : DEFAULT_CATEGORIES.join(', '); + const docTypeList = options.docTypes?.length + ? options.docTypes.join(', ') + : DEFAULT_DOC_TYPES.join(', '); const contentSection = contentPreview ? `\n内容前 1500 字符(供参考):\n${contentPreview.slice(0, 1500)}` @@ -134,7 +156,8 @@ export function parseClassifyResponse(text: string): ParsedClassifyResponse | nu ? parsed.sections .filter( (s: unknown) => - s && typeof (s as Record).heading === 'string' && + s && + typeof (s as Record).heading === 'string' && typeof (s as Record).summary === 'string' ) .map((s: Record) => ({ diff --git a/src/advance/llm/prompts/loader.ts b/src/advance/llm/prompts/loader.ts index 223bb92..995cb5b 100755 --- a/src/advance/llm/prompts/loader.ts +++ b/src/advance/llm/prompts/loader.ts @@ -63,11 +63,7 @@ export class FilePromptLoader implements PromptLoader { return join(dirname(__dirname), '..', '..', 'assets', 'prompts'); } - private resolve( - raw: string, - variables: Record, - resolving: Set - ): string { + private resolve(raw: string, variables: Record, resolving: Set): string { // 先处理 include,再处理变量,避免变量值里出现 include 被误解析 let result = this.resolveIncludes(raw, variables, resolving); result = this.substituteVariables(result, variables); @@ -83,7 +79,9 @@ export class FilePromptLoader implements PromptLoader { return raw.replace(includePattern, (_match, includeName: string) => { const normalized = this.normalizeName(includeName.trim()); if (resolving.has(normalized)) { - throw new Error(`Prompt include 循环引用: ${Array.from(resolving).join(' -> ')} -> ${normalized}`); + throw new Error( + `Prompt include 循环引用: ${Array.from(resolving).join(' -> ')} -> ${normalized}` + ); } const content = this.registry.get(normalized) ?? this.readFromDisk(normalized); const nextResolving = new Set(resolving); diff --git a/src/advance/llm/tool-types.ts b/src/advance/llm/tool-types.ts index e897158..e8ec251 100755 --- a/src/advance/llm/tool-types.ts +++ b/src/advance/llm/tool-types.ts @@ -39,10 +39,7 @@ export interface ToolResult { export type LlmMessageRole = 'user' | 'assistant'; -export type LlmMessageContent = - | { type: 'text'; text: string } - | ToolCall - | ToolResult; +export type LlmMessageContent = { type: 'text'; text: string } | ToolCall | ToolResult; export interface LlmMessage { role: LlmMessageRole; diff --git a/src/advance/scan/scan-worker-entry.ts b/src/advance/scan/scan-worker-entry.ts index f762f95..b5ffb8e 100755 --- a/src/advance/scan/scan-worker-entry.ts +++ b/src/advance/scan/scan-worker-entry.ts @@ -87,7 +87,7 @@ async function runScan(task: ScanWorkerTask): Promise { } process.on('message', (task: ScanWorkerTask) => { - runScan(task).catch((err) => { + runScan(task).catch(err => { const message = err instanceof Error ? err.message : String(err); console.error('[Scan Worker] 扫描任务失败:', message); sendMessage({ type: 'error', message }); diff --git a/src/advance/utils/git-info.ts b/src/advance/utils/git-info.ts index 2c7d3a7..8912bf9 100755 --- a/src/advance/utils/git-info.ts +++ b/src/advance/utils/git-info.ts @@ -24,7 +24,7 @@ export async function detectGitInfo(projectRoot: string): Promise { let remoteUrl: string | undefined; try { const remotes = await git.getRemotes(true); - const origin = remotes.find((r) => r.name === 'origin'); + const origin = remotes.find(r => r.name === 'origin'); remoteUrl = origin?.refs.fetch ?? origin?.refs.push; } catch { // 无法读取 remote 时继续返回空分支列表 diff --git a/src/electron/main/index.ts b/src/electron/main/index.ts index a94a35e..3469e5b 100755 --- a/src/electron/main/index.ts +++ b/src/electron/main/index.ts @@ -22,7 +22,7 @@ async function connectWithRetry(maxAttempts = 30, intervalMs = 1000): Promise setTimeout(resolve, intervalMs)); + await new Promise(resolve => setTimeout(resolve, intervalMs)); } } } @@ -72,24 +72,38 @@ app.whenReady().then(async () => { return { success: true }; }); - ipcMain.handle('show-open-dialog', async (_event, options: { - title?: string; - defaultPath?: string; - properties?: string[]; - }) => { - const win = BrowserWindow.getFocusedWindow(); - const dialogOptions = { - title: options.title, - defaultPath: options.defaultPath, - properties: (options.properties ?? ['openDirectory']) as Array< - 'openFile' | 'openDirectory' | 'multiSelections' | 'showHiddenFiles' | 'createDirectory' | 'promptToCreate' | 'noResolveAliases' | 'treatPackageAsDirectory' | 'dontAddToRecent' - >, - }; - const result = win - ? await dialog.showOpenDialog(win, dialogOptions) - : await dialog.showOpenDialog(dialogOptions); - return result; - }); + ipcMain.handle( + 'show-open-dialog', + async ( + _event, + options: { + title?: string; + defaultPath?: string; + properties?: string[]; + } + ) => { + const win = BrowserWindow.getFocusedWindow(); + const dialogOptions = { + title: options.title, + defaultPath: options.defaultPath, + properties: (options.properties ?? ['openDirectory']) as Array< + | 'openFile' + | 'openDirectory' + | 'multiSelections' + | 'showHiddenFiles' + | 'createDirectory' + | 'promptToCreate' + | 'noResolveAliases' + | 'treatPackageAsDirectory' + | 'dontAddToRecent' + >, + }; + const result = win + ? await dialog.showOpenDialog(win, dialogOptions) + : await dialog.showOpenDialog(dialogOptions); + return result; + } + ); ipcMain.handle('window-minimize', () => { const win = BrowserWindow.getFocusedWindow(); diff --git a/src/electron/main/ipc-client.ts b/src/electron/main/ipc-client.ts index c6e867c..8b5f59f 100755 --- a/src/electron/main/ipc-client.ts +++ b/src/electron/main/ipc-client.ts @@ -21,8 +21,8 @@ export class ElectronIpcClient { this.socket = createConnection(getIpcSocketPath(), () => { resolve(); }); - this.socket.on('data', (data) => this.handleData(data)); - this.socket.on('error', (err) => { + this.socket.on('data', data => this.handleData(data)); + this.socket.on('error', err => { // 连接阶段的错误交给 connect Promise if (!this.isConnected()) { reject(err); diff --git a/src/electron/renderer/api/electron-api.ts b/src/electron/renderer/api/electron-api.ts index 6f58ba3..e14d0b7 100755 --- a/src/electron/renderer/api/electron-api.ts +++ b/src/electron/renderer/api/electron-api.ts @@ -56,7 +56,9 @@ export function windowClose(): Promise { return getAPI().windowClose(); } -export function onWindowStateChange(callback: (state: { isMaximized: boolean }) => void): () => void { +export function onWindowStateChange( + callback: (state: { isMaximized: boolean }) => void +): () => void { return getAPI().onWindowStateChange(callback); } diff --git a/src/electron/renderer/components/ArchiveTree.tsx b/src/electron/renderer/components/ArchiveTree.tsx index b8fc25a..84e9f7e 100755 --- a/src/electron/renderer/components/ArchiveTree.tsx +++ b/src/electron/renderer/components/ArchiveTree.tsx @@ -22,7 +22,7 @@ function TreeNode({ node, depth = 0 }: { node: FileTreeNode; depth?: number }) { {isDir && node.children && node.children.length > 0 && (
- {node.children.map((child) => ( + {node.children.map(child => ( ))}
diff --git a/src/electron/renderer/components/ArchiverProviderConfig.tsx b/src/electron/renderer/components/ArchiverProviderConfig.tsx index 1acba28..c77e15c 100644 --- a/src/electron/renderer/components/ArchiverProviderConfig.tsx +++ b/src/electron/renderer/components/ArchiverProviderConfig.tsx @@ -222,8 +222,8 @@ export function ArchiverProviderConfig({ project, onSaved }: ArchiverProviderCon if (loading) { return ( -
-
加载项目知识配置...
+
+
加载项目知识配置...
); } @@ -235,22 +235,22 @@ export function ArchiverProviderConfig({ project, onSaved }: ArchiverProviderCon ]; return ( -
- {error &&
{error}
} - {saved &&
项目知识配置已保存。
} +
+ {error &&
{error}
} + {saved &&
项目知识配置已保存。
} -
-
+
+

Archiver 身份

该名称会作为 Archiver Role 写入 EverOS 的 Agent 身份。

-
- +
+ setConfig(previous => ({ @@ -262,20 +262,20 @@ export function ArchiverProviderConfig({ project, onSaved }: ArchiverProviderCon />
{nameChanged && ( -
+
修改名称会创建新的 EverOS Agent 身份,旧记忆不会自动迁移。
)}
-
-
+
+

自动归档

只需选择更新频率;Provider 的安装、启动和回退由系统自动处理。

-
+
更新频率 系统会自动选择可用知识源,并持续整理代码与文档知识。 @@ -284,15 +284,15 @@ export function ArchiverProviderConfig({ project, onSaved }: ArchiverProviderCon value={scheduleMode} options={scheduleOptions} onChange={changeSchedule} - className='archiver-v2-dropdown' + className="archiver-v2-dropdown" />
{scheduleMode === 'custom' && ( -
- +
+ setConfig(previous => ({ @@ -300,48 +300,48 @@ export function ArchiverProviderConfig({ project, onSaved }: ArchiverProviderCon automation: { ...previous.automation, cron: event.target.value }, })) } - placeholder='0 2 * * *' + placeholder="0 2 * * *" />
)}
-
-
+
+

自动知识方案

无需选择 Provider。系统按内置优先级探测环境,自动完成主源、回退和文档知识提炼。

-
+
{selectedProvider ? `当前主知识源:${selectedProvider.displayName}` : '系统自动遴选知识源'} - 默认优先结构探索 Provider;不可用时自动回退,并始终保留内置文档知识提炼。交互式 - Skill 只自动准备资源,由 Agent 工作流执行。 + 默认优先结构探索 Provider;不可用时自动回退,并始终保留内置文档知识提炼。交互式 Skill + 只自动准备资源,由 Agent 工作流执行。
-
-
+
+

Provider 诊断

仅展示系统自动探测结果,不显示启动命令、参数、环境变量或路径。

-
+
{providers.map(provider => { const probe = probes[provider.id]; const runStatus = latestStatuses.get(provider.id); @@ -354,8 +354,8 @@ export function ArchiverProviderConfig({ project, onSaved }: ArchiverProviderCon ? 'badge-warning' : 'badge-info'; return ( -
-
+
+
{provider.displayName} {providerKindLabel(provider.kind)} @@ -365,18 +365,18 @@ export function ArchiverProviderConfig({ project, onSaved }: ArchiverProviderCon

{provider.description}

-
+
版本:{probe?.version ?? '—'} 最近状态:{statusLabel(runStatus)} {runStatus?.finishedAt && {formatTime(runStatus.finishedAt)}}
{probe?.message && ( - {probe.message} + {probe.message} )} {provider.homepage && (
-
+
- {saved && 已保存} + {saved && 已保存}
); diff --git a/src/electron/renderer/components/AutocompleteInput.tsx b/src/electron/renderer/components/AutocompleteInput.tsx index 41b4e33..11a79f1 100755 --- a/src/electron/renderer/components/AutocompleteInput.tsx +++ b/src/electron/renderer/components/AutocompleteInput.tsx @@ -27,9 +27,7 @@ export function AutocompleteInput({ const filtered = useMemo(() => { const raw = value.trim().toLowerCase(); if (!raw) return options.slice(0, 10); - return options - .filter((o) => o.toLowerCase().includes(raw)) - .slice(0, 10); + return options.filter(o => o.toLowerCase().includes(raw)).slice(0, 10); }, [value, options]); useEffect(() => { @@ -52,11 +50,11 @@ export function AutocompleteInput({ switch (e.key) { case 'ArrowDown': e.preventDefault(); - setHighlighted((prev) => (prev + 1) % filtered.length); + setHighlighted(prev => (prev + 1) % filtered.length); break; case 'ArrowUp': e.preventDefault(); - setHighlighted((prev) => (prev - 1 + filtered.length) % filtered.length); + setHighlighted(prev => (prev - 1 + filtered.length) % filtered.length); break; case 'Enter': e.preventDefault(); @@ -82,16 +80,14 @@ export function AutocompleteInput({ className="input autocomplete-input" value={value} placeholder={placeholder} - onChange={(e) => { + onChange={e => { onChange(e.target.value); setOpen(true); }} onFocus={() => setOpen(true)} onKeyDown={handleKeyDown} /> - {loading && open && ( -
加载中...
- )} + {loading && open &&
加载中...
} {open && !loading && filtered.length > 0 && (
{filtered.map((option, index) => ( diff --git a/src/electron/renderer/components/CircularProgress.tsx b/src/electron/renderer/components/CircularProgress.tsx index b22ec21..db45904 100755 --- a/src/electron/renderer/components/CircularProgress.tsx +++ b/src/electron/renderer/components/CircularProgress.tsx @@ -7,7 +7,8 @@ interface CircularProgressProps { export function CircularProgress({ value, size = 16, strokeWidth = 2 }: CircularProgressProps) { const radius = (size - strokeWidth) / 2; const circumference = 2 * Math.PI * radius; - const offset = value != null ? circumference - (value / 100) * circumference : circumference * 0.75; + const offset = + value != null ? circumference - (value / 100) * circumference : circumference * 0.75; return ( diff --git a/src/electron/renderer/components/CollapsibleSection.tsx b/src/electron/renderer/components/CollapsibleSection.tsx index 9a794c3..3840dc5 100755 --- a/src/electron/renderer/components/CollapsibleSection.tsx +++ b/src/electron/renderer/components/CollapsibleSection.tsx @@ -42,12 +42,14 @@ export function CollapsibleSection({ if (isControlled) { onToggle?.(); } else { - setInternalExpanded((prev) => !prev); + setInternalExpanded(prev => !prev); } }; return ( -
+
{open && (
- {options.map((option) => ( + {options.map(option => (
= { @@ -44,8 +54,8 @@ function edgeKey(e: MemoryGraphEdge): string { function graphsEqual(a: MemoryGraph, b: MemoryGraph): boolean { if (a.nodes.length !== b.nodes.length || a.edges.length !== b.edges.length) return false; - const aNodeIds = a.nodes.map((n) => n.id).sort(); - const bNodeIds = b.nodes.map((n) => n.id).sort(); + const aNodeIds = a.nodes.map(n => n.id).sort(); + const bNodeIds = b.nodes.map(n => n.id).sort(); for (let i = 0; i < aNodeIds.length; i++) { if (aNodeIds[i] !== bNodeIds[i]) return false; } @@ -76,8 +86,12 @@ export function MemoryGraph({ graph, onNodeSelect }: MemoryGraphProps) { graphRef.current = graph; return graph; }, [graph]); - const [activeGroups, setActiveGroups] = useState>(() => new Set(Object.keys(GROUP_LABELS))); - const [tooltip, setTooltip] = useState<{ x: number; y: number; node?: MemoryGraphNode } | null>(null); + const [activeGroups, setActiveGroups] = useState>( + () => new Set(Object.keys(GROUP_LABELS)) + ); + const [tooltip, setTooltip] = useState<{ x: number; y: number; node?: MemoryGraphNode } | null>( + null + ); useEffect(() => { if (!containerRef.current) return; @@ -89,14 +103,16 @@ export function MemoryGraph({ graph, onNodeSelect }: MemoryGraphProps) { const edgeHighlight = style.getPropertyValue('--graph-edge-highlight').trim() || '#2563eb'; const nodes = new DataSet( - stableGraph.nodes.map((n) => ({ + stableGraph.nodes.map(n => ({ ...n, color: colors[n.group as GroupKey], hidden: !activeGroups.has(n.group), })) ); nodesRef.current = nodes; - const edges = new DataSet(stableGraph.edges.map((e, idx) => ({ ...e, id: e.id ?? `edge-${idx}` }))); + const edges = new DataSet( + stableGraph.edges.map((e, idx) => ({ ...e, id: e.id ?? `edge-${idx}` })) + ); const network = new Network( containerRef.current, @@ -105,7 +121,11 @@ export function MemoryGraph({ graph, onNodeSelect }: MemoryGraphProps) { nodes: { shape: 'dot', size: 18, - font: { color: textPrimary, size: 13, face: '-apple-system, BlinkMacSystemFont, Segoe UI, Roboto, sans-serif' }, + font: { + color: textPrimary, + size: 13, + face: '-apple-system, BlinkMacSystemFont, Segoe UI, Roboto, sans-serif', + }, borderWidth: 2, shadow: { enabled: true, color: 'rgba(0,0,0,0.5)', size: 10, x: 0, y: 0 }, }, @@ -129,16 +149,16 @@ export function MemoryGraph({ graph, onNodeSelect }: MemoryGraphProps) { } ); - network.on('click', (params) => { + network.on('click', params => { if (params.nodes.length > 0 && onNodeSelect) { const nodeId = params.nodes[0] as string; - const node = stableGraph.nodes.find((n) => n.id === nodeId); + const node = stableGraph.nodes.find(n => n.id === nodeId); if (node) onNodeSelect(node); } }); - network.on('hoverNode', (params) => { - const node = stableGraph.nodes.find((n) => n.id === params.node); + network.on('hoverNode', params => { + const node = stableGraph.nodes.find(n => n.id === params.node); if (!node) return; const domCoords = network.canvasToDOM(network.getPositions([params.node])[params.node]); const rect = containerRef.current!.getBoundingClientRect(); @@ -185,7 +205,7 @@ export function MemoryGraph({ graph, onNodeSelect }: MemoryGraphProps) { }, [theme]); const toggleGroup = (group: string) => { - setActiveGroups((prev) => { + setActiveGroups(prev => { const next = new Set(prev); if (next.has(group)) next.delete(group); else next.add(group); @@ -193,8 +213,10 @@ export function MemoryGraph({ graph, onNodeSelect }: MemoryGraphProps) { }); }; - const zoomIn = () => networkRef.current?.moveTo({ scale: (networkRef.current.getScale() ?? 1) * 1.2 }); - const zoomOut = () => networkRef.current?.moveTo({ scale: (networkRef.current.getScale() ?? 1) / 1.2 }); + const zoomIn = () => + networkRef.current?.moveTo({ scale: (networkRef.current.getScale() ?? 1) * 1.2 }); + const zoomOut = () => + networkRef.current?.moveTo({ scale: (networkRef.current.getScale() ?? 1) / 1.2 }); const fit = () => networkRef.current?.fit({ animation: true }); const colors = readGraphColors(); @@ -224,26 +246,35 @@ export function MemoryGraph({ graph, onNodeSelect }: MemoryGraphProps) {
{Object.entries(GROUP_LABELS).map(([group, label]) => (
- + {label}
))}
- - - + + +
{tooltip?.node && ( -
+
{GROUP_LABELS[tooltip.node.group]} diff --git a/src/electron/renderer/components/MemoryNodePanel.tsx b/src/electron/renderer/components/MemoryNodePanel.tsx index f639558..0895ce8 100755 --- a/src/electron/renderer/components/MemoryNodePanel.tsx +++ b/src/electron/renderer/components/MemoryNodePanel.tsx @@ -38,22 +38,30 @@ const GROUP_LABELS: Record = { export function MemoryNodePanel({ node, graph, onClose, onNodeClick }: MemoryNodePanelProps) { const connections = useMemo(() => { return graph.edges - .filter((e) => e.from === node.id || e.to === node.id) - .map((e) => { + .filter(e => e.from === node.id || e.to === node.id) + .map(e => { const otherId = e.from === node.id ? e.to : e.from; - const other = graph.nodes.find((n) => n.id === otherId); - return { edge: e, other: other ?? { id: otherId, label: otherId, group: 'system' as const } }; + const other = graph.nodes.find(n => n.id === otherId); + return { + edge: e, + other: other ?? { id: otherId, label: otherId, group: 'system' as const }, + }; }); }, [node, graph]); return (
- +
{GROUP_LABELS[node.group] ?? node.group} @@ -113,7 +121,9 @@ export function MemoryNodePanel({ node, graph, onClose, onNodeClick }: MemoryNod style={{ backgroundColor: GROUP_COLORS[other.group] ?? '#8b949e' }} /> {other.label} - {edge.label && {edge.label}} + {edge.label && ( + {edge.label} + )} ))}
diff --git a/src/electron/renderer/components/MemoryProgressView.tsx b/src/electron/renderer/components/MemoryProgressView.tsx index aa3af48..2bfb0f1 100755 --- a/src/electron/renderer/components/MemoryProgressView.tsx +++ b/src/electron/renderer/components/MemoryProgressView.tsx @@ -10,7 +10,7 @@ interface MemoryProgressViewProps { * Progress View 页签:统计、增长图、时间线(EverOS 风格) */ export function MemoryProgressView({ stats, graph }: MemoryProgressViewProps) { - const maxGrowth = Math.max(1, ...stats.dailyGrowth.map((d) => d.count)); + const maxGrowth = Math.max(1, ...stats.dailyGrowth.map(d => d.count)); const timeline = useMemo(() => { const groups = new Map(); @@ -37,7 +37,9 @@ export function MemoryProgressView({ stats, graph }: MemoryProgressViewProps) {
Total Memories
{stats.totalMemories}
-
+{stats.dailyGrowth.slice(-1)[0]?.count ?? 0} today
+
+ +{stats.dailyGrowth.slice(-1)[0]?.count ?? 0} today +
Connections
@@ -59,7 +61,7 @@ export function MemoryProgressView({ stats, graph }: MemoryProgressViewProps) {
Memory Growth (Last 14 Days)
- {stats.dailyGrowth.map((d) => ( + {stats.dailyGrowth.map(d => (
{day.count} memories
- {day.nodes.map((node) => ( + {day.nodes.map(node => (
{node.label}
- {node.details &&
{node.details.slice(0, 120)}
} + {node.details && ( +
+ {node.details.slice(0, 120)} +
+ )}
))}
diff --git a/src/electron/renderer/components/PageHeader.tsx b/src/electron/renderer/components/PageHeader.tsx index 240a430..f051658 100755 --- a/src/electron/renderer/components/PageHeader.tsx +++ b/src/electron/renderer/components/PageHeader.tsx @@ -20,9 +20,7 @@ export function PageHeader({ icon, title, onRefresh, extra }: PageHeaderProps) { const { sidebarCollapsed } = useLayout(); return ( -
+
{icon}

{title}

@@ -31,12 +29,7 @@ export function PageHeader({ icon, title, onRefresh, extra }: PageHeaderProps) {
{extra} {onRefresh && ( - )} diff --git a/src/electron/renderer/components/ProjectCard.tsx b/src/electron/renderer/components/ProjectCard.tsx index cfe966d..b8938b3 100755 --- a/src/electron/renderer/components/ProjectCard.tsx +++ b/src/electron/renderer/components/ProjectCard.tsx @@ -24,13 +24,13 @@ export function ProjectCard({ project }: { project: ProjectSummary }) {

{project.name}

{project.rootPath}
- {project.archiveRoot && ( -
归档位置: {project.archiveRoot}
- )} + {project.archiveRoot &&
归档位置: {project.archiveRoot}
}
健康度
-
{healthPercent}%
+
+ {healthPercent}% +
已复制
@@ -46,7 +46,9 @@ export function ProjectCard({ project }: { project: ProjectSummary }) {
- 待处理: {project.pending} · 已归档: {project.archived} · 已忽略: {project.ignored} · 已孤儿: {project.orphaned} · 最后扫描: {project.lastScannedAt ? new Date(project.lastScannedAt).toLocaleString() : '从未'} + 待处理: {project.pending} · 已归档: {project.archived} · 已忽略: {project.ignored} · 已孤儿:{' '} + {project.orphaned} · 最后扫描:{' '} + {project.lastScannedAt ? new Date(project.lastScannedAt).toLocaleString() : '从未'}
); diff --git a/src/electron/renderer/components/ServiceStatusPanel.tsx b/src/electron/renderer/components/ServiceStatusPanel.tsx index f00d444..a87bc44 100755 --- a/src/electron/renderer/components/ServiceStatusPanel.tsx +++ b/src/electron/renderer/components/ServiceStatusPanel.tsx @@ -45,7 +45,15 @@ function truncateError(message: string | null | undefined): { text: string; trun return { text: `${message.slice(0, MAX_ERROR_LENGTH)}...`, truncated: true }; } -function StatusBadge({ state, isDaemon = false, running }: { state?: string; isDaemon?: boolean; running?: boolean }) { +function StatusBadge({ + state, + isDaemon = false, + running, +}: { + state?: string; + isDaemon?: boolean; + running?: boolean; +}) { let display: StatusDisplay; if (isDaemon) { display = running @@ -54,7 +62,11 @@ function StatusBadge({ state, isDaemon = false, running }: { state?: string; isD } else if (state && state in STATE_DISPLAY) { display = STATE_DISPLAY[state]; } else { - display = { label: state ?? '未知', badgeClass: 'badge-secondary', dotClass: 'status-dot-idle' }; + display = { + label: state ?? '未知', + badgeClass: 'badge-secondary', + dotClass: 'status-dot-idle', + }; } return ( @@ -116,15 +128,17 @@ function TreeNode({ )}
- {url && status === 'running' && ( -
{url}
- )} + {url && status === 'running' &&
{url}
} {detail &&
{detail}
} {hasError && ( -
+
{errorText} - {truncated && (查看日志获取完整信息)} + {truncated && ( + (查看日志获取完整信息) + )}
)} @@ -137,7 +151,7 @@ export function ServiceStatusPanel({ daemon, localModel, remoteModel }: ServiceS const [expandedKeys, setExpandedKeys] = useState>(new Set()); const toggle = (key: NodeKey) => { - setExpandedKeys((prev) => { + setExpandedKeys(prev => { const next = new Set(prev); if (next.has(key)) { next.delete(key); @@ -155,117 +169,117 @@ export function ServiceStatusPanel({ daemon, localModel, remoteModel }: ServiceS
+ + + {daemon?.codeGraph.providers.map(provider => ( + + ))} + + - {daemon?.codeGraph.providers.map(provider => ( - - ))} - + /> + + {remoteModel && ( - {remoteModel && ( - - - - - )} - -
+ )} + +
); @@ -282,18 +296,22 @@ function formatCodeGraphJobStatus(daemon: DaemonStatus | null): string | null { } function formatCodeGraphProviderDetail(provider: CodeGraphProviderStatus): string | null { - const parts = [provider.version ? `v${provider.version}` : '', provider.message ?? ''].filter(Boolean); + const parts = [provider.version ? `v${provider.version}` : '', provider.message ?? ''].filter( + Boolean + ); return parts.join(' · ') || null; } -function inferLocalModelState(localModel: LocalModelStatus | null): ModelServiceStatus['state'] | undefined { +function inferLocalModelState( + localModel: LocalModelStatus | null +): ModelServiceStatus['state'] | undefined { if (!localModel) return 'idle'; const states = [localModel.embedding.state, localModel.rerank.state]; - if (states.some((s) => s === 'error')) return 'error'; - if (states.some((s) => s === 'running')) return 'running'; - if (states.some((s) => s === 'loading')) return 'loading'; - if (states.some((s) => s === 'downloading')) return 'downloading'; - if (states.some((s) => s === 'starting')) return 'starting'; + if (states.some(s => s === 'error')) return 'error'; + if (states.some(s => s === 'running')) return 'running'; + if (states.some(s => s === 'loading')) return 'loading'; + if (states.some(s => s === 'downloading')) return 'downloading'; + if (states.some(s => s === 'starting')) return 'starting'; return 'idle'; } @@ -303,11 +321,13 @@ function inferLocalModelError(localModel: LocalModelStatus | null): string | nul return errors.length > 0 ? errors.join('; ') : null; } -function inferRemoteModelState(remoteModel: RemoteModelStatus | null): 'unconfigured' | 'running' | 'error' | undefined { +function inferRemoteModelState( + remoteModel: RemoteModelStatus | null +): 'unconfigured' | 'running' | 'error' | undefined { if (!remoteModel) return 'unconfigured'; const states = [remoteModel.llm.state, remoteModel.multimodal.state]; - if (states.some((s) => s === 'error')) return 'error'; - if (states.some((s) => s === 'running')) return 'running'; + if (states.some(s => s === 'error')) return 'error'; + if (states.some(s => s === 'running')) return 'running'; return 'unconfigured'; } diff --git a/src/electron/renderer/components/ThemeToggle.tsx b/src/electron/renderer/components/ThemeToggle.tsx index 10e45f2..702b404 100755 --- a/src/electron/renderer/components/ThemeToggle.tsx +++ b/src/electron/renderer/components/ThemeToggle.tsx @@ -17,12 +17,30 @@ export function ThemeToggle() { > {isDark ? ( - + ) : ( - + )} diff --git a/src/electron/renderer/components/TitleBar.tsx b/src/electron/renderer/components/TitleBar.tsx index 7664720..10ea2c4 100755 --- a/src/electron/renderer/components/TitleBar.tsx +++ b/src/electron/renderer/components/TitleBar.tsx @@ -1,11 +1,16 @@ import { useEffect, useState } from 'react'; -import { windowMinimize, windowMaximize, windowClose, onWindowStateChange } from '../api/electron-api'; +import { + windowMinimize, + windowMaximize, + windowClose, + onWindowStateChange, +} from '../api/electron-api'; export function TitleBar() { const [isMaximized, setIsMaximized] = useState(false); useEffect(() => { - const unsubscribe = onWindowStateChange((state) => { + const unsubscribe = onWindowStateChange(state => { setIsMaximized(state.isMaximized); }); return unsubscribe; diff --git a/src/electron/renderer/contexts/LayoutContext.tsx b/src/electron/renderer/contexts/LayoutContext.tsx index f064288..311c983 100755 --- a/src/electron/renderer/contexts/LayoutContext.tsx +++ b/src/electron/renderer/contexts/LayoutContext.tsx @@ -19,7 +19,7 @@ export function LayoutProvider({ children, initialCollapsed = false }: LayoutPro const [sidebarCollapsed, setSidebarCollapsed] = useState(initialCollapsed); const toggleSidebar = useCallback(() => { - setSidebarCollapsed((prev) => !prev); + setSidebarCollapsed(prev => !prev); }, []); return ( diff --git a/src/electron/renderer/contexts/ThemeContext.tsx b/src/electron/renderer/contexts/ThemeContext.tsx index 4922fa0..3c90fe8 100755 --- a/src/electron/renderer/contexts/ThemeContext.tsx +++ b/src/electron/renderer/contexts/ThemeContext.tsx @@ -21,13 +21,13 @@ export function ThemeProvider({ children }: { children: ReactNode }) { let cancelled = false; window.electronAPI .invoke('theme.get') - .then((saved) => { + .then(saved => { if (cancelled) return; const next = saved === 'light' ? 'light' : 'dark'; setTheme(next); document.documentElement.dataset.theme = next; }) - .catch((err) => { + .catch(err => { if (cancelled) return; console.warn('读取主题配置失败,使用默认暗色主题', err); document.documentElement.dataset.theme = 'dark'; @@ -41,10 +41,10 @@ export function ThemeProvider({ children }: { children: ReactNode }) { }, []); const toggleTheme = useCallback(() => { - setTheme((prev) => { + setTheme(prev => { const next = prev === 'dark' ? 'light' : 'dark'; document.documentElement.dataset.theme = next; - window.electronAPI.invoke('theme.set', { theme: next }).catch((err) => { + window.electronAPI.invoke('theme.set', { theme: next }).catch(err => { console.warn('保存主题配置失败', err); }); return next; diff --git a/src/electron/renderer/hooks/useIpc.ts b/src/electron/renderer/hooks/useIpc.ts index 68844c0..a6fb590 100755 --- a/src/electron/renderer/hooks/useIpc.ts +++ b/src/electron/renderer/hooks/useIpc.ts @@ -36,11 +36,11 @@ export function useIpc(method: string, params?: unknown, options?: { pollInte if (!silent) setLoading(true); setError(null); return invoke(method, params) - .then((result) => { + .then(result => { cache.set(cacheKey, result); setData(result); }) - .catch((err) => setError(err instanceof Error ? err.message : String(err))) + .catch(err => setError(err instanceof Error ? err.message : String(err))) .finally(() => { if (!silent) setLoading(false); }); @@ -102,11 +102,9 @@ export function useIpc(method: string, params?: unknown, options?: { pollInte const mutate = useCallback( (value: T | ((prev: T | null) => T | null)) => { - setData((prev) => { + setData(prev => { const next = - typeof value === 'function' - ? (value as (prev: T | null) => T | null)(prev) - : value; + typeof value === 'function' ? (value as (prev: T | null) => T | null)(prev) : value; if (next !== null) { cache.set(cacheKey, next); } diff --git a/src/electron/renderer/hooks/useMemoryGraph.ts b/src/electron/renderer/hooks/useMemoryGraph.ts index 1c2e9a3..967e1ef 100755 --- a/src/electron/renderer/hooks/useMemoryGraph.ts +++ b/src/electron/renderer/hooks/useMemoryGraph.ts @@ -18,7 +18,11 @@ const EMPTY_GRAPH: MemoryGraph = { * 拉取并轮询记忆图谱数据 */ export function useMemoryGraph() { - const { data, loading, error, refresh } = useIpc('memory.graph', {}, { pollInterval: 5000 }); + const { data, loading, error, refresh } = useIpc( + 'memory.graph', + {}, + { pollInterval: 5000 } + ); return { graph: data ?? EMPTY_GRAPH, diff --git a/src/electron/renderer/hooks/useModelLogs.ts b/src/electron/renderer/hooks/useModelLogs.ts index 9ea7d79..e79b4eb 100755 --- a/src/electron/renderer/hooks/useModelLogs.ts +++ b/src/electron/renderer/hooks/useModelLogs.ts @@ -14,14 +14,22 @@ export function useModelLogs(lines?: number): ModelLogsResult { loading: embeddingLoading, error: embeddingError, refresh: refreshEmbedding, - } = useIpc<{ lines: string[] }>('localModel.logs', { capability: 'embedding', lines }, { pollInterval: 3000 }); + } = useIpc<{ lines: string[] }>( + 'localModel.logs', + { capability: 'embedding', lines }, + { pollInterval: 3000 } + ); const { data: rerankData, loading: rerankLoading, error: rerankError, refresh: refreshRerank, - } = useIpc<{ lines: string[] }>('localModel.logs', { capability: 'rerank', lines }, { pollInterval: 3000 }); + } = useIpc<{ lines: string[] }>( + 'localModel.logs', + { capability: 'rerank', lines }, + { pollInterval: 3000 } + ); return { embedding: embeddingData?.lines ?? [], diff --git a/src/electron/renderer/hooks/useServiceStatus.ts b/src/electron/renderer/hooks/useServiceStatus.ts index d543e89..b6cd341 100755 --- a/src/electron/renderer/hooks/useServiceStatus.ts +++ b/src/electron/renderer/hooks/useServiceStatus.ts @@ -1,5 +1,9 @@ import { useIpc } from './useIpc'; -import type { DaemonStatus, LocalModelStatus, RemoteModelStatus } from '../../shared/service-status'; +import type { + DaemonStatus, + LocalModelStatus, + RemoteModelStatus, +} from '../../shared/service-status'; const POLL_INTERVAL_MS = 3000; @@ -36,7 +40,9 @@ export function useServiceStatus(): ServiceStatusResult { loading: remoteModelLoading, error: remoteModelError, refresh: refreshRemoteModel, - } = useIpc('remoteModel.status', undefined, { pollInterval: POLL_INTERVAL_MS }); + } = useIpc('remoteModel.status', undefined, { + pollInterval: POLL_INTERVAL_MS, + }); return { daemon: daemon ?? null, diff --git a/src/electron/renderer/index.html b/src/electron/renderer/index.html index ea6605a..aa37e05 100755 --- a/src/electron/renderer/index.html +++ b/src/electron/renderer/index.html @@ -1,4 +1,4 @@ - + diff --git a/src/electron/renderer/pages/ActionHistory.tsx b/src/electron/renderer/pages/ActionHistory.tsx index 1a46f0a..8c8123f 100755 --- a/src/electron/renderer/pages/ActionHistory.tsx +++ b/src/electron/renderer/pages/ActionHistory.tsx @@ -34,7 +34,7 @@ export function ActionHistory() { className="input" placeholder="项目 ID(留空查询全部)" value={projectId} - onChange={(e) => setProjectId(e.target.value)} + onChange={e => setProjectId(e.target.value)} />
@@ -57,13 +57,15 @@ export function ActionHistory() { - {items.map((item) => ( + {items.map(item => ( {item.id} {item.type} {item.sourcePath} - + {item.status} diff --git a/src/electron/renderer/pages/Dashboard.tsx b/src/electron/renderer/pages/Dashboard.tsx index 66dd141..59ca949 100755 --- a/src/electron/renderer/pages/Dashboard.tsx +++ b/src/electron/renderer/pages/Dashboard.tsx @@ -62,7 +62,7 @@ export function Dashboard() { archiveRoot?: string; lastScannedAt: number | null; }; - mutate((prev) => { + mutate(prev => { const summary: ProjectSummary = { id: newProject.id, name: newProject.name, @@ -93,7 +93,7 @@ export function Dashboard() { const unregister = async (projectId: string) => { try { await window.electronAPI.invoke('project.unregister', { projectId }); - mutate((prev) => prev?.filter((p) => p.id !== projectId) ?? null); + mutate(prev => prev?.filter(p => p.id !== projectId) ?? null); refresh(true); } catch (err) { alert(err instanceof Error ? err.message : String(err)); @@ -115,9 +115,11 @@ export function Dashboard() { className="input" placeholder="点击右侧按钮选择项目目录" value={rootPath} - onChange={(e) => setRootPath(e.target.value)} + onChange={e => setRootPath(e.target.value)} /> - +
@@ -128,9 +130,11 @@ export function Dashboard() { className="input" placeholder="点击右侧按钮选择归档目录" value={archiveRoot} - onChange={(e) => setArchiveRoot(e.target.value)} + onChange={e => setArchiveRoot(e.target.value)} /> - +
@@ -144,21 +148,21 @@ export function Dashboard() { {registerError &&
{registerError}
}
- {(!projects || projects.length === 0) ? ( + {!projects || projects.length === 0 ? (

暂无注册项目

在上方选择项目路径和归档位置,即可开始监控。

) : (
- {projects.map((p) => ( + {projects.map(p => (
navigate(`/project/${p.id}`)} role="button" tabIndex={0} - onKeyDown={(e) => { + onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') { navigate(`/project/${p.id}`); } @@ -168,7 +172,7 @@ export function Dashboard() {
diff --git a/src/electron/renderer/pages/MemoryGraphPage.tsx b/src/electron/renderer/pages/MemoryGraphPage.tsx index b415a06..2598868 100755 --- a/src/electron/renderer/pages/MemoryGraphPage.tsx +++ b/src/electron/renderer/pages/MemoryGraphPage.tsx @@ -12,20 +12,14 @@ export function MemoryGraphPage() { return (
- } - title="记忆图谱" - onRefresh={() => refresh()} - /> + } title="记忆图谱" onRefresh={() => refresh()} /> {error &&
加载失败: {error}
} {loading &&
Loading…
}
{!loading && !error && stats.projectCount === 0 && ( -
- 暂无项目,请先注册项目后再查看记忆图谱。 -
+
暂无项目,请先注册项目后再查看记忆图谱。
)} {!loading && !error && stats.projectCount > 0 && } {!loading && !error && stats.projectCount > 0 && stats.totalMemories === 0 && ( diff --git a/src/electron/renderer/pages/MemoryStatsPage.tsx b/src/electron/renderer/pages/MemoryStatsPage.tsx index 91635fe..4c22cfd 100755 --- a/src/electron/renderer/pages/MemoryStatsPage.tsx +++ b/src/electron/renderer/pages/MemoryStatsPage.tsx @@ -11,11 +11,7 @@ export function MemoryStatsPage() { const stats = graph.stats; return ( - } - title="记忆统计" - onRefresh={() => refresh()} - > + } title="记忆统计" onRefresh={() => refresh()}>
{error &&
加载失败: {error}
} {loading &&
Loading…
} diff --git a/src/electron/renderer/pages/ProjectDetail.tsx b/src/electron/renderer/pages/ProjectDetail.tsx index 6f5f50d..845cad1 100755 --- a/src/electron/renderer/pages/ProjectDetail.tsx +++ b/src/electron/renderer/pages/ProjectDetail.tsx @@ -18,15 +18,32 @@ export function ProjectDetail() { const [tab, setTab] = useState('context'); const [scanning, setScanning] = useState(false); const [scanError, setScanError] = useState(null); - const { data: project } = useIpc<{ name: string; rootPath: string; archiveRoot?: string }>('project.get', { projectId: id }); - const { data: context, refresh: refreshContext } = useIpc<{ content: string }>('project.context', { projectId: id }); - const { data: suggestions, refresh: refreshSuggestions } = useIpc<{ content: string }>('project.suggestions', { projectId: id }); - const { data: archiveTree, refresh: refreshArchiveTree } = useIpc<{ tree: FileTreeNode | null }>('project.archive.tree', { projectId: id }); - const { data: status, refresh: refreshStatus } = useIpc('project.status', { projectId: id }); + const { data: project } = useIpc<{ name: string; rootPath: string; archiveRoot?: string }>( + 'project.get', + { projectId: id } + ); + const { data: context, refresh: refreshContext } = useIpc<{ content: string }>( + 'project.context', + { projectId: id } + ); + const { data: suggestions, refresh: refreshSuggestions } = useIpc<{ content: string }>( + 'project.suggestions', + { projectId: id } + ); + const { data: archiveTree, refresh: refreshArchiveTree } = useIpc<{ tree: FileTreeNode | null }>( + 'project.archive.tree', + { projectId: id } + ); + const { data: status, refresh: refreshStatus } = useIpc('project.status', { + projectId: id, + }); useEffect(() => { - const unsubscribe = window.electronAPI.onPush((event) => { - if (event.event === 'archive-tree-changed' && (event.payload as { projectId?: string }).projectId === id) { + const unsubscribe = window.electronAPI.onPush(event => { + if ( + event.event === 'archive-tree-changed' && + (event.payload as { projectId?: string }).projectId === id + ) { refreshArchiveTree(); } }); @@ -38,7 +55,12 @@ export function ProjectDetail() { setScanError(null); try { await invoke('project.scan', { projectId: id }); - await Promise.all([refreshContext(), refreshSuggestions(), refreshArchiveTree(), refreshStatus()]); + await Promise.all([ + refreshContext(), + refreshSuggestions(), + refreshArchiveTree(), + refreshStatus(), + ]); } catch (err) { setScanError(err instanceof Error ? err.message : String(err)); } finally { @@ -56,7 +78,16 @@ export function ProjectDetail() { return ( } title={project?.name ?? '项目详情'} onRefresh={refreshAll}> - + @@ -65,10 +96,19 @@ export function ProjectDetail() { {project && (
-
+
项目路径: {project.rootPath}
- {project.archiveRoot &&
归档位置: {project.archiveRoot}
} + {project.archiveRoot && ( +
归档位置: {project.archiveRoot}
+ )}
)} - {scanError &&
扫描失败: {scanError}
} + {scanError && ( +
+ 扫描失败: {scanError} +
+ )}
- - - - + + + +
diff --git a/src/electron/renderer/pages/RolePage.tsx b/src/electron/renderer/pages/RolePage.tsx index 0214c7b..70a5e16 100755 --- a/src/electron/renderer/pages/RolePage.tsx +++ b/src/electron/renderer/pages/RolePage.tsx @@ -245,8 +245,7 @@ export function RolePage({ role }: RolePageProps) { automation: { ...(roleConfig?.role === 'archiver' ? roleConfig.automation - : (ui.defaultConfig as Extract) - .automation), + : (ui.defaultConfig as Extract).automation), enabled: nextEnabled, }, } diff --git a/src/electron/renderer/pages/Settings.tsx b/src/electron/renderer/pages/Settings.tsx index db2e79b..15ed0d7 100755 --- a/src/electron/renderer/pages/Settings.tsx +++ b/src/electron/renderer/pages/Settings.tsx @@ -70,7 +70,7 @@ function parseEverOS(json: string): EverOSConfig { function stringifyEverOS(cfg: EverOSConfig): string { const cleaned: EverOSConfig = {}; - (Object.keys(cfg) as Array).forEach((key) => { + (Object.keys(cfg) as Array).forEach(key => { const value = cfg[key]; if (typeof value === 'string' && value.trim()) { (cleaned[key] as string | undefined) = value.trim(); @@ -111,22 +111,40 @@ function SecretInput({ value, onChange, placeholder, ariaLabel, error }: SecretI className={`input input-with-btn ${error ? 'input-error input-flash' : ''}`} value={value} placeholder={placeholder} - onChange={(e) => onChange(e.target.value)} + onChange={e => onChange(e.target.value)} />
))} - +
); @@ -269,7 +312,7 @@ function DaemonScheduleSection({ llmRequestsPerMinute: number; setLlmRequestsPerMinute: (value: number) => void; }) { - const isCustomCron = !SCAN_INTERVALS.some((i) => i.cron === scanCron && i.cron !== 'custom'); + const isCustomCron = !SCAN_INTERVALS.some(i => i.cron === scanCron && i.cron !== 'custom'); const handleIntervalChange = (value: string) => { if (value === 'custom') { @@ -291,8 +334,8 @@ function DaemonScheduleSection({ ({ value: i.cron, label: i.label }))} - onChange={(value) => handleIntervalChange(value)} + options={SCAN_INTERVALS.map(i => ({ value: i.cron, label: i.label }))} + onChange={value => handleIntervalChange(value)} /> {isCustomCron && ( handleCustomCronChange(e.target.value)} + onChange={e => handleCustomCronChange(e.target.value)} /> )} -
当前 cron: {scanCron}
+
+ 当前 cron: {scanCron} +
@@ -314,7 +359,7 @@ function DaemonScheduleSection({ min={1} max={600} value={llmRequestsPerMinute} - onChange={(e) => setLlmRequestsPerMinute(Math.max(1, Number(e.target.value) || 10))} + onChange={e => setLlmRequestsPerMinute(Math.max(1, Number(e.target.value) || 10))} />
请求间隔约 {Math.ceil(60000 / llmRequestsPerMinute)}ms @@ -378,8 +423,11 @@ function LocalModelsSection({ ({ value: m, label: m })), { value: 'custom', label: '自定义' }]} - onChange={(value) => { + options={[ + ...EMBEDDING_MODELS.map(m => ({ value: m, label: m })), + { value: 'custom', label: '自定义' }, + ]} + onChange={value => { if (value === 'custom') { setEmbeddingModel(embeddingCustom || ''); } else { @@ -393,7 +441,7 @@ function LocalModelsSection({ style={{ marginTop: 8 }} placeholder="HuggingFace 模型名,例如 organization/model-name" value={EMBEDDING_MODELS.includes(embeddingModel) ? embeddingCustom : embeddingModel} - onChange={(e) => { + onChange={e => { const value = e.target.value; if (EMBEDDING_MODELS.includes(embeddingModel)) { setEmbeddingCustom(value); @@ -412,8 +460,11 @@ function LocalModelsSection({ ({ value: m, label: m })), { value: 'custom', label: '自定义' }]} - onChange={(value) => { + options={[ + ...RERANK_MODELS.map(m => ({ value: m, label: m })), + { value: 'custom', label: '自定义' }, + ]} + onChange={value => { if (value === 'custom') { setRerankModel(rerankCustom || ''); } else { @@ -427,7 +478,7 @@ function LocalModelsSection({ style={{ marginTop: 8 }} placeholder="HuggingFace 模型名" value={RERANK_MODELS.includes(rerankModel) ? rerankCustom : rerankModel} - onChange={(e) => { + onChange={e => { const value = e.target.value; if (RERANK_MODELS.includes(rerankModel)) { setRerankCustom(value); @@ -477,17 +528,27 @@ function MultimodalConfigSection({ apiKey.trim() || apiUrl.trim() || model.trim() || - headerEntries.some((e) => e.key.trim() || e.value.trim()); + headerEntries.some(e => e.key.trim() || e.value.trim()); return (
已自定义 : 未配置} + badge={ + hasCustom ? ( + 已自定义 + ) : ( + 未配置 + ) + } >
- setProvider(value)} /> + setProvider(value)} + />
@@ -506,7 +567,7 @@ function MultimodalConfigSection({ className="input" value={apiUrl} placeholder="留空使用默认" - onChange={(e) => setApiUrl(e.target.value)} + onChange={e => setApiUrl(e.target.value)} />
OpenAI 兼容示例:https://your-openai-proxy.example.com/v1 @@ -519,7 +580,7 @@ function MultimodalConfigSection({ className="input" value={model} placeholder={provider === 'openai' ? 'gpt-4o' : 'claude-3-5-sonnet-20241022'} - onChange={(e) => setModel(e.target.value)} + onChange={e => setModel(e.target.value)} />
@@ -531,13 +592,13 @@ function MultimodalConfigSection({ className="input" placeholder="Header 名称" value={entry.key} - onChange={(e) => updateHeader(index, 'key', e.target.value)} + onChange={e => updateHeader(index, 'key', e.target.value)} /> updateHeader(index, 'value', e.target.value)} + onChange={e => updateHeader(index, 'value', e.target.value)} />
))} - +
); @@ -579,7 +651,9 @@ export function Settings() { const [multimodalApiKey, setMultimodalApiKey] = useState(''); const [multimodalApiUrl, setMultimodalApiUrl] = useState(''); const [multimodalModel, setMultimodalModel] = useState(''); - const [multimodalHeaderEntries, setMultimodalHeaderEntries] = useState([{ key: '', value: '' }]); + const [multimodalHeaderEntries, setMultimodalHeaderEntries] = useState([ + { key: '', value: '' }, + ]); const [embeddingModel, setEmbeddingModel] = useState(DEFAULT_EMBEDDING_MODEL); const [rerankModel, setRerankModel] = useState(DEFAULT_RERANK_MODEL); @@ -612,7 +686,7 @@ export function Settings() { }, [data]); const updateHeader = (index: number, field: keyof HeaderEntry, value: string) => { - setHeaderEntries((prev) => { + setHeaderEntries(prev => { const next = [...prev]; next[index] = { ...next[index], [field]: value }; return next; @@ -620,18 +694,18 @@ export function Settings() { }; const addHeader = () => { - setHeaderEntries((prev) => [...prev, { key: '', value: '' }]); + setHeaderEntries(prev => [...prev, { key: '', value: '' }]); }; const removeHeader = (index: number) => { - setHeaderEntries((prev) => { + setHeaderEntries(prev => { const next = prev.filter((_, i) => i !== index); return next.length > 0 ? next : [{ key: '', value: '' }]; }); }; const updateMultimodalHeader = (index: number, field: keyof HeaderEntry, value: string) => { - setMultimodalHeaderEntries((prev) => { + setMultimodalHeaderEntries(prev => { const next = [...prev]; next[index] = { ...next[index], [field]: value }; return next; @@ -639,11 +713,11 @@ export function Settings() { }; const addMultimodalHeader = () => { - setMultimodalHeaderEntries((prev) => [...prev, { key: '', value: '' }]); + setMultimodalHeaderEntries(prev => [...prev, { key: '', value: '' }]); }; const removeMultimodalHeader = (index: number) => { - setMultimodalHeaderEntries((prev) => { + setMultimodalHeaderEntries(prev => { const next = prev.filter((_, i) => i !== index); return next.length > 0 ? next : [{ key: '', value: '' }]; }); @@ -657,7 +731,9 @@ export function Settings() { const finalEmbedding = EMBEDDING_MODELS.includes(embeddingModel) ? embeddingModel : embeddingModel || embeddingCustom; - const finalRerank = RERANK_MODELS.includes(rerankModel) ? rerankModel : rerankModel || rerankCustom; + const finalRerank = RERANK_MODELS.includes(rerankModel) + ? rerankModel + : rerankModel || rerankCustom; if (!isValidHuggingFaceModelId(finalEmbedding)) { setLocalModelError(`Embedding 模型名不合法: ${finalEmbedding}`); @@ -698,7 +774,8 @@ export function Settings() { const mmHeaders = stringifyHeaders(multimodalHeaderEntries); const everosCfg: EverOSConfig = {}; - if (multimodalProvider.trim()) everosCfg.multimodalProvider = multimodalProvider.trim() as 'anthropic' | 'openai'; + if (multimodalProvider.trim()) + everosCfg.multimodalProvider = multimodalProvider.trim() as 'anthropic' | 'openai'; if (multimodalApiKey.trim()) everosCfg.multimodalApiKey = multimodalApiKey.trim(); if (multimodalApiUrl.trim()) everosCfg.multimodalBaseUrl = multimodalApiUrl.trim(); if (multimodalModel.trim()) everosCfg.multimodalModel = multimodalModel.trim(); @@ -804,7 +881,7 @@ export function Settings() {
- {TABS.map((tab) => ( + {TABS.map(tab => ( + ))} + 编辑模式下拖出连线;选中后按 Delete 删除 + + {pipeline.generated && !dirty && 自动生成} + {dirty && 未保存} + + {dirty && ( + + )} +
+ {saveError &&
保存失败:{saveError}
} +
+ + {selectedNode && ( + { + mutate(def => updateNodeInDefinition(def, updated)); + }} + onConfigSaved={refresh} + /> + )} +
+
+ ); +} + +/** 节点检查器:trigger 编辑调度;reviewer/maintainer 嵌入角色配置;其余展示只读信息 */ +function NodeInspector({ + node, + definition, + project, + generated, + onChange, + onConfigSaved, +}: { + node: PipelineNodeDto; + definition: PipelineDefinitionDto; + project: Project | null; + /** 正本是否仍是自动生成件(画布保存后转人类正本) */ + generated: boolean; + onChange: (node: PipelineNodeDto) => void; + /** 角色配置保存后刷新管线(生成件可能已被重建) */ + onConfigSaved: () => void; +}) { + const roleType = node.type.startsWith('role.') ? node.type.slice(5) : null; + const upstreamTrigger = + roleType != null + ? definition.nodes.find( + candidate => + candidate.type === 'trigger.cron' && + definition.edges.some( + edge => edge.from.node === candidate.id && edge.to.node === node.id + ) + ) + : undefined; + + return ( + + ); +} diff --git a/src/electron/renderer/components/PipelineGraph.tsx b/src/electron/renderer/components/PipelineGraph.tsx new file mode 100644 index 0000000..e216675 --- /dev/null +++ b/src/electron/renderer/components/PipelineGraph.tsx @@ -0,0 +1,196 @@ +import { useEffect, useMemo, useRef } from 'react'; +import { Network } from 'vis-network'; +import { DataSet } from 'vis-data'; +import type { PipelineDefinitionDto, PipelineNodeDto, PipelineRunDto } from '../../shared/types.js'; + +/** 节点类型分组 → 展示色与形状 */ +const TYPE_STYLES: Record = { + trigger: { color: '#f59e0b', shape: 'diamond' }, + role: { color: '#4f46e5', shape: 'box' }, + agent: { color: '#0d9488', shape: 'box' }, + knowledge: { color: '#059669', shape: 'ellipse' }, + sink: { color: '#64748b', shape: 'triangleDown' }, + default: { color: '#8b5cf6', shape: 'box' }, +}; + +/** stage 状态 → 节点边框色(运行状态叠加) */ +const STATUS_BORDER: Record = { + running: '#f59e0b', + succeeded: '#22c55e', + failed: '#ef4444', + skipped: '#94a3b8', +}; + +function typeGroup(type: string): string { + return type.split('.')[0] || 'default'; +} + +/** 最近一轮运行中各节点的状态(nodeId → status) */ +export function latestStageStatus(runs: PipelineRunDto[]): Map { + const map = new Map(); + const latest = runs[0]; + if (!latest) return map; + for (const stage of latest.stages) { + map.set(stage.nodeId, stage.status); + } + return map; +} + +/** 结构签名:仅当节点集合/边连接关系/布局模式变化时才需要重建 Network */ +function structureSignature(definition: PipelineDefinitionDto): string { + return JSON.stringify({ + nodes: definition.nodes.map(node => `${node.id}:${node.type}`).sort(), + edges: definition.edges + .map(edge => `${edge.from.node}.${edge.from.port}->${edge.to.node}.${edge.to.port}`) + .sort(), + // 全部节点有坐标 → 自由布局;坐标值本身的变化不重建(呈现效应原地更新) + freeLayout: definition.nodes.every(node => node.position), + }); +} + +function toVisNode(node: PipelineNodeDto, status: string | undefined) { + const group = TYPE_STYLES[typeGroup(node.type)] ?? TYPE_STYLES.default; + return { + id: node.id, + label: `${node.label ?? node.id}\n${node.type}`, + color: { + background: group.color, + border: status ? (STATUS_BORDER[status] ?? group.color) : group.color, + }, + borderWidth: status ? 4 : 1, + shape: group.shape as never, + font: { color: '#ffffff' }, + ...(node.position ? { x: node.position.x, y: node.position.y } : {}), + }; +} + +interface PipelineGraphProps { + definition: PipelineDefinitionDto; + runs: PipelineRunDto[]; + /** 选中节点时回调(null = 取消选中) */ + onSelectNode: (nodeId: string | null) => void; + /** 拖拽结束:固化全部节点坐标(一次性转入自由布局,避免部分有坐标导致弹回) */ + onMoveAllNodes: (positions: Record) => void; + /** 新增连线(from/to 节点 id;端口映射由父组件负责) */ + onAddEdge: (fromNodeId: string, toNodeId: string) => void; + onDeleteNodes: (nodeIds: string[]) => void; + onDeleteEdges: (edgeIds: string[]) => void; +} + +/** + * 管线画布:vis-network 渲染定义图,叠加最近运行状态。 + * + * 生命周期策略(避免轮询/检查器编辑把画布整个拆掉): + * - 结构效应:仅节点/边连接关系变化时重建 Network + * - 呈现效应:label/位置/运行状态变化时 DataSet 原地更新,缩放平移与选中态保持 + */ +export function PipelineGraph({ + definition, + runs, + onSelectNode, + onMoveAllNodes, + onAddEdge, + onDeleteNodes, + onDeleteEdges, +}: PipelineGraphProps) { + const containerRef = useRef(null); + const networkRef = useRef(null); + const nodesDataRef = useRef | null>(null); + const statusMap = useMemo(() => latestStageStatus(runs), [runs]); + const signature = structureSignature(definition); + + // 结构效应:仅当连接关系变化时重建 + useEffect(() => { + if (!containerRef.current) return undefined; + + const nodes = new DataSet( + definition.nodes.map(node => toVisNode(node, statusMap.get(node.id))) + ); + nodesDataRef.current = nodes; + const edges = new DataSet( + definition.edges.map((edge, index) => ({ + id: edge.id ?? `edge-${index}`, + from: edge.from.node, + to: edge.to.node, + label: edge.channel === 'memory' ? (edge.artifactType ?? '') : edge.channel, + dashes: edge.channel !== 'memory', + arrows: 'to' as const, + })) + ); + + const hasPositions = definition.nodes.every(node => node.position); + const network = new Network( + containerRef.current, + { nodes, edges }, + { + layout: hasPositions + ? { improvedLayout: false } + : { hierarchical: { enabled: true, direction: 'LR', sortMethod: 'directed' } }, + physics: { enabled: false }, + interaction: { multiselect: true }, + manipulation: { + enabled: true, + addNode: false, + editNode: false, + // 不直接改 DataSet:上抛给父组件更新定义,由状态重建图 + addEdge: ( + edgeData: { from?: string | number; to?: string | number }, + callback: (data?: unknown) => void + ) => { + if (edgeData.from != null && edgeData.to != null) { + onAddEdge(String(edgeData.from), String(edgeData.to)); + } + // 故意不调用 callback(edgeData):定义状态重建会覆盖 + void callback; + }, + deleteNode: ( + data: { nodes?: Array }, + callback: (data?: unknown) => void + ) => { + if (data.nodes?.length) onDeleteNodes(data.nodes.map(String)); + void callback; + }, + deleteEdge: ( + data: { edges?: Array }, + callback: (data?: unknown) => void + ) => { + if (data.edges?.length) onDeleteEdges(data.edges.map(String)); + void callback; + }, + }, + } + ); + networkRef.current = network; + + network.on('selectNode', params => { + onSelectNode(params.nodes[0] != null ? String(params.nodes[0]) : null); + }); + network.on('deselectNode', () => onSelectNode(null)); + network.on('dragEnd', params => { + if (!params.nodes?.length) return; + // 一次性固化全部节点坐标:部分节点无坐标会导致层级布局把被拖节点弹回 + const all = network.getPositions(); + const positions: Record = {}; + for (const [nodeId, pos] of Object.entries(all)) { + positions[nodeId] = { x: Math.round(pos.x), y: Math.round(pos.y) }; + } + onMoveAllNodes(positions); + }); + + return () => { + network.destroy(); + networkRef.current = null; + nodesDataRef.current = null; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [signature]); + + // 呈现效应:label/坐标/运行状态原地更新,不打断缩放与选中态 + useEffect(() => { + const nodesData = nodesDataRef.current; + if (!nodesData) return; + nodesData.update(definition.nodes.map(node => toVisNode(node, statusMap.get(node.id)))); + }, [definition, statusMap]); + + return
; +} diff --git a/src/electron/renderer/components/pipeline-edit.ts b/src/electron/renderer/components/pipeline-edit.ts new file mode 100644 index 0000000..15cfc05 --- /dev/null +++ b/src/electron/renderer/components/pipeline-edit.ts @@ -0,0 +1,112 @@ +import type { PipelineDefinitionDto, PipelineNodeDto } from '../../shared/types.js'; + +/** 可经画布新增的节点类型(仅限 daemon 当前可执行的类型) */ +export const NODE_PALETTE: Array<{ type: string; label: string }> = [ + { type: 'trigger.cron', label: '定时触发器' }, + { type: 'role.reviewer', label: 'Reviewer 角色' }, + { type: 'role.maintainer', label: 'Maintainer 角色' }, + { type: 'role.archiver', label: 'Archiver 角色' }, +]; + +/** 新增连线的默认端口映射 */ +export function defaultOutPort(type: string | undefined): string { + return type === 'trigger.cron' ? 'tick' : 'out'; +} +export function defaultInPort(type: string | undefined): string { + return type?.startsWith('role.') ? 'trigger' : 'in'; +} + +/** 新增连线;重复连接(同 from/to 端口)时幂等返回原定义 */ +export function addEdgeToDefinition( + def: PipelineDefinitionDto, + fromNodeId: string, + toNodeId: string +): PipelineDefinitionDto { + const fromNode = def.nodes.find(node => node.id === fromNodeId); + const toNode = def.nodes.find(node => node.id === toNodeId); + if (!fromNode || !toNode) return def; + const fromPort = defaultOutPort(fromNode.type); + const toPort = defaultInPort(toNode.type); + const exists = def.edges.some( + edge => + edge.from.node === fromNodeId && + edge.from.port === fromPort && + edge.to.node === toNodeId && + edge.to.port === toPort + ); + if (exists) return def; + return { + ...def, + edges: [ + ...def.edges, + { + id: `edge-${Date.now()}`, + from: { node: fromNodeId, port: fromPort }, + to: { node: toNodeId, port: toPort }, + channel: 'memory' as const, + }, + ], + }; +} + +/** 删除节点及其关联边 */ +export function deleteNodesFromDefinition( + def: PipelineDefinitionDto, + nodeIds: string[] +): PipelineDefinitionDto { + return { + ...def, + nodes: def.nodes.filter(node => !nodeIds.includes(node.id)), + edges: def.edges.filter( + edge => !nodeIds.includes(edge.from.node) && !nodeIds.includes(edge.to.node) + ), + }; +} + +/** 删除边(edge.id 缺省时按渲染序 edge-${index} 匹配,与画布 id 生成一致) */ +export function deleteEdgesFromDefinition( + def: PipelineDefinitionDto, + edgeIds: string[] +): PipelineDefinitionDto { + return { + ...def, + edges: def.edges.filter((edge, index) => !edgeIds.includes(edge.id ?? `edge-${index}`)), + }; +} + +/** 新增节点(无位置,交由分层布局);id 带随机后缀避免同毫秒连点碰撞 */ +export function addNodeToDefinition( + def: PipelineDefinitionDto, + type: string, + label: string +): PipelineDefinitionDto { + const nodeId = `${type.replace('.', '-')}-${crypto.randomUUID().slice(0, 8)}`; + return { + ...def, + nodes: [...def.nodes, { id: nodeId, type, label, params: {} }], + }; +} + +/** 更新节点(检查器编辑) */ +export function updateNodeInDefinition( + def: PipelineDefinitionDto, + updated: PipelineNodeDto +): PipelineDefinitionDto { + return { + ...def, + nodes: def.nodes.map(node => (node.id === updated.id ? updated : node)), + }; +} + +/** 持久化全部节点坐标(拖拽结束时一次性固化,避免部分有坐标导致层级布局弹回) */ +export function moveAllNodesInDefinition( + def: PipelineDefinitionDto, + positions: Record +): PipelineDefinitionDto { + return { + ...def, + nodes: def.nodes.map(node => + positions[node.id] ? { ...node, position: positions[node.id] } : node + ), + }; +} diff --git a/src/electron/renderer/pages/ProjectDetail.tsx b/src/electron/renderer/pages/ProjectDetail.tsx index 845cad1..b990d5e 100755 --- a/src/electron/renderer/pages/ProjectDetail.tsx +++ b/src/electron/renderer/pages/ProjectDetail.tsx @@ -9,13 +9,14 @@ import { invoke } from '../api/electron-api'; import type { ProjectStatus } from '../../shared/types'; import { ArchiveTree } from '../components/ArchiveTree'; +import { PipelineCanvas } from '../components/PipelineCanvas'; import type { FileTreeNode } from '../components/ArchiveTree'; -type Tab = 'context' | 'activity' | 'archive' | 'status'; +type Tab = 'pipeline' | 'context' | 'activity' | 'archive' | 'status'; export function ProjectDetail() { const { id } = useParams<{ id: string }>(); - const [tab, setTab] = useState('context'); + const [tab, setTab] = useState('pipeline'); const [scanning, setScanning] = useState(false); const [scanError, setScanError] = useState(null); const { data: project } = useIpc<{ name: string; rootPath: string; archiveRoot?: string }>( @@ -124,6 +125,12 @@ export function ProjectDetail() { )}
+
+ {tab === 'pipeline' && id && } {tab === 'context' && context && } {tab === 'activity' && suggestions && } {tab === 'archive' && } diff --git a/src/electron/renderer/styles/global.css b/src/electron/renderer/styles/global.css index 011d5a4..69443b6 100755 --- a/src/electron/renderer/styles/global.css +++ b/src/electron/renderer/styles/global.css @@ -3602,3 +3602,134 @@ select.input:focus { justify-content: flex-end; } } + +/* ===== 管线画布(M4) ===== */ +.pipeline-canvas-layout { + display: flex; + flex-direction: column; + gap: 8px; + height: 100%; + min-height: 480px; +} + +.pipeline-toolbar { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +.pipeline-palette-btn { + padding: 4px 10px; + border: 1px solid var(--border-color, #d1d5db); + border-radius: 6px; + background: var(--bg-secondary, #f9fafb); + cursor: pointer; + font-size: 12px; +} + +.pipeline-toolbar-hint { + color: var(--text-secondary, #6b7280); + font-size: 12px; +} + +.pipeline-toolbar-spacer { + flex: 1; +} + +.pipeline-badge { + font-size: 12px; + padding: 2px 8px; + border-radius: 10px; + background: var(--bg-secondary, #eef2ff); + color: var(--text-secondary, #4f46e5); +} + +.pipeline-badge.dirty { + background: #fef3c7; + color: #b45309; +} + +.pipeline-save-btn { + padding: 4px 14px; + border: none; + border-radius: 6px; + background: #4f46e5; + color: #fff; + cursor: pointer; +} + +.pipeline-save-btn:disabled { + opacity: 0.5; + cursor: default; +} + +.pipeline-discard-btn { + padding: 4px 10px; + border: 1px solid var(--border-color, #d1d5db); + border-radius: 6px; + background: transparent; + cursor: pointer; +} + +.pipeline-error { + color: #b91c1c; + font-size: 12px; +} + +.pipeline-main { + display: flex; + gap: 12px; + flex: 1; + min-height: 0; +} + +.pipeline-graph-canvas { + flex: 1; + min-height: 420px; + border: 1px solid var(--border-color, #e5e7eb); + border-radius: 8px; + background: var(--bg-primary, #fff); +} + +.pipeline-inspector { + width: 320px; + flex-shrink: 0; + overflow-y: auto; + border-left: 1px solid var(--border-color, #e5e7eb); + padding: 0 12px 12px; +} + +.pipeline-inspector h3 { + margin: 8px 0 12px; + font-size: 14px; +} + +.pipeline-inspector-field { + display: flex; + flex-direction: column; + gap: 4px; + margin-bottom: 10px; + font-size: 12px; +} + +.pipeline-inspector-field label { + color: var(--text-secondary, #6b7280); +} + +.pipeline-inspector-field input { + padding: 4px 8px; + border: 1px solid var(--border-color, #d1d5db); + border-radius: 4px; +} + +.pipeline-inspector-hint { + font-size: 12px; + color: var(--text-secondary, #6b7280); +} + +.pipeline-loading, +.pipeline-empty { + padding: 24px; + color: var(--text-secondary, #6b7280); +} diff --git a/src/electron/shared/types.ts b/src/electron/shared/types.ts index e46ca94..1e76bfd 100755 --- a/src/electron/shared/types.ts +++ b/src/electron/shared/types.ts @@ -290,3 +290,58 @@ export interface MemoryGraph { edges: MemoryGraphEdge[]; stats: MemoryGraphStats; } + +// ===== 管线画布(M4)===== + +export interface PipelinePortRefDto { + node: string; + port: string; +} + +export interface PipelineEdgeDto { + id?: string; + from: PipelinePortRefDto; + to: PipelinePortRefDto; + channel: 'memory' | 'queue' | 'gitlab-discussion' | 'everos' | 'fs' | 'a2a-task'; + artifactType?: string; +} + +export interface PipelineNodeDto { + id: string; + type: string; + label?: string; + params: Record; + position?: { x: number; y: number }; +} + +export interface PipelineDefinitionDto { + version: 1; + id: string; + label?: string; + nodes: PipelineNodeDto[]; + edges: PipelineEdgeDto[]; +} + +export interface PipelineGetResult { + exists: boolean; + /** true 表示正本仍是自动生成的投影件(画布保存后转人类正本) */ + generated: boolean; + definition: PipelineDefinitionDto | null; +} + +export interface PipelineStageRunDto { + nodeId: string; + status: 'running' | 'succeeded' | 'failed' | 'skipped'; + error: string | null; + startedAt: number; + finishedAt: number | null; +} + +export interface PipelineRunDto { + id: string; + status: 'running' | 'succeeded' | 'failed' | 'cancelled'; + error: string | null; + createdAt: number; + finishedAt: number | null; + stages: PipelineStageRunDto[]; +} diff --git a/tests/advance/pipeline/pipeline-scheduler.test.ts b/tests/advance/pipeline/pipeline-scheduler.test.ts index 50b0544..db95bb0 100644 --- a/tests/advance/pipeline/pipeline-scheduler.test.ts +++ b/tests/advance/pipeline/pipeline-scheduler.test.ts @@ -227,4 +227,52 @@ describe('PipelineScheduler', () => { expect(stopProjectSpy).toHaveBeenCalledWith('proj-1'); expect(cronJobs.size).toBe(0); }); + + it('getProjectPipeline 迁移生成默认管线并标记 generated', async () => { + const project = makeProject(tmp, { + reviewer: enabledRole('reviewer', '*/7 * * * *'), + } as Project['roles']); + const scheduler = new PipelineScheduler(makeContext(project) as never, 'virtual-runner.js', { + mcpUrl: 'http://127.0.0.1:1', + codeGraphUrl: 'http://127.0.0.1:2', + }); + const result = scheduler.getProjectPipeline('proj-1'); + expect(result.exists).toBe(true); + expect(result.generated).toBe(true); + expect(result.definition?.nodes.map(n => n.id).sort()).toEqual([ + 'role-reviewer', + 'trigger-reviewer', + ]); + }); + + it('updateProjectPipeline 写回人类正本(去标记)并热加载', async () => { + const project = makeProject(tmp, { + reviewer: enabledRole('reviewer', '*/7 * * * *'), + } as Project['roles']); + const scheduler = new PipelineScheduler(makeContext(project) as never, 'virtual-runner.js', { + mcpUrl: 'http://127.0.0.1:1', + codeGraphUrl: 'http://127.0.0.1:2', + }); + scheduler.register('reviewer'); + + const current = scheduler.getProjectPipeline('proj-1').definition!; + const updated = { + ...current, + nodes: current.nodes.map(node => + node.id === 'trigger-reviewer' ? { ...node, params: { schedule: '0 2 * * *' } } : node + ), + }; + scheduler.updateProjectPipeline('proj-1', updated); + + const after = scheduler.getProjectPipeline('proj-1'); + expect(after.generated).toBe(false); + expect(after.definition!.nodes.find(n => n.id === 'trigger-reviewer')!.params.schedule).toBe( + '0 2 * * *' + ); + + // 非法定义被拒绝 + expect(() => + scheduler.updateProjectPipeline('proj-1', { version: 1, id: 'x', nodes: [], edges: [] }) + ).toThrow(); + }); }); diff --git a/tests/electron/renderer/pipeline-edit.test.ts b/tests/electron/renderer/pipeline-edit.test.ts new file mode 100644 index 0000000..8c6b36f --- /dev/null +++ b/tests/electron/renderer/pipeline-edit.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it } from 'vitest'; +import { + addEdgeToDefinition, + addNodeToDefinition, + defaultInPort, + defaultOutPort, + deleteEdgesFromDefinition, + deleteNodesFromDefinition, + moveAllNodesInDefinition, + updateNodeInDefinition, +} from '../../../src/electron/renderer/components/pipeline-edit.js'; +import type { PipelineDefinitionDto } from '../../../src/electron/shared/types.js'; + +function makeDef(): PipelineDefinitionDto { + return { + version: 1, + id: 'p1', + nodes: [ + { id: 't1', type: 'trigger.cron', params: { schedule: '*/10 * * * *' } }, + { id: 'r1', type: 'role.reviewer', params: {} }, + { id: 'm1', type: 'role.maintainer', params: {} }, + ], + edges: [ + { + id: 'e1', + from: { node: 't1', port: 'tick' }, + to: { node: 'r1', port: 'trigger' }, + channel: 'memory', + }, + ], + }; +} + +describe('管线画布编辑操作', () => { + it('端口映射:trigger.cron 出 tick,role.* 入 trigger', () => { + expect(defaultOutPort('trigger.cron')).toBe('tick'); + expect(defaultOutPort('role.reviewer')).toBe('out'); + expect(defaultInPort('role.maintainer')).toBe('trigger'); + expect(defaultInPort('trigger.cron')).toBe('in'); + }); + + it('addEdge 使用类型推断端口且幂等', () => { + const def = makeDef(); + const withEdge = addEdgeToDefinition(def, 't1', 'm1'); + expect(withEdge.edges).toHaveLength(2); + expect(withEdge.edges[1]).toMatchObject({ + from: { node: 't1', port: 'tick' }, + to: { node: 'm1', port: 'trigger' }, + channel: 'memory', + }); + // 重复添加同一条边不改变定义 + expect(addEdgeToDefinition(withEdge, 't1', 'm1').edges).toHaveLength(2); + // 不存在的节点安全返回 + expect(addEdgeToDefinition(def, 'ghost', 'm1').edges).toHaveLength(1); + }); + + it('deleteNodes 级联删除关联边', () => { + const def = makeDef(); + const result = deleteNodesFromDefinition(def, ['r1']); + expect(result.nodes.map(n => n.id)).toEqual(['t1', 'm1']); + expect(result.edges).toHaveLength(0); + }); + + it('deleteEdges 按 id 与渲染序索引匹配', () => { + const def = makeDef(); + expect(deleteEdgesFromDefinition(def, ['e1']).edges).toHaveLength(0); + // 无 id 的边按 edge-${index} 匹配 + const noId = makeDef(); + delete (noId.edges[0] as { id?: string }).id; + expect(deleteEdgesFromDefinition(noId, ['edge-0']).edges).toHaveLength(0); + }); + + it('addNode / updateNode / moveAllNodes', () => { + const def = makeDef(); + const added = addNodeToDefinition(def, 'role.archiver', 'Archiver 角色'); + expect(added.nodes).toHaveLength(4); + expect(added.nodes[3].type).toBe('role.archiver'); + // 同毫秒连续添加两次,id 不碰撞 + const twice = addNodeToDefinition(added, 'role.archiver', 'Archiver 角色'); + const ids = twice.nodes.map(n => n.id); + expect(new Set(ids).size).toBe(ids.length); + + const updated = updateNodeInDefinition(twice, { + id: 't1', + type: 'trigger.cron', + params: { schedule: '0 1 * * *' }, + label: '夜间触发', + }); + expect(updated.nodes[0].label).toBe('夜间触发'); + expect(updated.nodes[0].params.schedule).toBe('0 1 * * *'); + + // 一次性固化全部节点坐标(拖单节点等效全量转自由布局) + const moved = moveAllNodesInDefinition(updated, { + t1: { x: 100, y: 50 }, + r1: { x: 300, y: 50 }, + m1: { x: 300, y: 200 }, + }); + expect(moved.nodes[0].position).toEqual({ x: 100, y: 50 }); + expect(moved.nodes.find(n => n.id === 'r1')!.position).toEqual({ x: 300, y: 50 }); + // 未出现在坐标表中的节点保持无坐标 + expect(moved.nodes[3].position).toBeUndefined(); + }); +}); From 310f24e0cfa3395957bc3ed81bdc5fb18ad7ec1e Mon Sep 17 00:00:00 2001 From: Windsander Date: Tue, 8 Sep 2026 21:34:27 +0800 Subject: [PATCH 10/18] =?UTF-8?q?feat(agents):=20=E5=A4=96=E9=83=A8=20Agen?= =?UTF-8?q?t=20=E8=8A=82=E7=82=B9=E2=80=94=E2=80=94A2A=20=E8=AF=8D?= =?UTF-8?q?=E6=B1=87=E4=BB=BB=E5=8A=A1=E4=BF=A1=E5=B0=81=20+=20=E4=B8=89?= =?UTF-8?q?=E4=BC=A0=E8=BE=93=E9=80=82=E9=85=8D=E5=99=A8=20+=20MCP=20?= =?UTF-8?q?=E9=97=A8=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit M5 外部 Agent 接入: - agents/task-envelope.ts:A2A 词汇契约(TaskEnvelope/TaskResult/AgentArtifact/ AgentCapabilityCard),output 键与管线端口的对应关系已注释 - agents/transports.ts:subprocess(stdin/stdout JSON,EPIPE 收敛、输出上限、 超时 SIGKILL)/ a2a(POST /tasks + AgentCard 探测,端点归一化,如实标注 不与现网完整 A2A 互通)/ mcp(SSE callTool,超时包装);TaskResult zod 校验 - agents/registry.ts:注册表(daemon-config agents 数组,加载期逐条校验); agentId 引用时注册项优先(防 pipeline.yaml 投毒);agent.subprocess 强制 agentId 白名单,网络类传输允许内联 - agents/mcp-facade.ts:MCP 门面(127.0.0.1 + 启动期随机 token),工具 pipeline_list_runs / pipeline_submit(异步派发+角色校验)/ knowledge_recall; 会话级资源管理,stop 主动关闭 SSE 会话(daemon 可干净退出); 独立于 EverOS 启动(recall 自行降级) - 调度器:agent.* 节点处理器(信封 id=runId:nodeId,处理器获传当前节点) - daemon.status 暴露 mcpFacade 地址;SECURITY.md 声明本地信任模型; DESIGN.md 补 M5 章节 对抗审核修复:EPIPE 崩溃、关停挂起、门面泄漏、闭包节点 id 错、 合并方向投毒、submit 阻塞、TaskResult 零校验、MCP 传输无超时。 验证:build + vitest(1010 通过)+ lint 零警告 + format 全绿 --- DESIGN.md | 22 +- SECURITY.md | 6 +- src/advance/agents/mcp-facade.ts | 216 +++++++++++++++ src/advance/agents/registry.ts | 149 ++++++++++ src/advance/agents/task-envelope.ts | 57 ++++ src/advance/agents/transports.ts | 256 ++++++++++++++++++ src/advance/cli.ts | 1 + src/advance/config/daemon-config.ts | 16 +- src/advance/daemon.ts | 28 +- src/advance/ipc/handlers.ts | 7 +- src/advance/pipeline/core/executor.ts | 2 +- src/advance/pipeline/core/types.ts | 7 +- src/advance/pipeline/pipeline-scheduler.ts | 50 +++- src/electron/shared/service-status.ts | 2 + tests/advance/agents/mcp-facade.test.ts | 109 ++++++++ .../agents/registry-transports.test.ts | 158 +++++++++++ tests/advance/ipc/daemon-status.test.ts | 2 + 17 files changed, 1075 insertions(+), 13 deletions(-) create mode 100644 src/advance/agents/mcp-facade.ts create mode 100644 src/advance/agents/registry.ts create mode 100644 src/advance/agents/task-envelope.ts create mode 100644 src/advance/agents/transports.ts create mode 100644 tests/advance/agents/mcp-facade.test.ts create mode 100644 tests/advance/agents/registry-transports.test.ts diff --git a/DESIGN.md b/DESIGN.md index 03460bf..cac5ed1 100755 --- a/DESIGN.md +++ b/DESIGN.md @@ -124,10 +124,24 @@ Archiver 文件优先管线产出策展产物(context.md 等)。 | M1 | 旧线清除;幸存者迁移入 advance;入口统一 | ✅ | | M2 | 管线核心:图 schema / YAML 加载 / 顺序执行器 / 运行落库 | ✅ | | M3 | Role 黑箱节点化;daemon 调度改造;配置自动迁移;本文档 | 进行中 | -| M4 | 可视化画布(节点编辑、检查器吸收 Role 配置、运行状态叠加) | | -| M5 | 外部 Agent 节点(A2A 词汇任务信封 + A2A/subprocess/MCP 适配器 + MCP 门面) | | -| M6 | 双正本智库 + 投影管线 + 蒸馏管线 | | -| M7 | 钻取层:Role 节点内 stage 子图拆解与编排 | | +| M4 | 可视化画布(节点编辑、检查器吸收 Role 配置、运行状态叠加) | ✅ | +| M5 | 外部 Agent 节点(A2A 词汇任务信封 + A2A/subprocess/MCP 适配器 + MCP 门面) | ✅ | + +### 外部 Agent 接入(M5) + +- **任务信封契约**(`src/advance/agents/task-envelope.ts`):A2A 词汇的 + TaskEnvelope/TaskResult/AgentArtifact/AgentCapabilityCard;传输层可插拔。 +- **传输适配器**:`subprocess`(本地 CLI,stdin/stdout JSON)、`a2a` + (HTTP POST /tasks + AgentCard 探测;A2A 词汇的最小映射,**不与现网完整 + A2A 协议互通**)、`mcp`(MCP SSE 工具调用)。 +- **注册表**(daemon-config.json 的 `agents` 数组):`params.agentId` 引用注册项时 + **注册项优先**(pipeline.yaml 可入库,不允许项目内定义覆盖本机连接参数); + `agent.subprocess` 强制要求 agentId(命令只来自本机白名单);网络类传输允许内联参数。 +- **MCP 门面**(`src/advance/agents/mcp-facade.ts`):127.0.0.1 + 随机 token, + 工具:`pipeline_list_runs` / `pipeline_submit`(异步派发)/ `knowledge_recall`。 + 门面独立于 EverOS 启动,地址经 `daemon.status` 展示。 + | M6 | 双正本智库 + 投影管线 + 蒸馏管线 | | + | M7 | 钻取层:Role 节点内 stage 子图拆解与编排 | | ## 五、工程约束(RULES) diff --git a/SECURITY.md b/SECURITY.md index e29f37b..6de736c 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -21,7 +21,9 @@ This is an individually maintained project; responses are best-effort (initial r ## 本项目特有的安全注意点 / Project-specific Notes -- CodeKeeper 在**本地**处理 LLM API Key、GitLab Token 等敏感凭据(`.env`,永不入库)。提交 Issue、日志或崩溃报告时,请务必先脱敏。 - CodeKeeper handles sensitive credentials (LLM API keys, GitLab tokens) **locally** via `.env`, which is never committed. Please redact credentials before sharing logs or crash reports. +- CodeKeeper 在**本地**处理 LLM API Key、GitLab Token 等敏感凭据(`daemon-config.json`,永不入库)。提交 Issue、日志或崩溃报告时,请务必先脱敏。 + CodeKeeper handles sensitive credentials (LLM API keys, GitLab tokens) **locally** via `daemon-config.json`, which is never committed. Please redact credentials before sharing logs or crash reports. +- MCP 门面(外部 Agent 接入点)仅绑定 `127.0.0.1` 并携带启动期随机 token;其 `pipeline_submit` 具有真实世界写副作用(评审评论、代码修复、MR 合并)。**绑定本机即信任本机全部进程**——请勿在共享/多租户机器上运行,勿把门面 URL 转发到本机之外。 + The MCP facade binds to `127.0.0.1` with a per-boot random token; `pipeline_submit` has real-world write side effects. Running on this host implies trusting all local processes — do not run on shared machines or forward the facade URL off-host. - 若发现凭据意外进入 git 历史,请立即通过上述私密渠道告知,我们将重写历史并轮换凭据。 If you find credentials accidentally committed to git history, please report via the private channel above; we will rewrite history and rotate the credentials. diff --git a/src/advance/agents/mcp-facade.ts b/src/advance/agents/mcp-facade.ts new file mode 100644 index 0000000..d4bb7db --- /dev/null +++ b/src/advance/agents/mcp-facade.ts @@ -0,0 +1,216 @@ +/** + * CodeKeeper MCP 门面:把管线与智库能力暴露给外部 Agent(方向 B)。 + * + * 外部 Agent(OpenCode、Claude Code、自研工具等)作为 MCP client 连接后, + * 可以查询管线运行状态、手动触发角色执行、召回项目知识。 + * + * 安全模型: + * - 仅监听 127.0.0.1; + * - 启动时生成随机 token 编入门面 URL(http://127.0.0.1:port/sse?token=...), + * 未带正确 token 的连接一律 401;URL 经 daemon 状态展示给本机用户。 + * - pipeline_submit 具有真实世界写副作用(评审评论/修复/合并), + * 绑定本机 + token 即"信任本机持有者"模型,详见 SECURITY.md。 + */ + +import http from 'node:http'; +import { randomUUID } from 'node:crypto'; +import { Server } from '@modelcontextprotocol/sdk/server/index.js'; +import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js'; +import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js'; +import { logger } from '../core/logger.js'; +import { everosMemorySearchProject } from '../classic/memory/everos-api.js'; +import type { PipelineScheduler } from '../pipeline/pipeline-scheduler.js'; +import { ROLES, type Role } from '../types.js'; + +export interface McpFacadeOptions { + scheduler: PipelineScheduler; + /** EverOS HTTP 服务地址;未就绪时知识召回返回明确错误 */ + getEverosUrl: () => string | null; + port?: number; +} + +const APP_ID = 'codekeeper-advance'; + +export class McpFacadeServer { + private httpServer: http.Server | null = null; + /** 活动 SSE 会话:stop 时主动关闭,避免 daemon 无法退出 */ + private readonly sessions = new Map(); + private readonly token = randomUUID(); + + constructor(private readonly options: McpFacadeOptions) {} + + /** 启动并返回带 token 的门面地址 */ + async start(): Promise { + return new Promise((resolve, reject) => { + const server = http.createServer((req, res) => void this.handleRequest(req, res)); + server.on('error', reject); + this.httpServer = server; + + server.listen(this.options.port ?? 0, '127.0.0.1', () => { + const addr = server.address(); + const port = typeof addr === 'object' && addr ? addr.port : 0; + resolve(`http://127.0.0.1:${port}?token=${this.token}`); + }); + }); + } + + async stop(): Promise { + for (const session of this.sessions.values()) { + try { + await session.server.close(); + await session.transport.close(); + } catch (error) { + logger.warn({ err: error }, '关闭 MCP 门面会话失败'); + } + } + this.sessions.clear(); + await new Promise(resolve => { + this.httpServer?.closeAllConnections(); + this.httpServer?.close(() => resolve()); + }); + this.httpServer = null; + } + + private async handleRequest(req: http.IncomingMessage, res: http.ServerResponse): Promise { + const url = new URL(req.url ?? '/', `http://${req.headers.host}`); + + if (url.searchParams.get('token') !== this.token) { + res.writeHead(401); + res.end('unauthorized'); + return; + } + + if (url.pathname === '/sse') { + const transport = new SSEServerTransport(`/messages?token=${this.token}`, res); + const sessionId = transport.sessionId; + const server = new Server( + { name: 'codekeeper-facade-mcp', version: '0.1.0' }, + { capabilities: { tools: {} } } + ); + this.registerTools(server); + this.sessions.set(sessionId, { transport, server }); + + res.on('close', async () => { + this.sessions.delete(sessionId); + try { + await server.close(); + } catch (error) { + logger.warn({ err: error, sessionId }, '关闭 MCP 门面会话失败'); + } + }); + + await server.connect(transport); + return; + } + + if (url.pathname === '/messages') { + const sessionId = url.searchParams.get('sessionId') ?? ''; + const session = this.sessions.get(sessionId); + if (!session) { + res.writeHead(404); + res.end('session not found'); + return; + } + await session.transport.handlePostMessage(req, res); + return; + } + + res.writeHead(404); + res.end('not found'); + } + + private registerTools(server: Server): void { + server.setRequestHandler(ListToolsRequestSchema, async () => ({ + tools: [ + { + name: 'pipeline_list_runs', + description: '查询项目管线的最近运行记录(含节点级状态)', + inputSchema: { + type: 'object', + properties: { + projectId: { type: 'string' }, + limit: { type: 'number' }, + }, + required: ['projectId'], + }, + }, + { + name: 'pipeline_submit', + description: '手动触发项目某个角色节点立即执行一轮(异步派发,不等待执行完成)', + inputSchema: { + type: 'object', + properties: { + projectId: { type: 'string' }, + role: { type: 'string', enum: [...ROLES] }, + }, + required: ['projectId', 'role'], + }, + }, + { + name: 'knowledge_recall', + description: '召回项目长期记忆中的相关知识(EverOS)', + inputSchema: { + type: 'object', + properties: { + projectId: { type: 'string' }, + query: { type: 'string' }, + topK: { type: 'number' }, + }, + required: ['projectId', 'query'], + }, + }, + ], + })); + + server.setRequestHandler(CallToolRequestSchema, async request => { + const args = (request.params.arguments ?? {}) as Record; + try { + switch (request.params.name) { + case 'pipeline_list_runs': + return jsonResult( + this.options.scheduler.listPipelineRuns( + String(args.projectId), + typeof args.limit === 'number' ? args.limit : 10 + ) + ); + case 'pipeline_submit': { + const role = String(args.role); + if (!ROLES.includes(role as Role)) { + return jsonResult({ error: `未知角色: ${role}` }); + } + // 异步派发:角色执行可达分钟级,不阻塞 MCP client + void this.options.scheduler + .runProjectRoleNow(String(args.projectId), role as Role) + .catch(error => { + const message = error instanceof Error ? error.message : String(error); + logger.warn(`[MCP门面] pipeline_submit 执行失败: ${message}`); + }); + return jsonResult({ submitted: true }); + } + case 'knowledge_recall': { + const everosUrl = this.options.getEverosUrl(); + if (!everosUrl) { + return jsonResult({ error: 'EverOS 未就绪,知识召回不可用' }); + } + const result = await everosMemorySearchProject(everosUrl, { + appId: APP_ID, + projectId: String(args.projectId), + query: String(args.query), + topK: typeof args.topK === 'number' ? args.topK : 5, + }); + return jsonResult(result); + } + default: + return jsonResult({ error: `未知工具: ${request.params.name}` }); + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return jsonResult({ error: message }); + } + }); + } +} + +function jsonResult(value: unknown) { + return { content: [{ type: 'text' as const, text: JSON.stringify(value, null, 2) }] }; +} diff --git a/src/advance/agents/registry.ts b/src/advance/agents/registry.ts new file mode 100644 index 0000000..b8425ff --- /dev/null +++ b/src/advance/agents/registry.ts @@ -0,0 +1,149 @@ +/** + * 外部 Agent 注册表(推广自 Archiver Provider 注册表模式)。 + * + * Agent 规格来源:daemon-config.json 的 agents 数组(应用级配置)。 + * 管线 agent.* 节点经 params.agentId 引用注册表中的 Agent; + * 节点也可内联连接参数(不注册的一次性对接),注册表提供健康探测与解析。 + */ + +import { z } from 'zod'; +import { logger } from '../core/logger.js'; +import type { AgentCapabilityCard } from './task-envelope.js'; +import { + A2aTransport, + McpTransport, + SubprocessTransport, + type AgentTransport, +} from './transports.js'; + +/** Agent 注册规格(daemon-config.json 的 agents 数组元素) */ +export const agentSpecSchema = z.object({ + id: z.string().min(1), + name: z.string().min(1), + description: z.string().optional(), + capabilities: z.array(z.string()).default([]), + transport: z.enum(['a2a', 'subprocess', 'mcp']), + /** a2a:对端 base URL */ + endpoint: z.string().optional(), + /** subprocess:可执行命令与参数 */ + command: z.string().optional(), + args: z.array(z.string()).optional(), + /** mcp:MCP server URL 与工具名 */ + serverUrl: z.string().optional(), + tool: z.string().optional(), +}); +export type AgentSpec = z.infer; + +export class AgentRegistry { + private readonly specs = new Map(); + + constructor(specs: AgentSpec[] = []) { + for (const spec of specs) this.register(spec); + } + + register(spec: AgentSpec): void { + this.specs.set(spec.id, spec); + } + + get(agentId: string): AgentSpec | undefined { + return this.specs.get(agentId); + } + + list(): AgentSpec[] { + return [...this.specs.values()]; + } + + /** + * 解析传输实例。 + * - params.agentId 引用注册项时,**注册项优先**(pipeline.yaml 是可入库正本, + * 不允许项目内定义覆盖本机 daemon-config 中的连接参数,否则 clone 即投毒); + * - 内联 params 一次性对接仅允许网络类传输(a2a/mcp); + * agent.subprocess 强制要求 agentId(可执行命令只能来自本机白名单)。 + */ + resolveTransport(nodeType: string, params: Record): AgentTransport { + const agentId = typeof params.agentId === 'string' ? params.agentId : undefined; + const spec = agentId ? this.specs.get(agentId) : undefined; + if (agentId && !spec) { + throw new Error(`未注册的外部 Agent: ${agentId}`); + } + if (!spec && nodeType === 'agent.subprocess') { + throw new Error( + 'agent.subprocess 节点必须引用已注册 Agent(params.agentId);命令只允许来自本机 daemon-config 白名单' + ); + } + // 注册项优先于节点内联参数 + const merged = { ...params, ...spec } as AgentSpec & Record; + + switch (nodeType) { + case 'agent.subprocess': { + const command = merged.command; + if (typeof command !== 'string' || !command) { + throw new Error('agent.subprocess 节点缺少 params.command'); + } + return new SubprocessTransport({ command, args: asStringArray(merged.args) }); + } + case 'agent.a2a': { + const endpoint = merged.endpoint; + if (typeof endpoint !== 'string' || !endpoint) { + throw new Error('agent.a2a 节点缺少 params.endpoint'); + } + return new A2aTransport({ endpoint }); + } + case 'agent.mcp': { + const { serverUrl, tool } = merged; + if (typeof serverUrl !== 'string' || !serverUrl || typeof tool !== 'string' || !tool) { + throw new Error('agent.mcp 节点缺少 params.serverUrl / params.tool'); + } + return new McpTransport({ serverUrl, tool }); + } + default: + throw new Error(`未知 agent 节点类型: ${nodeType}`); + } + } + + /** 健康探测:返回每个注册 Agent 的可达性 */ + async probeAll(): Promise> { + const results: Array<{ id: string; ok: boolean }> = []; + for (const spec of this.specs.values()) { + let ok = false; + try { + const transport = this.resolveTransport(`agent.${spec.transport}`, { + agentId: spec.id, + }); + ok = await transport.probe(); + } catch (error) { + logger.warn({ err: error, agentId: spec.id }, '外部 Agent 探测失败'); + } + results.push({ id: spec.id, ok }); + } + return results; + } + + /** 能力卡片列表(A2A 对端尝试实时拉取,失败回退注册信息) */ + async listCards(): Promise { + const cards: AgentCapabilityCard[] = []; + for (const spec of this.specs.values()) { + if (spec.transport === 'a2a' && spec.endpoint) { + const card = await new A2aTransport({ endpoint: spec.endpoint }).fetchCard(); + cards.push(card ?? specToCard(spec)); + } else { + cards.push(specToCard(spec)); + } + } + return cards; + } +} + +function specToCard(spec: AgentSpec): AgentCapabilityCard { + return { + id: spec.id, + name: spec.name, + description: spec.description, + capabilities: spec.capabilities, + transport: spec.transport, + }; +} + +function asStringArray(value: unknown): string[] | undefined { + return Array.isArray(value) ? value.filter(item => typeof item === 'string') : undefined; +} diff --git a/src/advance/agents/task-envelope.ts b/src/advance/agents/task-envelope.ts new file mode 100644 index 0000000..669652d --- /dev/null +++ b/src/advance/agents/task-envelope.ts @@ -0,0 +1,57 @@ +/** + * 外部 Agent 任务信封契约(A2A 词汇)。 + * + * 管线 agent.* 节点与外部 Agent 的交互统一为: + * 能力描述(AgentCapabilityCard)+ 任务信封(TaskEnvelope)+ 结果(TaskResult)。 + * 传输层(A2A HTTP / 本地子进程 / MCP 工具)是可插拔适配器,契约保持一致, + * 未来接入完整 A2A 协议时只需替换 transport 实现。 + */ + +/** 结构化产物(A2A Artifact 的最小形态) */ +export interface AgentArtifact { + /** 产物名(对应管线端口名) */ + name: string; + /** 产物类型(如 MRContext / Findings / Patch / KnowledgeItem) */ + type: string; + /** 文本载荷(二进制产物先不支撑,后续以引用/URI 扩展) */ + content: string; +} + +/** 任务信封(A2A Task 的最小形态) */ +export interface TaskEnvelope { + /** 任务 id(管线 runId:nodeId 派生) */ + id: string; + /** 能力标识(如 code-review / security-scan),供对端路由 */ + capability: string; + /** 管线端口产物(原始结构) */ + input: Record; + /** 结构化产物清单(input 的展平视图,便于非 JSON 对端消费) */ + artifacts: AgentArtifact[]; + /** 超时(毫秒),缺省由传输层决定 */ + timeoutMs?: number; +} + +/** 任务结果(A2A Task 终态的最小形态) */ +export interface TaskResult { + status: 'completed' | 'failed'; + /** + * 返回给管线的端口产物:键名必须与下游边的 from.port 一致才会被递送, + * 写错的键会被静默丢弃(executor.gatherInputs 按边定义取值)。 + */ + output: Record; + artifacts: AgentArtifact[]; + error?: string; +} + +/** + * Agent 能力描述(A2A AgentCard 的最小子集)。 + * params 中的连接信息(endpoint/command/serverUrl)不属于卡片语义,由注册表另行持有。 + */ +export interface AgentCapabilityCard { + id: string; + name: string; + description?: string; + /** 声明的能力标识列表(对应 TaskEnvelope.capability) */ + capabilities: string[]; + transport: 'a2a' | 'subprocess' | 'mcp'; +} diff --git a/src/advance/agents/transports.ts b/src/advance/agents/transports.ts new file mode 100644 index 0000000..0817109 --- /dev/null +++ b/src/advance/agents/transports.ts @@ -0,0 +1,256 @@ +/** + * 外部 Agent 传输适配器。 + * + * 三种传输: + * - subprocess:本地 CLI Agent(stdin 收 JSON 信封,stdout 回 JSON 结果) + * - a2a:远程 A2A 风格 HTTP Agent(POST /tasks;GET /.well-known/agent.json 探测)。 + * 注意:这是 A2A 词汇的最小映射,**不与现网完整 A2A 协议互通** + * (真实 A2A 走 JSON-RPC message/send/tasks/get),完整适配留给后续里程碑。 + * - mcp:MCP 工具调用(SSE transport) + * + * 约束(RULES):subprocess 的 command 必须是可执行文件(Windows 上 .cmd/.bat + * 封装需经 shell,请注册包装脚本);TaskResult 经 zod 校验,非法形状一律 failed。 + */ + +import { spawn } from 'node:child_process'; +import { z } from 'zod'; +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js'; +import type { AgentCapabilityCard, TaskEnvelope, TaskResult } from './task-envelope.js'; + +export interface AgentTransport { + readonly kind: AgentCapabilityCard['transport']; + /** 健康探测(不执行任务) */ + probe(): Promise; + execute(envelope: TaskEnvelope): Promise; +} + +const DEFAULT_TIMEOUT_MS = 10 * 60 * 1000; +const PROBE_TIMEOUT_MS = 5_000; +/** stdout/stderr 累积上限,失控对端不致撑爆内存 */ +const MAX_OUTPUT_CHARS = 5 * 1024 * 1024; + +/** TaskResult 形状校验:对端返回垃圾时一律收敛为 failed */ +const taskResultSchema = z.object({ + status: z.enum(['completed', 'failed']), + output: z.record(z.unknown()).default({}), + artifacts: z + .array(z.object({ name: z.string(), type: z.string(), content: z.string() })) + .default([]), + error: z.string().optional(), +}); + +export function parseTaskResult(raw: unknown): TaskResult { + const parsed = taskResultSchema.safeParse(raw); + if (!parsed.success) { + return { status: 'failed', output: {}, artifacts: [], error: '对端返回的 TaskResult 形状非法' }; + } + return parsed.data; +} + +function failure(error: unknown): TaskResult { + const message = error instanceof Error ? error.message : String(error); + return { status: 'failed', output: {}, artifacts: [], error: message }; +} + +/** + * 本地子进程传输:适用于 OpenCode 类 CLI Agent 的脚本化接入。 + * 约定:信封 JSON 写 stdin,结果 JSON 从 stdout 读;进程退出码非零视为 failed。 + */ +export class SubprocessTransport implements AgentTransport { + readonly kind = 'subprocess' as const; + + constructor(private readonly spec: { command: string; args?: string[] }) {} + + async probe(): Promise { + return new Promise(resolve => { + const child = spawn(this.spec.command, [...(this.spec.args ?? []), '--version'], { + stdio: ['ignore', 'ignore', 'ignore'], + }); + const timer = setTimeout(() => { + child.kill('SIGKILL'); + resolve(false); + }, PROBE_TIMEOUT_MS); + child.on('error', () => { + clearTimeout(timer); + resolve(false); + }); + child.on('exit', code => { + clearTimeout(timer); + resolve(code === 0); + }); + }); + } + + async execute(envelope: TaskEnvelope): Promise { + const timeoutMs = envelope.timeoutMs ?? DEFAULT_TIMEOUT_MS; + return new Promise(resolve => { + const child = spawn(this.spec.command, this.spec.args ?? [], { + stdio: ['pipe', 'pipe', 'pipe'], + }); + let stdout = ''; + let stderr = ''; + let settled = false; + const settle = (result: TaskResult) => { + if (settled) return; + settled = true; + clearTimeout(timer); + resolve(result); + }; + const timer = setTimeout(() => { + child.kill('SIGKILL'); + settle(failure(`子进程执行超时(${timeoutMs}ms)`)); + }, timeoutMs); + + child.stdout.on('data', chunk => { + if (stdout.length < MAX_OUTPUT_CHARS) stdout += String(chunk); + }); + child.stderr.on('data', chunk => { + if (stderr.length < MAX_OUTPUT_CHARS) stderr += String(chunk); + }); + // 子进程早退不读 stdin 时,写入会触发 EPIPE——由 exit 路径统一收敛为 failed + child.stdin.on('error', () => undefined); + child.on('error', error => settle(failure(error))); + child.on('exit', code => { + if (code !== 0) { + settle(failure(`子进程退出码 ${code}: ${stderr.slice(-500)}`)); + return; + } + try { + settle(parseTaskResult(JSON.parse(stdout))); + } catch { + settle(failure(`子进程输出不是合法 TaskResult JSON: ${stdout.slice(-500)}`)); + } + }); + child.stdin.write(JSON.stringify(envelope)); + child.stdin.end(); + }); + } +} + +/** + * A2A 风格 HTTP 传输:远程第三方 Agent。 + * 约定(A2A 词汇的最小映射):GET /.well-known/agent.json 取能力卡片; + * POST /tasks 提交信封,返回任务结果。与完整 A2A 协议的映射留给后续适配。 + */ +export class A2aTransport implements AgentTransport { + readonly kind = 'a2a' as const; + private readonly endpoint: string; + + constructor(spec: { endpoint: string }) { + // 归一化尾部斜杠,避免 //tasks + this.endpoint = spec.endpoint.replace(/\/+$/, ''); + } + + async probe(): Promise { + try { + const response = await fetch(`${this.endpoint}/.well-known/agent.json`, { + signal: AbortSignal.timeout(PROBE_TIMEOUT_MS * 2), + }); + return response.ok; + } catch { + return false; + } + } + + async execute(envelope: TaskEnvelope): Promise { + const timeoutMs = envelope.timeoutMs ?? DEFAULT_TIMEOUT_MS; + try { + const response = await fetch(`${this.endpoint}/tasks`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(envelope), + signal: AbortSignal.timeout(timeoutMs), + }); + if (!response.ok) { + return failure(`A2A 端点 ${response.status}: ${(await response.text()).slice(-500)}`); + } + return parseTaskResult(await response.json()); + } catch (error) { + return failure(error); + } + } + + /** 拉取对端能力卡片(注册表探测/展示用) */ + async fetchCard(): Promise { + try { + const response = await fetch(`${this.endpoint}/.well-known/agent.json`, { + signal: AbortSignal.timeout(PROBE_TIMEOUT_MS * 2), + }); + if (!response.ok) return null; + return (await response.json()) as AgentCapabilityCard; + } catch { + return null; + } + } +} + +/** MCP 工具传输:把任务信封作为 MCP 工具的入参调用 */ +export class McpTransport implements AgentTransport { + readonly kind = 'mcp' as const; + + constructor(private readonly spec: { serverUrl: string; tool: string }) {} + + async probe(): Promise { + const client = new Client({ name: 'codekeeper-agent-probe', version: '0.1.0' }); + try { + await client.connect(new SSEClientTransport(new URL('/sse', this.spec.serverUrl))); + const tools = await client.listTools(); + return tools.tools.some(tool => tool.name === this.spec.tool); + } catch { + return false; + } finally { + await client.close().catch(() => undefined); + } + } + + async execute(envelope: TaskEnvelope): Promise { + const timeoutMs = envelope.timeoutMs ?? DEFAULT_TIMEOUT_MS; + const client = new Client({ name: 'codekeeper-agent-mcp', version: '0.1.0' }); + try { + const work = (async () => { + await client.connect(new SSEClientTransport(new URL('/sse', this.spec.serverUrl))); + const result = await client.callTool({ + name: this.spec.tool, + arguments: { envelope }, + }); + const text = (result.content as Array<{ type: string; text?: string }>) + .filter(part => part.type === 'text') + .map(part => part.text ?? '') + .join('\n'); + try { + return parseTaskResult(JSON.parse(text)); + } catch { + // 对端未回结构化结果:整体作为单产物返回 + return { + status: 'completed' as const, + output: { result: text }, + artifacts: [{ name: 'result', type: 'Text', content: text }], + }; + } + })(); + return await withTimeout(work, timeoutMs); + } catch (error) { + return failure(error); + } finally { + await client.close().catch(() => undefined); + } + } +} + +/** 超时就拒绝的包装(MCP 传输等原生不支持超时的路径用) */ +function withTimeout(promise: Promise, timeoutMs: number): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error(`执行超时(${timeoutMs}ms)`)), timeoutMs); + promise.then( + value => { + clearTimeout(timer); + resolve(value); + }, + error => { + clearTimeout(timer); + reject(error); + } + ); + }); +} diff --git a/src/advance/cli.ts b/src/advance/cli.ts index 17dcd44..e47c608 100755 --- a/src/advance/cli.ts +++ b/src/advance/cli.ts @@ -107,6 +107,7 @@ export async function main(): Promise { headers: persisted.headers, scanCron: persisted.scanCron, llmRequestsPerMinute: persisted.llmRequestsPerMinute, + agents: persisted.agents, }); daemon.start(); console.log('守护进程已启动'); diff --git a/src/advance/config/daemon-config.ts b/src/advance/config/daemon-config.ts index 6cf32fe..34eca02 100755 --- a/src/advance/config/daemon-config.ts +++ b/src/advance/config/daemon-config.ts @@ -1,6 +1,7 @@ import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs'; import { join } from 'node:path'; import { homedir } from 'node:os'; +import { agentSpecSchema } from '../agents/registry.js'; /** * EverOS 多模态模型覆盖配置。 @@ -35,6 +36,8 @@ export interface DaemonPersistedConfig { rerankModel?: string; /** EverOS 独立配置;未配置时继承 Agent 通用配置 */ everos?: EverOSConfig; + /** 外部 Agent 注册表(管线 agent.* 节点引用),结构见 agents/registry.ts */ + agents?: import('../agents/registry.js').AgentSpec[]; } const CONFIG_DIR = join(homedir(), '.codekeeper-advance'); @@ -46,7 +49,18 @@ export function loadDaemonConfig(): DaemonPersistedConfig { } try { const raw = readFileSync(CONFIG_PATH, 'utf-8'); - return JSON.parse(raw) as DaemonPersistedConfig; + const config = JSON.parse(raw) as DaemonPersistedConfig; + // agents 逐条校验:坏 spec 在启动期剔除,而不是等到节点执行时才炸 + if (Array.isArray(config.agents)) { + config.agents = config.agents.filter(spec => { + const result = agentSpecSchema.safeParse(spec); + if (!result.success) { + console.warn('[daemon-config] 忽略非法的外部 Agent 配置项:', spec?.id ?? '(无 id)'); + } + return result.success; + }); + } + return config; } catch { return {}; } diff --git a/src/advance/daemon.ts b/src/advance/daemon.ts index 9527235..09c8535 100755 --- a/src/advance/daemon.ts +++ b/src/advance/daemon.ts @@ -12,6 +12,7 @@ import { handlers, type HandlerContext } from './ipc/handlers'; import { logger } from './core/logger'; import { LlmClient } from './llm/client'; import { PipelineScheduler } from './pipeline/pipeline-scheduler.js'; +import { McpFacadeServer } from './agents/mcp-facade.js'; import { ROLES } from './types.js'; import { ScanService } from './scan/scan-service.js'; import { GitLabProvider } from './classic/provider/gitlab-provider.js'; @@ -60,6 +61,8 @@ export interface DaemonOptions { rerankModel?: string; /** EverOS 独立配置;未设置时继承 Agent 通用配置 */ everos?: import('./config/daemon-config.js').EverOSConfig; + /** 外部 Agent 注册表(管线 agent.* 节点引用) */ + agents?: import('./agents/registry.js').AgentSpec[]; /** 测试或嵌入场景可替换 CodeGraph 服务实现 */ codeGraphService?: CodeGraphServiceController; } @@ -77,6 +80,8 @@ export class Daemon { private everosService: EverOSService | null = null; private everosMcpServer: EverOSMcpServer | null = null; private everosMcpUrl: string | null = null; + private mcpFacade: McpFacadeServer | null = null; + private mcpFacadeUrl: string | null = null; private everosStarting = false; private everosState: EverosStatus['state'] = 'idle'; private everosHttpUrl: string | null = null; @@ -129,6 +134,7 @@ export class Daemon { model: this.options.model ?? '', headers: this.options.headers ?? {}, llmRequestsPerMinute: this.options.llmRequestsPerMinute ?? 10, + agents: this.options.agents ?? [], }), maxEventsPerScan: this.options.maxEventsPerScan, }); @@ -185,12 +191,14 @@ export class Daemon { embeddingModel: this.options.embeddingModel ?? DEFAULT_EMBEDDING_MODEL, rerankModel: this.options.rerankModel ?? DEFAULT_RERANK_MODEL, everos: this.options.everos ? JSON.stringify(this.options.everos) : '', + agents: this.options.agents ?? [], }), isDaemonRunning: () => this.running, getEverosStatus: () => this.getEverosStatus(), getCodeGraphStatus: () => this.getCodeGraphStatus(), watchProject: project => this.watchProject(project), unwatchProject: projectId => this.unwatchProject(projectId), + getMcpFacadeUrl: () => this.mcpFacadeUrl, }; // 将完整的 handlerContext 回设到 serviceRegistry @@ -207,6 +215,21 @@ export class Daemon { }); await this.ipcServer.start(); + // MCP 门面独立于 EverOS 启动:pipeline_list_runs/submit 不依赖记忆服务, + // knowledge_recall 在 EverOS 未就绪时自行降级 + try { + this.mcpFacade = new McpFacadeServer({ + scheduler: this.serviceRegistry, + getEverosUrl: () => this.everosHttpUrl, + }); + this.mcpFacadeUrl = await this.mcpFacade.start(); + logger.info({ mcpFacadeUrl: this.mcpFacadeUrl }, 'MCP 门面已启动(外部 Agent 接入)'); + } catch (err) { + logger.warn({ err }, 'MCP 门面启动失败(不影响其余功能)'); + this.mcpFacade = null; + this.mcpFacadeUrl = null; + } + try { const codeGraphUrl = await this.codeGraphService.start(); this.serviceRegistry.setCodeGraphUrl(codeGraphUrl); @@ -282,7 +305,10 @@ export class Daemon { clearTimeout(this.watcherTimeout); this.watcherTimeout = null; } - // 停止所有管线触发器与角色节点子进程 + // 停止 MCP 门面与所有管线触发器、角色节点子进程 + await this.mcpFacade?.stop(); + this.mcpFacade = null; + this.mcpFacadeUrl = null; await this.serviceRegistry.stopAll(); await this.codeGraphService.stop(); this.scanService.stop(); diff --git a/src/advance/ipc/handlers.ts b/src/advance/ipc/handlers.ts index 15d0727..402b294 100755 --- a/src/advance/ipc/handlers.ts +++ b/src/advance/ipc/handlers.ts @@ -95,10 +95,14 @@ export interface HandlerContext { scanCron: string; llmRequestsPerMinute: number; everos: string; + agents?: import('../agents/registry.js').AgentSpec[]; }; watchProject?: (project: Project) => void; unwatchProject?: (projectId: string) => void; - /** EverOS HTTP URL,用于 memory.search 等 handler 直接访问 */ + /** MCP 门面地址(外部 Agent 接入点) */ + getMcpFacadeUrl?: () => + | string + | null; /** EverOS HTTP URL,用于 memory.search 等 handler 直接访问 */ everosUrl?: string; /** 本地 Embedding/Rerank 模型服务管理器 */ localModelManager?: LocalModelServiceManager; @@ -819,6 +823,7 @@ export const handlers: Record Prom queuedJobs: 0, providers: [], }, + mcpFacade: { url: ctx.getMcpFacadeUrl?.() ?? null }, } satisfies DaemonStatus; }, diff --git a/src/advance/pipeline/core/executor.ts b/src/advance/pipeline/core/executor.ts index 052e0b9..8b24152 100644 --- a/src/advance/pipeline/core/executor.ts +++ b/src/advance/pipeline/core/executor.ts @@ -168,7 +168,7 @@ export class PipelineExecutor { const stageId = this.store?.beginStage(runId, node.id, inputs); try { - const outputs = (await handler.run(ctx, inputs, node.params)) ?? {}; + const outputs = (await handler.run(ctx, inputs, node.params, node)) ?? {}; completedOutputs.set(node.id, outputs); if (this.store && stageId) { this.store.finishStage(stageId, 'succeeded', outputs); diff --git a/src/advance/pipeline/core/types.ts b/src/advance/pipeline/core/types.ts index 0448567..91eae66 100644 --- a/src/advance/pipeline/core/types.ts +++ b/src/advance/pipeline/core/types.ts @@ -100,10 +100,15 @@ export interface NodeHandler { readonly inputs?: readonly string[]; /** 声明的输出端口名(可选,用于校验边连接与产物类型) */ readonly outputs?: readonly string[]; + /** + * 同类型多节点共享一份处理器时,经第四个参数区分当前执行节点 + * (信封 id、日志定位等需要节点粒度的场景必须用它,勿闭包捕获)。 + */ run( ctx: RunContext, inputs: Record, - params: Record + params: Record, + node: NodeDef ): Promise | void>; } diff --git a/src/advance/pipeline/pipeline-scheduler.ts b/src/advance/pipeline/pipeline-scheduler.ts index 5c7daa8..a50af89 100644 --- a/src/advance/pipeline/pipeline-scheduler.ts +++ b/src/advance/pipeline/pipeline-scheduler.ts @@ -31,6 +31,8 @@ import { import { pipelineDefinitionSchema, PipelineDefinitionError } from './core/types.js'; import { topoSort } from './core/topology.js'; import { RoleNodeRuntime } from '../classic/role-node-runtime.js'; +import { AgentRegistry } from '../agents/registry.js'; +import type { TaskEnvelope } from '../agents/task-envelope.js'; export interface PipelineSchedulerOptions { /** EverOS MCP Server URL(由 daemon 启动后回设) */ @@ -333,6 +335,14 @@ export class PipelineScheduler { } } + /** 手动触发某项目的角色节点立即执行(MCP 门面 / 未来 UI 按钮用) */ + async runProjectRoleNow(projectId: string, role: Role): Promise { + const project = this.context.store.getProject(projectId); + if (!project) throw new Error(`项目不存在: ${projectId}`); + ensurePipelineDefinition(project); + await this.runRoleNow(project, role); + } + /** 立即执行某项目的角色节点(启动/重启后的对账轮) */ private async runRoleNow(project: Project, role: Role): Promise { const definition = loadProjectPipeline(project); @@ -348,7 +358,7 @@ export class PipelineScheduler { roleNodeId: string, role: Role ): Promise { - const handlers = this.buildNodeHandlers(project, role); + const handlers = this.buildNodeHandlers(project, role, definition); const executor = new PipelineExecutor(handlers, this.getRunStore()); const triggerNode = this.findUpstreamTrigger(definition, roleNodeId); const startFrom = triggerNode ? [triggerNode.id] : [roleNodeId]; @@ -358,7 +368,11 @@ export class PipelineScheduler { }); } - private buildNodeHandlers(project: Project, role: Role): Map { + private buildNodeHandlers( + project: Project, + role: Role, + definition?: PipelineDefinition + ): Map { const handlers = new Map(); handlers.set('trigger.cron', { type: 'trigger.cron', @@ -375,6 +389,38 @@ export class PipelineScheduler { return {}; }, }); + + // 外部 Agent 节点:经注册表解析传输适配器,任务信封交互 + if (definition) { + const registry = new AgentRegistry(this.context.getDaemonConfig?.().agents ?? []); + for (const node of definition.nodes) { + if (!node.type.startsWith('agent.') || handlers.has(node.type)) continue; + handlers.set(node.type, { + type: node.type, + run: async (ctx, inputs, params, currentNode) => { + const transport = registry.resolveTransport(node.type, params); + const capability = + typeof params.capability === 'string' ? params.capability : node.type; + const envelope: TaskEnvelope = { + id: `${ctx.vars.runId ?? 'run'}:${currentNode.id}`, + capability, + input: inputs, + artifacts: Object.entries(inputs).map(([name, value]) => ({ + name, + type: 'PipelineArtifact', + content: typeof value === 'string' ? value : JSON.stringify(value), + })), + timeoutMs: typeof params.timeoutMs === 'number' ? params.timeoutMs : undefined, + }; + const result = await transport.execute(envelope); + if (result.status === 'failed') { + throw new Error(`外部 Agent 执行失败: ${result.error ?? '未知错误'}`); + } + return result.output; + }, + }); + } + } return handlers; } diff --git a/src/electron/shared/service-status.ts b/src/electron/shared/service-status.ts index 17e12c8..d6612f1 100755 --- a/src/electron/shared/service-status.ts +++ b/src/electron/shared/service-status.ts @@ -58,6 +58,8 @@ export interface DaemonStatus { daemonRunning: boolean; everos: EverosStatus; codeGraph: CodeGraphServiceStatus; + /** MCP 门面地址(外部 Agent 接入点);未启动时为 null */ + mcpFacade?: { url: string | null }; } export interface LocalModelStatus { diff --git a/tests/advance/agents/mcp-facade.test.ts b/tests/advance/agents/mcp-facade.test.ts new file mode 100644 index 0000000..725559d --- /dev/null +++ b/tests/advance/agents/mcp-facade.test.ts @@ -0,0 +1,109 @@ +// @vitest-environment node +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js'; +import { McpFacadeServer } from '../../../src/advance/agents/mcp-facade.js'; + +/** + * MCP 门面端到端:真实起 127.0.0.1 服务 + MCP SSE client 调用。 + * scheduler 与 EverOS 均为 mock/缺省,验证门面协议与降级行为。 + */ +describe('McpFacadeServer', () => { + let facade: McpFacadeServer | null = null; + + afterEach(async () => { + await facade?.stop(); + facade = null; + }); + + async function startFacade(overrides: { + runs?: unknown[]; + submit?: ReturnType; + everosUrl?: string | null; + }) { + facade = new McpFacadeServer({ + scheduler: { + listPipelineRuns: () => overrides.runs ?? [], + runProjectRoleNow: overrides.submit ?? vi.fn().mockResolvedValue(undefined), + } as never, + getEverosUrl: () => overrides.everosUrl ?? null, + }); + const facadeUrl = await facade.start(); + // 门面 URL 自带 token:/sse 路径需保留查询串 + const base = new URL(facadeUrl); + const sseUrl = `${base.origin}/sse${base.search}`; + const client = new Client({ name: 'facade-test', version: '0.0.1' }); + await client.connect(new SSEClientTransport(new URL(sseUrl))); + return client; + } + + it('未带 token 的连接被拒绝', async () => { + facade = new McpFacadeServer({ + scheduler: { listPipelineRuns: () => [], runProjectRoleNow: vi.fn() } as never, + getEverosUrl: () => null, + }); + const facadeUrl = await facade.start(); + const base = new URL(facadeUrl); + const response = await fetch(`${base.origin}/sse`); + expect(response.status).toBe(401); + }); + + it('列出工具清单', async () => { + const client = await startFacade({}); + const tools = await client.listTools(); + expect(tools.tools.map(t => t.name).sort()).toEqual([ + 'knowledge_recall', + 'pipeline_list_runs', + 'pipeline_submit', + ]); + await client.close(); + }); + + it('pipeline_list_runs 返回运行记录', async () => { + const client = await startFacade({ + runs: [{ id: 'r1', status: 'succeeded', stages: [] }], + }); + const result = await client.callTool({ + name: 'pipeline_list_runs', + arguments: { projectId: 'p1' }, + }); + const text = (result.content as Array<{ text?: string }>)[0].text ?? ''; + expect(JSON.parse(text)[0].id).toBe('r1'); + await client.close(); + }); + + it('pipeline_submit 异步触发角色执行', async () => { + const submit = vi.fn().mockResolvedValue(undefined); + const client = await startFacade({ submit }); + const result = await client.callTool({ + name: 'pipeline_submit', + arguments: { projectId: 'p1', role: 'reviewer' }, + }); + const text = (result.content as Array<{ text?: string }>)[0].text ?? ''; + expect(JSON.parse(text).submitted).toBe(true); + await vi.waitFor(() => expect(submit).toHaveBeenCalledWith('p1', 'reviewer')); + await client.close(); + }); + + it('pipeline_submit 拒绝未知角色', async () => { + const client = await startFacade({}); + const result = await client.callTool({ + name: 'pipeline_submit', + arguments: { projectId: 'p1', role: 'ghost' }, + }); + const text = (result.content as Array<{ text?: string }>)[0].text ?? ''; + expect(JSON.parse(text).error).toContain('未知角色'); + await client.close(); + }); + + it('knowledge_recall 在 EverOS 未就绪时返回明确错误', async () => { + const client = await startFacade({ everosUrl: null }); + const result = await client.callTool({ + name: 'knowledge_recall', + arguments: { projectId: 'p1', query: '约定' }, + }); + const text = (result.content as Array<{ text?: string }>)[0].text ?? ''; + expect(JSON.parse(text).error).toContain('EverOS'); + await client.close(); + }); +}); diff --git a/tests/advance/agents/registry-transports.test.ts b/tests/advance/agents/registry-transports.test.ts new file mode 100644 index 0000000..b079e91 --- /dev/null +++ b/tests/advance/agents/registry-transports.test.ts @@ -0,0 +1,158 @@ +import { describe, expect, it, vi } from 'vitest'; +import { AgentRegistry, agentSpecSchema } from '../../../src/advance/agents/registry.js'; +import { A2aTransport } from '../../../src/advance/agents/transports.js'; +import type { TaskEnvelope } from '../../../src/advance/agents/task-envelope.js'; + +const baseEnvelope: TaskEnvelope = { + id: 'run-1:node-1', + capability: 'echo', + input: { value: 41 }, + artifacts: [], +}; + +describe('AgentRegistry', () => { + it('注册表解析:agentId 引用 + 内联参数合并', () => { + const registry = new AgentRegistry([ + { + id: 'local-echo', + name: '本地回显', + transport: 'subprocess', + command: 'node', + args: ['-e', 'console.log("x")'], + capabilities: ['echo'], + }, + ]); + const transport = registry.resolveTransport('agent.subprocess', { agentId: 'local-echo' }); + expect(transport.kind).toBe('subprocess'); + }); + + it('未注册 agentId 抛错', () => { + const registry = new AgentRegistry([]); + expect(() => registry.resolveTransport('agent.a2a', { agentId: 'ghost' })).toThrow(/未注册/); + }); + + it('内联参数可构造一次性网络类传输(a2a 无需注册)', () => { + const registry = new AgentRegistry([]); + const transport = registry.resolveTransport('agent.a2a', { + endpoint: 'http://agent.example.invalid', + }); + expect(transport.kind).toBe('a2a'); + }); + + it('subprocess 强制要求已注册 agentId(命令只允许本机白名单)', () => { + const registry = new AgentRegistry([]); + expect(() => registry.resolveTransport('agent.subprocess', { command: 'sh' })).toThrow( + /agentId/ + ); + }); + + it('agentId 引用时注册项优先于内联 params(防止 pipeline.yaml 投毒)', () => { + const registry = new AgentRegistry([ + { + id: 'trusted', + name: '可信', + transport: 'subprocess', + command: 'node', + capabilities: [], + }, + ]); + const transport = registry.resolveTransport('agent.subprocess', { + agentId: 'trusted', + command: 'evil-cmd', + }); + expect(transport.kind).toBe('subprocess'); + // spec 覆盖 params:command 仍是注册项的 node + expect((transport as unknown as { spec: { command: string } }).spec.command).toBe('node'); + }); + + it('缺少连接参数时报错信息含字段名', () => { + const registry = new AgentRegistry([ + { id: 'no-cmd', name: 'x', transport: 'subprocess', command: '', capabilities: [] }, + ]); + expect(() => registry.resolveTransport('agent.subprocess', { agentId: 'no-cmd' })).toThrow( + /command/ + ); + expect(() => registry.resolveTransport('agent.mcp', {})).toThrow(/serverUrl/); + }); + + it('spec schema 校验非法 transport', () => { + expect(() => agentSpecSchema.parse({ id: 'x', name: 'x', transport: 'pigeon' })).toThrow(); + }); + + it('subprocess probe:node 可执行返回 true', async () => { + const registry = new AgentRegistry([ + { id: 'n', name: 'n', transport: 'subprocess', command: process.execPath, args: [] }, + ]); + const results = await registry.probeAll(); + expect(results).toEqual([{ id: 'n', ok: true }]); + }); +}); + +describe('SubprocessTransport', () => { + it('stdin 收信封,stdout 回结果', async () => { + const { SubprocessTransport } = await import('../../../src/advance/agents/transports.js'); + // 从 stdin 读信封,回显 input.value+1 + const script = ` + let data='';process.stdin.on('data',c=>data+=c).on('end',()=>{ + const env=JSON.parse(data); + console.log(JSON.stringify({status:'completed',output:{value:env.input.value+1},artifacts:[]})); + });`; + const transport = new SubprocessTransport({ command: process.execPath, args: ['-e', script] }); + const result = await transport.execute(baseEnvelope); + expect(result.status).toBe('completed'); + expect(result.output.value).toBe(42); + }); + + it('退出码非零视为 failed 并带 stderr 尾部', async () => { + const { SubprocessTransport } = await import('../../../src/advance/agents/transports.js'); + const transport = new SubprocessTransport({ + command: process.execPath, + args: ['-e', 'console.error("炸了");process.exit(2)'], + }); + const result = await transport.execute(baseEnvelope); + expect(result.status).toBe('failed'); + expect(result.error).toContain('炸了'); + }); +}); + +describe('A2aTransport', () => { + it('POST /tasks 提交信封并解析结果', async () => { + const calls: Array<{ url: string; body: unknown }> = []; + vi.stubGlobal( + 'fetch', + vi.fn(async (url: string, init?: { body?: string }) => { + calls.push({ url, body: init?.body ? JSON.parse(init.body) : null }); + return { + ok: true, + json: async () => ({ status: 'completed', output: { ack: true }, artifacts: [] }), + text: async () => '', + } as Response; + }) + ); + try { + const transport = new A2aTransport({ endpoint: 'http://agent.example.invalid' }); + const result = await transport.execute(baseEnvelope); + expect(result.status).toBe('completed'); + expect(calls[0].url).toBe('http://agent.example.invalid/tasks'); + expect((calls[0].body as TaskEnvelope).capability).toBe('echo'); + } finally { + vi.unstubAllGlobals(); + } + }); + + it('端点不可达时 probe 为 false、execute 返回 failed', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => Promise.reject(new Error('连接失败'))) + ); + try { + const transport = new A2aTransport({ endpoint: 'http://agent.example.invalid' }); + expect(await transport.probe()).toBe(false); + const result = await transport.execute(baseEnvelope); + expect(result.status).toBe('failed'); + expect(result.error).toContain('连接失败'); + } finally { + vi.unstubAllGlobals(); + } + }); +}); diff --git a/tests/advance/ipc/daemon-status.test.ts b/tests/advance/ipc/daemon-status.test.ts index 4c02859..1fcb012 100755 --- a/tests/advance/ipc/daemon-status.test.ts +++ b/tests/advance/ipc/daemon-status.test.ts @@ -41,6 +41,7 @@ describe('daemon.status handler', () => { queuedJobs: 2, providers: [], }, + mcpFacade: { url: null }, }); }); @@ -65,6 +66,7 @@ describe('daemon.status handler', () => { queuedJobs: 0, providers: [], }, + mcpFacade: { url: null }, }); }); }); From 0c42a47ed5a6c4990e60241dfec813ee34c779e4 Mon Sep 17 00:00:00 2001 From: Windsander Date: Tue, 8 Sep 2026 22:28:25 +0800 Subject: [PATCH 11/18] =?UTF-8?q?feat(knowledge):=20=E5=8F=8C=E6=AD=A3?= =?UTF-8?q?=E6=9C=AC=E6=99=BA=E5=BA=93=E2=80=94=E2=80=94=E7=AD=96=E5=B1=95?= =?UTF-8?q?=E6=AD=A3=E6=9C=AC=20+=20EverOS=20=E6=8A=95=E5=BD=B1=20+=20?= =?UTF-8?q?=E4=BA=BA=E5=AE=A1=E8=92=B8=E9=A6=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit M6 双正本智库: - knowledge-store.ts:策展正本层——共享 /.codekeeper/knowledge/(入库) + 私有 ~/.codekeeper/memory/knowledge//(本地,测试可注入); Markdown+frontmatter zod 校验;容忍 BOM;inbox 草稿箱 - knowledge-projection.ts:正本→EverOS 投影,状态键含 root 维度 (双正本同 id 幂等正确),flush 失败回滚状态下轮重投 - knowledge-distill.ts:EverOS 经验 → LLM 候选(prompt 外置)→ knowledge-inbox/(source: distilled;与正本+草稿箱去重;UUID 兜底 id) - 召回闭环:recall_project_knowledge 扩展为 archiver + knowledge-projection 双 owner,投影的策展知识可被 Reviewer/Maintainer 召回 - 调度器:knowledge.project / knowledge.distill 节点处理器(daemon 进程内); 调度从按角色节点重构为按 trigger 节点拉分支——纯知识/Agent 分支可独立调度 - 默认管线:archiver 后链 knowledge.project - IPC:knowledge.list/approve/dismiss(knowledgeId slug 校验防路径穿越); UI:ProjectDetail 新增 Knowledge tab(草稿批准/驳回 + 正本列表) - 画布调色板加入知识节点;DESIGN.md 修复里程碑表并补智库章节 (含 knowledge-inbox 入库提示) 对抗审核+计划保真度审计:M1-M6 与计划一致;修复召回口径断裂、 双根幂等、IPC 路径穿越 3 个主要项及 BOM/flush 回滚/蒸馏去重等次要项。 README 双语能力章节更新留待 M7 收尾时统一刷新。 验证:build + vitest(1019 通过)+ lint 零警告 + format + electron:build 全绿 --- DESIGN.md | 16 +- .../classic/memory/everos-mcp-server.ts | 28 +- src/advance/ipc/handlers.ts | 70 ++++- src/advance/knowledge/knowledge-distill.ts | 113 ++++++++ src/advance/knowledge/knowledge-projection.ts | 124 ++++++++ src/advance/knowledge/knowledge-store.ts | 150 ++++++++++ src/advance/pipeline/default-pipeline.ts | 16 ++ src/advance/pipeline/pipeline-scheduler.ts | 179 ++++++++---- src/advance/store/schema.sql | 10 + src/assets/prompts/knowledge-distill.md | 27 ++ .../renderer/components/KnowledgePanel.tsx | 115 ++++++++ .../renderer/components/pipeline-edit.ts | 2 + src/electron/renderer/pages/ProjectDetail.tsx | 10 +- src/electron/renderer/styles/global.css | 77 +++++ tests/advance/knowledge/knowledge.test.ts | 269 ++++++++++++++++++ .../advance/pipeline/default-pipeline.test.ts | 9 +- 16 files changed, 1145 insertions(+), 70 deletions(-) create mode 100644 src/advance/knowledge/knowledge-distill.ts create mode 100644 src/advance/knowledge/knowledge-projection.ts create mode 100644 src/advance/knowledge/knowledge-store.ts create mode 100644 src/assets/prompts/knowledge-distill.md create mode 100644 src/electron/renderer/components/KnowledgePanel.tsx create mode 100644 tests/advance/knowledge/knowledge.test.ts diff --git a/DESIGN.md b/DESIGN.md index cac5ed1..8db7e7f 100755 --- a/DESIGN.md +++ b/DESIGN.md @@ -126,6 +126,8 @@ Archiver 文件优先管线产出策展产物(context.md 等)。 | M3 | Role 黑箱节点化;daemon 调度改造;配置自动迁移;本文档 | 进行中 | | M4 | 可视化画布(节点编辑、检查器吸收 Role 配置、运行状态叠加) | ✅ | | M5 | 外部 Agent 节点(A2A 词汇任务信封 + A2A/subprocess/MCP 适配器 + MCP 门面) | ✅ | +| M6 | 双正本智库 + 投影管线 + 蒸馏管线(人审闸门) | ✅ | +| M7 | 钻取层:Role 节点内 stage 子图拆解与编排 | | ### 外部 Agent 接入(M5) @@ -140,8 +142,18 @@ Archiver 文件优先管线产出策展产物(context.md 等)。 - **MCP 门面**(`src/advance/agents/mcp-facade.ts`):127.0.0.1 + 随机 token, 工具:`pipeline_list_runs` / `pipeline_submit`(异步派发)/ `knowledge_recall`。 门面独立于 EverOS 启动,地址经 `daemon.status` 展示。 - | M6 | 双正本智库 + 投影管线 + 蒸馏管线 | | - | M7 | 钻取层:Role 节点内 stage 子图拆解与编排 | | + +### 智库双正本(M6) + +- **正本**(唯一事实源,Markdown + frontmatter):共享 `/.codekeeper/knowledge/` + (随项目入库)+ 私有 `~/.codekeeper/memory/knowledge//`(本地)。 + 注意:人审草稿箱 `knowledge-inbox/` 也在项目内,未审的蒸馏草稿会随 git 可见—— + 团队不希望草稿入库时,请把 `.codekeeper/knowledge-inbox/` 加入项目 .gitignore。 +- **投影**:正本 → EverOS(内容哈希去重,状态表 knowledge_projection 含 root 维度; + flush 失败回滚状态下轮重投);EverOS/索引层随时可重建。 +- **蒸馏**:EverOS 经验 → LLM 候选 → 一律进 `knowledge-inbox/`(source: distilled), + 人类在 Knowledge tab 批准后才进入共享正本(承接旧 learn 循环的人审闸门)。 +- **召回口径**:`recall_project_knowledge` 覆盖 archiver 与 knowledge-projection 两个 owner。 ## 五、工程约束(RULES) diff --git a/src/advance/classic/memory/everos-mcp-server.ts b/src/advance/classic/memory/everos-mcp-server.ts index 6068a77..48c4c7d 100755 --- a/src/advance/classic/memory/everos-mcp-server.ts +++ b/src/advance/classic/memory/everos-mcp-server.ts @@ -924,14 +924,26 @@ export class EverOSMcpServer { query: string; }): Promise { const ctx = args.context; - const result = await everosMemorySearch(this.everosUrl, { - appId: ctx.appId, - projectId: ctx.projectId, - owner: { kind: 'agent', agentId: 'archiver' }, - query: args.query, - topK: 5, - }); - return result.items; + // 项目知识的两个 owner 口径:archiver 蒸馏产物 + 智库正本投影(M6) + const owners = ['archiver', 'knowledge-projection']; + const merged: EverOSSearchItem[] = []; + const seen = new Set(); + for (const agentId of owners) { + const result = await everosMemorySearch(this.everosUrl, { + appId: ctx.appId, + projectId: ctx.projectId, + owner: { kind: 'agent', agentId }, + query: args.query, + topK: 5, + }); + for (const item of result.items) { + if (!seen.has(item.id)) { + seen.add(item.id); + merged.push(item); + } + } + } + return merged.slice(0, 5); } private async handleMemoryDelete( diff --git a/src/advance/ipc/handlers.ts b/src/advance/ipc/handlers.ts index 402b294..ddb217d 100755 --- a/src/advance/ipc/handlers.ts +++ b/src/advance/ipc/handlers.ts @@ -1,4 +1,4 @@ -import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs'; +import { readFileSync, writeFileSync, existsSync, mkdirSync, unlinkSync } from 'node:fs'; import { join } from 'node:path'; import { getLogDir } from '../core/platform'; import { logger } from '../core/logger'; @@ -25,6 +25,30 @@ import type { ScanService } from '../scan/scan-service.js'; import type { IGitProvider } from '../classic/provider/types.js'; import type { PipelineScheduler } from '../pipeline/pipeline-scheduler.js'; import { regeneratePipelineDefinitionIfGenerated } from '../pipeline/default-pipeline.js'; +import { + getKnowledgeInboxDir, + listProjectKnowledge, + parseKnowledgeItem, + readKnowledgeDir, + writeSharedKnowledge, + type KnowledgeItem, +} from '../knowledge/knowledge-store.js'; + +/** 知识条目传输形状(正文截断,避免大文件全量过 IPC) */ +function toKnowledgeDto(item: KnowledgeItem) { + return { + id: item.frontmatter.id, + title: item.frontmatter.title, + category: item.frontmatter.category, + scope: item.frontmatter.scope, + confidence: item.frontmatter.confidence, + source: item.frontmatter.source, + tags: item.frontmatter.tags, + updated: item.frontmatter.updated, + root: item.root, + bodyPreview: item.body.slice(0, 500), + }; +} import type { LocalModelServiceManager } from '../classic/memory/local-model-service.js'; import type { ModelCapability } from '../classic/memory/model-server.js'; import { ReviewerManager } from '../classic/roles/reviewer-manager.js'; @@ -100,9 +124,8 @@ export interface HandlerContext { watchProject?: (project: Project) => void; unwatchProject?: (projectId: string) => void; /** MCP 门面地址(外部 Agent 接入点) */ - getMcpFacadeUrl?: () => - | string - | null; /** EverOS HTTP URL,用于 memory.search 等 handler 直接访问 */ + getMcpFacadeUrl?: () => string | null; + /** EverOS HTTP URL,用于 memory.search / 知识投影等直接访问 */ everosUrl?: string; /** 本地 Embedding/Rerank 模型服务管理器 */ localModelManager?: LocalModelServiceManager; @@ -980,6 +1003,45 @@ export const handlers: Record Prom if (!ctx.serviceRegistry) throw new Error('管线调度器未初始化'); return ctx.serviceRegistry.listPipelineRuns(projectId, limit); }, + + 'knowledge.list': async (ctx, params) => { + const { projectId } = params as { projectId: string }; + const project = ctx.store.getProject(projectId); + if (!project) throw new Error(`项目不存在: ${projectId}`); + return { + items: listProjectKnowledge(project).map(toKnowledgeDto), + inbox: readKnowledgeDir(getKnowledgeInboxDir(project), 'shared').map(toKnowledgeDto), + }; + }, + + 'knowledge.approve': async (ctx, params) => { + const { projectId, knowledgeId } = params as { projectId: string; knowledgeId: string }; + if (!/^[a-z0-9][a-z0-9-]*$/.test(knowledgeId)) { + throw new Error(`非法的知识 id: ${knowledgeId}`); + } + const project = ctx.store.getProject(projectId); + if (!project) throw new Error(`项目不存在: ${projectId}`); + const inboxDir = getKnowledgeInboxDir(project); + const source = join(inboxDir, `${knowledgeId}.md`); + if (!existsSync(source)) throw new Error(`草稿不存在: ${knowledgeId}`); + // 人审通过:移入共享正本(随项目入库) + const item = parseKnowledgeItem(readFileSync(source, 'utf-8'), 'shared', source); + writeSharedKnowledge(project, { frontmatter: item.frontmatter, body: item.body }); + unlinkSync(source); + return { success: true }; + }, + + 'knowledge.dismiss': async (ctx, params) => { + const { projectId, knowledgeId } = params as { projectId: string; knowledgeId: string }; + if (!/^[a-z0-9][a-z0-9-]*$/.test(knowledgeId)) { + throw new Error(`非法的知识 id: ${knowledgeId}`); + } + const project = ctx.store.getProject(projectId); + if (!project) throw new Error(`项目不存在: ${projectId}`); + const target = join(getKnowledgeInboxDir(project), `${knowledgeId}.md`); + if (existsSync(target)) unlinkSync(target); + return { success: true }; + }, }; function mapEverOSSearchItemToMemoryEntry(item: EverOSSearchItem): MemoryEntry { diff --git a/src/advance/knowledge/knowledge-distill.ts b/src/advance/knowledge/knowledge-distill.ts new file mode 100644 index 0000000..7b4dc65 --- /dev/null +++ b/src/advance/knowledge/knowledge-distill.ts @@ -0,0 +1,113 @@ +/** + * 知识蒸馏管线:EverOS 经验记忆 → 策展知识候选(人审草稿箱)。 + * + * 承接旧版 learn 循环的精神(经验 → 策展知识),但以"人工检查点"为闸门: + * LLM 产出的候选一律写入 knowledge-inbox/(source: distilled), + * 由人类在 Knowledge tab 审阅后批准才进入正本。 + */ + +import { mkdirSync, writeFileSync } from 'node:fs'; +import { randomUUID } from 'node:crypto'; +import { join } from 'node:path'; +import { z } from 'zod'; +import { logger } from '../core/logger.js'; +import type { LlmClient } from '../llm/client.js'; +import { FilePromptLoader } from '../llm/prompts/loader.js'; +import type { Project } from '../types.js'; +import { + getKnowledgeInboxDir, + listProjectKnowledge, + readKnowledgeDir, + serializeKnowledgeItem, + type KnowledgeFrontmatter, +} from './knowledge-store.js'; + +const candidateSchema = z.object({ + id: z + .string() + .regex(/^[a-z0-9][a-z0-9-]*$/) + .catch(() => `c-${randomUUID().slice(0, 8)}`), + title: z.string().min(1), + category: z.enum(['convention', 'architecture', 'domain', 'risk', 'stack', 'graph']), + confidence: z.enum(['high', 'medium', 'low']).default('low'), + tags: z.array(z.string()).default([]), + body: z.string().min(1), +}); + +const distillResponseSchema = z.object({ + candidates: z.array(candidateSchema).default([]), +}); + +export interface DistillResult { + candidates: number; + written: string[]; + skippedDuplicates: number; +} + +/** + * 从项目经验记忆蒸馏知识候选,写入人审草稿箱。 + * experiences:EverOS 中召回的近期经验文本(由调用方收集)。 + */ +export async function distillKnowledgeCandidates( + project: Project, + llmClient: LlmClient, + experiences: string[] +): Promise { + const result: DistillResult = { candidates: 0, written: [], skippedDuplicates: 0 }; + if (experiences.length === 0) return result; + + const promptLoader = new FilePromptLoader(); + const prompt = promptLoader.load('knowledge-distill', { + experiences: experiences.join('\n\n---\n\n'), + }); + + const raw = await llmClient.completeJson(prompt, '你是项目智库的蒸馏器。', { + type: 'object', + properties: { candidates: { type: 'array' } }, + required: ['candidates'], + }); + let parsedRaw: unknown; + try { + parsedRaw = JSON.parse(raw); + } catch { + logger.warn('[KnowledgeDistill] 蒸馏结果不是合法 JSON,丢弃本轮'); + return result; + } + const parsed = distillResponseSchema.safeParse(parsedRaw); + if (!parsed.success) { + logger.warn(`[KnowledgeDistill] 蒸馏结果不符合 schema,丢弃本轮`); + return result; + } + + // 与既有正本及待审草稿都去重(同 id 不重复入草稿箱) + const existingIds = new Set([ + ...listProjectKnowledge(project).map(item => item.frontmatter.id), + ...readKnowledgeDir(getKnowledgeInboxDir(project), 'shared').map(item => item.frontmatter.id), + ]); + const inboxDir = getKnowledgeInboxDir(project); + mkdirSync(inboxDir, { recursive: true }); + + for (const candidate of parsed.data.candidates) { + result.candidates += 1; + if (existingIds.has(candidate.id)) { + result.skippedDuplicates += 1; + continue; + } + existingIds.add(candidate.id); + const frontmatter: KnowledgeFrontmatter = { + id: candidate.id, + title: candidate.title, + category: candidate.category, + scope: 'project', + confidence: candidate.confidence, + source: 'distilled', + tags: candidate.tags, + updated: new Date().toISOString().slice(0, 10), + }; + const filePath = join(inboxDir, `${frontmatter.id}.md`); + writeFileSync(filePath, serializeKnowledgeItem({ frontmatter, body: candidate.body }), 'utf-8'); + result.written.push(filePath); + } + + return result; +} diff --git a/src/advance/knowledge/knowledge-projection.ts b/src/advance/knowledge/knowledge-projection.ts new file mode 100644 index 0000000..374cf80 --- /dev/null +++ b/src/advance/knowledge/knowledge-projection.ts @@ -0,0 +1,124 @@ +/** + * 知识投影:策展正本(Markdown 文件)→ EverOS 记忆索引。 + * + * 方向单向:正本(人类/蒸馏落盘的文件)→ EverOS 检索层。 + * 以内容哈希去重(knowledge_projection 表),EverOS 侧数据可随时视为可重建: + * 清空记忆后重跑投影即可恢复检索层。 + */ + +import { createHash } from 'node:crypto'; +import type Database from 'better-sqlite3'; +import { logger } from '../core/logger.js'; +import { everosMemoryAddMessages, everosMemoryFlush } from '../classic/memory/everos-api.js'; +import type { EverOSAddMessage } from '../classic/memory/everos-api.js'; +import type { Project } from '../types.js'; +import { listProjectKnowledge, type KnowledgeItem } from './knowledge-store.js'; + +const APP_ID = 'codekeeper-advance'; +/** 知识投影写入 EverOS 时使用的 owner/会话约定(与 archiver 的 record_project_knowledge 同级) */ +const KNOWLEDGE_AGENT = 'knowledge-projection'; + +export interface ProjectionResult { + total: number; + synced: number; + skipped: number; + failed: number; +} + +/** 把项目双正本中发生变化的知识条目投影到 EverOS */ +export async function projectKnowledgeToEverOS( + db: Database.Database, + everosUrl: string, + project: Project +): Promise { + const items = listProjectKnowledge(project); + const result: ProjectionResult = { total: items.length, synced: 0, skipped: 0, failed: 0 }; + + const upsertState = db.prepare( + `INSERT INTO knowledge_projection (project_id, root, knowledge_id, content_hash, synced_at) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(project_id, root, knowledge_id) + DO UPDATE SET content_hash = excluded.content_hash, synced_at = excluded.synced_at` + ); + const getState = db.prepare( + 'SELECT content_hash FROM knowledge_projection WHERE project_id = ? AND root = ? AND knowledge_id = ?' + ); + const deleteState = db.prepare( + 'DELETE FROM knowledge_projection WHERE project_id = ? AND root = ? AND knowledge_id = ?' + ); + const syncedThisRound: KnowledgeItem[] = []; + + for (const item of items) { + const hash = hashItem(item); + const existing = getState.get(project.id, item.root, item.frontmatter.id) as + | { content_hash: string } + | undefined; + if (existing?.content_hash === hash) { + result.skipped += 1; + continue; + } + + try { + const message: EverOSAddMessage = { + senderId: KNOWLEDGE_AGENT, + role: 'assistant', + content: formatKnowledgeMessage(item), + timestamp: Date.now(), + }; + await everosMemoryAddMessages( + everosUrl, + { appId: APP_ID, projectId: project.id, sessionId: `knowledge-${project.id}` }, + [message] + ); + upsertState.run(project.id, item.root, item.frontmatter.id, hash, Date.now()); + syncedThisRound.push(item); + result.synced += 1; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + logger.warn(`[KnowledgeProjection] 条目 ${item.frontmatter.id} 投影失败: ${message}`); + result.failed += 1; + } + } + + if (result.synced > 0) { + try { + await everosMemoryFlush(everosUrl, { + appId: APP_ID, + projectId: project.id, + sessionId: `knowledge-${project.id}`, + }); + } catch (error) { + // flush 失败:回滚本轮状态,下轮重新写入并 flush(避免消息滞留 buffer) + for (const item of syncedThisRound) { + deleteState.run(project.id, item.root, item.frontmatter.id); + } + result.synced = 0; + result.failed += syncedThisRound.length; + logger.warn({ err: error }, '[KnowledgeProjection] flush 失败,本轮状态已回滚'); + } + } + + return result; +} + +function hashItem(item: KnowledgeItem): string { + return createHash('sha256') + .update(JSON.stringify(item.frontmatter)) + .update('\n') + .update(item.body) + .digest('hex'); +} + +function formatKnowledgeMessage(item: KnowledgeItem): string { + const fm = item.frontmatter; + return [ + `[KNOWLEDGE:${fm.id}]`, + `title: ${fm.title}`, + `category: ${fm.category} | scope: ${fm.scope} | confidence: ${fm.confidence} | source: ${fm.source} | root: ${item.root}`, + fm.tags.length > 0 ? `tags: ${fm.tags.join(', ')}` : '', + '', + item.body, + ] + .filter(line => line !== '') + .join('\n'); +} diff --git a/src/advance/knowledge/knowledge-store.ts b/src/advance/knowledge/knowledge-store.ts new file mode 100644 index 0000000..998b34d --- /dev/null +++ b/src/advance/knowledge/knowledge-store.ts @@ -0,0 +1,150 @@ +/** + * 智库策展正本层(双正本)。 + * + * 唯一事实源是 Markdown + YAML frontmatter 文件: + * - 共享正本:/.codekeeper/knowledge/(随项目入库,团队可见) + * - 私有正本:~/.codekeeper/memory/knowledge//(本地私有) + * + * EverOS / CodeGraph 只是它们的可重建投影(见 knowledge-projection.ts)。 + */ + +import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { parse as parseYaml, stringify } from 'yaml'; +import { z } from 'zod'; +import { getAppStorageDir } from '../core/platform.js'; +import type { Project } from '../types.js'; + +/** 策展知识条目的 frontmatter */ +export const knowledgeFrontmatterSchema = z.object({ + id: z + .string() + .min(1) + .regex(/^[a-z0-9][a-z0-9-]*$/, 'id 必须是小写 slug'), + title: z.string().min(1), + category: z.enum(['convention', 'architecture', 'domain', 'risk', 'stack', 'graph']), + /** project=项目内共享;system=跨项目共性(写入时仍可放项目根,投影时上移) */ + scope: z.enum(['project', 'system']).default('project'), + confidence: z.enum(['high', 'medium', 'low']).default('medium'), + source: z.enum(['human', 'distilled', 'archiver']).default('human'), + tags: z.array(z.string()).default([]), + updated: z.string().min(1), +}); +export type KnowledgeFrontmatter = z.infer; + +export interface KnowledgeItem { + frontmatter: KnowledgeFrontmatter; + /** Markdown 正文 */ + body: string; + /** 来源根:shared=项目入库;local=本地私有 */ + root: 'shared' | 'local'; + filePath: string; +} + +export class KnowledgeStoreError extends Error { + constructor(message: string) { + super(message); + this.name = 'KnowledgeStoreError'; + } +} + +/** 共享正本目录(项目内,入库) */ +export function getSharedKnowledgeDir(project: Project): string { + return join(project.rootPath, '.codekeeper', 'knowledge'); +} + +/** 私有正本目录(本地);CK_KNOWLEDGE_LOCAL_ROOT 仅供测试注入 */ +export function getLocalKnowledgeDir(project: Project): string { + const override = process.env.CK_KNOWLEDGE_LOCAL_ROOT; + const safeName = project.id.replace(/[\\/:*?"<>|]/g, '_'); + const base = override ?? join(getAppStorageDir(), 'memory', 'knowledge'); + return join(base, safeName); +} + +/** 蒸馏草稿箱(共享根下,人审通过后才移入 knowledge/) */ +export function getKnowledgeInboxDir(project: Project): string { + return join(project.rootPath, '.codekeeper', 'knowledge-inbox'); +} + +/** 序列化条目为 Markdown + frontmatter */ +export function serializeKnowledgeItem(item: { + frontmatter: KnowledgeFrontmatter; + body: string; +}): string { + return `---\n${stringify(item.frontmatter)}---\n\n${item.body.trim()}\n`; +} + +/** 解析 Markdown 文件为知识条目;非法时抛 KnowledgeStoreError */ +export function parseKnowledgeItem( + content: string, + root: KnowledgeItem['root'], + filePath: string +): KnowledgeItem { + // Windows 编辑器常带 BOM,先剥离再匹配 frontmatter + const normalized = content.replace(/^\uFEFF/, ''); + const match = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/.exec(normalized); + if (!match) { + throw new KnowledgeStoreError(`知识条目缺少 frontmatter: ${filePath}`); + } + let rawFm: unknown; + try { + rawFm = parseYaml(match[1]); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new KnowledgeStoreError(`知识条目 frontmatter 非法 YAML: ${filePath}: ${message}`); + } + const result = knowledgeFrontmatterSchema.safeParse(rawFm); + if (!result.success) { + const issues = result.error.issues.map(i => `${i.path.join('.')}: ${i.message}`).join('; '); + throw new KnowledgeStoreError(`知识条目 frontmatter 不符合 schema: ${filePath}: ${issues}`); + } + return { frontmatter: result.data, body: match[2].trim(), root, filePath }; +} + +/** 读取一个目录下的全部知识条目(非法文件跳过并记录) */ +export function readKnowledgeDir(dir: string, root: KnowledgeItem['root']): KnowledgeItem[] { + if (!existsSync(dir)) return []; + const items: KnowledgeItem[] = []; + for (const entry of readdirSync(dir)) { + if (!entry.endsWith('.md')) continue; + const filePath = join(dir, entry); + try { + items.push(parseKnowledgeItem(readFileSync(filePath, 'utf-8'), root, filePath)); + } catch { + // 非法文件不阻断整体读取;调用方需要严格性时用 parseKnowledgeItem 直读 + } + } + return items; +} + +/** 列出项目的全部策展知识(共享 + 私有) */ +export function listProjectKnowledge(project: Project): KnowledgeItem[] { + return [ + ...readKnowledgeDir(getSharedKnowledgeDir(project), 'shared'), + ...readKnowledgeDir(getLocalKnowledgeDir(project), 'local'), + ]; +} + +/** 写入共享正本(human 编辑 / 蒸馏草稿人审通过后的落点) */ +export function writeSharedKnowledge( + project: Project, + item: { frontmatter: KnowledgeFrontmatter; body: string } +): string { + const dir = getSharedKnowledgeDir(project); + mkdirSync(dir, { recursive: true }); + const filePath = join(dir, `${item.frontmatter.id}.md`); + writeFileSync(filePath, serializeKnowledgeItem(item), 'utf-8'); + return filePath; +} + +/** 写入私有正本 */ +export function writeLocalKnowledge( + project: Project, + item: { frontmatter: KnowledgeFrontmatter; body: string } +): string { + const dir = getLocalKnowledgeDir(project); + mkdirSync(dir, { recursive: true }); + const filePath = join(dir, `${item.frontmatter.id}.md`); + writeFileSync(filePath, serializeKnowledgeItem(item), 'utf-8'); + return filePath; +} diff --git a/src/advance/pipeline/default-pipeline.ts b/src/advance/pipeline/default-pipeline.ts index 65e4784..c06c92b 100644 --- a/src/advance/pipeline/default-pipeline.ts +++ b/src/advance/pipeline/default-pipeline.ts @@ -61,6 +61,22 @@ export function buildDefaultPipeline(project: Project): PipelineDefinition { channel: 'memory', artifactType: 'Tick', }); + + // archiver 之后链知识投影:归档完成 → 正本 → EverOS 检索层 + if (role === 'archiver') { + nodes.push({ + id: 'knowledge-project', + type: 'knowledge.project', + label: '知识投影', + params: {}, + }); + edges.push({ + from: { node: roleNodeId, port: 'done' }, + to: { node: 'knowledge-project', port: 'in' }, + channel: 'memory', + artifactType: 'ArchiveDone', + }); + } } return { diff --git a/src/advance/pipeline/pipeline-scheduler.ts b/src/advance/pipeline/pipeline-scheduler.ts index a50af89..4123df3 100644 --- a/src/advance/pipeline/pipeline-scheduler.ts +++ b/src/advance/pipeline/pipeline-scheduler.ts @@ -22,7 +22,6 @@ import { PipelineRunStore } from './core/run-store.js'; import type { NodeHandler, RunContext } from './core/types.js'; import type { PipelineDefinition } from './core/types.js'; import { - DEFAULT_ROLE_SCHEDULES, ensurePipelineDefinition, GENERATED_PIPELINE_MARKER, getPipelineDefinitionPath, @@ -33,6 +32,9 @@ import { topoSort } from './core/topology.js'; import { RoleNodeRuntime } from '../classic/role-node-runtime.js'; import { AgentRegistry } from '../agents/registry.js'; import type { TaskEnvelope } from '../agents/task-envelope.js'; +import { projectKnowledgeToEverOS } from '../knowledge/knowledge-projection.js'; +import { distillKnowledgeCandidates } from '../knowledge/knowledge-distill.js'; +import { everosMemorySearchProject } from '../classic/memory/everos-api.js'; export interface PipelineSchedulerOptions { /** EverOS MCP Server URL(由 daemon 启动后回设) */ @@ -54,10 +56,10 @@ export class PipelineScheduler { private runStore: PipelineRunStore | null = null; private readonly activeRoles = new Set(); private readonly registeredRoles = new Set(); - /** 已注册的触发任务:key = projectId:nodeId(同角色多节点互不覆盖) */ + /** 已注册的触发任务:key = projectId:triggerNodeId */ private readonly jobs = new Map< string, - { projectId: string; role: Role; nodeId: string; task: ScheduledTask } + { projectId: string; branchRoles: Set; nodeId: string; task: ScheduledTask } >(); private options: PipelineSchedulerOptions; @@ -100,7 +102,7 @@ export class PipelineScheduler { async stop(role: Role): Promise { this.activeRoles.delete(role); for (const [key, entry] of [...this.jobs.entries()]) { - if (entry.role === role) { + if (entry.branchRoles.has(role)) { entry.task.stop(); this.jobs.delete(key); } @@ -299,39 +301,42 @@ export class PipelineScheduler { } private scheduleProject(project: Project): void { - if (this.activeRoles.size === 0) return; - ensurePipelineDefinition(project); const definition = loadProjectPipeline(project); if (!definition) return; - for (const node of definition.nodes) { - if (!node.type.startsWith('role.')) continue; - const role = node.type.slice('role.'.length) as Role; - if (!ROLES.includes(role) || !this.activeRoles.has(role)) continue; - // 与旧模型同一过滤口径:启用且(reviewer/maintainer)配置了 GitLab - if (!this.context.store.getRoleEnabledProjects(role).some(p => p.id === project.id)) { - continue; - } - - const scheduleExpr = - this.findTriggerSchedule(definition, node.id) ?? DEFAULT_ROLE_SCHEDULES[role]; - if (!validateCron(scheduleExpr)) { - const message = `项目 ${project.name} 节点 ${node.id} 的 cron 非法: ${scheduleExpr}`; + // 按 trigger.cron 节点注册调度:一个触发器拉起其整个下游分支 + for (const trigger of definition.nodes) { + if (trigger.type !== 'trigger.cron') continue; + const scheduleExpr = trigger.params.schedule; + if (typeof scheduleExpr !== 'string' || !validateCron(scheduleExpr)) { + const message = `项目 ${project.name} 节点 ${trigger.id} 的 cron 非法: ${String(scheduleExpr)}`; logger.error(`[Pipeline] ${message}`); recordProjectError(project, new Error(message), 'unknown'); continue; } - const key = `${project.id}:${node.id}`; + // 分支语义:含 role.* 节点的分支只在该角色激活且项目启用时调度; + // 纯 knowledge.*/agent.* 分支(无角色节点)由 daemon 进程内执行,始终可调度 + const branchRoles = collectBranchRoles(definition, trigger.id); + if (branchRoles.size > 0) { + const runnable = [...branchRoles].some( + role => + this.activeRoles.has(role) && + this.context.store.getRoleEnabledProjects(role).some(p => p.id === project.id) + ); + if (!runnable) continue; + } + + const key = `${project.id}:${trigger.id}`; const task = schedule(scheduleExpr, () => { - void this.executePipelineBranch(project, definition, node.id, role).catch(error => { + void this.executePipelineBranch(project, definition, trigger.id).catch(error => { const message = error instanceof Error ? error.message : String(error); - logger.error(`[Pipeline] 项目 ${project.name} 节点 ${node.id} 执行失败: ${message}`); + logger.error(`[Pipeline] 项目 ${project.name} 触发器 ${trigger.id} 执行失败: ${message}`); }); }); - this.jobs.set(key, { projectId: project.id, role, nodeId: node.id, task }); - logger.info(`[Pipeline] 项目 ${project.name} 节点 ${node.id} 已调度: ${scheduleExpr}`); + this.jobs.set(key, { projectId: project.id, branchRoles, nodeId: trigger.id, task }); + logger.info(`[Pipeline] 项目 ${project.name} 触发器 ${trigger.id} 已调度: ${scheduleExpr}`); } } @@ -343,34 +348,31 @@ export class PipelineScheduler { await this.runRoleNow(project, role); } - /** 立即执行某项目的角色节点(启动/重启后的对账轮) */ + /** 立即执行某项目的角色节点(启动/重启后的对账轮):从上游触发器拉起整个分支 */ private async runRoleNow(project: Project, role: Role): Promise { const definition = loadProjectPipeline(project); const roleNode = definition?.nodes.find(node => node.type === `role.${role}`); if (!definition || !roleNode) return; - await this.executePipelineBranch(project, definition, roleNode.id, role); + const trigger = this.findUpstreamTrigger(definition, roleNode.id); + await this.executePipelineBranch(project, definition, trigger?.id ?? roleNode.id); } - /** 触发器 → 角色节点的下游子图执行 */ + /** 从指定节点(通常是 trigger)执行下游子图 */ private async executePipelineBranch( project: Project, definition: PipelineDefinition, - roleNodeId: string, - role: Role + startFromNodeId: string ): Promise { - const handlers = this.buildNodeHandlers(project, role, definition); + const handlers = this.buildNodeHandlers(project, definition); const executor = new PipelineExecutor(handlers, this.getRunStore()); - const triggerNode = this.findUpstreamTrigger(definition, roleNodeId); - const startFrom = triggerNode ? [triggerNode.id] : [roleNodeId]; await executor.execute(definition, this.buildRunContext(project), { projectId: project.id, - startFrom, + startFrom: [startFromNodeId], }); } private buildNodeHandlers( project: Project, - role: Role, definition?: PipelineDefinition ): Map { const handlers = new Map(); @@ -379,16 +381,26 @@ export class PipelineScheduler { outputs: ['tick'], run: async () => ({ tick: new Date().toISOString() }), }); - handlers.set(`role.${role}`, { - type: `role.${role}`, - inputs: ['trigger'], - run: async () => { - // 对拍旧版 startProjectLoop 的 agentStarted 状态记录 - recordAgentStarted(project); - await this.runtime.runOnce(project, role); - return {}; - }, - }); + + // 角色节点:分支内出现的每个角色类型都注册(角色从当前节点类型派生) + if (definition) { + for (const node of definition.nodes) { + if (!node.type.startsWith('role.') || handlers.has(node.type)) continue; + const role = node.type.slice('role.'.length) as Role; + if (!ROLES.includes(role)) continue; + handlers.set(node.type, { + type: node.type, + inputs: ['trigger'], + run: async (_ctx, _inputs, _params, currentNode) => { + const currentRole = currentNode.type.slice('role.'.length) as Role; + // 对拍旧版 startProjectLoop 的 agentStarted 状态记录 + recordAgentStarted(project); + await this.runtime.runOnce(project, currentRole); + return {}; + }, + }); + } + } // 外部 Agent 节点:经注册表解析传输适配器,任务信封交互 if (definition) { @@ -421,6 +433,52 @@ export class PipelineScheduler { }); } } + // 知识节点:正本投影(→EverOS)与经验蒸馏(→人审草稿箱) + if (definition) { + if (definition.nodes.some(node => node.type === 'knowledge.project')) { + handlers.set('knowledge.project', { + type: 'knowledge.project', + run: async () => { + const everosUrl = this.context.everosUrl; + if (!everosUrl) throw new Error('EverOS 未就绪,无法投影知识'); + const result = await projectKnowledgeToEverOS( + this.context.store.database, + everosUrl, + project + ); + return { ...result }; + }, + }); + } + if (definition.nodes.some(node => node.type === 'knowledge.distill')) { + handlers.set('knowledge.distill', { + type: 'knowledge.distill', + run: async (_ctx, _inputs, params) => { + const llm = this.context.getClient(); + if (!llm) throw new Error('LLM 未配置,无法蒸馏知识'); + const everosUrl = this.context.everosUrl; + if (!everosUrl) throw new Error('EverOS 未就绪,无法收集经验'); + const queries = Array.isArray(params.experienceQueries) + ? params.experienceQueries.filter((q): q is string => typeof q === 'string') + : ['修复经验', '评审约定', '项目约定', '架构决策']; + const experiences: string[] = []; + for (const query of queries) { + const recalled = await everosMemorySearchProject(everosUrl, { + appId: 'codekeeper-advance', + projectId: project.id, + query, + topK: 5, + }); + for (const item of recalled.items) { + if (item.content) experiences.push(item.content); + } + } + const distilled = await distillKnowledgeCandidates(project, llm, experiences); + return { candidates: distilled.candidates, written: distilled.written.length }; + }, + }); + } + } return handlers; } @@ -451,16 +509,6 @@ export class PipelineScheduler { return definition.nodes.find(node => node.type === 'trigger.cron' && upstreamIds.has(node.id)); } - /** 角色节点的调度表达式:上游 trigger.cron 的 params.schedule */ - private findTriggerSchedule( - definition: PipelineDefinition, - roleNodeId: string - ): string | undefined { - const trigger = this.findUpstreamTrigger(definition, roleNodeId); - const schedule = trigger?.params?.schedule; - return typeof schedule === 'string' && schedule.trim() !== '' ? schedule : undefined; - } - private async waitForMemoryMcpUrl(timeoutMs = 60000): Promise { const start = Date.now(); while (!this.options.mcpUrl) { @@ -482,6 +530,29 @@ export class PipelineScheduler { } } +/** 收集某节点下游分支中出现的角色类型集合 */ +function collectBranchRoles(definition: PipelineDefinition, startNodeId: string): Set { + const downstream = new Map(); + for (const edge of definition.edges) { + downstream.set(edge.from.node, [...(downstream.get(edge.from.node) ?? []), edge.to.node]); + } + const roles = new Set(); + const queue = [startNodeId]; + const visited = new Set(); + while (queue.length > 0) { + const id = queue.shift(); + if (id === undefined || visited.has(id)) continue; + visited.add(id); + const node = definition.nodes.find(n => n.id === id); + if (node?.type.startsWith('role.')) { + const role = node.type.slice('role.'.length) as Role; + if (ROLES.includes(role)) roles.add(role); + } + queue.push(...(downstream.get(id) ?? [])); + } + return roles; +} + /** 疑似凭据的 params 键名(params 明文落库,凭据只允许经 services 注入) */ const SECRET_PARAM_KEYS = new Set([ 'token', diff --git a/src/advance/store/schema.sql b/src/advance/store/schema.sql index 8afd7cb..c11b094 100755 --- a/src/advance/store/schema.sql +++ b/src/advance/store/schema.sql @@ -187,3 +187,13 @@ CREATE TABLE IF NOT EXISTS stage_runs ( ); CREATE INDEX IF NOT EXISTS idx_stage_runs_run ON stage_runs(run_id); + +-- 知识正本 → EverOS 投影的去重状态(M6 智库;root 维度区分双正本同 id) +CREATE TABLE IF NOT EXISTS knowledge_projection ( + project_id TEXT NOT NULL, + root TEXT NOT NULL DEFAULT 'shared', + knowledge_id TEXT NOT NULL, + content_hash TEXT NOT NULL, + synced_at INTEGER NOT NULL, + PRIMARY KEY (project_id, root, knowledge_id) +); diff --git a/src/assets/prompts/knowledge-distill.md b/src/assets/prompts/knowledge-distill.md new file mode 100644 index 0000000..952696e --- /dev/null +++ b/src/assets/prompts/knowledge-distill.md @@ -0,0 +1,27 @@ +你是项目智库的蒸馏器。阅读以下 Agent 角色在项目中积累的经验记录(评审、修复、归档、交互), +提炼出值得长期保留的项目知识候选。 + +## 经验记录 +{{experiences}} + +## 要求 +1. 只提炼稳定的项目知识(约定、架构决策、领域概念、风险点、技术栈事实), + 不要把一次性事件、临时讨论、个别 MR 的细节当作知识。 +2. 每条候选必须能在经验记录中找到依据,禁止编造。 +3. 宁缺毋滥:没有值得提炼的内容时返回空列表。 +4. 每条候选给出:id(小写 slug)、title、category(convention|architecture|domain|risk|stack|graph)、 + confidence(high|medium|low)、tags、body(Markdown 正文,100~300 字)。 + +## 输出格式(严格 JSON,不要输出其他内容) +{ + "candidates": [ + { + "id": "api-auth-convention", + "title": "API 鉴权约定", + "category": "convention", + "confidence": "medium", + "tags": ["api", "auth"], + "body": "……" + } + ] +} diff --git a/src/electron/renderer/components/KnowledgePanel.tsx b/src/electron/renderer/components/KnowledgePanel.tsx new file mode 100644 index 0000000..2ce9e59 --- /dev/null +++ b/src/electron/renderer/components/KnowledgePanel.tsx @@ -0,0 +1,115 @@ +import { useState } from 'react'; +import { useIpc } from '../hooks/useIpc.js'; +import { invoke } from '../api/electron-api.js'; + +interface KnowledgeItemDto { + id: string; + title: string; + category: string; + scope: string; + confidence: string; + source: string; + tags: string[]; + updated: string; + root: 'shared' | 'local'; + bodyPreview: string; +} + +interface KnowledgeListResult { + items: KnowledgeItemDto[]; + inbox: KnowledgeItemDto[]; +} + +const SOURCE_LABELS: Record = { + human: '人工', + distilled: '蒸馏', + archiver: '归档', +}; + +/** + * 项目智库面板:策展正本(共享+本地)与蒸馏草稿箱(人审闸门)。 + */ +export function KnowledgePanel({ projectId }: { projectId: string }) { + const { data, refresh } = useIpc('knowledge.list', { projectId }); + const [busy, setBusy] = useState(null); + + const act = async (method: 'knowledge.approve' | 'knowledge.dismiss', knowledgeId: string) => { + setBusy(knowledgeId); + try { + await invoke(method, { projectId, knowledgeId }); + refresh(); + } finally { + setBusy(null); + } + }; + + if (!data) return
加载智库…
; + + return ( +
+ {data.inbox.length > 0 && ( +
+

待审草稿({data.inbox.length})

+

蒸馏管线产出的候选,批准后进入共享正本(随项目入库)。

+ {data.inbox.map(item => ( +
+
+ {item.title} + + {item.category} · {SOURCE_LABELS[item.source] ?? item.source} · 置信{' '} + {item.confidence} + +
+

{item.bodyPreview}

+
+ + +
+
+ ))} +
+ )} + +
+

策展正本({data.items.length})

+ {data.items.length === 0 && ( +

+ 尚无策展知识。正本位于项目的 .codekeeper/knowledge/(Markdown + frontmatter)。 +

+ )} + {data.items.map(item => ( +
+
+ {item.title} + + {item.category} · {SOURCE_LABELS[item.source] ?? item.source} ·{' '} + {item.root === 'shared' ? '共享' : '私有'} · {item.updated} + +
+ {item.tags.length > 0 && ( +
+ {item.tags.map(tag => ( + + {tag} + + ))} +
+ )} +

{item.bodyPreview}

+
+ ))} +
+
+ ); +} diff --git a/src/electron/renderer/components/pipeline-edit.ts b/src/electron/renderer/components/pipeline-edit.ts index 15cfc05..369b550 100644 --- a/src/electron/renderer/components/pipeline-edit.ts +++ b/src/electron/renderer/components/pipeline-edit.ts @@ -6,6 +6,8 @@ export const NODE_PALETTE: Array<{ type: string; label: string }> = [ { type: 'role.reviewer', label: 'Reviewer 角色' }, { type: 'role.maintainer', label: 'Maintainer 角色' }, { type: 'role.archiver', label: 'Archiver 角色' }, + { type: 'knowledge.project', label: '知识投影' }, + { type: 'knowledge.distill', label: '知识蒸馏' }, ]; /** 新增连线的默认端口映射 */ diff --git a/src/electron/renderer/pages/ProjectDetail.tsx b/src/electron/renderer/pages/ProjectDetail.tsx index b990d5e..ec1b3c7 100755 --- a/src/electron/renderer/pages/ProjectDetail.tsx +++ b/src/electron/renderer/pages/ProjectDetail.tsx @@ -9,10 +9,11 @@ import { invoke } from '../api/electron-api'; import type { ProjectStatus } from '../../shared/types'; import { ArchiveTree } from '../components/ArchiveTree'; +import { KnowledgePanel } from '../components/KnowledgePanel'; import { PipelineCanvas } from '../components/PipelineCanvas'; import type { FileTreeNode } from '../components/ArchiveTree'; -type Tab = 'pipeline' | 'context' | 'activity' | 'archive' | 'status'; +type Tab = 'pipeline' | 'context' | 'knowledge' | 'activity' | 'archive' | 'status'; export function ProjectDetail() { const { id } = useParams<{ id: string }>(); @@ -137,6 +138,12 @@ export function ProjectDetail() { > Context + + 钻取:{drillPath.join(' / ')} +
+ )} { mutate(def => updateNodeInDefinition(def, updated)); }} onConfigSaved={refresh} + onDrillIn={nodeId => setDrillPath([...drillPathRef.current, nodeId])} + onMaterializeSubgraph={node => { + mutate(root => { + const path = drillPathRef.current; + const apply = (def: PipelineDefinitionDto): PipelineDefinitionDto => ({ + ...def, + nodes: def.nodes.map(n => + n.id === node.id ? { ...n, subgraph: materializeRoleSubgraph(n.id, n.type) } : n + ), + }); + return path.length === 0 + ? apply(root) + : updateDrillDefinition( + root, + path, + apply(getDrillDefinition(root, path) ?? root) + ); + }); + setDrillPath([...drillPathRef.current, node.id]); + }} /> )}
@@ -200,6 +252,8 @@ function NodeInspector({ generated, onChange, onConfigSaved, + onDrillIn, + onMaterializeSubgraph, }: { node: PipelineNodeDto; definition: PipelineDefinitionDto; @@ -209,6 +263,10 @@ function NodeInspector({ onChange: (node: PipelineNodeDto) => void; /** 角色配置保存后刷新管线(生成件可能已被重建) */ onConfigSaved: () => void; + /** 钻取已有子图 */ + onDrillIn: (nodeId: string) => void; + /** 为角色节点生成默认子图并钻取 */ + onMaterializeSubgraph: (node: PipelineNodeDto) => void; }) { const roleType = node.type.startsWith('role.') ? node.type.slice(5) : null; const upstreamTrigger = @@ -274,6 +332,19 @@ function NodeInspector({ {roleType === 'archiver' && (

Archiver 的详细配置请在 Archiver 页维护。

)} + {roleType && ( +
+ {node.subgraph ? ( + + ) : ( + + )} +
+ )} ); } diff --git a/src/electron/renderer/components/pipeline-edit.ts b/src/electron/renderer/components/pipeline-edit.ts index 369b550..17c02e0 100644 --- a/src/electron/renderer/components/pipeline-edit.ts +++ b/src/electron/renderer/components/pipeline-edit.ts @@ -112,3 +112,86 @@ export function moveAllNodesInDefinition( ), }; } + +// ===== 钻取层(M7)===== + +/** 沿钻取路径取当前视图定义(路径为空返回根定义) */ +export function getDrillDefinition( + root: PipelineDefinitionDto, + drillPath: string[] +): PipelineDefinitionDto | null { + let current: PipelineDefinitionDto = root; + for (const nodeId of drillPath) { + const node = current.nodes.find(n => n.id === nodeId); + if (!node?.subgraph) return null; + current = node.subgraph; + } + return current; +} + +/** 沿钻取路径更新子图定义(返回新的根定义) */ +export function updateDrillDefinition( + root: PipelineDefinitionDto, + drillPath: string[], + updated: PipelineDefinitionDto +): PipelineDefinitionDto { + if (drillPath.length === 0) return updated; + const [head, ...rest] = drillPath; + return { + ...root, + nodes: root.nodes.map(node => + node.id === head && node.subgraph + ? { ...node, subgraph: updateDrillDefinition(node.subgraph, rest, updated) } + : node + ), + }; +} + +/** + * 为角色节点生成默认子图("展开为子图"): + * 单个复合 stage(stage.role-run)包装既有 Runner 黑箱,行为与现状完全一致; + * reviewer 额外附带 advisory 的 ast-grep 预检 stage。 + */ +export function materializeRoleSubgraph( + roleNodeId: string, + roleType: string +): PipelineDefinitionDto { + const nodes: PipelineDefinitionDto['nodes'] = []; + const edges: PipelineDefinitionDto['edges'] = []; + + if (roleType === 'role.reviewer') { + nodes.push({ + id: 'ast-grep-precheck', + type: 'stage.ast-grep', + label: 'ast-grep 预检', + params: { paths: [] }, + }); + nodes.push({ + id: 'review-run', + type: 'stage.role-run', + label: '评审执行(复合)', + params: {}, + }); + edges.push({ + from: { node: 'ast-grep-precheck', port: 'precheck' }, + to: { node: 'review-run', port: 'in' }, + channel: 'memory', + artifactType: 'PrecheckResult', + }); + } else { + nodes.push({ + id: 'role-run', + type: 'stage.role-run', + label: '角色执行(复合)', + params: {}, + }); + } + + return { + version: 1, + id: `${roleNodeId}-subgraph`, + label: `${roleType} 内部 stage 子图`, + nodes, + edges, + }; +} diff --git a/src/electron/shared/types.ts b/src/electron/shared/types.ts index 1e76bfd..c05aadf 100755 --- a/src/electron/shared/types.ts +++ b/src/electron/shared/types.ts @@ -312,6 +312,8 @@ export interface PipelineNodeDto { label?: string; params: Record; position?: { x: number; y: number }; + /** 钻取层:节点内 stage 子图(M7) */ + subgraph?: PipelineDefinitionDto; } export interface PipelineDefinitionDto { diff --git a/tests/advance/pipeline/core/subgraph.test.ts b/tests/advance/pipeline/core/subgraph.test.ts new file mode 100644 index 0000000..e0b07ec --- /dev/null +++ b/tests/advance/pipeline/core/subgraph.test.ts @@ -0,0 +1,151 @@ +import { describe, expect, it, vi } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import Database from 'better-sqlite3'; +import { PipelineExecutor } from '../../../../src/advance/pipeline/core/executor.js'; +import { PipelineRunStore } from '../../../../src/advance/pipeline/core/run-store.js'; +import type { + NodeHandler, + PipelineDefinition, + RunContext, +} from '../../../../src/advance/pipeline/core/types.js'; + +function makeCtx(): RunContext { + return { logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, services: {}, vars: {} }; +} + +function makeStore(): PipelineRunStore { + const db = new Database(':memory:'); + db.exec(readFileSync(join(__dirname, '../../../../src/advance/store/schema.sql'), 'utf-8')); + return new PipelineRunStore(db); +} + +function handler(type: string, run: NodeHandler['run']): [string, NodeHandler] { + return [type, { type, run }]; +} + +describe('PipelineExecutor 子图(钻取层)', () => { + it('无处理器的节点带 subgraph 时递归执行子图,stage 记录带层级前缀', async () => { + const calls: string[] = []; + const handlers = new Map([ + handler('stage.a', async () => { + calls.push('a'); + return { mid: 1 }; + }), + handler('stage.b', async (_ctx, inputs) => { + calls.push('b'); + return { final: (inputs.mid as number) + 1 }; + }), + ]); + const def = { + version: 1, + id: 'p', + nodes: [ + { id: 'trigger', type: 'stage.a', params: {} }, + { + id: 'composite', + type: 'role.reviewer', // 未注册处理器 → 走子图 + params: {}, + subgraph: { + version: 1, + id: 'composite-sub', + nodes: [ + { id: 's1', type: 'stage.a', params: {} }, + { id: 's2', type: 'stage.b', params: {} }, + ], + edges: [ + { + from: { node: 's1', port: 'mid' }, + to: { node: 's2', port: 'mid' }, + channel: 'memory' as const, + }, + ], + }, + }, + ], + edges: [ + { + from: { node: 'trigger', port: 'mid' }, + to: { node: 'composite', port: 'in' }, + channel: 'memory' as const, + }, + ], + } as unknown as PipelineDefinition; + + const store = makeStore(); + const record = await new PipelineExecutor(handlers, store).execute(def, makeCtx()); + + expect(record.status).toBe('succeeded'); + const stageIds = store.getStageRuns(record.id).map(s => s.nodeId); + expect(stageIds).toContain('composite/s1'); + expect(stageIds).toContain('composite/s2'); + // 终态节点输出汇聚为父节点产物 + const composite = store.getStageRuns(record.id).find(s => s.nodeId === 'composite'); + expect(composite?.outputs).toEqual({ final: 2 }); + }); + + it('子图入口节点继承父节点输入', async () => { + let received: Record = {}; + const handlers = new Map([ + handler('stage.capture', async (_ctx, inputs) => { + received = inputs; + return {}; + }), + handler('stage.src', async () => ({ external: 'x' })), + ]); + const def = { + version: 1, + id: 'p', + nodes: [ + { id: 'src', type: 'stage.src', params: {} }, + { + id: 'composite', + type: 'role.maintainer', + params: {}, + subgraph: { + version: 1, + id: 'sub', + nodes: [{ id: 'entry', type: 'stage.capture', params: {} }], + edges: [], + }, + }, + ], + edges: [ + { + from: { node: 'src', port: 'external' }, + to: { node: 'composite', port: 'external' }, + channel: 'memory' as const, + }, + ], + } as unknown as PipelineDefinition; + + const record = await new PipelineExecutor(handlers, makeStore()).execute(def, makeCtx()); + expect(record.status).toBe('succeeded'); + expect(received.external).toBe('x'); + }); + + it('子图内未注册 stage 类型导致父节点失败', async () => { + const def = { + version: 1, + id: 'p', + nodes: [ + { + id: 'composite', + type: 'role.archiver', + params: {}, + subgraph: { + version: 1, + id: 'sub', + nodes: [{ id: 's1', type: 'stage.ghost', params: {} }], + edges: [], + }, + }, + ], + edges: [], + } as unknown as PipelineDefinition; + + const record = await new PipelineExecutor(new Map(), makeStore()).execute(def, makeCtx()); + expect(record.status).toBe('failed'); + expect(record.error).toContain('stage.ghost'); + }); +}); diff --git a/tests/advance/pipeline/subgraph-edit.test.ts b/tests/advance/pipeline/subgraph-edit.test.ts new file mode 100644 index 0000000..e0df5ab --- /dev/null +++ b/tests/advance/pipeline/subgraph-edit.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from 'vitest'; +import { + getDrillDefinition, + materializeRoleSubgraph, + updateDrillDefinition, +} from '../../../src/electron/renderer/components/pipeline-edit.js'; +import { parseAstGrepOutput } from '../../../src/advance/ast-grep/runner.js'; +import type { PipelineDefinitionDto } from '../../../src/electron/shared/types.js'; + +describe('钻取层编辑助手', () => { + function makeRoot(): PipelineDefinitionDto { + return { + version: 1, + id: 'root', + nodes: [ + { + id: 'role-reviewer', + type: 'role.reviewer', + params: {}, + subgraph: materializeRoleSubgraph('role-reviewer', 'role.reviewer'), + }, + ], + edges: [], + }; + } + + it('getDrillDefinition 沿路径下钻,空路径返回根', () => { + const root = makeRoot(); + expect(getDrillDefinition(root, [])).toBe(root); + const sub = getDrillDefinition(root, ['role-reviewer']); + expect(sub?.id).toBe('role-reviewer-subgraph'); + expect(sub?.nodes.map(n => n.type)).toEqual(['stage.ast-grep', 'stage.role-run']); + expect(getDrillDefinition(root, ['ghost'])).toBeNull(); + }); + + it('updateDrillDefinition 编辑子图并映射回根定义', () => { + const root = makeRoot(); + const sub = getDrillDefinition(root, ['role-reviewer'])!; + const edited = { ...sub, nodes: [...sub.nodes, { id: 's3', type: 'stage.x', params: {} }] }; + const newRoot = updateDrillDefinition(root, ['role-reviewer'], edited); + expect(newRoot.nodes[0].subgraph?.nodes).toHaveLength(3); + // 原定义不被修改(不可变更新) + expect(root.nodes[0].subgraph?.nodes).toHaveLength(2); + }); + + it('materializeRoleSubgraph:非 reviewer 只含复合 stage', () => { + const sub = materializeRoleSubgraph('role-maintainer', 'role.maintainer'); + expect(sub.nodes).toHaveLength(1); + expect(sub.nodes[0].type).toBe('stage.role-run'); + }); +}); + +describe('ast-grep 输出解析', () => { + it('解析数组形态输出', () => { + const output = JSON.stringify([ + { + file: 'src/a.ts', + rules: [{ line: 3, column: 5, id: 'no-eval', message: '禁用 eval', severity: 'error' }], + }, + ]); + const findings = parseAstGrepOutput(output); + expect(findings).toEqual([ + { + file: 'src/a.ts', + line: 3, + column: 5, + ruleId: 'no-eval', + message: '禁用 eval', + severity: 'error', + }, + ]); + }); + + it('兼容 {results} 包装与垃圾输入', () => { + expect(parseAstGrepOutput(JSON.stringify({ results: [] }))).toEqual([]); + expect(parseAstGrepOutput('不是 JSON')).toEqual([]); + expect(parseAstGrepOutput('')).toEqual([]); + }); +}); From 933d402b9d35782b9d81ddf53636776de931fd0f Mon Sep 17 00:00:00 2001 From: Windsander Date: Tue, 8 Sep 2026 23:43:50 +0800 Subject: [PATCH 13/18] =?UTF-8?q?fix(pipeline):=20=E7=BB=88=E5=B1=80?= =?UTF-8?q?=E5=AE=A1=E8=AE=A1=E4=BF=AE=E5=A4=8D=E2=80=94=E2=80=94=E5=AD=90?= =?UTF-8?q?=E5=9B=BE=E8=BF=90=E8=A1=8C=E8=AE=B0=E5=BD=95=E5=8F=AF=E5=BD=92?= =?UTF-8?q?=E6=9F=A5=E3=80=81=E9=97=A8=E9=9D=A2=20URL=20=E5=AE=8C=E6=95=B4?= =?UTF-8?q?=E3=80=81=E5=81=87=E7=AB=AF=E5=8F=A3=E6=B6=88=E9=99=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 终局独立审计(对照目标逐项取证)发现的修复: - D1(M7 断线):role-entry 子图执行未传 projectId,stage 记录落库后 listRunsByProject 查不出——补传并加回归测试(子图运行记录按项目可查) - D2:MCP 门面 start() 返回完整 SSE 端点(含 /sse?token=),并在 ServiceStatusPanel 展示(外部 Agent 接入点 UI 可见) - D3:executor.resume 对含子图的 run 显式拒绝(跳过集失配防护,待支持后放开) - D4:role 节点处理器声明并产出 done 端口(默认管线 archiver→knowledge.project 边的产物语义补齐) - D5:DESIGN.md sink.* 标注预留 - D6:daemon.config 回退分支补 agents 字段(与运行态形状一致) 审计结论:M1-M7 全部通过,横切 RULES 9 项全过,目标级验收达成。 验证:build + vitest(1028 通过)+ lint 零警告 + format + electron:build 全绿 --- DESIGN.md | 2 +- src/advance/agents/mcp-facade.ts | 3 +- .../classic/agent-entries/role-entry.ts | 16 +++--- src/advance/ipc/handlers.ts | 1 + src/advance/pipeline/core/executor.ts | 6 +++ src/advance/pipeline/pipeline-scheduler.ts | 3 +- .../components/ServiceStatusPanel.tsx | 10 ++++ tests/advance/agents/mcp-facade.test.ts | 6 +-- tests/advance/pipeline/core/subgraph.test.ts | 49 +++++++++++++++++++ 9 files changed, 83 insertions(+), 13 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index 66e4f73..b5245cf 100755 --- a/DESIGN.md +++ b/DESIGN.md @@ -28,7 +28,7 @@ edges: ``` **节点命名空间**:`trigger.*`(cron/文件/事件)、`role.*`(reviewer/maintainer/archiver)、 -`agent.*`(外部 Agent:a2a/subprocess/mcp,M5)、`knowledge.*`(召回/投影,M6)、`sink.*`(投递)。 +`agent.*`(外部 Agent:a2a/subprocess/mcp,M5)、`knowledge.*`(召回/投影,M6)、`sink.*`(投递,预留,处理器后续里程碑实现)。 **边 = 类型化端口引用 + channel 绑定**: diff --git a/src/advance/agents/mcp-facade.ts b/src/advance/agents/mcp-facade.ts index d4bb7db..a2de817 100644 --- a/src/advance/agents/mcp-facade.ts +++ b/src/advance/agents/mcp-facade.ts @@ -49,7 +49,8 @@ export class McpFacadeServer { server.listen(this.options.port ?? 0, '127.0.0.1', () => { const addr = server.address(); const port = typeof addr === 'object' && addr ? addr.port : 0; - resolve(`http://127.0.0.1:${port}?token=${this.token}`); + // 直接返回可用的 SSE 端点(含 token),复制即可接入 + resolve(`http://127.0.0.1:${port}/sse?token=${this.token}`); }); }); } diff --git a/src/advance/classic/agent-entries/role-entry.ts b/src/advance/classic/agent-entries/role-entry.ts index f40927a..68da34a 100755 --- a/src/advance/classic/agent-entries/role-entry.ts +++ b/src/advance/classic/agent-entries/role-entry.ts @@ -121,7 +121,7 @@ async function main() { // 钻取层:项目的管线正本中,本角色节点若带 subgraph,则执行子图而非整轮黑箱 const roleSubgraph = loadRoleSubgraph(project, config.role); const stageHandlers = roleSubgraph ? createRoleStageHandlers({ project, runner }) : null; - // 子图执行同样落库(parent/child 分层 stage 记录,画布运行状态可见) + // 子图执行落库(stage 记录按子图节点命名,画布运行状态叠加可查) const subgraphExecutor = stageHandlers ? new PipelineExecutor(stageHandlers, new PipelineRunStore(store.database)) : null; @@ -132,11 +132,15 @@ async function main() { const work = subgraphExecutor && roleSubgraph ? subgraphExecutor - .execute(roleSubgraph, { - logger: console, - services: { project }, - vars: { projectId: project.id }, - }) + .execute( + roleSubgraph, + { + logger: console, + services: { project }, + vars: { projectId: project.id }, + }, + { projectId: project.id } + ) .then(record => { if (record.status !== 'succeeded') { throw new Error(record.error ?? '子图执行失败'); diff --git a/src/advance/ipc/handlers.ts b/src/advance/ipc/handlers.ts index ddb217d..61c6d4c 100755 --- a/src/advance/ipc/handlers.ts +++ b/src/advance/ipc/handlers.ts @@ -344,6 +344,7 @@ export const handlers: Record Prom embeddingModel: persisted.embeddingModel ?? DEFAULT_EMBEDDING_MODEL, rerankModel: persisted.rerankModel ?? DEFAULT_RERANK_MODEL, everos: persisted.everos ? JSON.stringify(persisted.everos) : '', + agents: persisted.agents ?? [], }; }, diff --git a/src/advance/pipeline/core/executor.ts b/src/advance/pipeline/core/executor.ts index 5a1ed4d..0b58247 100644 --- a/src/advance/pipeline/core/executor.ts +++ b/src/advance/pipeline/core/executor.ts @@ -79,6 +79,12 @@ export class PipelineExecutor { } const definition = record.definition; + // 子图的 stage 记录键为 parent/child 形式,resume 跳过集暂不支持; + // 显式拒绝,待支持后再放开 + const hasSubgraph = definition.nodes.some(node => (node as { subgraph?: unknown }).subgraph); + if (hasSubgraph) { + throw new PipelineDefinitionError('暂不支持对含子图的 run 执行 resume'); + } const ordered = this.prepare(definition); ctx.vars = { ...ctx.vars, pipelineId: definition.id, runId }; diff --git a/src/advance/pipeline/pipeline-scheduler.ts b/src/advance/pipeline/pipeline-scheduler.ts index 6c17949..583b5b5 100644 --- a/src/advance/pipeline/pipeline-scheduler.ts +++ b/src/advance/pipeline/pipeline-scheduler.ts @@ -391,12 +391,13 @@ export class PipelineScheduler { handlers.set(node.type, { type: node.type, inputs: ['trigger'], + outputs: ['done'], run: async (_ctx, _inputs, _params, currentNode) => { const currentRole = currentNode.type.slice('role.'.length) as Role; // 对拍旧版 startProjectLoop 的 agentStarted 状态记录 recordAgentStarted(project); await this.runtime.runOnce(project, currentRole); - return {}; + return { done: true }; }, }); } diff --git a/src/electron/renderer/components/ServiceStatusPanel.tsx b/src/electron/renderer/components/ServiceStatusPanel.tsx index a87bc44..8feabe1 100755 --- a/src/electron/renderer/components/ServiceStatusPanel.tsx +++ b/src/electron/renderer/components/ServiceStatusPanel.tsx @@ -246,6 +246,16 @@ export function ServiceStatusPanel({ daemon, localModel, remoteModel }: ServiceS onToggle={toggle} /> + {remoteModel && ( { } as never, getEverosUrl: () => overrides.everosUrl ?? null, }); - const facadeUrl = await facade.start(); - // 门面 URL 自带 token:/sse 路径需保留查询串 - const base = new URL(facadeUrl); - const sseUrl = `${base.origin}/sse${base.search}`; + const sseUrl = await facade.start(); + // 门面地址即为完整 SSE 端点(含 token) const client = new Client({ name: 'facade-test', version: '0.0.1' }); await client.connect(new SSEClientTransport(new URL(sseUrl))); return client; diff --git a/tests/advance/pipeline/core/subgraph.test.ts b/tests/advance/pipeline/core/subgraph.test.ts index e0b07ec..1245cf1 100644 --- a/tests/advance/pipeline/core/subgraph.test.ts +++ b/tests/advance/pipeline/core/subgraph.test.ts @@ -148,4 +148,53 @@ describe('PipelineExecutor 子图(钻取层)', () => { expect(record.status).toBe('failed'); expect(record.error).toContain('stage.ghost'); }); + + it('子图运行记录按 projectId 可查(D1 回归:画布运行状态叠加的数据源)', async () => { + const handlers = new Map([handler('stage.a', async () => ({ ok: true }))]); + const def = { + version: 1, + id: 'p', + nodes: [ + { + id: 'composite', + type: 'role.reviewer', + params: {}, + subgraph: { + version: 1, + id: 'sub', + nodes: [{ id: 's1', type: 'stage.a', params: {} }], + edges: [], + }, + }, + ], + edges: [], + } as unknown as PipelineDefinition; + + const store = makeStore(); + const record = await new PipelineExecutor(handlers, store).execute(def, makeCtx(), { + projectId: 'proj-x', + }); + expect(record.status).toBe('succeeded'); + // 按项目可查(listRunsByProject 是画布的数据源) + const runs = store.listRunsByProject('proj-x'); + expect(runs).toHaveLength(1); + expect(runs[0].id).toBe(record.id); + // 子图 stage 记录随父 run 可查 + const stages = store.getStageRuns(record.id); + expect(stages.map(s => s.nodeId)).toContain('composite/s1'); + + // 含子图的 run 显式拒绝 resume(跳过集失配防护;先造一个失败的含子图 run) + const failHandlers = new Map([ + handler('stage.a', async () => { + throw new Error('炸了'); + }), + ]); + const failed = await new PipelineExecutor(failHandlers, store).execute(def, makeCtx(), { + projectId: 'proj-x', + }); + expect(failed.status).toBe('failed'); + await expect( + new PipelineExecutor(handlers, store).resume(failed.id, makeCtx()) + ).rejects.toThrow(/子图/); + }); }); From 3759e8c4128e843545df7cf698dd7cb8fd76f3a8 Mon Sep 17 00:00:00 2001 From: Windsander Date: Wed, 9 Sep 2026 17:57:41 +0800 Subject: [PATCH 14/18] =?UTF-8?q?refactor(ui):=20=E6=94=B6=E6=95=9B?= =?UTF-8?q?=E5=85=A8=E5=B1=80=E5=AF=BC=E8=88=AA=E5=88=B0=E6=99=BA=E5=BA=93?= =?UTF-8?q?=E4=B8=8E=E7=B3=BB=E7=BB=9F=E7=8A=B6=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/electron/renderer/App.tsx | 98 ++++--------------- .../renderer/pages/KnowledgeHubPage.tsx | 81 +++++++++++++++ .../renderer/pages/SystemStatusPage.tsx | 20 ++++ tests/electron/renderer/App.test.tsx | 34 +++++-- .../renderer/pages/KnowledgeHubPage.test.tsx | 39 ++++++++ .../renderer/pages/SystemStatusPage.test.tsx | 27 +++++ 6 files changed, 210 insertions(+), 89 deletions(-) create mode 100644 src/electron/renderer/pages/KnowledgeHubPage.tsx create mode 100644 src/electron/renderer/pages/SystemStatusPage.tsx create mode 100644 tests/electron/renderer/pages/KnowledgeHubPage.test.tsx create mode 100644 tests/electron/renderer/pages/SystemStatusPage.test.tsx diff --git a/src/electron/renderer/App.tsx b/src/electron/renderer/App.tsx index 9269ec7..0c79d7c 100755 --- a/src/electron/renderer/App.tsx +++ b/src/electron/renderer/App.tsx @@ -10,25 +10,13 @@ import { DashboardIcon, MemoryGraphIcon, MemoryStatsIcon, - HistoryIcon, - LogsIcon, SettingsIcon, } from './components/icons.js'; import { Dashboard } from './pages/Dashboard.js'; import { ProjectDetail } from './pages/ProjectDetail.js'; -import { ActionHistory } from './pages/ActionHistory.js'; -import { Logs } from './pages/Logs.js'; import { Settings } from './pages/Settings.js'; -import { MrReview } from './pages/MrReview.js'; -import { Maintainer } from './pages/Maintainer.js'; -import { Archiver } from './pages/Archiver.js'; -import { MemoryStatsPage } from './pages/MemoryStatsPage.js'; -import { MemoryGraphPage } from './pages/MemoryGraphPage.js'; -import { getAllRoleUIs } from './roles/role-registry.js'; -// 触发角色 UI 注册(side-effect) -import './roles/reviewer-role.js'; -import './roles/maintainer-role.js'; -import './roles/archiver-role.js'; +import { KnowledgeHubPage } from './pages/KnowledgeHubPage.js'; +import { SystemStatusPage } from './pages/SystemStatusPage.js'; interface NavItem { to: string; @@ -36,19 +24,15 @@ interface NavItem { icon: React.ComponentType; } -/** 位于角色导航之前的顶部功能入口 */ +/** 全局一级导航:项目、智库与运行基础设施 */ const TOP_NAV_ITEMS: NavItem[] = [ { to: '/', label: '仪表盘', icon: DashboardIcon }, - { to: '/memory', label: '记忆图谱', icon: MemoryGraphIcon }, - { to: '/memory-stats', label: '记忆统计', icon: MemoryStatsIcon }, + { to: '/knowledge', label: '智库', icon: MemoryGraphIcon }, + { to: '/system', label: '系统状态', icon: MemoryStatsIcon }, ]; -/** 位于角色导航之后的底部功能入口 */ -const BOTTOM_NAV_ITEMS: NavItem[] = [ - { to: '/history', label: '动作历史', icon: HistoryIcon }, - { to: '/logs', label: '日志', icon: LogsIcon }, - { to: '/settings', label: '设置', icon: SettingsIcon }, -]; +/** 底部配置入口 */ +const BOTTOM_NAV_ITEMS: NavItem[] = [{ to: '/settings', label: '设置', icon: SettingsIcon }]; /** 简单 Toast 组件 */ function Toast({ message, onClose }: { message: string; onClose: () => void }) { @@ -114,7 +98,7 @@ function ReadyGuard({ isReady, children }: { isReady: boolean; children: React.R function AppContent() { const location = useLocation(); const { sidebarCollapsed } = useLayout(); - const isMemoryRoute = location.pathname === '/memory'; + const isMemoryRoute = location.pathname === '/knowledge'; const { daemon, localModel, loading } = useServiceStatus(); const isReady = @@ -150,17 +134,6 @@ function AppContent() { ))} - {getAllRoleUIs().map(ui => { - const Icon = ui.icon; - return ( - - ); - })} - {BOTTOM_NAV_ITEMS.map(item => ( ))} @@ -179,63 +152,30 @@ function AppContent() { } /> - - - - } - /> - - - - } - /> } /> - - - } - /> - - - - } - /> - - - - } - /> - - + } /> - + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } />
diff --git a/src/electron/renderer/pages/KnowledgeHubPage.tsx b/src/electron/renderer/pages/KnowledgeHubPage.tsx new file mode 100644 index 0000000..4351f9f --- /dev/null +++ b/src/electron/renderer/pages/KnowledgeHubPage.tsx @@ -0,0 +1,81 @@ +import { useState } from 'react'; +import { PageHeader } from '../components/PageHeader.js'; +import { MemoryGraphIcon, MemoryStatsIcon } from '../components/icons.js'; +import { MemoryGraphView } from '../components/MemoryGraphView.js'; +import { MemoryProgressView } from '../components/MemoryProgressView.js'; +import { useMemoryGraph } from '../hooks/useMemoryGraph.js'; + +type KnowledgeView = 'curated' | 'memory' | 'stats'; + +/** 全局智库入口:把策展知识与 EverOS 经验记忆放在同一信息架构下。 */ +export function KnowledgeHubPage() { + const [view, setView] = useState('curated'); + const { graph, loading, error, refresh } = useMemoryGraph(); + + return ( +
+ } title="智库" /> +
+ + + +
+ + {view === 'curated' && ( +
+
+

策展知识

+

项目级策展知识在项目详情的「知识」页维护。这里统一查看记忆索引与项目入口。

+
+
+ 从项目进入知识库 + 打开任意项目,在「知识」页查看共享正本、私有正本和待审蒸馏草稿。 +
+
+ )} + {view === 'memory' && ( +
+ + {error &&
加载失败: {error}
} + {loading &&
加载记忆...
} + {!loading && !error && graph.stats.projectCount === 0 && ( +
暂无项目,请先注册项目。
+ )} + {!loading && !error && graph.stats.projectCount > 0 && } +
+ )} + {view === 'stats' && ( +
+ + {error &&
加载失败: {error}
} + {loading &&
加载统计...
} + {!loading && !error && graph.stats.projectCount > 0 && ( + + )} +
+ )} +
+ ); +} diff --git a/src/electron/renderer/pages/SystemStatusPage.tsx b/src/electron/renderer/pages/SystemStatusPage.tsx new file mode 100644 index 0000000..b63520c --- /dev/null +++ b/src/electron/renderer/pages/SystemStatusPage.tsx @@ -0,0 +1,20 @@ +import { PageHeader } from '../components/PageHeader.js'; +import { ServiceStatusPanel } from '../components/ServiceStatusPanel.js'; +import { useServiceStatus } from '../hooks/useServiceStatus.js'; +import { MemoryStatsIcon } from '../components/icons.js'; + +/** 全局服务状态入口,与设置页配置职责分离。 */ +export function SystemStatusPage() { + const { daemon, localModel, remoteModel, loading } = useServiceStatus(); + + return ( +
+ } title="系统状态" /> + {loading ? ( +
加载服务状态...
+ ) : ( + + )} +
+ ); +} diff --git a/tests/electron/renderer/App.test.tsx b/tests/electron/renderer/App.test.tsx index 84cafaf..219dd71 100755 --- a/tests/electron/renderer/App.test.tsx +++ b/tests/electron/renderer/App.test.tsx @@ -6,9 +6,6 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { render, screen, act } from '@testing-library/react'; import { App } from '../../../src/electron/renderer/App'; import { useServiceStatus } from '../../../src/electron/renderer/hooks/useServiceStatus'; -import '../../../src/electron/renderer/roles/reviewer-role.js'; -import '../../../src/electron/renderer/roles/maintainer-role.js'; -import '../../../src/electron/renderer/roles/archiver-role.js'; vi.mock('../../../src/electron/renderer/hooks/useServiceStatus'); @@ -47,14 +44,16 @@ beforeEach(() => { }); describe('App 导航', () => { - it('渲染三个角色导航链接', async () => { + it('渲染收敛后的全局导航', async () => { vi.mocked(useServiceStatus).mockReturnValue(makeServiceStatus({ loading: true })); render(); - expect(screen.getByText('记忆图谱')).toBeTruthy(); - expect(screen.getByText('记忆统计')).toBeTruthy(); - expect(screen.getByText('自动评审')).toBeTruthy(); - expect(screen.getByText('自动维护')).toBeTruthy(); - expect(screen.getByText('项目知识')).toBeTruthy(); + expect(screen.getByText('仪表盘')).toBeTruthy(); + expect(screen.getByText('智库')).toBeTruthy(); + expect(screen.getByText('系统状态')).toBeTruthy(); + expect(screen.getByText('设置')).toBeTruthy(); + expect(screen.queryByText('自动评审')).toBeNull(); + expect(screen.queryByText('自动维护')).toBeNull(); + expect(screen.queryByText('项目知识')).toBeNull(); expect(await screen.findByTitle('切换到亮色主题')).toBeTruthy(); }); }); @@ -69,7 +68,7 @@ describe('App 启动就绪控制', () => { it('服务未就绪时非设置导航项被禁用', async () => { vi.mocked(useServiceStatus).mockReturnValue(makeServiceStatus({ loading: true })); render(); - const nav = screen.getByText('记忆图谱').closest('.sidebar-link'); + const nav = screen.getByText('智库').closest('.sidebar-link'); expect(nav?.getAttribute('aria-disabled')).toBe('true'); }); @@ -80,6 +79,21 @@ describe('App 启动就绪控制', () => { expect(await screen.findByText('大语言模型')).toBeTruthy(); }); + it.each([ + '/reviewer', + '/maintainer', + '/archiver', + '/history', + '/logs', + '/memory', + '/memory-stats', + ])('服务未就绪时旧路由 %s 不绕过设置门禁', async path => { + window.history.pushState({}, '', path); + vi.mocked(useServiceStatus).mockReturnValue(makeServiceStatus({ loading: true })); + render(); + expect(await screen.findByText('大语言模型')).toBeTruthy(); + }); + it('服务就绪后显示 toast,10 秒后自动消失', async () => { vi.useFakeTimers(); vi.mocked(useServiceStatus).mockReturnValue(makeServiceStatus({ loading: true })); diff --git a/tests/electron/renderer/pages/KnowledgeHubPage.test.tsx b/tests/electron/renderer/pages/KnowledgeHubPage.test.tsx new file mode 100644 index 0000000..0146bb0 --- /dev/null +++ b/tests/electron/renderer/pages/KnowledgeHubPage.test.tsx @@ -0,0 +1,39 @@ +import { describe, expect, it, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { KnowledgeHubPage } from '../../../../src/electron/renderer/pages/KnowledgeHubPage.js'; +import { LayoutProvider } from '../../../../src/electron/renderer/contexts/LayoutContext.js'; + +vi.mock('../../../../src/electron/renderer/hooks/useMemoryGraph.js', () => ({ + useMemoryGraph: () => ({ + graph: { + nodes: [], + edges: [], + stats: { + totalNodes: 0, + totalEdges: 0, + totalMemories: 0, + projectCount: 0, + activeDays: 0, + dailyGrowth: [], + }, + }, + loading: false, + error: null, + refresh: vi.fn(), + }), +})); + +describe('KnowledgeHubPage', () => { + it('渲染单层页面 chrome 与三个智库 tab', () => { + render( + + + + ); + expect(screen.getByText('智库')).toBeTruthy(); + expect(screen.getByRole('button', { name: '策展知识' })).toBeTruthy(); + expect(screen.getByRole('button', { name: /记忆图谱/ })).toBeTruthy(); + expect(screen.getByRole('button', { name: /记忆统计/ })).toBeTruthy(); + expect(screen.getByText('从项目进入知识库')).toBeTruthy(); + }); +}); diff --git a/tests/electron/renderer/pages/SystemStatusPage.test.tsx b/tests/electron/renderer/pages/SystemStatusPage.test.tsx new file mode 100644 index 0000000..5866e16 --- /dev/null +++ b/tests/electron/renderer/pages/SystemStatusPage.test.tsx @@ -0,0 +1,27 @@ +import { describe, expect, it, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { SystemStatusPage } from '../../../../src/electron/renderer/pages/SystemStatusPage.js'; +import { useServiceStatus } from '../../../../src/electron/renderer/hooks/useServiceStatus.js'; +import { LayoutProvider } from '../../../../src/electron/renderer/contexts/LayoutContext.js'; + +vi.mock('../../../../src/electron/renderer/hooks/useServiceStatus.js'); + +describe('SystemStatusPage', () => { + it('把服务状态面板提升为独立页面', () => { + vi.mocked(useServiceStatus).mockReturnValue({ + daemon: null, + localModel: null, + remoteModel: null, + loading: true, + error: null, + refresh: vi.fn(), + } as ReturnType); + render( + + + + ); + expect(screen.getByText('系统状态')).toBeTruthy(); + expect(screen.getByText('加载服务状态...')).toBeTruthy(); + }); +}); From fda89e5589ab3e14805325d357b2ff6d1989d100 Mon Sep 17 00:00:00 2001 From: Windsander Date: Wed, 9 Sep 2026 18:35:42 +0800 Subject: [PATCH 15/18] =?UTF-8?q?feat(ui):=20=E5=A2=9E=E5=BC=BA=E4=BB=AA?= =?UTF-8?q?=E8=A1=A8=E7=9B=98=E9=A1=B9=E7=9B=AE=E4=B8=8E=E8=A7=92=E8=89=B2?= =?UTF-8?q?=E6=9C=8D=E5=8A=A1=E6=80=BB=E8=A7=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/advance/ipc/handlers.ts | 7 +- .../components/GlobalRoleServices.tsx | 183 ++++++++++++++++++ .../renderer/components/ProjectCard.tsx | 17 ++ src/electron/renderer/pages/Dashboard.tsx | 3 + src/electron/renderer/styles/global.css | 103 ++++++++++ tests/electron/renderer/Dashboard.test.tsx | 2 + .../components/GlobalRoleServices.test.tsx | 49 +++++ 7 files changed, 363 insertions(+), 1 deletion(-) create mode 100644 src/electron/renderer/components/GlobalRoleServices.tsx create mode 100644 tests/electron/renderer/components/GlobalRoleServices.test.tsx diff --git a/src/advance/ipc/handlers.ts b/src/advance/ipc/handlers.ts index 61c6d4c..94504bc 100755 --- a/src/advance/ipc/handlers.ts +++ b/src/advance/ipc/handlers.ts @@ -6,7 +6,7 @@ import type { MetadataStore } from '../store/metadata-store'; import type { ProjectRegistry } from '../project-registry'; import type { LlmClient } from '../llm/client'; import type { Project } from '../types'; -import { getArchiveRoot } from '../types'; +import { getArchiveRoot, isRoleConfigEnabled } from '../types'; import { loadProjectConfig } from '../config/project-config'; import { loadDaemonConfig, saveDaemonConfig } from '../config/daemon-config.js'; import { @@ -237,6 +237,11 @@ export const handlers: Record Prom ...counts, healthScore, lastScannedAt: p.lastScannedAt, + roleStates: { + reviewer: isRoleConfigEnabled(p.roles?.reviewer), + maintainer: isRoleConfigEnabled(p.roles?.maintainer), + archiver: isRoleConfigEnabled(p.roles?.archiver), + }, }; }); }, diff --git a/src/electron/renderer/components/GlobalRoleServices.tsx b/src/electron/renderer/components/GlobalRoleServices.tsx new file mode 100644 index 0000000..9712873 --- /dev/null +++ b/src/electron/renderer/components/GlobalRoleServices.tsx @@ -0,0 +1,183 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { invoke } from '../api/electron-api.js'; + +type Role = 'reviewer' | 'maintainer' | 'archiver'; + +interface RoleServiceStatus { + running: boolean; + enabledProjects: number; + runningProjects: string[]; +} + +interface RoleServiceViewState { + status?: RoleServiceStatus; + error?: string; +} + +const ROLES: Array<{ role: Role; label: string }> = [ + { role: 'reviewer', label: 'Reviewer' }, + { role: 'maintainer', label: 'Maintainer' }, + { role: 'archiver', label: 'Archiver' }, +]; + +/** Dashboard 全局角色服务控制区。角色是跨项目运行时,项目配置仍留在管线节点检查器。 */ +export function GlobalRoleServices() { + const [statuses, setStatuses] = useState>>({}); + const [busyRoles, setBusyRoles] = useState>(() => new Set()); + const [actionError, setActionError] = useState(null); + const requestId = useRef(0); + const mounted = useRef(true); + const busyRolesRef = useRef>(new Set()); + + const refresh = useCallback(async () => { + const currentRequest = ++requestId.current; + const entries = await Promise.all( + ROLES.map(async ({ role }) => { + try { + const status = await invoke('role.service.status', { role }); + return [role, { status }] as const; + } catch (error) { + return [role, { error: error instanceof Error ? error.message : String(error) }] as const; + } + }) + ); + if (currentRequest !== requestId.current) return; + setStatuses(previous => { + const next = { ...previous }; + for (const [role, state] of entries) next[role] = state; + return next; + }); + }, []); + + useEffect(() => { + mounted.current = true; + let timer: ReturnType | undefined; + const scheduleRefresh = () => { + if (!mounted.current || document.visibilityState === 'hidden') return; + timer = setTimeout(async () => { + await refresh(); + scheduleRefresh(); + }, 3000); + }; + void refresh(); + scheduleRefresh(); + const handleVisibility = () => { + if (timer) { + clearTimeout(timer); + timer = undefined; + } + if (document.visibilityState === 'visible') void refresh(); + scheduleRefresh(); + }; + document.addEventListener('visibilitychange', handleVisibility); + return () => { + mounted.current = false; + requestId.current += 1; + if (timer) clearTimeout(timer); + document.removeEventListener('visibilitychange', handleVisibility); + }; + }, [refresh]); + + const changeService = async (role: Role, action: 'start' | 'stop' | 'restart') => { + if (busyRolesRef.current.has(role)) return; + busyRolesRef.current.add(role); + setBusyRoles(previous => new Set(previous).add(role)); + setActionError(null); + try { + await invoke(`role.service.${action}`, { role }); + await refresh(); + } catch (error) { + if (mounted.current) { + setActionError( + `${labelForRole(role)}${actionLabel(action)}失败:${error instanceof Error ? error.message : String(error)}` + ); + } + } finally { + busyRolesRef.current.delete(role); + if (mounted.current) { + setBusyRoles(previous => { + const next = new Set(previous); + next.delete(role); + return next; + }); + } + } + }; + + return ( +
+
+
+

全局角色服务

+

控制跨项目运行的 Role 节点。项目级启用状态在各项目管线中管理。

+
+ +
+
+ {ROLES.map(({ role, label }) => { + const status = statuses[role]; + const unavailable = Boolean(status?.error); + const busy = busyRoles.has(role); + return ( +
+
+ {label} + + + {unavailable ? '状态不可用' : status?.status?.running ? '运行中' : '已停止'} + +
+
+ {unavailable + ? (status?.error ?? '状态查询失败') + : `${status?.status?.enabledProjects ?? 0} 个项目可运行`} +
+
+ {status?.status?.running ? ( + <> + + + + ) : ( + + )} +
+
+ ); + })} +
+ {actionError &&
{actionError}
} +
+ ); +} + +function labelForRole(role: Role): string { + return ROLES.find(item => item.role === role)?.label ?? role; +} + +function actionLabel(action: 'start' | 'stop' | 'restart'): string { + return action === 'start' ? '启动' : action === 'stop' ? '停止' : '重启'; +} diff --git a/src/electron/renderer/components/ProjectCard.tsx b/src/electron/renderer/components/ProjectCard.tsx index b8938b3..626c666 100755 --- a/src/electron/renderer/components/ProjectCard.tsx +++ b/src/electron/renderer/components/ProjectCard.tsx @@ -12,6 +12,7 @@ export interface ProjectSummary { organized: number; flagged: number; lastScannedAt: number | null; + roleStates?: Partial>; } export function ProjectCard({ project }: { project: ProjectSummary }) { @@ -25,6 +26,22 @@ export function ProjectCard({ project }: { project: ProjectSummary }) {

{project.name}

{project.rootPath}
{project.archiveRoot &&
归档位置: {project.archiveRoot}
} +
+ {( + [ + ['reviewer', 'Reviewer'], + ['maintainer', 'Maintainer'], + ['archiver', 'Archiver'], + ] as const + ).map(([role, label]) => ( + + {label}: {project.roleStates?.[role] ? '配置启用' : '未启用'} + + ))} +
健康度
diff --git a/src/electron/renderer/pages/Dashboard.tsx b/src/electron/renderer/pages/Dashboard.tsx index 59ca949..90f223d 100755 --- a/src/electron/renderer/pages/Dashboard.tsx +++ b/src/electron/renderer/pages/Dashboard.tsx @@ -5,6 +5,7 @@ import { useIpc } from '../hooks/useIpc'; import { PageLayout } from '../components/PageLayout'; import { DashboardIcon } from '../components/icons'; import { ProjectCard, type ProjectSummary } from '../components/ProjectCard'; +import { GlobalRoleServices } from '../components/GlobalRoleServices.js'; export function Dashboard() { const navigate = useNavigate(); @@ -105,6 +106,7 @@ export function Dashboard() { return ( } title="仪表盘" onRefresh={() => refresh()}> +

注册新项目

@@ -176,6 +178,7 @@ export function Dashboard() { e.stopPropagation(); unregister(p.id); }} + onKeyDown={e => e.stopPropagation()} > 注销 diff --git a/src/electron/renderer/styles/global.css b/src/electron/renderer/styles/global.css index a96a7d0..5051f37 100755 --- a/src/electron/renderer/styles/global.css +++ b/src/electron/renderer/styles/global.css @@ -3810,3 +3810,106 @@ select.input:focus { border: 1px solid var(--border-color, #d1d5db); color: inherit; } + +/* ===== Dashboard role services (M8) ===== */ +.dashboard-services { + margin-bottom: 18px; + padding: 16px; + border: 1px solid var(--border-color, #e5e7eb); + border-radius: 8px; + background: var(--bg-primary, #fff); +} + +.dashboard-services-heading { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + margin-bottom: 14px; +} + +.dashboard-services-heading h2 { + margin: 0 0 4px; + font-size: 16px; +} + +.dashboard-services-heading p { + margin: 0; + color: var(--text-secondary, #6b7280); + font-size: 12px; +} + +.dashboard-service-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 10px; +} + +.dashboard-service-item { + min-width: 0; + padding: 12px; + border: 1px solid var(--border-color, #e5e7eb); + border-radius: 6px; +} + +.dashboard-service-title { + display: flex; + align-items: center; + gap: 6px; + font-size: 13px; +} + +.dashboard-service-title span:last-child { + color: var(--text-secondary, #6b7280); + font-size: 11px; +} + +.service-dot { + width: 7px; + height: 7px; + border-radius: 50%; + background: #9ca3af; +} + +.service-dot.running { + background: #22c55e; +} + +.dashboard-service-meta { + margin: 8px 0; + color: var(--text-secondary, #6b7280); + font-size: 11px; +} + +.dashboard-service-actions { + display: flex; + gap: 6px; +} + +.project-role-statuses { + display: flex; + gap: 6px; + margin: 8px 0; + flex-wrap: wrap; +} + +.project-role-status { + padding: 2px 7px; + border: 1px solid var(--border-color, #e5e7eb); + border-radius: 10px; + color: var(--text-secondary, #9ca3af); + font-size: 10px; + text-transform: capitalize; +} + +.project-role-status.enabled { + border-color: #86efac; + color: #15803d; + background: #f0fdf4; +} + +@media (max-width: 800px) { + .dashboard-service-grid { + grid-template-columns: 1fr; + } +} diff --git a/tests/electron/renderer/Dashboard.test.tsx b/tests/electron/renderer/Dashboard.test.tsx index e3867c0..ef1f69d 100755 --- a/tests/electron/renderer/Dashboard.test.tsx +++ b/tests/electron/renderer/Dashboard.test.tsx @@ -13,6 +13,7 @@ describe('Dashboard', () => { id: 'p1', name: 'proj', rootPath: '/tmp', + roleStates: { reviewer: true, maintainer: false, archiver: true }, healthScore: 0.8, pending: 1, archived: 2, @@ -37,6 +38,7 @@ describe('Dashboard', () => { await waitFor(() => { expect(screen.getByText('proj')).toBeTruthy(); expect(screen.getByText('80%')).toBeTruthy(); + expect(screen.getByText(/Reviewer.*配置启用/)).toBeTruthy(); }); }); }); diff --git a/tests/electron/renderer/components/GlobalRoleServices.test.tsx b/tests/electron/renderer/components/GlobalRoleServices.test.tsx new file mode 100644 index 0000000..e71b0e6 --- /dev/null +++ b/tests/electron/renderer/components/GlobalRoleServices.test.tsx @@ -0,0 +1,49 @@ +import { describe, expect, it, vi } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; +import { GlobalRoleServices } from '../../../../src/electron/renderer/components/GlobalRoleServices.js'; + +describe('GlobalRoleServices', () => { + it('展示三个角色服务并按状态提供启动/停止操作', async () => { + const invoke = vi.fn(async (method: string, params?: { role?: string }) => { + if (method === 'role.service.status') { + return { + running: params?.role === 'reviewer', + enabledProjects: 2, + runningProjects: ['p1', 'p2'], + }; + } + return { success: true }; + }); + window.electronAPI = { invoke } as unknown as Window['electronAPI']; + + render(); + + await waitFor(() => expect(screen.getAllByText('Reviewer')).toHaveLength(1)); + expect(screen.getByText('Maintainer')).toBeTruthy(); + expect(screen.getByText('Archiver')).toBeTruthy(); + expect(screen.getAllByText('2 个项目可运行')).toHaveLength(3); + expect(screen.getAllByRole('button', { name: '启动' })).toHaveLength(2); + expect(screen.getAllByRole('button', { name: '停止' })).toHaveLength(1); + }); + + it('点击启动后调用对应角色服务并刷新状态', async () => { + const invoke = vi.fn(async (method: string) => { + if (method === 'role.service.status') { + return { running: false, enabledProjects: 0, runningProjects: [] }; + } + return { success: true }; + }); + window.electronAPI = { invoke } as unknown as Window['electronAPI']; + + render(); + const buttons = await screen.findAllByRole('button', { name: '启动' }); + buttons[0].click(); + + await waitFor(() => { + expect(invoke).toHaveBeenCalledWith('role.service.start', { role: 'reviewer' }); + expect( + invoke.mock.calls.filter(call => call[0] === 'role.service.status').length + ).toBeGreaterThan(3); + }); + }); +}); From b6b75f1bda43d3329c21adc0a6e33e7401a34fb5 Mon Sep 17 00:00:00 2001 From: Windsander Date: Wed, 9 Sep 2026 20:05:10 +0800 Subject: [PATCH 16/18] =?UTF-8?q?refactor(ui):=20=E9=87=8D=E6=9E=84?= =?UTF-8?q?=E9=A1=B9=E7=9B=AE=E5=AE=B9=E5=99=A8=E4=B8=8E=E8=BF=90=E8=A1=8C?= =?UTF-8?q?=E8=AE=BE=E7=BD=AE=E9=A1=B5=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/advance/archive/undo-executor.ts | 5 +- src/advance/ipc/handlers.ts | 104 ++++++++++-- src/advance/store/metadata-store.ts | 44 +++++- .../renderer/components/PipelineCanvas.tsx | 5 +- .../components/ProjectSettingsPanel.tsx | 149 ++++++++++++++++++ .../renderer/components/RoleProjectConfig.tsx | 44 ++++-- .../renderer/components/RunCenter.tsx | 127 +++++++++++++++ src/electron/renderer/pages/ProjectDetail.tsx | 58 +++---- src/electron/renderer/pages/RolePage.tsx | 2 +- src/electron/renderer/styles/global.css | 133 ++++++++++++++++ src/electron/shared/types.ts | 2 + .../electron/renderer/ProjectDetail.test.tsx | 21 ++- .../components/RoleProjectConfig.test.tsx | 9 ++ 13 files changed, 634 insertions(+), 69 deletions(-) create mode 100644 src/electron/renderer/components/ProjectSettingsPanel.tsx create mode 100644 src/electron/renderer/components/RunCenter.tsx diff --git a/src/advance/archive/undo-executor.ts b/src/advance/archive/undo-executor.ts index f181bd2..3c9c2bc 100755 --- a/src/advance/archive/undo-executor.ts +++ b/src/advance/archive/undo-executor.ts @@ -12,7 +12,7 @@ export interface UndoResult { export class UndoExecutor { constructor(private options: { store: MetadataStore }) {} - async undo(actionId: string): Promise { + async undo(actionId: string, projectId?: string): Promise { const history = this.options.store.getActionHistory(actionId); if (!history) { return { success: false, message: '未找到动作历史记录' }; @@ -20,6 +20,9 @@ export class UndoExecutor { if (history.status === 'undone') { return { success: false, message: '该动作已被撤销' }; } + if (projectId && history.projectId !== projectId) { + return { success: false, message: '动作不属于当前项目' }; + } switch (history.type) { case 'copy': diff --git a/src/advance/ipc/handlers.ts b/src/advance/ipc/handlers.ts index 94504bc..d5aad9b 100755 --- a/src/advance/ipc/handlers.ts +++ b/src/advance/ipc/handlers.ts @@ -1,5 +1,15 @@ -import { readFileSync, writeFileSync, existsSync, mkdirSync, unlinkSync } from 'node:fs'; -import { join } from 'node:path'; +import { randomUUID } from 'node:crypto'; +import { + accessSync, + constants, + readFileSync, + writeFileSync, + existsSync, + mkdirSync, + unlinkSync, + renameSync, +} from 'node:fs'; +import { join, normalize, relative, resolve } from 'node:path'; import { getLogDir } from '../core/platform'; import { logger } from '../core/logger'; import type { MetadataStore } from '../store/metadata-store'; @@ -7,7 +17,8 @@ import type { ProjectRegistry } from '../project-registry'; import type { LlmClient } from '../llm/client'; import type { Project } from '../types'; import { getArchiveRoot, isRoleConfigEnabled } from '../types'; -import { loadProjectConfig } from '../config/project-config'; +import { loadProjectConfig, projectConfigSchema } from '../config/project-config.js'; +import YAML from 'yaml'; import { loadDaemonConfig, saveDaemonConfig } from '../config/daemon-config.js'; import { DEFAULT_EMBEDDING_MODEL, @@ -49,6 +60,12 @@ function toKnowledgeDto(item: KnowledgeItem) { bodyPreview: item.body.slice(0, 500), }; } + +function normalizeHistoryLimit(value: unknown): number { + const parsed = Number(value); + if (!Number.isFinite(parsed)) return 100; + return Math.min(100, Math.max(1, Math.floor(parsed))); +} import type { LocalModelServiceManager } from '../classic/memory/local-model-service.js'; import type { ModelCapability } from '../classic/memory/model-server.js'; import { ReviewerManager } from '../classic/roles/reviewer-manager.js'; @@ -234,6 +251,8 @@ export const handlers: Record Prom } return { ...p, + gitlab: p.gitlab ? { ...p.gitlab, token: '' } : undefined, + hasGitlabToken: Boolean(p.gitlab?.token), ...counts, healthScore, lastScannedAt: p.lastScannedAt, @@ -250,7 +269,12 @@ export const handlers: Record Prom const project = ctx.registry.get(params.projectId); if (!project) throw new Error('项目未注册'); const counts = ctx.store.getProjectCounts(project.id); - return { ...project, ...counts }; + return { + ...project, + gitlab: project.gitlab ? { ...project.gitlab, token: '' } : undefined, + hasGitlabToken: Boolean(project.gitlab?.token), + ...counts, + }; }, 'project.scan': async (ctx, params) => { @@ -311,23 +335,77 @@ export const handlers: Record Prom const dir = getArchiveRoot(project); mkdirSync(dir, { recursive: true }); const path = join(dir, 'config.yaml'); - writeFileSync(path, params.content, 'utf-8'); + const content = typeof params.content === 'string' ? params.content : ''; + const parsed = YAML.parse(content); + projectConfigSchema.parse(parsed); + const temporaryPath = `${path}.tmp-${randomUUID()}`; + try { + writeFileSync(temporaryPath, content, 'utf-8'); + renameSync(temporaryPath, path); + } finally { + if (existsSync(temporaryPath)) unlinkSync(temporaryPath); + } + ctx.unwatchProject?.(params.projectId); + const updatedProject = ctx.registry.get(params.projectId); + if (updatedProject) ctx.watchProject?.(updatedProject); + return { success: true }; + }, + + 'project.archive-root.update': async (ctx, params) => { + const project = ctx.registry.get(params.projectId); + if (!project) throw new Error('项目未注册'); + const archiveRootInput = + typeof params.archiveRoot === 'string' ? params.archiveRoot.trim() : ''; + const archiveRoot = archiveRootInput ? normalize(resolve(archiveRootInput)) : null; + if (archiveRoot) { + try { + mkdirSync(archiveRoot, { recursive: true }); + accessSync(archiveRoot, constants.R_OK | constants.W_OK); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error(`归档位置不可读写: ${archiveRoot}: ${message}`); + } + } + ctx.store.updateProjectArchiveRoot(params.projectId, archiveRoot); + ctx.unwatchProject?.(params.projectId); + const updatedProject = ctx.registry.get(params.projectId); + if (updatedProject) ctx.watchProject?.(updatedProject); + ctx.serviceRegistry.reloadProject(params.projectId); return { success: true }; }, 'action.history': async (ctx, params) => { if (params.projectId === 'all') { const projects = ctx.registry.list(); - return projects.flatMap(p => ctx.store.listActionHistory(p.id)); + const limit = normalizeHistoryLimit(params.limit); + const items = ctx.store.listActionHistoryAll(limit); + const roots = new Map(projects.map(project => [project.id, project.rootPath])); + return items.map(item => ({ + ...item, + sourcePath: relative(roots.get(item.projectId) ?? '', item.sourcePath) || '.', + targetPath: item.targetPath + ? relative(roots.get(item.projectId) ?? '', item.targetPath) || '.' + : undefined, + })); } - return ctx.store.listActionHistory(params.projectId); + const project = ctx.registry.get(params.projectId); + if (!project) throw new Error('项目未注册'); + const history = ctx.store.listActionHistory( + params.projectId, + normalizeHistoryLimit(params.limit) + ); + return history.map(item => ({ + ...item, + sourcePath: relative(project.rootPath, item.sourcePath) || '.', + targetPath: item.targetPath ? relative(project.rootPath, item.targetPath) || '.' : undefined, + })); }, 'action.undo': async (ctx, params) => { const project = ctx.registry.get(params.projectId); if (!project) throw new Error('项目未注册'); const executor = new UndoExecutor({ store: ctx.store }); - return executor.undo(params.actionId); + return executor.undo(params.actionId, params.projectId); }, 'daemon.config': async ctx => { @@ -462,7 +540,13 @@ export const handlers: Record Prom 'project.gitlab.config.get': async (ctx, params) => { const project = ctx.registry.get(params.projectId); if (!project) throw new Error('项目未注册'); - return { gitlab: project.gitlab ?? null }; + const includeSecret = params.includeSecret === true; + return { + gitlab: project.gitlab + ? { ...project.gitlab, token: includeSecret ? project.gitlab.token : '' } + : null, + hasToken: Boolean(project.gitlab?.token), + }; }, 'project.gitlab.config.update': async (ctx, params) => { @@ -476,7 +560,7 @@ export const handlers: Record Prom const updated: NonNullable = { baseUrl: gitlab.baseUrl, projectPath: gitlab.projectPath, - token: gitlab.token ?? project.gitlab?.token ?? '', + token: gitlab.token?.trim() ? gitlab.token.trim() : (project.gitlab?.token ?? ''), defaultBranch: gitlab.defaultBranch ?? project.gitlab?.defaultBranch ?? 'main', }; ctx.store.updateProjectGitlabConfig(params.projectId, updated); diff --git a/src/advance/store/metadata-store.ts b/src/advance/store/metadata-store.ts index 6d9502c..c8fa58c 100755 --- a/src/advance/store/metadata-store.ts +++ b/src/advance/store/metadata-store.ts @@ -644,11 +644,43 @@ export class MetadataStore { } listActionHistory( - projectId: string + projectId: string, + limit = 100 ): Array { const rows = this.db - .prepare('SELECT * FROM action_history WHERE project_id = ? ORDER BY applied_at DESC') - .all(projectId) as Array<{ + .prepare('SELECT * FROM action_history WHERE project_id = ? ORDER BY applied_at DESC LIMIT ?') + .all(projectId, limit) as Array<{ + id: number; + action_id: string; + project_id: string; + type: string; + source_path: string; + archive_path: string | null; + target_path: string | null; + status: string; + applied_at: number; + }>; + return rows.map(r => ({ + historyId: r.id, + id: r.action_id, + projectId: r.project_id, + sourcePath: r.source_path, + type: r.type as ArchiveAction['type'], + targetPath: r.archive_path ?? r.target_path ?? undefined, + risk: 'low', + reason: '', + confidence: 0, + createdAt: r.applied_at, + status: r.status as 'applied' | 'undone', + })); + } + + listActionHistoryAll( + limit = 100 + ): Array { + const rows = this.db + .prepare('SELECT * FROM action_history ORDER BY applied_at DESC LIMIT ?') + .all(limit) as Array<{ id: number; action_id: string; project_id: string; @@ -892,6 +924,12 @@ export class MetadataStore { .run(JSON.stringify(config), projectId); } + updateProjectArchiveRoot(projectId: string, archiveRoot: string | null): void { + this.db + .prepare('UPDATE projects SET archive_root = ? WHERE id = ?') + .run(archiveRoot, projectId); + } + updateProjectRoleConfig(projectId: string, role: Role, config: RoleConfig): void { const project = this.getProject(projectId); if (!project) throw new Error(`项目不存在: ${projectId}`); diff --git a/src/electron/renderer/components/PipelineCanvas.tsx b/src/electron/renderer/components/PipelineCanvas.tsx index 4b7e994..772ab76 100644 --- a/src/electron/renderer/components/PipelineCanvas.tsx +++ b/src/electron/renderer/components/PipelineCanvas.tsx @@ -10,6 +10,7 @@ import { useIpc } from '../hooks/useIpc.js'; import { invoke } from '../api/electron-api.js'; import { PipelineGraph } from './PipelineGraph.js'; import { RoleProjectConfig } from './RoleProjectConfig.js'; +import { ArchiverProviderConfig } from './ArchiverProviderConfig.js'; import { addEdgeToDefinition, addNodeToDefinition, @@ -330,7 +331,9 @@ function NodeInspector({
) : null} {roleType === 'archiver' && ( -

Archiver 的详细配置请在 Archiver 页维护。

+
+ {project && } +
)} {roleType && (
diff --git a/src/electron/renderer/components/ProjectSettingsPanel.tsx b/src/electron/renderer/components/ProjectSettingsPanel.tsx new file mode 100644 index 0000000..ef55ed5 --- /dev/null +++ b/src/electron/renderer/components/ProjectSettingsPanel.tsx @@ -0,0 +1,149 @@ +import { useEffect, useState } from 'react'; +import { invoke, showOpenDialog } from '../api/electron-api.js'; +import { useIpc } from '../hooks/useIpc.js'; +import type { Project } from '../../shared/types.js'; + +export function ProjectSettingsPanel({ projectId }: { projectId: string }) { + const { data: project, refresh } = useIpc('project.get', { projectId }); + const { data: projectConfig, refresh: refreshConfig } = useIpc<{ content: string }>( + 'project.config', + { projectId } + ); + const [gitlab, setGitlab] = useState({ + baseUrl: '', + projectPath: '', + token: '', + defaultBranch: 'main', + }); + const [archiveRoot, setArchiveRoot] = useState(''); + const [configContent, setConfigContent] = useState(''); + const [message, setMessage] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + if (project) { + setArchiveRoot(project.archiveRoot ?? ''); + setGitlab({ + baseUrl: project.gitlab?.baseUrl ?? '', + projectPath: project.gitlab?.projectPath ?? '', + token: project.gitlab?.token ?? '', + defaultBranch: project.gitlab?.defaultBranch ?? 'main', + }); + } + }, [project]); + useEffect(() => setConfigContent(projectConfig?.content ?? ''), [projectConfig]); + + const saveGitlab = async () => { + try { + setError(null); + await invoke('project.gitlab.config.update', { projectId, gitlab }); + setMessage('GitLab 配置已保存'); + refresh(); + } catch (reason) { + setError(reason instanceof Error ? reason.message : String(reason)); + } + }; + const saveArchiveRoot = async () => { + try { + setError(null); + await invoke('project.archive-root.update', { projectId, archiveRoot }); + setMessage('归档位置已保存'); + refresh(); + } catch (reason) { + setError(reason instanceof Error ? reason.message : String(reason)); + } + }; + const saveConfig = async () => { + try { + setError(null); + await invoke('project.config.update', { projectId, content: configContent }); + setMessage('扫描规则已保存'); + refreshConfig(); + } catch (reason) { + setError(reason instanceof Error ? reason.message : String(reason)); + } + }; + const pickArchiveRoot = async () => { + const result = await showOpenDialog({ + title: '选择归档目录', + properties: ['openDirectory', 'createDirectory'], + }); + if (!result.canceled && result.filePaths[0]) setArchiveRoot(result.filePaths[0]); + }; + + if (!project) return
加载项目设置...
; + return ( +
+ {message &&
{message}
} + {error &&
保存失败:{error}
} +
+
+

GitLab 项目

+ 项目级连接配置 +
+ + + + + +
+
+
+

归档位置

+ 留空使用项目内 .codekeeper +
+
+ setArchiveRoot(e.target.value)} /> + +
+ +
+
+
+

扫描规则

+ .codekeeper/config.yaml +
+