Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
bb493a1
✨ feat(shared): 主动建议类型定义
TaTaLiao Aug 3, 2026
5b7447a
✨ feat(sidecar): 建议存储 suggestions.json + 原子写
TaTaLiao Aug 3, 2026
68a1f90
🐛 fix(sidecar): writeIndex 先写盘再更新缓存避免不一致
TaTaLiao Aug 3, 2026
faed7b6
✨ feat(sidecar): 建议信号提取(6 类词典 + 重复意图)
TaTaLiao Aug 3, 2026
9246189
✨ feat(sidecar): 建议规则引擎(5 类 + skill 后处理)
TaTaLiao Aug 3, 2026
0d2927d
✨ feat(sidecar): 建议决策引擎 + 误报控制(阈值/预算/拒绝门)
TaTaLiao Aug 3, 2026
f50a871
🐛 test(sidecar): 强化同次评估去重测试覆盖重复候选路径
TaTaLiao Aug 3, 2026
e5f7e06
✨ feat(sidecar): 建议频率学习 + 连续忽略静默
TaTaLiao Aug 3, 2026
647de2e
✨ feat(sidecar): 工作模式分析器 + schema 严格校验
TaTaLiao Aug 3, 2026
a14b2e7
✨ feat(sidecar): 建议评估对话文本 adapter
TaTaLiao Aug 3, 2026
e9b67fb
✨ feat(sidecar): 建议编排服务(评估/反馈/分析)
TaTaLiao Aug 3, 2026
1f20c28
✨ feat(sidecar): 建议评估 workflow-hook 接入 run.afterComplete
TaTaLiao Aug 3, 2026
8a61ab6
✨ feat(sidecar,shared): 建议 RPC handlers + IPC channel
TaTaLiao Aug 3, 2026
e7a1826
✨ feat(sidecar,web): 建议变更实时推送 onSuggestionsChanged
TaTaLiao Aug 3, 2026
262926a
✨ feat(web): 建议 IPC client
TaTaLiao Aug 3, 2026
be2e1bb
🐛 fix(web): listSuggestions status 类型对齐全 4 态
TaTaLiao Aug 3, 2026
611032b
✨ feat(web): SuggestionBanner 三态横幅 + 实时订阅
TaTaLiao Aug 3, 2026
f1e1431
✨ feat(web): AgentInput 挂载 SuggestionBanner
TaTaLiao Aug 3, 2026
502081e
✨ feat(web): ProactiveHub 主动中心聚合视图
TaTaLiao Aug 3, 2026
d01467d
✨ feat(web): 侧栏新增「主动」入口
TaTaLiao Aug 3, 2026
8b467cc
🧪 test(sidecar): 建议系统端到端集成测试
TaTaLiao Aug 3, 2026
0c66e13
🧪 test(web): 侧栏视图模型 topActions 顺序补 proactive
TaTaLiao Aug 3, 2026
4974641
🐛 fix(desktop): 建议链路 [web→sidecar RPC] 接通
TaTaLiao Aug 3, 2026
cd47394
✨ feat(sidecar): 建议反馈后广播通知刷新
TaTaLiao Aug 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions apps/desktop/src/renderer-sidecar-methods.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
2 changes: 2 additions & 0 deletions apps/sidecar/src/rpc/create-rpc-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -71,6 +72,7 @@ export function createRpcHandlers(context: CreateRpcHandlersContext): Record<str
}),
createAutomationHandlers(),
createRoutineHandlers(),
createSuggestionHandlers({ writeNotification: context.writeNotification }),
createDesktopContextHandlers(desktopContextRpcService),
createWikiHandlers(),
createAgentHandlers({
Expand Down
194 changes: 194 additions & 0 deletions apps/sidecar/src/rpc/suggestion-handlers.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
import {
SUGGESTION_IPC_CHANNELS,
type SuggestionFeedback,
type SuggestionRecord,
type SuggestionStats,
} from "@lume/shared";

/**
* Handler 接线测试:mock store/service 模块,验证每个 channel 调对函数 + 透传参数。
* 不验证业务逻辑(store/service 各自的 .test.ts 已覆盖)。
*/

const storeMocks = {
listSuggestions: mock((_status?: SuggestionRecord["status"]): SuggestionRecord[] => []),
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<void> => Promise.resolve(),
),
runAnalysisAndPersist: mock((_ctx: { workspaceSlug?: string }): Promise<number> =>
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(() => {
mock.restore();
});

describe("createSuggestionHandlers", () => {
test("LIST 直通 store.listSuggestions(无 status)", async () => {
const { createSuggestionHandlers } = await import("./suggestion-handlers");
const handlers = createSuggestionHandlers({ writeNotification });
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({ 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({ writeNotification });
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({ writeNotification });
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({ writeNotification });
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({ 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({ 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({ 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({ 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({ writeNotification });
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({ 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({ 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({ 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");
});
});
111 changes: 111 additions & 0 deletions apps/sidecar/src/rpc/suggestion-handlers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
/**
* 主动建议 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,
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;
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 interface SuggestionHandlersContext {
/**
* sidecar → web 推送通道(与 agent-handlers / reading-handlers 同一机制)。
* 用于实时广播建议变更:service.notifySuggestionsChanged 触发后,broadcaster
* 经此通道推送 SUGGESTION_IPC_CHANNELS.CHANGED,web 收到后刷新建议状态。
*/
writeNotification: NotificationWriter;
}

export function createSuggestionHandlers(context: SuggestionHandlersContext): Record<string, RpcHandler> {
// 接线 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);
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 };
},
};
}
2 changes: 2 additions & 0 deletions apps/sidecar/src/services/agent-runtime/runner/lume-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import {
createMemoryWorkflowHookService,
createRuntimeEventWorkflowHookService,
createSecurityWorkflowHookService,
createSuggestionWorkflowHookService,
createTraceWorkflowHookService
} from "../../workflow-hooks/hook-services";
import {
Expand Down Expand Up @@ -781,6 +782,7 @@ function resolveWorkflowHooks(input: {
services: {
memory: createMemoryWorkflowHookService(),
security: createSecurityWorkflowHookService(),
suggestion: createSuggestionWorkflowHookService(),
runtimeEvents: createRuntimeEventWorkflowHookService(),
trace: createTraceWorkflowHookService(),
clock: { now: () => new Date() }
Expand Down
8 changes: 8 additions & 0 deletions apps/sidecar/src/services/infra/config-paths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
}
Loading
Loading