diff --git a/docs/superpowers/plans/2026-04-07-config-pages-realtime.md b/docs/superpowers/plans/2026-04-07-config-pages-realtime.md new file mode 100644 index 0000000..7577b0b --- /dev/null +++ b/docs/superpowers/plans/2026-04-07-config-pages-realtime.md @@ -0,0 +1,133 @@ +# Config Pages Realtime Refresh Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 让 Boss Web 的配置页在配置状态发生变化后自动刷新。 + +**Architecture:** 为配置类状态引入专用 SSE 事件,状态写入函数统一发布事件,页面通过 `RealtimeRefresh` 订阅对应事件完成无侵入刷新。实现保持在现有 `boss-events -> /api/v1/events -> RealtimeRefresh` 链路内,不新增新的推送通道。 + +**Tech Stack:** Next.js App Router, TypeScript, Node test runner, SSE + +--- + +### Task 1: 写红灯测试 + +**Files:** +- Create: `tests/config-pages-realtime-refresh.test.ts` +- Create: `tests/config-state-events.test.ts` + +- [ ] **Step 1: 写页面接线失败测试** + +```ts +assert.match(source, / + + +``` + +- [ ] **Step 3: 跑页面测试确认转绿** + +Run: `npx tsx --test tests/config-pages-realtime-refresh.test.ts` +Expected: PASS + +### Task 4: 全量验证 + +**Files:** +- Test: `tests/config-pages-realtime-refresh.test.ts` +- Test: `tests/config-state-events.test.ts` + +- [ ] **Step 1: 跑本轮相关测试** + +Run: `npx tsx --test tests/config-pages-realtime-refresh.test.ts tests/config-state-events.test.ts tests/realtime-refresh-utils.test.ts tests/project-scoped-realtime-refresh.test.ts tests/status-pages-realtime-refresh.test.ts` +Expected: PASS + +- [ ] **Step 2: 跑 lint** + +Run: `npm run lint` +Expected: exit code 0 + +- [ ] **Step 3: 跑 build** + +Run: `npm run build` +Expected: exit code 0 + +- [ ] **Step 4: 提交** + +```bash +git add docs/superpowers/specs/2026-04-07-config-pages-realtime-design.md docs/superpowers/plans/2026-04-07-config-pages-realtime.md tests/config-pages-realtime-refresh.test.ts tests/config-state-events.test.ts src/lib/boss-events.ts src/lib/boss-data.ts src/app/me/ai-accounts/page.tsx src/app/me/storage/page.tsx src/app/me/master-agent/page.tsx src/app/me/master-agent/takeover/page.tsx +git commit -m "Refresh config pages in realtime" +``` diff --git a/docs/superpowers/specs/2026-04-07-config-pages-realtime-design.md b/docs/superpowers/specs/2026-04-07-config-pages-realtime-design.md new file mode 100644 index 0000000..c53c12e --- /dev/null +++ b/docs/superpowers/specs/2026-04-07-config-pages-realtime-design.md @@ -0,0 +1,65 @@ +# 配置页实时刷新 Design + +## 目标 + +让 Boss Web 的配置类页面在后台状态变更后自动刷新,不再要求用户手动返回或重开页面才能看到最新配置。 + +## 本轮范围 + +- AI 账号页:`/me/ai-accounts` +- 附件与存储页:`/me/storage` +- 主 Agent 提示词 / 记忆页:`/me/master-agent` +- 全局接管页:`/me/master-agent/takeover` + +本轮不改 Android,不改聊天页,不引入新的数据存储层。 + +## 已批准方案 + +采用方案 A:为配置类状态新增专用 SSE 事件,再把对应页面接到 `RealtimeRefresh`。 + +新增事件: + +- `ai_accounts.updated` +- `storage.updated` +- `master_agent.settings.updated` + +## 事件分配 + +### AI 账号 + +以下写入动作统一发布 `ai_accounts.updated`: + +- 新增或更新 AI 账号 +- 删除 AI 账号 +- 切换当前主控 AI 账号 +- 更新 AI 账号健康状态 + +### 附件与存储 + +以下写入动作发布 `storage.updated`: + +- 保存附件存储配置 + +### 主 Agent 设置 + +以下写入动作统一发布 `master_agent.settings.updated`: + +- 更新全局主提示词 +- 更新或清空用户主提示词 +- 创建、更新、归档用户记忆 +- 更新 `master-agent` 项目的 `agentControls`,包括全局接管 + +## 页面接线 + +页面只监听和自身内容直接相关的事件: + +- `/me/ai-accounts` 监听 `ai_accounts.updated` +- `/me/storage` 监听 `storage.updated` +- `/me/master-agent` 监听 `master_agent.settings.updated` +- `/me/master-agent/takeover` 监听 `master_agent.settings.updated` + +## 验证 + +- 新增页面接线测试,确认以上四个页面都渲染了 `RealtimeRefresh` +- 新增事件发布测试,确认关键状态写入路径会发布正确的事件 +- 跑相关测试、`npm run lint`、`npm run build` diff --git a/src/app/me/ai-accounts/page.tsx b/src/app/me/ai-accounts/page.tsx index 5d40f9e..346e050 100644 --- a/src/app/me/ai-accounts/page.tsx +++ b/src/app/me/ai-accounts/page.tsx @@ -1,3 +1,4 @@ +import { RealtimeRefresh } from "@/components/app-runtime"; import { AppShell, HeaderTitle, PageNav, StatusBar } from "@/components/app-ui"; import { AiAccountsClient } from "@/components/ai-accounts-client"; import { requirePageSession } from "@/lib/boss-auth"; @@ -11,6 +12,7 @@ export default async function AiAccountsPage() { return ( + diff --git a/src/app/me/master-agent/page.tsx b/src/app/me/master-agent/page.tsx index d793087..31054e3 100644 --- a/src/app/me/master-agent/page.tsx +++ b/src/app/me/master-agent/page.tsx @@ -1,3 +1,4 @@ +import { RealtimeRefresh } from "@/components/app-runtime"; import { AppShell, PageNav, StatusBar } from "@/components/app-ui"; import { MasterAgentPromptMemoryClient } from "@/components/master-agent-prompt-memory-client"; import { requirePageSession } from "@/lib/boss-auth"; @@ -26,6 +27,7 @@ export default async function MasterAgentPromptMemoryPage() { return ( +
diff --git a/src/app/me/master-agent/takeover/page.tsx b/src/app/me/master-agent/takeover/page.tsx index 2b77ba5..afeefa7 100644 --- a/src/app/me/master-agent/takeover/page.tsx +++ b/src/app/me/master-agent/takeover/page.tsx @@ -1,3 +1,4 @@ +import { RealtimeRefresh } from "@/components/app-runtime"; import { AppShell, PageNav, StatusBar } from "@/components/app-ui"; import { MasterAgentTakeoverClient } from "@/components/master-agent-takeover-client"; import { requirePageSession } from "@/lib/boss-auth"; @@ -12,6 +13,7 @@ export default async function MasterAgentTakeoverPage() { return ( +
diff --git a/src/app/me/storage/page.tsx b/src/app/me/storage/page.tsx index 17467f9..f97215f 100644 --- a/src/app/me/storage/page.tsx +++ b/src/app/me/storage/page.tsx @@ -1,3 +1,4 @@ +import { RealtimeRefresh } from "@/components/app-runtime"; import { AppShell, PageNav, StatusBar } from "@/components/app-ui"; import { AttachmentStorageClient } from "@/components/attachment-storage-client"; import { requirePageSession } from "@/lib/boss-auth"; @@ -20,6 +21,7 @@ export default async function StoragePage() { return ( + diff --git a/src/lib/boss-data.ts b/src/lib/boss-data.ts index 7155b16..0412322 100644 --- a/src/lib/boss-data.ts +++ b/src/lib/boss-data.ts @@ -4322,7 +4322,7 @@ export async function updateProjectAgentControls( throw new Error("MASTER_AGENT_TAKEOVER_SCOPE_RESTRICTED"); } - return mutateStateIfChanged((state) => { + const result = await mutateStateIfChanged((state) => { const project = state.projects.find((item) => item.id === projectId); if (!project) throw new Error("PROJECT_NOT_FOUND"); @@ -4429,6 +4429,10 @@ export async function updateProjectAgentControls( changed: true, }; }); + if (projectId === "master-agent") { + publishBossEvent("master_agent.settings.updated", { projectId: "master-agent" }); + } + return result; } function projectOrchestrationRequestedBackendId(project: Project): OrchestrationBackendId { @@ -4529,7 +4533,7 @@ export async function getAttachmentStorageConfig(account: string) { } export async function upsertAttachmentStorageConfig(config: UserAttachmentStorageConfig) { - return mutateState((state) => { + const result = await mutateState((state) => { const index = state.userAttachmentStorageConfigs.findIndex( (item) => item.account === config.account, ); @@ -4540,6 +4544,8 @@ export async function upsertAttachmentStorageConfig(config: UserAttachmentStorag } return config; }); + publishBossEvent("storage.updated"); + return result; } export async function getMasterAgentPromptPolicy() { @@ -4556,7 +4562,7 @@ export async function updateMasterAgentPromptPolicy(input: { throw new Error("MASTER_AGENT_PROMPT_REQUIRED"); } - return mutateState((state) => { + const result = await mutateState((state) => { const policy: MasterAgentPromptPolicy = { globalPrompt, updatedBy: input.updatedBy?.trim() || undefined, @@ -4565,6 +4571,8 @@ export async function updateMasterAgentPromptPolicy(input: { state.masterAgentPromptPolicy = policy; return policy; }); + publishBossEvent("master_agent.settings.updated", { projectId: "master-agent" }); + return result; } export async function getUserMasterPrompt(account: string) { @@ -4578,7 +4586,7 @@ export async function updateUserMasterPrompt(account: string, content: string) { throw new Error("USER_MASTER_PROMPT_REQUIRED"); } - return mutateState((state) => { + const result = await mutateState((state) => { const next: UserMasterPrompt = { account, content: trimmedContent, @@ -4592,14 +4600,18 @@ export async function updateUserMasterPrompt(account: string, content: string) { } return next; }); + publishBossEvent("master_agent.settings.updated", { projectId: "master-agent" }); + return result; } export async function clearUserMasterPrompt(account: string) { - return mutateState((state) => { + const result = await mutateState((state) => { const before = state.userMasterPrompts.length; state.userMasterPrompts = state.userMasterPrompts.filter((item) => item.account !== account); return { cleared: before !== state.userMasterPrompts.length }; }); + publishBossEvent("master_agent.settings.updated", { projectId: "master-agent" }); + return result; } export async function listUserMasterMemories( @@ -4647,7 +4659,7 @@ export async function createUserMasterMemory(input: { throw new Error("USER_MASTER_MEMORY_PROJECT_ID_REQUIRED"); } - return mutateState((state) => { + const result = await mutateState((state) => { const now = nowIso(); const memory: MasterAgentMemory = { memoryId: randomToken("memory"), @@ -4667,6 +4679,8 @@ export async function createUserMasterMemory(input: { state.masterAgentMemories.unshift(memory); return memory; }); + publishBossEvent("master_agent.settings.updated", { projectId: "master-agent" }); + return result; } export async function updateUserMasterMemory( @@ -4679,7 +4693,7 @@ export async function updateUserMasterMemory( > >, ) { - return mutateState((state) => { + const result = await mutateState((state) => { const memory = state.masterAgentMemories.find( (item) => item.memoryId === memoryId && item.account === account, ); @@ -4721,10 +4735,12 @@ export async function updateUserMasterMemory( memory.updatedAt = nowIso(); return memory; }); + publishBossEvent("master_agent.settings.updated", { projectId: "master-agent" }); + return result; } export async function archiveUserMasterMemory(memoryId: string, account: string) { - return mutateState((state) => { + const result = await mutateState((state) => { const memory = state.masterAgentMemories.find( (item) => item.memoryId === memoryId && item.account === account, ); @@ -4736,6 +4752,8 @@ export async function archiveUserMasterMemory(memoryId: string, account: string) memory.updatedAt = nowIso(); return memory; }); + publishBossEvent("master_agent.settings.updated", { projectId: "master-agent" }); + return result; } export async function touchUserMasterMemories(memoryIds: string[], account: string) { @@ -5509,7 +5527,7 @@ export async function saveAiAccount(payload: { setActive?: boolean; loginStatusNote?: string; }) { - return mutateState((state) => { + const result = await mutateState((state) => { const existing = payload.accountId ? state.aiAccounts.find((item) => item.accountId === payload.accountId) : null; @@ -5580,13 +5598,15 @@ export async function saveAiAccount(payload: { return buildAiAccountSummary(next); }); + publishBossEvent("ai_accounts.updated"); + return result; } export async function deleteAiAccount(accountId: string) { if (accountId === ENV_OPENAI_ACCOUNT_ID) { throw new Error("ENV_AI_ACCOUNT_READ_ONLY"); } - return mutateState((state) => { + const result = await mutateState((state) => { const target = state.aiAccounts.find((item) => item.accountId === accountId); if (!target) { throw new Error("AI_ACCOUNT_NOT_FOUND"); @@ -5600,12 +5620,14 @@ export async function deleteAiAccount(accountId: string) { } return true; }); + publishBossEvent("ai_accounts.updated"); + return result; } export async function activateAiAccount(accountId: string, reason: string) { if (accountId === ENV_OPENAI_ACCOUNT_ID) { const state = await readState(); - return { + const result = { activeIdentity: { ...getMasterIdentitySummaryFromState(state), accountId: ENV_OPENAI_ACCOUNT_ID, @@ -5617,13 +5639,17 @@ export async function activateAiAccount(accountId: string, reason: string) { isEnvironmentFallback: true, }, }; + publishBossEvent("ai_accounts.updated"); + return result; } - return mutateState((state) => { + const result = await mutateState((state) => { setActiveAiAccountInState(state, accountId, reason); return { activeIdentity: getMasterIdentitySummaryFromState(state), }; }); + publishBossEvent("ai_accounts.updated"); + return result; } export async function updateAiAccountHealth(params: { @@ -5661,6 +5687,7 @@ export async function updateAiAccountHealth(params: { ); } }); + publishBossEvent("ai_accounts.updated"); } export async function getMasterAgentRuntimeAccount() { diff --git a/src/lib/boss-events.ts b/src/lib/boss-events.ts index 796bc25..ee5fac3 100644 --- a/src/lib/boss-events.ts +++ b/src/lib/boss-events.ts @@ -6,8 +6,11 @@ export type BossEventName = | "project.context_risk.updated" | "app.logs.updated" | "master_agent.task.updated" + | "master_agent.settings.updated" + | "ai_accounts.updated" | "devices.updated" | "devices.skills.updated" + | "storage.updated" | "ota.updated"; export interface BossEventPayload { diff --git a/tests/config-pages-realtime-refresh.test.ts b/tests/config-pages-realtime-refresh.test.ts new file mode 100644 index 0000000..25339c1 --- /dev/null +++ b/tests/config-pages-realtime-refresh.test.ts @@ -0,0 +1,47 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; + +async function readSource(relativePath: string) { + return readFile(new URL(`../${relativePath}`, import.meta.url), "utf8"); +} + +test("ai accounts page refreshes when AI account state changes", async () => { + const source = await readSource("src/app/me/ai-accounts/page.tsx"); + + assert.match(source, /import \{ RealtimeRefresh \}/, "expected ai accounts page to import RealtimeRefresh"); + assert.match(source, / { + const source = await readSource("src/app/me/storage/page.tsx"); + + assert.match(source, /import \{ RealtimeRefresh \}/, "expected storage page to import RealtimeRefresh"); + assert.match(source, / { + for (const relativePath of [ + "src/app/me/master-agent/page.tsx", + "src/app/me/master-agent/takeover/page.tsx", + ]) { + const source = await readSource(relativePath); + assert.match(source, /import \{ RealtimeRefresh \}/, `expected ${relativePath} to import RealtimeRefresh`); + assert.match(source, / project.id === "master-agent"); + if (masterAgentProject) { + masterAgentProject.agentControls = undefined; + } + await writeState(state); +} + +test.beforeEach(async () => { + await setup(); + await resetConfigState(); +}); + +test.after(async () => { + if (runtimeRoot) { + await rm(runtimeRoot, { recursive: true, force: true }); + } +}); + +test("saveAiAccount publishes ai account refresh event", async () => { + const events: Array<{ event: string }> = []; + const unsubscribe = subscribeBossEvents((event) => { + events.push({ event }); + }); + + await saveAiAccount({ + label: "主 GPT", + role: "primary", + provider: "openai_api", + displayName: "OpenAI 主账号", + apiKey: "sk-test-primary", + setActive: true, + }); + unsubscribe(); + + assert.equal(events.at(-1)?.event, "ai_accounts.updated"); +}); + +test("upsertAttachmentStorageConfig publishes storage refresh event", async () => { + const events: Array<{ event: string }> = []; + const unsubscribe = subscribeBossEvents((event) => { + events.push({ event }); + }); + + await upsertAttachmentStorageConfig({ + account: "17600003315", + mode: "server_file", + updatedAt: "2026-04-07T10:20:00.000Z", + }); + unsubscribe(); + + assert.equal(events.at(-1)?.event, "storage.updated"); +}); + +test("master agent prompt policy publishes master agent settings refresh event", async () => { + const events: Array<{ event: string; payload: { projectId?: string } }> = []; + const unsubscribe = subscribeBossEvents((event, payload) => { + events.push({ event, payload }); + }); + + await updateMasterAgentPromptPolicy({ + globalPrompt: "保持简洁并只输出有效内容。", + updatedBy: "17600003315", + }); + unsubscribe(); + + assert.equal(events.at(-1)?.event, "master_agent.settings.updated"); + assert.equal(events.at(-1)?.payload.projectId, "master-agent"); +}); + +test("master agent memory writes publish master agent settings refresh event", async () => { + const events: Array<{ event: string; payload: { projectId?: string } }> = []; + const unsubscribe = subscribeBossEvents((event, payload) => { + events.push({ event, payload }); + }); + + await createUserMasterMemory({ + account: "17600003315", + scope: "global", + title: "用户偏好", + content: "群聊里默认一键通过。", + memoryType: "user_preference", + }); + unsubscribe(); + + assert.equal(events.at(-1)?.event, "master_agent.settings.updated"); + assert.equal(events.at(-1)?.payload.projectId, "master-agent"); +}); + +test("master agent takeover changes publish master agent settings refresh event", async () => { + const events: Array<{ event: string; payload: { projectId?: string } }> = []; + const unsubscribe = subscribeBossEvents((event, payload) => { + events.push({ event, payload }); + }); + + await updateProjectAgentControls( + "master-agent", + { + globalTakeoverEnabled: true, + }, + "17600003315", + ); + unsubscribe(); + + assert.equal(events.at(-1)?.event, "master_agent.settings.updated"); + assert.equal(events.at(-1)?.payload.projectId, "master-agent"); +});