From bb493a131f6066e380914ffd6c3bf8c06d5ac822 Mon Sep 17 00:00:00 2001 From: Leo Date: Mon, 3 Aug 2026 23:34:33 +0800 Subject: [PATCH 01/24] =?UTF-8?q?=E2=9C=A8=20feat(shared):=20=E4=B8=BB?= =?UTF-8?q?=E5=8A=A8=E5=BB=BA=E8=AE=AE=E7=B1=BB=E5=9E=8B=E5=AE=9A=E4=B9=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/shared/src/types/index.ts | 1 + packages/shared/src/types/suggestion.ts | 50 +++++++++++++++++++++++++ 2 files changed, 51 insertions(+) create mode 100644 packages/shared/src/types/suggestion.ts diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index 7481eaf11..03d1488e6 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -31,3 +31,4 @@ export * from "./computer-use"; export * from "./logging"; export * from "./wiki"; export * from "./browser-runtime"; +export * from "./suggestion"; diff --git a/packages/shared/src/types/suggestion.ts b/packages/shared/src/types/suggestion.ts new file mode 100644 index 000000000..b7ff49c58 --- /dev/null +++ b/packages/shared/src/types/suggestion.ts @@ -0,0 +1,50 @@ +// packages/shared/src/types/suggestion.ts +export type SuggestionKind = "correction" | "followup" | "automation" | "todo" | "skill"; + +export type SuggestionAction = + | { type: "memory_correction"; raw: string; rule: string } + | { type: "open_automation_create"; automationTitle: string; suggestedPrompt: string } + | { type: "open_memory_board" } + | { type: "open_skill_creator"; topic: string }; + +export interface SuggestionCandidate { + duplicateKey: string; + kind: SuggestionKind; + title: string; + reason: string; + evidence: string; + rawConfidence: number; + action: SuggestionAction; +} + +export interface SuggestionRecord extends SuggestionCandidate { + id: number; + sessionId?: string; + threadId?: string; + workspaceSlug?: string; + status: "suggested" | "accepted" | "ignored" | "never"; + createdAt: number; + feedbackAt?: number; +} + +export type SuggestionFeedback = "accepted" | "ignored" | "never"; +export type SuggestionTypeWeights = Record; + +export interface SuggestionsIndex { + version: 1; + records: SuggestionRecord[]; + typeWeights: SuggestionTypeWeights; + enabled: boolean; +} + +export interface SuggestionStats { + suggestedCount: number; + todayAccepted: number; + todayIgnored: number; + todayNever: number; + typeWeights: SuggestionTypeWeights; +} + +export const DEFAULT_TYPE_WEIGHTS: SuggestionTypeWeights = { + correction: 1.0, followup: 1.0, automation: 1.0, skill: 0.8, todo: 0.9, +}; From 5b7447adf503df3995cc0992037213754ddf5a56 Mon Sep 17 00:00:00 2001 From: Leo Date: Mon, 3 Aug 2026 23:41:52 +0800 Subject: [PATCH 02/24] =?UTF-8?q?=E2=9C=A8=20feat(sidecar):=20=E5=BB=BA?= =?UTF-8?q?=E8=AE=AE=E5=AD=98=E5=82=A8=20suggestions.json=20+=20=E5=8E=9F?= =?UTF-8?q?=E5=AD=90=E5=86=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/services/infra/config-paths.ts | 8 + .../src/services/suggest/store.test.ts | 167 ++++++++++++++ apps/sidecar/src/services/suggest/store.ts | 211 ++++++++++++++++++ 3 files changed, 386 insertions(+) create mode 100644 apps/sidecar/src/services/suggest/store.test.ts create mode 100644 apps/sidecar/src/services/suggest/store.ts diff --git a/apps/sidecar/src/services/infra/config-paths.ts b/apps/sidecar/src/services/infra/config-paths.ts index 2c681702d..8e88e0ea2 100644 --- a/apps/sidecar/src/services/infra/config-paths.ts +++ b/apps/sidecar/src/services/infra/config-paths.ts @@ -391,3 +391,11 @@ export function getGlobalVectorIndexDir(): string { export function getWorkspaceVectorIndexDir(workspaceSlug: string): string { return ensureDir(join(getWorkspaceMemoryDir(workspaceSlug), "index"), "工作区向量索引目录"); } + +export function getSuggestionConfigDir(): string { + return ensureDir(join(getConfigDir(), "suggestions"), "建议配置目录"); +} + +export function getSuggestionIndexPath(): string { + return join(getSuggestionConfigDir(), "suggestions.json"); +} diff --git a/apps/sidecar/src/services/suggest/store.test.ts b/apps/sidecar/src/services/suggest/store.test.ts new file mode 100644 index 000000000..194e0b12a --- /dev/null +++ b/apps/sidecar/src/services/suggest/store.test.ts @@ -0,0 +1,167 @@ +import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { + clearSuggestions, + deleteSuggestion, + getEnabled, + getTypeWeights, + listSuggestions, + persistSuggestion, + resetSuggestionStoreForTest, + setEnabled, + suggestionStats, +} from "./store"; +import type { SuggestionCandidate } from "@lume/shared"; + +let root: string; + +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), "lume-suggest-")); + process.env.LUME_CONFIG_DIR = root; + resetSuggestionStoreForTest(); +}); + +afterEach(() => { + delete process.env.LUME_CONFIG_DIR; + rmSync(root, { recursive: true, force: true }); +}); + +const candidate = (overrides: Partial = {}): SuggestionCandidate => ({ + duplicateKey: "correction:test", + kind: "correction", + title: "t", + reason: "r", + evidence: "e", + rawConfidence: 0.9, + action: { type: "memory_correction", raw: "以后不要用 var", rule: "不要用 var" }, + ...overrides, +}); + +describe("suggestion store", () => { + test("persistSuggestion 写入并分配自增 id + status suggested", () => { + const rec = persistSuggestion(candidate(), { threadId: "t1", workspaceSlug: "ws" }); + expect(rec.id).toBeGreaterThan(0); + expect(rec.status).toBe("suggested"); + expect(listSuggestions()[0]?.threadId).toBe("t1"); + }); + + test("listSuggestions(status) 按状态过滤", () => { + persistSuggestion(candidate({ duplicateKey: "k1" })); + // 默认 status=suggested;accepted/ignored/never 由 feedback 模块改写,这里只测过滤 + expect(listSuggestions("suggested")).toHaveLength(1); + expect(listSuggestions("accepted")).toHaveLength(0); + }); + + test("enabled 默认 true,setEnabled 持久化", () => { + expect(getEnabled()).toBe(true); + setEnabled(false); + expect(getEnabled()).toBe(false); + // 重置缓存后仍应从磁盘读回 false + resetSuggestionStoreForTest(); + expect(getEnabled()).toBe(false); + }); + + test("id 单调自增 + 新记录 unshift 到首位", () => { + const a = persistSuggestion(candidate({ duplicateKey: "k1" })); + const b = persistSuggestion(candidate({ duplicateKey: "k2" })); + expect(b.id).toBeGreaterThan(a.id); + const list = listSuggestions(); + expect(list[0]?.id).toBe(b.id); + expect(list[1]?.id).toBe(a.id); + }); + + test("持久化落盘:resetSuggestionStoreForTest 后从磁盘读回", () => { + persistSuggestion(candidate({ duplicateKey: "k1" }), { threadId: "t1" }); + resetSuggestionStoreForTest(); + const list = listSuggestions(); + expect(list).toHaveLength(1); + expect(list[0]?.threadId).toBe("t1"); + }); + + test("deleteSuggestion 按 id 移除", () => { + const a = persistSuggestion(candidate({ duplicateKey: "k1" })); + persistSuggestion(candidate({ duplicateKey: "k2" })); + deleteSuggestion(a.id); + const list = listSuggestions(); + expect(list).toHaveLength(1); + expect(list.find((r) => r.id === a.id)).toBeUndefined(); + }); + + test("clearSuggestions 清空记录但保留 typeWeights + enabled", () => { + setEnabled(false); + persistSuggestion(candidate({ duplicateKey: "k1" })); + clearSuggestions(); + expect(listSuggestions()).toHaveLength(0); + expect(getEnabled()).toBe(false); + expect(getTypeWeights().correction).toBe(1.0); + }); + + test("getTypeWeights 返回默认权重", () => { + const w = getTypeWeights(); + expect(w.correction).toBe(1.0); + expect(w.skill).toBe(0.8); + expect(w.todo).toBe(0.9); + }); + + test("suggestionStats 统计 suggested 数量", () => { + persistSuggestion(candidate({ duplicateKey: "k1" })); + persistSuggestion(candidate({ duplicateKey: "k2" })); + const stats = suggestionStats(); + expect(stats.suggestedCount).toBe(2); + expect(stats.todayAccepted).toBe(0); + expect(stats.typeWeights.correction).toBe(1.0); + }); + + test("字段长度截断:title>200 / reason>500 / evidence>500 / duplicateKey>200", () => { + const longTitle = "x".repeat(300); + const longReason = "y".repeat(600); + const longEvidence = "z".repeat(600); + const longKey = "k".repeat(300); + const rec = persistSuggestion( + candidate({ + duplicateKey: longKey, + title: longTitle, + reason: longReason, + evidence: longEvidence, + }), + ); + expect(rec.title).toHaveLength(200); + expect(rec.reason).toHaveLength(500); + expect(rec.evidence).toHaveLength(500); + expect(rec.duplicateKey).toHaveLength(200); + // 落盘后读回同样被截断 + resetSuggestionStoreForTest(); + const list = listSuggestions(); + expect(list[0]?.title).toHaveLength(200); + }); + + test("损坏的 suggestions.json 自动备份 + 重建空索引(不抛错)", () => { + const indexPath = join(root, "suggestions", "suggestions.json"); + // 先写一条正常记录以保证文件存在 + persistSuggestion(candidate({ duplicateKey: "k1" })); + expect(existsSync(indexPath)).toBe(true); + // 用损坏内容覆盖 + writeFileSync(indexPath, "{ not valid json", "utf-8"); + resetSuggestionStoreForTest(); + // 读不应抛错 + const list = listSuggestions(); + expect(list).toHaveLength(0); + // 原损坏文件应被备份(出现 .corrupt- 副本) + const dir = join(root, "suggestions"); + const backups = existsSync(dir) + ? readdirSync(dir).filter((f) => f.includes(".corrupt-")) + : []; + expect(backups.length).toBe(1); + // enabled 应回到默认 true + expect(getEnabled()).toBe(true); + }); + + test("MAX_RECORDS=500:超出时裁剪最旧记录", () => { + for (let i = 0; i < 502; i++) { + persistSuggestion(candidate({ duplicateKey: `k${i}` })); + } + expect(listSuggestions()).toHaveLength(500); + }); +}); diff --git a/apps/sidecar/src/services/suggest/store.ts b/apps/sidecar/src/services/suggest/store.ts new file mode 100644 index 000000000..a180d0bb9 --- /dev/null +++ b/apps/sidecar/src/services/suggest/store.ts @@ -0,0 +1,211 @@ +import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"; +import { dirname } from "node:path"; +import { getSuggestionIndexPath } from "../infra/config-paths"; +import { createLogger } from "../infra/logger"; +import type { + SuggestionCandidate, + SuggestionRecord, + SuggestionsIndex, + SuggestionStats, + SuggestionTypeWeights, +} from "@lume/shared"; +import { DEFAULT_TYPE_WEIGHTS } from "@lume/shared"; + +const INDEX_VERSION = 1 as const; +const MAX_RECORDS = 500; +const STATUS_VALUES = new Set(["suggested", "accepted", "ignored", "never"]); +const FIELD_LIMITS = { title: 200, reason: 500, evidence: 500, duplicateKey: 200 } as const; +const KIND_KEYS = Object.keys(DEFAULT_TYPE_WEIGHTS) as (keyof SuggestionTypeWeights)[]; +const log = createLogger("suggestion-store"); + +let cache: SuggestionsIndex | null = null; + +function emptyIndex(): SuggestionsIndex { + return { + version: INDEX_VERSION, + records: [], + typeWeights: { ...DEFAULT_TYPE_WEIGHTS }, + enabled: true, + }; +} + +function writeJsonAtomic(path: string, payload: string): void { + mkdirSync(dirname(path), { recursive: true }); + const tmpPath = `${path}.${process.pid}.${Date.now()}.tmp`; + writeFileSync(tmpPath, payload, "utf-8"); + renameSync(tmpPath, path); +} + +function backupCorruptIndex(indexPath: string): void { + if (!existsSync(indexPath)) return; + const backupPath = `${indexPath}.corrupt-${Date.now()}`; + try { + renameSync(indexPath, backupPath); + log.warn("backed up corrupt suggestion index", { backupPath }); + } catch (error) { + log.warn("failed to back up corrupt suggestion index", { error, backupPath }); + } +} + +function normalizeTypeWeights(raw: unknown): SuggestionTypeWeights { + const base = { ...DEFAULT_TYPE_WEIGHTS }; + if (raw && typeof raw === "object" && !Array.isArray(raw)) { + const src = raw as Record; + for (const key of KIND_KEYS) { + const v = src[key]; + if (typeof v === "number" && Number.isFinite(v)) { + base[key] = v; + } + } + } + return base; +} + +/** 结构校验:id>0、status 属枚举、必填字段存在且类型正确。字段长度截断由 normalizeRecord 处理。 */ +export function isValidSuggestionRecord(raw: unknown): raw is SuggestionRecord { + if (!raw || typeof raw !== "object") return false; + const r = raw as Record; + if (typeof r.id !== "number" || !(r.id > 0)) return false; + if (typeof r.status !== "string" || !STATUS_VALUES.has(r.status as SuggestionRecord["status"])) return false; + if (typeof r.duplicateKey !== "string") return false; + if (typeof r.kind !== "string") return false; + if (typeof r.title !== "string") return false; + if (typeof r.reason !== "string") return false; + if (typeof r.evidence !== "string") return false; + if (typeof r.rawConfidence !== "number") return false; + if (!r.action || typeof r.action !== "object") return false; + if (typeof r.createdAt !== "number") return false; + return true; +} + +function truncate(value: string, limit: number): string { + return value.length > limit ? value.slice(0, limit) : value; +} + +function normalizeRecord(rec: SuggestionRecord): SuggestionRecord { + return { + ...rec, + title: truncate(rec.title, FIELD_LIMITS.title), + reason: truncate(rec.reason, FIELD_LIMITS.reason), + evidence: truncate(rec.evidence, FIELD_LIMITS.evidence), + duplicateKey: truncate(rec.duplicateKey, FIELD_LIMITS.duplicateKey), + }; +} + +function readIndex(): SuggestionsIndex { + if (cache) return cache; + const indexPath = getSuggestionIndexPath(); + if (!existsSync(indexPath)) { + cache = emptyIndex(); + return cache; + } + try { + const parsed = JSON.parse(readFileSync(indexPath, "utf-8")) as Partial; + if (!parsed || typeof parsed !== "object" || !Array.isArray(parsed.records)) { + throw new Error("records 字段缺失或非数组"); + } + const records = parsed.records.filter(isValidSuggestionRecord); + cache = { + version: INDEX_VERSION, + records, + typeWeights: normalizeTypeWeights(parsed.typeWeights), + enabled: typeof parsed.enabled === "boolean" ? parsed.enabled : true, + }; + return cache; + } catch (error) { + log.error("failed to read suggestion index", { error, indexPath }); + backupCorruptIndex(indexPath); + cache = emptyIndex(); + return cache; + } +} + +function writeIndex(index: SuggestionsIndex): void { + cache = index; + writeJsonAtomic(getSuggestionIndexPath(), JSON.stringify(index, null, 2)); +} + +export function persistSuggestion( + candidate: SuggestionCandidate, + ctx?: { threadId?: string; workspaceSlug?: string; sessionId?: string }, +): SuggestionRecord { + const index = readIndex(); + const maxId = index.records.reduce((max, r) => (r.id > max ? r.id : max), 0); + const record = normalizeRecord({ + ...candidate, + id: maxId + 1, + status: "suggested", + createdAt: Date.now(), + sessionId: ctx?.sessionId, + threadId: ctx?.threadId, + workspaceSlug: ctx?.workspaceSlug, + }); + writeIndex({ + ...index, + records: [record, ...index.records].slice(0, MAX_RECORDS), + }); + return record; +} + +export function listSuggestions(status?: SuggestionRecord["status"]): SuggestionRecord[] { + const records = readIndex().records; + if (!status) return [...records]; + return records.filter((r) => r.status === status); +} + +export function deleteSuggestion(id: number): void { + const index = readIndex(); + writeIndex({ + ...index, + records: index.records.filter((r) => r.id !== id), + }); +} + +export function clearSuggestions(): void { + const index = readIndex(); + writeIndex({ ...index, records: [] }); +} + +export function suggestionStats(): SuggestionStats { + const index = readIndex(); + const startOfToday = new Date(); + startOfToday.setHours(0, 0, 0, 0); + const todayMs = startOfToday.getTime(); + let suggestedCount = 0; + let todayAccepted = 0; + let todayIgnored = 0; + let todayNever = 0; + for (const r of index.records) { + if (r.status === "suggested") { + suggestedCount++; + continue; + } + if (typeof r.feedbackAt !== "number" || r.feedbackAt < todayMs) continue; + if (r.status === "accepted") todayAccepted++; + else if (r.status === "ignored") todayIgnored++; + else if (r.status === "never") todayNever++; + } + return { + suggestedCount, + todayAccepted, + todayIgnored, + todayNever, + typeWeights: { ...index.typeWeights }, + }; +} + +export function getEnabled(): boolean { + return readIndex().enabled; +} + +export function setEnabled(value: boolean): void { + writeIndex({ ...readIndex(), enabled: value }); +} + +export function getTypeWeights(): SuggestionTypeWeights { + return { ...readIndex().typeWeights }; +} + +export function resetSuggestionStoreForTest(): void { + cache = null; +} From 68a1f90a2c70800e419a5e00cfda2608d69a20a8 Mon Sep 17 00:00:00 2001 From: Leo Date: Mon, 3 Aug 2026 23:46:29 +0800 Subject: [PATCH 03/24] =?UTF-8?q?=F0=9F=90=9B=20fix(sidecar):=20writeIndex?= =?UTF-8?q?=20=E5=85=88=E5=86=99=E7=9B=98=E5=86=8D=E6=9B=B4=E6=96=B0?= =?UTF-8?q?=E7=BC=93=E5=AD=98=E9=81=BF=E5=85=8D=E4=B8=8D=E4=B8=80=E8=87=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/sidecar/src/services/suggest/store.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/sidecar/src/services/suggest/store.ts b/apps/sidecar/src/services/suggest/store.ts index a180d0bb9..bb9fd2119 100644 --- a/apps/sidecar/src/services/suggest/store.ts +++ b/apps/sidecar/src/services/suggest/store.ts @@ -121,8 +121,8 @@ function readIndex(): SuggestionsIndex { } function writeIndex(index: SuggestionsIndex): void { - cache = index; writeJsonAtomic(getSuggestionIndexPath(), JSON.stringify(index, null, 2)); + cache = index; } export function persistSuggestion( From faed7b6fe1b1034126da3e202104bd196d82c90f Mon Sep 17 00:00:00 2001 From: Leo Date: Mon, 3 Aug 2026 23:53:41 +0800 Subject: [PATCH 04/24] =?UTF-8?q?=E2=9C=A8=20feat(sidecar):=20=E5=BB=BA?= =?UTF-8?q?=E8=AE=AE=E4=BF=A1=E5=8F=B7=E6=8F=90=E5=8F=96=EF=BC=886=20?= =?UTF-8?q?=E7=B1=BB=E8=AF=8D=E5=85=B8=20+=20=E9=87=8D=E5=A4=8D=E6=84=8F?= =?UTF-8?q?=E5=9B=BE=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- .../src/services/suggest/signals.test.ts | 269 +++++++++++++ apps/sidecar/src/services/suggest/signals.ts | 367 ++++++++++++++++++ 2 files changed, 636 insertions(+) create mode 100644 apps/sidecar/src/services/suggest/signals.test.ts create mode 100644 apps/sidecar/src/services/suggest/signals.ts diff --git a/apps/sidecar/src/services/suggest/signals.test.ts b/apps/sidecar/src/services/suggest/signals.test.ts new file mode 100644 index 000000000..07c8a78e6 --- /dev/null +++ b/apps/sidecar/src/services/suggest/signals.test.ts @@ -0,0 +1,269 @@ +import { describe, expect, test } from "bun:test"; +import { + AUTOMATION_PATTERNS, + CORRECTION_PATTERNS, + extractSignals, + FOLLOWUP_PATTERNS, + hasStrongSignal, + isMeaningfulRule, + NEGATIVE_PATTERNS, + normalizeRule, + POSTPONE_PHRASES, + TODO_PATTERNS, + WEAK_INTENT_KEYS, +} from "./signals"; + +const um = (content: string) => [{ role: "user", content }] as const; + +// ===== Brief 契约测试(Task 3 行为合约) ===== + +describe("brief 契约: signals 核心行为", () => { + test("correction 模式命中 + confidence 0.95", () => { + const s = extractSignals(um("以后不要用 var 声明变量")); + expect(s.some((x) => x.kind === "correction")).toBe(true); + expect(s.find((x) => x.kind === "correction")!.confidence).toBe(0.95); + }); + + test("normalizeRule 剥离引导词但保留否定词", () => { + expect(normalizeRule("以后不要用 var")).toBe("不要用 var"); + expect(normalizeRule("请记住别再用 any")).toBe("别再用 any"); + }); + + test("NEGATIVE 整条短消息被标记拒绝(供 engine 用)", () => { + // extractSignals 不直接拒绝,但暴露 negative 标志 + const s = extractSignals(um("不用了")); + expect(s.some((x) => x.kind === "negative")).toBe(true); + }); + + test("POSTPONE 过滤掉 correction 尾巴", () => { + const s = extractSignals(um("以后注意代码风格,再聊")); + expect(s.some((x) => x.kind === "correction")).toBe(false); + }); + + test("repeat 跨 2 条消息同意图触发", () => { + const s = extractSignals([ + { role: "user", content: "帮我跑测试" }, + { role: "user", content: "帮我跑一下测试" }, + ]); + expect(s.some((x) => x.kind === "repeat")).toBe(true); + }); + + test("hasStrongSignal 快速路径", () => { + expect(hasStrongSignal("明天提醒我提交")).toBe(true); + expect(hasStrongSignal("你好")).toBe(false); + }); +}); + +// ===== 1:1 移植 Proma 的回归测试(验证 verbatim 移植) ===== + +describe("suggest/signals: 纠正信号", () => { + test("识别明确纠正 '以后不要 X'", () => { + const signals = extractSignals(um("以后不要用 setTimeout 写定时器")); + const correction = signals.find((s) => s.kind === "correction"); + expect(correction).toBeDefined(); + if (correction && correction.kind === "correction") { + expect(correction.confidence).toBeGreaterThan(0.9); + } + }); + + test("识别 '下次记得 X'", () => { + const signals = extractSignals(um("下次记得先查文档")); + expect(signals.some((s) => s.kind === "correction")).toBe(true); + }); + + test("识别 '我更喜欢 X'", () => { + const signals = extractSignals(um("我更喜欢用 TypeScript 而不是 JavaScript")); + expect(signals.some((s) => s.kind === "correction")).toBe(true); + }); + + test("过长文本不误报(纯描述无纠正词)", () => { + const signals = extractSignals(um("帮我写一个排序算法,要求稳定排序")); + expect(signals.some((s) => s.kind === "correction")).toBe(false); + }); + + test("'以后' 太短不触发(断片防护, raw.length<6 丢弃)", () => { + const signals = extractSignals(um("以后不要")); + expect(signals.some((s) => s.kind === "correction")).toBe(false); + }); + + test("'不要这样' 无意义内容不触发", () => { + const signals = extractSignals(um("不要这样")); + expect(signals.some((s) => s.kind === "correction")).toBe(false); + }); + + test("每条消息最多 1 个 correction 信号", () => { + // 单条消息同时命中多个 CORRECTION 正则,只产出 1 个 correction + const signals = extractSignals(um("以后不要用 var,下次记住别再用 any")); + const corrections = signals.filter((s) => s.kind === "correction"); + expect(corrections.length).toBe(1); + }); +}); + +describe("suggest/signals: 跟进/自动化/未完成", () => { + test("识别 '明天继续'", () => { + const signals = extractSignals(um("明天继续这个任务")); + expect(signals.some((s) => s.kind === "followup")).toBe(true); + }); + + test("识别 '稍后提醒我'", () => { + const signals = extractSignals(um("稍后提醒我提交代码")); + expect(signals.some((s) => s.kind === "followup")).toBe(true); + }); + + test("识别周期性需求 '每天自动总结'(automation 0.85)", () => { + const signals = extractSignals(um("每天自动帮我总结当天工作")); + const auto = signals.find((s) => s.kind === "automation"); + expect(auto).toBeDefined(); + expect(auto!.confidence).toBe(0.85); + }); + + test("识别未完成信号 '这个功能还没做完'(todo 0.72)", () => { + const signals = extractSignals(um("这个功能还没做完,回头再弄")); + const todo = signals.find((s) => s.kind === "todo"); + expect(todo).toBeDefined(); + expect(todo!.confidence).toBe(0.72); + }); + + test("'明天再说吧' 不触发 followup(推迟讨论不是任务)", () => { + const signals = extractSignals(um("明天再说吧")); + expect(signals.some((s) => s.kind === "followup")).toBe(false); + }); + + test("'还没' 断片不触发 todo(raw.length<4 丢弃)", () => { + const signals = extractSignals(um("还没")); + expect(signals.some((s) => s.kind === "todo")).toBe(false); + }); +}); + +describe("suggest/signals: 重复意图", () => { + test("同一意图出现 2 次识别为重复(count=2, confidence=0.6)", () => { + const signals = extractSignals([ + { role: "user", content: "帮我总结一下今天的工作" }, + { role: "user", content: "帮我总结一下项目进展" }, + ]); + const repeat = signals.find((s) => s.kind === "repeat"); + expect(repeat).toBeDefined(); + if (repeat && repeat.kind === "repeat") { + expect(repeat.count).toBe(2); + expect(repeat.confidence).toBe(0.6); + expect(repeat.messageIndexes.length).toBe(2); + } + }); + + test("不同意图不误判重复", () => { + const signals = extractSignals([ + { role: "user", content: "帮我写个排序" }, + { role: "user", content: "帮我画个图" }, + ]); + expect(signals.some((s) => s.kind === "repeat")).toBe(false); + }); + + test("弱意图 '帮我看看X'+'帮我看看Y' 不误判重复", () => { + const signals = extractSignals([ + { role: "user", content: "帮我看看这个文件" }, + { role: "user", content: "帮我看看那个配置" }, + ]); + expect(signals.some((s) => s.kind === "repeat")).toBe(false); + }); + + test("重复 3 次 confidence 升至 0.7(封顶 0.9)", () => { + const signals = extractSignals([ + { role: "user", content: "帮我部署测试环境" }, + { role: "user", content: "帮我部署预发环境" }, + { role: "user", content: "帮我部署生产环境" }, + ]); + const repeat = signals.find((s) => s.kind === "repeat"); + expect(repeat).toBeDefined(); + if (repeat && repeat.kind === "repeat") { + expect(repeat.count).toBe(3); + expect(repeat.confidence).toBe(0.7); + } + }); + + test("单条消息内重复不触发(需跨 ≥2 条消息)", () => { + const signals = extractSignals(um("帮我跑测试,帮我跑测试")); + // 同一 messageIndex 不应触发 repeat + const repeat = signals.find((s) => s.kind === "repeat"); + expect(repeat).toBeUndefined(); + }); +}); + +describe("suggest/signals: NEGATIVE 拒绝信号", () => { + test("'不用了' 短消息标记 negative", () => { + const signals = extractSignals(um("不用了")); + expect(signals.some((s) => s.kind === "negative")).toBe(true); + }); + + test("'算了' 标记 negative", () => { + const signals = extractSignals(um("算了")); + expect(signals.some((s) => s.kind === "negative")).toBe(true); + }); + + test("含拒绝词但主体是纠正的长消息仍提取纠正信号(不标记 negative)", () => { + // 长度 > 12 → 不是纯拒绝短句 + const signals = extractSignals(um("不用管那个 bug,以后写代码注意点")); + expect(signals.some((s) => s.kind === "negative")).toBe(false); + expect(signals.some((s) => s.kind === "correction")).toBe(true); + }); +}); + +describe("suggest/signals: POSTPONE 延后结束语", () => { + test("'以后再说吧' 不误判为纠正(延后≠纠正)", () => { + const signals = extractSignals(um("这个问题以后再说吧")); + expect(signals.some((s) => s.kind === "correction")).toBe(false); + }); +}); + +describe("suggest/signals: normalizeRule 否定词保留(P0 回归)", () => { + test("保留否定词('以后不要用 var' 不能变成 '用 var')", () => { + expect(normalizeRule("以后不要用 var 声明变量")).toBe("不要用 var 声明变量"); + expect(normalizeRule("下次别再用 var")).toBe("别再用 var"); + expect(normalizeRule("以后不要再写死路径")).toBe("不要再写死路径"); + }); + + test("去除尾标点", () => { + expect(normalizeRule("记住先查文档。")).toBe("先查文档"); + }); + + test("多层引导词剥离('请记住以后不要再...')", () => { + expect(normalizeRule("请记住以后不要用 any")).toBe("不要用 any"); + }); + + test("全部为引导词时回退原文", () => { + // 剥离后为空 → 回退 raw,避免返回空字符串 + expect(normalizeRule("以后")).toBe("以后"); + }); +}); + +describe("suggest/signals: isMeaningfulRule", () => { + test("长度 < 2 无效", () => { + expect(isMeaningfulRule("a")).toBe(false); + expect(isMeaningfulRule("")).toBe(false); + }); + + test("无意义残留词无效", () => { + expect(isMeaningfulRule("这样")).toBe(false); + expect(isMeaningfulRule("再说")).toBe(false); + expect(isMeaningfulRule("一下")).toBe(false); + expect(isMeaningfulRule("算了")).toBe(false); + }); + + test("有意义规则有效", () => { + expect(isMeaningfulRule("不要用 var")).toBe(true); + expect(isMeaningfulRule("先查文档")).toBe(true); + }); +}); + +describe("suggest/signals: 模式表完整性(1:1 移植校验)", () => { + test("所有模式表非空且为正则", () => { + expect(CORRECTION_PATTERNS.length).toBeGreaterThan(0); + expect(FOLLOWUP_PATTERNS.length).toBeGreaterThan(0); + expect(AUTOMATION_PATTERNS.length).toBeGreaterThan(0); + expect(TODO_PATTERNS.length).toBeGreaterThan(0); + expect(NEGATIVE_PATTERNS.length).toBeGreaterThan(0); + expect(POSTPONE_PHRASES.length).toBeGreaterThan(0); + expect(WEAK_INTENT_KEYS.length).toBeGreaterThan(0); + for (const re of CORRECTION_PATTERNS) expect(re).toBeInstanceOf(RegExp); + for (const re of FOLLOWUP_PATTERNS) expect(re).toBeInstanceOf(RegExp); + }); +}); diff --git a/apps/sidecar/src/services/suggest/signals.ts b/apps/sidecar/src/services/suggest/signals.ts new file mode 100644 index 000000000..397878f41 --- /dev/null +++ b/apps/sidecar/src/services/suggest/signals.ts @@ -0,0 +1,367 @@ +/** + * Suggestion 信号提取 — 从用户消息中提取结构化信号 + * + * 1:1 移植自 Proma `apps/electron/src/main/lib/suggest/signals.ts` (PR proma-ai/Proma#1409)。 + * 全部为确定性规则(不依赖 LLM),只对明确信号触发: + * - 用户亲口说"以后/下次/明天/记得"这类词(explicitness 高) + * - 重复行为模式 + * 模糊场景宁可不建议(对齐论文"该沉默时沉默")。 + * + * Lume 适配(相对 Proma 源): + * 1. 入参形状:Proma 接收 `string[]`,Lume 接收 `{role:"user"; content:string}[]` + * (brief 契约要求)。内部仍归约为 `string[]` 走 Proma 算法。 + * 2. NEGATIVE 暴露:Proma 命中"纯拒绝短句"后 `continue`(静默丢弃); + * Lume 改为产出 `negative` 信号,供 engine 层(Task 4)做"最近一条拒绝词"门判断。 + * 3. detectRepeatIntents:在 intentKey 切片前剥离 WEAK_INTENT_KEYS 子串, + * 使 "帮我跑测试" 与 "帮我跑一下测试" 归并为同一意图键 "跑测" + * (brief 契约测试要求;不破坏 Proma 既有 repeat/弱意图回归)。 + * 4. hasStrongSignal:Proma 接收 `string[]`;Lume 改为单条文本 `(text: string) => boolean` + * (brief 契约测试要求)。 + */ + +// ===== 信号模式表(verbatim from Proma) ===== + +/** 纠正信号:用户指出 Agent 的错误/改进(明确信号) */ +export const CORRECTION_PATTERNS = [ + /(?:以后|下次|记住|请记住|别再|不要|别再这样|希望你不要)[^。!?\n]{2,60}/, + /(?:不要|别)[^。!?\n]{0,20}(?:这样|这么做|用这种方式)[^。!?\n]{0,40}/, + /(?:我更喜欢|我更希望|我希望你(?:以后|下次))[^。!?\n]{2,60}/, +] as const; + +/** 跟进/时间表达信号:用户表达"稍后/明天/过一会"等延后意图 */ +export const FOLLOWUP_PATTERNS = [ + /(?:明天|稍后|过一会|过会儿|晚点|等会|待会|之后|回头|下次再)[^。!?\n]{0,30}(?:继续|做|弄|处理|看|说|再|提醒|提交|完成|弄完|整理|写|弄好)/, + /(?:继续|做|弄|处理|看|说|提醒)(?:明天|稍后|过一会|过会儿|晚点|等会|待会|之后|回头)/, +] as const; + +/** 自动化信号:用户表达重复性/周期性需求 */ +export const AUTOMATION_PATTERNS = [ + /(?:每天|每周|每月|定期|每天都要|每天自动)[^。!?\n]{2,50}/, + /(?:帮我盯|关注|跟进|监控|检查)[^。!?\n]{2,50}(?:每天|每周|状态|进展|更新)/, +] as const; + +/** 未完成信号:用户明确提及未完成任务/待办 */ +export const TODO_PATTERNS = [ + /(?:还差|还没|没做完|未完|剩下|待办|还没完成|待会再|回头再|之后再)[^。!?\n]{0,40}/, + /(?:这个任务|这件事|这个功能)(?:还没|未完|没做完|差一点|还差)/, +] as const; + +/** 明确拒绝词:当用户表现出不耐烦/不需要时,当轮不触发建议 */ +export const NEGATIVE_PATTERNS = [ + /(?:不用|不需要|别管|算了|不用了|没事|就这样|到此为止)/, +] as const; + +/** 延后结束语:用户只是推迟/结束话题,不是纠正或跟进任务 */ +export const POSTPONE_PHRASES = [ + /(?:再说|再聊|再看|再讨论|改天|回头再说|以后再说|以后聊|以后看|晚点再说|等会再说)/, +] as const; + +/** 弱意图词(repeat 检测跳过):"帮我看看 X"+"帮我看看 Y" 不应视为重复操作 */ +export const WEAK_INTENT_KEYS: readonly string[] = [ + "看看", + "一下", + "这个", + "那个", + "帮我", + "给我", + "帮我搞", + "弄下", +]; + +// ===== 信号结构 ===== + +export interface CorrectionSignal { + kind: "correction"; + /** 用户原始纠正语句 */ + raw: string; + /** 提炼后的行为规则 */ + rule: string; + /** 触发消息索引 */ + messageIndex: number; + confidence: number; +} + +export interface FollowupSignal { + kind: "followup"; + /** 触发消息 */ + raw: string; + messageIndex: number; + confidence: number; +} + +export interface AutomationSignal { + kind: "automation"; + /** 触发消息 */ + raw: string; + messageIndex: number; + confidence: number; +} + +export interface RepeatSignal { + kind: "repeat"; + /** 重复行为描述(同一意图出现次数) */ + intent: string; + /** 原始触发文本(Lume 适配:补齐 common 字段) */ + raw: string; + count: number; + messageIndexes: number[]; + confidence: number; +} + +export interface TodoSignal { + kind: "todo"; + /** 触发消息 */ + raw: string; + messageIndex: number; + confidence: number; +} + +/** NEGATIVE 拒绝信号(Lume 适配:暴露给 engine 层) */ +export interface NegativeSignal { + kind: "negative"; + /** 触发消息 */ + raw: string; + messageIndex: number; + confidence: number; +} + +export type Signal = + | CorrectionSignal + | FollowupSignal + | AutomationSignal + | RepeatSignal + | TodoSignal + | NegativeSignal; + +/** Lume 入参形状:仅用户消息 */ +export interface UserMessage { + role: "user"; + content: string; +} + +// ===== 提取实现 ===== + +/** + * 从用户消息中提取建议信号。 + * @param messages 用户消息(按时间序,仅 user 角色) + */ +export function extractSignals(messages: readonly UserMessage[]): Signal[] { + const userTexts = messages.map((m) => m.content); + return extractSignalsFromTexts(userTexts); +} + +/** 内部:基于纯文本数组的核心算法(1:1 移植 Proma + Lume 适配) */ +function extractSignalsFromTexts(userMessages: string[]): Signal[] { + const signals: Signal[] = []; + + for (let i = 0; i < userMessages.length; i++) { + const text = userMessages[i] ?? ""; + + // 明确拒绝信号:仅当整条消息就是拒绝(短句)时标记 negative, + // 避免"不用管那个bug,以后写代码注意点"这类含拒绝词但主体是纠正的消息被过度抑制。 + // engine 层的"最后一条含拒绝词"门已兜底。 + const cleanText = text.replace(/[,。!?\s]/g, ""); + const isPureRejection = cleanText.length <= 12 && NEGATIVE_PATTERNS.some((re) => re.test(text)); + if (isPureRejection) { + // Lume 适配:Proma 在此 `continue` 静默丢弃;Lume 暴露 negative 信号供 engine 决策。 + signals.push({ + kind: "negative", + raw: text, + messageIndex: i, + confidence: 0.9, + }); + continue; + } + + // 纠正信号(优先级最高,明确指令) + for (const re of CORRECTION_PATTERNS) { + const match = text.match(re); + if (match) { + const raw = match[0].trim(); + if (raw.length < 6) continue; // 至少要有"以后不要X"级别的信息量(防"以后不要"断片) + // 延后结束语不是纠正("以后再说吧"→ 不是"记住不要再说") + if (POSTPONE_PHRASES.some((p) => p.test(raw))) continue; + signals.push({ + kind: "correction", + raw, + rule: raw, + messageIndex: i, + confidence: 0.95, // 用户明确表达纠正,高置信 + }); + break; // 每条消息最多一个纠正信号 + } + } + + // 自动化信号(周期性需求) + for (const re of AUTOMATION_PATTERNS) { + const match = text.match(re); + if (match) { + signals.push({ + kind: "automation", + raw: match[0].trim(), + messageIndex: i, + confidence: 0.85, + }); + break; + } + } + + // 跟进信号(时间表达) + for (const re of FOLLOWUP_PATTERNS) { + const match = text.match(re); + if (match) { + const raw = match[0].trim(); + // 推迟讨论("明天再说吧")不是需要提醒的跟进任务 + if (POSTPONE_PHRASES.some((p) => p.test(raw))) continue; + signals.push({ + kind: "followup", + raw, + messageIndex: i, + confidence: 0.8, + }); + break; + } + } + + // 未完成信号(明确提及待办) + for (const re of TODO_PATTERNS) { + const match = text.match(re); + if (match) { + const raw = match[0].trim(); + if (raw.length < 4) continue; // 防"还没"断片 + signals.push({ + kind: "todo", + raw, + messageIndex: i, + confidence: 0.72, + }); + break; + } + } + } + + // 重复行为检测:同一意图词出现 ≥2 次(跨消息) + const repeatIntents = detectRepeatIntents(userMessages); + signals.push(...repeatIntents); + + return signals; +} + +/** 重复意图检测:识别同一意图词在多条消息中反复出现 */ +function detectRepeatIntents(userMessages: string[]): RepeatSignal[] { + const intentCounts = new Map(); + + // Lume 适配:按长度降序,避免 "帮我" 先于 "帮我搞" 被部分剥离 + const weakKeysDesc = [...WEAK_INTENT_KEYS].sort((a, b) => b.length - a.length); + const weakStripRe = new RegExp(weakKeysDesc.join("|"), "g"); + + for (let i = 0; i < userMessages.length; i++) { + const text = userMessages[i] ?? ""; + // 提取意图核心词("帮我 X" 中的 X) + const intentMatch = text.match(/(?:帮我|请|麻烦|能不能|可以)([^,。!?\n]{2,24})/); + if (!intentMatch) continue; + const intentGroup = intentMatch[1]; + if (!intentGroup) continue; + const intent = intentGroup.trim(); + if (intent.length < 2 || intent.length > 24) continue; + // 忽略纯疑问词 + if (/^(这个|那个|一下|看看|什么|怎么|为什么)$/.test(intent)) continue; + + // 归一化意图键:取前 2 字(中文意图核心动词通常在前), + // 使"总结今天的工作"与"总结一下进展"归为同一意图"总结" + let intentKey = intent.slice(0, 2); + // 弱意图词(看看/一下/这个/那个)不构成可自动化操作的重复行为 + if (WEAK_INTENT_KEYS.includes(intentKey)) continue; + if (/^(一下|这个|那个|帮我)$/.test(intentKey)) continue; + + // Lume 适配:剥离意图文本中的弱修饰词(如"一下"),再重新切片。 + // 使 "帮我跑一下测试" 与 "帮我跑测试" 归并为同一意图键 "跑测"。 + const stripped = intent.replace(weakStripRe, "").trim(); + if (stripped.length >= 2) { + const strippedKey = stripped.slice(0, 2); + // 剥离后再次过滤弱意图键 + if (!WEAK_INTENT_KEYS.includes(strippedKey) && !/^(一下|这个|那个|帮我)$/.test(strippedKey)) { + intentKey = strippedKey; + } + } + + const existing = intentCounts.get(intentKey); + if (existing) { + existing.count += 1; + existing.indexes.push(i); + } else { + intentCounts.set(intentKey, { count: 1, indexes: [i], intent: intentGroup }); + } + } + + const signals: RepeatSignal[] = []; + for (const [key, entry] of intentCounts) { + if (entry.count >= 2 && entry.indexes.length >= 2) { + signals.push({ + kind: "repeat", + intent: entry.intent ?? key, + raw: entry.intent ?? key, + count: entry.count, + messageIndexes: entry.indexes, + // 重复次数越多越可信,但封顶 0.9 + confidence: Math.min(0.6 + (entry.count - 2) * 0.1, 0.9), + }); + } + } + return signals; +} + +/** 规范化纠正规则:去掉句首引导词,提炼为可执行的规则文本 */ +export function normalizeRule(raw: string): string { + let rule = raw; + // 连续去掉句首引导词(支持多层,如"以后不要再")。 + // 注意:否定词(不要/别再/别)是规则的核心语义,绝不能删—— + // "以后不要用 var" 提炼后必须是 "不要用 var",而不是 "用 var"(语义反转 bug)。 + const LEADERS = [ + /^请记住/, + /^我希望你/, + /^我希望/, + /^我更喜欢/, + /^我更倾向/, + /^以后/, + /^下次/, + /^记住/, + /^麻烦(?:你)?/, + ]; + let changed = true; + while (changed) { + changed = false; + for (const re of LEADERS) { + if (re.test(rule)) { + rule = rule.replace(re, "").trim(); + changed = true; + } + } + } + if (!rule) rule = raw; + // 去尾标点 + rule = rule.replace(/[。!?]+$/, ""); + return rule; +} + +/** 规则是否有效(有实际可执行内容,不是无意义残留) */ +export function isMeaningfulRule(rule: string): boolean { + const trimmed = rule.trim(); + if (trimmed.length < 2) return false; + // 无意义残留词 + if (/^(这样|那样|再说|再聊|再说吧|而已|罢了|好了|算了|没事|这个|那个|一下)$/.test(trimmed)) + return false; + return true; +} + +/** + * 是否为明确触发词(供 orchestrator 快速判断是否需要评估)。 + * Lume 适配:签名从 Proma 的 `(userMessages: string[])` 收窄为单条文本。 + */ +export function hasStrongSignal(text: string): boolean { + if (CORRECTION_PATTERNS.some((re) => re.test(text))) return true; + if (FOLLOWUP_PATTERNS.some((re) => re.test(text))) return true; + if (AUTOMATION_PATTERNS.some((re) => re.test(text))) return true; + if (TODO_PATTERNS.some((re) => re.test(text))) return true; + return false; +} From 92461894ad3693924d95e56aae1cecf49c6da42e Mon Sep 17 00:00:00 2001 From: Leo Date: Tue, 4 Aug 2026 00:03:39 +0800 Subject: [PATCH 05/24] =?UTF-8?q?=E2=9C=A8=20feat(sidecar):=20=E5=BB=BA?= =?UTF-8?q?=E8=AE=AE=E8=A7=84=E5=88=99=E5=BC=95=E6=93=8E=EF=BC=885=20?= =?UTF-8?q?=E7=B1=BB=20+=20skill=20=E5=90=8E=E5=A4=84=E7=90=86=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/services/suggest/rules.test.ts | 216 ++++++++++++++ apps/sidecar/src/services/suggest/rules.ts | 266 ++++++++++++++++++ 2 files changed, 482 insertions(+) create mode 100644 apps/sidecar/src/services/suggest/rules.test.ts create mode 100644 apps/sidecar/src/services/suggest/rules.ts diff --git a/apps/sidecar/src/services/suggest/rules.test.ts b/apps/sidecar/src/services/suggest/rules.test.ts new file mode 100644 index 000000000..ec1c99a4b --- /dev/null +++ b/apps/sidecar/src/services/suggest/rules.test.ts @@ -0,0 +1,216 @@ +import { describe, expect, test } from "bun:test"; +import { + applyRules, + automationTitleFromRaw, + buildSkillCandidate, + loadDedupContext, + REPEAT_THRESHOLD, + SOP_CANDIDATE_THRESHOLD, +} from "./rules"; +import type { + AutomationSignal, + CorrectionSignal, + FollowupSignal, + NegativeSignal, + RepeatSignal, + Signal, + TodoSignal, +} from "./signals"; + +const correction = (raw: string, confidence = 0.95): CorrectionSignal => ({ + kind: "correction", + raw, + rule: raw, + messageIndex: 0, + confidence, +}); + +const followup = (raw: string): FollowupSignal => ({ + kind: "followup", + raw, + messageIndex: 0, + confidence: 0.8, +}); + +const automation = (raw: string): AutomationSignal => ({ + kind: "automation", + raw, + messageIndex: 0, + confidence: 0.85, +}); + +const repeat = (intent: string, count: number): RepeatSignal => ({ + kind: "repeat", + intent, + raw: intent, + count, + messageIndexes: [], + confidence: 0.7, +}); + +const todo = (raw: string): TodoSignal => ({ + kind: "todo", + raw, + messageIndex: 0, + confidence: 0.72, +}); + +const negative = (raw: string): NegativeSignal => ({ + kind: "negative", + raw, + messageIndex: 0, + confidence: 0.9, +}); + +const ctx = (overrides: Partial<{ + signals: Signal[]; + automationTitles: string[]; + correctionRules: string[]; + sopCandidateCount: number; +}> = {}) => ({ + signals: [], + automationTitles: [], + correctionRules: [], + sopCandidateCount: 0, + ...overrides, +}); + +// ===== Brief 契约测试 ===== + +describe("brief 契约: rules 核心行为", () => { + test("correction 信号 → memory_correction 候选 + duplicateKey", () => { + const out = applyRules(ctx({ signals: [correction("以后不要用 var")] })); + expect(out).toHaveLength(1); + expect(out[0]!.kind).toBe("correction"); + expect(out[0]!.action.type).toBe("memory_correction"); + // normalizeRule("以后不要用 var") = "不要用 var";rule 部分截断 30 字 + expect(out[0]!.duplicateKey).toBe(`correction:${"不要用 var".slice(0, 30)}`); + }); + + test("automation 信号去重已有 automation 标题", () => { + // automationTitleFromRaw("每天自动拉取数据") = "拉取数据";已有 "每天拉取数据" 包含它 → 去重 + const out = applyRules( + ctx({ signals: [automation("每天自动拉取数据")], automationTitles: ["每天拉取数据"] }), + ); + expect(out).toHaveLength(0); + }); + + test("skill 候选仅当 sop ≥ SOP_CANDIDATE_THRESHOLD", () => { + expect(buildSkillCandidate(SOP_CANDIDATE_THRESHOLD - 1)).toBeUndefined(); + expect(buildSkillCandidate(SOP_CANDIDATE_THRESHOLD)?.kind).toBe("skill"); + }); +}); + +// ===== 5 类规则逐项验证 ===== + +describe("applyRules: 5 类规则", () => { + test("correction: 已有相同 correction 规则 → 去重", () => { + const out = applyRules( + ctx({ signals: [correction("以后不要用 var")], correctionRules: ["不要用 var"] }), + ); + expect(out).toHaveLength(0); + }); + + test("correction: 无意义规则(『这样』)不产生候选", () => { + // normalizeRule("以后这样") = "这样" → isMeaningfulRule=false + const out = applyRules(ctx({ signals: [correction("以后这样")] })); + expect(out).toHaveLength(0); + }); + + test("followup → open_automation_create 候选", () => { + const out = applyRules(ctx({ signals: [followup("明天继续处理")] })); + expect(out).toHaveLength(1); + expect(out[0]!.kind).toBe("followup"); + expect(out[0]!.action.type).toBe("open_automation_create"); + expect(out[0]!.duplicateKey).toBe(`followup:${"明天继续处理".slice(0, 24)}`); + }); + + test("automation → 候选 + 标题来自 automationTitleFromRaw", () => { + const out = applyRules(ctx({ signals: [automation("每天自动拉取数据")] })); + expect(out).toHaveLength(1); + expect(out[0]!.kind).toBe("automation"); + expect(out[0]!.action.type).toBe("open_automation_create"); + // duplicateKey 用 automationTitleFromRaw 结果 + expect(out[0]!.duplicateKey).toBe(`automation:${automationTitleFromRaw("每天自动拉取数据")}`); + }); + + test(`repeat: count >= REPEAT_THRESHOLD(${REPEAT_THRESHOLD}) → "定期{intent}" 候选`, () => { + const out = applyRules(ctx({ signals: [repeat("跑测试", 2)] })); + expect(out).toHaveLength(1); + expect(out[0]!.kind).toBe("automation"); + expect(out[0]!.duplicateKey).toBe("automation:定期跑测试"); + }); + + test(`repeat: count < REPEAT_THRESHOLD → 不产生候选`, () => { + const out = applyRules(ctx({ signals: [repeat("跑测试", 1)] })); + expect(out).toHaveLength(0); + }); + + test("repeat: 已有包含 intent 的 automation 标题 → 去重", () => { + const out = applyRules( + ctx({ signals: [repeat("跑测试", 2)], automationTitles: ["定期跑测试"] }), + ); + expect(out).toHaveLength(0); + }); + + test("todo → open_memory_board 候选", () => { + const out = applyRules(ctx({ signals: [todo("还没完成报告")] })); + expect(out).toHaveLength(1); + expect(out[0]!.kind).toBe("todo"); + expect(out[0]!.action.type).toBe("open_memory_board"); + expect(out[0]!.duplicateKey).toBe(`todo:${"还没完成报告".slice(0, 20)}`); + }); + + test("negative 信号被忽略(engine 层处理拒绝)", () => { + const out = applyRules(ctx({ signals: [negative("不用了")] })); + expect(out).toHaveLength(0); + }); + + test("多信号混合 → 按序输出", () => { + const out = applyRules( + ctx({ signals: [correction("以后不要用 var"), followup("明天继续处理"), todo("还没完成报告")] }), + ); + expect(out.map((c) => c.kind)).toEqual(["correction", "followup", "todo"]); + }); +}); + +// ===== 辅助函数 ===== + +describe("automationTitleFromRaw", () => { + test("剥离句首周期词 + 帮我/请 + 盯/关注 + 尾标点", () => { + expect(automationTitleFromRaw("每天自动拉取数据")).toBe("拉取数据"); + expect(automationTitleFromRaw("每天都要检查状态")).toBe("状态"); + expect(automationTitleFromRaw("帮我跟进进展")).toBe("进展"); + expect(automationTitleFromRaw("定期监控日志,")).toBe("日志"); + }); + + test("≤24 字(超长截断)", () => { + const long = "每天自动执行一个非常有意义的超长任务名称需要被截断处理才行"; + expect(automationTitleFromRaw(long).length).toBe(24); + }); + + test("全被剥光时回退到原文前 20 字", () => { + // "帮我盯一下" 全部被剥光 → 回退 raw.slice(0,20) + expect(automationTitleFromRaw("帮我盯一下")).toBe("帮我盯一下".slice(0, 20)); + }); +}); + +// ===== loadDedupContext(Lume 适配桥接层) ===== + +describe("loadDedupContext", () => { + test("返回结构正确(fail-open:空存储 → 空数组 + 0)", () => { + const out = loadDedupContext({}); + expect(Array.isArray(out.automationTitles)).toBe(true); + expect(Array.isArray(out.correctionRules)).toBe(true); + expect(typeof out.sopCandidateCount).toBe("number"); + }); + + test("任意 workspace 都不抛错(fail-open:底层异常被吞,返回有效 shape)", () => { + // 不存在的 workspace 不应让函数抛出;automation 为全局列表(可能非空), + // memory-v2 含 global 范围(可能非空)。关键是稳定不抛错 + 结构正确。 + const out = loadDedupContext({ workspaceSlug: "__nonexistent__" }); + expect(Array.isArray(out.automationTitles)).toBe(true); + expect(Array.isArray(out.correctionRules)).toBe(true); + expect(typeof out.sopCandidateCount).toBe("number"); + }); +}); diff --git a/apps/sidecar/src/services/suggest/rules.ts b/apps/sidecar/src/services/suggest/rules.ts new file mode 100644 index 000000000..aa49e3733 --- /dev/null +++ b/apps/sidecar/src/services/suggest/rules.ts @@ -0,0 +1,266 @@ +/** + * Suggestion 确定性规则 — 把信号转成建议候选 + * + * 1:1 移植自 Proma `apps/electron/src/main/lib/suggest/rules.ts` (PR proma-ai/Proma#1409)。 + * 5 类规则: + * - correction:用户纠正 → 记住这个纠正(动作:写入 memory correction) + * - followup:时间表达 → 创建跟进提醒(动作:打开 automation 创建) + * - automation:重复行为/周期需求 → 建议开启定时任务 + * - repeat:同一意图 ≥2 次 → 建议定期自动化 + * - todo:明确未完成任务 → 建议创建 Todo + * + * 全部只读本地确定性信号,不依赖 LLM。 + * + * Lume 适配(相对 Proma 源): + * 1. 入参形状:Proma 的 applyRules 接收 `userMessages: string[]` 内部调 extractSignals; + * Lume 改为接收已抽取的 `signals: Signal[]`(Task 3 输出),规则层不再做抽取。 + * 2. negative 信号:Proma 在 extractSignals 内 `continue` 静默丢弃;Lume 暴露 negative 信号, + * 本规则层的 default 分支自然忽略(engine 层负责"最近拒绝词"门判断)。 + * 3. dedup 源:Proma 读自家 automation/corrections/sop;Lume 的 loadDedupContext 桥接到 + * automation-manager.listAutomationJobs + memory-v2 markdown-store。 + * 4. 输出形状:Proma 返回 `{candidate}[]`(RuleMatch 包装);Lume 直接返回 SuggestionCandidate[] + * 以匹配 brief 契约(engine 直接消费扁平候选)。 + */ + +import type { SuggestionCandidate } from "@lume/shared"; +import type { Signal } from "./signals"; +import { isMeaningfulRule, normalizeRule } from "./signals"; +import { listAutomationJobs } from "../automation/automation-manager"; +import { listEntries, listPending } from "../memory-v2/markdown-store"; + +/** SOP 候选数量阈值:达到后建议沉淀为 Skill */ +export const SOP_CANDIDATE_THRESHOLD = 3; + +/** 重复行为阈值:同一意图 ≥2 次建议 automation */ +export const REPEAT_THRESHOLD = 2; + +/** applyRules 输入上下文 */ +export interface ApplyRulesContext { + /** Task 3 extractSignals 已抽取的信号 */ + signals: Signal[]; + /** 已有 automation 任务标题(去重用) */ + automationTitles: string[]; + /** 已有 correction 规则文本(去重用,containment 判断) */ + correctionRules: string[]; + /** + * SOP/state 候选计数。applyRules 本身不使用(skill 候选由 buildSkillCandidate + * 单独生成,engine 在后处理合并);此字段由 ctx 透传便于 engine 统一装配上下文。 + */ + sopCandidateCount: number; +} + +/** loadDedupContext 输入 */ +export interface DedupContextInput { + workspaceSlug?: string; +} + +/** loadDedupContext 输出 */ +export interface DedupContext { + automationTitles: string[]; + correctionRules: string[]; + sopCandidateCount: number; +} + +/** + * 执行规则集:从信号 + 上下文生成建议候选。 + * 逐信号映射,去重命中即跳过(不做全局合并,留给 engine)。 + */ +export function applyRules(ctx: ApplyRulesContext): SuggestionCandidate[] { + const candidates: SuggestionCandidate[] = []; + for (const signal of ctx.signals) { + const candidate = signalToCandidate(signal, ctx); + if (candidate) candidates.push(candidate); + } + return candidates; +} + +/** 单条信号 → 候选(verbatim 移植 Proma signalToCandidate;去重交给 engine 兜底) */ +function signalToCandidate(signal: Signal, ctx: ApplyRulesContext): SuggestionCandidate | undefined { + switch (signal.kind) { + case "correction": { + const rule = normalizeRule(signal.raw); + // 无意义规则("这样"/"再说")不产生建议 + if (!isMeaningfulRule(rule)) return undefined; + // 去重:已有相同/相似 correction 规则不再建议(containment 双向判断) + const existing = ctx.correctionRules.some((r) => r === rule || r.includes(rule) || rule.includes(r)); + if (existing) return undefined; + + return { + duplicateKey: `correction:${rule.slice(0, 30)}`, + kind: "correction", + title: "记住这个纠正", + reason: "你刚刚纠正了 Agent 的行为,建议把这条规则写入长期记忆,以后不再犯同样的错。", + evidence: signal.raw, + rawConfidence: signal.confidence, + action: { + type: "memory_correction", + raw: signal.raw, + rule, + }, + }; + } + + case "followup": { + return { + duplicateKey: `followup:${signal.raw.slice(0, 24)}`, + kind: "followup", + title: "创建跟进提醒", + reason: "你提到了稍后继续,建议创建一个跟进提醒,到时间自动提示你继续这个任务。", + evidence: signal.raw, + rawConfidence: signal.confidence, + action: { + type: "open_automation_create", + automationTitle: "跟进提醒", + suggestedPrompt: `提醒我:${signal.raw}`, + }, + }; + } + + case "automation": { + // 去重:已有同类 automation 任务不再建议 + const title = automationTitleFromRaw(signal.raw); + const existing = ctx.automationTitles.some( + (t) => t === title || t.includes(title) || title.includes(t), + ); + if (existing) return undefined; + + return { + duplicateKey: `automation:${title}`, + kind: "automation", + title: "开启定时任务", + reason: "你表达的是周期性/长期关注的需求,建议创建一个定时任务,让 Agent 无人值守地自动处理。", + evidence: signal.raw, + rawConfidence: signal.confidence, + action: { + type: "open_automation_create", + automationTitle: title, + suggestedPrompt: `${title}(定期自动执行)`, + }, + }; + } + + case "repeat": { + if (signal.count < REPEAT_THRESHOLD) return undefined; + const title = `定期${signal.intent}`; + const existing = ctx.automationTitles.some( + (t) => t === title || t.includes(signal.intent) || signal.intent.includes(t), + ); + if (existing) return undefined; + + return { + duplicateKey: `automation:${title}`, + kind: "automation", + title: "把重复操作变成定时任务", + reason: `你在本次会话中${signal.count}次要求"${signal.intent}",建议创建一个定时任务自动完成,省去重复操作。`, + evidence: `重复出现 ${signal.count} 次:"${signal.intent}"`, + rawConfidence: signal.confidence, + action: { + type: "open_automation_create", + automationTitle: title, + suggestedPrompt: `定期执行:${signal.intent}`, + }, + }; + } + + case "todo": { + return { + duplicateKey: `todo:${signal.raw.slice(0, 20)}`, + kind: "todo", + title: "把未完成任务记下来", + reason: "你提到了未完成的事项,建议创建一个 Todo 记录,避免遗漏。", + evidence: signal.raw, + rawConfidence: signal.confidence, + action: { + type: "open_memory_board", + }, + }; + } + + // Lume 适配:negative 信号(用户明确拒绝)由 engine 层做"最近拒绝词"门判断, + // 规则层不产生候选。 + default: + return undefined; + } +} + +/** SOP 候选 → Skill 建议(由 engine 在候选后处理中调用) */ +export function buildSkillCandidate(sopCount: number): SuggestionCandidate | undefined { + if (sopCount < SOP_CANDIDATE_THRESHOLD) return undefined; + return { + duplicateKey: `skill:sop-candidates`, + kind: "skill", + title: "把常用流程沉淀为 Skill", + reason: `长期记忆中已积累 ${sopCount} 条可复用流程(SOP),建议把它们整理成 Skill,以后一句话即可复用。`, + evidence: `${sopCount} 条 SOP 候选`, + rawConfidence: 0.75, + action: { + type: "open_skill_creator", + topic: "SOP 流程沉淀", + }, + }; +} + +/** + * 从自动化信号原始文本提炼任务标题(verbatim 移植 Proma)。 + * 剥离句首周期词 / 请求词 / 盯类动词 / 尾标点;超长截断到 24 字; + * 全被剥光时回退到原文前 20 字。 + */ +export function automationTitleFromRaw(raw: string): string { + let title = raw + .replace(/^(每天自动|每天都要|每天|每周|每月|定期)/, "") + .replace(/^(帮我|请|麻烦|能不能|可以)/, "") + .replace(/(帮我)?(盯|关注|跟进|监控|检查)(一下)?/, "") + .replace(/[,。!?\n]+$/, "") + .trim(); + if (!title) title = raw.slice(0, 20); + return title.length > 24 ? title.slice(0, 24) : title; +} + +/** + * 桥接 Lume 去重源(fail-open:任一源失败 → 该源空,不抛错)。 + * + * Lume API 映射(spec §"Lume adaptation — dedup sources"): + * - automationTitles:`listAutomationJobs().map(j => j.name)`(automation-manager.ts:94) + * - correctionRules:memory-v2 中带 `correction` tag 的 entry/pending statement + * (markdown-store.ts:listEntries/listPending)。最简合理映射:tag 含 "correction"。 + * - sopCandidateCount:memory-v2 中 `kind === "state"` 的 active entry 计数 + * (spec: Proma sop → Lume state)。 + * + * 顾虑(flagged):correction tag 的精确语义在 memory-v2 中没有强约束(tags 是自由字符串数组), + * 此处采用"tag 包含 correction 字面量"的最简判断;若后续 memory-v2 引入结构化 correction kind, + * 应改用结构化字段。 + */ +export function loadDedupContext(input: DedupContextInput = {}): DedupContext { + const workspaceSlug = input.workspaceSlug; + + // automation 标题 + let automationTitles: string[] = []; + try { + automationTitles = listAutomationJobs().map((j) => j.name); + } catch { + automationTitles = []; + } + + // memory-v2 entries(active)+ pending(open):correction 规则 + sop/state 计数 + let correctionRules: string[] = []; + let sopCandidateCount = 0; + try { + const entries = listEntries({ workspaceSlug, includeStatuses: ["active"] }); + const pending = listPending({ workspaceSlug, includeStatuses: ["open"] }); + + const fromEntries = entries + .filter((e) => e.frontmatter.tags.includes("correction")) + .map((e) => e.statement); + const fromPending = pending + .filter((p) => p.frontmatter.candidate.tags?.includes("correction")) + .map((p) => p.frontmatter.candidate.statement); + correctionRules = [...fromEntries, ...fromPending]; + + sopCandidateCount = entries.filter((e) => e.frontmatter.kind === "state").length; + } catch { + correctionRules = []; + sopCandidateCount = 0; + } + + return { automationTitles, correctionRules, sopCandidateCount }; +} From 0d2927dfe7c58f49e79600afb4b942a5338e31a5 Mon Sep 17 00:00:00 2001 From: Leo Date: Tue, 4 Aug 2026 00:11:16 +0800 Subject: [PATCH 06/24] =?UTF-8?q?=E2=9C=A8=20feat(sidecar):=20=E5=BB=BA?= =?UTF-8?q?=E8=AE=AE=E5=86=B3=E7=AD=96=E5=BC=95=E6=93=8E=20+=20=E8=AF=AF?= =?UTF-8?q?=E6=8A=A5=E6=8E=A7=E5=88=B6=EF=BC=88=E9=98=88=E5=80=BC/?= =?UTF-8?q?=E9=A2=84=E7=AE=97/=E6=8B=92=E7=BB=9D=E9=97=A8=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/services/suggest/engine.test.ts | 200 +++++++++++++++++ apps/sidecar/src/services/suggest/engine.ts | 212 ++++++++++++++++++ 2 files changed, 412 insertions(+) create mode 100644 apps/sidecar/src/services/suggest/engine.test.ts create mode 100644 apps/sidecar/src/services/suggest/engine.ts diff --git a/apps/sidecar/src/services/suggest/engine.test.ts b/apps/sidecar/src/services/suggest/engine.test.ts new file mode 100644 index 000000000..73828048f --- /dev/null +++ b/apps/sidecar/src/services/suggest/engine.test.ts @@ -0,0 +1,200 @@ +import { describe, expect, test } from "bun:test"; +import { + DEFAULT_SUGGEST_OPTIONS, + defaultTypeWeights, + evaluateSuggestions, +} from "./engine"; +import type { SuggestionTypeWeights } from "@lume/shared"; + +const um = (content: string) => [{ role: "user", content }] as const; + +const fullWeights: SuggestionTypeWeights = { + correction: 1, + followup: 1, + automation: 1, + skill: 0.8, + todo: 0.9, +}; + +describe("evaluateSuggestions — 误报控制", () => { + test("明确拒绝门:最后一条含 NEGATIVE → 整轮空", () => { + const out = evaluateSuggestions( + [...um("以后注意代码风格"), ...um("不用了")], + { + maxPerSession: 2, + seenKeys: new Set(), + typeWeights: fullWeights, + }, + ); + expect(out.candidates).toHaveLength(0); + expect(out.suppressed).toHaveLength(0); + }); + + test("threshold 过滤:effective < 0.6 进 suppressed", () => { + // todo rawConfidence 0.72 × weight 0.1 = 0.072 < 0.6 → suppressed + const lowTodo: SuggestionTypeWeights = { ...fullWeights, todo: 0.1 }; + const out = evaluateSuggestions(um("还差一点没做完"), { + maxPerSession: 2, + seenKeys: new Set(), + typeWeights: lowTodo, + }); + expect(out.suppressed.length + out.candidates.length).toBeGreaterThan(0); + expect(out.candidates.find((c) => c.kind === "todo")).toBeUndefined(); + const todoSuppressed = out.suppressed.find((s) => s.candidate.kind === "todo"); + expect(todoSuppressed).toBeDefined(); + expect(todoSuppressed?.reason).toContain("置信度不足"); + }); + + test("maxPerEvaluation=1:多候选只取最高 effective 1 条", () => { + const out = evaluateSuggestions(um("以后不要用 var,明天提醒我提交"), { + maxPerSession: 2, + seenKeys: new Set(), + typeWeights: fullWeights, + }); + expect(out.candidates.length).toBeLessThanOrEqual(1); + // correction eff 0.95 > followup eff 0.8 → 取 correction + expect(out.candidates[0]?.kind).toBe("correction"); + }); + + test("默认 maxPerEvaluation=1 隐式截断", () => { + // 不传 maxPerEvaluation,应使用默认 1 + const out = evaluateSuggestions(um("以后不要用 var,明天提醒我提交"), { + seenKeys: new Set(), + typeWeights: fullWeights, + }); + expect(out.candidates).toHaveLength(1); + }); +}); + +describe("evaluateSuggestions — 去重四连", () => { + test("seenKeys:同会话已建议过 → suppressed", () => { + // 先拿到一个 correction 候选的 duplicateKey + const probe = evaluateSuggestions(um("以后不要用 var"), { + seenKeys: new Set(), + typeWeights: fullWeights, + }); + const key = probe.candidates[0]?.duplicateKey; + expect(key).toBeDefined(); + + const out = evaluateSuggestions(um("以后不要用 var"), { + seenKeys: new Set([key!]), + typeWeights: fullWeights, + }); + expect(out.candidates).toHaveLength(0); + expect(out.suppressed[0]?.reason).toContain("同会话已建议过"); + }); + + test("neverKeys:用户永久屏蔽 → suppressed", () => { + const probe = evaluateSuggestions(um("以后不要用 var"), { + seenKeys: new Set(), + typeWeights: fullWeights, + }); + const key = probe.candidates[0]?.duplicateKey!; + const out = evaluateSuggestions(um("以后不要用 var"), { + seenKeys: new Set(), + neverKeys: new Set([key]), + typeWeights: fullWeights, + }); + expect(out.candidates).toHaveLength(0); + expect(out.suppressed[0]?.reason).toContain("不再建议"); + }); + + test("silencedKinds:该类型已被用户静默 → suppressed", () => { + const out = evaluateSuggestions(um("还差一点没做完"), { + seenKeys: new Set(), + silencedKinds: new Set(["todo"]), + typeWeights: fullWeights, + }); + expect(out.candidates.find((c) => c.kind === "todo")).toBeUndefined(); + expect(out.suppressed.find((s) => s.candidate.kind === "todo")?.reason).toContain("静默"); + }); + + test("同次评估内重复候选 → suppressed", () => { + // 同一意图重复 2 次(repeat signal)+ correction 可能产生同 duplicateKey 的 automation 候选 + // 用两条产生相同 duplicateKey 的消息构造 + const out = evaluateSuggestions( + [...um("帮我跑一下测试"), ...um("帮我跑测试"), ...um("帮我跑一下测试")], + { + seenKeys: new Set(), + typeWeights: fullWeights, + }, + ); + // 至少有一条进 suppressed(重复候选),且 candidates 中无同 key 重复 + const candidateKeys = out.candidates.map((c) => c.duplicateKey); + expect(new Set(candidateKeys).size).toBe(candidateKeys.length); + }); +}); + +describe("evaluateSuggestions — 边界", () => { + test("空 user 消息 → 空", () => { + const out = evaluateSuggestions( + [{ role: "user", content: " " }], + { seenKeys: new Set(), typeWeights: fullWeights }, + ); + expect(out.candidates).toHaveLength(0); + expect(out.suppressed).toHaveLength(0); + }); + + test("无 user 消息 → 空", () => { + const out = evaluateSuggestions([], { seenKeys: new Set(), typeWeights: fullWeights }); + expect(out.candidates).toHaveLength(0); + }); + + test("无强信号 → 0 候选(不触发建议)", () => { + const out = evaluateSuggestions(um("随便聊聊"), { + seenKeys: new Set(), + typeWeights: fullWeights, + }); + expect(out.candidates).toHaveLength(0); + }); + + test("skill 候选受 sopCandidateCount 驱动(sop≥3 触发,<3 不触发)", () => { + // 用非触发消息让 skill 成为唯一候选(避免被 correction 等高分候选挤出预算) + const trigger = evaluateSuggestions(um("随便聊聊"), { + seenKeys: new Set(), + typeWeights: fullWeights, + dedupContext: { + automationTitles: [], + correctionRules: [], + sopCandidateCount: 5, // >= SOP_CANDIDATE_THRESHOLD(3) → 触发 skill + }, + }); + expect(trigger.candidates.find((c) => c.kind === "skill")).toBeDefined(); + + const noTrigger = evaluateSuggestions(um("随便聊聊"), { + seenKeys: new Set(), + typeWeights: fullWeights, + dedupContext: { + automationTitles: [], + correctionRules: [], + sopCandidateCount: 1, // < 阈值 → 不触发 skill + }, + }); + expect(noTrigger.candidates.find((c) => c.kind === "skill")).toBeUndefined(); + }); +}); + +describe("默认参数", () => { + test("DEFAULT_SUGGEST_OPTIONS 常量精确", () => { + expect(DEFAULT_SUGGEST_OPTIONS.threshold).toBe(0.6); + expect(DEFAULT_SUGGEST_OPTIONS.maxPerEvaluation).toBe(1); + expect(DEFAULT_SUGGEST_OPTIONS.maxPerSession).toBe(2); + }); + + test("defaultTypeWeights 初始权重", () => { + const w = defaultTypeWeights(); + expect(w.correction).toBe(1.0); + expect(w.followup).toBe(1.0); + expect(w.automation).toBe(1.0); + expect(w.skill).toBe(0.8); + expect(w.todo).toBe(0.9); + }); + + test("todo 默认权重不会死锁(0.72 × 0.9 = 0.648 > 0.6)", () => { + const out = evaluateSuggestions(um("还差一点没做完"), { + seenKeys: new Set(), + typeWeights: defaultTypeWeights(), + }); + expect(out.candidates.find((c) => c.kind === "todo")).toBeDefined(); + }); +}); diff --git a/apps/sidecar/src/services/suggest/engine.ts b/apps/sidecar/src/services/suggest/engine.ts new file mode 100644 index 000000000..bb6ed8c02 --- /dev/null +++ b/apps/sidecar/src/services/suggest/engine.ts @@ -0,0 +1,212 @@ +/** + * Suggestion 决策引擎 — 候选评分 + 去重 + 频率加权 + 阈值/预算(误报控制) + * + * 1:1 移植自 Proma `apps/electron/src/main/lib/suggest/engine.ts` (PR proma-ai/Proma#1409)。 + * 决策流程: + * 1. 过滤 user 文本(空 → 返回空) + * 2. 拒绝门:最后一条 user 消息含 NEGATIVE 模式 → 整轮不触发 + * 3. extractSignals + applyRules + buildSkillCandidate 生成候选 + * 4. 去重四连:seenKeys(同会话已建议) / neverKeys(永久屏蔽) / 同次评估 dup / silencedKinds(类型静默) + * 5. 频率加权:effective = rawConfidence × typeWeight(kind) + * 6. 阈值过滤:< threshold → suppressed(带原因) + * 7. 按 effective 降序取 maxPerEvaluation 条 + * + * Lume 适配(相对 Proma 源): + * 1. 纯函数:Proma 接收 `(input, index, opts)` 三参,index 含 store/feedback 持久状态; + * Lume 改为 `(messages, opts)` 两参,所有外部状态经 opts 注入(seenKeys/neverKeys/ + * silencedKinds/typeWeights/dedupContext),engine 不再 import store/feedback。 + * Task 9 service 负责装配 opts(read store + read feedback → 组装 Set/Map 传入)。 + * 这样 engine 可在隔离环境下单测(brief 契约测试直接传 opts)。 + * 2. 类型权重:Proma 用 index.typeWeights 容忍旧索引;Lume 改为 opts.typeWeights 必填 + * (service 总是从 store.getTypeWeights() 拿到完整对象),缺字段时回退 1.0。 + * 3. 入参形状:Proma input.messages 接收完整 ChatMessage;Lume 收窄为 {role:"user";content:string}[]。 + * 4. 新增 silencedKinds:Proma 没有按 kind 静默的能力;Lume brief 契约要求"类型静默"为 + * 去重四连之一,对应 feedback 层"不再建议这类"的 kind 级 mute。 + * 5. applyRules 形状:Proma applyRules 返回 RuleMatch[](包裹 candidate),Lume 直接返回 + * SuggestionCandidate[](Task 4 已确定);engine 不再 `.map(m => m.candidate)`。 + * 6. skill 候选合并:Proma 在 engine 内 buildSkillCandidate 后 push;Lume 同(透传 sopCount)。 + */ + +import type { SuggestionCandidate, SuggestionKind, SuggestionTypeWeights } from "@lume/shared"; +import type { UserMessage } from "./signals"; +import { NEGATIVE_PATTERNS, extractSignals } from "./signals"; +import { applyRules, buildSkillCandidate } from "./rules"; + +// ===== 默认参数(verbatim from Proma) ===== + +export const DEFAULT_SUGGEST_OPTIONS: { + threshold: number; + maxPerEvaluation: number; + maxPerSession: number; +} = { + /** 置信度阈值:raw × weight ≥ 0.6 才建议 */ + threshold: 0.6, + /** 单次评估最多 1 条(低频优先,避免连环打扰) */ + maxPerEvaluation: 1, + /** 同会话最多 2 条 */ + maxPerSession: 2, +}; + +/** + * 默认类型权重(初始)。 + * 与 packages/shared `DEFAULT_TYPE_WEIGHTS` 同源;此处独立导出供 engine + * 单测在不引入 shared 常量依赖的情况下验证默认值。 + * - skill 0.8:偏打扰,初始略低 + * - todo 0.9:必须满足 0.72 × 0.9 = 0.648 > 0.6 阈值,避免 todo 死锁不出 + */ +export function defaultTypeWeights(): SuggestionTypeWeights { + return { + correction: 1.0, + followup: 1.0, + automation: 1.0, + skill: 0.8, + todo: 0.9, + }; +} + +// ===== opts 形状 ===== + +/** 去重上下文(service 从 automation-manager / memory-v2 装配) */ +export interface DedupContext { + automationTitles: string[]; + correctionRules: string[]; + /** SOP/state 候选计数,驱动 buildSkillCandidate */ + sopCandidateCount: number; +} + +/** engine 求值选项 —— 所有外部状态经此注入(纯函数契约) */ +export interface EvaluateOptions { + /** 类型权重(service 从 store.getTypeWeights() 注入) */ + typeWeights: SuggestionTypeWeights; + /** 同会话已建议过的 duplicateKey 集合(防重出) */ + seenKeys: Set; + /** 用户永久屏蔽的 duplicateKey 集合("不再建议这条") */ + neverKeys?: Set; + /** 被静默的类型集合("不再建议这类",kind 级 mute) */ + silencedKinds?: Set; + /** 单会话最多建议条数(默认 2) */ + maxPerSession?: number; + /** 置信度阈值(默认 0.6) */ + threshold?: number; + /** 单次评估最多建议条数(默认 1) */ + maxPerEvaluation?: number; + /** 规则去重上下文(automation/correction/sop,service 装配) */ + dedupContext?: DedupContext; +} + +/** engine 输出 */ +export interface EvaluationResult { + candidates: SuggestionCandidate[]; + suppressed: Array<{ candidate: SuggestionCandidate; reason: string }>; +} + +// ===== 主入口 ===== + +/** + * 评估一组会话消息,生成建议候选(已被频率/去重/预算过滤)。 + * + * 纯函数:所有外部状态经 opts 注入,engine 内部不 import store/feedback。 + */ +export function evaluateSuggestions( + messages: readonly UserMessage[], + opts: EvaluateOptions, +): EvaluationResult { + const suppressed: EvaluationResult["suppressed"] = []; + + // 1. 过滤 user 文本 + const userMessages = messages + .filter( + (m) => + m.role === "user" && typeof m.content === "string" && m.content.trim().length > 0, + ) + .map((m) => m.content); + + if (userMessages.length === 0) return { candidates: [], suppressed }; + + // 2. 拒绝门:最后一条 user 消息含 NEGATIVE → 整轮不触发 + // (对齐 Proma:re.test(lastUserMsg) 任一命中即整体抑制) + const lastUserMsg = userMessages[userMessages.length - 1] ?? ""; + if (NEGATIVE_PATTERNS.some((re) => re.test(lastUserMsg))) { + return { candidates: [], suppressed }; + } + + // 3. 候选生成:extractSignals → applyRules → 补 skill 候选 + const dedup = opts.dedupContext ?? { + automationTitles: [], + correctionRules: [], + sopCandidateCount: 0, + }; + const filteredMessages = messages.filter((m) => m.role === "user"); + const signals = extractSignals(filteredMessages); + const ruleCandidates = applyRules({ + signals, + automationTitles: dedup.automationTitles, + correctionRules: dedup.correctionRules, + sopCandidateCount: dedup.sopCandidateCount, + }); + const candidates: SuggestionCandidate[] = [...ruleCandidates]; + const skillCandidate = buildSkillCandidate(dedup.sopCandidateCount); + if (skillCandidate) candidates.push(skillCandidate); + + // 4-6. 去重四连 + 频率加权 + 阈值过滤 + const seenKeys = opts.seenKeys; + const neverKeys = opts.neverKeys ?? new Set(); + const silencedKinds = opts.silencedKinds ?? new Set(); + const threshold = opts.threshold ?? DEFAULT_SUGGEST_OPTIONS.threshold; + const maxPerEvaluation = + opts.maxPerEvaluation ?? DEFAULT_SUGGEST_OPTIONS.maxPerEvaluation; + + const dedupSeenInEval = new Set(); + const scored: Array<{ candidate: SuggestionCandidate; effective: number }> = []; + + for (const candidate of candidates) { + // 同会话去重(已建议过) + if (seenKeys.has(candidate.duplicateKey)) { + suppressed.push({ candidate, reason: "同会话已建议过" }); + continue; + } + // 永久屏蔽 + if (neverKeys.has(candidate.duplicateKey)) { + suppressed.push({ candidate, reason: "用户已选择不再建议这类" }); + continue; + } + // 同次评估内去重 + if (dedupSeenInEval.has(candidate.duplicateKey)) { + suppressed.push({ candidate, reason: "重复候选" }); + continue; + } + dedupSeenInEval.add(candidate.duplicateKey); + // 类型静默 + if (silencedKinds.has(candidate.kind)) { + suppressed.push({ candidate, reason: "该类型已被用户静默" }); + continue; + } + // 频率加权 + const weight = typeWeightOf(opts.typeWeights, candidate.kind); + const effective = candidate.rawConfidence * weight; + if (effective < threshold) { + suppressed.push({ + candidate, + reason: `置信度不足(raw=${candidate.rawConfidence.toFixed(2)}, weight=${weight.toFixed(2)}, effective=${effective.toFixed(2)})`, + }); + continue; + } + scored.push({ candidate, effective }); + } + + // 7. 按 effective 降序取预算内 + scored.sort((a, b) => b.effective - a.effective); + const top = scored.slice(0, maxPerEvaluation).map((s) => s.candidate); + + return { candidates: top, suppressed }; +} + +/** + * 取类型权重(容忍缺字段,缺字段回退 1.0)。 + * 内部辅助:service 注入的 typeWeights 已是完整对象,此处仅防御。 + */ +function typeWeightOf(weights: SuggestionTypeWeights, kind: SuggestionKind): number { + const w = weights[kind]; + if (typeof w === "number" && w > 0) return w; + return 1.0; +} From f50a871872d29dfc3971141268ec57d318d9f3a6 Mon Sep 17 00:00:00 2001 From: Leo Date: Tue, 4 Aug 2026 00:17:45 +0800 Subject: [PATCH 07/24] =?UTF-8?q?=F0=9F=90=9B=20test(sidecar):=20=E5=BC=BA?= =?UTF-8?q?=E5=8C=96=E5=90=8C=E6=AC=A1=E8=AF=84=E4=BC=B0=E5=8E=BB=E9=87=8D?= =?UTF-8?q?=E6=B5=8B=E8=AF=95=E8=A6=86=E7=9B=96=E9=87=8D=E5=A4=8D=E5=80=99?= =?UTF-8?q?=E9=80=89=E8=B7=AF=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/services/suggest/engine.test.ts | 21 +++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/apps/sidecar/src/services/suggest/engine.test.ts b/apps/sidecar/src/services/suggest/engine.test.ts index 73828048f..df3cc2102 100644 --- a/apps/sidecar/src/services/suggest/engine.test.ts +++ b/apps/sidecar/src/services/suggest/engine.test.ts @@ -110,18 +110,27 @@ describe("evaluateSuggestions — 去重四连", () => { }); test("同次评估内重复候选 → suppressed", () => { - // 同一意图重复 2 次(repeat signal)+ correction 可能产生同 duplicateKey 的 automation 候选 - // 用两条产生相同 duplicateKey 的消息构造 + // 两条消息各自触发 followup 信号,且 FOLLOWUP 匹配 raw 均为 "明天提醒我提交" + // (贪婪 {0,30} 回溯到最长:明天 + 提醒我提 + 动词"提交"),slice(0,24) 后同 + // duplicateKey "followup:明天提醒我提交",第二条被同次评估去重抑制。 const out = evaluateSuggestions( - [...um("帮我跑一下测试"), ...um("帮我跑测试"), ...um("帮我跑一下测试")], + [ + ...um("明天提醒我提交代码审查的最终版本"), + ...um("明天提醒我提交代码审查的另一部分"), + ], { seenKeys: new Set(), typeWeights: fullWeights, }, ); - // 至少有一条进 suppressed(重复候选),且 candidates 中无同 key 重复 - const candidateKeys = out.candidates.map((c) => c.duplicateKey); - expect(new Set(candidateKeys).size).toBe(candidateKeys.length); + // 候选中至多 1 条 followup(重复的被去重) + const followupCandidates = out.candidates.filter((c) => c.kind === "followup"); + expect(followupCandidates.length).toBeLessThanOrEqual(1); + // 第二条 followup 必须以 "重复候选" 原因被同次评估去重抑制 + const dedupSuppressed = out.suppressed.find((s) => s.reason.includes("重复候选")); + expect(dedupSuppressed).toBeDefined(); + expect(dedupSuppressed?.candidate.kind).toBe("followup"); + expect(dedupSuppressed?.candidate.duplicateKey).toBe("followup:明天提醒我提交"); }); }); From e5f7e06c2aa48ad38030487e373772844d9dab52 Mon Sep 17 00:00:00 2001 From: Leo Date: Tue, 4 Aug 2026 00:23:08 +0800 Subject: [PATCH 08/24] =?UTF-8?q?=E2=9C=A8=20feat(sidecar):=20=E5=BB=BA?= =?UTF-8?q?=E8=AE=AE=E9=A2=91=E7=8E=87=E5=AD=A6=E4=B9=A0=20+=20=E8=BF=9E?= =?UTF-8?q?=E7=BB=AD=E5=BF=BD=E7=95=A5=E9=9D=99=E9=BB=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/services/suggest/feedback.test.ts | 234 ++++++++++++++++++ apps/sidecar/src/services/suggest/feedback.ts | 118 +++++++++ apps/sidecar/src/services/suggest/store.ts | 27 ++ 3 files changed, 379 insertions(+) create mode 100644 apps/sidecar/src/services/suggest/feedback.test.ts create mode 100644 apps/sidecar/src/services/suggest/feedback.ts diff --git a/apps/sidecar/src/services/suggest/feedback.test.ts b/apps/sidecar/src/services/suggest/feedback.test.ts new file mode 100644 index 000000000..c135f7523 --- /dev/null +++ b/apps/sidecar/src/services/suggest/feedback.test.ts @@ -0,0 +1,234 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { + getTypeWeights, + listSuggestions, + persistSuggestion, + resetSuggestionStoreForTest, +} from "./store"; +import { + SILENCE_AFTER_IGNORES, + getNeverKeys, + isTypeSilenced, + recordFeedback, +} from "./feedback"; +import type { SuggestionCandidate } from "@lume/shared"; + +let root: string; + +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), "lume-suggest-")); + process.env.LUME_CONFIG_DIR = root; + resetSuggestionStoreForTest(); +}); + +afterEach(() => { + delete process.env.LUME_CONFIG_DIR; + rmSync(root, { recursive: true, force: true }); +}); + +const candidate = (overrides: Partial = {}): SuggestionCandidate => ({ + duplicateKey: "correction:test", + kind: "correction", + title: "t", + reason: "r", + evidence: "e", + rawConfidence: 0.9, + action: { type: "memory_correction", raw: "以后不要用 var", rule: "不要用 var" }, + ...overrides, +}); + +describe("suggestion feedback — 频率学习", () => { + test("accepted ×1.2 单调上升直到上限 2.0", () => { + // correction 默认 1.0 + const r1 = persistSuggestion(candidate({ duplicateKey: "k1" })); + recordFeedback(r1.id, "accepted"); + expect(getTypeWeights().correction).toBeCloseTo(1.2, 6); + + const r2 = persistSuggestion(candidate({ duplicateKey: "k2" })); + recordFeedback(r2.id, "accepted"); + expect(getTypeWeights().correction).toBeCloseTo(1.44, 6); + + // 连续 accept 直到封顶 2.0 + let sim = 1.44; + for (let i = 3; i <= 10; i++) { + const r = persistSuggestion(candidate({ duplicateKey: `k${i}` })); + recordFeedback(r.id, "accepted"); + sim = Math.min(2.0, sim * 1.2); + } + expect(getTypeWeights().correction).toBe(2.0); + }); + + test("ignored ×0.8 单调下降直到下限 0.2", () => { + // correction 默认 1.0;连续 ignored 收敛到 0.2 + let sim = 1.0; + for (let i = 0; i < 10; i++) { + const r = persistSuggestion(candidate({ duplicateKey: `ig-${i}` })); + recordFeedback(r.id, "ignored"); + sim = Math.max(0.2, sim * 0.8); + } + expect(getTypeWeights().correction).toBeCloseTo(sim, 6); + expect(getTypeWeights().correction).toBe(0.2); + }); + + test("never ×0.5(下限 0.2)+ duplicateKey 永久屏蔽 + status 落盘", () => { + const r = persistSuggestion( + candidate({ + duplicateKey: "never-1", + kind: "automation", + action: { + type: "open_automation_create", + automationTitle: "t", + suggestedPrompt: "p", + }, + }), + ); + recordFeedback(r.id, "never"); + // 1.0 × 0.5 = 0.5,未触底 + expect(getTypeWeights().automation).toBeCloseTo(0.5, 6); + // duplicateKey 进入 neverKeys + expect(getNeverKeys().has("never-1")).toBe(true); + // record status 持久化为 never + const rec = listSuggestions().find((x) => x.id === r.id); + expect(rec?.status).toBe("never"); + expect(typeof rec?.feedbackAt).toBe("number"); + }); + + test("never 权重下限 0.2:连续 never 收敛到 0.2", () => { + // automation 默认 1.0 → 0.5 → 0.25 → 0.125→触底 0.2 + const mkAuto = (key: string): SuggestionCandidate => + candidate({ + duplicateKey: key, + kind: "automation", + action: { + type: "open_automation_create", + automationTitle: "t", + suggestedPrompt: "p", + }, + }); + const r1 = persistSuggestion(mkAuto("n1")); + recordFeedback(r1.id, "never"); + expect(getTypeWeights().automation).toBeCloseTo(0.5, 6); + const r2 = persistSuggestion(mkAuto("n2")); + recordFeedback(r2.id, "never"); + expect(getTypeWeights().automation).toBeCloseTo(0.25, 6); + const r3 = persistSuggestion(mkAuto("n3")); + recordFeedback(r3.id, "never"); + expect(getTypeWeights().automation).toBe(0.2); + }); + + test("权重学习是按 kind 独立调节(互不干扰)", () => { + // accepted correction 不影响 todo 权重 + const r1 = persistSuggestion(candidate({ duplicateKey: "c1", kind: "correction" })); + recordFeedback(r1.id, "accepted"); + expect(getTypeWeights().correction).toBeCloseTo(1.2, 6); + expect(getTypeWeights().todo).toBe(0.9); // 默认未变 + }); +}); + +describe("suggestion feedback — 类型静默", () => { + test("连续忽略 SILENCE_AFTER_IGNORES 次同 kind → 静默;其他 kind 不受影响", () => { + expect(SILENCE_AFTER_IGNORES).toBe(3); + for (let i = 0; i < 3; i++) { + const r = persistSuggestion( + candidate({ + duplicateKey: `todo-${i}`, + kind: "todo", + rawConfidence: 0.72, + action: { type: "open_memory_board" }, + }), + ); + recordFeedback(r.id, "ignored"); + } + expect(isTypeSilenced("todo")).toBe(true); + expect(isTypeSilenced("correction")).toBe(false); + }); + + test("静默只看最近 3 条同 kind:中间夹一条 accepted → 不静默", () => { + // 写入顺序(数组 newest-first):[t3, t2(accepted), t1, t0] + const mkTodo = (key: string): SuggestionCandidate => + candidate({ + duplicateKey: key, + kind: "todo", + rawConfidence: 0.72, + action: { type: "open_memory_board" }, + }); + const r0 = persistSuggestion(mkTodo("t0")); + recordFeedback(r0.id, "ignored"); + const r1 = persistSuggestion(mkTodo("t1")); + recordFeedback(r1.id, "ignored"); + const r2 = persistSuggestion(mkTodo("t2")); + recordFeedback(r2.id, "accepted"); // 打断连续忽略 + const r3 = persistSuggestion(mkTodo("t3")); + recordFeedback(r3.id, "ignored"); + // 最近 3 条(newest-first):t3(ignored), t2(accepted), t1(ignored) → 不静默 + expect(isTypeSilenced("todo")).toBe(false); + // 再补 3 条 ignored,使最近 3 条全 ignored + for (let i = 4; i <= 6; i++) { + const r = persistSuggestion(mkTodo(`t${i}`)); + recordFeedback(r.id, "ignored"); + } + expect(isTypeSilenced("todo")).toBe(true); + }); + + test("不足 3 条同 kind → 不静默", () => { + const r = persistSuggestion( + candidate({ + duplicateKey: "solo", + kind: "todo", + rawConfidence: 0.72, + action: { type: "open_memory_board" }, + }), + ); + recordFeedback(r.id, "ignored"); + expect(isTypeSilenced("todo")).toBe(false); + }); +}); + +describe("suggestion feedback — never 永久屏蔽集合", () => { + test("getNeverKeys 收集所有 status=never 的 duplicateKey(accepted 不入集)", () => { + const r1 = persistSuggestion(candidate({ duplicateKey: "never-a", kind: "correction" })); + recordFeedback(r1.id, "never"); + const r2 = persistSuggestion( + candidate({ + duplicateKey: "never-b", + kind: "automation", + action: { + type: "open_automation_create", + automationTitle: "t", + suggestedPrompt: "p", + }, + }), + ); + recordFeedback(r2.id, "never"); + const r3 = persistSuggestion(candidate({ duplicateKey: "acc-1", kind: "correction" })); + recordFeedback(r3.id, "accepted"); + + const keys = getNeverKeys(); + expect(keys.has("never-a")).toBe(true); + expect(keys.has("never-b")).toBe(true); + expect(keys.has("acc-1")).toBe(false); + expect(keys.size).toBe(2); + }); +}); + +describe("suggestion feedback — 边界", () => { + test("recordFeedback 未知 id → 安全无操作(不抛错、不改权重)", () => { + recordFeedback(99999, "accepted"); + expect(getTypeWeights().correction).toBe(1.0); + expect(getTypeWeights().automation).toBe(1.0); + }); + + test("recordFeedback 后落盘:resetSuggestionStoreForTest 后权重/状态仍读回", () => { + const r = persistSuggestion(candidate({ duplicateKey: "p1", kind: "correction" })); + recordFeedback(r.id, "accepted"); + expect(getTypeWeights().correction).toBeCloseTo(1.2, 6); + // 清缓存从磁盘读回 + resetSuggestionStoreForTest(); + expect(getTypeWeights().correction).toBeCloseTo(1.2, 6); + const rec = listSuggestions().find((x) => x.id === r.id); + expect(rec?.status).toBe("accepted"); + }); +}); diff --git a/apps/sidecar/src/services/suggest/feedback.ts b/apps/sidecar/src/services/suggest/feedback.ts new file mode 100644 index 000000000..0e53e48dd --- /dev/null +++ b/apps/sidecar/src/services/suggest/feedback.ts @@ -0,0 +1,118 @@ +/** + * Suggestion 反馈层 — 频率学习 + 连续忽略静默 + * + * 1:1 移植自 Proma `apps/electron/src/main/lib/suggest/feedback.ts` (PR proma-ai/Proma#1409)。 + * 用户三态反馈 → 类型权重调节("越用越好用"的机制): + * - accepted:weight × 1.2(上限 2.0),同类建议更容易出现 + * - ignored:weight × 0.8(下限 0.2),同类建议收敛 + * - never:该 duplicateKey 永久屏蔽 + 类型 weight × 0.5(下限 0.2) + * 连续忽略 N 次后该类型自动静默(P9 时机学习的简化落地)。 + * + * Lume 适配(相对 Proma 源): + * 1. 持久化分层:Proma feedback.ts 直接持有 cache + 读写文件;Lume 拆分为 + * store.ts 负责所有持久化,feedback.ts 只读 store 计算后写回(经 + * getTypeWeights/setTypeWeights + updateSuggestionStatus)。feedback 不再 + * import 文件 IO / config-paths。 + * 2. id 类型:Proma 用 string UUID;Lume store 用 number 自增。recordFeedback + * 签名随之收 number。 + * 3. 返回值:Proma recordFeedback 返回 SuggestionRecord | undefined; + * Lume brief 契约为 void(Task 9 service 不需要返回值)。 + * 4. 常量与权重数学完全 verbatim:1.2/0.8/0.5、cap 2.0、floor 0.2、 + * SILENCE_AFTER_IGNORES=3。 + */ + +import type { SuggestionFeedback, SuggestionKind, SuggestionTypeWeights } from "@lume/shared"; +import { + getTypeWeights, + listSuggestions, + setTypeWeights, + updateSuggestionStatus, +} from "./store"; + +/** 连续忽略达到该次数后,类型自动静默(跳过评估) */ +export const SILENCE_AFTER_IGNORES = 3; + +// ===== 权重数学(verbatim from Proma) ===== + +const WEIGHT_ACCEPTED_FACTOR = 1.2; +const WEIGHT_IGNORED_FACTOR = 0.8; +const WEIGHT_NEVER_FACTOR = 0.5; +const WEIGHT_CEILING = 2.0; +const WEIGHT_FLOOR = 0.2; + +/** 取类型当前权重,缺字段回退 1.0(防御旧索引) */ +function currentWeight(weights: SuggestionTypeWeights, kind: SuggestionKind): number { + const w = weights[kind]; + if (typeof w === "number" && w > 0) return w; + return 1.0; +} + +/** 上下限夹紧 */ +function clampWeight(value: number): number { + if (value > WEIGHT_CEILING) return WEIGHT_CEILING; + if (value < WEIGHT_FLOOR) return WEIGHT_FLOOR; + return value; +} + +// ===== 对外 API ===== + +/** + * 记录用户反馈,更新类型权重 + 单条 record 状态。 + * - accepted:weight × 1.2(上限 2.0) + * - ignored:weight × 0.8(下限 0.2) + * - never:weight × 0.5(下限 0.2)+ duplicateKey 永久屏蔽(经 getNeverKeys 读出) + * + * id 不存在或 feedback 非法时为安全无操作。 + */ +export function recordFeedback(id: number, feedback: SuggestionFeedback): void { + // 入口白名单:防止非法枚举污染 status(IPC 入口防御) + if (feedback !== "accepted" && feedback !== "ignored" && feedback !== "never") return; + + const record = listSuggestions().find((r) => r.id === id); + if (!record) return; + + const weights = getTypeWeights(); + const current = currentWeight(weights, record.kind); + let next: number; + switch (feedback) { + case "accepted": + next = current * WEIGHT_ACCEPTED_FACTOR; + break; + case "ignored": + next = current * WEIGHT_IGNORED_FACTOR; + break; + case "never": + next = current * WEIGHT_NEVER_FACTOR; + break; + } + weights[record.kind] = clampWeight(next); + + // 写回权重 + 更新 record 状态(两次 store 写,均经 cache,状态一致) + setTypeWeights(weights); + updateSuggestionStatus(id, feedback); +} + +/** + * 判断某类型的建议是否已被"连续忽略自动静默"。 + * 取该 kind 最近 SILENCE_AFTER_IGNORES 条记录,全部 status=ignored → true。 + * 记录不足 3 条时返回 false(未形成连续忽略模式)。 + */ +export function isTypeSilenced(kind: SuggestionKind): boolean { + const recent = listSuggestions() + .filter((r) => r.kind === kind) + .slice(0, SILENCE_AFTER_IGNORES); + if (recent.length < SILENCE_AFTER_IGNORES) return false; + return recent.every((r) => r.status === "ignored"); +} + +/** + * 获取用户永久屏蔽(status=never)的 duplicateKey 集合。 + * Task 9 service 将其作为 neverKeys 传给 engine 的去重四连。 + */ +export function getNeverKeys(): Set { + const keys = new Set(); + for (const r of listSuggestions()) { + if (r.status === "never") keys.add(r.duplicateKey); + } + return keys; +} diff --git a/apps/sidecar/src/services/suggest/store.ts b/apps/sidecar/src/services/suggest/store.ts index bb9fd2119..5f2a73b27 100644 --- a/apps/sidecar/src/services/suggest/store.ts +++ b/apps/sidecar/src/services/suggest/store.ts @@ -206,6 +206,33 @@ export function getTypeWeights(): SuggestionTypeWeights { return { ...readIndex().typeWeights }; } +/** + * 整体替换类型权重表并落盘。 + * feedback 层计算完新权重后调用此函数写回(不在此处做 clamp——业务约束由 feedback 负责)。 + */ +export function setTypeWeights(weights: SuggestionTypeWeights): void { + const index = readIndex(); + writeIndex({ ...index, typeWeights: { ...weights } }); +} + +/** + * 更新单条建议的反馈状态 + feedbackAt 时间戳(feedback 层调用)。 + * id 不存在时为安全无操作。 + */ +export function updateSuggestionStatus( + id: number, + status: SuggestionRecord["status"], +): void { + const index = readIndex(); + if (!index.records.some((r) => r.id === id)) return; + writeIndex({ + ...index, + records: index.records.map((r) => + r.id === id ? { ...r, status, feedbackAt: Date.now() } : r, + ), + }); +} + export function resetSuggestionStoreForTest(): void { cache = null; } From 647de2ebd899153c3aa3ef0e537aabe9ec52f4c7 Mon Sep 17 00:00:00 2001 From: Leo Date: Tue, 4 Aug 2026 00:31:46 +0800 Subject: [PATCH 09/24] =?UTF-8?q?=E2=9C=A8=20feat(sidecar):=20=E5=B7=A5?= =?UTF-8?q?=E4=BD=9C=E6=A8=A1=E5=BC=8F=E5=88=86=E6=9E=90=E5=99=A8=20+=20sc?= =?UTF-8?q?hema=20=E4=B8=A5=E6=A0=BC=E6=A0=A1=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/services/suggest/analyst.test.ts | 586 ++++++++++++++++++ apps/sidecar/src/services/suggest/analyst.ts | 449 ++++++++++++++ 2 files changed, 1035 insertions(+) create mode 100644 apps/sidecar/src/services/suggest/analyst.test.ts create mode 100644 apps/sidecar/src/services/suggest/analyst.ts diff --git a/apps/sidecar/src/services/suggest/analyst.test.ts b/apps/sidecar/src/services/suggest/analyst.test.ts new file mode 100644 index 000000000..30c7f32b4 --- /dev/null +++ b/apps/sidecar/src/services/suggest/analyst.test.ts @@ -0,0 +1,586 @@ +import { describe, expect, test } from "bun:test"; +import type { LLMProvider } from "@lume/agent-sdk"; +import { + ALLOWED_KINDS, + MAX_CANDIDATES, + parseAnalystResponse, + runAnalysis, + validateAnalystCandidate, + validateAnalystCandidates, +} from "./analyst"; + +// ===== Brief 契约:常量 ===== + +describe("brief 契约: 常量", () => { + test("ALLOWED_KINDS 仅含 automation/skill/todo(不含 correction/followup)", () => { + expect(ALLOWED_KINDS).toEqual(["automation", "skill", "todo"]); + }); + + test("MAX_CANDIDATES = 3", () => { + expect(MAX_CANDIDATES).toBe(3); + }); +}); + +// ===== validateAnalystCandidate:schema 严格校验 ===== + +describe("validateAnalystCandidate: kind 校验", () => { + test("越界 kind(correction)被拒 → null", () => { + expect( + validateAnalystCandidate({ + kind: "correction", + title: "t", + reason: "r", + evidence: "e", + duplicateKey: "k", + action: { type: "memory_correction", raw: "r", rule: "r" }, + }), + ).toBeNull(); + }); + + test("越界 kind(followup)被拒 → null", () => { + expect( + validateAnalystCandidate({ + kind: "followup", + title: "t", + reason: "r", + evidence: "e", + duplicateKey: "k", + action: { type: "open_automation_create", automationTitle: "t", suggestedPrompt: "p" }, + }), + ).toBeNull(); + }); + + test("未知 kind 被拒 → null", () => { + expect( + validateAnalystCandidate({ + kind: "unknown", + title: "t", + reason: "r", + evidence: "e", + duplicateKey: "k", + action: { type: "open_memory_board" }, + }), + ).toBeNull(); + }); + + test("非对象输入被拒 → null", () => { + expect(validateAnalystCandidate(null)).toBeNull(); + expect(validateAnalystCandidate(undefined)).toBeNull(); + expect(validateAnalystCandidate("automation" as never)).toBeNull(); + }); +}); + +describe("validateAnalystCandidate: 字段非空校验", () => { + test("任一必填字段空字符串被拒", () => { + expect( + validateAnalystCandidate({ + kind: "todo", + title: "", + reason: "r", + evidence: "e", + duplicateKey: "k", + action: { type: "open_memory_board" }, + }), + ).toBeNull(); + expect( + validateAnalystCandidate({ + kind: "todo", + title: "t", + reason: "", + evidence: "e", + duplicateKey: "k", + action: { type: "open_memory_board" }, + }), + ).toBeNull(); + expect( + validateAnalystCandidate({ + kind: "todo", + title: "t", + reason: "r", + evidence: "e", + duplicateKey: "", + action: { type: "open_memory_board" }, + }), + ).toBeNull(); + }); + + test("缺少 action 被拒", () => { + expect( + validateAnalystCandidate({ + kind: "todo", + title: "t", + reason: "r", + evidence: "e", + duplicateKey: "k", + }), + ).toBeNull(); + }); +}); + +describe("validateAnalystCandidate: 长度截断(Lume 偏离 Proma:截断而非拒绝)", () => { + test("title 超 40 字被截断后接受", () => { + const longTitle = "x".repeat(50); + const c = validateAnalystCandidate({ + kind: "automation", + title: longTitle, + reason: "r", + evidence: "e", + duplicateKey: "k", + action: { type: "open_automation_create", automationTitle: "t", suggestedPrompt: "p" }, + }); + expect(c).not.toBeNull(); + expect(c!.title.length).toBeLessThanOrEqual(40); + expect(c!.title).toBe("x".repeat(40)); + }); + + test("reason 超 200 字被截断", () => { + const longReason = "r".repeat(250); + const c = validateAnalystCandidate({ + kind: "todo", + title: "t", + reason: longReason, + evidence: "e", + duplicateKey: "k", + action: { type: "open_memory_board" }, + }); + expect(c).not.toBeNull(); + expect(c!.reason.length).toBe(200); + }); + + test("evidence 超 200 字被截断", () => { + const longEvidence = "e".repeat(300); + const c = validateAnalystCandidate({ + kind: "todo", + title: "t", + reason: "r", + evidence: longEvidence, + duplicateKey: "k", + action: { type: "open_memory_board" }, + }); + expect(c).not.toBeNull(); + expect(c!.evidence.length).toBe(200); + }); + + test("duplicateKey 超 200 字被截断", () => { + const longKey = "k".repeat(250); + const c = validateAnalystCandidate({ + kind: "todo", + title: "t", + reason: "r", + evidence: "e", + duplicateKey: longKey, + action: { type: "open_memory_board" }, + }); + expect(c).not.toBeNull(); + expect(c!.duplicateKey.length).toBe(200); + }); + + test("automation.automationTitle 超 100 字被截断", () => { + const longTitle = "a".repeat(150); + const c = validateAnalystCandidate({ + kind: "automation", + title: "t", + reason: "r", + evidence: "e", + duplicateKey: "k", + action: { type: "open_automation_create", automationTitle: longTitle, suggestedPrompt: "p" }, + }); + expect(c).not.toBeNull(); + expect((c!.action as { automationTitle: string }).automationTitle.length).toBe(100); + }); + + test("automation.suggestedPrompt 超 1000 字被截断", () => { + const longPrompt = "p".repeat(1200); + const c = validateAnalystCandidate({ + kind: "automation", + title: "t", + reason: "r", + evidence: "e", + duplicateKey: "k", + action: { type: "open_automation_create", automationTitle: "t", suggestedPrompt: longPrompt }, + }); + expect(c).not.toBeNull(); + expect((c!.action as { suggestedPrompt: string }).suggestedPrompt.length).toBe(1000); + }); + + test("skill.topic 超 100 字被截断", () => { + const longTopic = "t".repeat(150); + const c = validateAnalystCandidate({ + kind: "skill", + title: "t", + reason: "r", + evidence: "e", + duplicateKey: "k", + action: { type: "open_skill_creator", topic: longTopic }, + }); + expect(c).not.toBeNull(); + expect((c!.action as { topic: string }).topic.length).toBe(100); + }); +}); + +describe("validateAnalystCandidate: kind-action 匹配", () => { + test("automation 必须 open_automation_create(不匹配被拒)", () => { + expect( + validateAnalystCandidate({ + kind: "automation", + title: "t", + reason: "r", + evidence: "e", + duplicateKey: "k", + action: { type: "open_memory_board" }, + }), + ).toBeNull(); + }); + + test("automation 缺 automationTitle 被拒", () => { + expect( + validateAnalystCandidate({ + kind: "automation", + title: "t", + reason: "r", + evidence: "e", + duplicateKey: "k", + action: { type: "open_automation_create", suggestedPrompt: "p" }, + }), + ).toBeNull(); + }); + + test("automation 缺 suggestedPrompt 被拒", () => { + expect( + validateAnalystCandidate({ + kind: "automation", + title: "t", + reason: "r", + evidence: "e", + duplicateKey: "k", + action: { type: "open_automation_create", automationTitle: "t" }, + }), + ).toBeNull(); + }); + + test("skill 必须 open_skill_creator(不匹配被拒)", () => { + expect( + validateAnalystCandidate({ + kind: "skill", + title: "t", + reason: "r", + evidence: "e", + duplicateKey: "k", + action: { type: "open_memory_board" }, + }), + ).toBeNull(); + }); + + test("skill 缺 topic 被拒", () => { + expect( + validateAnalystCandidate({ + kind: "skill", + title: "t", + reason: "r", + evidence: "e", + duplicateKey: "k", + action: { type: "open_skill_creator" }, + }), + ).toBeNull(); + }); + + test("todo 必须 open_memory_board(不匹配被拒)", () => { + expect( + validateAnalystCandidate({ + kind: "todo", + title: "t", + reason: "r", + evidence: "e", + duplicateKey: "k", + action: { type: "open_automation_create", automationTitle: "t", suggestedPrompt: "p" }, + }), + ).toBeNull(); + }); +}); + +describe("validateAnalystCandidate: 默认 rawConfidence", () => { + test("automation rawConfidence = 0.7", () => { + const c = validateAnalystCandidate({ + kind: "automation", + title: "t", + reason: "r", + evidence: "e", + duplicateKey: "k", + action: { type: "open_automation_create", automationTitle: "t", suggestedPrompt: "p" }, + }); + expect(c!.rawConfidence).toBe(0.7); + }); + + test("skill rawConfidence = 0.65", () => { + const c = validateAnalystCandidate({ + kind: "skill", + title: "t", + reason: "r", + evidence: "e", + duplicateKey: "k", + action: { type: "open_skill_creator", topic: "topic" }, + }); + expect(c!.rawConfidence).toBe(0.65); + }); + + test("todo rawConfidence = 0.6", () => { + const c = validateAnalystCandidate({ + kind: "todo", + title: "t", + reason: "r", + evidence: "e", + duplicateKey: "k", + action: { type: "open_memory_board" }, + }); + expect(c!.rawConfidence).toBe(0.6); + }); +}); + +describe("validateAnalystCandidate: 非字符串字段容错(safeStr)", () => { + test("数组型 evidence 取首个字符串元素", () => { + const c = validateAnalystCandidate({ + kind: "todo", + title: "t", + reason: "r", + evidence: ["证据一", "证据二"], + duplicateKey: "k", + action: { type: "open_memory_board" }, + }); + expect(c!.evidence).toBe("证据一"); + }); + + test("数字字段转字符串", () => { + const c = validateAnalystCandidate({ + kind: "todo", + title: 42, + reason: "r", + evidence: "e", + duplicateKey: "k", + action: { type: "open_memory_board" }, + }); + expect(c!.title).toBe("42"); + }); +}); + +// ===== validateAnalystCandidates:去重 + slice ===== + +describe("validateAnalystCandidates", () => { + test("按 duplicateKey 去重", () => { + const raws = [ + { + kind: "todo", + title: "t1", + reason: "r", + evidence: "e", + duplicateKey: "same-key", + action: { type: "open_memory_board" }, + }, + { + kind: "todo", + title: "t2", + reason: "r", + evidence: "e", + duplicateKey: "same-key", + action: { type: "open_memory_board" }, + }, + ]; + const out = validateAnalystCandidates(raws); + expect(out).toHaveLength(1); + expect(out[0]!.title).toBe("t1"); + }); + + test("超过 MAX_CANDIDATES 截断为 3", () => { + const raws = Array.from({ length: 6 }, (_, i) => ({ + kind: "todo", + title: `t${i}`, + reason: "r", + evidence: "e", + duplicateKey: `key-${i}`, + action: { type: "open_memory_board" }, + })); + const out = validateAnalystCandidates(raws); + expect(out).toHaveLength(MAX_CANDIDATES); + }); + + test("非法候选被过滤", () => { + const raws = [ + { kind: "correction", title: "x", action: { type: "memory_correction" } }, + { + kind: "todo", + title: "t", + reason: "r", + evidence: "e", + duplicateKey: "k", + action: { type: "open_memory_board" }, + }, + ]; + const out = validateAnalystCandidates(raws); + expect(out).toHaveLength(1); + expect(out[0]!.kind).toBe("todo"); + }); + + test("空数组输入 → 空数组", () => { + expect(validateAnalystCandidates([])).toEqual([]); + }); +}); + +// ===== parseAnalystResponse:LLM 输出解析 ===== + +describe("parseAnalystResponse", () => { + test("裸 JSON 数组", () => { + const raw = JSON.stringify([ + { + kind: "todo", + title: "t", + reason: "r", + evidence: "e", + duplicateKey: "k", + action: { type: "open_memory_board" }, + }, + ]); + const out = parseAnalystResponse(raw); + expect(out).toHaveLength(1); + expect(out[0]!.kind).toBe("todo"); + }); + + test("markdown 围栏 ```json 剥离", () => { + const raw = '```json\n[{"kind":"todo","title":"t"}]\n```'; + const out = parseAnalystResponse(raw); + expect(out).toHaveLength(1); + expect(out[0]!.kind).toBe("todo"); + }); + + test("围栏 ``` (无 json 标签)剥离", () => { + const raw = '```\n[{"kind":"skill","title":"t"}]\n```'; + const out = parseAnalystResponse(raw); + expect(out).toHaveLength(1); + }); + + test("前后带噪声文本,区间提取", () => { + const raw = '好的,分析如下:\n[{"kind":"todo","title":"t"}]\n以上。'; + const out = parseAnalystResponse(raw); + expect(out).toHaveLength(1); + }); + + test("空字符串 → []", () => { + expect(parseAnalystResponse("")).toEqual([]); + expect(parseAnalystResponse(" ")).toEqual([]); + }); + + test("无数组结构 → []", () => { + expect(parseAnalystResponse('{"kind":"todo"}')).toEqual([]); + expect(parseAnalystResponse("not json at all")).toEqual([]); + }); + + test("JSON 解析失败 → []", () => { + expect(parseAnalystResponse("[{invalid}]")).toEqual([]); + }); + + test("解析结果非数组(对象)→ []", () => { + expect(parseAnalystResponse('{"a":1}')).toEqual([]); + }); + + test("数组内非对象元素被过滤", () => { + const raw = '["string", 42, {"kind":"todo","title":"t"}]'; + const out = parseAnalystResponse(raw); + expect(out).toHaveLength(1); + }); +}); + +// ===== runAnalysis:LLM 编排(注入 provider,不打真实 API) ===== + +const fakeProvider = (responseText: string): LLMProvider => ({ + apiType: "openai-completions", + async createMessage() { + return { + content: [{ type: "text", text: responseText }], + stopReason: "end_turn", + usage: { input_tokens: 0, output_tokens: 0 }, + }; + }, +}); + +describe("runAnalysis", () => { + test("注入 provider:返回解析+校验后的候选", async () => { + const canned = JSON.stringify([ + { + kind: "automation", + title: "每周发版检查", + reason: "重复出现", + evidence: "近期记忆", + duplicateKey: "automation:每周发版检查", + action: { + type: "open_automation_create", + automationTitle: "每周发版检查", + suggestedPrompt: "执行发版检查", + }, + }, + { + kind: "todo", + title: "汇总待办", + reason: "未完成", + evidence: "记忆", + duplicateKey: "todo:汇总待办", + action: { type: "open_memory_board" }, + }, + ]); + const out = await runAnalysis({ + context: "近期记忆条目:\n- [fact] 每周发版", + modelRef: "openai/gpt-5-mini", + createProvider: () => fakeProvider(canned), + }); + expect(out).toHaveLength(2); + expect(out[0]!.kind).toBe("automation"); + expect(out[1]!.kind).toBe("todo"); + }); + + test("LLM 返回非法 JSON → fail-open []", async () => { + const out = await runAnalysis({ + context: "近期记忆", + modelRef: "openai/gpt-5-mini", + createProvider: () => fakeProvider("not json"), + }); + expect(out).toEqual([]); + }); + + test("provider 抛错 → fail-open [](不抛出)", async () => { + const throwingProvider: LLMProvider = { + apiType: "openai-completions", + async createMessage() { + throw new Error("network down"); + }, + }; + const out = await runAnalysis({ + context: "近期记忆", + modelRef: "openai/gpt-5-mini", + createProvider: () => throwingProvider, + }); + expect(out).toEqual([]); + }); + + test("空 context → 直接返回 [](不调用 LLM)", async () => { + let called = false; + const out = await runAnalysis({ + context: "", + modelRef: "openai/gpt-5-mini", + createProvider: () => { + called = true; + return fakeProvider("[]"); + }, + }); + expect(out).toEqual([]); + expect(called).toBe(false); + }); + + test("围码包裹的 LLM 输出也能正确解析", async () => { + const canned = '```json\n[{"kind":"skill","title":"沉淀 Skill","reason":"r","evidence":"e","duplicateKey":"skill:x","action":{"type":"open_skill_creator","topic":"流程"}}]\n```'; + const out = await runAnalysis({ + context: "近期记忆", + modelRef: "openai/gpt-5-mini", + createProvider: () => fakeProvider(canned), + }); + expect(out).toHaveLength(1); + expect(out[0]!.kind).toBe("skill"); + expect(out[0]!.rawConfidence).toBe(0.65); + }); +}); diff --git a/apps/sidecar/src/services/suggest/analyst.ts b/apps/sidecar/src/services/suggest/analyst.ts new file mode 100644 index 000000000..3e4b47f3b --- /dev/null +++ b/apps/sidecar/src/services/suggest/analyst.ts @@ -0,0 +1,449 @@ +/** + * Suggestion Analyst — 工作模式分析器(Phase B 方向 2) + * + * 从规则引擎的"明确信号触发"进化到"隐含模式发现": + * - 规则引擎(rules.ts):用户明确说"以后不要 X / 明天继续" → 立即建议 + * - 分析器(本文件):低频(每日/手动)用 LLM 分析近期记忆, + * 识别重复出现的工作模式(SOP 候选 / 重复检查 / 待沉淀偏好), + * 输出 schema 校验过的建议候选,写入 suggestions 复用三态反馈。 + * + * 设计(蓝图 §7.4 第二阶段): + * - 输入经过截断与脱敏(只取记忆条目摘要,不含完整会话) + * - 主进程只接受 schema 校验通过、duplicateKey 合法的候选 + * - LLM 不能直接创建 Schedule/Monitor,只能提出候选 + * + * 1:1 移植自 Proma `apps/electron/src/main/lib/suggest/analyst.ts` (PR proma-ai/Proma#1409)。 + * + * Lume 适配(相对 Proma 源): + * 1. LLM 调用:Proma 用自家 `callLlm`(`MEMORY_LLM_*` env);Lume 复用 memory-v2 + * 的模型解析链(`resolveMemoryExtractionModelRefs` → `resolveChannelModelBinding` + * → `createLazyConnectionLlmProvider`),不引入新 env。temperature 未在 SDK + * `CreateMessageParams` 暴露,已丢弃(见 task-7-report concerns)。timeout 用 + * `AbortSignal.timeout(60_000)` 实现。 + * 2. 输入构建:`runAnalysis` 接收预构建 `context`(`buildAnalysisInput` 产出), + * 便于测试注入;persona 段跳过(Lume persona 未完整)。 + * 3. 长度上限:Proma 超长直接 reject;Lume 改为**截断后接受**(更宽容,少丢候选)。 + * 4. memory entries:Lume 用 `listEntries`(kind 来自 frontmatter.kind,statement 字段), + * 包含所有 kind(Lume 无 todo_context;state 亦有模式发现价值,全部包含)。 + */ + +import { type ApiType, type LLMProvider } from "@lume/agent-sdk"; +import type { SuggestionCandidate, SuggestionKind } from "@lume/shared"; +import { decryptApiKey, resolveChannelModelBinding } from "../channel/channel-manager"; +import { createLazyConnectionLlmProvider } from "../model-runtime/connection-provider"; +import { getEffectiveLumeConfig } from "../system/lume-config-service"; +import { listEntries, listPending } from "../memory-v2/markdown-store"; +import { resolveMemoryExtractionModelRefs } from "../memory-v2/extraction"; +import { listAutomationJobs } from "../automation/automation-manager"; + +/** 分析器允许产出的建议类型(保守:只产出规则引擎也能处理、有明确动作的类型) */ +export const ALLOWED_KINDS: SuggestionKind[] = ["automation", "skill", "todo"]; + +/** 单次分析最多产出的候选数 */ +export const MAX_CANDIDATES = 3; + +/** LLM 参数:maxTokens / timeoutMs(temperature 未在 SDK 暴露,见模块注释) */ +const ANALYST_MAX_TOKENS = 4096; +const ANALYST_TIMEOUT_MS = 60_000; + +/** 分析器系统提示(识别 4 类模式 + 严格 JSON 数组输出约束) */ +const ANALYST_PROMPT = `你是一位工作模式分析助手。请分析用户的长期记忆,发现**重复出现的工作模式**,并给出可执行的建议。 + +输入: +- 近期记忆条目(fact/preference/correction/sop/todo_context 类型) +- 用户画像(persona) +- 已生效的行为纠正规则 +- 已存在的定时任务名称(避免重复推荐) + +任务: +1. 识别**重复模式**:同一类操作反复出现(如"每次发版前检查清单""每周要手动汇总") +2. 识别**可沉淀的流程**(SOP):多步骤操作重复 ≥2 次 +3. 识别**值得自动化的日常**:定期/周期性工作 +4. 识别**待确认的偏好**:用户反复表达但未固化的规则 + +输出格式(严格 JSON 数组,不要输出其他内容): +[ + { + "kind": "automation" | "skill" | "todo", + "title": "简短标题(≤20 字)", + "reason": "建议理由(一句,解释为什么值得做)", + "evidence": "证据(基于哪些记忆条目)", + "duplicateKey": "去重键(如 automation:每周发版检查)", + "action": { + "type": "open_automation_create" | "open_skill_creator" | "open_memory_board", + "automationTitle": "(automation 类型)建议的定时任务标题", + "suggestedPrompt": "(automation 类型)定时任务执行提示词", + "topic": "(skill 类型)Skill 主题" + } + } +] + +约束: +- 只输出确有证据的模式,不确定就输出 [] +- 不要重复已有定时任务(见输入) +- kind=automation 时 action.type=open_automation_create;kind=skill 时 open_skill_creator;kind=todo 时 open_memory_board +- 每个候选必须能回答"为什么现在值得做" +`; + +/** 字段长度上限(超长由 validateAnalystCandidate 截断) */ +const LIMITS = { + title: 40, + reason: 200, + evidence: 200, + duplicateKey: 200, + automationTitle: 100, + suggestedPrompt: 1000, + topic: 100, +} as const; + +/** 分析器输出(LLM 原始响应解析前) */ +interface AnalystRawCandidate { + kind?: string; + title?: unknown; + reason?: unknown; + evidence?: unknown; + duplicateKey?: unknown; + action?: { + type?: string; + automationTitle?: unknown; + suggestedPrompt?: unknown; + topic?: unknown; + [key: string]: unknown; + }; +} + +/** 分析器 provider 工厂(与 memory-v2 同形:测试可注入) */ +type AnalysisProviderFactory = (input: { + apiType: ApiType; + apiKey: string; + baseURL?: string; +}) => LLMProvider; + +/** runAnalysis 输入 */ +export interface AnalysisInput { + /** 预构建的分析上下文(来自 buildAnalysisInput)。必需,空则跳过 */ + context: string; + /** workspace slug(用于模型配置解析) */ + workspaceSlug?: string; + /** 模型引用覆盖(优先级最高) */ + modelRef?: string; + /** 兜底模型引用列表 */ + fallbackModelRefs?: string[]; + /** 可注入的 provider 工厂(测试用) */ + createProvider?: AnalysisProviderFactory; +} + +/** buildAnalysisInput 选项 */ +export interface BuildAnalysisInputOptions { + workspaceSlug?: string; +} + +/** + * 构建分析输入摘要。 + * - recent memory-v2 entries:listEntries(active),slice 60→40,statement slice(0,100),`[kind]` 前缀 + * - active corrections:tag 含 "correction" 的 entries + pending,top 5 + * - automation names:listAutomationJobs().name + * - persona:跳过(Lume persona 未完整;spec "persona 为空时跳过") + */ +export function buildAnalysisInput(opts: BuildAnalysisInputOptions = {}): string { + const sections: string[] = []; + + // 近期记忆条目 + let entries: ReturnType = []; + try { + entries = listEntries({ + workspaceSlug: opts.workspaceSlug, + includeStatuses: ["active"], + }); + } catch { + entries = []; + } + const recent = entries.slice(0, 60).slice(0, 40); + if (recent.length > 0) { + sections.push("近期记忆条目:"); + for (const entry of recent) { + const kind = entry.frontmatter.kind ?? "unknown"; + const content = (entry.statement ?? "").slice(0, 100); + sections.push(`- [${kind}] ${content}`); + } + } + + // 已生效行为规则(correction tag) + try { + const pending = listPending({ + workspaceSlug: opts.workspaceSlug, + includeStatuses: ["open"], + }); + const fromEntries = entries + .filter((e) => e.frontmatter.tags?.includes("correction")) + .map((e) => e.statement); + const fromPending = pending + .filter((p) => p.frontmatter.candidate?.tags?.includes("correction")) + .map((p) => p.frontmatter.candidate?.statement ?? "") + .filter(Boolean); + const rules = [...fromEntries, ...fromPending].slice(0, 5); + if (rules.length > 0) { + sections.push("\n已生效行为规则:"); + for (const rule of rules) sections.push(`- ${rule}`); + } + } catch { + // fail-open:corrections 段省略 + } + + // 已有定时任务 + try { + const automations = listAutomationJobs().map((a) => a.name).filter(Boolean); + if (automations.length > 0) { + sections.push(`\n已有定时任务:${automations.join("、")}`); + } + } catch { + // fail-open:automation 段省略 + } + + return sections.length > 0 ? sections.join("\n") : "(暂无记忆)"; +} + +/** 解析 LLM 输出为候选数组(围栏剥离 + 区间提取 + Array.isArray) */ +export function parseAnalystResponse(raw: string): AnalystRawCandidate[] { + if (!raw || raw.trim().length === 0) return []; + // 剥离 markdown 围栏 + let text = raw.trim(); + const fenceMatch = text.match(/```(?:json)?\s*([\s\S]*?)```/); + if (fenceMatch?.[1]) text = fenceMatch[1].trim(); + // 找第一个 [ 到最后一个 ] + const start = text.indexOf("["); + const end = text.lastIndexOf("]"); + if (start === -1 || end === -1 || end <= start) return []; + const jsonText = text.slice(start, end + 1); + try { + const parsed = JSON.parse(jsonText) as unknown; + if (!Array.isArray(parsed)) return []; + return parsed.filter( + (item): item is AnalystRawCandidate => !!item && typeof item === "object", + ) as AnalystRawCandidate[]; + } catch { + return []; + } +} + +/** + * 安全字符串化:LLM 可能返回非字符串字段(数组/对象/数字),统一转字符串; + * 无法转为有效字符串返回 null。 + */ +function safeStr(v: unknown): string | null { + if (typeof v === "string") { + const s = v.trim(); + return s.length > 0 ? s : null; + } + if (typeof v === "number" || typeof v === "boolean") { + const s = String(v).trim(); + return s.length > 0 ? s : null; + } + if (Array.isArray(v)) { + // 数组 → 取首个字符串元素(LLM 可能把 evidence 输出成数组) + for (const item of v) { + const s = safeStr(item); + if (s) return s; + } + return null; + } + return null; +} + +/** 截断到指定长度 */ +function truncate(s: string, max: number): string { + return s.length > max ? s.slice(0, max) : s; +} + +/** + * schema 校验单条候选:kind 合法、字段非空、长度截断、kind-action 匹配、默认 rawConfidence。 + * 接收 `unknown`(schema gate:LLM 输出任意形状)。返回 null 表示不通过。 + */ +export function validateAnalystCandidate(raw: unknown): SuggestionCandidate | null { + if (!raw || typeof raw !== "object") return null; + const r = raw as AnalystRawCandidate; + const kind = r.kind; + if (typeof kind !== "string" || !ALLOWED_KINDS.includes(kind as SuggestionKind)) { + return null; + } + + const title = safeStr(r.title); + const reason = safeStr(r.reason); + const evidence = safeStr(r.evidence); + const duplicateKey = safeStr(r.duplicateKey); + if (!title || !reason || !evidence || !duplicateKey) return null; + + // 长度截断(Lume 偏离 Proma:截断而非 reject) + const tTitle = truncate(title, LIMITS.title); + const tReason = truncate(reason, LIMITS.reason); + const tEvidence = truncate(evidence, LIMITS.evidence); + const tDup = truncate(duplicateKey, LIMITS.duplicateKey); + + // 动作校验 + const action = r.action; + const actionType = action?.type; + if (!actionType) return null; + + if (kind === "automation") { + if (actionType !== "open_automation_create") return null; + const automationTitle = safeStr(action?.automationTitle); + const suggestedPrompt = safeStr(action?.suggestedPrompt); + if (!automationTitle || !suggestedPrompt) return null; + return { + kind, + title: tTitle, + reason: tReason, + evidence: tEvidence, + duplicateKey: tDup, + rawConfidence: 0.7, // LLM 分析产出的候选默认中等置信(需用户确认) + action: { + type: "open_automation_create", + automationTitle: truncate(automationTitle, LIMITS.automationTitle), + suggestedPrompt: truncate(suggestedPrompt, LIMITS.suggestedPrompt), + }, + }; + } + if (kind === "skill") { + if (actionType !== "open_skill_creator") return null; + const topic = safeStr(action?.topic); + if (!topic) return null; + return { + kind, + title: tTitle, + reason: tReason, + evidence: tEvidence, + duplicateKey: tDup, + rawConfidence: 0.65, + action: { type: "open_skill_creator", topic: truncate(topic, LIMITS.topic) }, + }; + } + if (kind === "todo") { + if (actionType !== "open_memory_board") return null; + return { + kind, + title: tTitle, + reason: tReason, + evidence: tEvidence, + duplicateKey: tDup, + rawConfidence: 0.6, + action: { type: "open_memory_board" }, + }; + } + return null; +} + +/** 校验并过滤候选数组:duplicateKey 去重 + slice MAX_CANDIDATES */ +export function validateAnalystCandidates( + raws: readonly unknown[], +): SuggestionCandidate[] { + const result: SuggestionCandidate[] = []; + const seen = new Set(); + for (const item of raws) { + const candidate = validateAnalystCandidate(item); + if (!candidate) continue; + if (seen.has(candidate.duplicateKey)) continue; + seen.add(candidate.duplicateKey); + result.push(candidate); + if (result.length >= MAX_CANDIDATES) break; + } + return result; +} + +/** + * 运行工作模式分析(LLM),返回合法候选(fail-open:无配置/失败返回空)。 + * + * 模型解析复用 memory-v2 链:`memory.extraction.modelRef` → + * `memory.extractionModelRef` → `models.agent.fallbackModelRefs`。 + */ +export async function runAnalysis(input: AnalysisInput): Promise { + if (!input.context || input.context.trim().length === 0) return []; + try { + const config = getEffectiveLumeConfig(input.workspaceSlug); + const modelRefs = resolveMemoryExtractionModelRefs(config, { + modelRef: input.modelRef, + fallbackModelRefs: input.fallbackModelRefs, + }); + if (modelRefs.length === 0) return []; + + for (const modelRef of modelRefs) { + try { + const response = await callAnalystWithModel({ + modelRef, + context: input.context, + createProvider: input.createProvider, + }); + if (response === undefined) continue; // binding 不存在 / provider 未配置 + const parsed = parseAnalystResponse(response); + return validateAnalystCandidates(parsed); + } catch { + continue; // 该 model 失败,尝试下一个 + } + } + return []; + } catch (error) { + console.warn( + "[Analyst] 工作模式分析失败:", + error instanceof Error ? error.message : error, + ); + return []; + } +} + +/** 单模型调用:解析 binding + 创建 provider + 发起 createMessage */ +async function callAnalystWithModel(input: { + modelRef: string; + context: string; + createProvider?: AnalysisProviderFactory; +}): Promise { + const binding = resolveChannelModelBinding(input.modelRef, "chat"); + if (!binding && !input.createProvider) return undefined; + + const provider = createAnalysisProvider({ + modelRef: input.modelRef, + binding, + createProvider: input.createProvider, + }); + + const response = await provider.createMessage({ + model: binding?.modelId ?? input.modelRef.split("/").at(-1) ?? input.modelRef, + maxTokens: ANALYST_MAX_TOKENS, + system: ANALYST_PROMPT, + messages: [{ role: "user", content: input.context }], + abortSignal: AbortSignal.timeout(ANALYST_TIMEOUT_MS), + }); + + return response.content + .map((block) => (block.type === "text" ? block.text : "")) + .filter(Boolean) + .join("\n"); +} + +/** 创建分析 provider(复用 memory-v2 createMemoryExtractionProvider 逻辑) */ +function createAnalysisProvider(input: { + modelRef: string; + binding: ReturnType; + createProvider?: AnalysisProviderFactory; +}): LLMProvider { + if (!input.createProvider && input.binding) { + return createLazyConnectionLlmProvider({ + connectionId: input.binding.channel.id, + modelId: input.binding.modelId, + }); + } + return input.createProvider!({ + apiType: input.binding + ? resolveAnalysisApiType(input.binding.channel.provider) + : "openai-completions", + apiKey: input.binding ? decryptApiKey(input.binding.channel.id) : "", + baseURL: input.binding?.channel.baseUrl, + }); +} + +function resolveAnalysisApiType(provider: string): ApiType { + const normalized = provider.trim().toLowerCase(); + if (normalized === "anthropic" || normalized === "anthropic-compatible") { + return "anthropic-messages"; + } + if (normalized === "deepseek") return "deepseek-chat-completions"; + return "openai-completions"; +} From a14b2e7cc65035e63bbc3b233fa1862c430425a1 Mon Sep 17 00:00:00 2001 From: Leo Date: Tue, 4 Aug 2026 00:37:53 +0800 Subject: [PATCH 10/24] =?UTF-8?q?=E2=9C=A8=20feat(sidecar):=20=E5=BB=BA?= =?UTF-8?q?=E8=AE=AE=E8=AF=84=E4=BC=B0=E5=AF=B9=E8=AF=9D=E6=96=87=E6=9C=AC?= =?UTF-8?q?=20adapter?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/services/suggest/adapter.test.ts | 121 ++++++++++++++++++ apps/sidecar/src/services/suggest/adapter.ts | 77 +++++++++++ 2 files changed, 198 insertions(+) create mode 100644 apps/sidecar/src/services/suggest/adapter.test.ts create mode 100644 apps/sidecar/src/services/suggest/adapter.ts diff --git a/apps/sidecar/src/services/suggest/adapter.test.ts b/apps/sidecar/src/services/suggest/adapter.test.ts new file mode 100644 index 000000000..be51db323 --- /dev/null +++ b/apps/sidecar/src/services/suggest/adapter.test.ts @@ -0,0 +1,121 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { + createAgentThread, + replaceAgentThreadTranscript, +} from "../agent/agent-thread-manager"; +import { extractRecentConversation } from "./adapter"; +import type { AgentMessage } from "@lume/shared"; + +let root: string; +let threadSeq = 0; + +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), "lume-suggest-adapter-")); + process.env.LUME_CONFIG_DIR = root; + threadSeq = 0; +}); + +afterEach(() => { + delete process.env.LUME_CONFIG_DIR; + rmSync(root, { recursive: true, force: true }); +}); + +const uid = (): string => `m-${threadSeq++}-${Math.random().toString(36).slice(2, 8)}`; + +const um = (content: string): AgentMessage => ({ + id: uid(), + role: "user", + content, + createdAt: Date.now(), +}); + +const am = (content: string): AgentMessage => ({ + id: uid(), + role: "assistant", + content, + createdAt: Date.now(), +}); + +describe("extractRecentConversation — brief 契约", () => { + test("空线程 → []", async () => { + const t = createAgentThread(); + const out = await extractRecentConversation({ threadId: t.id }); + expect(out).toEqual([]); + }); + + test("仅保留 user 角色(跳过 assistant)", async () => { + const t = createAgentThread(); + replaceAgentThreadTranscript(t.id, [ + um("你好"), + am("你好!有什么可以帮你的吗?"), + um("帮我写代码"), + ]); + const out = await extractRecentConversation({ threadId: t.id }); + expect(out).toHaveLength(2); + expect(out.every((m) => m.role === "user")).toBe(true); + expect(out.map((m) => m.content)).toEqual(["你好", "帮我写代码"]); + }); + + test("默认 limit=30:超过部分截尾", async () => { + const t = createAgentThread(); + const msgs: AgentMessage[] = []; + for (let i = 0; i < 35; i++) msgs.push(um(`msg-${i}`)); + replaceAgentThreadTranscript(t.id, msgs); + const out = await extractRecentConversation({ threadId: t.id }); + expect(out).toHaveLength(30); + expect(out[0]!.content).toBe("msg-5"); + expect(out[29]!.content).toBe("msg-34"); + }); + + test("自定义 limit 生效", async () => { + const t = createAgentThread(); + const msgs: AgentMessage[] = []; + for (let i = 0; i < 10; i++) msgs.push(um(`u${i}`)); + replaceAgentThreadTranscript(t.id, msgs); + const out = await extractRecentConversation({ threadId: t.id, limit: 3 }); + expect(out).toHaveLength(3); + expect(out.map((m) => m.content)).toEqual(["u7", "u8", "u9"]); + }); + + test("单条 content 切片至 800 字符", async () => { + const t = createAgentThread(); + const long = "x".repeat(1200); + replaceAgentThreadTranscript(t.id, [um(long)]); + const out = await extractRecentConversation({ threadId: t.id }); + expect(out).toHaveLength(1); + expect(out[0]!.content).toHaveLength(800); + }); + + test("跳过空 / 全空白 user 消息", async () => { + const t = createAgentThread(); + replaceAgentThreadTranscript(t.id, [ + um("hello"), + um(" "), + um(""), + um("\n\t"), + um("world"), + ]); + const out = await extractRecentConversation({ threadId: t.id }); + expect(out.map((m) => m.content)).toEqual(["hello", "world"]); + }); + + test("fail-open:不存在的线程 → [](不抛错)", async () => { + const out = await extractRecentConversation({ threadId: "non-existent-thread-id" }); + expect(out).toEqual([]); + }); + + test("fail-open:空 / 纯空白 threadId → []", async () => { + expect(await extractRecentConversation({ threadId: "" })).toEqual([]); + expect(await extractRecentConversation({ threadId: " " })).toEqual([]); + }); + + test("输出形状:{role:'user'; content:string}[] 与 signals.UserMessage 一致", async () => { + const t = createAgentThread(); + replaceAgentThreadTranscript(t.id, [um("abc")]); + const out = await extractRecentConversation({ threadId: t.id }); + expect(out[0]).toEqual({ role: "user", content: "abc" }); + }); +}); diff --git a/apps/sidecar/src/services/suggest/adapter.ts b/apps/sidecar/src/services/suggest/adapter.ts new file mode 100644 index 000000000..369153f69 --- /dev/null +++ b/apps/sidecar/src/services/suggest/adapter.ts @@ -0,0 +1,77 @@ +/** + * 对话文本 adapter — 从 thread transcript 抽取最近 user 消息供建议引擎评估。 + * + * 读取路径:`getAgentThreadMessages(threadId)`(agent-thread-manager.ts:327), + * 该函数已把 runtime-core transcript / 版本存储投影为 AgentMessage[], + * 其中 content 字段是 extractRenderableAssistantText 提取后的纯文本 + * (tool_use / tool_result / thinking / image 块在投影层已被剥离)。 + * 因此本 adapter 只需按 role === "user" 过滤、对 content 做切片, + * 不再重复解析 sdkMessages 原始块。 + * + * fail-open:读取异常或线程不存在时返回 [],绝不抛错——建议引擎把缺失 + * 上下文视为"无信号",而不是一次运行失败。 + * + * Task 9 service 将本函数输出直接喂给 evaluateSuggestions(形状与 + * signals.UserMessage 一致:{role:"user"; content:string}[])。 + */ +import type { AgentMessage } from "@lume/shared"; +import { getAgentThreadMessages } from "../agent/agent-thread-manager"; +import { createLogger } from "../infra/logger"; + +const log = createLogger("suggest-adapter"); + +/** 默认回溯的 user 消息条数(brief 契约) */ +const DEFAULT_LIMIT = 30; +/** 单条 user 消息 content 切片上限(brief 契约) */ +const MAX_CONTENT_SLICE = 800; + +export interface ExtractConversationInput { + /** 目标线程 ID(必需) */ + threadId: string; + /** + * 工作区 slug。当前 read path 仅依赖 threadId(线程索引全局唯一), + * 此字段为 Task 9 service 上下文对称保留,暂不参与读取。 + */ + workspaceSlug?: string; + /** 回溯条数,默认 30 */ + limit?: number; +} + +/** adapter 输出条目(与 signals.UserMessage 同形) */ +export type ConversationUserMessage = { role: "user"; content: string }; + +/** + * 抽取指定线程最近的 user 消息(纯文本)。 + * + * @returns 按时间正序的 user 消息数组;线程为空或读取失败时返回 [] + */ +export async function extractRecentConversation( + input: ExtractConversationInput, +): Promise { + const threadId = typeof input.threadId === "string" ? input.threadId.trim() : ""; + if (!threadId) return []; + + const limit = + typeof input.limit === "number" && Number.isFinite(input.limit) && input.limit > 0 + ? Math.floor(input.limit) + : DEFAULT_LIMIT; + + let messages: AgentMessage[]; + try { + messages = getAgentThreadMessages(threadId); + } catch (error) { + log.warn("failed to read thread messages", { threadId, error }); + return []; + } + + const userMessages: ConversationUserMessage[] = []; + for (const msg of messages) { + if (msg.role !== "user") continue; + const raw = typeof msg.content === "string" ? msg.content : ""; + if (!raw.trim()) continue; + userMessages.push({ role: "user", content: raw.slice(0, MAX_CONTENT_SLICE) }); + } + + // 取最后 `limit` 条 user 消息(chronological tail) + return userMessages.slice(-limit); +} From e9b67fb882ac97f7e042691336f7b1922822b4dd Mon Sep 17 00:00:00 2001 From: Leo Date: Tue, 4 Aug 2026 00:44:44 +0800 Subject: [PATCH 11/24] =?UTF-8?q?=E2=9C=A8=20feat(sidecar):=20=E5=BB=BA?= =?UTF-8?q?=E8=AE=AE=E7=BC=96=E6=8E=92=E6=9C=8D=E5=8A=A1=EF=BC=88=E8=AF=84?= =?UTF-8?q?=E4=BC=B0/=E5=8F=8D=E9=A6=88/=E5=88=86=E6=9E=90=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/services/suggest/service.test.ts | 349 ++++++++++++++++++ apps/sidecar/src/services/suggest/service.ts | 282 ++++++++++++++ 2 files changed, 631 insertions(+) create mode 100644 apps/sidecar/src/services/suggest/service.test.ts create mode 100644 apps/sidecar/src/services/suggest/service.ts diff --git a/apps/sidecar/src/services/suggest/service.test.ts b/apps/sidecar/src/services/suggest/service.test.ts new file mode 100644 index 000000000..01a9a6b7d --- /dev/null +++ b/apps/sidecar/src/services/suggest/service.test.ts @@ -0,0 +1,349 @@ +/** + * service.test.ts — 编排层测试 + * + * 策略:用 mock.module 隔离所有依赖(store/engine/feedback/analyst/adapter/rules/ + * automation-manager/smart-add/logger),用 mutable state + mock spies 验证编排逻辑。 + * 这些依赖各自的单元测试已覆盖自身行为,此处只关心 service 的"装配 + 编排 + fail-open"。 + */ +import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; +import type { + SuggestionCandidate, + SuggestionFeedback, + SuggestionKind, + SuggestionRecord, + SuggestionTypeWeights, +} from "@lume/shared"; + +// ===== 可变状态 + spy ===== +const ALL_KINDS: SuggestionKind[] = ["correction", "followup", "automation", "todo", "skill"]; + +const state = { + enabled: true, + typeWeights: { + correction: 1, + followup: 1, + automation: 1, + skill: 1, + todo: 1, + } as SuggestionTypeWeights, + records: [] as SuggestionRecord[], + neverKeys: new Set(), + silencedKinds: new Set(), + dedupContext: { automationTitles: [] as string[], correctionRules: [] as string[], sopCandidateCount: 0 }, + evalCandidates: [] as SuggestionCandidate[], + analysisCandidates: [] as SuggestionCandidate[], + extractedMessages: [{ role: "user", content: "以后不要用 var" }] as { + role: "user"; + content: string; + }[], + extractThrow: false, + evalThrow: false, + analysisThrow: false, +}; + +const spies = { + persistSuggestion: mock((candidate: SuggestionCandidate, ctx?: object): SuggestionRecord => { + const rec: SuggestionRecord = { + ...candidate, + id: state.records.length + 1, + status: "suggested", + createdAt: Date.now(), + sessionId: (ctx as { sessionId?: string })?.sessionId, + threadId: (ctx as { threadId?: string })?.threadId, + workspaceSlug: (ctx as { workspaceSlug?: string })?.workspaceSlug, + }; + state.records.push(rec); + return rec; + }), + recordFeedback: mock((_id: number, _fb: SuggestionFeedback) => {}), + createAutomationJob: mock((input: { name: string; prompt: string; schedule: unknown }) => ({ + id: "job-1", + ...input, + })), + smartAdd: mock(async (_input: { workspaceSlug?: string; candidate: object }) => ({ + action: "added", + })), + broadcaster: mock(() => {}), +}; + +// ===== mock.module 依赖 ===== +mock.module("./store", () => ({ + getEnabled: () => state.enabled, + getTypeWeights: () => ({ ...state.typeWeights }), + listSuggestions: (status?: SuggestionRecord["status"]) => + status ? state.records.filter((r) => r.status === status) : [...state.records], + persistSuggestion: spies.persistSuggestion, + updateSuggestionStatus: (id: number, status: SuggestionRecord["status"]) => { + state.records = state.records.map((r) => + r.id === id ? { ...r, status, feedbackAt: Date.now() } : r, + ); + }, +})); + +mock.module("./engine", () => ({ + evaluateSuggestions: (_messages: unknown, _opts: unknown) => { + if (state.evalThrow) throw new Error("engine boom"); + return { candidates: [...state.evalCandidates], suppressed: [] }; + }, +})); + +mock.module("./feedback", () => ({ + recordFeedback: spies.recordFeedback, + isTypeSilenced: (kind: SuggestionKind) => state.silencedKinds.has(kind), + getNeverKeys: () => new Set(state.neverKeys), +})); + +mock.module("./analyst", () => ({ + buildAnalysisInput: (_opts?: object) => "fake-context", + runAnalysis: async (_input: object) => { + if (state.analysisThrow) throw new Error("analyst boom"); + return [...state.analysisCandidates]; + }, +})); + +mock.module("./adapter", () => ({ + extractRecentConversation: async (_input: object) => { + if (state.extractThrow) throw new Error("adapter boom"); + return [...state.extractedMessages]; + }, +})); + +mock.module("./rules", () => ({ + loadDedupContext: () => ({ ...state.dedupContext }), +})); + +mock.module("../automation/automation-manager", () => ({ + createAutomationJob: spies.createAutomationJob, +})); + +mock.module("../memory-v2/smart-add", () => ({ + smartAddMemoryV2Candidate: spies.smartAdd, +})); + +mock.module("../infra/logger", () => ({ + createLogger: () => ({ + trace: () => {}, + debug: () => {}, + info: () => {}, + warn: () => {}, + error: () => {}, + fatal: () => {}, + }), +})); + +const { evaluateSessionSuggestions, handleSuggestionFeedback, runAnalysisAndPersist, setSuggestionChangeBroadcaster } = + await import("./service"); + +// ===== helpers ===== +function resetState(): void { + state.enabled = true; + state.typeWeights = { correction: 1, followup: 1, automation: 1, skill: 1, todo: 1 }; + state.records = []; + state.neverKeys = new Set(); + state.silencedKinds = new Set(); + state.dedupContext = { automationTitles: [], correctionRules: [], sopCandidateCount: 0 }; + state.evalCandidates = []; + state.analysisCandidates = []; + state.extractedMessages = [{ role: "user", content: "以后不要用 var" }]; + state.extractThrow = false; + state.evalThrow = false; + state.analysisThrow = false; + spies.persistSuggestion.mockClear(); + spies.recordFeedback.mockClear(); + spies.createAutomationJob.mockClear(); + spies.smartAdd.mockClear(); + spies.broadcaster.mockClear(); +} + +const correctionCandidate: SuggestionCandidate = { + duplicateKey: "correction:test", + kind: "correction", + title: "记住这个纠正", + reason: "r", + evidence: "e", + rawConfidence: 0.95, + action: { type: "memory_correction", raw: "以后不要用 var", rule: "不要用 var" }, +}; + +const automationCandidate: SuggestionCandidate = { + duplicateKey: "automation:每日汇总", + kind: "automation", + title: "开启定时任务", + reason: "r", + evidence: "e", + rawConfidence: 0.9, + action: { type: "open_automation_create", automationTitle: "每日汇总", suggestedPrompt: "汇总今日进度" }, +}; + +// ===== tests ===== +describe("evaluateSessionSuggestions", () => { + beforeEach(() => { + resetState(); + setSuggestionChangeBroadcaster(spies.broadcaster); + }); + afterEach(() => setSuggestionChangeBroadcaster(() => {})); + + test("enabled=false → 不评估、不 persist", async () => { + state.enabled = false; + state.evalCandidates = [correctionCandidate]; + await evaluateSessionSuggestions({ threadId: "t1", sessionId: "s1" }); + expect(spies.persistSuggestion).not.toHaveBeenCalled(); + expect(spies.broadcaster).not.toHaveBeenCalled(); + }); + + test("同会话已有 ≥ maxPerSession(2) 条 suggested → 不评估", async () => { + state.evalCandidates = [correctionCandidate]; + // 预置 2 条同会话 suggested + state.records = [ + { ...correctionCandidate, id: 1, status: "suggested", createdAt: 0, sessionId: "s1" }, + { ...correctionCandidate, id: 2, status: "suggested", createdAt: 0, sessionId: "s1", duplicateKey: "other" }, + ]; + await evaluateSessionSuggestions({ threadId: "t1", sessionId: "s1" }); + expect(spies.persistSuggestion).not.toHaveBeenCalled(); + }); + + test("happy path → persist + 广播", async () => { + state.evalCandidates = [correctionCandidate]; + await evaluateSessionSuggestions({ threadId: "t1", workspaceSlug: "ws", sessionId: "s1" }); + expect(spies.persistSuggestion).toHaveBeenCalledTimes(1); + expect(spies.persistSuggestion).toHaveBeenCalledWith( + correctionCandidate, + { threadId: "t1", workspaceSlug: "ws", sessionId: "s1" }, + ); + expect(spies.broadcaster).toHaveBeenCalled(); + }); + + test("engine 抛错 → fail-open(不抛、不 persist)", async () => { + state.evalThrow = true; + await evaluateSessionSuggestions({ threadId: "t1", sessionId: "s1" }); + expect(spies.persistSuggestion).not.toHaveBeenCalled(); + }); + + test("adapter 抛错 → fail-open", async () => { + state.extractThrow = true; + await evaluateSessionSuggestions({ threadId: "t1", sessionId: "s1" }); + expect(spies.persistSuggestion).not.toHaveBeenCalled(); + }); +}); + +describe("handleSuggestionFeedback", () => { + beforeEach(() => { + resetState(); + setSuggestionChangeBroadcaster(spies.broadcaster); + }); + afterEach(() => setSuggestionChangeBroadcaster(() => {})); + + test("recordFeedback 总是被调用(任何反馈)", async () => { + state.records = [{ ...correctionCandidate, id: 5, status: "suggested", createdAt: 0 }]; + await handleSuggestionFeedback(5, "ignored"); + expect(spies.recordFeedback).toHaveBeenCalledWith(5, "ignored"); + expect(spies.smartAdd).not.toHaveBeenCalled(); + expect(spies.createAutomationJob).not.toHaveBeenCalled(); + }); + + test("accepted + memory_correction → smartAddMemoryV2Candidate 调用(带 correction tag)", async () => { + state.records = [{ ...correctionCandidate, id: 5, status: "suggested", createdAt: 0, workspaceSlug: "ws" }]; + await handleSuggestionFeedback(5, "accepted"); + expect(spies.recordFeedback).toHaveBeenCalledWith(5, "accepted"); + expect(spies.smartAdd).toHaveBeenCalledTimes(1); + expect(spies.smartAdd).toHaveBeenCalledWith( + expect.objectContaining({ + workspaceSlug: "ws", + candidate: expect.objectContaining({ + kind: "preference", + statement: "不要用 var", + confidence: "high", + tags: expect.arrayContaining(["correction", "suggestion-derived"]), + }), + }), + ); + }); + + test("accepted + open_automation_create → createAutomationJob(manual schedule)", async () => { + state.records = [ + { ...automationCandidate, id: 7, status: "suggested", createdAt: 0 }, + ]; + await handleSuggestionFeedback(7, "accepted"); + expect(spies.createAutomationJob).toHaveBeenCalledTimes(1); + expect(spies.createAutomationJob).toHaveBeenCalledWith( + expect.objectContaining({ + name: "每日汇总", + schedule: { type: "manual" }, + prompt: "汇总今日进度", + }), + ); + }); + + test("accepted + open_memory_board → no-op(仅 recordFeedback)", async () => { + const todo: SuggestionRecord = { + duplicateKey: "todo:x", + kind: "todo", + title: "t", + reason: "r", + evidence: "e", + rawConfidence: 0.9, + action: { type: "open_memory_board" }, + id: 9, + status: "suggested", + createdAt: 0, + }; + state.records = [todo]; + await handleSuggestionFeedback(9, "accepted"); + expect(spies.recordFeedback).toHaveBeenCalledWith(9, "accepted"); + expect(spies.smartAdd).not.toHaveBeenCalled(); + expect(spies.createAutomationJob).not.toHaveBeenCalled(); + }); + + test("smartAdd 抛错 → fail-open(不向上传播)", async () => { + state.records = [{ ...correctionCandidate, id: 5, status: "suggested", createdAt: 0 }]; + spies.smartAdd.mockImplementation(async () => { + throw new Error("smartAdd boom"); + }); + await expect(handleSuggestionFeedback(5, "accepted")).resolves.toBeUndefined(); + spies.smartAdd.mockImplementation(async () => ({ action: "added" })); + }); +}); + +describe("runAnalysisAndPersist", () => { + beforeEach(() => { + resetState(); + setSuggestionChangeBroadcaster(spies.broadcaster); + }); + afterEach(() => setSuggestionChangeBroadcaster(() => {})); + + test("去重 neverKeys + 已 suggested,返回新增数", async () => { + state.analysisCandidates = [ + { ...automationCandidate, duplicateKey: "automation:new" }, + { ...automationCandidate, duplicateKey: "automation:never" }, + { ...automationCandidate, duplicateKey: "automation:existing" }, + ]; + state.neverKeys = new Set(["automation:never"]); + state.records = [ + { ...automationCandidate, id: 1, status: "suggested", createdAt: 0, duplicateKey: "automation:existing" }, + ]; + const count = await runAnalysisAndPersist({ workspaceSlug: "ws" }); + expect(count).toBe(1); + expect(spies.persistSuggestion).toHaveBeenCalledTimes(1); + expect(spies.broadcaster).toHaveBeenCalledTimes(1); + }); + + test("runAnalysis 抛错 → fail-open 返回 0", async () => { + state.analysisThrow = true; + const count = await runAnalysisAndPersist({ workspaceSlug: "ws" }); + expect(count).toBe(0); + expect(spies.persistSuggestion).not.toHaveBeenCalled(); + }); +}); + +describe("setSuggestionChangeBroadcaster", () => { + beforeEach(() => resetState()); + + test("注入的 broadcaster 在 persist 后被调用", async () => { + state.evalCandidates = [correctionCandidate]; + const customBroadcaster = mock(() => {}); + setSuggestionChangeBroadcaster(customBroadcaster); + await evaluateSessionSuggestions({ threadId: "t1", sessionId: "s1" }); + expect(customBroadcaster).toHaveBeenCalled(); + setSuggestionChangeBroadcaster(() => {}); + }); +}); diff --git a/apps/sidecar/src/services/suggest/service.ts b/apps/sidecar/src/services/suggest/service.ts new file mode 100644 index 000000000..57c3075a2 --- /dev/null +++ b/apps/sidecar/src/services/suggest/service.ts @@ -0,0 +1,282 @@ +/** + * Suggestion 编排服务 — 整合 engine / feedback / analyst / store / adapter + + * 把建议动作接到 Lume 既有的 automation + memory-v2 子系统。 + * + * 三个对外入口(全部 fail-open:绝不向调用方抛错): + * - evaluateSessionSuggestions(ctx):会话级规则评估,hook fire-and-forget 调用 + * - handleSuggestionFeedback(id, feedback):用户三态反馈 → 学习权重 + 触发动作 + * - runAnalysisAndPersist(ctx):LLM 工作模式分析 → 候选去重落库 + * + * IPC 广播解耦:notifySuggestionsChanged 经模块级可注入 broadcaster 调用。 + * Task 12 通过 setSuggestionChangeBroadcaster 注入真实 channel;本服务不直接 + * 依赖 IPC 层,保持纯逻辑可测。 + * + * 1:1 编排映射自 Proma `apps/electron/src/main/lib/suggest/service.ts` + * (PR proma-ai/Proma#1409),但动作分发适配 Lume: + * - memory_correction → smartAddMemoryV2Candidate(而非 Proma 的 addCorrection) + * - open_automation_create → createAutomationJob(schedule=manual,而非弹窗填表) + * - open_memory_board / open_skill_creator → 暂 no-op(UI 导航在 web 端) + */ + +import type { + SuggestionFeedback, + SuggestionKind, + SuggestionRecord, + SuggestionTypeWeights, +} from "@lume/shared"; +import { evaluateSuggestions, type DedupContext } from "./engine"; +import { loadDedupContext } from "./rules"; +import { extractRecentConversation } from "./adapter"; +import { recordFeedback, isTypeSilenced, getNeverKeys } from "./feedback"; +import { buildAnalysisInput, runAnalysis } from "./analyst"; +import { + getEnabled, + getTypeWeights, + listSuggestions, + persistSuggestion, +} from "./store"; +import { createAutomationJob } from "../automation/automation-manager"; +import { smartAddMemoryV2Candidate } from "../memory-v2/smart-add"; +import { createLogger } from "../infra/logger"; + +const log = createLogger("suggest-service"); + +/** 同会话最多建议条数(brief 契约) */ +const MAX_PER_SESSION = 2; + +/** 所有建议类型(用于遍历检测类型静默) */ +const ALL_KINDS: SuggestionKind[] = ["correction", "followup", "automation", "todo", "skill"]; + +// ===== IPC 广播(Task 12 注入真实 channel) ===== + +/** + * 建议变更广播器。Task 12 通过 setSuggestionChangeBroadcaster 注入真实 IPC + * channel;在此之前为 no-op。service 不直接依赖 IPC 层,保持可测。 + */ +let suggestionChangeBroadcaster: () => void = () => {}; + +/** 注入建议变更广播器(Task 12 调用)。 */ +export function setSuggestionChangeBroadcaster(fn: () => void): void { + suggestionChangeBroadcaster = fn; +} + +function notifySuggestionsChanged(): void { + try { + suggestionChangeBroadcaster(); + } catch (error) { + log.warn("suggestion change broadcaster threw", { error }); + } +} + +// ===== 入口 1:会话级评估 ===== + +export interface SessionSuggestContext { + /** 目标线程 ID(必需,用于读取会话消息) */ + threadId: string; + /** 工作区 slug(透传到 persist / dedup context) */ + workspaceSlug?: string; + /** 会话 ID(用于同会话去重计数;缺失时回退 threadId) */ + sessionId?: string; +} + +/** + * 评估当前会话消息,生成建议候选并落库。 + * + * 流程: + * 1. 全局开关 getEnabled 关 → return + * 2. 同会话 suggested 条数 ≥ MAX_PER_SESSION → return(频控) + * 3. extractRecentConversation 抽取最近 30 条 user 消息 + * 4. 装配 engine opts(typeWeights/seenKeys/neverKeys/silencedKinds/dedupContext) + * 5. evaluateSuggestions 纯函数求值 + * 6. 逐条 persist + 广播(类型静默双保险) + * + * fire-and-forget 调用:fail-open,绝不抛错。 + */ +export async function evaluateSessionSuggestions( + ctx: SessionSuggestContext, +): Promise { + try { + if (!getEnabled()) return; + + const sessionKey = pickSessionKey(ctx); + const seenKeys = new Set(); + let sessionSuggested = 0; + if (sessionKey) { + for (const r of listSuggestions("suggested")) { + if (pickSessionKey(r) === sessionKey) { + sessionSuggested++; + seenKeys.add(r.duplicateKey); + } + } + } + if (sessionSuggested >= MAX_PER_SESSION) return; + + const messages = await extractRecentConversation({ + threadId: ctx.threadId, + workspaceSlug: ctx.workspaceSlug, + limit: 30, + }); + + const neverKeys = getNeverKeys(); + const silencedKinds = new Set( + ALL_KINDS.filter((kind) => isTypeSilenced(kind)), + ); + const typeWeights: SuggestionTypeWeights = getTypeWeights(); + const dedupContext: DedupContext = safeLoadDedupContext(ctx.workspaceSlug); + + const { candidates } = evaluateSuggestions(messages, { + typeWeights, + seenKeys, + neverKeys, + silencedKinds, + dedupContext, + }); + + for (const candidate of candidates) { + // 类型静默双保险(engine 已过滤,此处防御性兜底) + if (silencedKinds.has(candidate.kind)) continue; + persistSuggestion(candidate, { + threadId: ctx.threadId, + workspaceSlug: ctx.workspaceSlug, + sessionId: ctx.sessionId, + }); + notifySuggestionsChanged(); + } + } catch (error) { + log.warn("evaluateSessionSuggestions failed (fail-open)", { error }); + } +} + +// ===== 入口 2:反馈处理 ===== + +/** + * 用户反馈处理:先记录反馈(学习权重 + 更新状态),再分发动作。 + * - accepted + memory_correction → smartAddMemoryV2Candidate(写入长期记忆) + * - accepted + open_automation_create → createAutomationJob(manual schedule) + * - accepted + open_memory_board / open_skill_creator → no-op(UI 导航 web 端,TODO) + * - ignored / never → 仅 recordFeedback + * + * fail-open:动作失败不影响 recordFeedback 已落盘的权重学习。 + */ +export async function handleSuggestionFeedback( + id: number, + feedback: SuggestionFeedback, +): Promise { + try { + recordFeedback(id, feedback); + if (feedback !== "accepted") return; + + const record = listSuggestions().find((r) => r.id === id); + if (!record) return; + + await dispatchAcceptedAction(record); + } catch (error) { + log.warn("handleSuggestionFeedback failed (fail-open)", { error }); + } +} + +/** 分发 accepted 动作(按 action.type 路由到对应子系统) */ +async function dispatchAcceptedAction(record: SuggestionRecord): Promise { + const action = record.action; + switch (action.type) { + case "memory_correction": { + await smartAddMemoryV2Candidate({ + workspaceSlug: record.workspaceSlug, + candidate: { + kind: "preference", + targetScope: record.workspaceSlug ? "workspace" : "global", + statement: action.rule, + confidence: "high", + tags: ["correction", "suggestion-derived"], + claim: { + subject: "user/self", + predicate: "preference", + object: action.rule, + }, + }, + }); + return; + } + case "open_automation_create": { + createAutomationJob({ + name: action.automationTitle, + schedule: { type: "manual" }, + prompt: action.suggestedPrompt, + }); + return; + } + case "open_memory_board": + case "open_skill_creator": + // TODO(Task 11+): UI 导航由 web 端处理;此处仅完成反馈记录(已 recordFeedback)。 + return; + } +} + +// ===== 入口 3:LLM 工作模式分析 ===== + +export interface AnalysisContext { + workspaceSlug?: string; +} + +/** + * 运行 LLM 工作模式分析,去重后落库。 + * + * 去重:丢弃 duplicateKey 在 neverKeys(用户永久屏蔽)或已有 suggested 记录中的候选。 + * 返回新增候选数(fail-open:失败返回 0)。 + */ +export async function runAnalysisAndPersist( + ctx: AnalysisContext = {}, +): Promise { + try { + const neverKeys = getNeverKeys(); + const suggestedKeys = new Set( + listSuggestions("suggested").map((r) => r.duplicateKey), + ); + + const context = safeBuildAnalysisInput(ctx.workspaceSlug); + const candidates = await runAnalysis({ + context, + workspaceSlug: ctx.workspaceSlug, + }); + + const filtered = candidates.filter( + (c) => !neverKeys.has(c.duplicateKey) && !suggestedKeys.has(c.duplicateKey), + ); + + for (const candidate of filtered) { + persistSuggestion(candidate, { workspaceSlug: ctx.workspaceSlug }); + notifySuggestionsChanged(); + } + return filtered.length; + } catch (error) { + log.warn("runAnalysisAndPersist failed (fail-open)", { error }); + return 0; + } +} + +// ===== 辅助(fail-open 包装) ===== + +/** loadDedupContext 失败时回退到空上下文(不让一个子系统的故障阻断评估) */ +function safeLoadDedupContext(workspaceSlug?: string): DedupContext { + try { + return loadDedupContext({ workspaceSlug }); + } catch (error) { + log.warn("loadDedupContext failed (fail-open to empty)", { error }); + return { automationTitles: [], correctionRules: [], sopCandidateCount: 0 }; + } +} + +/** buildAnalysisInput 失败时回退到空 context(runAnalysis 会因此返回 []) */ +function safeBuildAnalysisInput(workspaceSlug?: string): string { + try { + return buildAnalysisInput({ workspaceSlug }); + } catch (error) { + log.warn("buildAnalysisInput failed (fail-open to empty)", { error }); + return ""; + } +} + +/** 统一会话标识:优先 sessionId,回退 threadId */ +function pickSessionKey(r: { sessionId?: string; threadId?: string }): string | undefined { + return r.sessionId ?? r.threadId; +} From 1f20c28a575a87dac14934024d47ac164a135697 Mon Sep 17 00:00:00 2001 From: Leo Date: Tue, 4 Aug 2026 00:52:53 +0800 Subject: [PATCH 12/24] =?UTF-8?q?=E2=9C=A8=20feat(sidecar):=20=E5=BB=BA?= =?UTF-8?q?=E8=AE=AE=E8=AF=84=E4=BC=B0=20workflow-hook=20=E6=8E=A5?= =?UTF-8?q?=E5=85=A5=20run.afterComplete?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../agent-runtime/runner/lume-runner.ts | 2 + .../services/workflow-hooks/contributions.ts | 9 ++ .../workflow-hooks/core-hooks.test.ts | 2 + .../core-suggestion-hooks.test.ts | 145 ++++++++++++++++++ .../workflow-hooks/core-suggestion-hooks.ts | 42 +++++ .../src/services/workflow-hooks/hook-bus.ts | 3 + .../services/workflow-hooks/hook-runtime.ts | 4 +- .../services/workflow-hooks/hook-services.ts | 26 ++++ 8 files changed, 232 insertions(+), 1 deletion(-) create mode 100644 apps/sidecar/src/services/workflow-hooks/core-suggestion-hooks.test.ts create mode 100644 apps/sidecar/src/services/workflow-hooks/core-suggestion-hooks.ts diff --git a/apps/sidecar/src/services/agent-runtime/runner/lume-runner.ts b/apps/sidecar/src/services/agent-runtime/runner/lume-runner.ts index fec08abc9..d1e34ca2d 100644 --- a/apps/sidecar/src/services/agent-runtime/runner/lume-runner.ts +++ b/apps/sidecar/src/services/agent-runtime/runner/lume-runner.ts @@ -36,6 +36,7 @@ import { createMemoryWorkflowHookService, createRuntimeEventWorkflowHookService, createSecurityWorkflowHookService, + createSuggestionWorkflowHookService, createTraceWorkflowHookService } from "../../workflow-hooks/hook-services"; import { @@ -781,6 +782,7 @@ function resolveWorkflowHooks(input: { services: { memory: createMemoryWorkflowHookService(), security: createSecurityWorkflowHookService(), + suggestion: createSuggestionWorkflowHookService(), runtimeEvents: createRuntimeEventWorkflowHookService(), trace: createTraceWorkflowHookService(), clock: { now: () => new Date() } diff --git a/apps/sidecar/src/services/workflow-hooks/contributions.ts b/apps/sidecar/src/services/workflow-hooks/contributions.ts index d0c4b3974..78de48918 100644 --- a/apps/sidecar/src/services/workflow-hooks/contributions.ts +++ b/apps/sidecar/src/services/workflow-hooks/contributions.ts @@ -35,6 +35,15 @@ export function createCoreWorkflowHookContributions( capabilities: ["context.append"], handlerRef: "core.plugin.skill-activation" }, + { + id: "core.suggestion.completion", + pluginId: "lume-core", + event: "run.afterComplete", + phase: "observe", + priority: "normal", + capabilities: ["runtime.emit"], + handlerRef: "core.suggestion.completion" + }, ...(config.security === false ? [] : [ { id: "core.security.permission", diff --git a/apps/sidecar/src/services/workflow-hooks/core-hooks.test.ts b/apps/sidecar/src/services/workflow-hooks/core-hooks.test.ts index 6049d1ee2..7136e39c9 100644 --- a/apps/sidecar/src/services/workflow-hooks/core-hooks.test.ts +++ b/apps/sidecar/src/services/workflow-hooks/core-hooks.test.ts @@ -16,6 +16,7 @@ function createContext( extractCandidates: async () => [] }, security: { evaluatePermissionDecision: async () => ({}) }, + suggestion: { evaluateSessionSuggestions: async () => {} }, runtimeEvents: { buildDiagnosticEvent: (input) => ({ type: "workflow_hook.diagnostic", ...input }) }, trace: { buildHookTrace: (input) => ({ type: "workflow_hook", ...input }) }, clock: { now: () => new Date("2026-05-26T00:00:00.000Z") }, @@ -35,6 +36,7 @@ describe("core workflow hooks", () => { expect(contributions.map((item) => item.id)).toEqual([ "core.plugin.skill-activation", + "core.suggestion.completion", "core.security.permission", ]); }); diff --git a/apps/sidecar/src/services/workflow-hooks/core-suggestion-hooks.test.ts b/apps/sidecar/src/services/workflow-hooks/core-suggestion-hooks.test.ts new file mode 100644 index 000000000..80c03e3fb --- /dev/null +++ b/apps/sidecar/src/services/workflow-hooks/core-suggestion-hooks.test.ts @@ -0,0 +1,145 @@ +import { describe, expect, test } from "bun:test"; +import { createCoreSuggestionHookHandlers } from "./core-suggestion-hooks"; +import { createSuggestionWorkflowHookService } from "./hook-services"; +import type { LumeWorkflowHookHandlerContext, LumeWorkflowSuggestionService } from "./hook-services"; +import type { LumeWorkflowRunAfterCompleteEvent } from "./hook-events"; + +function createContext( + suggestion: Pick +): LumeWorkflowHookHandlerContext { + return { + services: { + // 其它服务对本 hook 不可达,给最小 noop 占位以满足类型 + memory: { + recallContext: async () => ({ prefix: "", userMessageForModel: "", items: [] }), + extractCandidates: async () => [] + }, + security: { evaluatePermissionDecision: async () => ({}) }, + suggestion, + runtimeEvents: { buildDiagnosticEvent: (input) => ({ type: "workflow_hook.diagnostic", ...input }) }, + trace: { buildHookTrace: (input) => ({ type: "workflow_hook", ...input }) }, + clock: { now: () => new Date("2026-08-03T00:00:00.000Z") } + } + }; +} + +function afterCompleteEvent(overrides: Partial = {}): LumeWorkflowRunAfterCompleteEvent { + return { + event: "run.afterComplete", + runId: "run-1", + threadId: "thread-1", + cwd: "/tmp/project", + workspaceSlug: "demo", + userMessage: "please summarize the queue", + runStateSummary: { status: "completed", generatedItemCount: 2, pendingInterruptionCount: 0 }, + memoryContextUsedItems: [], + ...overrides + }; +} + +/** flush microtasks so fire-and-forget .then/.catch settles before assertions */ +function flushMicrotasks(): Promise { + return new Promise((resolve) => setTimeout(resolve, 0)); +} + +describe("core.suggestion.completion hook", () => { + test("invokes evaluateSessionSuggestions on run.afterComplete with threadId/workspaceSlug/sessionId=threadId", async () => { + const calls: Array<{ threadId: string; workspaceSlug?: string; sessionId?: string }> = []; + const handlers = createCoreSuggestionHookHandlers(); + + const result = await handlers["core.suggestion.completion"]!( + afterCompleteEvent(), + createContext({ + evaluateSessionSuggestions: async (input) => { + calls.push(input); + } + }) + ); + await flushMicrotasks(); + + // handler 立即返回空 effects(fire-and-forget) + expect(result.effects).toEqual([]); + // payload 无 sessionId,按 brief 回退 threadId + expect(calls).toEqual([ + { threadId: "thread-1", workspaceSlug: "demo", sessionId: "thread-1" } + ]); + }); + + test("does not await evaluateSessionSuggestions — resolves before the eval completes", async () => { + let evalResolved = false; + let resolveEval!: () => void; + const evalPromise = new Promise((resolve) => { + resolveEval = resolve; + }); + + const handlers = createCoreSuggestionHookHandlers(); + const start = Date.now(); + await handlers["core.suggestion.completion"]!( + afterCompleteEvent(), + createContext({ + evaluateSessionSuggestions: async () => { + await evalPromise; + evalResolved = true; + } + }) + ); + const elapsed = Date.now() - start; + + // handler 在 eval 完成前就已返回(fire-and-forget:不阻塞 run 完成) + expect(evalResolved).toBe(false); + expect(elapsed).toBeLessThan(50); + + resolveEval(); + await flushMicrotasks(); + expect(evalResolved).toBe(true); + }); + + test("swallows errors from evaluateSessionSuggestions (never throws, returns empty effects)", async () => { + const handlers = createCoreSuggestionHookHandlers(); + + // 若 handler 未妥善 swallow,错误会从这里冒泡成 unhandled rejection 或抛出 + const result = await handlers["core.suggestion.completion"]!( + afterCompleteEvent(), + createContext({ + evaluateSessionSuggestions: async () => { + throw new Error("suggest engine blew up"); + } + }) + ); + await flushMicrotasks(); + + expect(result.effects).toEqual([]); + }); + + test("ignores non run.afterComplete events", async () => { + const calls: unknown[] = []; + const handlers = createCoreSuggestionHookHandlers(); + + const result = await handlers["core.suggestion.completion"]!( + // 强制构造一个非 run.afterComplete 事件以验证 guard + { event: "context.beforeAssemble", runId: "r", threadId: "t", cwd: "/tmp", userMessage: "hi", availableTools: [], tokenBudget: 1 } as never, + createContext({ + evaluateSessionSuggestions: async (input) => { + calls.push(input); + } + }) + ); + await flushMicrotasks(); + + expect(result.effects).toEqual([]); + expect(calls).toEqual([]); + }); + + test("createSuggestionWorkflowHookService forwards to the injected evaluate fn", async () => { + const calls: unknown[] = []; + const service = createSuggestionWorkflowHookService({ + evaluate: async (input) => { + calls.push(input); + } + }); + + await service.evaluateSessionSuggestions({ threadId: "t-1", workspaceSlug: "w" }); + + expect(calls).toEqual([{ threadId: "t-1", workspaceSlug: "w" }]); + }); +}); diff --git a/apps/sidecar/src/services/workflow-hooks/core-suggestion-hooks.ts b/apps/sidecar/src/services/workflow-hooks/core-suggestion-hooks.ts new file mode 100644 index 000000000..2d1e3fad7 --- /dev/null +++ b/apps/sidecar/src/services/workflow-hooks/core-suggestion-hooks.ts @@ -0,0 +1,42 @@ +/** + * Proactive Suggestion workflow-hook —— 监听 `run.afterComplete`,fire-and-forget + * 触发 `evaluateSessionSuggestions`。 + * + * 设计要点: + * - 与 `core.memory.completion` 监听同一事件(run.afterComplete),但走完全独立的 + * handler / contribution / service,互不影响。 + * - **fire-and-forget**:handler 立即返回空 effects,建议评估在后台进行。建议评估 + * 绝不阻塞 run 完成路径——这是 brief 的硬约束。 + * - **try/catch 兜底**:附加 `.catch()` 吞掉所有 rejection,绝不冒泡到 hook-bus + * 的 try/catch(bus 虽然也会 catch,但我们不希望错误计数污染 run 的 errors)。 + * service 层本身也是 fail-open(service.ts:98 try/catch),这里是双保险。 + * - sessionId:`run.afterComplete` payload 不携带 sessionId(见 hook-events.ts:56-71 + * 的 LumeWorkflowRunAfterCompleteEvent 字段),按 brief 指示回退 threadId + * (与 service 内 pickSessionKey 的回退逻辑一致,service.ts:280)。 + */ +import type { LumeWorkflowHookHandlerRegistry } from "./hook-events"; + +export function createCoreSuggestionHookHandlers(): LumeWorkflowHookHandlerRegistry { + return { + "core.suggestion.completion": async (event, context) => { + if (event.event !== "run.afterComplete") { + return { effects: [] }; + } + + // fire-and-forget:handler 不 await,立即返回;评估在后台进行。 + // .catch() 兜底:建议失败绝不冒泡到 hook-bus / run 完成路径。 + void context.services.suggestion + .evaluateSessionSuggestions({ + threadId: event.threadId, + workspaceSlug: event.workspaceSlug, + // payload 无 sessionId(hook-events.ts:56-71),回退 threadId + sessionId: event.threadId + }) + .catch(() => { + // swallow:见上文设计要点。service.ts:98 已 log warn,此处静默即可。 + }); + + return { effects: [] }; + } + }; +} diff --git a/apps/sidecar/src/services/workflow-hooks/hook-bus.ts b/apps/sidecar/src/services/workflow-hooks/hook-bus.ts index 7e12d21f5..d8f8830ee 100644 --- a/apps/sidecar/src/services/workflow-hooks/hook-bus.ts +++ b/apps/sidecar/src/services/workflow-hooks/hook-bus.ts @@ -87,6 +87,9 @@ function createNoopHookContext(): LumeWorkflowHookHandlerContext { security: { evaluatePermissionDecision: async () => ({}) }, + suggestion: { + evaluateSessionSuggestions: async () => {} + }, runtimeEvents: { buildDiagnosticEvent: (input) => ({ type: "workflow_hook.diagnostic", diff --git a/apps/sidecar/src/services/workflow-hooks/hook-runtime.ts b/apps/sidecar/src/services/workflow-hooks/hook-runtime.ts index 178847fdf..4e2b134dd 100644 --- a/apps/sidecar/src/services/workflow-hooks/hook-runtime.ts +++ b/apps/sidecar/src/services/workflow-hooks/hook-runtime.ts @@ -3,6 +3,7 @@ import { createCoreMemoryHookHandlers } from "./core-memory-hooks"; import { createCoreObservabilityHookHandlers } from "./core-observability-hooks"; import { createCorePluginHookHandlers } from "./core-plugin-hooks"; import { createCoreSecurityHookHandlers } from "./core-security-hooks"; +import { createCoreSuggestionHookHandlers } from "./core-suggestion-hooks"; import { createCoreWorkflowHookContributions } from "./contributions"; import { LumeWorkflowHookBus } from "./hook-bus"; import type { LumeWorkflowHookEvent } from "./hook-events"; @@ -31,7 +32,8 @@ export function createLumeWorkflowHookRuntime(input: { ...createCoreMemoryHookHandlers(), ...createCorePluginHookHandlers(), ...createCoreSecurityHookHandlers(), - ...createCoreObservabilityHookHandlers() + ...createCoreObservabilityHookHandlers(), + ...createCoreSuggestionHookHandlers() }, context: { services: input.services } })); diff --git a/apps/sidecar/src/services/workflow-hooks/hook-services.ts b/apps/sidecar/src/services/workflow-hooks/hook-services.ts index e35a06f37..dfa979c55 100644 --- a/apps/sidecar/src/services/workflow-hooks/hook-services.ts +++ b/apps/sidecar/src/services/workflow-hooks/hook-services.ts @@ -4,6 +4,7 @@ import { buildMemoryV2UserMessageContext, type MemoryV2UserMessageContext } from "../memory-v2/user-message-prefix"; +import { evaluateSessionSuggestions, type SessionSuggestContext } from "../suggest/service"; import type { LumeWorkflowHookEventName } from "./hook-events"; import type { LumeWorkflowRuntimeEventDraft, @@ -42,6 +43,17 @@ export interface LumeWorkflowSecurityService { }): Promise<{ decision?: "allow" | "ask" | "deny"; reason?: string }>; } +/** + * Proactive Suggestion 服务接口 —— hook handler 通过此抽象调用建议评估, + * 便于测试注入 mock(与 memory/security 等服务同构)。 + * + * `evaluateSessionSuggestions` 是 fire-and-forget 入口(service.ts:95), + * service 内部已 fail-open;本接口仅做类型契约。 + */ +export interface LumeWorkflowSuggestionService { + evaluateSessionSuggestions(input: SessionSuggestContext): Promise; +} + export interface LumeWorkflowRuntimeEventService { buildDiagnosticEvent(input: { runId: string; @@ -66,6 +78,7 @@ export interface LumeWorkflowTraceService { export interface LumeWorkflowHookServices { memory: LumeWorkflowMemoryService; security: LumeWorkflowSecurityService; + suggestion: LumeWorkflowSuggestionService; runtimeEvents: LumeWorkflowRuntimeEventService; trace: LumeWorkflowTraceService; clock: { now(): Date }; @@ -95,6 +108,19 @@ export function createMemoryWorkflowHookService(input: { }; } +/** + * 创建 Proactive Suggestion hook 服务。默认绑定 `evaluateSessionSuggestions` + * (service.ts:95);测试可注入 mock 以隔离 LLM / store 依赖。 + */ +export function createSuggestionWorkflowHookService(input: { + evaluate?: typeof evaluateSessionSuggestions; +} = {}): LumeWorkflowSuggestionService { + const evaluate = input.evaluate ?? evaluateSessionSuggestions; + return { + evaluateSessionSuggestions: async (ctx) => evaluate(ctx) + }; +} + export function createSecurityWorkflowHookService(): LumeWorkflowSecurityService { return { evaluatePermissionDecision: async () => ({}) From 8a61ab64c2c722b83785685499705f21ec878b67 Mon Sep 17 00:00:00 2001 From: Leo Date: Tue, 4 Aug 2026 00:58:36 +0800 Subject: [PATCH 13/24] =?UTF-8?q?=E2=9C=A8=20feat(sidecar,shared):=20?= =?UTF-8?q?=E5=BB=BA=E8=AE=AE=20RPC=20handlers=20+=20IPC=20channel?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/sidecar/src/rpc/create-rpc-handlers.ts | 2 + .../src/rpc/suggestion-handlers.test.ts | 152 ++++++++++++++++++ apps/sidecar/src/rpc/suggestion-handlers.ts | 93 +++++++++++ packages/shared/src/types/suggestion.ts | 24 +++ 4 files changed, 271 insertions(+) create mode 100644 apps/sidecar/src/rpc/suggestion-handlers.test.ts create mode 100644 apps/sidecar/src/rpc/suggestion-handlers.ts diff --git a/apps/sidecar/src/rpc/create-rpc-handlers.ts b/apps/sidecar/src/rpc/create-rpc-handlers.ts index f4ac7b69c..13aad9942 100644 --- a/apps/sidecar/src/rpc/create-rpc-handlers.ts +++ b/apps/sidecar/src/rpc/create-rpc-handlers.ts @@ -10,6 +10,7 @@ import { createMemoryHandlers } from "./memory-handlers"; import { createModelMetaHandlers } from "./model-meta-handlers"; import { createReadingHandlers } from "./reading-handlers"; import { createRoutineHandlers } from "./routine-handlers"; +import { createSuggestionHandlers } from "./suggestion-handlers"; import { createSystemHandlers } from "./system-handlers"; import { createDesktopContextHandlers } from "./desktop-context-handlers"; import { createWikiHandlers } from "./wiki-handlers"; @@ -71,6 +72,7 @@ export function createRpcHandlers(context: CreateRpcHandlersContext): Record []), + deleteSuggestion: mock((_id: number): void => undefined), + clearSuggestions: mock((): void => undefined), + suggestionStats: mock( + (): SuggestionStats => ({ + suggestedCount: 0, + todayAccepted: 0, + todayIgnored: 0, + todayNever: 0, + typeWeights: { correction: 1, followup: 1, automation: 1, skill: 0.8, todo: 0.9 }, + }), + ), + setEnabled: mock((_value: boolean): void => undefined), +}; + +const serviceMocks = { + handleSuggestionFeedback: mock( + (_id: number, _feedback: SuggestionFeedback): Promise => Promise.resolve(), + ), + runAnalysisAndPersist: mock((_ctx: { workspaceSlug?: string }): Promise => + Promise.resolve(0), + ), +}; + +beforeEach(() => { + mock.module("../services/suggest/store", () => storeMocks); + mock.module("../services/suggest/service", () => serviceMocks); + Object.values(storeMocks).forEach((m) => m.mockClear()); + Object.values(serviceMocks).forEach((m) => m.mockClear()); +}); + +afterEach(() => { + mock.restore(); +}); + +describe("createSuggestionHandlers", () => { + test("LIST 直通 store.listSuggestions(无 status)", async () => { + const { createSuggestionHandlers } = await import("./suggestion-handlers"); + const handlers = createSuggestionHandlers(); + await handlers[SUGGESTION_IPC_CHANNELS.LIST]!({}); + expect(storeMocks.listSuggestions).toHaveBeenCalledTimes(1); + expect(storeMocks.listSuggestions.mock.calls[0]).toEqual([undefined]); + }); + + test("LIST 透传 status 过滤参数", async () => { + const { createSuggestionHandlers } = await import("./suggestion-handlers"); + const handlers = createSuggestionHandlers(); + await handlers[SUGGESTION_IPC_CHANNELS.LIST]!({ status: "accepted" }); + expect(storeMocks.listSuggestions.mock.calls[0]).toEqual(["accepted"]); + }); + + test("LIST 非法 status → throw", async () => { + const { createSuggestionHandlers } = await import("./suggestion-handlers"); + const handlers = createSuggestionHandlers(); + await expect( + handlers[SUGGESTION_IPC_CHANNELS.LIST]!({ status: "bogus" }), + ).rejects.toThrow(/suggestion:list/); + expect(storeMocks.listSuggestions).not.toHaveBeenCalled(); + }); + + test("ACT 调 service.handleSuggestionFeedback(id, feedback)", async () => { + const { createSuggestionHandlers } = await import("./suggestion-handlers"); + const handlers = createSuggestionHandlers(); + const result = await handlers[SUGGESTION_IPC_CHANNELS.ACT]!({ id: 7, feedback: "accepted" }); + expect(result).toEqual({ ok: true }); + expect(serviceMocks.handleSuggestionFeedback).toHaveBeenCalledTimes(1); + expect(serviceMocks.handleSuggestionFeedback.mock.calls[0]).toEqual([7, "accepted"]); + }); + + test("ACT 非法 feedback → throw", async () => { + const { createSuggestionHandlers } = await import("./suggestion-handlers"); + const handlers = createSuggestionHandlers(); + await expect( + handlers[SUGGESTION_IPC_CHANNELS.ACT]!({ id: 7, feedback: "maybe" }), + ).rejects.toThrow(/suggestion:act/); + expect(serviceMocks.handleSuggestionFeedback).not.toHaveBeenCalled(); + }); + + test("ACT 非正数 id → throw", async () => { + const { createSuggestionHandlers } = await import("./suggestion-handlers"); + const handlers = createSuggestionHandlers(); + await expect( + handlers[SUGGESTION_IPC_CHANNELS.ACT]!({ id: -1, feedback: "ignored" }), + ).rejects.toThrow(/suggestion:act/); + }); + + test("STATS 直通 store.suggestionStats", async () => { + const { createSuggestionHandlers } = await import("./suggestion-handlers"); + const handlers = createSuggestionHandlers(); + await handlers[SUGGESTION_IPC_CHANNELS.STATS]!(null); + expect(storeMocks.suggestionStats).toHaveBeenCalledTimes(1); + }); + + test("DELETE 直通 store.deleteSuggestion(id)", async () => { + const { createSuggestionHandlers } = await import("./suggestion-handlers"); + const handlers = createSuggestionHandlers(); + await handlers[SUGGESTION_IPC_CHANNELS.DELETE]!({ id: 42 }); + expect(storeMocks.deleteSuggestion.mock.calls[0]).toEqual([42]); + }); + + test("CLEAR_ALL 直通 store.clearSuggestions", async () => { + const { createSuggestionHandlers } = await import("./suggestion-handlers"); + const handlers = createSuggestionHandlers(); + await handlers[SUGGESTION_IPC_CHANNELS.CLEAR_ALL]!(null); + expect(storeMocks.clearSuggestions).toHaveBeenCalledTimes(1); + }); + + test("RUN_ANALYSIS 调 service.runAnalysisAndPersist({ workspaceSlug })", async () => { + const { createSuggestionHandlers } = await import("./suggestion-handlers"); + const handlers = createSuggestionHandlers(); + serviceMocks.runAnalysisAndPersist.mockResolvedValueOnce(3); + const result = await handlers[SUGGESTION_IPC_CHANNELS.RUN_ANALYSIS]!({ workspaceSlug: "demo" }); + expect(result).toEqual({ added: 3 }); + expect(serviceMocks.runAnalysisAndPersist.mock.calls[0]).toEqual([{ workspaceSlug: "demo" }]); + }); + + test("RUN_ANALYSIS 无参 → workspaceSlug undefined", async () => { + const { createSuggestionHandlers } = await import("./suggestion-handlers"); + const handlers = createSuggestionHandlers(); + await handlers[SUGGESTION_IPC_CHANNELS.RUN_ANALYSIS]!({}); + expect(serviceMocks.runAnalysisAndPersist.mock.calls[0]).toEqual([{ workspaceSlug: undefined }]); + }); + + test("SET_ENABLED 直通 store.setEnabled(bool)", async () => { + const { createSuggestionHandlers } = await import("./suggestion-handlers"); + const handlers = createSuggestionHandlers(); + await handlers[SUGGESTION_IPC_CHANNELS.SET_ENABLED]!({ enabled: false }); + expect(storeMocks.setEnabled.mock.calls[0]).toEqual([false]); + }); + + test("SET_ENABLED 非 bool → throw", async () => { + const { createSuggestionHandlers } = await import("./suggestion-handlers"); + const handlers = createSuggestionHandlers(); + await expect( + handlers[SUGGESTION_IPC_CHANNELS.SET_ENABLED]!({ enabled: "yes" }), + ).rejects.toThrow(/suggestion:set-enabled/); + }); +}); diff --git a/apps/sidecar/src/rpc/suggestion-handlers.ts b/apps/sidecar/src/rpc/suggestion-handlers.ts new file mode 100644 index 000000000..1a436055d --- /dev/null +++ b/apps/sidecar/src/rpc/suggestion-handlers.ts @@ -0,0 +1,93 @@ +/** + * 主动建议 RPC handlers(sidecar)。 + * + * 模式参考 model-meta-handlers / planning-todo-handlers: + * - 每个 channel 用 `validateInput` 校验入参,失败 throw(→ reject → toast) + * - list / stats / delete / clear-all / set-enabled 直通 store + * - act / run-analysis 路由到 service + * + * 服务层自身 fail-open(不会向此处抛错),但 handlers 仍保持 + * 「入参非法即 throw」的 IPC 契约,调用方依赖此约定显示错误提示。 + */ + +import { SUGGESTION_IPC_CHANNELS, type SuggestionFeedback, type SuggestionRecord } from "@lume/shared"; +import { + clearSuggestions, + deleteSuggestion, + listSuggestions, + setEnabled, + suggestionStats, +} from "../services/suggest/store"; +import { handleSuggestionFeedback, runAnalysisAndPersist } from "../services/suggest/service"; +import type { RpcHandler } from "./types"; +import { validateInput, z } from "./validation"; + +const SUGGESTION_STATUS_VALUES = ["suggested", "accepted", "ignored", "never"] as const; +const FEEDBACK_VALUES: readonly SuggestionFeedback[] = ["accepted", "ignored", "never"]; + +const listInputSchema = z + .object({ + status: z.enum(SUGGESTION_STATUS_VALUES).optional(), + }) + .strict(); + +const actInputSchema = z + .object({ + id: z.number().int().positive(), + feedback: z.enum(FEEDBACK_VALUES as [SuggestionFeedback, ...SuggestionFeedback[]]), + }) + .strict(); + +const deleteInputSchema = z + .object({ + id: z.number().int().positive(), + }) + .strict(); + +const runAnalysisInputSchema = z + .object({ + workspaceSlug: z.string().trim().min(1).optional(), + }) + .strict(); + +const setEnabledInputSchema = z + .object({ + enabled: z.boolean(), + }) + .strict(); + +export function createSuggestionHandlers(): Record { + return { + [SUGGESTION_IPC_CHANNELS.LIST]: async (params) => { + const input = validateInput(listInputSchema, params, SUGGESTION_IPC_CHANNELS.LIST); + return listSuggestions(input.status) satisfies SuggestionRecord[]; + }, + [SUGGESTION_IPC_CHANNELS.ACT]: async (params) => { + const input = validateInput(actInputSchema, params, SUGGESTION_IPC_CHANNELS.ACT); + await handleSuggestionFeedback(input.id, input.feedback); + return { ok: true as const }; + }, + [SUGGESTION_IPC_CHANNELS.STATS]: async () => { + return suggestionStats(); + }, + [SUGGESTION_IPC_CHANNELS.DELETE]: async (params) => { + const input = validateInput(deleteInputSchema, params, SUGGESTION_IPC_CHANNELS.DELETE); + deleteSuggestion(input.id); + return { ok: true as const }; + }, + [SUGGESTION_IPC_CHANNELS.CLEAR_ALL]: async () => { + clearSuggestions(); + return { ok: true as const }; + }, + [SUGGESTION_IPC_CHANNELS.RUN_ANALYSIS]: async (params) => { + const input = validateInput(runAnalysisInputSchema, params, SUGGESTION_IPC_CHANNELS.RUN_ANALYSIS); + const added = await runAnalysisAndPersist({ workspaceSlug: input.workspaceSlug }); + return { added }; + }, + [SUGGESTION_IPC_CHANNELS.SET_ENABLED]: async (params) => { + const input = validateInput(setEnabledInputSchema, params, SUGGESTION_IPC_CHANNELS.SET_ENABLED); + setEnabled(input.enabled); + return { ok: true as const }; + }, + }; +} diff --git a/packages/shared/src/types/suggestion.ts b/packages/shared/src/types/suggestion.ts index b7ff49c58..c592868b9 100644 --- a/packages/shared/src/types/suggestion.ts +++ b/packages/shared/src/types/suggestion.ts @@ -48,3 +48,27 @@ export interface SuggestionStats { export const DEFAULT_TYPE_WEIGHTS: SuggestionTypeWeights = { correction: 1.0, followup: 1.0, automation: 1.0, skill: 0.8, todo: 0.9, }; + +/** + * 主动建议 IPC channel(sidecar RPC)。命名遵循 `:` 惯例。 + * + * list / stats / delete / clear-all / set-enabled 直通 store; + * act 路由到 service.handleSuggestionFeedback(学习权重 + 动作分发); + * run-analysis 路由到 service.runAnalysisAndPersist(LLM 分析 + 去重落库)。 + */ +export const SUGGESTION_IPC_CHANNELS = { + /** 列出建议(可按 status 过滤) */ + LIST: "suggestion:list", + /** 用户三态反馈 → service.handleSuggestionFeedback */ + ACT: "suggestion:act", + /** 今日/累计统计 → store.suggestionStats */ + STATS: "suggestion:stats", + /** 删除单条 → store.deleteSuggestion */ + DELETE: "suggestion:delete", + /** 清空全部 → store.clearSuggestions */ + CLEAR_ALL: "suggestion:clear-all", + /** 触发 LLM 工作模式分析 → service.runAnalysisAndPersist */ + RUN_ANALYSIS: "suggestion:run-analysis", + /** 开关建议系统 → store.setEnabled */ + SET_ENABLED: "suggestion:set-enabled", +} as const; From e7a1826a1853315c087502c73995adf62a12adbb Mon Sep 17 00:00:00 2001 From: Leo Date: Tue, 4 Aug 2026 01:06:34 +0800 Subject: [PATCH 14/24] =?UTF-8?q?=E2=9C=A8=20feat(sidecar,web):=20?= =?UTF-8?q?=E5=BB=BA=E8=AE=AE=E5=8F=98=E6=9B=B4=E5=AE=9E=E6=97=B6=E6=8E=A8?= =?UTF-8?q?=E9=80=81=20onSuggestionsChanged?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/sidecar/src/rpc/create-rpc-handlers.ts | 2 +- .../src/rpc/suggestion-handlers.test.ts | 68 +++++++++++++++---- apps/sidecar/src/rpc/suggestion-handlers.ts | 24 ++++++- apps/web/src/atoms/index.ts | 1 + apps/web/src/atoms/suggestion-atoms.ts | 11 +++ apps/web/src/hooks/useGlobalAgentListeners.ts | 13 +++- packages/shared/src/types/suggestion.ts | 6 ++ 7 files changed, 107 insertions(+), 18 deletions(-) create mode 100644 apps/web/src/atoms/suggestion-atoms.ts diff --git a/apps/sidecar/src/rpc/create-rpc-handlers.ts b/apps/sidecar/src/rpc/create-rpc-handlers.ts index 13aad9942..87fed5bc4 100644 --- a/apps/sidecar/src/rpc/create-rpc-handlers.ts +++ b/apps/sidecar/src/rpc/create-rpc-handlers.ts @@ -72,7 +72,7 @@ export function createRpcHandlers(context: CreateRpcHandlersContext): Record => Promise.resolve(0), ), + setSuggestionChangeBroadcaster: mock((_fn: () => void): void => undefined), }; +const writeNotification = mock((_method: string, _params: unknown): void => undefined); + beforeEach(() => { mock.module("../services/suggest/store", () => storeMocks); mock.module("../services/suggest/service", () => serviceMocks); Object.values(storeMocks).forEach((m) => m.mockClear()); Object.values(serviceMocks).forEach((m) => m.mockClear()); + writeNotification.mockClear(); }); afterEach(() => { @@ -50,7 +54,7 @@ afterEach(() => { describe("createSuggestionHandlers", () => { test("LIST 直通 store.listSuggestions(无 status)", async () => { const { createSuggestionHandlers } = await import("./suggestion-handlers"); - const handlers = createSuggestionHandlers(); + const handlers = createSuggestionHandlers({ writeNotification }); await handlers[SUGGESTION_IPC_CHANNELS.LIST]!({}); expect(storeMocks.listSuggestions).toHaveBeenCalledTimes(1); expect(storeMocks.listSuggestions.mock.calls[0]).toEqual([undefined]); @@ -58,14 +62,14 @@ describe("createSuggestionHandlers", () => { test("LIST 透传 status 过滤参数", async () => { const { createSuggestionHandlers } = await import("./suggestion-handlers"); - const handlers = createSuggestionHandlers(); + const handlers = createSuggestionHandlers({ writeNotification }); await handlers[SUGGESTION_IPC_CHANNELS.LIST]!({ status: "accepted" }); expect(storeMocks.listSuggestions.mock.calls[0]).toEqual(["accepted"]); }); test("LIST 非法 status → throw", async () => { const { createSuggestionHandlers } = await import("./suggestion-handlers"); - const handlers = createSuggestionHandlers(); + const handlers = createSuggestionHandlers({ writeNotification }); await expect( handlers[SUGGESTION_IPC_CHANNELS.LIST]!({ status: "bogus" }), ).rejects.toThrow(/suggestion:list/); @@ -74,7 +78,7 @@ describe("createSuggestionHandlers", () => { test("ACT 调 service.handleSuggestionFeedback(id, feedback)", async () => { const { createSuggestionHandlers } = await import("./suggestion-handlers"); - const handlers = createSuggestionHandlers(); + const handlers = createSuggestionHandlers({ writeNotification }); const result = await handlers[SUGGESTION_IPC_CHANNELS.ACT]!({ id: 7, feedback: "accepted" }); expect(result).toEqual({ ok: true }); expect(serviceMocks.handleSuggestionFeedback).toHaveBeenCalledTimes(1); @@ -83,7 +87,7 @@ describe("createSuggestionHandlers", () => { test("ACT 非法 feedback → throw", async () => { const { createSuggestionHandlers } = await import("./suggestion-handlers"); - const handlers = createSuggestionHandlers(); + const handlers = createSuggestionHandlers({ writeNotification }); await expect( handlers[SUGGESTION_IPC_CHANNELS.ACT]!({ id: 7, feedback: "maybe" }), ).rejects.toThrow(/suggestion:act/); @@ -92,36 +96,37 @@ describe("createSuggestionHandlers", () => { test("ACT 非正数 id → throw", async () => { const { createSuggestionHandlers } = await import("./suggestion-handlers"); - const handlers = createSuggestionHandlers(); + const handlers = createSuggestionHandlers({ writeNotification }); await expect( handlers[SUGGESTION_IPC_CHANNELS.ACT]!({ id: -1, feedback: "ignored" }), ).rejects.toThrow(/suggestion:act/); + expect(serviceMocks.handleSuggestionFeedback).not.toHaveBeenCalled(); }); test("STATS 直通 store.suggestionStats", async () => { const { createSuggestionHandlers } = await import("./suggestion-handlers"); - const handlers = createSuggestionHandlers(); + const handlers = createSuggestionHandlers({ writeNotification }); await handlers[SUGGESTION_IPC_CHANNELS.STATS]!(null); expect(storeMocks.suggestionStats).toHaveBeenCalledTimes(1); }); test("DELETE 直通 store.deleteSuggestion(id)", async () => { const { createSuggestionHandlers } = await import("./suggestion-handlers"); - const handlers = createSuggestionHandlers(); + const handlers = createSuggestionHandlers({ writeNotification }); await handlers[SUGGESTION_IPC_CHANNELS.DELETE]!({ id: 42 }); expect(storeMocks.deleteSuggestion.mock.calls[0]).toEqual([42]); }); test("CLEAR_ALL 直通 store.clearSuggestions", async () => { const { createSuggestionHandlers } = await import("./suggestion-handlers"); - const handlers = createSuggestionHandlers(); + const handlers = createSuggestionHandlers({ writeNotification }); await handlers[SUGGESTION_IPC_CHANNELS.CLEAR_ALL]!(null); expect(storeMocks.clearSuggestions).toHaveBeenCalledTimes(1); }); test("RUN_ANALYSIS 调 service.runAnalysisAndPersist({ workspaceSlug })", async () => { const { createSuggestionHandlers } = await import("./suggestion-handlers"); - const handlers = createSuggestionHandlers(); + const handlers = createSuggestionHandlers({ writeNotification }); serviceMocks.runAnalysisAndPersist.mockResolvedValueOnce(3); const result = await handlers[SUGGESTION_IPC_CHANNELS.RUN_ANALYSIS]!({ workspaceSlug: "demo" }); expect(result).toEqual({ added: 3 }); @@ -130,23 +135,60 @@ describe("createSuggestionHandlers", () => { test("RUN_ANALYSIS 无参 → workspaceSlug undefined", async () => { const { createSuggestionHandlers } = await import("./suggestion-handlers"); - const handlers = createSuggestionHandlers(); + const handlers = createSuggestionHandlers({ writeNotification }); await handlers[SUGGESTION_IPC_CHANNELS.RUN_ANALYSIS]!({}); expect(serviceMocks.runAnalysisAndPersist.mock.calls[0]).toEqual([{ workspaceSlug: undefined }]); }); test("SET_ENABLED 直通 store.setEnabled(bool)", async () => { const { createSuggestionHandlers } = await import("./suggestion-handlers"); - const handlers = createSuggestionHandlers(); + const handlers = createSuggestionHandlers({ writeNotification }); await handlers[SUGGESTION_IPC_CHANNELS.SET_ENABLED]!({ enabled: false }); expect(storeMocks.setEnabled.mock.calls[0]).toEqual([false]); }); test("SET_ENABLED 非 bool → throw", async () => { const { createSuggestionHandlers } = await import("./suggestion-handlers"); - const handlers = createSuggestionHandlers(); + const handlers = createSuggestionHandlers({ writeNotification }); await expect( handlers[SUGGESTION_IPC_CHANNELS.SET_ENABLED]!({ enabled: "yes" }), ).rejects.toThrow(/suggestion:set-enabled/); }); }); + +describe("createSuggestionHandlers broadcaster 接线(Task 12)", () => { + test("构造时注入 broadcaster:调用 service.setSuggestionChangeBroadcaster", async () => { + const { createSuggestionHandlers } = await import("./suggestion-handlers"); + createSuggestionHandlers({ writeNotification }); + expect(serviceMocks.setSuggestionChangeBroadcaster).toHaveBeenCalledTimes(1); + const injected = serviceMocks.setSuggestionChangeBroadcaster.mock.calls[0]![0]; + expect(typeof injected).toBe("function"); + }); + + test("注入的 broadcaster 经 writeNotification 推送 SUGGESTIONS_CHANGED", async () => { + const { createSuggestionHandlers } = await import("./suggestion-handlers"); + createSuggestionHandlers({ writeNotification }); + expect(serviceMocks.setSuggestionChangeBroadcaster).toHaveBeenCalledTimes(1); + // 取出 handler 注入的 broadcaster 并直接调用,模拟 service.notifySuggestionsChanged + const injectedBroadcaster = serviceMocks.setSuggestionChangeBroadcaster.mock.calls[0]![0] as () => void; + writeNotification.mockClear(); + injectedBroadcaster(); + expect(writeNotification).toHaveBeenCalledTimes(1); + expect(writeNotification.mock.calls[0]).toEqual([ + SUGGESTION_IPC_CHANNELS.CHANGED, + { type: "suggestions_changed" }, + ]); + }); + + test("channel 推送抛错由 broadcaster 直接抛出(fail-open 责任在 service.notifySuggestionsChanged 的 try/catch)", async () => { + const { createSuggestionHandlers } = await import("./suggestion-handlers"); + const brokenChannel = mock((): void => { + throw new Error("channel down"); + }); + createSuggestionHandlers({ writeNotification: brokenChannel }); + const injectedBroadcaster = serviceMocks.setSuggestionChangeBroadcaster.mock.calls[0]![0] as () => void; + // broadcaster 自身不做 try/catch —— service.notifySuggestionsChanged 包了 try/catch + // 吞掉错误并 log.warn,确保推送失败不破坏持久化(service.test.ts 已覆盖 fail-open)。 + expect(injectedBroadcaster).toThrow("channel down"); + }); +}); diff --git a/apps/sidecar/src/rpc/suggestion-handlers.ts b/apps/sidecar/src/rpc/suggestion-handlers.ts index 1a436055d..a7778b525 100644 --- a/apps/sidecar/src/rpc/suggestion-handlers.ts +++ b/apps/sidecar/src/rpc/suggestion-handlers.ts @@ -18,8 +18,12 @@ import { setEnabled, suggestionStats, } from "../services/suggest/store"; -import { handleSuggestionFeedback, runAnalysisAndPersist } from "../services/suggest/service"; -import type { RpcHandler } from "./types"; +import { + handleSuggestionFeedback, + runAnalysisAndPersist, + setSuggestionChangeBroadcaster, +} from "../services/suggest/service"; +import type { NotificationWriter, RpcHandler } from "./types"; import { validateInput, z } from "./validation"; const SUGGESTION_STATUS_VALUES = ["suggested", "accepted", "ignored", "never"] as const; @@ -56,7 +60,21 @@ const setEnabledInputSchema = z }) .strict(); -export function createSuggestionHandlers(): Record { +export interface SuggestionHandlersContext { + /** + * sidecar → web 推送通道(与 agent-handlers / reading-handlers 同一机制)。 + * 用于实时广播建议变更:service.notifySuggestionsChanged 触发后,broadcaster + * 经此通道推送 SUGGESTION_IPC_CHANNELS.CHANGED,web 收到后刷新建议状态。 + */ + writeNotification: NotificationWriter; +} + +export function createSuggestionHandlers(context: SuggestionHandlersContext): Record { + // 接线 broadcaster:service 落库后调用 notifySuggestionsChanged → 此处推送 notification。 + // fail-open:notifySuggestionsChanged 内部已 try/catch,channel 推送失败不影响持久化。 + setSuggestionChangeBroadcaster(() => { + context.writeNotification(SUGGESTION_IPC_CHANNELS.CHANGED, { type: "suggestions_changed" }); + }); return { [SUGGESTION_IPC_CHANNELS.LIST]: async (params) => { const input = validateInput(listInputSchema, params, SUGGESTION_IPC_CHANNELS.LIST); diff --git a/apps/web/src/atoms/index.ts b/apps/web/src/atoms/index.ts index 66c57338e..08e28b894 100644 --- a/apps/web/src/atoms/index.ts +++ b/apps/web/src/atoms/index.ts @@ -7,3 +7,4 @@ export * from './command-palette' export * from './automation-atoms' export * from './skill-atoms' export * from './right-panel-atoms' +export * from './suggestion-atoms' diff --git a/apps/web/src/atoms/suggestion-atoms.ts b/apps/web/src/atoms/suggestion-atoms.ts new file mode 100644 index 000000000..725810fe6 --- /dev/null +++ b/apps/web/src/atoms/suggestion-atoms.ts @@ -0,0 +1,11 @@ +import { atom } from 'jotai' + +/** + * 建议变更版本号(单调递增)。每次收到 sidecar 推送的 + * SUGGESTION_IPC_CHANNELS.CHANGED 通知时 +1。 + * + * 消费方(建议列表 / Banner / Hub,Task 14+)用 useAtomValue 订阅此 atom, + * 在 useEffect 依赖中加入版本号即可触发重新拉取 suggestion:list —— 无需各自 + * 直接监听底层推送通道。这是 web 侧唯一的建议 reload 信号源。 + */ +export const suggestionsVersionAtom = atom(0) diff --git a/apps/web/src/hooks/useGlobalAgentListeners.ts b/apps/web/src/hooks/useGlobalAgentListeners.ts index e537ee77c..79c8907bc 100644 --- a/apps/web/src/hooks/useGlobalAgentListeners.ts +++ b/apps/web/src/hooks/useGlobalAgentListeners.ts @@ -19,12 +19,14 @@ import { currentWorkspaceIdAtom, tabsAtom, welcomePromptSeedAtom, + suggestionsVersionAtom, } from '@/atoms' import { buildDesktopProposalOpenRequestState } from '@/components/settings/desktop-assistant-proposals-state' import { threadMessagesCache } from '@/components/agent/thread-messages-cache' import { AGENT_IPC_CHANNELS, DESKTOP_CONTEXT_IPC_CHANNELS, + SUGGESTION_IPC_CHANNELS, type AgentMessageAppendedEvent, type AgentPendingInteractiveState, type AgentRuntimeEventNotification, @@ -91,6 +93,7 @@ export function useGlobalAgentListeners() { const currentWorkspaceId = useAtomValue(currentWorkspaceIdAtom) const setActiveTabId = useSetAtom(activeTabIdAtom) const setWelcomePromptSeed = useSetAtom(welcomePromptSeedAtom) + const setSuggestionsVersion = useSetAtom(suggestionsVersionAtom) const pendingRuntimeEventsRef = useRef([]) const runtimeEventsRafRef = useRef(null) @@ -376,6 +379,14 @@ export function useGlobalAgentListeners() { ))) break } + case SUGGESTION_IPC_CHANNELS.CHANGED: { + // sidecar 推送的建议变更信号。bump 版本号 → 消费方(建议列表 / Banner, + // Task 14+)订阅 suggestionsVersionAtom 触发 suggestion:list 重拉。 + // TODO(Task 13): 改用 desktop-api/suggestion.ts 的 onSuggestionsChanged + // 类型化封装,替代此处直接订阅 SUGGESTION_IPC_CHANNELS.CHANGED。 + setSuggestionsVersion((v) => v + 1) + break + } } }) return () => { @@ -395,5 +406,5 @@ export function useGlobalAgentListeners() { setRuntimeEvents((prev) => appendRuntimeEvents(prev, batch)) } } - }, [setStreamingStates, setRuntimeStatus, setRuntimeEvents, setPendingInteractive, setMessageQueues, setQueueInterrupted, setSubagentRuns, setSubagentWork, setPlanModePhase, setThreads, setErrorMessages, setDesktopActionVisual, setSidePanelViews, setTabs, tabs, currentWorkspaceId, setActiveTabId, setWelcomePromptSeed, enqueueRuntimeEvent]) + }, [setStreamingStates, setRuntimeStatus, setRuntimeEvents, setPendingInteractive, setMessageQueues, setQueueInterrupted, setSubagentRuns, setSubagentWork, setPlanModePhase, setThreads, setErrorMessages, setDesktopActionVisual, setSidePanelViews, setTabs, tabs, currentWorkspaceId, setActiveTabId, setWelcomePromptSeed, setSuggestionsVersion, enqueueRuntimeEvent]) } diff --git a/packages/shared/src/types/suggestion.ts b/packages/shared/src/types/suggestion.ts index c592868b9..a70b72523 100644 --- a/packages/shared/src/types/suggestion.ts +++ b/packages/shared/src/types/suggestion.ts @@ -71,4 +71,10 @@ export const SUGGESTION_IPC_CHANNELS = { RUN_ANALYSIS: "suggestion:run-analysis", /** 开关建议系统 → store.setEnabled */ SET_ENABLED: "suggestion:set-enabled", + /** + * 建议变更推送(sidecar → web notification)。payload 仅作信号: + * `{ type: "suggestions_changed" }`。web 收到后自取最新建议列表。 + * 由 service.notifySuggestionsChanged 经注入的 broadcaster 触发。 + */ + CHANGED: "suggestion:changed", } as const; From 262926add805e48adef522dc14e1e32c1ef9314a Mon Sep 17 00:00:00 2001 From: Leo Date: Tue, 4 Aug 2026 01:12:18 +0800 Subject: [PATCH 15/24] =?UTF-8?q?=E2=9C=A8=20feat(web):=20=E5=BB=BA?= =?UTF-8?q?=E8=AE=AE=20IPC=20client?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/src/hooks/useGlobalAgentListeners.ts | 17 ++- apps/web/src/lib/desktop-api/index.ts | 1 + .../src/lib/desktop-api/suggestion.test.ts | 114 ++++++++++++++++++ apps/web/src/lib/desktop-api/suggestion.ts | 64 ++++++++++ 4 files changed, 186 insertions(+), 10 deletions(-) create mode 100644 apps/web/src/lib/desktop-api/suggestion.test.ts create mode 100644 apps/web/src/lib/desktop-api/suggestion.ts diff --git a/apps/web/src/hooks/useGlobalAgentListeners.ts b/apps/web/src/hooks/useGlobalAgentListeners.ts index 79c8907bc..f7399d60a 100644 --- a/apps/web/src/hooks/useGlobalAgentListeners.ts +++ b/apps/web/src/hooks/useGlobalAgentListeners.ts @@ -1,6 +1,6 @@ import { useCallback, useEffect, useRef } from 'react' import { useAtomValue, useSetAtom } from 'jotai' -import { acknowledgeRendererDelivery, listSubagentWork, onSidecarEvent, sidecarCall } from '@/lib/desktop-api' +import { acknowledgeRendererDelivery, listSubagentWork, onSidecarEvent, onSuggestionsChanged, sidecarCall } from '@/lib/desktop-api' import { agentStreamingStatesAtom, agentRuntimeStatusAtom, @@ -26,7 +26,6 @@ import { threadMessagesCache } from '@/components/agent/thread-messages-cache' import { AGENT_IPC_CHANNELS, DESKTOP_CONTEXT_IPC_CHANNELS, - SUGGESTION_IPC_CHANNELS, type AgentMessageAppendedEvent, type AgentPendingInteractiveState, type AgentRuntimeEventNotification, @@ -379,18 +378,16 @@ export function useGlobalAgentListeners() { ))) break } - case SUGGESTION_IPC_CHANNELS.CHANGED: { - // sidecar 推送的建议变更信号。bump 版本号 → 消费方(建议列表 / Banner, - // Task 14+)订阅 suggestionsVersionAtom 触发 suggestion:list 重拉。 - // TODO(Task 13): 改用 desktop-api/suggestion.ts 的 onSuggestionsChanged - // 类型化封装,替代此处直接订阅 SUGGESTION_IPC_CHANNELS.CHANGED。 - setSuggestionsVersion((v) => v + 1) - break - } } }) + // sidecar 推送的建议变更信号 → bump 版本号 → 消费方(建议列表 / Banner, + // Task 14+)订阅 suggestionsVersionAtom 触发 suggestion:list 重拉。 + const unlistenSuggestions = onSuggestionsChanged(() => { + setSuggestionsVersion((v) => v + 1) + }) return () => { unlisten.then((fn) => fn()) + unlistenSuggestions.then((fn) => fn()) if (runtimeEventsRafRef.current !== null) { cancelAnimationFrame(runtimeEventsRafRef.current) runtimeEventsRafRef.current = null diff --git a/apps/web/src/lib/desktop-api/index.ts b/apps/web/src/lib/desktop-api/index.ts index 5c2c26be7..1a4d0a934 100644 --- a/apps/web/src/lib/desktop-api/index.ts +++ b/apps/web/src/lib/desktop-api/index.ts @@ -16,6 +16,7 @@ export * from './reading' export * from './wiki' export * from './planning-todo' export * from './browser' +export * from './suggestion' export { localFilePreviewUrl, openInSystem } from './native' export { sidecarCall } from './system' export type { diff --git a/apps/web/src/lib/desktop-api/suggestion.test.ts b/apps/web/src/lib/desktop-api/suggestion.test.ts new file mode 100644 index 000000000..54559f63d --- /dev/null +++ b/apps/web/src/lib/desktop-api/suggestion.test.ts @@ -0,0 +1,114 @@ +import { beforeEach, describe, expect, mock, test } from 'bun:test' +import { SUGGESTION_IPC_CHANNELS } from '@lume/shared' + +const invokeMock = mock(async (_command: string, _payload?: unknown) => ({})) +const unlistenMock = mock(() => {}) + +// listen captures the registered listener so tests can dispatch events. +let listenHandler: ((event: { payload: unknown }) => void) | null = null +const listenMock = mock( + ( + _channel: string, + listener: (event: { payload: unknown }) => void, + ): Promise<() => void> => { + listenHandler = listener + return Promise.resolve(unlistenMock) + }, +) + +mock.module('@/lib/desktop-runtime/core', () => ({ + invoke: invokeMock, +})) + +mock.module('@/lib/desktop-runtime/event', () => ({ + listen: listenMock, +})) + +const suggestionApi = await import('./suggestion') + +describe('desktop suggestion API', () => { + beforeEach(() => { + invokeMock.mockClear() + listenMock.mockClear() + unlistenMock.mockClear() + listenHandler = null + invokeMock.mockImplementation(async () => ({})) + }) + + test('listSuggestions routes through LIST channel, omits status when absent', async () => { + await suggestionApi.listSuggestions() + await suggestionApi.listSuggestions('accepted') + + expect(invokeMock.mock.calls).toEqual([ + ['sidecar_call', { method: SUGGESTION_IPC_CHANNELS.LIST, params: {} }], + ['sidecar_call', { method: SUGGESTION_IPC_CHANNELS.LIST, params: { status: 'accepted' } }], + ]) + }) + + test('actOnSuggestion routes id + feedback through ACT channel', async () => { + await suggestionApi.actOnSuggestion(7, 'never') + + expect(invokeMock.mock.calls).toEqual([ + ['sidecar_call', { method: SUGGESTION_IPC_CHANNELS.ACT, params: { id: 7, feedback: 'never' } }], + ]) + }) + + test('getSuggestionStats / clearAllSuggestions call with empty params', async () => { + await suggestionApi.getSuggestionStats() + await suggestionApi.clearAllSuggestions() + + expect(invokeMock.mock.calls).toEqual([ + ['sidecar_call', { method: SUGGESTION_IPC_CHANNELS.STATS, params: {} }], + ['sidecar_call', { method: SUGGESTION_IPC_CHANNELS.CLEAR_ALL, params: {} }], + ]) + }) + + test('deleteSuggestion routes id through DELETE channel', async () => { + await suggestionApi.deleteSuggestion(42) + + expect(invokeMock.mock.calls).toEqual([ + ['sidecar_call', { method: SUGGESTION_IPC_CHANNELS.DELETE, params: { id: 42 } }], + ]) + }) + + test('runSuggestionAnalysis omits workspaceSlug when absent', async () => { + await suggestionApi.runSuggestionAnalysis() + await suggestionApi.runSuggestionAnalysis('my-team') + + expect(invokeMock.mock.calls).toEqual([ + ['sidecar_call', { method: SUGGESTION_IPC_CHANNELS.RUN_ANALYSIS, params: {} }], + ['sidecar_call', { method: SUGGESTION_IPC_CHANNELS.RUN_ANALYSIS, params: { workspaceSlug: 'my-team' } }], + ]) + }) + + test('setSuggestionsEnabled routes boolean through SET_ENABLED channel', async () => { + await suggestionApi.setSuggestionsEnabled(false) + + expect(invokeMock.mock.calls).toEqual([ + ['sidecar_call', { method: SUGGESTION_IPC_CHANNELS.SET_ENABLED, params: { enabled: false } }], + ]) + }) + + test('onSuggestionsChanged subscribes to sidecar:event, filters CHANGED, returns unsubscribe', async () => { + const cb = mock((_signal: { type: 'suggestions_changed' }) => {}) + const unsub = suggestionApi.onSuggestionsChanged(cb) + + expect(listenMock).toHaveBeenCalledTimes(1) + expect(listenMock.mock.calls[0][0]).toBe('sidecar:event') + expect(listenHandler).toBeTypeOf('function') + + // 无关 method 不触发回调 + listenHandler!({ payload: { method: 'something:else', params: {} } }) + expect(cb).not.toHaveBeenCalled() + + // CHANGED → 回调收到 signal payload + const signal = { type: 'suggestions_changed' as const } + listenHandler!({ payload: { method: SUGGESTION_IPC_CHANNELS.CHANGED, params: signal } }) + expect(cb).toHaveBeenCalledTimes(1) + expect(cb.mock.calls[0][0]).toEqual(signal) + + // unsubscribe 调用底层 unlisten + await unsub.then((fn) => fn()) + expect(unlistenMock).toHaveBeenCalledTimes(1) + }) +}) diff --git a/apps/web/src/lib/desktop-api/suggestion.ts b/apps/web/src/lib/desktop-api/suggestion.ts new file mode 100644 index 000000000..f80323db7 --- /dev/null +++ b/apps/web/src/lib/desktop-api/suggestion.ts @@ -0,0 +1,64 @@ +import { invoke } from '@/lib/desktop-runtime/core' +import { listen } from '@/lib/desktop-runtime/event' +import { + SUGGESTION_IPC_CHANNELS, + type SuggestionFeedback, + type SuggestionRecord, + type SuggestionStats, +} from '@lume/shared' + +const call = (method: string, params: unknown) => + invoke('sidecar_call', { method, params }) + +/** + * CHANGED 推送的 payload 仅作信号(service.notifySuggestionsChanged 触发, + * 见 suggestion-handlers.ts)。web 收到后自取最新建议列表(bump 版本号 → 重拉)。 + */ +export type SuggestionsChangedSignal = { type: 'suggestions_changed' } + +/** 列出建议(可按 status 过滤) */ +export const listSuggestions = (status?: SuggestionFeedback) => + call( + SUGGESTION_IPC_CHANNELS.LIST, + status ? { status } : {}, + ) + +/** 用户三态反馈 → service.handleSuggestionFeedback(学习权重 + 动作分发) */ +export const actOnSuggestion = (id: number, feedback: SuggestionFeedback) => + call<{ ok: true }>(SUGGESTION_IPC_CHANNELS.ACT, { id, feedback }) + +/** 今日/累计统计 → store.suggestionStats */ +export const getSuggestionStats = () => + call(SUGGESTION_IPC_CHANNELS.STATS, {}) + +/** 删除单条 → store.deleteSuggestion */ +export const deleteSuggestion = (id: number) => + call<{ ok: true }>(SUGGESTION_IPC_CHANNELS.DELETE, { id }) + +/** 清空全部 → store.clearSuggestions */ +export const clearAllSuggestions = () => + call<{ ok: true }>(SUGGESTION_IPC_CHANNELS.CLEAR_ALL, {}) + +/** 触发 LLM 工作模式分析 → service.runAnalysisAndPersist;返回新增候选数 */ +export const runSuggestionAnalysis = (workspaceSlug?: string) => + call<{ added: number }>( + SUGGESTION_IPC_CHANNELS.RUN_ANALYSIS, + workspaceSlug ? { workspaceSlug } : {}, + ) + +/** 开关建议系统 → store.setEnabled */ +export const setSuggestionsEnabled = (enabled: boolean) => + call<{ ok: true }>(SUGGESTION_IPC_CHANNELS.SET_ENABLED, { enabled }) + +/** + * 建议变更推送订阅(sidecar → web notification)。 + * 与 planning-todo.ts 的 onPlanningTodoChange 同一模式:包一层 listen, + * 按 method 过滤 CHANGED。返回 unsubscribe(Promise<() => void>)。 + */ +export const onSuggestionsChanged = ( + listener: (signal: SuggestionsChangedSignal) => void, +) => + listen<{ method: string; params: unknown }>('sidecar:event', (event) => { + if (event.payload?.method === SUGGESTION_IPC_CHANNELS.CHANGED) + listener(event.payload.params as SuggestionsChangedSignal) + }) From be2e1bb116aaa498881e1cf1507879cd3286a466 Mon Sep 17 00:00:00 2001 From: Leo Date: Tue, 4 Aug 2026 01:15:29 +0800 Subject: [PATCH 16/24] =?UTF-8?q?=F0=9F=90=9B=20fix(web):=20listSuggestion?= =?UTF-8?q?s=20status=20=E7=B1=BB=E5=9E=8B=E5=AF=B9=E9=BD=90=E5=85=A8=204?= =?UTF-8?q?=20=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/src/lib/desktop-api/suggestion.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/lib/desktop-api/suggestion.ts b/apps/web/src/lib/desktop-api/suggestion.ts index f80323db7..669025d61 100644 --- a/apps/web/src/lib/desktop-api/suggestion.ts +++ b/apps/web/src/lib/desktop-api/suggestion.ts @@ -17,7 +17,7 @@ const call = (method: string, params: unknown) => export type SuggestionsChangedSignal = { type: 'suggestions_changed' } /** 列出建议(可按 status 过滤) */ -export const listSuggestions = (status?: SuggestionFeedback) => +export const listSuggestions = (status?: SuggestionRecord['status']) => call( SUGGESTION_IPC_CHANNELS.LIST, status ? { status } : {}, From 611032b9930eda2f6c4bf81604b706569874df63 Mon Sep 17 00:00:00 2001 From: Leo Date: Tue, 4 Aug 2026 01:21:19 +0800 Subject: [PATCH 17/24] =?UTF-8?q?=E2=9C=A8=20feat(web):=20SuggestionBanner?= =?UTF-8?q?=20=E4=B8=89=E6=80=81=E6=A8=AA=E5=B9=85=20+=20=E5=AE=9E?= =?UTF-8?q?=E6=97=B6=E8=AE=A2=E9=98=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task 14: SuggestionBanner 组件。订阅 suggestionsVersionAtom,过滤 thread+workspace+24h 未过期记录,渲染 接受/忽略/不再建议 三态按钮,点击后调 actOnSuggestion 并显式重拉(覆盖 Task 12 feedback 不广播的 gap)。仿 AgentView.test.tsx fake-DOM 测试模式。 Co-Authored-By: Claude Fable 5 --- .../agent/SuggestionBanner.test.tsx | 428 ++++++++++++++++++ .../src/components/agent/SuggestionBanner.tsx | 200 ++++++++ 2 files changed, 628 insertions(+) create mode 100644 apps/web/src/components/agent/SuggestionBanner.test.tsx create mode 100644 apps/web/src/components/agent/SuggestionBanner.tsx diff --git a/apps/web/src/components/agent/SuggestionBanner.test.tsx b/apps/web/src/components/agent/SuggestionBanner.test.tsx new file mode 100644 index 000000000..78bad30db --- /dev/null +++ b/apps/web/src/components/agent/SuggestionBanner.test.tsx @@ -0,0 +1,428 @@ +import { beforeEach, describe, expect, mock, test } from 'bun:test' +import React, { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { Provider, createStore } from 'jotai' +import { suggestionsVersionAtom } from '@/atoms' +import type { SuggestionFeedback, SuggestionRecord } from '@lume/shared' + +mock.restore() + +// ── desktop-api/suggestion mock ──────────────────────────────────────────── +const listSuggestionsMock = mock(async () => [] as SuggestionRecord[]) +const actOnSuggestionMock = mock(async () => ({ ok: true as const })) + +mock.module('@/lib/desktop-api/suggestion', () => ({ + listSuggestions: (...args: unknown[]) => + listSuggestionsMock(...(args as [SuggestionRecord['status']?])), + actOnSuggestion: (...args: unknown[]) => + actOnSuggestionMock(...(args as [number, SuggestionFeedback])), +})) + +// ── Button mock:捕获 onClick,绕过 fake DOM 不派发 React 合成事件 ──────── +// 仿 AgentView.test.tsx 用 latestAgentInputProps 捕获子组件 props 的模式。 +type CapturedButton = { + action: string + recordId: number + onClick: () => void + label: string +} +const capturedButtons: CapturedButton[] = [] + +mock.module('@/components/ui/button', () => ({ + Button: (props: { + children?: React.ReactNode + onClick?: () => void + 'data-suggestion-action'?: string + 'data-suggestion-record-id'?: number + 'aria-label'?: string + }) => { + const action = props['data-suggestion-action'] + const recordId = props['data-suggestion-record-id'] + if (action && typeof recordId === 'number' && typeof props.onClick === 'function') { + capturedButtons.push({ + action, + recordId, + onClick: props.onClick, + label: + typeof props['aria-label'] === 'string' + ? props['aria-label'] + : textOf(props.children), + }) + } + const children = Array.isArray(props.children) ? props.children : [props.children] + return React.createElement( + 'button', + { + type: 'button', + 'data-suggestion-action': action, + 'data-suggestion-record-id': recordId, + 'aria-label': props['aria-label'], + }, + ...children, + ) + }, +})) + +function textOf(node: React.ReactNode): string { + if (node == null || typeof node === 'boolean') return '' + if (typeof node === 'string' || typeof node === 'number') return String(node) + if (Array.isArray(node)) return node.map(textOf).join('') + return '' +} + +// ── fake DOM(仿 AgentView.test.tsx,仅保留 SuggestionBanner 所需最小集合)── +class FakeEventTarget { + parentNode: FakeEventTarget | null = null + childNodes: FakeEventTarget[] = [] + appendChild(node: T): T { + if (node.parentNode) node.parentNode.removeChild(node) + node.parentNode = this + this.childNodes.push(node) + return node + } + removeChild(node: T): T { + const i = this.childNodes.indexOf(node) + if (i >= 0) { + this.childNodes.splice(i, 1) + node.parentNode = null + } + return node + } + contains(target: unknown): boolean { + if (target === this) return true + return this.childNodes.some((c) => c.contains(target)) + } +} + +class FakeTextNode extends FakeEventTarget { + readonly nodeType = 3 as const + ownerDocument: FakeDocument + nodeValue: string + data: string + constructor(value: string, ownerDocument: FakeDocument) { + super() + this.ownerDocument = ownerDocument + this.nodeValue = value + this.data = value + } + get textContent() { + return this.nodeValue + } + set textContent(v: string) { + this.nodeValue = v + this.data = v + } +} + +class FakeElement extends FakeEventTarget { + readonly nodeType = 1 as const + ownerDocument: FakeDocument + tagName: string + nodeName: string + namespaceURI = 'http://www.w3.org/1999/xhtml' + attributes = new Map() + style: Record = {} + constructor(tagName: string, ownerDocument: FakeDocument) { + super() + this.ownerDocument = ownerDocument + this.tagName = tagName.toUpperCase() + this.nodeName = this.tagName + } + setAttribute(name: string, value: string) { + this.attributes.set(name, value) + } + setAttributeNS(_: string | null, name: string, value: string) { + this.setAttribute(name, value) + } + removeAttribute(name: string) { + this.attributes.delete(name) + } + addEventListener() {} + removeEventListener() {} + focus() {} + get textContent() { + return this.childNodes.map((c: any) => c.textContent ?? '').join('') + } + set textContent(v: string) { + this.childNodes = [] + if (v !== '') this.appendChild(this.ownerDocument.createTextNode(v)) + } +} + +class FakeDocument extends FakeEventTarget { + readonly nodeType = 9 as const + ownerDocument = this + documentElement: FakeElement + body: FakeElement + defaultView: typeof globalThis + activeElement: FakeElement + constructor() { + super() + this.documentElement = new FakeElement('html', this) + this.body = new FakeElement('body', this) + this.defaultView = globalThis + this.activeElement = this.body + this.appendChild(this.documentElement) + this.documentElement.appendChild(this.body) + } + createElement(tagName: string) { + return new FakeElement(tagName, this) + } + createElementNS(_: string | null, tagName: string) { + return new FakeElement(tagName, this) + } + createTextNode(value: string) { + return new FakeTextNode(value, this) + } + addEventListener() {} + removeEventListener() {} +} + +function installFakeDom() { + const keys = [ + 'IS_REACT_ACT_ENVIRONMENT', + 'document', + 'window', + 'self', + 'navigator', + 'Node', + 'Element', + 'HTMLElement', + 'HTMLIFrameElement', + 'Text', + 'requestAnimationFrame', + 'cancelAnimationFrame', + 'localStorage', + ] as const + const previousDescriptors = new Map() + for (const key of keys) { + previousDescriptors.set(key, Object.getOwnPropertyDescriptor(globalThis, key)) + } + const document = new FakeDocument() + const storage = new Map() + Object.assign(globalThis, { + IS_REACT_ACT_ENVIRONMENT: true, + document, + window: globalThis, + self: globalThis, + navigator: { userAgent: 'bun' }, + Node: FakeEventTarget, + Element: FakeElement, + HTMLElement: FakeElement, + HTMLIFrameElement: class extends FakeElement {}, + Text: FakeTextNode, + requestAnimationFrame: (cb: FrameRequestCallback) => setTimeout(() => cb(Date.now()), 0), + cancelAnimationFrame: (handle: ReturnType) => clearTimeout(handle), + localStorage: { + getItem: (k: string) => storage.get(k) ?? null, + setItem: (k: string, v: string) => { + storage.set(k, v) + }, + removeItem: (k: string) => { + storage.delete(k) + }, + }, + }) + return { + container: document.createElement('div'), + cleanup: () => { + for (const key of keys) { + const d = previousDescriptors.get(key) + if (d) Object.defineProperty(globalThis, key, d) + else Reflect.deleteProperty(globalThis, key) + } + }, + } +} + +async function flush() { + for (let i = 0; i < 8; i++) await Promise.resolve() + await new Promise((r) => setTimeout(r, 0)) +} + +const { SuggestionBanner, SUGGESTION_EXPIRY_MS } = await import('./SuggestionBanner') + +function makeRecord(overrides: Partial = {}): SuggestionRecord { + return { + id: 1, + duplicateKey: 'k1', + kind: 'followup', + title: '建议标题', + reason: '原因是这样', + evidence: '一段证据', + rawConfidence: 0.5, + action: { type: 'open_memory_board' }, + status: 'suggested', + createdAt: Date.now(), + threadId: 'thread-1', + workspaceSlug: 'ws', + ...overrides, + } +} + +async function render(props: { + threadId: string + workspaceSlug?: string + store?: ReturnType +}) { + const env = installFakeDom() + const store = props.store ?? createStore() + const root: Root | null = createRoot(env.container as never) + await act(async () => { + root!.render( + + + , + ) + await flush() + }) + return { ...env, store, root } +} + +async function unmount(env: { root: Root | null }) { + await act(async () => { + env.root?.unmount() + env.root = null + await flush() + }) +} + +function uniqueActionsFor(recordId: number): string[] { + return Array.from( + new Set(capturedButtons.filter((b) => b.recordId === recordId).map((b) => b.action)), + ).sort() +} + +describe('SuggestionBanner', () => { + beforeEach(() => { + listSuggestionsMock.mockReset() + actOnSuggestionMock.mockReset() + listSuggestionsMock.mockResolvedValue([]) + actOnSuggestionMock.mockResolvedValue({ ok: true as const }) + capturedButtons.length = 0 + }) + + test('无建议时不渲染任何容器', async () => { + listSuggestionsMock.mockResolvedValueOnce([]) + const env = await render({ threadId: 'thread-1', workspaceSlug: 'ws' }) + try { + expect(env.container.childNodes.length).toBe(0) + expect(env.container.textContent).toBe('') + } finally { + await unmount(env) + env.cleanup() + } + }) + + test('有建议时渲染三态按钮(接受/忽略/不再建议)+ 标题/原因/依据', async () => { + listSuggestionsMock.mockResolvedValueOnce([makeRecord({ id: 7 })]) + const env = await render({ threadId: 'thread-1', workspaceSlug: 'ws' }) + try { + const text = env.container.textContent ?? '' + expect(text).toContain('建议标题') + expect(text).toContain('原因是这样') + expect(text).toContain('依据:一段证据') + expect(uniqueActionsFor(7)).toEqual(['accepted', 'ignored', 'never']) + } finally { + await unmount(env) + env.cleanup() + } + }) + + test('点击忽略 → actOnSuggestion(id, "ignored") 被调 + 触发 listSuggestions 重拉 + banner 消失', async () => { + listSuggestionsMock + .mockResolvedValueOnce([makeRecord({ id: 9 })]) + .mockResolvedValueOnce([]) // act 后重拉返回空 + const env = await render({ threadId: 'thread-1', workspaceSlug: 'ws' }) + try { + const callsBefore = listSuggestionsMock.mock.calls.length + const ignoreBtn = capturedButtons.find((b) => b.recordId === 9 && b.action === 'ignored') + expect(ignoreBtn).toBeDefined() + + await act(async () => { + ignoreBtn!.onClick() + await flush() + }) + + expect(actOnSuggestionMock).toHaveBeenCalledWith(9, 'ignored') + expect(listSuggestionsMock.mock.calls.length).toBeGreaterThan(callsBefore) + // act 后 banner 消失 + expect(env.container.childNodes.length).toBe(0) + } finally { + await unmount(env) + env.cleanup() + } + }) + + test('会话隔离:仅渲染匹配 threadId + workspaceSlug 的建议', async () => { + listSuggestionsMock.mockResolvedValueOnce([ + makeRecord({ id: 1, threadId: 'thread-1', workspaceSlug: 'ws', title: '命中' }), + makeRecord({ id: 2, threadId: 'thread-2', workspaceSlug: 'ws', title: '他线程' }), + makeRecord({ id: 3, threadId: 'thread-1', workspaceSlug: 'other', title: '他工作区' }), + ]) + const env = await render({ threadId: 'thread-1', workspaceSlug: 'ws' }) + try { + const text = env.container.textContent ?? '' + expect(text).toContain('命中') + expect(text).not.toContain('他线程') + expect(text).not.toContain('他工作区') + expect(Array.from(new Set(capturedButtons.map((b) => b.recordId)))).toEqual([1]) + } finally { + await unmount(env) + env.cleanup() + } + }) + + test('workspaceSlug=undefined 时仅匹配 workspaceSlug 缺失的记录', async () => { + listSuggestionsMock.mockResolvedValueOnce([ + makeRecord({ id: 1, threadId: 'thread-1', workspaceSlug: undefined, title: '无工作区' }), + makeRecord({ id: 2, threadId: 'thread-1', workspaceSlug: 'ws', title: '有工作区' }), + ]) + const env = await render({ threadId: 'thread-1' }) + try { + const text = env.container.textContent ?? '' + expect(text).toContain('无工作区') + expect(text).not.toContain('有工作区') + } finally { + await unmount(env) + env.cleanup() + } + }) + + test('过期(>24h)不展示', async () => { + listSuggestionsMock.mockResolvedValueOnce([ + makeRecord({ + id: 1, + createdAt: Date.now() - SUGGESTION_EXPIRY_MS - 60_000, + title: '陈旧', + }), + makeRecord({ id: 2, createdAt: Date.now() - 1000, title: '新鲜' }), + ]) + const env = await render({ threadId: 'thread-1', workspaceSlug: 'ws' }) + try { + const text = env.container.textContent ?? '' + expect(text).not.toContain('陈旧') + expect(text).toContain('新鲜') + } finally { + await unmount(env) + env.cleanup() + } + }) + + test('suggestionsVersionAtom 版本号变化 → 触发 listSuggestions 重拉', async () => { + const store = createStore() + listSuggestionsMock.mockResolvedValue([]) + const env = await render({ threadId: 'thread-1', workspaceSlug: 'ws', store }) + try { + const callsBefore = listSuggestionsMock.mock.calls.length + await act(async () => { + store.set(suggestionsVersionAtom, 1) + await flush() + }) + expect(listSuggestionsMock.mock.calls.length).toBeGreaterThan(callsBefore) + } finally { + await unmount(env) + env.cleanup() + } + }) +}) diff --git a/apps/web/src/components/agent/SuggestionBanner.tsx b/apps/web/src/components/agent/SuggestionBanner.tsx new file mode 100644 index 000000000..fa19adf2c --- /dev/null +++ b/apps/web/src/components/agent/SuggestionBanner.tsx @@ -0,0 +1,200 @@ +import { useEffect, useState } from 'react' +import { useAtomValue } from 'jotai' +import { Ban, Check, Sparkles, X } from 'lucide-react' +import { suggestionsVersionAtom } from '@/atoms' +import { cn } from '@/lib/utils' +import { actOnSuggestion, listSuggestions } from '@/lib/desktop-api/suggestion' +import { Button } from '@/components/ui/button' +import type { SuggestionFeedback, SuggestionKind, SuggestionRecord } from '@lume/shared' + +/** + * 建议在 banner 中展示的渲染层 TTL:自 createdAt 起 24h 外不展示。 + * 与 sidecar 是否仍保留无关 —— 防止陈旧建议长期挂在输入框上方。 + */ +export const SUGGESTION_EXPIRY_MS = 24 * 60 * 60 * 1000 + +const KIND_LABEL: Record = { + correction: '修正', + followup: '跟进', + automation: '自动化', + todo: '待办', + skill: '技能', +} + +export interface SuggestionBannerProps { + threadId: string + workspaceSlug?: string +} + +/** + * 三态建议横幅。挂在 AgentInput 上方(Task 15 接入): + * 订阅 suggestionsVersionAtom → sidecar 推送 CHANGED 时 bump → 触发本组件重拉 + * suggestion:list("suggested");过滤到当前 thread + workspace 且未过期(24h)的记录。 + * + * 每条建议渲染为一张卡片:kind 标签 + 标题 + 原因 + 依据 + 三个反馈按钮 + * (接受 / 忽略 / 不再建议这类)。点击后调 actOnSuggestion 并显式重拉 —— + * feedback 不会触发 sidecar 的 CHANGED 广播(Task 12 gap),重拉确保 UI 立即更新。 + */ +export function SuggestionBanner({ threadId, workspaceSlug }: SuggestionBannerProps) { + const version = useAtomValue(suggestionsVersionAtom) + const [records, setRecords] = useState([]) + + useEffect(() => { + let cancelled = false + listSuggestions('suggested') + .then((all) => { + if (cancelled) return + setRecords(filterVisible(all, threadId, workspaceSlug)) + }) + .catch((err) => { + if (!cancelled) { + console.error('[SuggestionBanner] listSuggestions failed', err) + } + }) + return () => { + cancelled = true + } + }, [threadId, workspaceSlug, version]) + + const handleAct = async (id: number, feedback: SuggestionFeedback) => { + try { + await actOnSuggestion(id, feedback) + } catch (err) { + console.error('[SuggestionBanner] actOnSuggestion failed', err) + return + } + // Task 12 gap:feedback 不触发 sidecar 的 CHANGED 广播, + // 因此显式重拉一次,确保 UI 立即移除已处理的建议。 + try { + const all = await listSuggestions('suggested') + setRecords(filterVisible(all, threadId, workspaceSlug)) + } catch (err) { + console.error('[SuggestionBanner] reload after act failed', err) + } + } + + if (records.length === 0) return null + + return ( +
+
+ {records.map((record) => ( + + ))} +
+
+ ) +} + +function filterVisible( + all: SuggestionRecord[], + threadId: string, + workspaceSlug: string | undefined, +): SuggestionRecord[] { + const now = Date.now() + return all.filter( + (r) => + r.threadId === threadId && + r.workspaceSlug === workspaceSlug && + now - r.createdAt < SUGGESTION_EXPIRY_MS, + ) +} + +function SuggestionCard({ + record, + onAct, +}: { + record: SuggestionRecord + onAct: (id: number, feedback: SuggestionFeedback) => void +}) { + return ( +
+
+ +
+
+
+ + {KIND_LABEL[record.kind]} + +

+ {record.title} +

+
+ {record.reason && ( +

{record.reason}

+ )} + {record.evidence && ( +

依据:{record.evidence}

+ )} +
+
+ onAct(record.id, 'accepted')} + className="border-white/[0.12] bg-white/[0.04] text-[#d6d6d6] hover:border-white/[0.18] hover:bg-white/[0.10] hover:text-white" + > + + 接受 + + onAct(record.id, 'ignored')} + className="border-white/[0.10] bg-transparent text-[#9a9a9a] hover:border-white/[0.16] hover:bg-white/[0.06] hover:text-white" + > + + 忽略 + + onAct(record.id, 'never')} + className="border-white/[0.10] bg-transparent text-[#9a9a9a] hover:border-[#6e3c3c] hover:bg-[#3b2a2a] hover:text-[#ffc0c0]" + > + + 不再建议这类 + +
+
+ ) +} + +function ActionButton({ + action, + recordId, + onClick, + className, + children, + 'aria-label': ariaLabel, +}: { + action: SuggestionFeedback + recordId: number + onClick: () => void + className: string + children: React.ReactNode + 'aria-label'?: string +}) { + return ( + + ) +} From f1e1431a89e8064c774a78f43c8580938f75d866 Mon Sep 17 00:00:00 2001 From: Leo Date: Tue, 4 Aug 2026 01:25:06 +0800 Subject: [PATCH 18/24] =?UTF-8?q?=E2=9C=A8=20feat(web):=20AgentInput=20?= =?UTF-8?q?=E6=8C=82=E8=BD=BD=20SuggestionBanner?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/src/components/agent/AgentInput.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/web/src/components/agent/AgentInput.tsx b/apps/web/src/components/agent/AgentInput.tsx index bfd4e134b..4a96608be 100644 --- a/apps/web/src/components/agent/AgentInput.tsx +++ b/apps/web/src/components/agent/AgentInput.tsx @@ -81,6 +81,7 @@ import { syncPermissionModeWithPlanModePhase, } from './agent-input-state' import { AgentMessageQueueList } from './AgentMessageQueueList' +import { SuggestionBanner } from './SuggestionBanner' import { createEmptyAgentMessageQueueSnapshot, reorderQueuedMessages, @@ -1811,6 +1812,7 @@ export function AgentInput({ return (
+
Date: Tue, 4 Aug 2026 01:33:10 +0800 Subject: [PATCH 19/24] =?UTF-8?q?=E2=9C=A8=20feat(web):=20ProactiveHub=20?= =?UTF-8?q?=E4=B8=BB=E5=8A=A8=E4=B8=AD=E5=BF=83=E8=81=9A=E5=90=88=E8=A7=86?= =?UTF-8?q?=E5=9B=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../proactive/ProactiveHub.test.tsx | 682 ++++++++++++++++++ .../src/components/proactive/ProactiveHub.tsx | 569 +++++++++++++++ 2 files changed, 1251 insertions(+) create mode 100644 apps/web/src/components/proactive/ProactiveHub.test.tsx create mode 100644 apps/web/src/components/proactive/ProactiveHub.tsx diff --git a/apps/web/src/components/proactive/ProactiveHub.test.tsx b/apps/web/src/components/proactive/ProactiveHub.test.tsx new file mode 100644 index 000000000..706f22663 --- /dev/null +++ b/apps/web/src/components/proactive/ProactiveHub.test.tsx @@ -0,0 +1,682 @@ +import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test' +import React, { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { Provider, createStore } from 'jotai' +import { + agentWorkspacesAtom, + currentWorkspaceIdAtom, + suggestionsVersionAtom, +} from '@/atoms' +import type { + AutomationJob, + MemorySettingsSnapshot, + SuggestionFeedback, + SuggestionRecord, + SuggestionStats, +} from '@lume/shared' + +mock.restore() + +// ── data source mocks ─────────────────────────────────────────────────────── +const listSuggestionsMock = mock(async () => [] as SuggestionRecord[]) +const actOnSuggestionMock = mock(async () => ({ ok: true as const })) +const deleteSuggestionMock = mock(async () => ({ ok: true as const })) +const getSuggestionStatsMock = mock(async () => emptyStats()) +const runSuggestionAnalysisMock = mock(async () => ({ added: 0 })) + +mock.module('@/lib/desktop-api/suggestion', () => ({ + listSuggestions: (...args: unknown[]) => + listSuggestionsMock(...(args as [SuggestionRecord['status']?])), + actOnSuggestion: (...args: unknown[]) => + actOnSuggestionMock(...(args as [number, SuggestionFeedback])), + deleteSuggestion: (...args: unknown[]) => + deleteSuggestionMock(...(args as [number])), + getSuggestionStats: () => getSuggestionStatsMock(), + runSuggestionAnalysis: (...args: unknown[]) => + runSuggestionAnalysisMock(...(args as [string?])), +})) + +const listAutomationJobsMock = mock(async () => [] as AutomationJob[]) +mock.module('@/lib/desktop-api/automation', () => ({ + listAutomationJobs: () => listAutomationJobsMock(), +})) + +const getMemorySettingsSnapshotMock = mock( + async () => null as MemorySettingsSnapshot | null, +) +mock.module('@/lib/desktop-api', () => ({ + getMemorySettingsSnapshot: (...args: unknown[]) => + getMemorySettingsSnapshotMock(...(args as [string])), +})) + +const toastSuccessMock = mock((_msg: string) => undefined) +const toastErrorMock = mock((_msg: string) => undefined) +mock.module('sonner', () => ({ + toast: { + success: (msg: string) => toastSuccessMock(msg), + error: (msg: string) => toastErrorMock(msg), + }, +})) + +// ── Button mock:捕获 onClick(仿 AgentView/SuggestionBanner test 的 props 捕获模式)── +type CapturedClick = { key: string; onClick: () => void; disabled?: boolean } +const capturedClicks: CapturedClick[] = [] + +function captureKey(props: Record): string { + // data-suggestion-action="" + data-suggestion-record-id= → "action:id" + const action = props['data-suggestion-action'] + if (action !== undefined) { + const recordId = props['data-suggestion-record-id'] + return recordId !== undefined ? `${action}:${recordId}` : String(action) + } + // data-suggestion-delete= → "delete:id" + const del = props['data-suggestion-delete'] + if (del !== undefined) return `delete:${del}` + // data-proactive-analyze / data-proactive-open-memory → 布尔标记 + if (props['data-proactive-analyze'] !== undefined) return 'analyze' + if (props['data-proactive-open-memory'] !== undefined) return 'open-memory' + return '' +} + +mock.module('@/components/ui/button', () => ({ + Button: (props: { + children?: React.ReactNode + onClick?: () => void + disabled?: boolean + [key: string]: unknown + }) => { + const key = captureKey(props) + if (key && typeof props.onClick === 'function') { + capturedClicks.push({ + key, + onClick: props.onClick, + disabled: props.disabled, + }) + } + const children = Array.isArray(props.children) + ? props.children + : [props.children] + return React.createElement( + 'button', + { + type: 'button', + 'data-captured': key || undefined, + disabled: props.disabled, + }, + ...children, + ) + }, +})) + +// ── fake DOM(仿 AgentView.test.tsx / SuggestionBanner.test.tsx)───────────── +class FakeEventTarget { + parentNode: FakeEventTarget | null = null + childNodes: FakeEventTarget[] = [] + appendChild(node: T): T { + if (node.parentNode) node.parentNode.removeChild(node) + node.parentNode = this + this.childNodes.push(node) + return node + } + removeChild(node: T): T { + const i = this.childNodes.indexOf(node) + if (i >= 0) { + this.childNodes.splice(i, 1) + node.parentNode = null + } + return node + } + contains(target: unknown): boolean { + if (target === this) return true + return this.childNodes.some((c) => c.contains(target)) + } +} + +class FakeTextNode extends FakeEventTarget { + readonly nodeType = 3 as const + ownerDocument: FakeDocument + nodeValue: string + data: string + constructor(value: string, ownerDocument: FakeDocument) { + super() + this.ownerDocument = ownerDocument + this.nodeValue = value + this.data = value + } + get textContent() { + return this.nodeValue + } + set textContent(v: string) { + this.nodeValue = v + this.data = v + } +} + +class FakeElement extends FakeEventTarget { + readonly nodeType = 1 as const + ownerDocument: FakeDocument + tagName: string + nodeName: string + namespaceURI = 'http://www.w3.org/1999/xhtml' + attributes = new Map() + style: Record = {} + constructor(tagName: string, ownerDocument: FakeDocument) { + super() + this.ownerDocument = ownerDocument + this.tagName = tagName.toUpperCase() + this.nodeName = this.tagName + } + setAttribute(name: string, value: string) { + this.attributes.set(name, value) + } + setAttributeNS(_: string | null, name: string, value: string) { + this.setAttribute(name, value) + } + removeAttribute(name: string) { + this.attributes.delete(name) + } + addEventListener() {} + removeEventListener() {} + focus() {} + get textContent() { + return this.childNodes.map((c: any) => c.textContent ?? '').join('') + } + set textContent(v: string) { + this.childNodes = [] + if (v !== '') this.appendChild(this.ownerDocument.createTextNode(v)) + } +} + +class FakeDocument extends FakeEventTarget { + readonly nodeType = 9 as const + ownerDocument = this + documentElement: FakeElement + body: FakeElement + defaultView: typeof globalThis + activeElement: FakeElement + constructor() { + super() + this.documentElement = new FakeElement('html', this) + this.body = new FakeElement('body', this) + this.defaultView = globalThis + this.activeElement = this.body + this.appendChild(this.documentElement) + this.documentElement.appendChild(this.body) + } + createElement(tagName: string) { + return new FakeElement(tagName, this) + } + createElementNS(_: string | null, tagName: string) { + return new FakeElement(tagName, this) + } + createTextNode(value: string) { + return new FakeTextNode(value, this) + } + addEventListener() {} + removeEventListener() {} +} + +function installFakeDom() { + const keys = [ + 'IS_REACT_ACT_ENVIRONMENT', + 'document', + 'window', + 'self', + 'navigator', + 'Node', + 'Element', + 'HTMLElement', + 'HTMLIFrameElement', + 'Text', + 'requestAnimationFrame', + 'cancelAnimationFrame', + 'localStorage', + ] as const + const previousDescriptors = new Map< + PropertyKey, + PropertyDescriptor | undefined + >() + for (const key of keys) { + previousDescriptors.set(key, Object.getOwnPropertyDescriptor(globalThis, key)) + } + const document = new FakeDocument() + const storage = new Map() + Object.assign(globalThis, { + IS_REACT_ACT_ENVIRONMENT: true, + document, + window: globalThis, + self: globalThis, + navigator: { userAgent: 'bun' }, + Node: FakeEventTarget, + Element: FakeElement, + HTMLElement: FakeElement, + HTMLIFrameElement: class extends FakeElement {}, + Text: FakeTextNode, + requestAnimationFrame: (cb: FrameRequestCallback) => + setTimeout(() => cb(Date.now()), 0), + cancelAnimationFrame: (handle: ReturnType) => + clearTimeout(handle), + localStorage: { + getItem: (k: string) => storage.get(k) ?? null, + setItem: (k: string, v: string) => { + storage.set(k, v) + }, + removeItem: (k: string) => { + storage.delete(k) + }, + }, + }) + return { + container: document.createElement('div'), + cleanup: () => { + for (const key of keys) { + const d = previousDescriptors.get(key) + if (d) Object.defineProperty(globalThis, key, d) + else Reflect.deleteProperty(globalThis, key) + } + }, + } +} + +async function flush() { + for (let i = 0; i < 8; i++) await Promise.resolve() + await new Promise((r) => setTimeout(r, 0)) +} + +const { ProactiveHub } = await import('./ProactiveHub') + +// ── fixture builders ──────────────────────────────────────────────────────── +function emptyStats(): SuggestionStats { + return { + suggestedCount: 0, + todayAccepted: 0, + todayIgnored: 0, + todayNever: 0, + typeWeights: { + correction: 1, + followup: 1, + automation: 1, + todo: 1, + skill: 1, + }, + } +} + +function makeRecord(overrides: Partial = {}): SuggestionRecord { + return { + id: 1, + duplicateKey: 'k1', + kind: 'followup', + title: '建议标题', + reason: '原因是这样', + evidence: '一段证据', + rawConfidence: 0.5, + action: { type: 'open_memory_board' }, + status: 'suggested', + createdAt: Date.now(), + ...overrides, + } +} + +function makeJob(overrides: Partial = {}): AutomationJob { + return { + id: 'job-1', + name: '每日回顾', + enabled: true, + schedule: { type: 'cron', cronExpr: '0 9 * * *' }, + prompt: '总结今天的进展', + createdAt: 1, + updatedAt: 2, + ...overrides, + } +} + +function makeSnapshot( + overrides: Partial = {}, +): MemorySettingsSnapshot { + return { + workspaceSlug: 'ws', + counts: { + active: 12, + workspace: 5, + global: 7, + suspectedStale: 0, + pinned: 0, + daily: 0, + runs: 0, + pending: { conflicts: 1, stale: 0, lowConfidence: 0, total: 1 }, + }, + files: [], + workspaceEntries: [], + globalEntries: [], + pending: [], + extraction: { source: 'disabled', message: '' }, + retrieval: { + semantic: { message: '', mode: 'auto' }, + } as MemorySettingsSnapshot['retrieval'], + ...overrides, + } +} + +async function render(options?: { + store?: ReturnType + onOpenMemorySettings?: () => void +}) { + const env = installFakeDom() + const store = options?.store ?? createStore() + const root: Root | null = createRoot(env.container as never) + await act(async () => { + root!.render( + + + , + ) + await flush() + }) + return { ...env, store, root } +} + +async function unmount(env: { root: Root | null }) { + await act(async () => { + env.root?.unmount() + env.root = null + await flush() + }) +} + +function clicksFor(keyPart: string): CapturedClick[] { + return capturedClicks.filter( + (c) => c.key === keyPart || c.key.startsWith(`${keyPart}:`), + ) +} + +/** 去重:同一 data-* 标记的按钮每次 re-render 都会被捕获,按 key 去重后计数。 */ +function uniqueKeys(keyPart: string): string[] { + return Array.from( + new Set( + clicksFor(keyPart).map((c) => c.key), + ), + ) +} + +describe('ProactiveHub', () => { + beforeEach(() => { + listSuggestionsMock.mockReset() + listSuggestionsMock.mockResolvedValue([]) + actOnSuggestionMock.mockReset() + actOnSuggestionMock.mockResolvedValue({ ok: true as const }) + deleteSuggestionMock.mockReset() + deleteSuggestionMock.mockResolvedValue({ ok: true as const }) + getSuggestionStatsMock.mockReset() + getSuggestionStatsMock.mockResolvedValue(emptyStats()) + runSuggestionAnalysisMock.mockReset() + runSuggestionAnalysisMock.mockResolvedValue({ added: 0 }) + listAutomationJobsMock.mockReset() + listAutomationJobsMock.mockResolvedValue([]) + getMemorySettingsSnapshotMock.mockReset() + getMemorySettingsSnapshotMock.mockResolvedValue(null) + toastSuccessMock.mockReset() + toastErrorMock.mockReset() + capturedClicks.length = 0 + }) + + afterEach(async () => { + await flush() + }) + + test('渲染:标题 + 副标题 + 4 统计卡 + 全部 section(含建议/自动化/记忆)', async () => { + listSuggestionsMock.mockResolvedValueOnce([ + makeRecord({ id: 7, title: '跟进老王', reason: '三天未回复' }), + ]) + getSuggestionStatsMock.mockResolvedValueOnce({ + ...emptyStats(), + suggestedCount: 3, + todayAccepted: 2, + }) + listAutomationJobsMock.mockResolvedValueOnce([ + makeJob({ id: 'a', name: '每日回顾' }), + makeJob({ id: 'b', name: '周报草稿', schedule: { type: 'interval', intervalMs: 60_000 } }), + ]) + getMemorySettingsSnapshotMock.mockResolvedValueOnce( + makeSnapshot({ + counts: { + active: 12, + workspace: 5, + global: 7, + suspectedStale: 0, + pinned: 0, + daily: 0, + runs: 0, + pending: { conflicts: 1, stale: 0, lowConfidence: 0, total: 1 }, + }, + pending: [ + { + id: 'p1', + path: 'mem/p.md', + type: 'conflict', + status: 'open', + created: '2026-08-01', + statement: '候选记忆A', + reason: '与现有记忆冲突', + existingIds: [], + candidate: { + id: 'c1', + scope: 'workspace', + statement: '候选记忆A', + kind: 'fact', + confidence: 'medium', + tags: [], + }, + existingEntries: [], + }, + ], + }), + ) + const store = createStore() + store.set(agentWorkspacesAtom, [ + { id: 'w1', name: 'Lume', slug: 'ws', createdAt: 1, updatedAt: 2 }, + ]) + store.set(currentWorkspaceIdAtom, 'w1') + const env = await render({ store }) + try { + const text = env.container.textContent ?? '' + // header + expect(text).toContain('主动中心') + expect(text).toMatch(/关注 \d+ 件事/) + // stat tiles + expect(text).toContain('主动任务') + expect(text).toContain('待定建议') + expect(text).toContain('长期记忆') + expect(text).toContain('今日采纳') + // suggestions section + expect(text).toContain('Proma 建议') + expect(text).toContain('跟进老王') + expect(text).toContain('三天未回复') + // automations section + expect(text).toContain('正在关注') + expect(text).toContain('每日回顾') + expect(text).toContain('周报草稿') + // pending section + expect(text).toContain('需要确认') + expect(text).toContain('候选记忆A') + // persona placeholder + expect(text).toContain('用户画像') + // 三态按钮存在 + expect(uniqueKeys('accepted')).toHaveLength(1) + expect(uniqueKeys('ignored')).toHaveLength(1) + expect(uniqueKeys('never')).toHaveLength(1) + // 删除按钮存在 + expect(uniqueKeys('delete')).toHaveLength(1) + // analyze button 存在 + expect(uniqueKeys('analyze')).toHaveLength(1) + } finally { + await unmount(env) + env.cleanup() + } + }) + + test('空状态:各数据源为空时渲染空提示', async () => { + const env = await render() + try { + const text = env.container.textContent ?? '' + expect(text).toContain('暂无待定建议') + expect(text).toContain('暂无活跃的自动化任务') + expect(text).toContain('暂无待确认记忆') + expect(text).toContain('用户画像') + } finally { + await unmount(env) + env.cleanup() + } + }) + + test('分析按钮 → 调 runSuggestionAnalysis + toast + 重拉', async () => { + runSuggestionAnalysisMock.mockResolvedValueOnce({ added: 2 }) + // 首次加载 0 次,点击后重拉再 1 次 + const env = await render() + try { + const callsBefore = runSuggestionAnalysisMock.mock.calls.length + const listCallsBefore = listSuggestionsMock.mock.calls.length + const analyze = clicksFor('analyze')[0] + expect(analyze).toBeDefined() + + await act(async () => { + analyze!.onClick() + await flush() + }) + + expect(runSuggestionAnalysisMock.mock.calls.length).toBeGreaterThan( + callsBefore, + ) + expect(toastSuccessMock).toHaveBeenCalled() + // reload happened + expect(listSuggestionsMock.mock.calls.length).toBeGreaterThan( + listCallsBefore, + ) + } finally { + await unmount(env) + env.cleanup() + } + }) + + test('建议「接受」按钮 → 调 actOnSuggestion(id,"accepted") + 重拉列表', async () => { + listSuggestionsMock + .mockResolvedValueOnce([makeRecord({ id: 9 })]) + .mockResolvedValueOnce([]) + const env = await render() + try { + const listCallsBefore = listSuggestionsMock.mock.calls.length + const accept = clicksFor('accepted').find((c) => c.key.endsWith(':9')) + expect(accept).toBeDefined() + + await act(async () => { + accept!.onClick() + await flush() + }) + + expect(actOnSuggestionMock).toHaveBeenCalledWith(9, 'accepted') + expect(listSuggestionsMock.mock.calls.length).toBeGreaterThan( + listCallsBefore, + ) + } finally { + await unmount(env) + env.cleanup() + } + }) + + test('建议删除按钮 → 调 deleteSuggestion(id) + 重拉列表', async () => { + listSuggestionsMock + .mockResolvedValueOnce([makeRecord({ id: 5 })]) + .mockResolvedValueOnce([]) + const env = await render() + try { + const listCallsBefore = listSuggestionsMock.mock.calls.length + const del = clicksFor('delete').find((c) => c.key.endsWith(':5')) + expect(del).toBeDefined() + + await act(async () => { + del!.onClick() + await flush() + }) + + expect(deleteSuggestionMock).toHaveBeenCalledWith(5) + expect(listSuggestionsMock.mock.calls.length).toBeGreaterThan( + listCallsBefore, + ) + } finally { + await unmount(env) + env.cleanup() + } + }) + + test('suggestionsVersionAtom 变化 → 触发重拉', async () => { + const env = await render() + try { + const callsAfterMount = listSuggestionsMock.mock.calls.length + await act(async () => { + env.store.set(suggestionsVersionAtom, (n: number) => n + 1) + await flush() + }) + expect(listSuggestionsMock.mock.calls.length).toBeGreaterThan( + callsAfterMount, + ) + } finally { + await unmount(env) + env.cleanup() + } + }) + + test('memory pending section 渲染条目且「管理记忆」按钮触发 onOpenMemorySettings', async () => { + getMemorySettingsSnapshotMock.mockResolvedValueOnce( + makeSnapshot({ + pending: [ + { + id: 'p1', + path: 'mem/p.md', + type: 'conflict', + status: 'open', + created: '2026-08-01', + statement: '记住我偏好深色', + reason: '与现有「浅色」冲突', + existingIds: [], + candidate: { + id: 'c1', + scope: 'workspace', + statement: '记住我偏好深色', + kind: 'preference', + confidence: 'medium', + tags: [], + }, + existingEntries: [], + }, + ], + }), + ) + const store = createStore() + store.set(agentWorkspacesAtom, [ + { id: 'w1', name: 'Lume', slug: 'ws', createdAt: 1, updatedAt: 2 }, + ]) + store.set(currentWorkspaceIdAtom, 'w1') + + let opened = false + const env = await render({ + store, + onOpenMemorySettings: () => { + opened = true + }, + }) + try { + const text = env.container.textContent ?? '' + expect(text).toContain('记住我偏好深色') + const openBtn = clicksFor('open-memory')[0] + expect(openBtn).toBeDefined() + await act(async () => { + openBtn!.onClick() + await flush() + }) + expect(opened).toBe(true) + } finally { + await unmount(env) + env.cleanup() + } + }) +}) diff --git a/apps/web/src/components/proactive/ProactiveHub.tsx b/apps/web/src/components/proactive/ProactiveHub.tsx new file mode 100644 index 000000000..8f8bb1abe --- /dev/null +++ b/apps/web/src/components/proactive/ProactiveHub.tsx @@ -0,0 +1,569 @@ +import { useCallback, useEffect, useState } from 'react' +import { useAtomValue } from 'jotai' +import { + Ban, + Check, + Inbox, + Sparkles, + Trash2, + Wand2, + X, +} from 'lucide-react' +import { toast } from 'sonner' +import { + agentWorkspacesAtom, + currentWorkspaceIdAtom, + suggestionsVersionAtom, +} from '@/atoms' +import { Button } from '@/components/ui/button' +import { listAutomationJobs } from '@/lib/desktop-api/automation' +import { getMemorySettingsSnapshot } from '@/lib/desktop-api' +import { + actOnSuggestion, + deleteSuggestion, + getSuggestionStats, + listSuggestions, + runSuggestionAnalysis, +} from '@/lib/desktop-api/suggestion' +import { cn } from '@/lib/utils' +import type { + AutomationJob, + AutomationSchedule, + MemorySettingsPendingSummary, + MemorySettingsSnapshot, + SuggestionFeedback, + SuggestionKind, + SuggestionRecord, + SuggestionStats, +} from '@lume/shared' + +const KIND_LABEL: Record = { + correction: '修正', + followup: '跟进', + automation: '自动化', + todo: '待办', + skill: '技能', +} + +/** + * 主动中心:聚合建议 / 自动化 / 待确认记忆 / 用户画像 的单一视图。 + * Task 17 会将其挂到侧栏作为独立入口。 + * + * 数据并发拉取(Promise.all),任一失败不阻塞其它 section; + * 订阅 suggestionsVersionAtom → sidecar 推送 CHANGED 时 bump → 触发重拉。 + */ +export interface ProactiveHubProps { + /** 打开「设置 → 记忆」面板。由 Task 17 父组件注入;未提供时隐藏「管理记忆」按钮。 */ + onOpenMemorySettings?: () => void +} + +export function ProactiveHub({ onOpenMemorySettings }: ProactiveHubProps) { + const version = useAtomValue(suggestionsVersionAtom) + const currentWorkspaceId = useAtomValue(currentWorkspaceIdAtom) + const workspaces = useAtomValue(agentWorkspacesAtom) + const workspaceSlug = + workspaces.find((w) => w.id === currentWorkspaceId)?.slug ?? + workspaces[0]?.slug + + const [suggestions, setSuggestions] = useState([]) + const [automations, setAutomations] = useState([]) + const [snapshot, setSnapshot] = useState(null) + const [stats, setStats] = useState(null) + const [analyzing, setAnalyzing] = useState(false) + const [busyId, setBusyId] = useState(null) + + /** + * 并发拉取四个独立数据源;每个源各自 catch → 失败仅降级为该 section 的空状态, + * 不影响其它 section 展示(Promise.all + per-task catch 等价 allSettled 但更直白)。 + */ + const refresh = useCallback(async () => { + await Promise.all([ + listSuggestions('suggested') + .then(setSuggestions) + .catch((err) => { + console.error('[ProactiveHub] listSuggestions failed', err) + }), + listAutomationJobs() + .then((jobs) => setAutomations(jobs.filter((job) => job.enabled))) + .catch((err) => { + console.error('[ProactiveHub] listAutomationJobs failed', err) + setAutomations([]) + }), + getSuggestionStats() + .then(setStats) + .catch((err) => { + console.error('[ProactiveHub] getSuggestionStats failed', err) + }), + workspaceSlug + ? getMemorySettingsSnapshot(workspaceSlug) + .then(setSnapshot) + .catch((err) => { + console.error( + '[ProactiveHub] getMemorySettingsSnapshot failed', + err, + ) + setSnapshot(null) + }) + : Promise.resolve(), + ]) + if (!workspaceSlug) setSnapshot(null) + }, [workspaceSlug]) + + useEffect(() => { + void refresh() + }, [refresh, version]) + + const reloadSuggestions = useCallback(async () => { + const [list, nextStats] = await Promise.all([ + listSuggestions('suggested'), + getSuggestionStats().catch(() => null), + ]) + setSuggestions(list) + if (nextStats) setStats(nextStats) + }, []) + + const analyze = async () => { + setAnalyzing(true) + try { + const result = await runSuggestionAnalysis(workspaceSlug) + toast.success( + result.added > 0 + ? `分析完成,新增 ${result.added} 条建议` + : '分析完成,暂无新建议', + ) + await reloadSuggestions() + } catch (err) { + toast.error(err instanceof Error ? err.message : '工作模式分析失败') + } finally { + setAnalyzing(false) + } + } + + const handleAct = async (id: number, feedback: SuggestionFeedback) => { + setBusyId(id) + try { + await actOnSuggestion(id, feedback) + await reloadSuggestions() + } catch (err) { + toast.error(err instanceof Error ? err.message : '反馈失败') + } finally { + setBusyId(null) + } + } + + const handleDelete = async (id: number) => { + setBusyId(id) + try { + await deleteSuggestion(id) + await reloadSuggestions() + } catch (err) { + toast.error(err instanceof Error ? err.message : '删除失败') + } finally { + setBusyId(null) + } + } + + const pendingItems = (snapshot?.pending ?? []).filter( + (item) => item.status === 'open', + ) + const pendingCount = snapshot?.counts.pending.total ?? pendingItems.length + const memoryCount = snapshot?.counts.active ?? null + const focusTotal = + automations.length + suggestions.length + pendingCount + + return ( +
+
+
+
+

主动中心

+

+ 关注 {focusTotal} 件事 + {suggestions.length} 条建议待定 +

+
+ +
+
+ +
+
+ + + + +
+ +
}> + {suggestions.length === 0 ? ( + + ) : ( +
+ {suggestions.map((record) => ( + handleAct(record.id, feedback)} + onDelete={() => handleDelete(record.id)} + /> + ))} +
+ )} +
+ +
}> + {automations.length === 0 ? ( + + ) : ( +
+ {automations.map((job) => ( + + ))} +
+ )} +
+ +
}> + {pendingCount === 0 ? ( + + ) : ( +
+ {pendingItems.length === 0 ? ( + + ) : ( + pendingItems.map((item) => ( + + )) + )} + {onOpenMemorySettings && ( +
+ +
+ )} +
+ )} +
+ +
}> + +
+
+
+ ) +} + +function StatTile({ + label, + value, + ...rest +}: { + label: string + value: number | string +} & Record) { + return ( +
+
{label}
+
{value}
+
+ ) +} + +function Section({ + title, + icon, + children, +}: { + title: string + icon: React.ReactNode + children: React.ReactNode +}) { + return ( +
+

+ {icon} + {title} +

+ {children} +
+ ) +} + +function EmptyState({ text, hint }: { text: string; hint?: string }) { + return ( +
+
+ +
+
{text}
+ {hint && ( +

+ {hint} +

+ )} +
+ ) +} + +function SuggestionRow({ + record, + busy, + onAct, + onDelete, +}: { + record: SuggestionRecord + busy: boolean + onAct: (feedback: SuggestionFeedback) => void + onDelete: () => void +}) { + return ( +
+
+
+ + {KIND_LABEL[record.kind]} + +

{record.title}

+
+ {record.reason && ( +

+ {record.reason} +

+ )} + {record.evidence && ( +

+ 依据:{record.evidence} +

+ )} +
+
+ onAct('accepted')} + disabled={busy} + > + + 接受 + + onAct('ignored')} + disabled={busy} + > + + 忽略 + + onAct('never')} + disabled={busy} + > + + 不再建议这类 + + +
+
+ ) +} + +function SuggestionButton({ + action, + recordId, + onClick, + disabled, + className, + children, + 'aria-label': ariaLabel, +}: { + action: SuggestionFeedback + recordId: number + onClick: () => void + disabled?: boolean + className?: string + children: React.ReactNode + 'aria-label'?: string +}) { + return ( + + ) +} + +function AutomationRow({ job }: { job: AutomationJob }) { + return ( +
+
+
{job.name}
+ {job.description && ( +

+ {job.description} +

+ )} +
+ + {formatSchedule(job.schedule)} + +
+ ) +} + +function PendingMemoryRow({ item }: { item: MemorySettingsPendingSummary }) { + return ( +
+
+ + {PENDING_LABEL[item.type]} + + {item.candidate.scope === 'global' ? '全局' : '工作区'} +
+

{item.candidate.statement}

+ {item.reason && ( +

+ {item.reason} +

+ )} +
+ ) +} + +const PENDING_LABEL: Record = { + conflict: '冲突', + stale: '过期', + 'low-confidence': '低置信', +} + +/** + * 把 AutomationSchedule 渲染为人类可读的简短文案。 + * cron → 「每天 09:00」「每周一 09:00」等常见模式;非常规 cron 直接显示表达式。 + */ +export function formatSchedule(schedule: AutomationSchedule): string { + switch (schedule.type) { + case 'manual': + return '手动' + case 'once': + return schedule.runAt + ? `单次 · ${new Date(schedule.runAt).toLocaleString()}` + : '单次' + case 'interval': + return schedule.intervalMs + ? `每 ${formatInterval(schedule.intervalMs)}` + : '固定间隔' + case 'cron': + return describeCron(schedule.cronExpr ?? '') + default: + return '—' + } +} + +function formatInterval(ms: number): string { + const minutes = Math.round(ms / 60_000) + if (minutes < 60) return `${minutes} 分钟` + const hours = Math.round(minutes / 60) + if (hours < 24) return `${hours} 小时` + return `${Math.round(hours / 24)} 天` +} + +/** 仅覆盖最常见 cron 模式;未识别时回退到原始表达式。 */ +function describeCron(expr: string): string { + const parts = expr.trim().split(/\s+/) + if (parts.length !== 5) return expr + const [minute, hour, , , weekday] = parts + if (!/^\d+$/.test(minute) || !/^\d+$/.test(hour)) return expr + const hh = Number(hour).toString().padStart(2, '0') + const mm = Number(minute).toString().padStart(2, '0') + if (weekday === '*') return `每天 ${hh}:${mm}` + if (/^\d+$/.test(weekday)) return `每${WEEKDAY_LABEL[weekday] ?? weekday} ${hh}:${mm}` + if (weekday === '1-5') return `工作日 ${hh}:${mm}` + return expr +} + +const WEEKDAY_LABEL: Record = { + '0': '周日', + '1': '周一', + '2': '周二', + '3': '周三', + '4': '周四', + '5': '周五', + '6': '周六', + '7': '周日', +} From d01467d2d1ceaceb88cb2273fafd38d12f058aa7 Mon Sep 17 00:00:00 2001 From: Leo Date: Tue, 4 Aug 2026 01:38:48 +0800 Subject: [PATCH 20/24] =?UTF-8?q?=E2=9C=A8=20feat(web):=20=E4=BE=A7?= =?UTF-8?q?=E6=A0=8F=E6=96=B0=E5=A2=9E=E3=80=8C=E4=B8=BB=E5=8A=A8=E3=80=8D?= =?UTF-8?q?=E5=85=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/src/atoms/tab-atoms.ts | 2 +- .../src/components/app-shell/LeftSidebar.tsx | 11 +++++++++++ .../src/components/app-shell/LumeSidebar.tsx | 3 +++ .../app-shell/lume-sidebar-view-model.ts | 3 ++- apps/web/src/components/tabs/TabContent.tsx | 17 ++++++++++++++++- 5 files changed, 33 insertions(+), 3 deletions(-) diff --git a/apps/web/src/atoms/tab-atoms.ts b/apps/web/src/atoms/tab-atoms.ts index beb6cc8df..650527a7d 100644 --- a/apps/web/src/atoms/tab-atoms.ts +++ b/apps/web/src/atoms/tab-atoms.ts @@ -2,7 +2,7 @@ import { atom } from 'jotai' import { atomWithStorage } from 'jotai/utils' import type { DesktopContextTarget } from '@lume/shared' -export type TabType = 'agent' | 'settings' | 'welcome' | 'automation' | 'skills' | 'reading' | 'lume' | 'todo' | 'file' | 'browser' +export type TabType = 'agent' | 'settings' | 'welcome' | 'automation' | 'skills' | 'reading' | 'lume' | 'todo' | 'file' | 'browser' | 'proactive' export type SettingsTab = 'channel' | 'agent' | 'mcp' | 'about' export type FileTabSource = 'workspace' | 'thread' | 'local' diff --git a/apps/web/src/components/app-shell/LeftSidebar.tsx b/apps/web/src/components/app-shell/LeftSidebar.tsx index 5c76128a7..07ae50577 100644 --- a/apps/web/src/components/app-shell/LeftSidebar.tsx +++ b/apps/web/src/components/app-shell/LeftSidebar.tsx @@ -279,6 +279,14 @@ export function LeftSidebar({ forceCollapsed = false }: { forceCollapsed?: boole } } + const openProactive = () => { + const proactiveId = '__proactive__' + setActiveTabId(proactiveId) + if (!tabs.find((tab) => tab.id === proactiveId)) { + setTabs((previous) => [...previous, { id: proactiveId, type: 'proactive', title: '主动' }]) + } + } + const togglePin = async (threadId: string) => { const thread = threads.find((item) => item.id === threadId) if (!thread) return @@ -456,6 +464,9 @@ export function LeftSidebar({ forceCollapsed = false }: { forceCollapsed?: boole case 'todos': openTodos() return + case 'proactive': + openProactive() + return } } diff --git a/apps/web/src/components/app-shell/LumeSidebar.tsx b/apps/web/src/components/app-shell/LumeSidebar.tsx index 214153c4d..66d7816f7 100644 --- a/apps/web/src/components/app-shell/LumeSidebar.tsx +++ b/apps/web/src/components/app-shell/LumeSidebar.tsx @@ -13,6 +13,7 @@ import { BookOpen, Bot, ListTodo, + Sparkles, } from 'lucide-react' import { cn } from '@/lib/utils' import type { @@ -360,6 +361,8 @@ function renderIcon(icon: string, size: number) { return case 'list-todo': return + case 'sparkles': + return case 'folder': return case 'trash': diff --git a/apps/web/src/components/app-shell/lume-sidebar-view-model.ts b/apps/web/src/components/app-shell/lume-sidebar-view-model.ts index 88ac96749..b7d986fd4 100644 --- a/apps/web/src/components/app-shell/lume-sidebar-view-model.ts +++ b/apps/web/src/components/app-shell/lume-sidebar-view-model.ts @@ -1,6 +1,6 @@ import type { AgentThreadMeta, AgentWorkspace } from '@lume/shared' -export type LumeSidebarTopActionId = 'new-chat' | 'lume' | 'skills' | 'automations' | 'todos' +export type LumeSidebarTopActionId = 'new-chat' | 'lume' | 'skills' | 'automations' | 'todos' | 'proactive' export type LumeSidebarFooterActionId = 'recycle-bin' | 'settings' export const UNASSIGNED_THREADS_WORKSPACE_ID = '__unassigned__' const UNASSIGNED_THREADS_WORKSPACE_NAME = '普通会话' @@ -97,6 +97,7 @@ export function buildLumeSidebarViewModel({ active: activeTabId === '__automation__', }, { id: 'todos', label: '待办', icon: 'list-todo', kind: 'button', active: activeTabId === '__todos__', ...(planningTodoCount > 0 ? { badge: String(planningTodoCount) } : {}) }, + { id: 'proactive', label: '主动', icon: 'sparkles', kind: 'button', active: activeTabId === '__proactive__' }, ] const footerActions: LumeSidebarFooterAction[] = [ diff --git a/apps/web/src/components/tabs/TabContent.tsx b/apps/web/src/components/tabs/TabContent.tsx index ad7c8e3bb..edd639554 100644 --- a/apps/web/src/components/tabs/TabContent.tsx +++ b/apps/web/src/components/tabs/TabContent.tsx @@ -1,8 +1,9 @@ import { useAtomValue, useSetAtom } from 'jotai' -import { tabsAtom, activeTabIdAtom, clearTabDesktopContextTarget, setTabDesktopContextTarget } from '@/atoms' +import { activeTabIdAtom, clearTabDesktopContextTarget, setTabDesktopContextTarget, settingsInitialTabAtom, tabsAtom } from '@/atoms' import { AgentView } from '@/components/agent/AgentView' import { AutomationManagementView } from '@/components/automation/AutomationManagementView' import { LumeView } from '@/components/lume/LumeView' +import { ProactiveHub } from '@/components/proactive/ProactiveHub' import { ReadingView } from '@/components/reading/ReadingView' import { SettingsView } from '@/components/settings/SettingsView' import { SkillsMarketView } from '@/components/skills/SkillsMarketView' @@ -14,8 +15,18 @@ export function TabContent() { const tabs = useAtomValue(tabsAtom) const setTabs = useSetAtom(tabsAtom) const activeTabId = useAtomValue(activeTabIdAtom) + const setActiveTabId = useSetAtom(activeTabIdAtom) + const setSettingsInitialTab = useSetAtom(settingsInitialTabAtom) const activeTab = tabs.find((t) => t.id === activeTabId) + const openMemorySettings = () => { + setSettingsInitialTab('memory') + setTabs((previous) => previous.some((tab) => tab.id === '__settings__') + ? previous + : [...previous, { id: '__settings__', type: 'settings', title: '设置' }]) + setActiveTabId('__settings__') + } + if (!activeTab) { return (
@@ -70,5 +81,9 @@ export function TabContent() { if (activeTab.type === 'todo') return + if (activeTab.type === 'proactive') { + return + } + return null } From 8b467cce08a025a3e332d93274de918ccdcbc77e Mon Sep 17 00:00:00 2001 From: Leo Date: Tue, 4 Aug 2026 01:45:05 +0800 Subject: [PATCH 21/24] =?UTF-8?q?=F0=9F=A7=AA=20test(sidecar):=20=E5=BB=BA?= =?UTF-8?q?=E8=AE=AE=E7=B3=BB=E7=BB=9F=E7=AB=AF=E5=88=B0=E7=AB=AF=E9=9B=86?= =?UTF-8?q?=E6=88=90=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/services/suggest/integration.test.ts | 282 ++++++++++++++++++ 1 file changed, 282 insertions(+) create mode 100644 apps/sidecar/src/services/suggest/integration.test.ts diff --git a/apps/sidecar/src/services/suggest/integration.test.ts b/apps/sidecar/src/services/suggest/integration.test.ts new file mode 100644 index 000000000..2ca30129c --- /dev/null +++ b/apps/sidecar/src/services/suggest/integration.test.ts @@ -0,0 +1,282 @@ +/** + * integration.test.ts — 建议系统端到端集成测试 + * + * 目标:跑通 REAL signals → rules → engine → store → feedback 管线,仅在外部边界 + * 打桩(adapter / dedup 源 / memory-v2 写入 / automation 写入 / LLM analyst)。 + * + * 验证两条关键链路: + * 1) 用户含纠正语气的消息 → evaluateSessionSuggestions → correction 候选 → + * persistSuggestion → listSuggestions("suggested") 出现 status="suggested" 的 + * correction 记录;注入的 broadcaster 被调用。 + * 2) handleSuggestionFeedback(id, "accepted") on memory_correction → 调用 + * smartAddMemoryV2Candidate(mock 捕获)AND feedback 层把 correction 类型权重 + * 从 1.0 调到 1.2(1.0 × 1.2)。 + * + * 真实 vs 打桩: + * REAL:signals / rules / engine / feedback / store / service(编排逻辑本身) + * MOCK: + * - adapter.extractRecentConversation:返回固定含纠正语的消息数组(不打线程 transcript) + * - automation-manager:listAutomationJobs→[](dedup 空)/ createAutomationJob→spy + * - memory-v2/markdown-store:listEntries/listPending→[](dedup 空) + * - memory-v2/smart-add:smartAddMemoryV2Candidate→spy(不写真实记忆) + * - analyst:buildAnalysisInput/runAnalysis→[](LLM 链路在 analyst.test.ts 单独覆盖) + * - infra/logger:静默 + * + * Store I/O 走 tmpdir + LUME_CONFIG_DIR(同 store.test.ts / feedback.test.ts 套路)。 + */ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; + +// ===== 外部边界 spy ===== +const spies = { + /** adapter 调用计数(验证管线确实读取了会话消息) */ + extractCalls: mock((_input: unknown) => {}), + /** smartAddMemoryV2Candidate 捕获(accepted memory_correction 动作) */ + smartAdd: mock(async (_input: { workspaceSlug?: string; candidate: object }) => ({ + action: "added" as const, + })), + /** createAutomationJob 捕获(accepted open_automation_create 动作) */ + createAutomationJob: mock((input: { name: string; prompt: string; schedule: unknown }) => ({ + id: "job-1", + ...input, + })), + /** IPC 建议变更广播器捕获 */ + broadcaster: mock(() => {}), +}; + +// ===== mock.module:仅外部边界 ===== + +// adapter:返回固定含纠正语的用户消息(绕开 thread transcript 读取) +mock.module("./adapter", () => ({ + extractRecentConversation: async (input: unknown) => { + spies.extractCalls(input); + return [{ role: "user", content: "以后不要用 var 声明变量" }]; + }, +})); + +// automation-manager:listAutomationJobs→[](dedup 空)+ createAutomationJob→spy +mock.module("../automation/automation-manager", () => ({ + listAutomationJobs: () => [], + createAutomationJob: spies.createAutomationJob, +})); + +// memory-v2/markdown-store:listEntries/listPending→[](dedup 空,不打 memory 文件) +mock.module("../memory-v2/markdown-store", () => ({ + listEntries: () => [], + listPending: () => [], +})); + +// memory-v2/smart-add:spy(accepted memory_correction 动作不写真实记忆) +mock.module("../memory-v2/smart-add", () => ({ + smartAddMemoryV2Candidate: spies.smartAdd, +})); + +// analyst:LLM 链路不参与本集成测试(analyst.test.ts 已单独覆盖) +mock.module("./analyst", () => ({ + buildAnalysisInput: () => "", + runAnalysis: async () => [], +})); + +// infra/logger:静默 +mock.module("../infra/logger", () => ({ + createLogger: () => ({ + trace: () => {}, + debug: () => {}, + info: () => {}, + warn: () => {}, + error: () => {}, + fatal: () => {}, + }), +})); + +// ===== 在 mock 装配完成后,再 import 真实模块 ===== +const { + evaluateSessionSuggestions, + handleSuggestionFeedback, + setSuggestionChangeBroadcaster, +} = await import("./service"); +const { + getTypeWeights, + listSuggestions, + persistSuggestion, + resetSuggestionStoreForTest, +} = await import("./store"); + +// ===== 真实 tmpdir store I/O(同 store.test.ts / feedback.test.ts) ===== +let root: string; + +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), "lume-suggest-int-")); + process.env.LUME_CONFIG_DIR = root; + resetSuggestionStoreForTest(); + spies.extractCalls.mockClear(); + spies.smartAdd.mockClear(); + spies.createAutomationJob.mockClear(); + spies.broadcaster.mockClear(); + setSuggestionChangeBroadcaster(spies.broadcaster); +}); + +afterEach(() => { + setSuggestionChangeBroadcaster(() => {}); + delete process.env.LUME_CONFIG_DIR; + rmSync(root, { recursive: true, force: true }); +}); + +describe("建议系统端到端:signals → rules → engine → store → broadcast", () => { + test("含纠正语的用户消息经评估落库为 correction 建议,并触发广播", async () => { + // 初始空库 + expect(listSuggestions("suggested")).toHaveLength(0); + expect(getTypeWeights().correction).toBe(1.0); + + await evaluateSessionSuggestions({ + threadId: "thread-1", + workspaceSlug: "ws-1", + sessionId: "session-1", + }); + + // adapter 被调用(消息确实被读取喂进管线) + expect(spies.extractCalls).toHaveBeenCalledTimes(1); + + // 一条 correction 记录落库,status=suggested + const suggested = listSuggestions("suggested"); + expect(suggested).toHaveLength(1); + const rec = suggested[0]!; + expect(rec.kind).toBe("correction"); + expect(rec.status).toBe("suggested"); + expect(rec.threadId).toBe("thread-1"); + expect(rec.workspaceSlug).toBe("ws-1"); + expect(rec.sessionId).toBe("session-1"); + // 动作正确:memory_correction + 规范化后的 rule + expect(rec.action).toMatchObject({ + type: "memory_correction", + rule: "不要用 var 声明变量", + }); + + // 广播器被调用(IPC 解耦钩子) + expect(spies.broadcaster).toHaveBeenCalled(); + }); + + test("全局开关关闭 → 不评估、不 persist、不广播", async () => { + // 直接落库一条把 enabled 翻成 false(绕过 setEnabled,复用 store API) + const { setEnabled } = await import("./store"); + setEnabled(false); + + await evaluateSessionSuggestions({ + threadId: "thread-disabled", + workspaceSlug: "ws", + sessionId: "session", + }); + + // 管线在 getEnabled() 检查处短路:adapter 未读、无候选落库、未广播 + expect(spies.extractCalls).not.toHaveBeenCalled(); + expect(listSuggestions("suggested")).toHaveLength(0); + expect(spies.broadcaster).not.toHaveBeenCalled(); + }); +}); + +describe("建议系统端到端:accepted 反馈 → 学习权重 + 触发记忆写入", () => { + test("accepted memory_correction → smartAddMemoryV2Candidate 调用 + correction 权重 1.0→1.2", async () => { + // 1) 跑完评估链路,得到一条 memory_correction 建议 + await evaluateSessionSuggestions({ + threadId: "thread-2", + workspaceSlug: "ws-2", + sessionId: "session-2", + }); + const rec = listSuggestions("suggested")[0]!; + expect(rec.action.type).toBe("memory_correction"); + // 反馈前 correction 权重仍为默认 1.0 + expect(getTypeWeights().correction).toBe(1.0); + // 反馈前 smart-add 未被调用 + expect(spies.smartAdd).not.toHaveBeenCalled(); + + // 2) 用户接受建议 → handleSuggestionFeedback 编排 recordFeedback + 动作分发 + await handleSuggestionFeedback(rec.id, "accepted"); + + // 3) smartAddMemoryV2Candidate 被调用一次,参数携带规范化后的规则作为 statement + expect(spies.smartAdd).toHaveBeenCalledTimes(1); + expect(spies.smartAdd).toHaveBeenCalledWith( + expect.objectContaining({ + workspaceSlug: "ws-2", + candidate: expect.objectContaining({ + kind: "preference", + statement: "不要用 var 声明变量", + confidence: "high", + tags: expect.arrayContaining(["correction", "suggestion-derived"]), + }), + }), + ); + + // 4) feedback 层频率学习:correction 权重 1.0 × 1.2 = 1.2 + expect(getTypeWeights().correction).toBeCloseTo(1.2, 6); + // record 状态被 recordFeedback 写回为 accepted + const accepted = listSuggestions().find((r) => r.id === rec.id); + expect(accepted?.status).toBe("accepted"); + }); + + test("accepted memory_correction(无 workspaceSlug)→ 全局 scope + 权重 1.2", async () => { + // 直接 persist 一条无 workspace 的 memory_correction 建议,跳过评估链路 + const rec = persistSuggestion({ + duplicateKey: "correction:manual", + kind: "correction", + title: "记住这个纠正", + reason: "r", + evidence: "e", + rawConfidence: 0.95, + action: { type: "memory_correction", raw: "以后别再 var", rule: "别再 var" }, + }); + + await handleSuggestionFeedback(rec.id, "accepted"); + + expect(spies.smartAdd).toHaveBeenCalledWith( + expect.objectContaining({ + candidate: expect.objectContaining({ + targetScope: "global", + statement: "别再 var", + }), + }), + ); + expect(getTypeWeights().correction).toBeCloseTo(1.2, 6); + }); +}); + +describe("建议系统端到端:ignored 反馈不触发动作,仅调权", () => { + test("ignored memory_correction → smartAdd 不调用 + correction 权重 1.0→0.8", async () => { + await evaluateSessionSuggestions({ + threadId: "thread-3", + workspaceSlug: "ws-3", + sessionId: "session-3", + }); + const rec = listSuggestions("suggested")[0]!; + + await handleSuggestionFeedback(rec.id, "ignored"); + + expect(spies.smartAdd).not.toHaveBeenCalled(); + expect(spies.createAutomationJob).not.toHaveBeenCalled(); + // 1.0 × 0.8 = 0.8 + expect(getTypeWeights().correction).toBeCloseTo(0.8, 6); + }); +}); + +describe("建议系统端到端:落盘持久化", () => { + test("评估落库后清缓存从磁盘读回,记录与权重一致", async () => { + await evaluateSessionSuggestions({ + threadId: "thread-4", + workspaceSlug: "ws-4", + sessionId: "session-4", + }); + const before = listSuggestions("suggested")[0]!; + + // 清缓存模拟进程重启 + resetSuggestionStoreForTest(); + + const after = listSuggestions("suggested"); + expect(after).toHaveLength(1); + expect(after[0]!.id).toBe(before.id); + expect(after[0]!.kind).toBe("correction"); + expect(after[0]!.action.type).toBe("memory_correction"); + // 权重未受反馈影响,仍为默认 1.0 + expect(getTypeWeights().correction).toBe(1.0); + }); +}); From 0c66e136ddeb2dc79c3ecc5a67027a8cc134e9bc Mon Sep 17 00:00:00 2001 From: Leo Date: Tue, 4 Aug 2026 01:55:27 +0800 Subject: [PATCH 22/24] =?UTF-8?q?=F0=9F=A7=AA=20test(web):=20=E4=BE=A7?= =?UTF-8?q?=E6=A0=8F=E8=A7=86=E5=9B=BE=E6=A8=A1=E5=9E=8B=20topActions=20?= =?UTF-8?q?=E9=A1=BA=E5=BA=8F=E8=A1=A5=20proactive?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../web/src/components/app-shell/lume-sidebar-view-model.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/web/src/components/app-shell/lume-sidebar-view-model.test.ts b/apps/web/src/components/app-shell/lume-sidebar-view-model.test.ts index 7f818aab7..132adec10 100644 --- a/apps/web/src/components/app-shell/lume-sidebar-view-model.test.ts +++ b/apps/web/src/components/app-shell/lume-sidebar-view-model.test.ts @@ -66,6 +66,7 @@ describe('buildLumeSidebarViewModel', () => { 'skills', 'automations', 'todos', + 'proactive', ]) expect(model.topActions.find((action) => action.id === 'skills')?.label).toBe('技能 / 插件') }) From 4974641699b459cc26b17e4484604054019fecdd Mon Sep 17 00:00:00 2001 From: Leo Date: Tue, 4 Aug 2026 02:02:10 +0800 Subject: [PATCH 23/24] =?UTF-8?q?=F0=9F=90=9B=20fix(desktop):=20=E5=BB=BA?= =?UTF-8?q?=E8=AE=AE=E9=93=BE=E8=B7=AF=20[web=E2=86=92sidecar=20RPC]=20?= =?UTF-8?q?=E6=8E=A5=E9=80=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PUBLIC_RENDERER_SIDECAR_METHODS 漏配 7 个 suggestion RPC (list/act/stats/delete/clear-all/run-analysis/set-enabled), 导致生产桌面模式下 validateRendererSidecarMethod 抛 'unsupported renderer sidecar method',前端 list/act 等全部 失败。这是 Proma P0 教训的典型:链路看似接通但实际 IPC 被白名单拦截。electron-security.test.mjs 已覆盖(修复前失败)。 Co-Authored-By: Claude Fable 5 --- apps/desktop/src/renderer-sidecar-methods.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/apps/desktop/src/renderer-sidecar-methods.ts b/apps/desktop/src/renderer-sidecar-methods.ts index 807d921a1..44a64d881 100644 --- a/apps/desktop/src/renderer-sidecar-methods.ts +++ b/apps/desktop/src/renderer-sidecar-methods.ts @@ -366,6 +366,13 @@ export const PUBLIC_RENDERER_SIDECAR_METHODS = new Set([ 'routine:trigger-entry', 'runtime:get-status', 'shell:open-external', + 'suggestion:list', + 'suggestion:act', + 'suggestion:stats', + 'suggestion:delete', + 'suggestion:clear-all', + 'suggestion:run-analysis', + 'suggestion:set-enabled', 'system-config:get-effective', 'system-config:network-diagnostic', 'system-config:update-section', From cd4739473e7018653adba4e249ef619bbea066eb Mon Sep 17 00:00:00 2001 From: Leo Date: Tue, 4 Aug 2026 02:11:13 +0800 Subject: [PATCH 24/24] =?UTF-8?q?=E2=9C=A8=20feat(sidecar):=20=E5=BB=BA?= =?UTF-8?q?=E8=AE=AE=E5=8F=8D=E9=A6=88=E5=90=8E=E5=B9=BF=E6=92=AD=E9=80=9A?= =?UTF-8?q?=E7=9F=A5=E5=88=B7=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/sidecar/src/services/suggest/service.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/sidecar/src/services/suggest/service.ts b/apps/sidecar/src/services/suggest/service.ts index 57c3075a2..20f0c451a 100644 --- a/apps/sidecar/src/services/suggest/service.ts +++ b/apps/sidecar/src/services/suggest/service.ts @@ -164,6 +164,7 @@ export async function handleSuggestionFeedback( ): Promise { try { recordFeedback(id, feedback); + notifySuggestionsChanged(); if (feedback !== "accepted") return; const record = listSuggestions().find((r) => r.id === id);