From c9c4c38ecb494edab30edec620cacffc4a65b442 Mon Sep 17 00:00:00 2001 From: yujiezhang-ops Date: Tue, 23 Jun 2026 20:42:18 +0800 Subject: [PATCH 1/5] feat(viewer): assemble index.html from parts/ fragments (PLAN-007 STEP-01) index.html is now GENERATED from src/viewer/parts/* (head/style/body/script/ tail) by scripts/build-viewer-html.mjs via pure byte-for-byte concatenation in manifest order. The committed index.html is byte-identical to before apart from one inert GENERATED banner comment after the doctype, so the strict CSP, the runtime nonce/version placeholders, the build `cp`, and every test/scan that readFileSync()s index.html keep working unchanged. Establishes the safety net for the rest of PLAN-007: later steps split the css and js fragments further while a drift guard proves the artifact is unchanged. - scripts/build-viewer-html.mjs: regenerate (default) / --check / --watch - npm run build: runs `--check` (fails CI if index.html is out of sync) - npm run viewer:build / viewer:watch: dev regeneration - test/viewer-assembly.test.ts: asserts assemble(parts/) === committed index.html byte-for-byte, fragments exist, nonce placeholder + single nonce'd script tag survive No backend / CSP / endpoint changes; no MCP-tool or REST-endpoint count impact. Signed-off-by: yujiezhang-ops --- package.json | 4 +- scripts/build-viewer-html.mjs | 58 + src/viewer/index.html | 1 + src/viewer/parts/00-head.html | 17 + src/viewer/parts/10-style.css | 3943 ++++++++++++++++++ src/viewer/parts/20-body.html | 54 + src/viewer/parts/30-app.js | 7112 ++++++++++++++++++++++++++++++++ src/viewer/parts/40-tail.html | 3 + src/viewer/parts/manifest.json | 7 + test/viewer-assembly.test.ts | 50 + 10 files changed, 11248 insertions(+), 1 deletion(-) create mode 100644 scripts/build-viewer-html.mjs create mode 100644 src/viewer/parts/00-head.html create mode 100644 src/viewer/parts/10-style.css create mode 100644 src/viewer/parts/20-body.html create mode 100644 src/viewer/parts/30-app.js create mode 100644 src/viewer/parts/40-tail.html create mode 100644 src/viewer/parts/manifest.json create mode 100644 test/viewer-assembly.test.ts diff --git a/package.json b/package.json index cbff1701..bd57dbc9 100644 --- a/package.json +++ b/package.json @@ -17,8 +17,10 @@ "agentmemory-lab": "dist/cli.mjs" }, "scripts": { - "build": "tsdown && (cp iii-config.yaml dist/ 2>/dev/null || true) && (cp iii-config.docker.yaml dist/ 2>/dev/null || true) && (cp docker-compose.yml dist/ 2>/dev/null || true) && (cp .env.example dist/ 2>/dev/null || true) && mkdir -p dist/viewer/agent-avatars dist/viewer/demo dist/functions && cp src/viewer/index.html dist/viewer/ && cp src/viewer/favicon.svg dist/viewer/ && cp src/viewer/agent-avatars/* dist/viewer/agent-avatars/ && cp src/viewer/demo/* dist/viewer/demo/ && cp src/functions/todo-extract-langextract.py dist/functions/ && cp src/functions/todo-update-llm.py dist/functions/", + "build": "tsdown && node scripts/build-viewer-html.mjs --check && (cp iii-config.yaml dist/ 2>/dev/null || true) && (cp iii-config.docker.yaml dist/ 2>/dev/null || true) && (cp docker-compose.yml dist/ 2>/dev/null || true) && (cp .env.example dist/ 2>/dev/null || true) && mkdir -p dist/viewer/agent-avatars dist/viewer/demo dist/functions && cp src/viewer/index.html dist/viewer/ && cp src/viewer/favicon.svg dist/viewer/ && cp src/viewer/agent-avatars/* dist/viewer/agent-avatars/ && cp src/viewer/demo/* dist/viewer/demo/ && cp src/functions/todo-extract-langextract.py dist/functions/ && cp src/functions/todo-update-llm.py dist/functions/", "dev": "tsx src/index.ts", + "viewer:build": "node scripts/build-viewer-html.mjs", + "viewer:watch": "node scripts/build-viewer-html.mjs --watch", "start": "node dist/cli.mjs", "start:local-memory": "AGENTMEMORY_III_CONFIG=./iii-config.local-memory.yaml node dist/cli.mjs", "migrate": "node dist/functions/migrate.js", diff --git a/scripts/build-viewer-html.mjs b/scripts/build-viewer-html.mjs new file mode 100644 index 00000000..cd0430af --- /dev/null +++ b/scripts/build-viewer-html.mjs @@ -0,0 +1,58 @@ +#!/usr/bin/env node +// Assemble src/viewer/index.html from the ordered fragments in +// src/viewer/parts/. index.html is a GENERATED artifact: it is committed (so +// the viewer server, the build `cp`, and the tests that readFileSync it keep +// working unchanged), but its source of truth is parts/*. The assembly is a +// pure byte-for-byte concatenation in manifest order — no templating, no +// wrapping — so the viewer's runtime placeholders (__AGENTMEMORY_VIEWER_NONCE__ +// / __AGENTMEMORY_VERSION__) and the strict CSP are untouched. +// +// Usage: +// node scripts/build-viewer-html.mjs regenerate index.html from parts/ +// node scripts/build-viewer-html.mjs --check fail (exit 1) if index.html is stale +// node scripts/build-viewer-html.mjs --watch rebuild on any change under parts/ +import { readFileSync, writeFileSync, watch } from "node:fs"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; + +const here = dirname(fileURLToPath(import.meta.url)); +const viewerDir = join(here, "..", "src", "viewer"); +const partsDir = join(viewerDir, "parts"); +const outFile = join(viewerDir, "index.html"); + +function assemble() { + const manifest = JSON.parse(readFileSync(join(partsDir, "manifest.json"), "utf-8")); + if (!Array.isArray(manifest) || manifest.length === 0) { + throw new Error("parts/manifest.json must be a non-empty array of fragment filenames"); + } + return manifest.map((name) => readFileSync(join(partsDir, name), "utf-8")).join(""); +} + +const args = new Set(process.argv.slice(2)); + +if (args.has("--check")) { + const expected = readFileSync(outFile, "utf-8"); + if (assemble() !== expected) { + console.error( + "src/viewer/index.html is out of sync with src/viewer/parts/.\n" + + "Run `npm run viewer:build` and commit the regenerated index.html.", + ); + process.exit(1); + } + console.log("viewer index.html is up to date with parts/"); +} else if (args.has("--watch")) { + const rebuild = () => { + try { + writeFileSync(outFile, assemble()); + console.log("rebuilt src/viewer/index.html"); + } catch (err) { + console.error("viewer rebuild failed:", err.message); + } + }; + rebuild(); + watch(partsDir, rebuild); + console.log(`watching ${partsDir} — Ctrl+C to stop`); +} else { + writeFileSync(outFile, assemble()); + console.log("wrote src/viewer/index.html from parts/"); +} diff --git a/src/viewer/index.html b/src/viewer/index.html index 2b5eec54..7512d7bb 100644 --- a/src/viewer/index.html +++ b/src/viewer/index.html @@ -1,4 +1,5 @@ + diff --git a/src/viewer/parts/00-head.html b/src/viewer/parts/00-head.html new file mode 100644 index 00000000..db63af46 --- /dev/null +++ b/src/viewer/parts/00-head.html @@ -0,0 +1,17 @@ + + + + + + + AI Todo + + + + + +
+ +

AI Todo

+
+
+ + Live updates off +
+
+ +
+
+ + + +
+
+ + + + +
+
+ +
+ +
+
+
+
+
+
+
+
+
+
+
+
+ + + + + + + + diff --git a/src/viewer/parts/manifest.json b/src/viewer/parts/manifest.json new file mode 100644 index 00000000..9e45efd6 --- /dev/null +++ b/src/viewer/parts/manifest.json @@ -0,0 +1,7 @@ +[ + "00-head.html", + "10-style.css", + "20-body.html", + "30-app.js", + "40-tail.html" +] diff --git a/test/viewer-assembly.test.ts b/test/viewer-assembly.test.ts new file mode 100644 index 00000000..084edecb --- /dev/null +++ b/test/viewer-assembly.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vitest"; +import { readFileSync, existsSync } from "node:fs"; +import { join } from "node:path"; + +// Drift guard for STEP-01 (PLAN-007): src/viewer/index.html is a GENERATED +// artifact assembled from src/viewer/parts/* by scripts/build-viewer-html.mjs. +// These tests fail if index.html and parts/ fall out of sync (e.g. someone +// hand-edited index.html, or edited a fragment without running +// `npm run viewer:build`). The assembly is re-implemented here independently +// so the guard does not merely re-run the build script's own logic. +const VIEWER_DIR = join("src", "viewer"); +const PARTS_DIR = join(VIEWER_DIR, "parts"); + +function manifest(): string[] { + const parsed = JSON.parse(readFileSync(join(PARTS_DIR, "manifest.json"), "utf-8")); + expect(Array.isArray(parsed)).toBe(true); + expect(parsed.length).toBeGreaterThan(0); + return parsed as string[]; +} + +function assemble(): string { + return manifest() + .map((name) => readFileSync(join(PARTS_DIR, name), "utf-8")) + .join(""); +} + +describe("viewer index.html assembly (PLAN-007 STEP-01)", () => { + it("every fragment named in the manifest exists", () => { + for (const name of manifest()) { + expect(existsSync(join(PARTS_DIR, name)), `missing fragment ${name}`).toBe(true); + } + }); + + it("assembling parts/ reproduces the committed index.html byte-for-byte", () => { + const committed = readFileSync(join(VIEWER_DIR, "index.html"), "utf-8"); + expect(assemble()).toBe(committed); + }); + + it("preserves the nonce placeholder the server replaces per request", () => { + const html = readFileSync(join(VIEWER_DIR, "index.html"), "utf-8"); + // document.ts replaceAll()s this each request; losing it breaks nonce/CSP. + expect(html).toContain("__AGENTMEMORY_VIEWER_NONCE__"); + }); + + it("keeps exactly one nonce'd script tag intact (CSP relies on it)", () => { + const html = readFileSync(join(VIEWER_DIR, "index.html"), "utf-8"); + const realTag = html.match(/ tag`).not.toMatch(/<\/script>/); + // a bare "', + ); + } + }); +}); + + From b6a0a42d588e5f69c69bbe721fccefa5c957962d Mon Sep 17 00:00:00 2001 From: yujiezhang-ops Date: Tue, 23 Jun 2026 21:23:01 +0800 Subject: [PATCH 4/5] refactor(viewer): extract i18n catalog + REST transport into named fragments (PLAN-007 STEP-04) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Carves the two contiguous, file-order blocks out of the STEP-03 app fragments: - app/00-prelude.js -> 00-prelude.js (url/ws/theme) + 05-i18n.js (I18N_MESSAGES catalog + t/statusLabel/applyI18n/observationTypeLabel + lang bootstrap) + 08-state-expert.js (TAB_IDS/TAB_REDIRECTS/EXPERT_TABS + expert mode) - app/90-rest.js -> 40-data-helpers.js + 45-transport.js (api/apiGet/apiPost/ apiPatch/apiDelete + settledData/renderDataWarnings/mergeSessions) + 90-rest.js connectWs lives far from the api block, so the full transport layer cannot be gathered without reordering (would break byte-identity) — only the contiguous api block is extracted; WS stays in 90-rest. The i18n fragment has top-level DOM bootstrap code so it is not sandbox-unit-tested here; the guard is byte-identity (regenerated index.html unchanged vs the STEP-03 commit) plus structural assertions that the catalog/api landed in the expected fragments. No assembled-output / CSP / endpoint change. Signed-off-by: yujiezhang-ops --- src/viewer/parts/app/00-prelude.js | 527 ----------- src/viewer/parts/app/05-i18n.js | 442 ++++++++++ src/viewer/parts/app/08-state-expert.js | 85 ++ src/viewer/parts/app/40-data-helpers.js | 1003 +++++++++++++++++++++ src/viewer/parts/app/45-transport.js | 76 ++ src/viewer/parts/app/90-rest.js | 1079 ----------------------- src/viewer/parts/manifest.json | 4 + test/viewer-assembly.test.ts | 14 + 8 files changed, 1624 insertions(+), 1606 deletions(-) create mode 100644 src/viewer/parts/app/05-i18n.js create mode 100644 src/viewer/parts/app/08-state-expert.js create mode 100644 src/viewer/parts/app/40-data-helpers.js create mode 100644 src/viewer/parts/app/45-transport.js diff --git a/src/viewer/parts/app/00-prelude.js b/src/viewer/parts/app/00-prelude.js index a2ddf7ca..9681ee5e 100644 --- a/src/viewer/parts/app/00-prelude.js +++ b/src/viewer/parts/app/00-prelude.js @@ -84,530 +84,3 @@ // ponytail: inline single-file base; promote to a shared module only if a // second surface (extension) needs the same catalog. /* i18n-core:start */ - var I18N_MESSAGES = { - en: { - 'tab.dashboard': 'Overview', 'tab.actions': 'To-Do', 'tab.sessions': 'Evidence', - 'status.pending': 'Pending', 'status.active': 'In progress', 'status.done': 'Done', - 'status.blocked': 'Blocked', 'status.cancelled': 'Cancelled', - 'filter.review': 'To confirm', 'filter.all': 'All', - 'dash.firstRun.kicker': 'First run', - 'dash.firstRun.title': 'Import sample data first', - 'dash.firstRun.body': 'No session data yet. Run the demo and the page will fill with browsable to-dos and activity.', - 'dash.firstRun.link': 'View quick start →', - 'dash.noRecord': 'No records yet', - 'dash.stat.sessions': 'Sessions', - 'dash.stat.recent': 'Latest', - 'dash.stat.todos': 'Todos', - 'dash.stat.openWorkbench': 'Open workbench', - 'dash.stat.replyQueue': 'Reply queue', - 'dash.stat.actionCandidates': 'Action candidates', - 'dash.stat.pendingActions': 'Pending actions', - 'dash.stat.memories': 'Memories', - 'dash.stat.latestVersion': 'Latest version', - 'dash.stat.lessons': 'Lessons', - 'dash.stat.lessonsSub': 'Lessons to organize', - 'dash.stat.graphNodes': 'Graph nodes', - 'dash.stat.edges': 'Edges', - 'dash.stat.status': 'Status', - 'dash.stat.toolCalls': 'Tool calls', - 'dash.stat.tracking': 'Tracking', - 'dash.stat.functions': 'functions', - 'dash.stat.circuitBreaker': 'Circuit breaker', - 'dash.stat.failed': 'Failed', - 'dash.stat.times': 'times', - 'dash.systemResources': 'System resources', - 'dash.alerts': 'Alerts', - 'dash.notes': 'Notes', - 'dash.recentSessions': 'Recent sessions', - 'dash.emptySessions': 'No session data yet. Run the demo or connect your workflow.', - 'dash.unnamedSession': 'Unnamed session', - 'dash.openLatestSyncOf': 'Open latest sync of', - 'dash.syncs': 'syncs', - 'dash.records': 'records', - 'dash.lastSync': 'Last sync', - 'dash.functionMetrics': 'Function metrics (OTel)', - 'dash.workers': 'Workers', - 'dash.circuitBreakerDetail': 'Circuit breaker detail', - 'dash.semanticMemory': 'Semantic memory', - 'dash.emptySemantic': 'No semantic memory yet; the system distills it from observations over time.', - 'dash.proceduralMemory': 'Procedural memory', - 'dash.emptyProcedural': 'No procedural memory yet; repeated patterns are extracted automatically.', - 'dash.consolidationStatus': 'Consolidation status', - 'dash.memoryRelations': 'Memory relations', - 'act.attn.next': 'Next', 'act.attn.needsWork': 'Needs work', 'act.attn.noteworthy': 'Noteworthy', - 'act.prio.high': 'Important', 'act.prio.normal': 'Normal', 'act.prio.low': 'Low', - 'act.untitled': 'Untitled', 'act.untitledCandidate': 'Untitled candidate', - 'act.metric.waiting': 'Awaiting reply', 'act.metric.review': 'To confirm', 'act.metric.followUp': 'To follow up', 'act.metric.active': 'In progress', 'act.metric.done': 'Done', - 'act.searchPlaceholder': 'Search todos...', - 'act.nToConfirm': 'to confirm', 'act.nConfirmed': 'confirmed', 'act.refresh': 'Refresh', - 'act.viewOriginal': 'View original', 'act.confirm': 'Confirm', 'act.ignore': 'Ignore', - 'act.from': 'From', 'act.viewSource': 'View source →', 'act.updated': 'Updated', - 'act.extract.title': 'Use LLM to organize recent sessions', - 'act.extract.run': 'Organize with LLM', - 'act.extract.running': 'Organizing...', - 'act.extract.done': 'Organized', - 'act.extract.rules': 'LLM unavailable', - 'act.extract.error': 'Organize failed', - 'act.extract.failedExisting': 'Extraction failed; showing existing todos', - 'act.extract.loading': 'Loading todos...', - 'act.extract.starting': 'Organizing recent sessions...', - 'act.extract.background': 'Latest todos are shown; still organizing...', - 'act.extract.doneLlm': 'LLM extraction complete', - 'act.extract.doneMixed': 'Partial LLM extraction complete', - 'act.extract.doneRules': 'Rules extraction complete', - 'act.extract.created': 'new', - 'act.extract.history': 'history', - 'act.extract.discarded': 'discarded', - 'act.extract.cleaned': 'cleaned', - 'act.extract.reason': 'reason', - 'act.cleanup.title': 'Update recorded cards from sessions that changed', - 'act.cleanup.run': 'Update', - 'act.cleanup.running': 'Updating...', - 'act.cleanup.applying': 'Applying updates...', - 'act.cleanup.done': 'Updated', - 'act.cleanup.error': 'Update failed', - 'act.cleanup.failed': 'Update failed; cards unchanged', - 'act.cleanup.clean': 'All cards are up to date', - 'act.cleanup.llmUnavailable': 'LLM unavailable — no changes', - 'act.cleanup.confirm': 'Apply these updates?', - 'act.cleanup.summary': 'update {rewritten} · done {completed} · drop {dropped} · merge {merged}', - 'act.status.complete': 'Complete', - 'act.status.archive': 'Archive', - 'act.status.delete': 'Delete', - 'act.itemsUnit': 'items', - 'act.empty.title': 'No todos yet', - 'act.empty.lead': 'This is where todos, blocked items, and completed work extracted from your sessions will appear.', - 'settings.title': 'Settings', - 'settings.subtitle': 'Local configuration is written to the user config file and takes effect after restarting the service.', - 'settings.close': 'Close', - 'settings.language': 'UI language', - 'settings.extractor': 'LLM extraction config', - 'settings.sinceDays': 'Look-back window (days): only sessions from the last N days', - 'settings.maxInteractions': 'Max interaction records per session (one user request → agent reply)', - 'settings.apiKeyKeep': 'Enter a new API key to replace it, or leave blank to keep the current key', - 'settings.apiKeyMissing': 'Not configured', - 'settings.apiKeyLabel': 'API key:', - 'settings.save': 'Save config', - 'settings.saving': 'Saving...', - 'settings.savedRestart': 'Config saved. Restart the service to apply it.', - 'settings.saveFailed': 'Config save failed', - 'act.status.updateFailed': 'Todo status update failed', - 'obs.type.file_read': 'Read file', - 'obs.type.file_write': 'Write file', - 'obs.type.file_edit': 'Edit file', - 'obs.type.command_run': 'Run command', - 'obs.type.search': 'Search', - 'obs.type.web_fetch': 'Web fetch', - 'obs.type.conversation': 'Conversation', - 'obs.type.error': 'Error', - 'obs.type.decision': 'Decision', - 'obs.type.discovery': 'Discovery', - 'obs.type.subagent': 'Subagent', - 'obs.type.notification': 'Notification', - 'obs.type.task': 'Task', - 'obs.type.image': 'Image', - 'obs.type.other': 'Other', - 'obs.summary.localOperation': 'Run local operation', - 'obs.summary.restartService': 'Restart AI Todo service', - 'obs.summary.startService': 'Start AI Todo service', - 'obs.summary.openPreview': 'Open AI Todo preview', - 'obs.summary.checkService': 'Check service status', - 'obs.summary.verifyPageFix': 'Verify page fix', - 'obs.summary.viewFixLog': 'View fix log', - 'obs.summary.checkBrowserAutomation': 'Check browser automation dependency', - 'obs.summary.checkProcess': 'Check local service process', - 'obs.summary.organizeLocalFiles': 'Organize local files', - 'obs.summary.readLocalServiceData': 'Read local service data', - 'obs.summary.viewLocalFiles': 'View local file content', - 'obs.summary.runProjectScript': 'Run project script', - 'obs.summary.checkCodeVersion': 'Check or update code version', - 'obs.summary.runLocalCommand': 'Run local command', - 'obs.output.noText': 'This step completed without extra output.', - 'obs.output.serviceOk': 'The result is healthy and the service is available.', - 'obs.output.npxMissing': 'Check result: npx is not available in this environment.', - 'obs.output.npxOk': 'Check result: browser automation dependency is available.', - 'obs.output.pageFixOk': 'Check result: the page contains the expected fix.', - 'obs.output.error': 'The command returned an error and needs follow-up.', - 'obs.output.readService': 'Read data returned by the local service.', - 'obs.output.viewFiles': 'Viewed local file content to confirm current state.', - 'obs.output.done': 'This step completed and returned output.', - 'obs.display.promptSubmit': 'User request', - 'obs.display.agentMessage': 'Assistant response', - 'obs.display.updatePlan': 'Update plan', - 'obs.display.updatePlanBody': 'The task plan was updated.', - 'obs.display.applyPatch': 'Update local files', - 'obs.display.applyPatchBody': 'Code changes were applied.', - 'obs.display.toolTrace': 'This step was recorded as a structured tool event.', - 'episode.type.file_read': 'Read material', - 'episode.type.file_write': 'Write file', - 'episode.type.file_edit': 'Edit file', - 'episode.type.command_run': 'Local operation', - 'episode.type.search': 'Search', - 'episode.type.web_fetch': 'Web material', - 'episode.type.conversation': 'Conversation progress', - 'episode.type.error': 'Error triage', - 'episode.type.decision': 'Decision record', - 'episode.type.discovery': 'Discovery', - 'episode.type.subagent': 'Collaboration task', - 'episode.type.notification': 'Reminder', - 'episode.type.task': 'Task progress', - 'episode.type.other': 'Other', - 'episode.kind.user_need': 'User request', - 'episode.kind.bugfix': 'Fix record', - 'episode.kind.research': 'Project research', - 'episode.kind.file_work': 'File work', - 'episode.kind.important': 'Important segment', - 'episode.kind.work': 'Work progress', - 'episode.workSegment': 'Work segment', - 'episode.record': 'records', - 'episode.bodyPrefix': 'This segment mainly contains ', - 'episode.bodySuffix': ', summarized from low-level records.', - 'project.uncategorized': 'Uncategorized', - 'project.all': 'All projects', - 'project.browser': 'Browser', - 'project.demo': 'Demo data', - 'source.local': 'Local record', - 'source.agentMarked': 'Agent wrote a source marker', - 'source.demoNote': 'From the demo command, not real user work', - 'source.importedClaude': 'Claude Code history import', - 'source.importedClaudeNote': 'Imported from local JSONL history', - 'source.pathInferred': 'Inferred from project path', - 'source.unknownAgentNote': 'This session does not identify which agent created it', - 'agent.avatarAlt': 'avatar', - 'ses.noRecordId': 'no record ID', - 'tab.memories': 'Memories', 'tab.lessons': 'Lessons', 'tab.graph': 'Graph', 'tab.timeline': 'Timeline', 'tab.activity': 'Live', 'tab.profile': 'Profile', 'tab.audit': 'Audit', 'tab.replay': 'Replay', 'tab.crystals': 'Crystals', - 'ses.emptyNeedRetry': 'No sessions read yet — make sure the local service is running, then retry.', - 'ses.empty': 'No sessions yet', 'ses.retry': 'Retry', 'ses.allSessions': 'All sessions', - 'ses.heroTitle': 'Sessions', - 'ses.heroNote': 'Sessions from your browser and local agents land here; browse them by folder or source.', - 'ses.unitSessions': 'sessions', 'ses.unitFolders': 'folders', 'ses.unitSources': 'sources', 'ses.unitRecords': 'records', 'ses.recordsUnit': 'records', - 'ses.groupModeAria': 'Group sessions by', 'ses.byFolder': 'By folder', 'ses.bySource': 'By source', - 'ses.groupAria': 'Session groups', 'ses.source': 'Source', 'ses.folder': 'Folder', - 'ses.emptyFolder': 'No sessions in this folder yet.', 'ses.emptySource': 'No sessions from this source yet.', - 'ses.noPreview': 'No preview — click to view the full session.', - 'ses.avatarFallback': 'S', 'ses.localRecord': 'Local record' - }, - zh: { - 'tab.dashboard': '总览', 'tab.actions': '待办', 'tab.sessions': '证据', - 'status.pending': '待处理', 'status.active': '进行中', 'status.done': '已完成', - 'status.blocked': '受阻', 'status.cancelled': '已取消', - 'filter.review': '待确认', 'filter.all': '全部', - 'dash.firstRun.kicker': '首次使用', - 'dash.firstRun.title': '先导入示例数据', - 'dash.firstRun.body': '目前还没有会话数据。先运行 demo,页面就会出现可浏览的待办与活动。', - 'dash.firstRun.link': '查看快速开始 →', - 'dash.noRecord': '暂无记录', - 'dash.stat.sessions': '会话', - 'dash.stat.recent': '最近', - 'dash.stat.todos': '待办', - 'dash.stat.openWorkbench': '打开工作台', - 'dash.stat.replyQueue': '待回复队列', - 'dash.stat.actionCandidates': '行动候选', - 'dash.stat.pendingActions': '待跟进行动', - 'dash.stat.memories': '记忆', - 'dash.stat.latestVersion': '最新版本', - 'dash.stat.lessons': '经验', - 'dash.stat.lessonsSub': '可整理经验', - 'dash.stat.graphNodes': '关系节点', - 'dash.stat.edges': '连线', - 'dash.stat.status': '状态', - 'dash.stat.toolCalls': '工具调用', - 'dash.stat.tracking': '追踪', - 'dash.stat.functions': '个函数', - 'dash.stat.circuitBreaker': '熔断器', - 'dash.stat.failed': '失败', - 'dash.stat.times': '次', - 'dash.systemResources': '系统资源', - 'dash.alerts': '告警', - 'dash.notes': '备注', - 'dash.recentSessions': '最近会话', - 'dash.emptySessions': '还没有会话数据。先运行 demo 或接入你的工作流。', - 'dash.unnamedSession': '未命名会话', - 'dash.openLatestSyncOf': '打开最新同步 ·', - 'dash.syncs': '次同步', - 'dash.records': '条记录', - 'dash.lastSync': '最近同步', - 'dash.functionMetrics': '函数指标 (OTel)', - 'dash.workers': '工作进程', - 'dash.circuitBreakerDetail': '熔断器详情', - 'dash.semanticMemory': '语义记忆', - 'dash.emptySemantic': '还没有语义记忆,系统会逐步从观察中沉淀。', - 'dash.proceduralMemory': '流程记忆', - 'dash.emptyProcedural': '还没有流程记忆,重复模式会自动提炼。', - 'dash.consolidationStatus': '归并状态', - 'dash.memoryRelations': '记忆关系', - 'act.attn.next': '下一步', 'act.attn.needsWork': '需要处理', 'act.attn.noteworthy': '值得关注', - 'act.prio.high': '重要', 'act.prio.normal': '普通', 'act.prio.low': '不急', - 'act.untitled': '未命名待办', 'act.untitledCandidate': '未命名待办候选', - 'act.metric.waiting': '待回应', 'act.metric.review': '待确认', 'act.metric.followUp': '待跟进', 'act.metric.active': '进行中', 'act.metric.done': '已完成', - 'act.searchPlaceholder': '搜索待办...', - 'act.nToConfirm': '条待确认', 'act.nConfirmed': '件已确认', 'act.refresh': '刷新', - 'act.viewOriginal': '查看原文', 'act.confirm': '确认', 'act.ignore': '忽略', - 'act.from': '来自', 'act.viewSource': '看原文 →', 'act.updated': '更新', - 'act.extract.title': '调用大模型整理最近会话', - 'act.extract.run': '用大模型整理', - 'act.extract.running': '整理中...', - 'act.extract.done': '已整理', - 'act.extract.rules': '未走大模型', - 'act.extract.error': '整理失败', - 'act.extract.failedExisting': '抽取失败,已显示现有待办', - 'act.extract.loading': '正在整理待办...', - 'act.extract.starting': '正在从最近会话整理待办...', - 'act.extract.background': '已显示最新待办,后台仍在整理...', - 'act.extract.doneLlm': '大模型整理完成', - 'act.extract.doneMixed': '部分大模型整理完成', - 'act.extract.doneRules': '未走大模型,已用规则整理', - 'act.extract.created': '新增', - 'act.extract.history': '历史', - 'act.extract.discarded': '丢弃', - 'act.extract.cleaned': '清理', - 'act.extract.reason': '原因', - 'act.cleanup.title': '用大模型更新已记录的卡片(来源会话有新进展时)', - 'act.cleanup.run': '更新', - 'act.cleanup.running': '更新中...', - 'act.cleanup.applying': '正在应用更新...', - 'act.cleanup.done': '已更新', - 'act.cleanup.error': '更新失败', - 'act.cleanup.failed': '更新失败,卡片未改动', - 'act.cleanup.clean': '卡片已是最新', - 'act.cleanup.llmUnavailable': '大模型不可用 — 未改动', - 'act.cleanup.confirm': '应用这些更新?', - 'act.cleanup.summary': '更新 {rewritten} · 完成 {completed} · 丢弃 {dropped} · 合并 {merged}', - 'act.status.complete': '完成', - 'act.status.archive': '归档', - 'act.status.delete': '删除', - 'act.itemsUnit': '件', - 'act.empty.title': '还没有待办', - 'act.empty.lead': '这里会放从会话里整理出的待办、卡住事项和已完成事项。', - 'settings.title': '设置', - 'settings.subtitle': '本机配置会写入用户配置文件,重启服务后生效。', - 'settings.close': '关闭', - 'settings.language': '界面语言', - 'settings.extractor': '大模型抽取配置', - 'settings.sinceDays': '回溯天数:只抽取最近 N 天内的会话', - 'settings.maxInteractions': '每会话最多交互记录数(一次用户派发→Agent 回复为一条)', - 'settings.apiKeyKeep': '输入新 API key 覆盖,留空保持不变', - 'settings.apiKeyMissing': '未配置', - 'settings.apiKeyLabel': 'API key:', - 'settings.save': '保存配置', - 'settings.saving': '保存中...', - 'settings.savedRestart': '配置已保存,重启后生效。', - 'settings.saveFailed': '配置保存失败', - 'act.status.updateFailed': '待办状态更新失败', - 'obs.type.file_read': '读取文件', - 'obs.type.file_write': '写入文件', - 'obs.type.file_edit': '编辑文件', - 'obs.type.command_run': '执行命令', - 'obs.type.search': '搜索', - 'obs.type.web_fetch': '网页获取', - 'obs.type.conversation': '对话', - 'obs.type.error': '错误', - 'obs.type.decision': '决策', - 'obs.type.discovery': '发现', - 'obs.type.subagent': '子代理', - 'obs.type.notification': '通知', - 'obs.type.task': '任务', - 'obs.type.image': '图片', - 'obs.type.other': '其它', - 'obs.summary.localOperation': '执行本地操作', - 'obs.summary.restartService': '重启 AI Todo 服务', - 'obs.summary.startService': '启动 AI Todo 服务', - 'obs.summary.openPreview': '打开 AI Todo 预览', - 'obs.summary.checkService': '检查服务状态', - 'obs.summary.verifyPageFix': '验证页面修复是否生效', - 'obs.summary.viewFixLog': '查看修复日志', - 'obs.summary.checkBrowserAutomation': '检查浏览器自动化工具是否可用', - 'obs.summary.checkProcess': '检查本地服务进程', - 'obs.summary.organizeLocalFiles': '整理本地文件', - 'obs.summary.readLocalServiceData': '读取本地服务数据', - 'obs.summary.viewLocalFiles': '查看本地文件内容', - 'obs.summary.runProjectScript': '运行项目脚本', - 'obs.summary.checkCodeVersion': '检查或更新代码版本', - 'obs.summary.runLocalCommand': '执行本地命令', - 'obs.output.noText': '这一步已执行,没有返回额外文本。', - 'obs.output.serviceOk': '结果正常,服务已经可用。', - 'obs.output.npxMissing': '检查结果:当前环境没有可用的 npx。', - 'obs.output.npxOk': '检查结果:浏览器自动化工具依赖可用。', - 'obs.output.pageFixOk': '检查结果:页面里已经包含对应修复逻辑。', - 'obs.output.error': '执行返回了错误信息,需要继续排查。', - 'obs.output.readService': '已读取本地服务返回的数据。', - 'obs.output.viewFiles': '已查看本地文件内容,用于确认当前状态。', - 'obs.output.done': '这一步已完成,并返回了执行结果。', - 'obs.display.promptSubmit': '用户提出需求', - 'obs.display.agentMessage': '助手回应', - 'obs.display.updatePlan': '更新执行计划', - 'obs.display.updatePlanBody': '已更新任务计划。', - 'obs.display.applyPatch': '更新本地文件', - 'obs.display.applyPatchBody': '已应用代码修改。', - 'obs.display.toolTrace': '这一步已记录为结构化工具事件。', - 'episode.type.file_read': '读取资料', - 'episode.type.file_write': '写入文件', - 'episode.type.file_edit': '修改文件', - 'episode.type.command_run': '本地操作', - 'episode.type.search': '搜索定位', - 'episode.type.web_fetch': '网页资料', - 'episode.type.conversation': '对话推进', - 'episode.type.error': '异常排查', - 'episode.type.decision': '决策记录', - 'episode.type.discovery': '发现线索', - 'episode.type.subagent': '协作任务', - 'episode.type.notification': '提醒', - 'episode.type.task': '任务推进', - 'episode.type.other': '其它', - 'episode.kind.user_need': '用户需求', - 'episode.kind.bugfix': '修复记录', - 'episode.kind.research': '项目研究', - 'episode.kind.file_work': '文件整理', - 'episode.kind.important': '重要片段', - 'episode.kind.work': '工作推进', - 'episode.workSegment': '工作片段', - 'episode.record': '记录', - 'episode.bodyPrefix': '这一段主要包含 ', - 'episode.bodySuffix': ',已从底层记录整理成可读片段。', - 'project.uncategorized': '未归类', - 'project.all': '全部项目', - 'project.browser': '浏览器', - 'project.demo': '演示数据', - 'source.local': '本地记录', - 'source.agentMarked': 'Agent 已写入来源标记', - 'source.demoNote': '来自 demo 命令,不代表你的真实工作', - 'source.importedClaude': 'Claude Code 历史导入', - 'source.importedClaudeNote': '从本地 JSONL 历史记录导入', - 'source.pathInferred': '按项目路径识别', - 'source.unknownAgentNote': '这条会话没有写明来自哪个 Agent', - 'agent.avatarAlt': '头像', - 'ses.noRecordId': '无记录 ID', - 'tab.memories': '记忆', 'tab.lessons': '经验', 'tab.graph': '图谱', 'tab.timeline': '时间线', 'tab.activity': '实时', 'tab.profile': '档案', 'tab.audit': '审计', 'tab.replay': '回放', 'tab.crystals': '结晶', - 'ses.emptyNeedRetry': '暂时没有读到会话,请确认本地服务已启动后重试。', - 'ses.empty': '暂无会话', 'ses.retry': '重试加载', 'ses.allSessions': '全部会话', - 'ses.heroTitle': '会话', - 'ses.heroNote': '浏览器和本地 Agent 的会话会汇入这里,可以按文件夹或来源查看。', - 'ses.unitSessions': '段会话', 'ses.unitFolders': '个文件夹', 'ses.unitSources': '个来源', 'ses.unitRecords': '条记录', 'ses.recordsUnit': '条', - 'ses.groupModeAria': '会话分组方式', 'ses.byFolder': '按文件夹', 'ses.bySource': '按来源', - 'ses.groupAria': '会话分组', 'ses.source': '来源', 'ses.folder': '文件夹', - 'ses.emptyFolder': '这个文件夹下暂时没有会话。', 'ses.emptySource': '这个来源下暂时没有会话。', - 'ses.noPreview': '暂无预览,点击查看完整过程。', - 'ses.avatarFallback': '会', 'ses.localRecord': '本地记录' - } - }; - var I18N_LANG = 'en'; - function t(key, fallback) { - var table = I18N_MESSAGES[I18N_LANG] || {}; - if (Object.prototype.hasOwnProperty.call(table, key)) return table[key]; - if (Object.prototype.hasOwnProperty.call(I18N_MESSAGES.en, key)) return I18N_MESSAGES.en[key]; - return fallback != null ? fallback : key; - } - function statusLabel(status) { return t('status.' + status, status); } - /* i18n-core:end */ - (function () { - try { - var lang = new URLSearchParams(window.location.search).get('lang') || localStorage.getItem('agentmemory-lang'); - if (lang === 'en' || lang === 'zh') I18N_LANG = lang; - } catch (e) { /* no URL access — keep default */ } - })(); - function applyI18n(root) { - var scope = root || document; - scope.querySelectorAll('[data-i18n]').forEach(function (el) { - el.textContent = t(el.getAttribute('data-i18n')); - }); - var gear = document.getElementById('settings-gear'); - if (gear) { - gear.setAttribute('aria-label', t('settings.title')); - gear.setAttribute('title', t('settings.title')); - } - } - if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', function () { applyI18n(); }); - } else { - applyI18n(); - } - // === /i18n base === - function observationTypeLabel(type) { - var key = String(type || 'other'); - return t('obs.type.' + key, key.replace(/_/g, ' ')); - } - var CB_STATE_COLORS = { closed: 'badge-green', open: 'badge-red', 'half-open': 'badge-yellow' }; - var TAB_IDS = ['dashboard', 'actions', 'sessions']; - var TAB_REDIRECTS = { memories: 'dashboard', lessons: 'dashboard', activity: 'sessions', graph: 'dashboard', profile: 'dashboard', audit: 'dashboard', replay: 'dashboard', timeline: 'sessions', crystals: 'dashboard' }; - // 专家模式:默认三栏干净;开启后在导航末尾恢复被砍/被隐藏的视图(后端资产不丢)。 - // 这些视图的 view 容器与渲染函数都还在文件里,本步只是放行入口。 - var EXPERT_TABS = [ - { id: 'memories', label: 'tab.memories' }, - { id: 'lessons', label: 'tab.lessons' }, - { id: 'graph', label: 'tab.graph' }, - { id: 'timeline', label: 'tab.timeline' }, - { id: 'activity', label: 'tab.activity' }, - { id: 'profile', label: 'tab.profile' }, - { id: 'audit', label: 'tab.audit' }, - { id: 'replay', label: 'tab.replay' }, - { id: 'crystals', label: 'tab.crystals' } - ]; - function expertModeEnabled() { - try { - var q = new URLSearchParams(window.location.search); - if (q.get('expert') === '1') return true; - if (q.get('expert') === '0') return false; - } catch (_) {} - try { return localStorage.getItem('viewer_expert_mode') === '1'; } catch (_) { return false; } - } - function setExpertMode(on) { - try { localStorage.setItem('viewer_expert_mode', on ? '1' : '0'); } catch (_) {} - } - // 把被隐藏视图的按钮渲染进导航(仅专家模式)。幂等:每次先清掉旧的再按需重建。 - function renderExpertTabs() { - var bar = document.getElementById('tab-bar'); - if (!bar) return; - var on = expertModeEnabled(); - var existing = document.getElementById('tab-expert-group'); - if (existing && existing.parentNode) existing.parentNode.removeChild(existing); - var toggle = document.getElementById('expert-toggle'); - if (toggle) toggle.setAttribute('aria-pressed', on ? 'true' : 'false'); - if (!on) return; - var main = bar.querySelector('.tab-main'); - if (!main) return; - var group = document.createElement('span'); - group.id = 'tab-expert-group'; - group.className = 'tab-expert-group'; - EXPERT_TABS.forEach(function(tab) { - var b = document.createElement('button'); - b.setAttribute('data-tab', tab.id); - b.className = 'tab-expert-btn'; - b.textContent = t(tab.label); - group.appendChild(b); - }); - main.appendChild(group); - } - function toggleExpertMode() { - var next = !expertModeEnabled(); - setExpertMode(next); - renderExpertTabs(); - // 关闭时若当前停在被隐藏视图,折回总览 - if (!next && EXPERT_TABS.some(function(t) { return t.id === state.activeTab; })) { - switchTab('dashboard'); - } - } - - var LOCAL_SKILLS = [{"name":"academic-cv-builder","root":"Agents","path":"~/.agents/skills/academic-cv-builder/SKILL.md"},{"name":"career-changer-translator","root":"Agents","path":"~/.agents/skills/career-changer-translator/SKILL.md"},{"name":"cover-letter-generator","root":"Agents","path":"~/.agents/skills/cover-letter-generator/SKILL.md"},{"name":"creative-portfolio-resume","root":"Agents","path":"~/.agents/skills/creative-portfolio-resume/SKILL.md"},{"name":"executive-resume-writer","root":"Agents","path":"~/.agents/skills/executive-resume-writer/SKILL.md"},{"name":"find-skills","root":"Agents","path":"~/.agents/skills/find-skills/SKILL.md"},{"name":"guizang-social-card-skill","root":"Agents","path":"~/.agents/skills/guizang-social-card-skill/SKILL.md"},{"name":"huashu-design","root":"Agents","path":"~/.agents/skills/huashu-design/SKILL.md"},{"name":"interview-prep-generator","root":"Agents","path":"~/.agents/skills/interview-prep-generator/SKILL.md"},{"name":"job-description-analyzer","root":"Agents","path":"~/.agents/skills/job-description-analyzer/SKILL.md"},{"name":"linkedin-profile-optimizer","root":"Agents","path":"~/.agents/skills/linkedin-profile-optimizer/SKILL.md"},{"name":"offer-comparison-analyzer","root":"Agents","path":"~/.agents/skills/offer-comparison-analyzer/SKILL.md"},{"name":"pdf","root":"Agents","path":"~/.agents/skills/pdf/SKILL.md"},{"name":"portfolio-case-study-writer","root":"Agents","path":"~/.agents/skills/portfolio-case-study-writer/SKILL.md"},{"name":"post-to-xhs","root":"Agents","path":"~/.agents/skills/xiaohongshu/skills/post-to-xhs/SKILL.md"},{"name":"reference-list-builder","root":"Agents","path":"~/.agents/skills/reference-list-builder/SKILL.md"},{"name":"resume-ats-optimizer","root":"Agents","path":"~/.agents/skills/resume-ats-optimizer/SKILL.md"},{"name":"resume-bullet-writer","root":"Agents","path":"~/.agents/skills/resume-bullet-writer/SKILL.md"},{"name":"resume-formatter","root":"Agents","path":"~/.agents/skills/resume-formatter/SKILL.md"},{"name":"resume-quantifier","root":"Agents","path":"~/.agents/skills/resume-quantifier/SKILL.md"},{"name":"resume-section-builder","root":"Agents","path":"~/.agents/skills/resume-section-builder/SKILL.md"},{"name":"resume-tailor","root":"Agents","path":"~/.agents/skills/resume-tailor/SKILL.md"},{"name":"resume-version-manager","root":"Agents","path":"~/.agents/skills/resume-version-manager/SKILL.md"},{"name":"salary-negotiation-prep","root":"Agents","path":"~/.agents/skills/salary-negotiation-prep/SKILL.md"},{"name":"setup-xhs-mcp","root":"Agents","path":"~/.agents/skills/xiaohongshu/skills/setup-xhs-mcp/SKILL.md"},{"name":"social-media-marketing","root":"Agents","path":"~/.agents/skills/social-media-marketing/SKILL.md"},{"name":"tech-resume-optimizer","root":"Agents","path":"~/.agents/skills/tech-resume-optimizer/SKILL.md"},{"name":"weread-skills","root":"Agents","path":"~/.agents/skills/weread-skills/SKILL.md"},{"name":"x-twitter-growth","root":"Agents","path":"~/.agents/skills/x-twitter-growth/SKILL.md"},{"name":"xhs-content-plan","root":"Agents","path":"~/.agents/skills/xiaohongshu/skills/xhs-content-plan/SKILL.md"},{"name":"xhs-explore","root":"Agents","path":"~/.agents/skills/xiaohongshu/skills/xhs-explore/SKILL.md"},{"name":"xhs-interact","root":"Agents","path":"~/.agents/skills/xiaohongshu/skills/xhs-interact/SKILL.md"},{"name":"xhs-login","root":"Agents","path":"~/.agents/skills/xiaohongshu/skills/xhs-login/SKILL.md"},{"name":"xhs-profile","root":"Agents","path":"~/.agents/skills/xiaohongshu/skills/xhs-profile/SKILL.md"},{"name":"xhs-search","root":"Agents","path":"~/.agents/skills/xiaohongshu/skills/xhs-search/SKILL.md"},{"name":"xiaohongshu","root":"Agents","path":"~/.agents/skills/xiaohongshu/SKILL.md"},{"name":"academic-cv-builder","root":"Codex","path":"~/.codex/skills/academic-cv-builder/SKILL.md"},{"name":"aihot","root":"Codex","path":"~/.codex/skills/aihot/SKILL.md"},{"name":"andrej-karpathy-perspective","root":"Codex","path":"~/.codex/skills/nuwa-skill/examples/andrej-karpathy-perspective/SKILL.md"},{"name":"buddyup-next","root":"Codex","path":"~/.codex/skills/buddyup-next/SKILL.md"},{"name":"buddyup-study-abroad-assistant","root":"Codex","path":"~/.codex/skills/buddyup-study-abroad-assistant/SKILL.md"},{"name":"buddyup-xiaohongshu-growth","root":"Codex","path":"~/.codex/skills/buddyup-xiaohongshu-growth/SKILL.md"},{"name":"cai-life-skill","root":"Codex","path":"~/.codex/skills/cai-life-skill/SKILL.md"},{"name":"career-changer-translator","root":"Codex","path":"~/.codex/skills/career-changer-translator/SKILL.md"},{"name":"chatgpt-apps","root":"Codex","path":"~/.codex/skills/chatgpt-apps/SKILL.md"},{"name":"cli-creator","root":"Codex","path":"~/.codex/skills/cli-creator/SKILL.md"},{"name":"cocoloop-main","root":"Codex","path":"~/.codex/skills/cocoloop-main/SKILL.md"},{"name":"cover-letter-generator","root":"Codex","path":"~/.codex/skills/cover-letter-generator/SKILL.md"},{"name":"creative-portfolio-resume","root":"Codex","path":"~/.codex/skills/creative-portfolio-resume/SKILL.md"},{"name":"douban-sync-skill","root":"Codex","path":"~/.codex/skills/douban-sync-skill/SKILL.md"},{"name":"elon-musk-perspective","root":"Codex","path":"~/.codex/skills/nuwa-skill/examples/elon-musk-perspective/SKILL.md"},{"name":"executive-resume-writer","root":"Codex","path":"~/.codex/skills/executive-resume-writer/SKILL.md"},{"name":"feishu-research-docs","root":"Codex","path":"~/.codex/skills/feishu-research-docs/SKILL.md"},{"name":"feynman-perspective","root":"Codex","path":"~/.codex/skills/nuwa-skill/examples/feynman-perspective/SKILL.md"},{"name":"figma-code-connect-components","root":"Codex","path":"~/.codex/skills/figma-code-connect-components/SKILL.md"},{"name":"figma-create-design-system-rules","root":"Codex","path":"~/.codex/skills/figma-create-design-system-rules/SKILL.md"},{"name":"figma","root":"Codex","path":"~/.codex/skills/figma/SKILL.md"},{"name":"gh-address-comments","root":"Codex","path":"~/.codex/skills/gh-address-comments/SKILL.md"},{"name":"hv-analysis","root":"Codex","path":"~/.codex/skills/hv-analysis/SKILL.md"},{"name":"ilya-sutskever-perspective","root":"Codex","path":"~/.codex/skills/nuwa-skill/examples/ilya-sutskever-perspective/SKILL.md"},{"name":"imagegen","root":"Codex","path":"~/.codex/skills/.system/imagegen/SKILL.md"},{"name":"interview-prep-generator","root":"Codex","path":"~/.codex/skills/interview-prep-generator/SKILL.md"},{"name":"job-description-analyzer","root":"Codex","path":"~/.codex/skills/job-description-analyzer/SKILL.md"},{"name":"kevin-kelly-perspective","root":"Codex","path":"~/.codex/skills/kevin-kelly-perspective/SKILL.md"},{"name":"khazix-writer","root":"Codex","path":"~/.codex/skills/khazix-writer/SKILL.md"},{"name":"laws-of-ux-2","root":"Codex","path":"~/.codex/skills/laws-of-ux-2/SKILL.md"},{"name":"linkedin-profile-optimizer","root":"Codex","path":"~/.codex/skills/linkedin-profile-optimizer/SKILL.md"},{"name":"mrbeast-perspective","root":"Codex","path":"~/.codex/skills/nuwa-skill/examples/mrbeast-perspective/SKILL.md"},{"name":"munger-perspective","root":"Codex","path":"~/.codex/skills/nuwa-skill/examples/munger-perspective/SKILL.md"},{"name":"naval-perspective","root":"Codex","path":"~/.codex/skills/nuwa-skill/examples/naval-perspective/SKILL.md"},{"name":"neat-freak","root":"Codex","path":"~/.codex/skills/neat-freak/SKILL.md"},{"name":"nuwa-skill","root":"Codex","path":"~/.codex/skills/nuwa-skill/SKILL.md"},{"name":"offer-comparison-analyzer","root":"Codex","path":"~/.codex/skills/offer-comparison-analyzer/SKILL.md"},{"name":"openai-docs","root":"Codex","path":"~/.codex/skills/.system/openai-docs/SKILL.md"},{"name":"paul-graham-perspective","root":"Codex","path":"~/.codex/skills/nuwa-skill/examples/paul-graham-perspective/SKILL.md"},{"name":"playwright","root":"Codex","path":"~/.codex/skills/playwright/SKILL.md"},{"name":"plugin-creator","root":"Codex","path":"~/.codex/skills/.system/plugin-creator/SKILL.md"},{"name":"portfolio-case-study-writer","root":"Codex","path":"~/.codex/skills/portfolio-case-study-writer/SKILL.md"},{"name":"proactive-everme","root":"Codex","path":"~/.codex/skills/proactive-everme/SKILL.md"},{"name":"reference-list-builder","root":"Codex","path":"~/.codex/skills/reference-list-builder/SKILL.md"},{"name":"resume-ats-optimizer","root":"Codex","path":"~/.codex/skills/resume-ats-optimizer/SKILL.md"},{"name":"resume-bullet-writer","root":"Codex","path":"~/.codex/skills/resume-bullet-writer/SKILL.md"},{"name":"resume-formatter","root":"Codex","path":"~/.codex/skills/resume-formatter/SKILL.md"},{"name":"resume-quantifier","root":"Codex","path":"~/.codex/skills/resume-quantifier/SKILL.md"},{"name":"resume-section-builder","root":"Codex","path":"~/.codex/skills/resume-section-builder/SKILL.md"},{"name":"resume-tailor","root":"Codex","path":"~/.codex/skills/resume-tailor/SKILL.md"},{"name":"resume-version-manager","root":"Codex","path":"~/.codex/skills/resume-version-manager/SKILL.md"},{"name":"salary-negotiation-prep","root":"Codex","path":"~/.codex/skills/salary-negotiation-prep/SKILL.md"},{"name":"self-improvement","root":"Codex","path":"~/.codex/skills/self-improvement/SKILL.md"},{"name":"skill-creator","root":"Codex","path":"~/.codex/skills/.system/skill-creator/SKILL.md"},{"name":"skill-installer","root":"Codex","path":"~/.codex/skills/.system/skill-installer/SKILL.md"},{"name":"speech","root":"Codex","path":"~/.codex/skills/speech/SKILL.md"},{"name":"steve-jobs-perspective","root":"Codex","path":"~/.codex/skills/nuwa-skill/examples/steve-jobs-perspective/SKILL.md"},{"name":"storage-analyzer","root":"Codex","path":"~/.codex/skills/storage-analyzer/SKILL.md"},{"name":"sun-yuchen-perspective","root":"Codex","path":"~/.codex/skills/nuwa-skill/examples/sun-yuchen-perspective/SKILL.md"},{"name":"taleb-perspective","root":"Codex","path":"~/.codex/skills/nuwa-skill/examples/taleb-perspective/SKILL.md"},{"name":"taught-master-applications","root":"Codex","path":"~/.codex/skills/taught-master-applications/SKILL.md"},{"name":"taught-master-applications","root":"Codex","path":"~/.codex/skills/taught-master-applications/taught-master-applications/SKILL.md"},{"name":"tech-resume-optimizer","root":"Codex","path":"~/.codex/skills/tech-resume-optimizer/SKILL.md"},{"name":"trump-perspective","root":"Codex","path":"~/.codex/skills/nuwa-skill/examples/trump-perspective/SKILL.md"},{"name":"x-mastery-mentor","root":"Codex","path":"~/.codex/skills/nuwa-skill/examples/x-mastery-mentor/SKILL.md"},{"name":"zhang-yiming-perspective","root":"Codex","path":"~/.codex/skills/nuwa-skill/examples/zhang-yiming-perspective/SKILL.md"},{"name":"zhangxuefeng-perspective","root":"Codex","path":"~/.codex/skills/nuwa-skill/examples/zhangxuefeng-perspective/SKILL.md"},{"name":"agentmemory-import","root":"Plugin","path":"~/.codex/plugins/cache/agentmemory-lab/agentmemory-lab/0.1.0/skills/agentmemory-import/SKILL.md"},{"name":"agentmemory-understand","root":"Plugin","path":"~/.codex/plugins/cache/agentmemory-lab/agentmemory-lab/0.1.0/skills/agentmemory-understand/SKILL.md"},{"name":"agentmemory","root":"Plugin","path":"~/.codex/plugins/cache/agentmemory-lab/agentmemory-lab/0.1.0/skills/agentmemory/SKILL.md"},{"name":"audit","root":"Plugin","path":"~/.codex/plugins/cache/role-specific-plugins/product-design/0.1.41/skills/audit/SKILL.md"},{"name":"control-in-app-browser","root":"Plugin","path":"~/.codex/plugins/cache/openai-bundled/browser/26.601.21317/skills/control-in-app-browser/SKILL.md"},{"name":"design-qa","root":"Plugin","path":"~/.codex/plugins/cache/role-specific-plugins/product-design/0.1.41/skills/design-qa/SKILL.md"},{"name":"documents","root":"Plugin","path":"~/.codex/plugins/cache/openai-primary-runtime/documents/26.601.10930/skills/documents/SKILL.md"},{"name":"everme-memory","root":"Plugin","path":"~/.codex/plugins/cache/everme/everme/0.4.0/skills/everme-memory/SKILL.md"},{"name":"figma-code-connect","root":"Plugin","path":"~/.codex/plugins/cache/openai-curated/figma/2abb1c44/skills/figma-code-connect/SKILL.md"},{"name":"figma-create-new-file","root":"Plugin","path":"~/.codex/plugins/cache/openai-curated/figma/2abb1c44/skills/figma-create-new-file/SKILL.md"},{"name":"figma-generate-design","root":"Plugin","path":"~/.codex/plugins/cache/openai-curated/figma/2abb1c44/skills/figma-generate-design/SKILL.md"},{"name":"figma-generate-diagram","root":"Plugin","path":"~/.codex/plugins/cache/openai-curated/figma/2abb1c44/skills/figma-generate-diagram/SKILL.md"},{"name":"figma-generate-library","root":"Plugin","path":"~/.codex/plugins/cache/openai-curated/figma/2abb1c44/skills/figma-generate-library/SKILL.md"},{"name":"figma-use-figjam","root":"Plugin","path":"~/.codex/plugins/cache/openai-curated/figma/2abb1c44/skills/figma-use-figjam/SKILL.md"},{"name":"figma-use-slides","root":"Plugin","path":"~/.codex/plugins/cache/openai-curated/figma/2abb1c44/skills/figma-use-slides/SKILL.md"},{"name":"figma-use","root":"Plugin","path":"~/.codex/plugins/cache/openai-curated/figma/2abb1c44/skills/figma-use/SKILL.md"},{"name":"get-context","root":"Plugin","path":"~/.codex/plugins/cache/role-specific-plugins/product-design/0.1.41/skills/get-context/SKILL.md"},{"name":"ideate","root":"Plugin","path":"~/.codex/plugins/cache/role-specific-plugins/product-design/0.1.41/skills/ideate/SKILL.md"},{"name":"image-to-code","root":"Plugin","path":"~/.codex/plugins/cache/role-specific-plugins/product-design/0.1.41/skills/image-to-code/SKILL.md"},{"name":"index","root":"Plugin","path":"~/.codex/plugins/cache/role-specific-plugins/product-design/0.1.41/skills/index/SKILL.md"},{"name":"presentations","root":"Plugin","path":"~/.codex/plugins/cache/openai-primary-runtime/presentations/26.601.10930/skills/presentations/SKILL.md"},{"name":"prototype","root":"Plugin","path":"~/.codex/plugins/cache/role-specific-plugins/product-design/0.1.41/skills/prototype/SKILL.md"},{"name":"research","root":"Plugin","path":"~/.codex/plugins/cache/role-specific-plugins/product-design/0.1.41/skills/research/SKILL.md"},{"name":"share","root":"Plugin","path":"~/.codex/plugins/cache/role-specific-plugins/product-design/0.1.41/skills/share/SKILL.md"},{"name":"spreadsheets","root":"Plugin","path":"~/.codex/plugins/cache/openai-primary-runtime/spreadsheets/26.601.10930/skills/spreadsheets/SKILL.md"},{"name":"understand-chat","root":"Plugin","path":"~/.codex/plugins/cache/understand-anything/plugin-backup-Q98O9D/understand-anything/2.7.5/skills/understand-chat/SKILL.md"},{"name":"understand-chat","root":"Plugin","path":"~/.codex/plugins/cache/understand-anything/understand-anything/2.7.6/skills/understand-chat/SKILL.md"},{"name":"understand-dashboard","root":"Plugin","path":"~/.codex/plugins/cache/understand-anything/plugin-backup-Q98O9D/understand-anything/2.7.5/skills/understand-dashboard/SKILL.md"},{"name":"understand-dashboard","root":"Plugin","path":"~/.codex/plugins/cache/understand-anything/understand-anything/2.7.6/skills/understand-dashboard/SKILL.md"},{"name":"understand-diff","root":"Plugin","path":"~/.codex/plugins/cache/understand-anything/plugin-backup-Q98O9D/understand-anything/2.7.5/skills/understand-diff/SKILL.md"},{"name":"understand-diff","root":"Plugin","path":"~/.codex/plugins/cache/understand-anything/understand-anything/2.7.6/skills/understand-diff/SKILL.md"},{"name":"understand-domain","root":"Plugin","path":"~/.codex/plugins/cache/understand-anything/plugin-backup-Q98O9D/understand-anything/2.7.5/skills/understand-domain/SKILL.md"},{"name":"understand-domain","root":"Plugin","path":"~/.codex/plugins/cache/understand-anything/understand-anything/2.7.6/skills/understand-domain/SKILL.md"},{"name":"understand-explain","root":"Plugin","path":"~/.codex/plugins/cache/understand-anything/plugin-backup-Q98O9D/understand-anything/2.7.5/skills/understand-explain/SKILL.md"},{"name":"understand-explain","root":"Plugin","path":"~/.codex/plugins/cache/understand-anything/understand-anything/2.7.6/skills/understand-explain/SKILL.md"},{"name":"understand-knowledge","root":"Plugin","path":"~/.codex/plugins/cache/understand-anything/plugin-backup-Q98O9D/understand-anything/2.7.5/skills/understand-knowledge/SKILL.md"},{"name":"understand-knowledge","root":"Plugin","path":"~/.codex/plugins/cache/understand-anything/understand-anything/2.7.6/skills/understand-knowledge/SKILL.md"},{"name":"understand-onboard","root":"Plugin","path":"~/.codex/plugins/cache/understand-anything/plugin-backup-Q98O9D/understand-anything/2.7.5/skills/understand-onboard/SKILL.md"},{"name":"understand-onboard","root":"Plugin","path":"~/.codex/plugins/cache/understand-anything/understand-anything/2.7.6/skills/understand-onboard/SKILL.md"},{"name":"understand","root":"Plugin","path":"~/.codex/plugins/cache/understand-anything/plugin-backup-Q98O9D/understand-anything/2.7.5/skills/understand/SKILL.md"},{"name":"understand","root":"Plugin","path":"~/.codex/plugins/cache/understand-anything/understand-anything/2.7.6/skills/understand/SKILL.md"},{"name":"url-to-code","root":"Plugin","path":"~/.codex/plugins/cache/role-specific-plugins/product-design/0.1.41/skills/url-to-code/SKILL.md"},{"name":"user-context","root":"Plugin","path":"~/.codex/plugins/cache/role-specific-plugins/product-design/0.1.41/skills/user-context/SKILL.md"}]; - - var state = { - activeTab: 'dashboard', - dashboard: { loaded: false, health: null, sessions: [], memories: [], actions: [], actionReviews: [], inboxAwaiting: [], graphStats: null, recentAudit: [], lessons: [], crystals: [], delivery: null }, - dashboardRefresh: { enabled: true, intervalMs: 30000 }, - graph: { loaded: false, nodes: [], edges: [], stats: null, filters: {}, selectedNode: null }, - memories: { loaded: false, items: [], reviewItems: [], search: '', typeFilter: '', sourceFilter: '' }, - timeline: { loaded: false, observations: [], sessionId: '', projectKey: '', sessions: [], minImportance: 0, page: 0, pageSize: 50, mode: 'episodes', episodeFilter: 'all', expandedEpisodes: {} }, - sessions: { loaded: false, items: [], selectedId: null, groupMode: 'folder', folderKey: 'all', sourceKey: 'all', warnings: [], highlightsById: {}, detailSectionsById: {}, detailCacheById: {}, stale: false, requestSeq: 0, detailRequestSeq: 0, pendingHighlightObsId: null, previewExpandedById: {} }, - audit: { loaded: false, entries: [], opFilter: '' }, - activity: { loaded: false, observations: [], sessions: [], typeFilter: '', loadingPhase: '', warnings: [] }, - lessons: { loaded: false, items: [], search: '', skillSearch: '', skillRootFilter: 'all', mode: 'explicit', projects: [] }, - actions: { loaded: false, items: [], reviewItems: [], frontier: [], statusFilter: '', search: '', doneExpanded: false, extractStatus: '', extractMessage: '', extractInFlight: false, stale: false, config: null, configSaving: false, configDraft: {} }, - inbox: { loaded: false, items: [], awaitingItems: [], answeredItems: [], dismissedItems: [], replyingId: null, pendingById: {}, briefingExpanded: false, answeredExpanded: false }, - crystals: { loaded: false, items: [], search: '', lessonMap: {} }, - profile: { loaded: false, projects: [], selectedProject: '', data: null }, - replay: { loaded: false, sessions: [], selectedId: '', timeline: null, cursor: 0, playing: false, speed: 1, timer: null, startAt: 0, offsetAt: 0 }, - flagsConfig: null, - flagsDismissed: {}, - settings: { open: false }, - ws: null - }; - diff --git a/src/viewer/parts/app/05-i18n.js b/src/viewer/parts/app/05-i18n.js new file mode 100644 index 00000000..b38934f9 --- /dev/null +++ b/src/viewer/parts/app/05-i18n.js @@ -0,0 +1,442 @@ + var I18N_MESSAGES = { + en: { + 'tab.dashboard': 'Overview', 'tab.actions': 'To-Do', 'tab.sessions': 'Evidence', + 'status.pending': 'Pending', 'status.active': 'In progress', 'status.done': 'Done', + 'status.blocked': 'Blocked', 'status.cancelled': 'Cancelled', + 'filter.review': 'To confirm', 'filter.all': 'All', + 'dash.firstRun.kicker': 'First run', + 'dash.firstRun.title': 'Import sample data first', + 'dash.firstRun.body': 'No session data yet. Run the demo and the page will fill with browsable to-dos and activity.', + 'dash.firstRun.link': 'View quick start →', + 'dash.noRecord': 'No records yet', + 'dash.stat.sessions': 'Sessions', + 'dash.stat.recent': 'Latest', + 'dash.stat.todos': 'Todos', + 'dash.stat.openWorkbench': 'Open workbench', + 'dash.stat.replyQueue': 'Reply queue', + 'dash.stat.actionCandidates': 'Action candidates', + 'dash.stat.pendingActions': 'Pending actions', + 'dash.stat.memories': 'Memories', + 'dash.stat.latestVersion': 'Latest version', + 'dash.stat.lessons': 'Lessons', + 'dash.stat.lessonsSub': 'Lessons to organize', + 'dash.stat.graphNodes': 'Graph nodes', + 'dash.stat.edges': 'Edges', + 'dash.stat.status': 'Status', + 'dash.stat.toolCalls': 'Tool calls', + 'dash.stat.tracking': 'Tracking', + 'dash.stat.functions': 'functions', + 'dash.stat.circuitBreaker': 'Circuit breaker', + 'dash.stat.failed': 'Failed', + 'dash.stat.times': 'times', + 'dash.systemResources': 'System resources', + 'dash.alerts': 'Alerts', + 'dash.notes': 'Notes', + 'dash.recentSessions': 'Recent sessions', + 'dash.emptySessions': 'No session data yet. Run the demo or connect your workflow.', + 'dash.unnamedSession': 'Unnamed session', + 'dash.openLatestSyncOf': 'Open latest sync of', + 'dash.syncs': 'syncs', + 'dash.records': 'records', + 'dash.lastSync': 'Last sync', + 'dash.functionMetrics': 'Function metrics (OTel)', + 'dash.workers': 'Workers', + 'dash.circuitBreakerDetail': 'Circuit breaker detail', + 'dash.semanticMemory': 'Semantic memory', + 'dash.emptySemantic': 'No semantic memory yet; the system distills it from observations over time.', + 'dash.proceduralMemory': 'Procedural memory', + 'dash.emptyProcedural': 'No procedural memory yet; repeated patterns are extracted automatically.', + 'dash.consolidationStatus': 'Consolidation status', + 'dash.memoryRelations': 'Memory relations', + 'act.attn.next': 'Next', 'act.attn.needsWork': 'Needs work', 'act.attn.noteworthy': 'Noteworthy', + 'act.prio.high': 'Important', 'act.prio.normal': 'Normal', 'act.prio.low': 'Low', + 'act.untitled': 'Untitled', 'act.untitledCandidate': 'Untitled candidate', + 'act.metric.waiting': 'Awaiting reply', 'act.metric.review': 'To confirm', 'act.metric.followUp': 'To follow up', 'act.metric.active': 'In progress', 'act.metric.done': 'Done', + 'act.searchPlaceholder': 'Search todos...', + 'act.nToConfirm': 'to confirm', 'act.nConfirmed': 'confirmed', 'act.refresh': 'Refresh', + 'act.viewOriginal': 'View original', 'act.confirm': 'Confirm', 'act.ignore': 'Ignore', + 'act.from': 'From', 'act.viewSource': 'View source →', 'act.updated': 'Updated', + 'act.extract.title': 'Use LLM to organize recent sessions', + 'act.extract.run': 'Organize with LLM', + 'act.extract.running': 'Organizing...', + 'act.extract.done': 'Organized', + 'act.extract.rules': 'LLM unavailable', + 'act.extract.error': 'Organize failed', + 'act.extract.failedExisting': 'Extraction failed; showing existing todos', + 'act.extract.loading': 'Loading todos...', + 'act.extract.starting': 'Organizing recent sessions...', + 'act.extract.background': 'Latest todos are shown; still organizing...', + 'act.extract.doneLlm': 'LLM extraction complete', + 'act.extract.doneMixed': 'Partial LLM extraction complete', + 'act.extract.doneRules': 'Rules extraction complete', + 'act.extract.created': 'new', + 'act.extract.history': 'history', + 'act.extract.discarded': 'discarded', + 'act.extract.cleaned': 'cleaned', + 'act.extract.reason': 'reason', + 'act.cleanup.title': 'Update recorded cards from sessions that changed', + 'act.cleanup.run': 'Update', + 'act.cleanup.running': 'Updating...', + 'act.cleanup.applying': 'Applying updates...', + 'act.cleanup.done': 'Updated', + 'act.cleanup.error': 'Update failed', + 'act.cleanup.failed': 'Update failed; cards unchanged', + 'act.cleanup.clean': 'All cards are up to date', + 'act.cleanup.llmUnavailable': 'LLM unavailable — no changes', + 'act.cleanup.confirm': 'Apply these updates?', + 'act.cleanup.summary': 'update {rewritten} · done {completed} · drop {dropped} · merge {merged}', + 'act.status.complete': 'Complete', + 'act.status.archive': 'Archive', + 'act.status.delete': 'Delete', + 'act.itemsUnit': 'items', + 'act.empty.title': 'No todos yet', + 'act.empty.lead': 'This is where todos, blocked items, and completed work extracted from your sessions will appear.', + 'settings.title': 'Settings', + 'settings.subtitle': 'Local configuration is written to the user config file and takes effect after restarting the service.', + 'settings.close': 'Close', + 'settings.language': 'UI language', + 'settings.extractor': 'LLM extraction config', + 'settings.sinceDays': 'Look-back window (days): only sessions from the last N days', + 'settings.maxInteractions': 'Max interaction records per session (one user request → agent reply)', + 'settings.apiKeyKeep': 'Enter a new API key to replace it, or leave blank to keep the current key', + 'settings.apiKeyMissing': 'Not configured', + 'settings.apiKeyLabel': 'API key:', + 'settings.save': 'Save config', + 'settings.saving': 'Saving...', + 'settings.savedRestart': 'Config saved. Restart the service to apply it.', + 'settings.saveFailed': 'Config save failed', + 'act.status.updateFailed': 'Todo status update failed', + 'obs.type.file_read': 'Read file', + 'obs.type.file_write': 'Write file', + 'obs.type.file_edit': 'Edit file', + 'obs.type.command_run': 'Run command', + 'obs.type.search': 'Search', + 'obs.type.web_fetch': 'Web fetch', + 'obs.type.conversation': 'Conversation', + 'obs.type.error': 'Error', + 'obs.type.decision': 'Decision', + 'obs.type.discovery': 'Discovery', + 'obs.type.subagent': 'Subagent', + 'obs.type.notification': 'Notification', + 'obs.type.task': 'Task', + 'obs.type.image': 'Image', + 'obs.type.other': 'Other', + 'obs.summary.localOperation': 'Run local operation', + 'obs.summary.restartService': 'Restart AI Todo service', + 'obs.summary.startService': 'Start AI Todo service', + 'obs.summary.openPreview': 'Open AI Todo preview', + 'obs.summary.checkService': 'Check service status', + 'obs.summary.verifyPageFix': 'Verify page fix', + 'obs.summary.viewFixLog': 'View fix log', + 'obs.summary.checkBrowserAutomation': 'Check browser automation dependency', + 'obs.summary.checkProcess': 'Check local service process', + 'obs.summary.organizeLocalFiles': 'Organize local files', + 'obs.summary.readLocalServiceData': 'Read local service data', + 'obs.summary.viewLocalFiles': 'View local file content', + 'obs.summary.runProjectScript': 'Run project script', + 'obs.summary.checkCodeVersion': 'Check or update code version', + 'obs.summary.runLocalCommand': 'Run local command', + 'obs.output.noText': 'This step completed without extra output.', + 'obs.output.serviceOk': 'The result is healthy and the service is available.', + 'obs.output.npxMissing': 'Check result: npx is not available in this environment.', + 'obs.output.npxOk': 'Check result: browser automation dependency is available.', + 'obs.output.pageFixOk': 'Check result: the page contains the expected fix.', + 'obs.output.error': 'The command returned an error and needs follow-up.', + 'obs.output.readService': 'Read data returned by the local service.', + 'obs.output.viewFiles': 'Viewed local file content to confirm current state.', + 'obs.output.done': 'This step completed and returned output.', + 'obs.display.promptSubmit': 'User request', + 'obs.display.agentMessage': 'Assistant response', + 'obs.display.updatePlan': 'Update plan', + 'obs.display.updatePlanBody': 'The task plan was updated.', + 'obs.display.applyPatch': 'Update local files', + 'obs.display.applyPatchBody': 'Code changes were applied.', + 'obs.display.toolTrace': 'This step was recorded as a structured tool event.', + 'episode.type.file_read': 'Read material', + 'episode.type.file_write': 'Write file', + 'episode.type.file_edit': 'Edit file', + 'episode.type.command_run': 'Local operation', + 'episode.type.search': 'Search', + 'episode.type.web_fetch': 'Web material', + 'episode.type.conversation': 'Conversation progress', + 'episode.type.error': 'Error triage', + 'episode.type.decision': 'Decision record', + 'episode.type.discovery': 'Discovery', + 'episode.type.subagent': 'Collaboration task', + 'episode.type.notification': 'Reminder', + 'episode.type.task': 'Task progress', + 'episode.type.other': 'Other', + 'episode.kind.user_need': 'User request', + 'episode.kind.bugfix': 'Fix record', + 'episode.kind.research': 'Project research', + 'episode.kind.file_work': 'File work', + 'episode.kind.important': 'Important segment', + 'episode.kind.work': 'Work progress', + 'episode.workSegment': 'Work segment', + 'episode.record': 'records', + 'episode.bodyPrefix': 'This segment mainly contains ', + 'episode.bodySuffix': ', summarized from low-level records.', + 'project.uncategorized': 'Uncategorized', + 'project.all': 'All projects', + 'project.browser': 'Browser', + 'project.demo': 'Demo data', + 'source.local': 'Local record', + 'source.agentMarked': 'Agent wrote a source marker', + 'source.demoNote': 'From the demo command, not real user work', + 'source.importedClaude': 'Claude Code history import', + 'source.importedClaudeNote': 'Imported from local JSONL history', + 'source.pathInferred': 'Inferred from project path', + 'source.unknownAgentNote': 'This session does not identify which agent created it', + 'agent.avatarAlt': 'avatar', + 'ses.noRecordId': 'no record ID', + 'tab.memories': 'Memories', 'tab.lessons': 'Lessons', 'tab.graph': 'Graph', 'tab.timeline': 'Timeline', 'tab.activity': 'Live', 'tab.profile': 'Profile', 'tab.audit': 'Audit', 'tab.replay': 'Replay', 'tab.crystals': 'Crystals', + 'ses.emptyNeedRetry': 'No sessions read yet — make sure the local service is running, then retry.', + 'ses.empty': 'No sessions yet', 'ses.retry': 'Retry', 'ses.allSessions': 'All sessions', + 'ses.heroTitle': 'Sessions', + 'ses.heroNote': 'Sessions from your browser and local agents land here; browse them by folder or source.', + 'ses.unitSessions': 'sessions', 'ses.unitFolders': 'folders', 'ses.unitSources': 'sources', 'ses.unitRecords': 'records', 'ses.recordsUnit': 'records', + 'ses.groupModeAria': 'Group sessions by', 'ses.byFolder': 'By folder', 'ses.bySource': 'By source', + 'ses.groupAria': 'Session groups', 'ses.source': 'Source', 'ses.folder': 'Folder', + 'ses.emptyFolder': 'No sessions in this folder yet.', 'ses.emptySource': 'No sessions from this source yet.', + 'ses.noPreview': 'No preview — click to view the full session.', + 'ses.avatarFallback': 'S', 'ses.localRecord': 'Local record' + }, + zh: { + 'tab.dashboard': '总览', 'tab.actions': '待办', 'tab.sessions': '证据', + 'status.pending': '待处理', 'status.active': '进行中', 'status.done': '已完成', + 'status.blocked': '受阻', 'status.cancelled': '已取消', + 'filter.review': '待确认', 'filter.all': '全部', + 'dash.firstRun.kicker': '首次使用', + 'dash.firstRun.title': '先导入示例数据', + 'dash.firstRun.body': '目前还没有会话数据。先运行 demo,页面就会出现可浏览的待办与活动。', + 'dash.firstRun.link': '查看快速开始 →', + 'dash.noRecord': '暂无记录', + 'dash.stat.sessions': '会话', + 'dash.stat.recent': '最近', + 'dash.stat.todos': '待办', + 'dash.stat.openWorkbench': '打开工作台', + 'dash.stat.replyQueue': '待回复队列', + 'dash.stat.actionCandidates': '行动候选', + 'dash.stat.pendingActions': '待跟进行动', + 'dash.stat.memories': '记忆', + 'dash.stat.latestVersion': '最新版本', + 'dash.stat.lessons': '经验', + 'dash.stat.lessonsSub': '可整理经验', + 'dash.stat.graphNodes': '关系节点', + 'dash.stat.edges': '连线', + 'dash.stat.status': '状态', + 'dash.stat.toolCalls': '工具调用', + 'dash.stat.tracking': '追踪', + 'dash.stat.functions': '个函数', + 'dash.stat.circuitBreaker': '熔断器', + 'dash.stat.failed': '失败', + 'dash.stat.times': '次', + 'dash.systemResources': '系统资源', + 'dash.alerts': '告警', + 'dash.notes': '备注', + 'dash.recentSessions': '最近会话', + 'dash.emptySessions': '还没有会话数据。先运行 demo 或接入你的工作流。', + 'dash.unnamedSession': '未命名会话', + 'dash.openLatestSyncOf': '打开最新同步 ·', + 'dash.syncs': '次同步', + 'dash.records': '条记录', + 'dash.lastSync': '最近同步', + 'dash.functionMetrics': '函数指标 (OTel)', + 'dash.workers': '工作进程', + 'dash.circuitBreakerDetail': '熔断器详情', + 'dash.semanticMemory': '语义记忆', + 'dash.emptySemantic': '还没有语义记忆,系统会逐步从观察中沉淀。', + 'dash.proceduralMemory': '流程记忆', + 'dash.emptyProcedural': '还没有流程记忆,重复模式会自动提炼。', + 'dash.consolidationStatus': '归并状态', + 'dash.memoryRelations': '记忆关系', + 'act.attn.next': '下一步', 'act.attn.needsWork': '需要处理', 'act.attn.noteworthy': '值得关注', + 'act.prio.high': '重要', 'act.prio.normal': '普通', 'act.prio.low': '不急', + 'act.untitled': '未命名待办', 'act.untitledCandidate': '未命名待办候选', + 'act.metric.waiting': '待回应', 'act.metric.review': '待确认', 'act.metric.followUp': '待跟进', 'act.metric.active': '进行中', 'act.metric.done': '已完成', + 'act.searchPlaceholder': '搜索待办...', + 'act.nToConfirm': '条待确认', 'act.nConfirmed': '件已确认', 'act.refresh': '刷新', + 'act.viewOriginal': '查看原文', 'act.confirm': '确认', 'act.ignore': '忽略', + 'act.from': '来自', 'act.viewSource': '看原文 →', 'act.updated': '更新', + 'act.extract.title': '调用大模型整理最近会话', + 'act.extract.run': '用大模型整理', + 'act.extract.running': '整理中...', + 'act.extract.done': '已整理', + 'act.extract.rules': '未走大模型', + 'act.extract.error': '整理失败', + 'act.extract.failedExisting': '抽取失败,已显示现有待办', + 'act.extract.loading': '正在整理待办...', + 'act.extract.starting': '正在从最近会话整理待办...', + 'act.extract.background': '已显示最新待办,后台仍在整理...', + 'act.extract.doneLlm': '大模型整理完成', + 'act.extract.doneMixed': '部分大模型整理完成', + 'act.extract.doneRules': '未走大模型,已用规则整理', + 'act.extract.created': '新增', + 'act.extract.history': '历史', + 'act.extract.discarded': '丢弃', + 'act.extract.cleaned': '清理', + 'act.extract.reason': '原因', + 'act.cleanup.title': '用大模型更新已记录的卡片(来源会话有新进展时)', + 'act.cleanup.run': '更新', + 'act.cleanup.running': '更新中...', + 'act.cleanup.applying': '正在应用更新...', + 'act.cleanup.done': '已更新', + 'act.cleanup.error': '更新失败', + 'act.cleanup.failed': '更新失败,卡片未改动', + 'act.cleanup.clean': '卡片已是最新', + 'act.cleanup.llmUnavailable': '大模型不可用 — 未改动', + 'act.cleanup.confirm': '应用这些更新?', + 'act.cleanup.summary': '更新 {rewritten} · 完成 {completed} · 丢弃 {dropped} · 合并 {merged}', + 'act.status.complete': '完成', + 'act.status.archive': '归档', + 'act.status.delete': '删除', + 'act.itemsUnit': '件', + 'act.empty.title': '还没有待办', + 'act.empty.lead': '这里会放从会话里整理出的待办、卡住事项和已完成事项。', + 'settings.title': '设置', + 'settings.subtitle': '本机配置会写入用户配置文件,重启服务后生效。', + 'settings.close': '关闭', + 'settings.language': '界面语言', + 'settings.extractor': '大模型抽取配置', + 'settings.sinceDays': '回溯天数:只抽取最近 N 天内的会话', + 'settings.maxInteractions': '每会话最多交互记录数(一次用户派发→Agent 回复为一条)', + 'settings.apiKeyKeep': '输入新 API key 覆盖,留空保持不变', + 'settings.apiKeyMissing': '未配置', + 'settings.apiKeyLabel': 'API key:', + 'settings.save': '保存配置', + 'settings.saving': '保存中...', + 'settings.savedRestart': '配置已保存,重启后生效。', + 'settings.saveFailed': '配置保存失败', + 'act.status.updateFailed': '待办状态更新失败', + 'obs.type.file_read': '读取文件', + 'obs.type.file_write': '写入文件', + 'obs.type.file_edit': '编辑文件', + 'obs.type.command_run': '执行命令', + 'obs.type.search': '搜索', + 'obs.type.web_fetch': '网页获取', + 'obs.type.conversation': '对话', + 'obs.type.error': '错误', + 'obs.type.decision': '决策', + 'obs.type.discovery': '发现', + 'obs.type.subagent': '子代理', + 'obs.type.notification': '通知', + 'obs.type.task': '任务', + 'obs.type.image': '图片', + 'obs.type.other': '其它', + 'obs.summary.localOperation': '执行本地操作', + 'obs.summary.restartService': '重启 AI Todo 服务', + 'obs.summary.startService': '启动 AI Todo 服务', + 'obs.summary.openPreview': '打开 AI Todo 预览', + 'obs.summary.checkService': '检查服务状态', + 'obs.summary.verifyPageFix': '验证页面修复是否生效', + 'obs.summary.viewFixLog': '查看修复日志', + 'obs.summary.checkBrowserAutomation': '检查浏览器自动化工具是否可用', + 'obs.summary.checkProcess': '检查本地服务进程', + 'obs.summary.organizeLocalFiles': '整理本地文件', + 'obs.summary.readLocalServiceData': '读取本地服务数据', + 'obs.summary.viewLocalFiles': '查看本地文件内容', + 'obs.summary.runProjectScript': '运行项目脚本', + 'obs.summary.checkCodeVersion': '检查或更新代码版本', + 'obs.summary.runLocalCommand': '执行本地命令', + 'obs.output.noText': '这一步已执行,没有返回额外文本。', + 'obs.output.serviceOk': '结果正常,服务已经可用。', + 'obs.output.npxMissing': '检查结果:当前环境没有可用的 npx。', + 'obs.output.npxOk': '检查结果:浏览器自动化工具依赖可用。', + 'obs.output.pageFixOk': '检查结果:页面里已经包含对应修复逻辑。', + 'obs.output.error': '执行返回了错误信息,需要继续排查。', + 'obs.output.readService': '已读取本地服务返回的数据。', + 'obs.output.viewFiles': '已查看本地文件内容,用于确认当前状态。', + 'obs.output.done': '这一步已完成,并返回了执行结果。', + 'obs.display.promptSubmit': '用户提出需求', + 'obs.display.agentMessage': '助手回应', + 'obs.display.updatePlan': '更新执行计划', + 'obs.display.updatePlanBody': '已更新任务计划。', + 'obs.display.applyPatch': '更新本地文件', + 'obs.display.applyPatchBody': '已应用代码修改。', + 'obs.display.toolTrace': '这一步已记录为结构化工具事件。', + 'episode.type.file_read': '读取资料', + 'episode.type.file_write': '写入文件', + 'episode.type.file_edit': '修改文件', + 'episode.type.command_run': '本地操作', + 'episode.type.search': '搜索定位', + 'episode.type.web_fetch': '网页资料', + 'episode.type.conversation': '对话推进', + 'episode.type.error': '异常排查', + 'episode.type.decision': '决策记录', + 'episode.type.discovery': '发现线索', + 'episode.type.subagent': '协作任务', + 'episode.type.notification': '提醒', + 'episode.type.task': '任务推进', + 'episode.type.other': '其它', + 'episode.kind.user_need': '用户需求', + 'episode.kind.bugfix': '修复记录', + 'episode.kind.research': '项目研究', + 'episode.kind.file_work': '文件整理', + 'episode.kind.important': '重要片段', + 'episode.kind.work': '工作推进', + 'episode.workSegment': '工作片段', + 'episode.record': '记录', + 'episode.bodyPrefix': '这一段主要包含 ', + 'episode.bodySuffix': ',已从底层记录整理成可读片段。', + 'project.uncategorized': '未归类', + 'project.all': '全部项目', + 'project.browser': '浏览器', + 'project.demo': '演示数据', + 'source.local': '本地记录', + 'source.agentMarked': 'Agent 已写入来源标记', + 'source.demoNote': '来自 demo 命令,不代表你的真实工作', + 'source.importedClaude': 'Claude Code 历史导入', + 'source.importedClaudeNote': '从本地 JSONL 历史记录导入', + 'source.pathInferred': '按项目路径识别', + 'source.unknownAgentNote': '这条会话没有写明来自哪个 Agent', + 'agent.avatarAlt': '头像', + 'ses.noRecordId': '无记录 ID', + 'tab.memories': '记忆', 'tab.lessons': '经验', 'tab.graph': '图谱', 'tab.timeline': '时间线', 'tab.activity': '实时', 'tab.profile': '档案', 'tab.audit': '审计', 'tab.replay': '回放', 'tab.crystals': '结晶', + 'ses.emptyNeedRetry': '暂时没有读到会话,请确认本地服务已启动后重试。', + 'ses.empty': '暂无会话', 'ses.retry': '重试加载', 'ses.allSessions': '全部会话', + 'ses.heroTitle': '会话', + 'ses.heroNote': '浏览器和本地 Agent 的会话会汇入这里,可以按文件夹或来源查看。', + 'ses.unitSessions': '段会话', 'ses.unitFolders': '个文件夹', 'ses.unitSources': '个来源', 'ses.unitRecords': '条记录', 'ses.recordsUnit': '条', + 'ses.groupModeAria': '会话分组方式', 'ses.byFolder': '按文件夹', 'ses.bySource': '按来源', + 'ses.groupAria': '会话分组', 'ses.source': '来源', 'ses.folder': '文件夹', + 'ses.emptyFolder': '这个文件夹下暂时没有会话。', 'ses.emptySource': '这个来源下暂时没有会话。', + 'ses.noPreview': '暂无预览,点击查看完整过程。', + 'ses.avatarFallback': '会', 'ses.localRecord': '本地记录' + } + }; + var I18N_LANG = 'en'; + function t(key, fallback) { + var table = I18N_MESSAGES[I18N_LANG] || {}; + if (Object.prototype.hasOwnProperty.call(table, key)) return table[key]; + if (Object.prototype.hasOwnProperty.call(I18N_MESSAGES.en, key)) return I18N_MESSAGES.en[key]; + return fallback != null ? fallback : key; + } + function statusLabel(status) { return t('status.' + status, status); } + /* i18n-core:end */ + (function () { + try { + var lang = new URLSearchParams(window.location.search).get('lang') || localStorage.getItem('agentmemory-lang'); + if (lang === 'en' || lang === 'zh') I18N_LANG = lang; + } catch (e) { /* no URL access — keep default */ } + })(); + function applyI18n(root) { + var scope = root || document; + scope.querySelectorAll('[data-i18n]').forEach(function (el) { + el.textContent = t(el.getAttribute('data-i18n')); + }); + var gear = document.getElementById('settings-gear'); + if (gear) { + gear.setAttribute('aria-label', t('settings.title')); + gear.setAttribute('title', t('settings.title')); + } + } + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', function () { applyI18n(); }); + } else { + applyI18n(); + } + // === /i18n base === + function observationTypeLabel(type) { + var key = String(type || 'other'); + return t('obs.type.' + key, key.replace(/_/g, ' ')); + } diff --git a/src/viewer/parts/app/08-state-expert.js b/src/viewer/parts/app/08-state-expert.js new file mode 100644 index 00000000..8c9cbefa --- /dev/null +++ b/src/viewer/parts/app/08-state-expert.js @@ -0,0 +1,85 @@ + var CB_STATE_COLORS = { closed: 'badge-green', open: 'badge-red', 'half-open': 'badge-yellow' }; + var TAB_IDS = ['dashboard', 'actions', 'sessions']; + var TAB_REDIRECTS = { memories: 'dashboard', lessons: 'dashboard', activity: 'sessions', graph: 'dashboard', profile: 'dashboard', audit: 'dashboard', replay: 'dashboard', timeline: 'sessions', crystals: 'dashboard' }; + // 专家模式:默认三栏干净;开启后在导航末尾恢复被砍/被隐藏的视图(后端资产不丢)。 + // 这些视图的 view 容器与渲染函数都还在文件里,本步只是放行入口。 + var EXPERT_TABS = [ + { id: 'memories', label: 'tab.memories' }, + { id: 'lessons', label: 'tab.lessons' }, + { id: 'graph', label: 'tab.graph' }, + { id: 'timeline', label: 'tab.timeline' }, + { id: 'activity', label: 'tab.activity' }, + { id: 'profile', label: 'tab.profile' }, + { id: 'audit', label: 'tab.audit' }, + { id: 'replay', label: 'tab.replay' }, + { id: 'crystals', label: 'tab.crystals' } + ]; + function expertModeEnabled() { + try { + var q = new URLSearchParams(window.location.search); + if (q.get('expert') === '1') return true; + if (q.get('expert') === '0') return false; + } catch (_) {} + try { return localStorage.getItem('viewer_expert_mode') === '1'; } catch (_) { return false; } + } + function setExpertMode(on) { + try { localStorage.setItem('viewer_expert_mode', on ? '1' : '0'); } catch (_) {} + } + // 把被隐藏视图的按钮渲染进导航(仅专家模式)。幂等:每次先清掉旧的再按需重建。 + function renderExpertTabs() { + var bar = document.getElementById('tab-bar'); + if (!bar) return; + var on = expertModeEnabled(); + var existing = document.getElementById('tab-expert-group'); + if (existing && existing.parentNode) existing.parentNode.removeChild(existing); + var toggle = document.getElementById('expert-toggle'); + if (toggle) toggle.setAttribute('aria-pressed', on ? 'true' : 'false'); + if (!on) return; + var main = bar.querySelector('.tab-main'); + if (!main) return; + var group = document.createElement('span'); + group.id = 'tab-expert-group'; + group.className = 'tab-expert-group'; + EXPERT_TABS.forEach(function(tab) { + var b = document.createElement('button'); + b.setAttribute('data-tab', tab.id); + b.className = 'tab-expert-btn'; + b.textContent = t(tab.label); + group.appendChild(b); + }); + main.appendChild(group); + } + function toggleExpertMode() { + var next = !expertModeEnabled(); + setExpertMode(next); + renderExpertTabs(); + // 关闭时若当前停在被隐藏视图,折回总览 + if (!next && EXPERT_TABS.some(function(t) { return t.id === state.activeTab; })) { + switchTab('dashboard'); + } + } + + var LOCAL_SKILLS = [{"name":"academic-cv-builder","root":"Agents","path":"~/.agents/skills/academic-cv-builder/SKILL.md"},{"name":"career-changer-translator","root":"Agents","path":"~/.agents/skills/career-changer-translator/SKILL.md"},{"name":"cover-letter-generator","root":"Agents","path":"~/.agents/skills/cover-letter-generator/SKILL.md"},{"name":"creative-portfolio-resume","root":"Agents","path":"~/.agents/skills/creative-portfolio-resume/SKILL.md"},{"name":"executive-resume-writer","root":"Agents","path":"~/.agents/skills/executive-resume-writer/SKILL.md"},{"name":"find-skills","root":"Agents","path":"~/.agents/skills/find-skills/SKILL.md"},{"name":"guizang-social-card-skill","root":"Agents","path":"~/.agents/skills/guizang-social-card-skill/SKILL.md"},{"name":"huashu-design","root":"Agents","path":"~/.agents/skills/huashu-design/SKILL.md"},{"name":"interview-prep-generator","root":"Agents","path":"~/.agents/skills/interview-prep-generator/SKILL.md"},{"name":"job-description-analyzer","root":"Agents","path":"~/.agents/skills/job-description-analyzer/SKILL.md"},{"name":"linkedin-profile-optimizer","root":"Agents","path":"~/.agents/skills/linkedin-profile-optimizer/SKILL.md"},{"name":"offer-comparison-analyzer","root":"Agents","path":"~/.agents/skills/offer-comparison-analyzer/SKILL.md"},{"name":"pdf","root":"Agents","path":"~/.agents/skills/pdf/SKILL.md"},{"name":"portfolio-case-study-writer","root":"Agents","path":"~/.agents/skills/portfolio-case-study-writer/SKILL.md"},{"name":"post-to-xhs","root":"Agents","path":"~/.agents/skills/xiaohongshu/skills/post-to-xhs/SKILL.md"},{"name":"reference-list-builder","root":"Agents","path":"~/.agents/skills/reference-list-builder/SKILL.md"},{"name":"resume-ats-optimizer","root":"Agents","path":"~/.agents/skills/resume-ats-optimizer/SKILL.md"},{"name":"resume-bullet-writer","root":"Agents","path":"~/.agents/skills/resume-bullet-writer/SKILL.md"},{"name":"resume-formatter","root":"Agents","path":"~/.agents/skills/resume-formatter/SKILL.md"},{"name":"resume-quantifier","root":"Agents","path":"~/.agents/skills/resume-quantifier/SKILL.md"},{"name":"resume-section-builder","root":"Agents","path":"~/.agents/skills/resume-section-builder/SKILL.md"},{"name":"resume-tailor","root":"Agents","path":"~/.agents/skills/resume-tailor/SKILL.md"},{"name":"resume-version-manager","root":"Agents","path":"~/.agents/skills/resume-version-manager/SKILL.md"},{"name":"salary-negotiation-prep","root":"Agents","path":"~/.agents/skills/salary-negotiation-prep/SKILL.md"},{"name":"setup-xhs-mcp","root":"Agents","path":"~/.agents/skills/xiaohongshu/skills/setup-xhs-mcp/SKILL.md"},{"name":"social-media-marketing","root":"Agents","path":"~/.agents/skills/social-media-marketing/SKILL.md"},{"name":"tech-resume-optimizer","root":"Agents","path":"~/.agents/skills/tech-resume-optimizer/SKILL.md"},{"name":"weread-skills","root":"Agents","path":"~/.agents/skills/weread-skills/SKILL.md"},{"name":"x-twitter-growth","root":"Agents","path":"~/.agents/skills/x-twitter-growth/SKILL.md"},{"name":"xhs-content-plan","root":"Agents","path":"~/.agents/skills/xiaohongshu/skills/xhs-content-plan/SKILL.md"},{"name":"xhs-explore","root":"Agents","path":"~/.agents/skills/xiaohongshu/skills/xhs-explore/SKILL.md"},{"name":"xhs-interact","root":"Agents","path":"~/.agents/skills/xiaohongshu/skills/xhs-interact/SKILL.md"},{"name":"xhs-login","root":"Agents","path":"~/.agents/skills/xiaohongshu/skills/xhs-login/SKILL.md"},{"name":"xhs-profile","root":"Agents","path":"~/.agents/skills/xiaohongshu/skills/xhs-profile/SKILL.md"},{"name":"xhs-search","root":"Agents","path":"~/.agents/skills/xiaohongshu/skills/xhs-search/SKILL.md"},{"name":"xiaohongshu","root":"Agents","path":"~/.agents/skills/xiaohongshu/SKILL.md"},{"name":"academic-cv-builder","root":"Codex","path":"~/.codex/skills/academic-cv-builder/SKILL.md"},{"name":"aihot","root":"Codex","path":"~/.codex/skills/aihot/SKILL.md"},{"name":"andrej-karpathy-perspective","root":"Codex","path":"~/.codex/skills/nuwa-skill/examples/andrej-karpathy-perspective/SKILL.md"},{"name":"buddyup-next","root":"Codex","path":"~/.codex/skills/buddyup-next/SKILL.md"},{"name":"buddyup-study-abroad-assistant","root":"Codex","path":"~/.codex/skills/buddyup-study-abroad-assistant/SKILL.md"},{"name":"buddyup-xiaohongshu-growth","root":"Codex","path":"~/.codex/skills/buddyup-xiaohongshu-growth/SKILL.md"},{"name":"cai-life-skill","root":"Codex","path":"~/.codex/skills/cai-life-skill/SKILL.md"},{"name":"career-changer-translator","root":"Codex","path":"~/.codex/skills/career-changer-translator/SKILL.md"},{"name":"chatgpt-apps","root":"Codex","path":"~/.codex/skills/chatgpt-apps/SKILL.md"},{"name":"cli-creator","root":"Codex","path":"~/.codex/skills/cli-creator/SKILL.md"},{"name":"cocoloop-main","root":"Codex","path":"~/.codex/skills/cocoloop-main/SKILL.md"},{"name":"cover-letter-generator","root":"Codex","path":"~/.codex/skills/cover-letter-generator/SKILL.md"},{"name":"creative-portfolio-resume","root":"Codex","path":"~/.codex/skills/creative-portfolio-resume/SKILL.md"},{"name":"douban-sync-skill","root":"Codex","path":"~/.codex/skills/douban-sync-skill/SKILL.md"},{"name":"elon-musk-perspective","root":"Codex","path":"~/.codex/skills/nuwa-skill/examples/elon-musk-perspective/SKILL.md"},{"name":"executive-resume-writer","root":"Codex","path":"~/.codex/skills/executive-resume-writer/SKILL.md"},{"name":"feishu-research-docs","root":"Codex","path":"~/.codex/skills/feishu-research-docs/SKILL.md"},{"name":"feynman-perspective","root":"Codex","path":"~/.codex/skills/nuwa-skill/examples/feynman-perspective/SKILL.md"},{"name":"figma-code-connect-components","root":"Codex","path":"~/.codex/skills/figma-code-connect-components/SKILL.md"},{"name":"figma-create-design-system-rules","root":"Codex","path":"~/.codex/skills/figma-create-design-system-rules/SKILL.md"},{"name":"figma","root":"Codex","path":"~/.codex/skills/figma/SKILL.md"},{"name":"gh-address-comments","root":"Codex","path":"~/.codex/skills/gh-address-comments/SKILL.md"},{"name":"hv-analysis","root":"Codex","path":"~/.codex/skills/hv-analysis/SKILL.md"},{"name":"ilya-sutskever-perspective","root":"Codex","path":"~/.codex/skills/nuwa-skill/examples/ilya-sutskever-perspective/SKILL.md"},{"name":"imagegen","root":"Codex","path":"~/.codex/skills/.system/imagegen/SKILL.md"},{"name":"interview-prep-generator","root":"Codex","path":"~/.codex/skills/interview-prep-generator/SKILL.md"},{"name":"job-description-analyzer","root":"Codex","path":"~/.codex/skills/job-description-analyzer/SKILL.md"},{"name":"kevin-kelly-perspective","root":"Codex","path":"~/.codex/skills/kevin-kelly-perspective/SKILL.md"},{"name":"khazix-writer","root":"Codex","path":"~/.codex/skills/khazix-writer/SKILL.md"},{"name":"laws-of-ux-2","root":"Codex","path":"~/.codex/skills/laws-of-ux-2/SKILL.md"},{"name":"linkedin-profile-optimizer","root":"Codex","path":"~/.codex/skills/linkedin-profile-optimizer/SKILL.md"},{"name":"mrbeast-perspective","root":"Codex","path":"~/.codex/skills/nuwa-skill/examples/mrbeast-perspective/SKILL.md"},{"name":"munger-perspective","root":"Codex","path":"~/.codex/skills/nuwa-skill/examples/munger-perspective/SKILL.md"},{"name":"naval-perspective","root":"Codex","path":"~/.codex/skills/nuwa-skill/examples/naval-perspective/SKILL.md"},{"name":"neat-freak","root":"Codex","path":"~/.codex/skills/neat-freak/SKILL.md"},{"name":"nuwa-skill","root":"Codex","path":"~/.codex/skills/nuwa-skill/SKILL.md"},{"name":"offer-comparison-analyzer","root":"Codex","path":"~/.codex/skills/offer-comparison-analyzer/SKILL.md"},{"name":"openai-docs","root":"Codex","path":"~/.codex/skills/.system/openai-docs/SKILL.md"},{"name":"paul-graham-perspective","root":"Codex","path":"~/.codex/skills/nuwa-skill/examples/paul-graham-perspective/SKILL.md"},{"name":"playwright","root":"Codex","path":"~/.codex/skills/playwright/SKILL.md"},{"name":"plugin-creator","root":"Codex","path":"~/.codex/skills/.system/plugin-creator/SKILL.md"},{"name":"portfolio-case-study-writer","root":"Codex","path":"~/.codex/skills/portfolio-case-study-writer/SKILL.md"},{"name":"proactive-everme","root":"Codex","path":"~/.codex/skills/proactive-everme/SKILL.md"},{"name":"reference-list-builder","root":"Codex","path":"~/.codex/skills/reference-list-builder/SKILL.md"},{"name":"resume-ats-optimizer","root":"Codex","path":"~/.codex/skills/resume-ats-optimizer/SKILL.md"},{"name":"resume-bullet-writer","root":"Codex","path":"~/.codex/skills/resume-bullet-writer/SKILL.md"},{"name":"resume-formatter","root":"Codex","path":"~/.codex/skills/resume-formatter/SKILL.md"},{"name":"resume-quantifier","root":"Codex","path":"~/.codex/skills/resume-quantifier/SKILL.md"},{"name":"resume-section-builder","root":"Codex","path":"~/.codex/skills/resume-section-builder/SKILL.md"},{"name":"resume-tailor","root":"Codex","path":"~/.codex/skills/resume-tailor/SKILL.md"},{"name":"resume-version-manager","root":"Codex","path":"~/.codex/skills/resume-version-manager/SKILL.md"},{"name":"salary-negotiation-prep","root":"Codex","path":"~/.codex/skills/salary-negotiation-prep/SKILL.md"},{"name":"self-improvement","root":"Codex","path":"~/.codex/skills/self-improvement/SKILL.md"},{"name":"skill-creator","root":"Codex","path":"~/.codex/skills/.system/skill-creator/SKILL.md"},{"name":"skill-installer","root":"Codex","path":"~/.codex/skills/.system/skill-installer/SKILL.md"},{"name":"speech","root":"Codex","path":"~/.codex/skills/speech/SKILL.md"},{"name":"steve-jobs-perspective","root":"Codex","path":"~/.codex/skills/nuwa-skill/examples/steve-jobs-perspective/SKILL.md"},{"name":"storage-analyzer","root":"Codex","path":"~/.codex/skills/storage-analyzer/SKILL.md"},{"name":"sun-yuchen-perspective","root":"Codex","path":"~/.codex/skills/nuwa-skill/examples/sun-yuchen-perspective/SKILL.md"},{"name":"taleb-perspective","root":"Codex","path":"~/.codex/skills/nuwa-skill/examples/taleb-perspective/SKILL.md"},{"name":"taught-master-applications","root":"Codex","path":"~/.codex/skills/taught-master-applications/SKILL.md"},{"name":"taught-master-applications","root":"Codex","path":"~/.codex/skills/taught-master-applications/taught-master-applications/SKILL.md"},{"name":"tech-resume-optimizer","root":"Codex","path":"~/.codex/skills/tech-resume-optimizer/SKILL.md"},{"name":"trump-perspective","root":"Codex","path":"~/.codex/skills/nuwa-skill/examples/trump-perspective/SKILL.md"},{"name":"x-mastery-mentor","root":"Codex","path":"~/.codex/skills/nuwa-skill/examples/x-mastery-mentor/SKILL.md"},{"name":"zhang-yiming-perspective","root":"Codex","path":"~/.codex/skills/nuwa-skill/examples/zhang-yiming-perspective/SKILL.md"},{"name":"zhangxuefeng-perspective","root":"Codex","path":"~/.codex/skills/nuwa-skill/examples/zhangxuefeng-perspective/SKILL.md"},{"name":"agentmemory-import","root":"Plugin","path":"~/.codex/plugins/cache/agentmemory-lab/agentmemory-lab/0.1.0/skills/agentmemory-import/SKILL.md"},{"name":"agentmemory-understand","root":"Plugin","path":"~/.codex/plugins/cache/agentmemory-lab/agentmemory-lab/0.1.0/skills/agentmemory-understand/SKILL.md"},{"name":"agentmemory","root":"Plugin","path":"~/.codex/plugins/cache/agentmemory-lab/agentmemory-lab/0.1.0/skills/agentmemory/SKILL.md"},{"name":"audit","root":"Plugin","path":"~/.codex/plugins/cache/role-specific-plugins/product-design/0.1.41/skills/audit/SKILL.md"},{"name":"control-in-app-browser","root":"Plugin","path":"~/.codex/plugins/cache/openai-bundled/browser/26.601.21317/skills/control-in-app-browser/SKILL.md"},{"name":"design-qa","root":"Plugin","path":"~/.codex/plugins/cache/role-specific-plugins/product-design/0.1.41/skills/design-qa/SKILL.md"},{"name":"documents","root":"Plugin","path":"~/.codex/plugins/cache/openai-primary-runtime/documents/26.601.10930/skills/documents/SKILL.md"},{"name":"everme-memory","root":"Plugin","path":"~/.codex/plugins/cache/everme/everme/0.4.0/skills/everme-memory/SKILL.md"},{"name":"figma-code-connect","root":"Plugin","path":"~/.codex/plugins/cache/openai-curated/figma/2abb1c44/skills/figma-code-connect/SKILL.md"},{"name":"figma-create-new-file","root":"Plugin","path":"~/.codex/plugins/cache/openai-curated/figma/2abb1c44/skills/figma-create-new-file/SKILL.md"},{"name":"figma-generate-design","root":"Plugin","path":"~/.codex/plugins/cache/openai-curated/figma/2abb1c44/skills/figma-generate-design/SKILL.md"},{"name":"figma-generate-diagram","root":"Plugin","path":"~/.codex/plugins/cache/openai-curated/figma/2abb1c44/skills/figma-generate-diagram/SKILL.md"},{"name":"figma-generate-library","root":"Plugin","path":"~/.codex/plugins/cache/openai-curated/figma/2abb1c44/skills/figma-generate-library/SKILL.md"},{"name":"figma-use-figjam","root":"Plugin","path":"~/.codex/plugins/cache/openai-curated/figma/2abb1c44/skills/figma-use-figjam/SKILL.md"},{"name":"figma-use-slides","root":"Plugin","path":"~/.codex/plugins/cache/openai-curated/figma/2abb1c44/skills/figma-use-slides/SKILL.md"},{"name":"figma-use","root":"Plugin","path":"~/.codex/plugins/cache/openai-curated/figma/2abb1c44/skills/figma-use/SKILL.md"},{"name":"get-context","root":"Plugin","path":"~/.codex/plugins/cache/role-specific-plugins/product-design/0.1.41/skills/get-context/SKILL.md"},{"name":"ideate","root":"Plugin","path":"~/.codex/plugins/cache/role-specific-plugins/product-design/0.1.41/skills/ideate/SKILL.md"},{"name":"image-to-code","root":"Plugin","path":"~/.codex/plugins/cache/role-specific-plugins/product-design/0.1.41/skills/image-to-code/SKILL.md"},{"name":"index","root":"Plugin","path":"~/.codex/plugins/cache/role-specific-plugins/product-design/0.1.41/skills/index/SKILL.md"},{"name":"presentations","root":"Plugin","path":"~/.codex/plugins/cache/openai-primary-runtime/presentations/26.601.10930/skills/presentations/SKILL.md"},{"name":"prototype","root":"Plugin","path":"~/.codex/plugins/cache/role-specific-plugins/product-design/0.1.41/skills/prototype/SKILL.md"},{"name":"research","root":"Plugin","path":"~/.codex/plugins/cache/role-specific-plugins/product-design/0.1.41/skills/research/SKILL.md"},{"name":"share","root":"Plugin","path":"~/.codex/plugins/cache/role-specific-plugins/product-design/0.1.41/skills/share/SKILL.md"},{"name":"spreadsheets","root":"Plugin","path":"~/.codex/plugins/cache/openai-primary-runtime/spreadsheets/26.601.10930/skills/spreadsheets/SKILL.md"},{"name":"understand-chat","root":"Plugin","path":"~/.codex/plugins/cache/understand-anything/plugin-backup-Q98O9D/understand-anything/2.7.5/skills/understand-chat/SKILL.md"},{"name":"understand-chat","root":"Plugin","path":"~/.codex/plugins/cache/understand-anything/understand-anything/2.7.6/skills/understand-chat/SKILL.md"},{"name":"understand-dashboard","root":"Plugin","path":"~/.codex/plugins/cache/understand-anything/plugin-backup-Q98O9D/understand-anything/2.7.5/skills/understand-dashboard/SKILL.md"},{"name":"understand-dashboard","root":"Plugin","path":"~/.codex/plugins/cache/understand-anything/understand-anything/2.7.6/skills/understand-dashboard/SKILL.md"},{"name":"understand-diff","root":"Plugin","path":"~/.codex/plugins/cache/understand-anything/plugin-backup-Q98O9D/understand-anything/2.7.5/skills/understand-diff/SKILL.md"},{"name":"understand-diff","root":"Plugin","path":"~/.codex/plugins/cache/understand-anything/understand-anything/2.7.6/skills/understand-diff/SKILL.md"},{"name":"understand-domain","root":"Plugin","path":"~/.codex/plugins/cache/understand-anything/plugin-backup-Q98O9D/understand-anything/2.7.5/skills/understand-domain/SKILL.md"},{"name":"understand-domain","root":"Plugin","path":"~/.codex/plugins/cache/understand-anything/understand-anything/2.7.6/skills/understand-domain/SKILL.md"},{"name":"understand-explain","root":"Plugin","path":"~/.codex/plugins/cache/understand-anything/plugin-backup-Q98O9D/understand-anything/2.7.5/skills/understand-explain/SKILL.md"},{"name":"understand-explain","root":"Plugin","path":"~/.codex/plugins/cache/understand-anything/understand-anything/2.7.6/skills/understand-explain/SKILL.md"},{"name":"understand-knowledge","root":"Plugin","path":"~/.codex/plugins/cache/understand-anything/plugin-backup-Q98O9D/understand-anything/2.7.5/skills/understand-knowledge/SKILL.md"},{"name":"understand-knowledge","root":"Plugin","path":"~/.codex/plugins/cache/understand-anything/understand-anything/2.7.6/skills/understand-knowledge/SKILL.md"},{"name":"understand-onboard","root":"Plugin","path":"~/.codex/plugins/cache/understand-anything/plugin-backup-Q98O9D/understand-anything/2.7.5/skills/understand-onboard/SKILL.md"},{"name":"understand-onboard","root":"Plugin","path":"~/.codex/plugins/cache/understand-anything/understand-anything/2.7.6/skills/understand-onboard/SKILL.md"},{"name":"understand","root":"Plugin","path":"~/.codex/plugins/cache/understand-anything/plugin-backup-Q98O9D/understand-anything/2.7.5/skills/understand/SKILL.md"},{"name":"understand","root":"Plugin","path":"~/.codex/plugins/cache/understand-anything/understand-anything/2.7.6/skills/understand/SKILL.md"},{"name":"url-to-code","root":"Plugin","path":"~/.codex/plugins/cache/role-specific-plugins/product-design/0.1.41/skills/url-to-code/SKILL.md"},{"name":"user-context","root":"Plugin","path":"~/.codex/plugins/cache/role-specific-plugins/product-design/0.1.41/skills/user-context/SKILL.md"}]; + + var state = { + activeTab: 'dashboard', + dashboard: { loaded: false, health: null, sessions: [], memories: [], actions: [], actionReviews: [], inboxAwaiting: [], graphStats: null, recentAudit: [], lessons: [], crystals: [], delivery: null }, + dashboardRefresh: { enabled: true, intervalMs: 30000 }, + graph: { loaded: false, nodes: [], edges: [], stats: null, filters: {}, selectedNode: null }, + memories: { loaded: false, items: [], reviewItems: [], search: '', typeFilter: '', sourceFilter: '' }, + timeline: { loaded: false, observations: [], sessionId: '', projectKey: '', sessions: [], minImportance: 0, page: 0, pageSize: 50, mode: 'episodes', episodeFilter: 'all', expandedEpisodes: {} }, + sessions: { loaded: false, items: [], selectedId: null, groupMode: 'folder', folderKey: 'all', sourceKey: 'all', warnings: [], highlightsById: {}, detailSectionsById: {}, detailCacheById: {}, stale: false, requestSeq: 0, detailRequestSeq: 0, pendingHighlightObsId: null, previewExpandedById: {} }, + audit: { loaded: false, entries: [], opFilter: '' }, + activity: { loaded: false, observations: [], sessions: [], typeFilter: '', loadingPhase: '', warnings: [] }, + lessons: { loaded: false, items: [], search: '', skillSearch: '', skillRootFilter: 'all', mode: 'explicit', projects: [] }, + actions: { loaded: false, items: [], reviewItems: [], frontier: [], statusFilter: '', search: '', doneExpanded: false, extractStatus: '', extractMessage: '', extractInFlight: false, stale: false, config: null, configSaving: false, configDraft: {} }, + inbox: { loaded: false, items: [], awaitingItems: [], answeredItems: [], dismissedItems: [], replyingId: null, pendingById: {}, briefingExpanded: false, answeredExpanded: false }, + crystals: { loaded: false, items: [], search: '', lessonMap: {} }, + profile: { loaded: false, projects: [], selectedProject: '', data: null }, + replay: { loaded: false, sessions: [], selectedId: '', timeline: null, cursor: 0, playing: false, speed: 1, timer: null, startAt: 0, offsetAt: 0 }, + flagsConfig: null, + flagsDismissed: {}, + settings: { open: false }, + ws: null + }; + diff --git a/src/viewer/parts/app/40-data-helpers.js b/src/viewer/parts/app/40-data-helpers.js new file mode 100644 index 00000000..3772bac7 --- /dev/null +++ b/src/viewer/parts/app/40-data-helpers.js @@ -0,0 +1,1003 @@ + function cleanSessionPreview(text) { + var t = String(text || '').trim(); + if (!t) return ''; + var noisyStarts = [ + '# AGENTS.md instructions', + '', + 'Automation:', + 'Response MUST end with', + 'You are Codex', + 'Filesystem sandboxing defines' + ]; + for (var i = 0; i < noisyStarts.length; i++) { + if (t.indexOf(noisyStarts[i]) === 0) return ''; + } + t = t.replace(/^# In app browser:[\s\S]*?## My request for Codex:\s*/m, '').trim(); + t = t.replace(/^# Browser comments:[\s\S]*?## My request for Codex:\s*/m, '').trim(); + t = t.replace(/^The next image is untrusted page evidence[\s\S]*?instructions\.\s*/m, '').trim(); + return t; + } + function normalizePreviewText(text) { + return cleanSessionPreview(text).replace(/\s+/g, ' ').trim(); + } + function compactSessionTitle(text) { + var t = normalizePreviewText(text); + if (!t) return ''; + t = t.replace(/https?:\/\/\S+/g, '').trim(); + t = t.replace(/\[[^\]]{20,}\]\([^)]+\)/g, '').trim(); + t = t.replace(/[((][^()()]{24,}[))]/g, '').trim(); + t = t.replace(/^#+\s*/, '').replace(/\*\*/g, '').replace(/^\d+[.、]\s*/, '').trim(); + t = t.replace(/\s+/g, ' '); + if (t.length > 44) { + var cut = t.slice(0, 44); + var stop = Math.max(cut.lastIndexOf('。'), cut.lastIndexOf(';'), cut.lastIndexOf(','), cut.lastIndexOf(' ')); + if (stop > 14) cut = cut.slice(0, stop); + t = cut + '...'; + } + return t; + } + function sessionTitleText(s) { + return compactSessionTitle(s && (s.title || s.firstPrompt)) || sessionDisplayName(s); + } + function sessionBodyPreview(s, title) { + var candidates = [s && s.summary, s && s.latestPrompt, s && s.firstPrompt]; + var titleText = normalizePreviewText(title); + for (var i = 0; i < candidates.length; i++) { + var preview = normalizePreviewText(candidates[i]); + if (!preview) continue; + if (titleText && preview === titleText) continue; + if (titleText && preview.indexOf(titleText) === 0) { + preview = preview.slice(titleText.length).replace(/^[\s::,,.。;;-]+/, '').trim(); + } + if (preview && preview !== titleText) return preview; + } + return ''; + } + function parseJsonObject(text) { + var raw = String(text || '').trim(); + if (!raw || raw[0] !== '{') return null; + try { return JSON.parse(raw); } catch (e) { return null; } + } + function splitCommandNarrative(text) { + var raw = String(text || '').trim(); + if (!raw) return { command: '', output: '' }; + var pipeIndex = raw.indexOf(' | '); + var left = pipeIndex >= 0 ? raw.slice(0, pipeIndex).trim() : raw; + var right = pipeIndex >= 0 ? raw.slice(pipeIndex + 3).trim() : ''; + var obj = parseJsonObject(left); + return { command: obj && obj.command ? String(obj.command) : '', output: right }; + } + function extractCommandText(o) { + var rawNarrative = String(o && o.narrative || '').trim(); + var rawSubtitle = String(o && o.subtitle || '').trim(); + var first = rawNarrative.indexOf(' | ') >= 0 ? splitCommandNarrative(rawNarrative) : splitCommandNarrative(rawSubtitle); + if (first.command || first.output) return first; + var second = splitCommandNarrative(rawNarrative); + if (second.command || second.output) return second; + var ti = o && o.toolInput; + if (ti && typeof ti === 'object' && ti.command) return { command: String(ti.command), output: String(o && o.toolOutput || '') }; + return { command: '', output: '' }; + } + function normalizedToolTraceName(value) { + return String(value || '').trim().toLowerCase().replace(/\s+/g, '_'); + } + function isJsonLikeText(text) { + var trimmed = String(text || '').trim(); + if (!trimmed || (trimmed[0] !== '{' && trimmed[0] !== '[')) return false; + try { + JSON.parse(trimmed); + return true; + } catch (e) { + return /^[{\[]/.test(trimmed); + } + } + function looksToolTraceDisplayText(text) { + var trimmed = String(text || '').trim(); + if (!trimmed) return false; + var lower = trimmed.toLowerCase(); + return isJsonLikeText(trimmed) || /"plan"\s*:|"command"\s*:|toolinput|tooloutput|function_id/.test(lower); + } + function commandHumanSummary(command) { + var c = String(command || '').trim(); + if (!c) return t('obs.summary.localOperation'); + if (c.indexOf('ensure-agentmemory-live.mjs') >= 0 && c.indexOf('--restart') >= 0) return t('obs.summary.restartService'); + if (c.indexOf('ensure-agentmemory-live.mjs') >= 0) return t('obs.summary.startService'); + if (/^open\s+['"]?https?:\/\/127\.0\.0\.1:3114/.test(c)) return t('obs.summary.openPreview'); + if (c.indexOf('agentmemory/livez') >= 0 || c.indexOf('/health') >= 0) return t('obs.summary.checkService'); + if (c.indexOf('sessionBodyPreview') >= 0 || c.indexOf('observationDisplay') >= 0) return t('obs.summary.verifyPageFix'); + if (c.indexOf('bugfix-log') >= 0 && /sed|cat|ls/.test(c)) return t('obs.summary.viewFixLog'); + if (/command -v npx/.test(c)) return t('obs.summary.checkBrowserAutomation'); + if (/^(ps|lsof|netstat)\b/.test(c) || c.indexOf(' ps ') >= 0 || c.indexOf('lsof ') >= 0) return t('obs.summary.checkProcess'); + if (/^mkdir\b|^mv\b|^cp\b|^node <<|cleanup|重复旧版本|_待确认清理/.test(c)) return t('obs.summary.organizeLocalFiles'); + if (/^curl\b/.test(c)) return t('obs.summary.readLocalServiceData'); + if (/^sed\b|^rg\b|^grep\b|^find\b|^ls\b|^nl\b/.test(c)) return t('obs.summary.viewLocalFiles'); + if (/^(npm|pnpm|bun|yarn)\b/.test(c)) return t('obs.summary.runProjectScript'); + if (/^(git)\b/.test(c)) return t('obs.summary.checkCodeVersion'); + return t('obs.summary.runLocalCommand'); + } + function commandOutputSummary(output, command) { + var out = String(output || '').trim(); + var c = String(command || ''); + if (!out) return t('obs.output.noText'); + if (out.indexOf('"ok": true') >= 0 || out.indexOf('"status":"ok"') >= 0 || out.indexOf('"status": "ok"') >= 0) return t('obs.output.serviceOk'); + if (out.indexOf('npx-missing') >= 0) return t('obs.output.npxMissing'); + if (out.indexOf('npx-ok') >= 0) return t('obs.output.npxOk'); + if (out.indexOf('sessionBodyPreview') >= 0 || out.indexOf('observationDisplay') >= 0) return t('obs.output.pageFixOk'); + if (out.indexOf('error') >= 0 || out.indexOf('failed') >= 0 || out.indexOf('Error:') >= 0) return t('obs.output.error'); + if (/^curl\b/.test(c)) return t('obs.output.readService'); + if (/^sed\b|^rg\b|^grep\b|^find\b|^ls\b|^nl\b/.test(c)) return t('obs.output.viewFiles'); + return t('obs.output.done'); + } + function observationDisplay(o) { + var baseTitle = normalizePreviewText(o && (o.title || o.toolName || (o.hookType ? o.hookType.replace(/_/g, ' ') : 'Observation'))); + var rawSubtitle = normalizePreviewText(o && o.subtitle); + var rawNarrative = normalizePreviewText(o && o.narrative); + var commandInfo = extractCommandText(o); + var type = o && (o.type || o.hookType || ''); + var toolName = normalizedToolTraceName(o && (o.toolName || o.title || o.hookType)); + var title = baseTitle || 'Observation'; + var subtitle = rawSubtitle; + var body = rawNarrative; + if (title === 'prompt_submit') title = t('obs.display.promptSubmit'); + if (title === 'agent_message') title = t('obs.display.agentMessage'); + if (toolName === 'update_plan') { + title = t('obs.display.updatePlan'); + subtitle = ''; + body = t('obs.display.updatePlanBody'); + } else if (toolName === 'apply_patch') { + title = t('obs.display.applyPatch'); + subtitle = ''; + body = t('obs.display.applyPatchBody'); + } else if ((type === 'command_run' || toolName === 'bash' || toolName === 'exec_command') && commandInfo.command) { + title = commandHumanSummary(commandInfo.command); + subtitle = ''; + body = commandOutputSummary(commandInfo.output, commandInfo.command); + } else if (rawSubtitle && rawNarrative && rawNarrative.indexOf(rawSubtitle) === 0) { + body = rawNarrative.slice(rawSubtitle.length).replace(/^[\s|::,,.。;;-]+/, '').trim(); + } + if (looksToolTraceDisplayText(subtitle)) subtitle = ''; + if (looksToolTraceDisplayText(body)) body = t('obs.display.toolTrace'); + body = sessionBodyPreview({ summary: body, latestPrompt: rawNarrative, firstPrompt: rawSubtitle }, title); + return { title: title, subtitle: subtitle, body: body }; + } + function looksRawSystemText(text) { + var t = String(text || '').trim(); + return t.indexOf('{"command"') >= 0 || t.indexOf('\\\"command\\\"') >= 0 || /^Bash($|[::])/.test(t) || /^prompt_submit$/.test(t) || /^agent_message$/.test(t); + } + function cleanEpisodeText(text, fallback) { + var t = normalizePreviewText(text); + if (!t || looksRawSystemText(t)) return fallback || ''; + return t; + } + function episodeActionText(o) { + var type = observationType(o); + var display = observationDisplay(o); + var title = cleanEpisodeText(display.title, typeDisplayLabel(type)); + var body = cleanEpisodeText(display.body, ''); + if (body && body !== title) return title + ':' + truncate(body, 64); + return title; + } + function typeDisplayLabel(type) { + return t('episode.type.' + (type || 'other'), String(type || t('episode.type.other')).replace(/_/g, ' ')); + } + function episodeKindLabel(kind) { + return t('episode.kind.' + (kind || 'work'), t('episode.kind.work')); + } + function observationType(o) { + var toolMap = { Read: 'file_read', Write: 'file_write', Edit: 'file_edit', Bash: 'command_run', Grep: 'search', Glob: 'search', WebFetch: 'web_fetch', WebSearch: 'web_fetch', AskUserQuestion: 'conversation', Task: 'subagent' }; + return (o && (o.type || toolMap[o.toolName] || (o.hookType ? o.hookType.replace(/_/g, ' ') : 'other'))) || 'other'; + } + function episodeTimeKey(o) { + var sid = o && (o._sessionId || o.sessionId); + if (sid) return 'session:' + sid; + try { + var d = new Date(o.timestamp); + if (!Number.isNaN(d.getTime())) return 'hour:' + d.toISOString().slice(0, 13); + } catch(e) {} + return 'misc'; + } + function episodeFallbackTitle(first, projectName) { + var name = first && (first._sessionName || first.sessionName); + if (name && name !== 'Codex 会话') return name; + var d = first && first.timestamp ? formatTime(first.timestamp) : ''; + return (projectName ? projectName + ' · ' : '') + (d || t('episode.workSegment')); + } + function classifyEpisode(counts, title, body, importance) { + var text = String((title || '') + ' ' + (body || '')).toLowerCase(); + if ((importance || 0) >= 8 || counts.decision || counts.error) return 'important'; + if (/修|bug|error|错误|异常|排查|fix|fail|undefined|json|bash/.test(text)) return 'bugfix'; + if (/解释|研究|github|项目|repo|资料|分析|搜索|tencent|memory/.test(text) || counts.web_fetch || counts.search) return 'research'; + if (/用户提出需求|需求|继续|优化|启动/.test(text) || counts.conversation) return 'user_need'; + if (counts.file_write || counts.file_edit || counts.file_read) return 'file_work'; + return 'work'; + } + function filterTimelineEpisodes(episodes) { + var filter = state.timeline.episodeFilter || 'all'; + if (filter === 'all') return episodes; + return episodes.filter(function(ep) { + if (filter === 'important') return ep.kind === 'important' || ep.kind === 'user_need' || ep.kind === 'bugfix'; + return ep.kind === filter; + }); + } + function buildTimelineEpisodes(observations) { + var project = (state.timeline.sessions || []).find(function(s) { return sessionProjectKey(s) === state.timeline.projectKey; }); + var projectName = project ? projectDisplayName(project) : ''; + var buckets = {}; + (observations || []).forEach(function(o) { + var key = episodeTimeKey(o); + if (!buckets[key]) buckets[key] = []; + buckets[key].push(o); + }); + return Object.keys(buckets).map(function(key) { + var items = buckets[key].slice().sort(function(a, b) { + return String(a.timestamp || '').localeCompare(String(b.timestamp || '')); + }); + var first = items[0] || {}; + var last = items[items.length - 1] || first; + var counts = {}; + var actions = []; + var title = ''; + var bodyCandidate = ''; + items.forEach(function(o) { + var type = observationType(o); + counts[type] = (counts[type] || 0) + 1; + var display = observationDisplay(o); + var cleanTitle = cleanEpisodeText(display.title, ''); + var cleanBody = cleanEpisodeText(display.body, ''); + if (!title && cleanTitle && cleanTitle !== 'Observation') title = cleanTitle; + if (!bodyCandidate && cleanBody) bodyCandidate = cleanBody; + var action = episodeActionText(o); + if (actions.indexOf(action) < 0) actions.push(action); + }); + var typeText = Object.keys(counts).sort(function(a, b) { return counts[b] - counts[a]; }).slice(0, 4).map(function(t) { + return typeDisplayLabel(t) + ' ' + counts[t]; + }).join(','); + var readableTitle = title || episodeFallbackTitle(first, projectName); + var readableBody = bodyCandidate || (t('episode.bodyPrefix') + typeText + t('episode.bodySuffix')); + var maxImportance = items.reduce(function(max, o) { return Math.max(max, typeof o.importance === 'number' ? o.importance : 5); }, 0); + var kind = classifyEpisode(counts, readableTitle, readableBody, maxImportance); + return { + key: key, + title: readableTitle, + body: readableBody, + actions: actions.slice(0, 6), + count: items.length, + typeText: typeText || t('episode.record') + ' ' + items.length, + start: first.timestamp, + end: last.timestamp, + importance: maxImportance, + kind: kind + }; + }).sort(function(a, b) { + return String(b.end || '').localeCompare(String(a.end || '')); + }); + } + function memoryTypeLabel(t) { + var map = { + fact: '事实', + preference: '偏好', + profile: '身份档案', + architecture: '架构', + workflow: '流程', + pattern: '模式', + bug: '问题', + goal: '目标', + history: '经历', + project: '项目', + principle: '原则', + lifestyle: '生活' + }; + return map[t] || t || '未分类'; + } + function memoryAreaToType(area) { + var map = { + profile: 'profile', + preference: 'preference', + project: 'project', + principle: 'pattern', + history: 'history' + }; + return map[area] || 'fact'; + } + function memoryTypeToArea(type, mem) { + var category = memoryCategory(mem || { type: type }); + if (category === '身份档案') return 'profile'; + if (category === '偏好') return 'preference'; + if (category === '项目与目标') return 'project'; + if (category === '判断框架') return 'principle'; + if (category === '经历') return 'history'; + return type === 'project' ? 'project' : type === 'preference' ? 'preference' : type === 'pattern' || type === 'workflow' ? 'principle' : 'profile'; + } + function shortDateTime(ts) { + if (!ts) return '-'; + try { + var d = new Date(ts); + return d.getFullYear() + '/' + String(d.getMonth() + 1).padStart(2, '0') + '/' + String(d.getDate()).padStart(2, '0') + + ' ' + String(d.getHours()).padStart(2, '0') + ':' + String(d.getMinutes()).padStart(2, '0'); + } catch { + return ts; + } + } + function inferAgentFromSession(s) { + var direct = s.agentId || s.agent || s.agentName || ''; + if (direct) return String(direct); + var pid = String(s.project || s.cwd || s.id || '').toLowerCase(); + if (pid.indexOf('demo') >= 0) return t('project.demo'); + if (pid.indexOf('codex') >= 0) return 'Codex'; + if (pid.indexOf('claude') >= 0) return 'Claude'; + if (pid.indexOf('cursor') >= 0) return 'Cursor'; + if (pid.indexOf('openclaw') >= 0) return 'OpenClaw'; + return t('source.local'); + } + function inferSessionSource(s) { + var direct = s.agentId || s.agent || s.agentName || ''; + if (direct) return { name: String(direct), kind: 'agent', note: t('source.agentMarked') }; + var tags = Array.isArray(s.tags) ? s.tags.join(' ').toLowerCase() : ''; + var pid = String(s.project || s.cwd || s.id || '').toLowerCase(); + if (pid.indexOf('demo') >= 0) return { name: t('project.demo'), kind: 'demo', note: t('source.demoNote') }; + if (tags.indexOf('jsonl-import') >= 0) return { name: t('source.importedClaude'), kind: 'imported', note: t('source.importedClaudeNote') }; + if (pid.indexOf('codex') >= 0) return { name: 'Codex', kind: 'agent', note: t('source.pathInferred') }; + if (pid.indexOf('claude') >= 0) return { name: 'Claude', kind: 'agent', note: t('source.pathInferred') }; + if (pid.indexOf('cursor') >= 0) return { name: 'Cursor', kind: 'agent', note: t('source.pathInferred') }; + if (pid.indexOf('openclaw') >= 0) return { name: 'OpenClaw', kind: 'agent', note: t('source.pathInferred') }; + return { name: t('source.local'), kind: 'local', note: t('source.unknownAgentNote') }; + } + function agentAvatarSpec(name) { + var n = String(name || '').toLowerCase(); + if (n.indexOf('codex') >= 0) return { label: 'Codex', cls: 'codex', image: '/agent-avatars/codex.png' }; + if (n.indexOf('claude') >= 0) return { label: 'Claude', cls: 'claude', image: '/agent-avatars/claude.png' }; + if (n.indexOf('hermes') >= 0) return { label: 'Hermes', cls: 'hermes', image: '/agent-avatars/hermes.png' }; + if (n.indexOf('openclaw') >= 0) return { label: 'OpenClaw', cls: 'openclaw', image: '/agent-avatars/openclaw.png' }; + if (n.indexOf('cursor') >= 0) return { label: 'Cursor', cls: 'cursor' }; + if (n.indexOf('演示') >= 0 || n.indexOf('demo') >= 0) return { label: I18N_LANG === 'zh' ? '演' : 'D', cls: 'unknown' }; + if (n.indexOf('导入') >= 0 || n.indexOf('import') >= 0) return { label: I18N_LANG === 'zh' ? '导' : 'I', cls: 'claude', image: '/agent-avatars/claude.png' }; + if (n.indexOf('本地记录') >= 0 || n.indexOf('local record') >= 0 || n.indexOf('未标记') >= 0 || n.indexOf('unknown') >= 0) return { label: I18N_LANG === 'zh' ? '本' : 'L', cls: 'unknown' }; + return { label: String(name || '?').replace(/\s+/g, '').slice(0, 2).toUpperCase(), cls: 'unknown' }; + } + function renderAgentAvatar(avatar) { + var cls = 'agent-avatar ' + esc(avatar.cls || 'unknown') + (avatar.image ? ' has-image' : ''); + if (avatar.image) { + return '' + esc(avatar.label || 'Agent') + ' ' + esc(t('agent.avatarAlt')) + ''; + } + return '' + esc(avatar.label || '?') + ''; + } + function isDemoSession(s) { + var raw = String((s && (s.id || s.project || s.cwd || s.title || s.summary)) || '').toLowerCase(); + return raw.indexOf('demo_') >= 0 || raw.indexOf('/tmp/agentmemory-demo') >= 0 || raw.indexOf('agentmemory-demo') >= 0; + } + function sessionId(s) { + return s && s.id !== undefined && s.id !== null ? String(s.id) : ''; + } + function isValidSession(s) { + return !!sessionId(s); + } + function shortSessionId(s, n) { + var id = sessionId(s); + return id ? id.slice(0, n || 8) : ''; + } + function sessionDisplayName(s) { + var project = s && s.project ? String(s.project).split('/').pop() : ''; + if (project) return project; + return shortSessionId(s, 8) || t('dash.unnamedSession'); + } + function sessionLabel(s) { + var id = shortSessionId(s, 8); + var name = sessionDisplayName(s); + return id ? name + ' (' + id + ')' : name + ' (' + t('ses.noRecordId') + ')'; + } + function sessionProjectKey(s) { + var raw = (s && (s.cwd || s.project)) ? String(s.cwd || s.project) : t('project.uncategorized'); + if (raw.indexOf('/tmp/agentmemory-demo') >= 0) return t('project.demo'); + if (raw.indexOf('/Users/szn') === 0) { + var parts = raw.split('/').filter(Boolean); + if (parts.length >= 3) return parts.slice(0, 3).join('/'); + } + return raw.replace(/\/$/, '') || t('project.uncategorized'); + } + function projectDisplayName(key) { + var k = String(key || t('project.uncategorized')); + if (k === 'all') return t('project.all'); + if (k === 'browser') return t('project.browser'); + if (k === '未归类' || k === 'Uncategorized') return t('project.uncategorized'); + if (k === '演示数据' || k === 'Demo data') return t('project.demo'); + var parts = k.split('/').filter(Boolean); + return parts.length ? parts[parts.length - 1] : k; + } + function arrayFromCsv(value) { + if (Array.isArray(value)) return value.map(function(v) { return String(v || '').trim(); }).filter(Boolean); + return String(value || '').split(/[,,\s]+/).map(function(v) { return v.trim(); }).filter(Boolean); + } + function reviewPayload(item) { + return (item && item.payload && typeof item.payload === 'object') ? item.payload : {}; + } + function reviewProject(item) { + var payload = reviewPayload(item); + if (payload.projectScope === 'all' || payload.project === 'all') return 'all'; + if (payload.project) return payload.project; + if (item && item.kind === 'action') return ''; + return (item && item.page && item.page.host) || 'browser'; + } + function reviewTags(item) { + var payload = reviewPayload(item); + return arrayFromCsv(payload.tags).slice(0, 6); + } + function isMarkdownPlanText(text) { + var raw = String(text || ''); + var hasPlanHeading = /^#{1,3}\s+.*(?:计划|Plan)\s*$/im.test(raw); + var sectionMatches = raw.match(/^#{1,3}\s+(?:Summary|Key Changes|Test Plan|Assumptions|Public API|Implementation|执行步骤|验证命令)\b/img) || []; + var compact = raw.replace(/\s+/g, ' '); + var compactPlanSections = [ + /#{1,3}\s+Summary\b/i, + /#{1,3}\s+Key Changes\b/i, + /#{1,3}\s+Test Plan\b/i, + /#{1,3}\s+Assumptions\b/i, + /#{1,3}\s+Implementation\b/i, + /#{1,3}\s+执行步骤\b/i, + /#{1,3}\s+验证命令\b/i + ].filter(function(pattern) { return pattern.test(compact); }).length; + var compactPlan = /#{1,3}\s+[^#]{0,160}(?:计划|Plan)/i.test(compact) && + (compactPlanSections >= 2 || /#{1,3}\s+Summary\b/i.test(compact)); + return (hasPlanHeading && sectionMatches.length >= 2) || compactPlan; + } + function isReviewTextPolluted(text) { + var trimmed = String(text || '').trim(); + if (!trimmed) return false; + var lower = trimmed.toLowerCase(); + if (isJsonLikeText(trimmed)) return true; + if (isMarkdownPlanText(trimmed)) return true; + if (/please implement this plan/i.test(trimmed)) return true; + if (/"plan"\s*:/.test(trimmed) && /"status"\s*:/.test(trimmed) && /"step"\s*:/.test(trimmed)) return true; + if (/"command"\s*:|toolinput|tooloutput|function_id/.test(lower)) return true; + if (/"(?:cmd|workdir|yield_time_ms|max_output_tokens)"\s*:/.test(lower)) return true; + if (/\b(tooluseid|tooluse|call_[a-z0-9]+|chunk id|wall time|process exited)\b/i.test(trimmed)) return true; + if (/^(?:json|state|limit)\s+[\w.-]+/i.test(trimmed)) return true; + if (/\b(?:namewithowner|headrefname|baserefname|databaseid)\b/i.test(lower)) return true; + if (/^\s*(?:gh|git|npm|pnpm|yarn|python3?|node|curl)\s+[^\n]*(?:--json|--limit|--workdir|--max-output|--yield-time|status|show|list|run|test|install|build)\b/i.test(trimmed)) return true; + if (/审查结果\s*\[[Pp]\d+\]/.test(trimmed) && /(?:src|test)\/[^\s]+(?:\s*\(line\s+\d+\))?/.test(trimmed)) return true; + if (/^src\/[^\s]+/m.test(trimmed) && /\bnpm\s+(test|run|install|build)\b/i.test(trimmed)) return true; + if (/^\s*(npm|pnpm|yarn)\s+(test|run|install|build)\b/im.test(trimmed)) return true; + return false; + } + function isReviewItemDisplayable(item) { + if (!item) return false; + return !isReviewTextPolluted(item.title) && !isReviewTextPolluted(item.content); + } + function isActionReviewRenderable(item) { + if (!item) return false; + return item.kind === 'action' && isReviewItemDisplayable(item); + } + function actionTags(action) { + return Array.isArray(action && action.tags) ? action.tags.map(String) : []; + } + function isGeneratedAction(action) { + var tags = actionTags(action); + return (action && action.createdBy === 'todo-extract') || + tags.indexOf('todo-extracted') >= 0 || + tags.indexOf('action-candidate') >= 0 || + !!(action && action.metadata && action.metadata.todoExtraction); + } + function isActionRenderable(action) { + if (!action) return false; + if (!isGeneratedAction(action)) return true; + return !isReviewTextPolluted(action.title) && !isReviewTextPolluted(action.description); + } + function reviewActionPriority(item) { + var payload = reviewPayload(item); + var candidate = payload.actionCandidate && typeof payload.actionCandidate === 'object' ? payload.actionCandidate : {}; + var value = candidate.priority || payload.priority || item.priority || 5; + var parsed = parseInt(value, 10); + if (!Number.isFinite(parsed)) parsed = 5; + return Math.max(1, Math.min(10, parsed)); + } + function reviewSourceLabel(item) { + var payload = reviewPayload(item); + return payload.sourceLabel || payload.provider || (item && item.page && (item.page.typeLabel || item.page.host)) || '浏览器'; + } + function browserSessionObservations(item) { + var page = (item && item.page) || {}; + var conversation = (item && item.conversation) || {}; + var turns = Array.isArray(conversation.turns) ? conversation.turns : []; + var createdAt = (item && (item.createdAt || item.updatedAt)) || new Date().toISOString(); + var observations = []; + if (page.title || page.url) { + observations.push({ + id: (item.id || 'browser') + '_page', + sessionId: 'browser_' + (item.id || ''), + timestamp: createdAt, + type: 'web_fetch', + title: page.title || '浏览器页面', + subtitle: page.host || page.typeLabel || '浏览器', + narrative: [page.title, page.url].filter(Boolean).join('\n'), + facts: [], + concepts: ['browser'], + files: [], + importance: 0.5 + }); + } + turns.forEach(function(turn, index) { + var role = turn && turn.role === 'assistant' ? 'AI' : turn && turn.role === 'user' ? '用户' : '对话'; + var text = String((turn && turn.text) || '').trim(); + if (!text) return; + observations.push({ + id: (item.id || 'browser') + '_turn_' + index, + sessionId: 'browser_' + (item.id || ''), + timestamp: createdAt, + type: 'conversation', + title: role, + subtitle: page.title || page.host || '浏览器对话', + narrative: text, + facts: [], + concepts: ['browser', role], + files: [], + importance: role === '用户' ? 0.8 : 0.6 + }); + }); + if (item && item.content) { + observations.push({ + id: (item.id || 'browser') + '_review', + sessionId: 'browser_' + (item.id || ''), + timestamp: item.updatedAt || createdAt, + type: item.kind === 'lesson' ? 'decision' : 'discovery', + title: item.kind === 'lesson' ? '经验候选' : '同步会话', + subtitle: item.status === 'approved' ? '已保存' : item.status === 'dismissed' ? '已忽略' : '待审阅', + narrative: item.content, + facts: [], + concepts: ['browser', item.kind === 'lesson' ? 'lesson' : 'session'], + files: [], + importance: 0.7 + }); + } + return observations; + } + function browserReviewSessions(items) { + return (items || []).filter(function(item) { + var payload = reviewPayload(item); + return item && item.source === 'browser-extension' && !payload.browserSessionId && item.page && (item.page.type === 'ai-chat' || item.conversation || item.page.url); + }).map(function(item) { + var page = item.page || {}; + var payload = reviewPayload(item); + var provider = (item.conversation && item.conversation.provider) || payload.provider || page.host || '浏览器'; + var obs = browserSessionObservations(item); + return { + id: 'browser_' + item.id, + project: provider || '浏览器', + cwd: 'browser/' + (page.host || provider || 'web'), + source: 'browser-extension', + agentId: provider || '浏览器', + startedAt: item.createdAt || item.updatedAt || '', + updatedAt: item.updatedAt || item.createdAt || '', + endedAt: item.reviewedAt || item.updatedAt || item.createdAt || '', + status: item.status === 'pending' ? 'active' : 'completed', + observationCount: obs.length, + firstPrompt: page.title || item.title || '浏览器对话', + summary: item.content || '', + tags: ['browser', page.type || '', item.kind || ''].filter(Boolean), + embeddedObservations: obs, + browserPage: page + }; + }); + } + function groupSessionsByProject(items) { + var groups = {}; + (items || []).forEach(function(s) { + var key = sessionProjectKey(s); + if (!groups[key]) groups[key] = { key: key, name: projectDisplayName(key), sessions: [], count: 0, latest: '', observations: 0, sources: {}, hasMissingId: false }; + groups[key].sessions.push(s); + if (!sessionId(s)) groups[key].hasMissingId = true; + groups[key].count += 1; + groups[key].observations += s.observationCount || 0; + var rt = sessionRecordTime(s); + if (rt > groups[key].latest) groups[key].latest = rt; + var src = inferSessionSource(s).name || '未标记来源'; + groups[key].sources[src] = (groups[key].sources[src] || 0) + 1; + }); + return Object.keys(groups).map(function(k) { + groups[k].sessions.sort(function(a, b) { return (sessionRecordTime(b) || '').localeCompare(sessionRecordTime(a) || ''); }); + return groups[k]; + }).sort(function(a, b) { return (b.latest || '').localeCompare(a.latest || ''); }); + } + function sessionSourceKey(s) { + var source = inferSessionSource(s); + return source.kind + ':' + source.name; + } + function sessionSourceGroups(items) { + var groups = { + all: { key: 'all', name: '全部会话', count: 0, latest: '', observations: 0, kind: 'all' } + }; + (items || []).forEach(function(s) { + var source = inferSessionSource(s); + var key = sessionSourceKey(s); + if (!groups[key]) groups[key] = { key: key, name: source.name || '未标记来源', count: 0, latest: '', observations: 0, kind: source.kind || 'source' }; + groups.all.count += 1; + groups[key].count += 1; + groups.all.observations += s.observationCount || 0; + groups[key].observations += s.observationCount || 0; + var rt = sessionRecordTime(s); + if (rt > groups.all.latest) groups.all.latest = rt; + if (rt > groups[key].latest) groups[key].latest = rt; + }); + return Object.keys(groups).map(function(k) { return groups[k]; }).sort(function(a, b) { + if (a.key === 'all') return -1; + if (b.key === 'all') return 1; + return (b.latest || '').localeCompare(a.latest || ''); + }); + } + function sessionRecordTime(s) { + return (s && (s.updatedAt || s.endedAt || s.startedAt)) || ''; + } + function sessionStatusLabel(status) { + var map = { active: '历史会话', completed: '历史会话', archived: '历史会话', failed: '历史会话' }; + return map[status] || '历史会话'; + } + function sessionSourceSummary(s, obsCount) { + var source = String((s && s.source) || '').indexOf('browser') >= 0 ? '浏览器对话' : String((s && s.source) || '').indexOf('local-') === 0 ? '本地 Agent 会话' : '工作台会话'; + return [ + { label: '来源', value: source }, + { label: '项目', value: projectDisplayName(sessionProjectKey(s)) }, + { label: '记录', value: (obsCount || 0) + ' 条' }, + { label: '时间', value: shortDateTime(sessionRecordTime(s)) || '-' } + ]; + } + function statusIconMarkup(status, label) { + var s = String(status || '').toLowerCase(); + var title = esc(label || sessionStatusLabel(s)); + var cls = s === 'completed' || s === 'done' || s === 'closed' ? 'done' + : s === 'active' || s === 'running' ? 'active' + : s === 'blocked' || s === 'failed' || s === 'cancelled' ? s + : 'pending'; + var icon = ''; + if (cls === 'active') icon = ''; + if (cls === 'pending') icon = ''; + if (cls === 'blocked') icon = ''; + if (cls === 'failed' || cls === 'cancelled') icon = ''; + return ''; + } + function translateLessonText(text) { + var t = String(text || '').trim(); + var rules = [ + [/^DO NOT respond to these messages or otherwise consider them in your response unless the user explicitly asks you to\.$/i, '除非用户明确要求,否则不要回应或采纳系统旁路消息。'], + [/^do not include obvious instructions like "Provide helpful error messages to users", "Write unit tests for all new utilit/i, '不要把显而易见的泛化建议写进经验,例如“提供友好的错误提示”“为所有工具写测试”。'], + [/^Don't include generic development practices\.$/i, '不要记录过于泛泛的开发常识。'], + [/^make sure to include the important parts\.$/i, '总结时要保留真正重要的部分,不要只留下空泛结论。'], + [/^Do not make up information such as "Common Development Tasks", "Tips for Development", "Support and Documentation"/i, '不要编造不存在的章节或功能说明。'], + [/^Avoid listing every component or file structure that can be easily discovered\.$/i, '避免罗列用户自己很容易查到的组件或文件结构。'] + ]; + for (var i = 0; i < rules.length; i++) { + if (rules[i][0].test(t)) return rules[i][1]; + } + return t; + } + function memoryCategory(m) { + var text = ((m && (m.title || m.content || '')) + ' ' + ((m && m.concepts) || []).join(' ')).toLowerCase(); + if (/身份|个人档案|profile|alias|name|自我|生日|出生|本科就读|常用语言|当前重点项目|\d{4}[-/年]\d{1,2}[-/月]\d{1,2}/.test(text)) return '身份档案'; + if (/偏好|preference|喜欢|沟通|协作|working style/.test(text)) return '偏好'; + if (/项目|buddyup|project|产品|创业|ucl|hci/.test(text)) return '项目与目标'; + if (/原则|判断|框架|原则|pattern|workflow|工作流/.test(text)) return '判断框架'; + if (/经历|教育|学校|history|experience/.test(text)) return '经历'; + return memoryTypeLabel(m && m.type); + } + function splitCommaList(text) { + return String(text || '') + .split(/[、,,]/) + .map(function(x) { return x.trim().replace(/[。;;]$/, ''); }) + .filter(Boolean); + } + function splitSentenceList(text) { + return String(text || '') + .split(/[。;;\n]/) + .map(function(x) { return x.trim(); }) + .filter(Boolean); + } + function splitIdentityProfile(content) { + var text = String(content || '').trim(); + var focusMatch = text.match(/当前重点项目包括[::]?\s*([^。]+)。?/); + var communicationMatch = text.match(/沟通偏好[::]\s*([^。]+)。?/); + var languageMatch = text.match(/常用语言包括[::]\s*([^。]+)。?/); + var educationMatch = text.match(/(本科就读于[^。]+。?)/); + var intro = text + .replace(/当前重点项目包括[::]?\s*[^。]+。?/g, '') + .replace(/沟通偏好[::]\s*[^。]+。?/g, '') + .replace(/常用语言包括[::]\s*[^。]+。?/g, '') + .replace(/本科就读于[^。]+。?/g, '') + .replace(/\s+/g, ' ') + .trim(); + return { + intro: intro || text, + education: splitSentenceList(educationMatch && educationMatch[1]), + focus: splitCommaList(focusMatch && focusMatch[1]), + communication: splitCommaList(communicationMatch && communicationMatch[1]), + language: splitCommaList(languageMatch && languageMatch[1]) + }; + } + function memoryStrength(m) { + var rawStrength = (m && m.strength) || 0; + var strength = Math.round(rawStrength <= 1 ? rawStrength * 100 : rawStrength * 10); + if (strength > 100) strength = 100; + if (strength < 0) strength = 0; + return strength; + } + function memorySourceKind(m) { + var concepts = (m && Array.isArray(m.concepts) ? m.concepts : []).join(' ').toLowerCase(); + var project = String((m && m.project) || '').toLowerCase(); + var text = String((m && (m.title || m.content)) || '').toLowerCase(); + if (project === 'browser' || concepts.indexOf('browser-context') >= 0 || text.indexOf('网页记忆线索') >= 0 || text.indexOf('浏览器候选记忆') >= 0) return 'browser'; + if (m && m.sessionIds && m.sessionIds.length) return 'session'; + return 'manual'; + } + function browserSourceFromMemory(m) { + var concepts = m && Array.isArray(m.concepts) ? m.concepts : []; + var source = ''; + concepts.forEach(function(c) { + var s = String(c || ''); + if (s.indexOf('browser-source:') === 0 && !source) source = s.slice('browser-source:'.length); + }); + if (source) return source; + var host = ''; + concepts.forEach(function(c) { + var s = String(c || ''); + if (s.indexOf('browser-host:') === 0 && !host) host = s.slice('browser-host:'.length); + }); + return host; + } + function browserSourceLabel(source) { + var labels = { chatgpt: 'ChatGPT', claude: 'Claude', gemini: 'Gemini', perplexity: 'Perplexity', grok: 'Grok', deepseek: 'DeepSeek', github: 'GitHub', feishu: '飞书', notion: 'Notion' }; + if (!source) return '浏览器'; + return labels[source] || source.replace(/^www\./, ''); + } + function memorySourceLabel(m) { + var kind = memorySourceKind(m); + if (kind === 'browser') return browserSourceLabel(browserSourceFromMemory(m)); + if (kind === 'session') return '会话'; + return '手动'; + } + function renderMemoryCard(card) { + var mem = card.memory || {}; + var cls = card.kind || 'default'; + var html = '
'; + html += '
'; + html += '
' + esc(card.title || '未命名记忆') + '
'; + html += '' + esc(card.label || memoryTypeLabel(mem.type)) + ''; + html += '
'; + if (card.items && card.items.length) { + html += '
    '; + card.items.forEach(function(item) { html += '
  • ' + esc(item) + '
  • '; }); + html += '
'; + } else { + html += '
' + esc(card.body || '暂无内容') + '
'; + } + if (mem.concepts && mem.concepts.length > 0) { + html += '
'; + mem.concepts.slice(0, 4).forEach(function(c) { html += '' + esc(c) + ''; }); + html += '
'; + } + html += ''; + html += '
'; + return html; + } + function memoryDisplayCards(m) { + var content = (m && (m.content || m.title)) || ''; + if (memoryCategory(m) === '身份档案') { + var data = splitIdentityProfile(content); + var cards = []; + if (data.intro) cards.push({ memory: m, kind: 'identity', label: '身份', title: '基本信息', body: data.intro }); + if (data.education && data.education.length) cards.push({ memory: m, kind: 'history', label: '经历', title: '教育经历', items: data.education }); + if (data.focus && data.focus.length) cards.push({ memory: m, kind: 'project', label: '项目', title: '当前重点', items: data.focus }); + if (data.communication && data.communication.length) cards.push({ memory: m, kind: 'preference', label: '偏好', title: '沟通偏好', items: data.communication }); + if (data.language && data.language.length) cards.push({ memory: m, kind: 'identity', label: '语言', title: '常用表达', items: data.language }); + return cards.length ? cards : [{ memory: m, kind: 'identity', label: memoryTypeLabel(m && m.type), title: (m && m.title) || '身份档案', body: content }]; + } + var title = ((m && m.title) || '').trim(); + var body = ((m && m.content) || '').trim(); + var sameTitle = title && body && body.indexOf(title) === 0; + return [{ + memory: m, + kind: 'default', + label: memoryTypeLabel(m && m.type), + title: sameTitle ? truncate(body, 72) : truncate(title || body, 72), + body: sameTitle ? body.slice(title.length).trim() || body : body + }]; + } + function renderIdentityProfileCard(mem) { + if (!mem) return ''; + var data = splitIdentityProfile(mem.content || mem.title || ''); + var headline = '未命名身份'; + var birth = ''; + var intro = data.intro || ''; + var nameMatch = intro.match(/^([^,。]+?)(?:,|。)/); + if (nameMatch) headline = nameMatch[1].trim(); + var birthMatch = intro.match(/(\d{4}[-/年]\d{1,2}[-/月]\d{1,2}\s*生?)/); + if (birthMatch) birth = birthMatch[1].replace(/年|月/g, '-').replace(/日/g, '').replace(/--/g, '-'); + var titleLine = intro.replace(headline, '').replace(/^,/, '').trim(); + if (birth) titleLine = titleLine.replace(birthMatch && birthMatch[0], '').replace(/^,/, '').trim(); + var html = '
'; + html += '
'; + html += '
'; + html += '
身份档案
'; + html += '
' + esc(headline) + '
'; + html += '
' + esc(titleLine || intro) + '
'; + html += '
'; + html += '
'; + if (birth) html += '生日 ' + esc(birth) + ''; + html += '' + esc(memoryTypeLabel(mem.type)) + ''; + html += ''; + html += '
'; + html += '
'; + var sections = [ + ['当前重点', data.focus], + ['沟通偏好', data.communication], + ['常用语言', data.language] + ]; + sections.forEach(function(section) { + var label = section[0]; + var list = section[1] || []; + html += '
'; + html += '
' + esc(label) + '
'; + if (list.length) { + html += '
'; + list.forEach(function(item) { html += '' + esc(item) + ''; }); + html += '
'; + } else { + html += '
暂无结构化内容
'; + } + html += '
'; + }); + html += '
'; + html += '
'; + return html; + } + function renderIdentityProfileGroup(cards) { + cards = cards || []; + if (!cards.length) return ''; + var primary = (cards[0] && cards[0].memory) || {}; + var titleCard = cards.find(function(c) { return c.title === '基本信息'; }) || cards[0]; + var headline = titleCard && titleCard.body ? titleCard.body.split(/[,。]/)[0] : '身份档案'; + var html = '
'; + html += '
'; + html += '
'; + html += '
' + esc(headline || '身份档案') + '
'; + if (titleCard && titleCard.body) { + html += '
' + esc(truncate(titleCard.body, 150)) + '
'; + } + html += '
'; + html += '
'; + html += ''; + html += ''; + html += '
'; + html += '
'; + cards.forEach(function(card) { + if (card === titleCard) return; + html += '
'; + html += '
'; + html += '
' + esc(card.title || '信息') + '
'; + html += '' + esc(card.label || '') + ''; + html += '
'; + if (card.items && card.items.length) { + html += '
    '; + card.items.forEach(function(item) { html += '
  • ' + esc(item) + '
  • '; }); + html += '
'; + } else { + html += '
' + esc(card.body || '暂无内容') + '
'; + } + html += '
'; + }); + html += '
'; + html += '
'; + return html; + } + function buildAgentMemoryPrompt(mem) { + var data = splitIdentityProfile((mem && (mem.content || mem.title)) || ''); + var lines = []; + lines.push('请根据这份用户记忆来优化协作方式:'); + if (data.intro) lines.push('- 用户画像:' + data.intro); + if (data.focus && data.focus.length) lines.push('- 当前重点:' + data.focus.join(';')); + if (data.communication && data.communication.length) lines.push('- 沟通偏好:' + data.communication.join(';')); + if (data.language && data.language.length) lines.push('- 常用表达/风格:' + data.language.join(';')); + if (data.education && data.education.length) lines.push('- 经历背景:' + data.education.join(';')); + lines.push('- 协作原则:少暴露用户难以理解的内部概念,把功能解释成清晰的产品流程;能用图标表达的小标签优先用图标;界面和建议都要服务真实使用,而不是只展示系统记录。'); + return lines.join('\n'); + } + function openAgentPrompt(id) { + var mem = (state.memories.items || []).find(function(m) { return m.id === id; }) || (state.memories.items || []).find(function(m) { return memoryCategory(m) === '身份档案'; }); + if (!mem) return; + var prompt = buildAgentMemoryPrompt(mem); + var modal = document.getElementById('modal'); + var overlay = document.getElementById('modal-overlay'); + modal.innerHTML = + '

协作提示

' + + '

这些记忆会自动进入 Agent 的理解范围;这里仅保留可复制的提示文本。

' + + '
' + + '' + + '
' + + '
' + + ''; + overlay.classList.add('open'); + setTimeout(function() { + var input = document.getElementById('agent-memory-prompt'); + if (input) input.focus(); + }, 0); + } + async function copyAgentPrompt() { + var text = (document.getElementById('agent-memory-prompt') || {}).value || ''; + var status = document.getElementById('agent-prompt-status'); + try { + if (navigator.clipboard && navigator.clipboard.writeText) { + await navigator.clipboard.writeText(text); + } else { + var ta = document.getElementById('agent-memory-prompt'); + if (ta) { ta.select(); document.execCommand('copy'); } + } + if (status) { + status.style.display = 'block'; + status.style.color = 'var(--green)'; + status.textContent = '已复制。'; + } + } catch (err) { + if (status) { + status.style.display = 'block'; + status.style.color = 'var(--red)'; + status.textContent = '复制失败,可以手动选中这段提示。'; + } + } + } + async function copyTextToClipboard(text) { + if (navigator.clipboard && navigator.clipboard.writeText) { + await navigator.clipboard.writeText(text || ''); + return; + } + var ta = document.createElement('textarea'); + ta.value = text || ''; + ta.style.position = 'fixed'; + ta.style.left = '-9999px'; + document.body.appendChild(ta); + ta.select(); + document.execCommand('copy'); + document.body.removeChild(ta); + } + function debounce(fn, ms) { + var t; + return function() { + var args = arguments, ctx = this; + clearTimeout(t); + t = setTimeout(function() { fn.apply(ctx, args); }, ms); + }; + } + + // IME_SAFE_SEARCH_V2 + function bindImeSafeSearch(input, ms, onSearch) { + var composing = false; + var justCommitted = false; + var run = debounce(function(value) { onSearch(value); }, ms); + input.addEventListener('compositionstart', function() { composing = true; }); + input.addEventListener('compositionend', function() { + composing = false; + justCommitted = true; + onSearch(input.value); + setTimeout(function() { justCommitted = false; }, 0); + }); + input.addEventListener('input', function(e) { + if (composing || e.isComposing) return; + if (justCommitted) return; + run(input.value); + }); + } + function captureSearchFocus(ids) { + var a = document.activeElement; + if (!a || ids.indexOf(a.id) < 0) return null; + return { id: a.id, start: a.selectionStart, end: a.selectionEnd }; + } + function restoreSearchFocus(focus) { + if (!focus) return; + var el = document.getElementById(focus.id); + if (!el) return; + el.focus(); + if (typeof el.setSelectionRange === 'function') { + try { el.setSelectionRange(focus.start, focus.end); } catch (e) {} + } + } + diff --git a/src/viewer/parts/app/45-transport.js b/src/viewer/parts/app/45-transport.js new file mode 100644 index 00000000..7828ad50 --- /dev/null +++ b/src/viewer/parts/app/45-transport.js @@ -0,0 +1,76 @@ + async function api(path, opts) { + try { + var headers = Object.assign({ 'Cache-Control': 'no-cache' }, (opts && opts.headers) || {}); + var fetchOpts = Object.assign({}, opts || {}, { headers: headers }); + var urls = [REST + '/agentmemory/' + path, REST + '/' + path]; + for (var i = 0; i < urls.length; i++) { + var res = await fetch(urls[i], fetchOpts); + if (res.ok) return await res.json(); + } + console.warn('[viewer] API ' + (fetchOpts.method || 'GET') + ' ' + path + ' failed on all route variants'); + return null; + } catch (err) { + console.warn('[viewer] API error on ' + path + ':', err); + return null; + } + } + async function apiGet(path) { return api(path); } + async function apiPost(path, body) { + return api(path, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body || {}) + }); + } + async function apiDelete(path, body) { + return api(path, { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body || {}) + }); + } + async function apiPatch(path, body) { + return api(path, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body || {}) + }); + } + async function loadLocalCodexSessions(limit) { + var count = limit || 120; + var result = await apiGet('local-agent-sessions?limit=' + encodeURIComponent(count)); + if (!result) result = await apiGet('local-codex-sessions?limit=' + encodeURIComponent(count)); + return (result && result.sessions) || []; + } + async function settledData(label, fallback, loader) { + try { + var data = await loader(); + return { label: label, data: data, failed: false }; + } catch (err) { + console.warn('[viewer] ' + label + ' failed:', err); + return { label: label, data: fallback, failed: true }; + } + } + function renderDataWarnings(warnings, retryAction) { + if (!warnings || warnings.length === 0) return ''; + var html = '
' + esc(warnings.join('、')) + ' 暂时没读到,已先展示可用数据。
'; + html += '
'; + return html; + } + function mergeSessions(primary, local) { + var seen = {}; + var out = []; + (primary || []).forEach(function(s) { + var id = sessionId(s); + if (!id) return; + seen[id] = true; + out.push(s); + }); + (local || []).forEach(function(s) { + var id = sessionId(s).replace(/^codex_local_/, 'codex_'); + if (!sessionId(s)) return; + if (!seen[id] && !seen[sessionId(s)]) out.push(s); + }); + return out; + } + diff --git a/src/viewer/parts/app/90-rest.js b/src/viewer/parts/app/90-rest.js index 4d64d42d..3d79a878 100644 --- a/src/viewer/parts/app/90-rest.js +++ b/src/viewer/parts/app/90-rest.js @@ -1,1082 +1,3 @@ - function cleanSessionPreview(text) { - var t = String(text || '').trim(); - if (!t) return ''; - var noisyStarts = [ - '# AGENTS.md instructions', - '', - 'Automation:', - 'Response MUST end with', - 'You are Codex', - 'Filesystem sandboxing defines' - ]; - for (var i = 0; i < noisyStarts.length; i++) { - if (t.indexOf(noisyStarts[i]) === 0) return ''; - } - t = t.replace(/^# In app browser:[\s\S]*?## My request for Codex:\s*/m, '').trim(); - t = t.replace(/^# Browser comments:[\s\S]*?## My request for Codex:\s*/m, '').trim(); - t = t.replace(/^The next image is untrusted page evidence[\s\S]*?instructions\.\s*/m, '').trim(); - return t; - } - function normalizePreviewText(text) { - return cleanSessionPreview(text).replace(/\s+/g, ' ').trim(); - } - function compactSessionTitle(text) { - var t = normalizePreviewText(text); - if (!t) return ''; - t = t.replace(/https?:\/\/\S+/g, '').trim(); - t = t.replace(/\[[^\]]{20,}\]\([^)]+\)/g, '').trim(); - t = t.replace(/[((][^()()]{24,}[))]/g, '').trim(); - t = t.replace(/^#+\s*/, '').replace(/\*\*/g, '').replace(/^\d+[.、]\s*/, '').trim(); - t = t.replace(/\s+/g, ' '); - if (t.length > 44) { - var cut = t.slice(0, 44); - var stop = Math.max(cut.lastIndexOf('。'), cut.lastIndexOf(';'), cut.lastIndexOf(','), cut.lastIndexOf(' ')); - if (stop > 14) cut = cut.slice(0, stop); - t = cut + '...'; - } - return t; - } - function sessionTitleText(s) { - return compactSessionTitle(s && (s.title || s.firstPrompt)) || sessionDisplayName(s); - } - function sessionBodyPreview(s, title) { - var candidates = [s && s.summary, s && s.latestPrompt, s && s.firstPrompt]; - var titleText = normalizePreviewText(title); - for (var i = 0; i < candidates.length; i++) { - var preview = normalizePreviewText(candidates[i]); - if (!preview) continue; - if (titleText && preview === titleText) continue; - if (titleText && preview.indexOf(titleText) === 0) { - preview = preview.slice(titleText.length).replace(/^[\s::,,.。;;-]+/, '').trim(); - } - if (preview && preview !== titleText) return preview; - } - return ''; - } - function parseJsonObject(text) { - var raw = String(text || '').trim(); - if (!raw || raw[0] !== '{') return null; - try { return JSON.parse(raw); } catch (e) { return null; } - } - function splitCommandNarrative(text) { - var raw = String(text || '').trim(); - if (!raw) return { command: '', output: '' }; - var pipeIndex = raw.indexOf(' | '); - var left = pipeIndex >= 0 ? raw.slice(0, pipeIndex).trim() : raw; - var right = pipeIndex >= 0 ? raw.slice(pipeIndex + 3).trim() : ''; - var obj = parseJsonObject(left); - return { command: obj && obj.command ? String(obj.command) : '', output: right }; - } - function extractCommandText(o) { - var rawNarrative = String(o && o.narrative || '').trim(); - var rawSubtitle = String(o && o.subtitle || '').trim(); - var first = rawNarrative.indexOf(' | ') >= 0 ? splitCommandNarrative(rawNarrative) : splitCommandNarrative(rawSubtitle); - if (first.command || first.output) return first; - var second = splitCommandNarrative(rawNarrative); - if (second.command || second.output) return second; - var ti = o && o.toolInput; - if (ti && typeof ti === 'object' && ti.command) return { command: String(ti.command), output: String(o && o.toolOutput || '') }; - return { command: '', output: '' }; - } - function normalizedToolTraceName(value) { - return String(value || '').trim().toLowerCase().replace(/\s+/g, '_'); - } - function isJsonLikeText(text) { - var trimmed = String(text || '').trim(); - if (!trimmed || (trimmed[0] !== '{' && trimmed[0] !== '[')) return false; - try { - JSON.parse(trimmed); - return true; - } catch (e) { - return /^[{\[]/.test(trimmed); - } - } - function looksToolTraceDisplayText(text) { - var trimmed = String(text || '').trim(); - if (!trimmed) return false; - var lower = trimmed.toLowerCase(); - return isJsonLikeText(trimmed) || /"plan"\s*:|"command"\s*:|toolinput|tooloutput|function_id/.test(lower); - } - function commandHumanSummary(command) { - var c = String(command || '').trim(); - if (!c) return t('obs.summary.localOperation'); - if (c.indexOf('ensure-agentmemory-live.mjs') >= 0 && c.indexOf('--restart') >= 0) return t('obs.summary.restartService'); - if (c.indexOf('ensure-agentmemory-live.mjs') >= 0) return t('obs.summary.startService'); - if (/^open\s+['"]?https?:\/\/127\.0\.0\.1:3114/.test(c)) return t('obs.summary.openPreview'); - if (c.indexOf('agentmemory/livez') >= 0 || c.indexOf('/health') >= 0) return t('obs.summary.checkService'); - if (c.indexOf('sessionBodyPreview') >= 0 || c.indexOf('observationDisplay') >= 0) return t('obs.summary.verifyPageFix'); - if (c.indexOf('bugfix-log') >= 0 && /sed|cat|ls/.test(c)) return t('obs.summary.viewFixLog'); - if (/command -v npx/.test(c)) return t('obs.summary.checkBrowserAutomation'); - if (/^(ps|lsof|netstat)\b/.test(c) || c.indexOf(' ps ') >= 0 || c.indexOf('lsof ') >= 0) return t('obs.summary.checkProcess'); - if (/^mkdir\b|^mv\b|^cp\b|^node <<|cleanup|重复旧版本|_待确认清理/.test(c)) return t('obs.summary.organizeLocalFiles'); - if (/^curl\b/.test(c)) return t('obs.summary.readLocalServiceData'); - if (/^sed\b|^rg\b|^grep\b|^find\b|^ls\b|^nl\b/.test(c)) return t('obs.summary.viewLocalFiles'); - if (/^(npm|pnpm|bun|yarn)\b/.test(c)) return t('obs.summary.runProjectScript'); - if (/^(git)\b/.test(c)) return t('obs.summary.checkCodeVersion'); - return t('obs.summary.runLocalCommand'); - } - function commandOutputSummary(output, command) { - var out = String(output || '').trim(); - var c = String(command || ''); - if (!out) return t('obs.output.noText'); - if (out.indexOf('"ok": true') >= 0 || out.indexOf('"status":"ok"') >= 0 || out.indexOf('"status": "ok"') >= 0) return t('obs.output.serviceOk'); - if (out.indexOf('npx-missing') >= 0) return t('obs.output.npxMissing'); - if (out.indexOf('npx-ok') >= 0) return t('obs.output.npxOk'); - if (out.indexOf('sessionBodyPreview') >= 0 || out.indexOf('observationDisplay') >= 0) return t('obs.output.pageFixOk'); - if (out.indexOf('error') >= 0 || out.indexOf('failed') >= 0 || out.indexOf('Error:') >= 0) return t('obs.output.error'); - if (/^curl\b/.test(c)) return t('obs.output.readService'); - if (/^sed\b|^rg\b|^grep\b|^find\b|^ls\b|^nl\b/.test(c)) return t('obs.output.viewFiles'); - return t('obs.output.done'); - } - function observationDisplay(o) { - var baseTitle = normalizePreviewText(o && (o.title || o.toolName || (o.hookType ? o.hookType.replace(/_/g, ' ') : 'Observation'))); - var rawSubtitle = normalizePreviewText(o && o.subtitle); - var rawNarrative = normalizePreviewText(o && o.narrative); - var commandInfo = extractCommandText(o); - var type = o && (o.type || o.hookType || ''); - var toolName = normalizedToolTraceName(o && (o.toolName || o.title || o.hookType)); - var title = baseTitle || 'Observation'; - var subtitle = rawSubtitle; - var body = rawNarrative; - if (title === 'prompt_submit') title = t('obs.display.promptSubmit'); - if (title === 'agent_message') title = t('obs.display.agentMessage'); - if (toolName === 'update_plan') { - title = t('obs.display.updatePlan'); - subtitle = ''; - body = t('obs.display.updatePlanBody'); - } else if (toolName === 'apply_patch') { - title = t('obs.display.applyPatch'); - subtitle = ''; - body = t('obs.display.applyPatchBody'); - } else if ((type === 'command_run' || toolName === 'bash' || toolName === 'exec_command') && commandInfo.command) { - title = commandHumanSummary(commandInfo.command); - subtitle = ''; - body = commandOutputSummary(commandInfo.output, commandInfo.command); - } else if (rawSubtitle && rawNarrative && rawNarrative.indexOf(rawSubtitle) === 0) { - body = rawNarrative.slice(rawSubtitle.length).replace(/^[\s|::,,.。;;-]+/, '').trim(); - } - if (looksToolTraceDisplayText(subtitle)) subtitle = ''; - if (looksToolTraceDisplayText(body)) body = t('obs.display.toolTrace'); - body = sessionBodyPreview({ summary: body, latestPrompt: rawNarrative, firstPrompt: rawSubtitle }, title); - return { title: title, subtitle: subtitle, body: body }; - } - function looksRawSystemText(text) { - var t = String(text || '').trim(); - return t.indexOf('{"command"') >= 0 || t.indexOf('\\\"command\\\"') >= 0 || /^Bash($|[::])/.test(t) || /^prompt_submit$/.test(t) || /^agent_message$/.test(t); - } - function cleanEpisodeText(text, fallback) { - var t = normalizePreviewText(text); - if (!t || looksRawSystemText(t)) return fallback || ''; - return t; - } - function episodeActionText(o) { - var type = observationType(o); - var display = observationDisplay(o); - var title = cleanEpisodeText(display.title, typeDisplayLabel(type)); - var body = cleanEpisodeText(display.body, ''); - if (body && body !== title) return title + ':' + truncate(body, 64); - return title; - } - function typeDisplayLabel(type) { - return t('episode.type.' + (type || 'other'), String(type || t('episode.type.other')).replace(/_/g, ' ')); - } - function episodeKindLabel(kind) { - return t('episode.kind.' + (kind || 'work'), t('episode.kind.work')); - } - function observationType(o) { - var toolMap = { Read: 'file_read', Write: 'file_write', Edit: 'file_edit', Bash: 'command_run', Grep: 'search', Glob: 'search', WebFetch: 'web_fetch', WebSearch: 'web_fetch', AskUserQuestion: 'conversation', Task: 'subagent' }; - return (o && (o.type || toolMap[o.toolName] || (o.hookType ? o.hookType.replace(/_/g, ' ') : 'other'))) || 'other'; - } - function episodeTimeKey(o) { - var sid = o && (o._sessionId || o.sessionId); - if (sid) return 'session:' + sid; - try { - var d = new Date(o.timestamp); - if (!Number.isNaN(d.getTime())) return 'hour:' + d.toISOString().slice(0, 13); - } catch(e) {} - return 'misc'; - } - function episodeFallbackTitle(first, projectName) { - var name = first && (first._sessionName || first.sessionName); - if (name && name !== 'Codex 会话') return name; - var d = first && first.timestamp ? formatTime(first.timestamp) : ''; - return (projectName ? projectName + ' · ' : '') + (d || t('episode.workSegment')); - } - function classifyEpisode(counts, title, body, importance) { - var text = String((title || '') + ' ' + (body || '')).toLowerCase(); - if ((importance || 0) >= 8 || counts.decision || counts.error) return 'important'; - if (/修|bug|error|错误|异常|排查|fix|fail|undefined|json|bash/.test(text)) return 'bugfix'; - if (/解释|研究|github|项目|repo|资料|分析|搜索|tencent|memory/.test(text) || counts.web_fetch || counts.search) return 'research'; - if (/用户提出需求|需求|继续|优化|启动/.test(text) || counts.conversation) return 'user_need'; - if (counts.file_write || counts.file_edit || counts.file_read) return 'file_work'; - return 'work'; - } - function filterTimelineEpisodes(episodes) { - var filter = state.timeline.episodeFilter || 'all'; - if (filter === 'all') return episodes; - return episodes.filter(function(ep) { - if (filter === 'important') return ep.kind === 'important' || ep.kind === 'user_need' || ep.kind === 'bugfix'; - return ep.kind === filter; - }); - } - function buildTimelineEpisodes(observations) { - var project = (state.timeline.sessions || []).find(function(s) { return sessionProjectKey(s) === state.timeline.projectKey; }); - var projectName = project ? projectDisplayName(project) : ''; - var buckets = {}; - (observations || []).forEach(function(o) { - var key = episodeTimeKey(o); - if (!buckets[key]) buckets[key] = []; - buckets[key].push(o); - }); - return Object.keys(buckets).map(function(key) { - var items = buckets[key].slice().sort(function(a, b) { - return String(a.timestamp || '').localeCompare(String(b.timestamp || '')); - }); - var first = items[0] || {}; - var last = items[items.length - 1] || first; - var counts = {}; - var actions = []; - var title = ''; - var bodyCandidate = ''; - items.forEach(function(o) { - var type = observationType(o); - counts[type] = (counts[type] || 0) + 1; - var display = observationDisplay(o); - var cleanTitle = cleanEpisodeText(display.title, ''); - var cleanBody = cleanEpisodeText(display.body, ''); - if (!title && cleanTitle && cleanTitle !== 'Observation') title = cleanTitle; - if (!bodyCandidate && cleanBody) bodyCandidate = cleanBody; - var action = episodeActionText(o); - if (actions.indexOf(action) < 0) actions.push(action); - }); - var typeText = Object.keys(counts).sort(function(a, b) { return counts[b] - counts[a]; }).slice(0, 4).map(function(t) { - return typeDisplayLabel(t) + ' ' + counts[t]; - }).join(','); - var readableTitle = title || episodeFallbackTitle(first, projectName); - var readableBody = bodyCandidate || (t('episode.bodyPrefix') + typeText + t('episode.bodySuffix')); - var maxImportance = items.reduce(function(max, o) { return Math.max(max, typeof o.importance === 'number' ? o.importance : 5); }, 0); - var kind = classifyEpisode(counts, readableTitle, readableBody, maxImportance); - return { - key: key, - title: readableTitle, - body: readableBody, - actions: actions.slice(0, 6), - count: items.length, - typeText: typeText || t('episode.record') + ' ' + items.length, - start: first.timestamp, - end: last.timestamp, - importance: maxImportance, - kind: kind - }; - }).sort(function(a, b) { - return String(b.end || '').localeCompare(String(a.end || '')); - }); - } - function memoryTypeLabel(t) { - var map = { - fact: '事实', - preference: '偏好', - profile: '身份档案', - architecture: '架构', - workflow: '流程', - pattern: '模式', - bug: '问题', - goal: '目标', - history: '经历', - project: '项目', - principle: '原则', - lifestyle: '生活' - }; - return map[t] || t || '未分类'; - } - function memoryAreaToType(area) { - var map = { - profile: 'profile', - preference: 'preference', - project: 'project', - principle: 'pattern', - history: 'history' - }; - return map[area] || 'fact'; - } - function memoryTypeToArea(type, mem) { - var category = memoryCategory(mem || { type: type }); - if (category === '身份档案') return 'profile'; - if (category === '偏好') return 'preference'; - if (category === '项目与目标') return 'project'; - if (category === '判断框架') return 'principle'; - if (category === '经历') return 'history'; - return type === 'project' ? 'project' : type === 'preference' ? 'preference' : type === 'pattern' || type === 'workflow' ? 'principle' : 'profile'; - } - function shortDateTime(ts) { - if (!ts) return '-'; - try { - var d = new Date(ts); - return d.getFullYear() + '/' + String(d.getMonth() + 1).padStart(2, '0') + '/' + String(d.getDate()).padStart(2, '0') + - ' ' + String(d.getHours()).padStart(2, '0') + ':' + String(d.getMinutes()).padStart(2, '0'); - } catch { - return ts; - } - } - function inferAgentFromSession(s) { - var direct = s.agentId || s.agent || s.agentName || ''; - if (direct) return String(direct); - var pid = String(s.project || s.cwd || s.id || '').toLowerCase(); - if (pid.indexOf('demo') >= 0) return t('project.demo'); - if (pid.indexOf('codex') >= 0) return 'Codex'; - if (pid.indexOf('claude') >= 0) return 'Claude'; - if (pid.indexOf('cursor') >= 0) return 'Cursor'; - if (pid.indexOf('openclaw') >= 0) return 'OpenClaw'; - return t('source.local'); - } - function inferSessionSource(s) { - var direct = s.agentId || s.agent || s.agentName || ''; - if (direct) return { name: String(direct), kind: 'agent', note: t('source.agentMarked') }; - var tags = Array.isArray(s.tags) ? s.tags.join(' ').toLowerCase() : ''; - var pid = String(s.project || s.cwd || s.id || '').toLowerCase(); - if (pid.indexOf('demo') >= 0) return { name: t('project.demo'), kind: 'demo', note: t('source.demoNote') }; - if (tags.indexOf('jsonl-import') >= 0) return { name: t('source.importedClaude'), kind: 'imported', note: t('source.importedClaudeNote') }; - if (pid.indexOf('codex') >= 0) return { name: 'Codex', kind: 'agent', note: t('source.pathInferred') }; - if (pid.indexOf('claude') >= 0) return { name: 'Claude', kind: 'agent', note: t('source.pathInferred') }; - if (pid.indexOf('cursor') >= 0) return { name: 'Cursor', kind: 'agent', note: t('source.pathInferred') }; - if (pid.indexOf('openclaw') >= 0) return { name: 'OpenClaw', kind: 'agent', note: t('source.pathInferred') }; - return { name: t('source.local'), kind: 'local', note: t('source.unknownAgentNote') }; - } - function agentAvatarSpec(name) { - var n = String(name || '').toLowerCase(); - if (n.indexOf('codex') >= 0) return { label: 'Codex', cls: 'codex', image: '/agent-avatars/codex.png' }; - if (n.indexOf('claude') >= 0) return { label: 'Claude', cls: 'claude', image: '/agent-avatars/claude.png' }; - if (n.indexOf('hermes') >= 0) return { label: 'Hermes', cls: 'hermes', image: '/agent-avatars/hermes.png' }; - if (n.indexOf('openclaw') >= 0) return { label: 'OpenClaw', cls: 'openclaw', image: '/agent-avatars/openclaw.png' }; - if (n.indexOf('cursor') >= 0) return { label: 'Cursor', cls: 'cursor' }; - if (n.indexOf('演示') >= 0 || n.indexOf('demo') >= 0) return { label: I18N_LANG === 'zh' ? '演' : 'D', cls: 'unknown' }; - if (n.indexOf('导入') >= 0 || n.indexOf('import') >= 0) return { label: I18N_LANG === 'zh' ? '导' : 'I', cls: 'claude', image: '/agent-avatars/claude.png' }; - if (n.indexOf('本地记录') >= 0 || n.indexOf('local record') >= 0 || n.indexOf('未标记') >= 0 || n.indexOf('unknown') >= 0) return { label: I18N_LANG === 'zh' ? '本' : 'L', cls: 'unknown' }; - return { label: String(name || '?').replace(/\s+/g, '').slice(0, 2).toUpperCase(), cls: 'unknown' }; - } - function renderAgentAvatar(avatar) { - var cls = 'agent-avatar ' + esc(avatar.cls || 'unknown') + (avatar.image ? ' has-image' : ''); - if (avatar.image) { - return '' + esc(avatar.label || 'Agent') + ' ' + esc(t('agent.avatarAlt')) + ''; - } - return '' + esc(avatar.label || '?') + ''; - } - function isDemoSession(s) { - var raw = String((s && (s.id || s.project || s.cwd || s.title || s.summary)) || '').toLowerCase(); - return raw.indexOf('demo_') >= 0 || raw.indexOf('/tmp/agentmemory-demo') >= 0 || raw.indexOf('agentmemory-demo') >= 0; - } - function sessionId(s) { - return s && s.id !== undefined && s.id !== null ? String(s.id) : ''; - } - function isValidSession(s) { - return !!sessionId(s); - } - function shortSessionId(s, n) { - var id = sessionId(s); - return id ? id.slice(0, n || 8) : ''; - } - function sessionDisplayName(s) { - var project = s && s.project ? String(s.project).split('/').pop() : ''; - if (project) return project; - return shortSessionId(s, 8) || t('dash.unnamedSession'); - } - function sessionLabel(s) { - var id = shortSessionId(s, 8); - var name = sessionDisplayName(s); - return id ? name + ' (' + id + ')' : name + ' (' + t('ses.noRecordId') + ')'; - } - function sessionProjectKey(s) { - var raw = (s && (s.cwd || s.project)) ? String(s.cwd || s.project) : t('project.uncategorized'); - if (raw.indexOf('/tmp/agentmemory-demo') >= 0) return t('project.demo'); - if (raw.indexOf('/Users/szn') === 0) { - var parts = raw.split('/').filter(Boolean); - if (parts.length >= 3) return parts.slice(0, 3).join('/'); - } - return raw.replace(/\/$/, '') || t('project.uncategorized'); - } - function projectDisplayName(key) { - var k = String(key || t('project.uncategorized')); - if (k === 'all') return t('project.all'); - if (k === 'browser') return t('project.browser'); - if (k === '未归类' || k === 'Uncategorized') return t('project.uncategorized'); - if (k === '演示数据' || k === 'Demo data') return t('project.demo'); - var parts = k.split('/').filter(Boolean); - return parts.length ? parts[parts.length - 1] : k; - } - function arrayFromCsv(value) { - if (Array.isArray(value)) return value.map(function(v) { return String(v || '').trim(); }).filter(Boolean); - return String(value || '').split(/[,,\s]+/).map(function(v) { return v.trim(); }).filter(Boolean); - } - function reviewPayload(item) { - return (item && item.payload && typeof item.payload === 'object') ? item.payload : {}; - } - function reviewProject(item) { - var payload = reviewPayload(item); - if (payload.projectScope === 'all' || payload.project === 'all') return 'all'; - if (payload.project) return payload.project; - if (item && item.kind === 'action') return ''; - return (item && item.page && item.page.host) || 'browser'; - } - function reviewTags(item) { - var payload = reviewPayload(item); - return arrayFromCsv(payload.tags).slice(0, 6); - } - function isMarkdownPlanText(text) { - var raw = String(text || ''); - var hasPlanHeading = /^#{1,3}\s+.*(?:计划|Plan)\s*$/im.test(raw); - var sectionMatches = raw.match(/^#{1,3}\s+(?:Summary|Key Changes|Test Plan|Assumptions|Public API|Implementation|执行步骤|验证命令)\b/img) || []; - var compact = raw.replace(/\s+/g, ' '); - var compactPlanSections = [ - /#{1,3}\s+Summary\b/i, - /#{1,3}\s+Key Changes\b/i, - /#{1,3}\s+Test Plan\b/i, - /#{1,3}\s+Assumptions\b/i, - /#{1,3}\s+Implementation\b/i, - /#{1,3}\s+执行步骤\b/i, - /#{1,3}\s+验证命令\b/i - ].filter(function(pattern) { return pattern.test(compact); }).length; - var compactPlan = /#{1,3}\s+[^#]{0,160}(?:计划|Plan)/i.test(compact) && - (compactPlanSections >= 2 || /#{1,3}\s+Summary\b/i.test(compact)); - return (hasPlanHeading && sectionMatches.length >= 2) || compactPlan; - } - function isReviewTextPolluted(text) { - var trimmed = String(text || '').trim(); - if (!trimmed) return false; - var lower = trimmed.toLowerCase(); - if (isJsonLikeText(trimmed)) return true; - if (isMarkdownPlanText(trimmed)) return true; - if (/please implement this plan/i.test(trimmed)) return true; - if (/"plan"\s*:/.test(trimmed) && /"status"\s*:/.test(trimmed) && /"step"\s*:/.test(trimmed)) return true; - if (/"command"\s*:|toolinput|tooloutput|function_id/.test(lower)) return true; - if (/"(?:cmd|workdir|yield_time_ms|max_output_tokens)"\s*:/.test(lower)) return true; - if (/\b(tooluseid|tooluse|call_[a-z0-9]+|chunk id|wall time|process exited)\b/i.test(trimmed)) return true; - if (/^(?:json|state|limit)\s+[\w.-]+/i.test(trimmed)) return true; - if (/\b(?:namewithowner|headrefname|baserefname|databaseid)\b/i.test(lower)) return true; - if (/^\s*(?:gh|git|npm|pnpm|yarn|python3?|node|curl)\s+[^\n]*(?:--json|--limit|--workdir|--max-output|--yield-time|status|show|list|run|test|install|build)\b/i.test(trimmed)) return true; - if (/审查结果\s*\[[Pp]\d+\]/.test(trimmed) && /(?:src|test)\/[^\s]+(?:\s*\(line\s+\d+\))?/.test(trimmed)) return true; - if (/^src\/[^\s]+/m.test(trimmed) && /\bnpm\s+(test|run|install|build)\b/i.test(trimmed)) return true; - if (/^\s*(npm|pnpm|yarn)\s+(test|run|install|build)\b/im.test(trimmed)) return true; - return false; - } - function isReviewItemDisplayable(item) { - if (!item) return false; - return !isReviewTextPolluted(item.title) && !isReviewTextPolluted(item.content); - } - function isActionReviewRenderable(item) { - if (!item) return false; - return item.kind === 'action' && isReviewItemDisplayable(item); - } - function actionTags(action) { - return Array.isArray(action && action.tags) ? action.tags.map(String) : []; - } - function isGeneratedAction(action) { - var tags = actionTags(action); - return (action && action.createdBy === 'todo-extract') || - tags.indexOf('todo-extracted') >= 0 || - tags.indexOf('action-candidate') >= 0 || - !!(action && action.metadata && action.metadata.todoExtraction); - } - function isActionRenderable(action) { - if (!action) return false; - if (!isGeneratedAction(action)) return true; - return !isReviewTextPolluted(action.title) && !isReviewTextPolluted(action.description); - } - function reviewActionPriority(item) { - var payload = reviewPayload(item); - var candidate = payload.actionCandidate && typeof payload.actionCandidate === 'object' ? payload.actionCandidate : {}; - var value = candidate.priority || payload.priority || item.priority || 5; - var parsed = parseInt(value, 10); - if (!Number.isFinite(parsed)) parsed = 5; - return Math.max(1, Math.min(10, parsed)); - } - function reviewSourceLabel(item) { - var payload = reviewPayload(item); - return payload.sourceLabel || payload.provider || (item && item.page && (item.page.typeLabel || item.page.host)) || '浏览器'; - } - function browserSessionObservations(item) { - var page = (item && item.page) || {}; - var conversation = (item && item.conversation) || {}; - var turns = Array.isArray(conversation.turns) ? conversation.turns : []; - var createdAt = (item && (item.createdAt || item.updatedAt)) || new Date().toISOString(); - var observations = []; - if (page.title || page.url) { - observations.push({ - id: (item.id || 'browser') + '_page', - sessionId: 'browser_' + (item.id || ''), - timestamp: createdAt, - type: 'web_fetch', - title: page.title || '浏览器页面', - subtitle: page.host || page.typeLabel || '浏览器', - narrative: [page.title, page.url].filter(Boolean).join('\n'), - facts: [], - concepts: ['browser'], - files: [], - importance: 0.5 - }); - } - turns.forEach(function(turn, index) { - var role = turn && turn.role === 'assistant' ? 'AI' : turn && turn.role === 'user' ? '用户' : '对话'; - var text = String((turn && turn.text) || '').trim(); - if (!text) return; - observations.push({ - id: (item.id || 'browser') + '_turn_' + index, - sessionId: 'browser_' + (item.id || ''), - timestamp: createdAt, - type: 'conversation', - title: role, - subtitle: page.title || page.host || '浏览器对话', - narrative: text, - facts: [], - concepts: ['browser', role], - files: [], - importance: role === '用户' ? 0.8 : 0.6 - }); - }); - if (item && item.content) { - observations.push({ - id: (item.id || 'browser') + '_review', - sessionId: 'browser_' + (item.id || ''), - timestamp: item.updatedAt || createdAt, - type: item.kind === 'lesson' ? 'decision' : 'discovery', - title: item.kind === 'lesson' ? '经验候选' : '同步会话', - subtitle: item.status === 'approved' ? '已保存' : item.status === 'dismissed' ? '已忽略' : '待审阅', - narrative: item.content, - facts: [], - concepts: ['browser', item.kind === 'lesson' ? 'lesson' : 'session'], - files: [], - importance: 0.7 - }); - } - return observations; - } - function browserReviewSessions(items) { - return (items || []).filter(function(item) { - var payload = reviewPayload(item); - return item && item.source === 'browser-extension' && !payload.browserSessionId && item.page && (item.page.type === 'ai-chat' || item.conversation || item.page.url); - }).map(function(item) { - var page = item.page || {}; - var payload = reviewPayload(item); - var provider = (item.conversation && item.conversation.provider) || payload.provider || page.host || '浏览器'; - var obs = browserSessionObservations(item); - return { - id: 'browser_' + item.id, - project: provider || '浏览器', - cwd: 'browser/' + (page.host || provider || 'web'), - source: 'browser-extension', - agentId: provider || '浏览器', - startedAt: item.createdAt || item.updatedAt || '', - updatedAt: item.updatedAt || item.createdAt || '', - endedAt: item.reviewedAt || item.updatedAt || item.createdAt || '', - status: item.status === 'pending' ? 'active' : 'completed', - observationCount: obs.length, - firstPrompt: page.title || item.title || '浏览器对话', - summary: item.content || '', - tags: ['browser', page.type || '', item.kind || ''].filter(Boolean), - embeddedObservations: obs, - browserPage: page - }; - }); - } - function groupSessionsByProject(items) { - var groups = {}; - (items || []).forEach(function(s) { - var key = sessionProjectKey(s); - if (!groups[key]) groups[key] = { key: key, name: projectDisplayName(key), sessions: [], count: 0, latest: '', observations: 0, sources: {}, hasMissingId: false }; - groups[key].sessions.push(s); - if (!sessionId(s)) groups[key].hasMissingId = true; - groups[key].count += 1; - groups[key].observations += s.observationCount || 0; - var rt = sessionRecordTime(s); - if (rt > groups[key].latest) groups[key].latest = rt; - var src = inferSessionSource(s).name || '未标记来源'; - groups[key].sources[src] = (groups[key].sources[src] || 0) + 1; - }); - return Object.keys(groups).map(function(k) { - groups[k].sessions.sort(function(a, b) { return (sessionRecordTime(b) || '').localeCompare(sessionRecordTime(a) || ''); }); - return groups[k]; - }).sort(function(a, b) { return (b.latest || '').localeCompare(a.latest || ''); }); - } - function sessionSourceKey(s) { - var source = inferSessionSource(s); - return source.kind + ':' + source.name; - } - function sessionSourceGroups(items) { - var groups = { - all: { key: 'all', name: '全部会话', count: 0, latest: '', observations: 0, kind: 'all' } - }; - (items || []).forEach(function(s) { - var source = inferSessionSource(s); - var key = sessionSourceKey(s); - if (!groups[key]) groups[key] = { key: key, name: source.name || '未标记来源', count: 0, latest: '', observations: 0, kind: source.kind || 'source' }; - groups.all.count += 1; - groups[key].count += 1; - groups.all.observations += s.observationCount || 0; - groups[key].observations += s.observationCount || 0; - var rt = sessionRecordTime(s); - if (rt > groups.all.latest) groups.all.latest = rt; - if (rt > groups[key].latest) groups[key].latest = rt; - }); - return Object.keys(groups).map(function(k) { return groups[k]; }).sort(function(a, b) { - if (a.key === 'all') return -1; - if (b.key === 'all') return 1; - return (b.latest || '').localeCompare(a.latest || ''); - }); - } - function sessionRecordTime(s) { - return (s && (s.updatedAt || s.endedAt || s.startedAt)) || ''; - } - function sessionStatusLabel(status) { - var map = { active: '历史会话', completed: '历史会话', archived: '历史会话', failed: '历史会话' }; - return map[status] || '历史会话'; - } - function sessionSourceSummary(s, obsCount) { - var source = String((s && s.source) || '').indexOf('browser') >= 0 ? '浏览器对话' : String((s && s.source) || '').indexOf('local-') === 0 ? '本地 Agent 会话' : '工作台会话'; - return [ - { label: '来源', value: source }, - { label: '项目', value: projectDisplayName(sessionProjectKey(s)) }, - { label: '记录', value: (obsCount || 0) + ' 条' }, - { label: '时间', value: shortDateTime(sessionRecordTime(s)) || '-' } - ]; - } - function statusIconMarkup(status, label) { - var s = String(status || '').toLowerCase(); - var title = esc(label || sessionStatusLabel(s)); - var cls = s === 'completed' || s === 'done' || s === 'closed' ? 'done' - : s === 'active' || s === 'running' ? 'active' - : s === 'blocked' || s === 'failed' || s === 'cancelled' ? s - : 'pending'; - var icon = ''; - if (cls === 'active') icon = ''; - if (cls === 'pending') icon = ''; - if (cls === 'blocked') icon = ''; - if (cls === 'failed' || cls === 'cancelled') icon = ''; - return ''; - } - function translateLessonText(text) { - var t = String(text || '').trim(); - var rules = [ - [/^DO NOT respond to these messages or otherwise consider them in your response unless the user explicitly asks you to\.$/i, '除非用户明确要求,否则不要回应或采纳系统旁路消息。'], - [/^do not include obvious instructions like "Provide helpful error messages to users", "Write unit tests for all new utilit/i, '不要把显而易见的泛化建议写进经验,例如“提供友好的错误提示”“为所有工具写测试”。'], - [/^Don't include generic development practices\.$/i, '不要记录过于泛泛的开发常识。'], - [/^make sure to include the important parts\.$/i, '总结时要保留真正重要的部分,不要只留下空泛结论。'], - [/^Do not make up information such as "Common Development Tasks", "Tips for Development", "Support and Documentation"/i, '不要编造不存在的章节或功能说明。'], - [/^Avoid listing every component or file structure that can be easily discovered\.$/i, '避免罗列用户自己很容易查到的组件或文件结构。'] - ]; - for (var i = 0; i < rules.length; i++) { - if (rules[i][0].test(t)) return rules[i][1]; - } - return t; - } - function memoryCategory(m) { - var text = ((m && (m.title || m.content || '')) + ' ' + ((m && m.concepts) || []).join(' ')).toLowerCase(); - if (/身份|个人档案|profile|alias|name|自我|生日|出生|本科就读|常用语言|当前重点项目|\d{4}[-/年]\d{1,2}[-/月]\d{1,2}/.test(text)) return '身份档案'; - if (/偏好|preference|喜欢|沟通|协作|working style/.test(text)) return '偏好'; - if (/项目|buddyup|project|产品|创业|ucl|hci/.test(text)) return '项目与目标'; - if (/原则|判断|框架|原则|pattern|workflow|工作流/.test(text)) return '判断框架'; - if (/经历|教育|学校|history|experience/.test(text)) return '经历'; - return memoryTypeLabel(m && m.type); - } - function splitCommaList(text) { - return String(text || '') - .split(/[、,,]/) - .map(function(x) { return x.trim().replace(/[。;;]$/, ''); }) - .filter(Boolean); - } - function splitSentenceList(text) { - return String(text || '') - .split(/[。;;\n]/) - .map(function(x) { return x.trim(); }) - .filter(Boolean); - } - function splitIdentityProfile(content) { - var text = String(content || '').trim(); - var focusMatch = text.match(/当前重点项目包括[::]?\s*([^。]+)。?/); - var communicationMatch = text.match(/沟通偏好[::]\s*([^。]+)。?/); - var languageMatch = text.match(/常用语言包括[::]\s*([^。]+)。?/); - var educationMatch = text.match(/(本科就读于[^。]+。?)/); - var intro = text - .replace(/当前重点项目包括[::]?\s*[^。]+。?/g, '') - .replace(/沟通偏好[::]\s*[^。]+。?/g, '') - .replace(/常用语言包括[::]\s*[^。]+。?/g, '') - .replace(/本科就读于[^。]+。?/g, '') - .replace(/\s+/g, ' ') - .trim(); - return { - intro: intro || text, - education: splitSentenceList(educationMatch && educationMatch[1]), - focus: splitCommaList(focusMatch && focusMatch[1]), - communication: splitCommaList(communicationMatch && communicationMatch[1]), - language: splitCommaList(languageMatch && languageMatch[1]) - }; - } - function memoryStrength(m) { - var rawStrength = (m && m.strength) || 0; - var strength = Math.round(rawStrength <= 1 ? rawStrength * 100 : rawStrength * 10); - if (strength > 100) strength = 100; - if (strength < 0) strength = 0; - return strength; - } - function memorySourceKind(m) { - var concepts = (m && Array.isArray(m.concepts) ? m.concepts : []).join(' ').toLowerCase(); - var project = String((m && m.project) || '').toLowerCase(); - var text = String((m && (m.title || m.content)) || '').toLowerCase(); - if (project === 'browser' || concepts.indexOf('browser-context') >= 0 || text.indexOf('网页记忆线索') >= 0 || text.indexOf('浏览器候选记忆') >= 0) return 'browser'; - if (m && m.sessionIds && m.sessionIds.length) return 'session'; - return 'manual'; - } - function browserSourceFromMemory(m) { - var concepts = m && Array.isArray(m.concepts) ? m.concepts : []; - var source = ''; - concepts.forEach(function(c) { - var s = String(c || ''); - if (s.indexOf('browser-source:') === 0 && !source) source = s.slice('browser-source:'.length); - }); - if (source) return source; - var host = ''; - concepts.forEach(function(c) { - var s = String(c || ''); - if (s.indexOf('browser-host:') === 0 && !host) host = s.slice('browser-host:'.length); - }); - return host; - } - function browserSourceLabel(source) { - var labels = { chatgpt: 'ChatGPT', claude: 'Claude', gemini: 'Gemini', perplexity: 'Perplexity', grok: 'Grok', deepseek: 'DeepSeek', github: 'GitHub', feishu: '飞书', notion: 'Notion' }; - if (!source) return '浏览器'; - return labels[source] || source.replace(/^www\./, ''); - } - function memorySourceLabel(m) { - var kind = memorySourceKind(m); - if (kind === 'browser') return browserSourceLabel(browserSourceFromMemory(m)); - if (kind === 'session') return '会话'; - return '手动'; - } - function renderMemoryCard(card) { - var mem = card.memory || {}; - var cls = card.kind || 'default'; - var html = '
'; - html += '
'; - html += '
' + esc(card.title || '未命名记忆') + '
'; - html += '' + esc(card.label || memoryTypeLabel(mem.type)) + ''; - html += '
'; - if (card.items && card.items.length) { - html += '
    '; - card.items.forEach(function(item) { html += '
  • ' + esc(item) + '
  • '; }); - html += '
'; - } else { - html += '
' + esc(card.body || '暂无内容') + '
'; - } - if (mem.concepts && mem.concepts.length > 0) { - html += '
'; - mem.concepts.slice(0, 4).forEach(function(c) { html += '' + esc(c) + ''; }); - html += '
'; - } - html += ''; - html += '
'; - return html; - } - function memoryDisplayCards(m) { - var content = (m && (m.content || m.title)) || ''; - if (memoryCategory(m) === '身份档案') { - var data = splitIdentityProfile(content); - var cards = []; - if (data.intro) cards.push({ memory: m, kind: 'identity', label: '身份', title: '基本信息', body: data.intro }); - if (data.education && data.education.length) cards.push({ memory: m, kind: 'history', label: '经历', title: '教育经历', items: data.education }); - if (data.focus && data.focus.length) cards.push({ memory: m, kind: 'project', label: '项目', title: '当前重点', items: data.focus }); - if (data.communication && data.communication.length) cards.push({ memory: m, kind: 'preference', label: '偏好', title: '沟通偏好', items: data.communication }); - if (data.language && data.language.length) cards.push({ memory: m, kind: 'identity', label: '语言', title: '常用表达', items: data.language }); - return cards.length ? cards : [{ memory: m, kind: 'identity', label: memoryTypeLabel(m && m.type), title: (m && m.title) || '身份档案', body: content }]; - } - var title = ((m && m.title) || '').trim(); - var body = ((m && m.content) || '').trim(); - var sameTitle = title && body && body.indexOf(title) === 0; - return [{ - memory: m, - kind: 'default', - label: memoryTypeLabel(m && m.type), - title: sameTitle ? truncate(body, 72) : truncate(title || body, 72), - body: sameTitle ? body.slice(title.length).trim() || body : body - }]; - } - function renderIdentityProfileCard(mem) { - if (!mem) return ''; - var data = splitIdentityProfile(mem.content || mem.title || ''); - var headline = '未命名身份'; - var birth = ''; - var intro = data.intro || ''; - var nameMatch = intro.match(/^([^,。]+?)(?:,|。)/); - if (nameMatch) headline = nameMatch[1].trim(); - var birthMatch = intro.match(/(\d{4}[-/年]\d{1,2}[-/月]\d{1,2}\s*生?)/); - if (birthMatch) birth = birthMatch[1].replace(/年|月/g, '-').replace(/日/g, '').replace(/--/g, '-'); - var titleLine = intro.replace(headline, '').replace(/^,/, '').trim(); - if (birth) titleLine = titleLine.replace(birthMatch && birthMatch[0], '').replace(/^,/, '').trim(); - var html = '
'; - html += '
'; - html += '
'; - html += '
身份档案
'; - html += '
' + esc(headline) + '
'; - html += '
' + esc(titleLine || intro) + '
'; - html += '
'; - html += '
'; - if (birth) html += '生日 ' + esc(birth) + ''; - html += '' + esc(memoryTypeLabel(mem.type)) + ''; - html += ''; - html += '
'; - html += '
'; - var sections = [ - ['当前重点', data.focus], - ['沟通偏好', data.communication], - ['常用语言', data.language] - ]; - sections.forEach(function(section) { - var label = section[0]; - var list = section[1] || []; - html += '
'; - html += '
' + esc(label) + '
'; - if (list.length) { - html += '
'; - list.forEach(function(item) { html += '' + esc(item) + ''; }); - html += '
'; - } else { - html += '
暂无结构化内容
'; - } - html += '
'; - }); - html += '
'; - html += '
'; - return html; - } - function renderIdentityProfileGroup(cards) { - cards = cards || []; - if (!cards.length) return ''; - var primary = (cards[0] && cards[0].memory) || {}; - var titleCard = cards.find(function(c) { return c.title === '基本信息'; }) || cards[0]; - var headline = titleCard && titleCard.body ? titleCard.body.split(/[,。]/)[0] : '身份档案'; - var html = '
'; - html += '
'; - html += '
'; - html += '
' + esc(headline || '身份档案') + '
'; - if (titleCard && titleCard.body) { - html += '
' + esc(truncate(titleCard.body, 150)) + '
'; - } - html += '
'; - html += '
'; - html += ''; - html += ''; - html += '
'; - html += '
'; - cards.forEach(function(card) { - if (card === titleCard) return; - html += '
'; - html += '
'; - html += '
' + esc(card.title || '信息') + '
'; - html += '' + esc(card.label || '') + ''; - html += '
'; - if (card.items && card.items.length) { - html += '
    '; - card.items.forEach(function(item) { html += '
  • ' + esc(item) + '
  • '; }); - html += '
'; - } else { - html += '
' + esc(card.body || '暂无内容') + '
'; - } - html += '
'; - }); - html += '
'; - html += '
'; - return html; - } - function buildAgentMemoryPrompt(mem) { - var data = splitIdentityProfile((mem && (mem.content || mem.title)) || ''); - var lines = []; - lines.push('请根据这份用户记忆来优化协作方式:'); - if (data.intro) lines.push('- 用户画像:' + data.intro); - if (data.focus && data.focus.length) lines.push('- 当前重点:' + data.focus.join(';')); - if (data.communication && data.communication.length) lines.push('- 沟通偏好:' + data.communication.join(';')); - if (data.language && data.language.length) lines.push('- 常用表达/风格:' + data.language.join(';')); - if (data.education && data.education.length) lines.push('- 经历背景:' + data.education.join(';')); - lines.push('- 协作原则:少暴露用户难以理解的内部概念,把功能解释成清晰的产品流程;能用图标表达的小标签优先用图标;界面和建议都要服务真实使用,而不是只展示系统记录。'); - return lines.join('\n'); - } - function openAgentPrompt(id) { - var mem = (state.memories.items || []).find(function(m) { return m.id === id; }) || (state.memories.items || []).find(function(m) { return memoryCategory(m) === '身份档案'; }); - if (!mem) return; - var prompt = buildAgentMemoryPrompt(mem); - var modal = document.getElementById('modal'); - var overlay = document.getElementById('modal-overlay'); - modal.innerHTML = - '

协作提示

' + - '

这些记忆会自动进入 Agent 的理解范围;这里仅保留可复制的提示文本。

' + - '
' + - '' + - '
' + - '
' + - ''; - overlay.classList.add('open'); - setTimeout(function() { - var input = document.getElementById('agent-memory-prompt'); - if (input) input.focus(); - }, 0); - } - async function copyAgentPrompt() { - var text = (document.getElementById('agent-memory-prompt') || {}).value || ''; - var status = document.getElementById('agent-prompt-status'); - try { - if (navigator.clipboard && navigator.clipboard.writeText) { - await navigator.clipboard.writeText(text); - } else { - var ta = document.getElementById('agent-memory-prompt'); - if (ta) { ta.select(); document.execCommand('copy'); } - } - if (status) { - status.style.display = 'block'; - status.style.color = 'var(--green)'; - status.textContent = '已复制。'; - } - } catch (err) { - if (status) { - status.style.display = 'block'; - status.style.color = 'var(--red)'; - status.textContent = '复制失败,可以手动选中这段提示。'; - } - } - } - async function copyTextToClipboard(text) { - if (navigator.clipboard && navigator.clipboard.writeText) { - await navigator.clipboard.writeText(text || ''); - return; - } - var ta = document.createElement('textarea'); - ta.value = text || ''; - ta.style.position = 'fixed'; - ta.style.left = '-9999px'; - document.body.appendChild(ta); - ta.select(); - document.execCommand('copy'); - document.body.removeChild(ta); - } - function debounce(fn, ms) { - var t; - return function() { - var args = arguments, ctx = this; - clearTimeout(t); - t = setTimeout(function() { fn.apply(ctx, args); }, ms); - }; - } - - // IME_SAFE_SEARCH_V2 - function bindImeSafeSearch(input, ms, onSearch) { - var composing = false; - var justCommitted = false; - var run = debounce(function(value) { onSearch(value); }, ms); - input.addEventListener('compositionstart', function() { composing = true; }); - input.addEventListener('compositionend', function() { - composing = false; - justCommitted = true; - onSearch(input.value); - setTimeout(function() { justCommitted = false; }, 0); - }); - input.addEventListener('input', function(e) { - if (composing || e.isComposing) return; - if (justCommitted) return; - run(input.value); - }); - } - function captureSearchFocus(ids) { - var a = document.activeElement; - if (!a || ids.indexOf(a.id) < 0) return null; - return { id: a.id, start: a.selectionStart, end: a.selectionEnd }; - } - function restoreSearchFocus(focus) { - if (!focus) return; - var el = document.getElementById(focus.id); - if (!el) return; - el.focus(); - if (typeof el.setSelectionRange === 'function') { - try { el.setSelectionRange(focus.start, focus.end); } catch (e) {} - } - } - - async function api(path, opts) { - try { - var headers = Object.assign({ 'Cache-Control': 'no-cache' }, (opts && opts.headers) || {}); - var fetchOpts = Object.assign({}, opts || {}, { headers: headers }); - var urls = [REST + '/agentmemory/' + path, REST + '/' + path]; - for (var i = 0; i < urls.length; i++) { - var res = await fetch(urls[i], fetchOpts); - if (res.ok) return await res.json(); - } - console.warn('[viewer] API ' + (fetchOpts.method || 'GET') + ' ' + path + ' failed on all route variants'); - return null; - } catch (err) { - console.warn('[viewer] API error on ' + path + ':', err); - return null; - } - } - async function apiGet(path) { return api(path); } - async function apiPost(path, body) { - return api(path, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body || {}) - }); - } - async function apiDelete(path, body) { - return api(path, { - method: 'DELETE', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body || {}) - }); - } - async function apiPatch(path, body) { - return api(path, { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body || {}) - }); - } - async function loadLocalCodexSessions(limit) { - var count = limit || 120; - var result = await apiGet('local-agent-sessions?limit=' + encodeURIComponent(count)); - if (!result) result = await apiGet('local-codex-sessions?limit=' + encodeURIComponent(count)); - return (result && result.sessions) || []; - } - async function settledData(label, fallback, loader) { - try { - var data = await loader(); - return { label: label, data: data, failed: false }; - } catch (err) { - console.warn('[viewer] ' + label + ' failed:', err); - return { label: label, data: fallback, failed: true }; - } - } - function renderDataWarnings(warnings, retryAction) { - if (!warnings || warnings.length === 0) return ''; - var html = '
' + esc(warnings.join('、')) + ' 暂时没读到,已先展示可用数据。
'; - html += '
'; - return html; - } - function mergeSessions(primary, local) { - var seen = {}; - var out = []; - (primary || []).forEach(function(s) { - var id = sessionId(s); - if (!id) return; - seen[id] = true; - out.push(s); - }); - (local || []).forEach(function(s) { - var id = sessionId(s).replace(/^codex_local_/, 'codex_'); - if (!sessionId(s)) return; - if (!seen[id] && !seen[sessionId(s)]) out.push(s); - }); - return out; - } - function normalizeTab(tab) { var normalized = String(tab || '').replace(/^#/, '').toLowerCase(); // 专家模式下,被隐藏视图放行;否则按 TAB_REDIRECTS 折回主三栏。 diff --git a/src/viewer/parts/manifest.json b/src/viewer/parts/manifest.json index 8d246294..10dfdd1e 100644 --- a/src/viewer/parts/manifest.json +++ b/src/viewer/parts/manifest.json @@ -12,8 +12,12 @@ "style/90-responsive-avatars.css", "20-body.html", "app/00-prelude.js", + "app/05-i18n.js", + "app/08-state-expert.js", "app/10-html-safe.js", "app/20-format-utils.js", + "app/40-data-helpers.js", + "app/45-transport.js", "app/90-rest.js", "40-tail.html" ] diff --git a/test/viewer-assembly.test.ts b/test/viewer-assembly.test.ts index 8cadb36d..3a03d20c 100644 --- a/test/viewer-assembly.test.ts +++ b/test/viewer-assembly.test.ts @@ -102,4 +102,18 @@ describe("viewer JS fragmentation (PLAN-007 STEP-03)", () => { }); }); +describe("viewer i18n + transport extraction (PLAN-007 STEP-04)", () => { + it("isolates the i18n catalog + t() into app/05-i18n.js", () => { + const i18n = readFileSync(join(PARTS_DIR, "app", "05-i18n.js"), "utf-8"); + expect(i18n).toContain("I18N_MESSAGES"); + expect(i18n).toMatch(/function t\(/); + }); + + it("isolates the REST transport (api) into app/45-transport.js", () => { + const transport = readFileSync(join(PARTS_DIR, "app", "45-transport.js"), "utf-8"); + expect(transport).toMatch(/function api\(/); + }); +}); + + From 68cbb994f125f10855a2182b03fff2ca0fe98a07 Mon Sep 17 00:00:00 2001 From: yujiezhang-ops Date: Tue, 23 Jun 2026 21:29:55 +0800 Subject: [PATCH 5/5] refactor(viewer): split render layer into per-view app fragments (PLAN-007 STEP-05) app/90-rest.js (5318 lines) is split file-order into 10 fragments: 50-routing, 52-dashboard, 54-legacy-graph-memories-timeline-obs-activity, 56-sessions, 58-legacy-lessons, 60-actions-todo (the To-Do workbench), 62-legacy-crystals- audit-profile, 64-ws-flags-events, 68-legacy-replay, 90-bootstrap. Core views (dashboard/sessions/actions-todo) are now in their own files and the legacy agentmemory views are isolated for later deletion (PLAN-006). Byte-identity preserved (regenerated index.html unchanged vs the STEP-04 commit; drift guard green). This step is the SAFE, byte-preserving half of the original STEP-05 plan. The data-action if-ladder -> dispatch-table conversion is a behavior-changing refactor with no byte-identity safety net and needs DOM-level parity testing (no jsdom in the repo); it is deferred to a gated follow-up (STEP-05b) rather than shipped unverified. No assembled-output / CSP / endpoint change. Signed-off-by: yujiezhang-ops --- src/viewer/parts/app/50-routing.js | 70 + src/viewer/parts/app/52-dashboard.js | 483 ++ ...cy-graph-memories-timeline-obs-activity.js | 1537 +++++ src/viewer/parts/app/56-sessions.js | 635 ++ src/viewer/parts/app/58-legacy-lessons.js | 157 + src/viewer/parts/app/60-actions-todo.js | 1150 ++++ .../app/62-legacy-crystals-audit-profile.js | 336 ++ src/viewer/parts/app/64-ws-flags-events.js | 699 +++ src/viewer/parts/app/68-legacy-replay.js | 202 + src/viewer/parts/app/90-bootstrap.js | 49 + src/viewer/parts/app/90-rest.js | 5318 ----------------- src/viewer/parts/manifest.json | 11 +- test/viewer-assembly.test.ts | 15 + 13 files changed, 5343 insertions(+), 5319 deletions(-) create mode 100644 src/viewer/parts/app/50-routing.js create mode 100644 src/viewer/parts/app/52-dashboard.js create mode 100644 src/viewer/parts/app/54-legacy-graph-memories-timeline-obs-activity.js create mode 100644 src/viewer/parts/app/56-sessions.js create mode 100644 src/viewer/parts/app/58-legacy-lessons.js create mode 100644 src/viewer/parts/app/60-actions-todo.js create mode 100644 src/viewer/parts/app/62-legacy-crystals-audit-profile.js create mode 100644 src/viewer/parts/app/64-ws-flags-events.js create mode 100644 src/viewer/parts/app/68-legacy-replay.js create mode 100644 src/viewer/parts/app/90-bootstrap.js delete mode 100644 src/viewer/parts/app/90-rest.js diff --git a/src/viewer/parts/app/50-routing.js b/src/viewer/parts/app/50-routing.js new file mode 100644 index 00000000..05f17b73 --- /dev/null +++ b/src/viewer/parts/app/50-routing.js @@ -0,0 +1,70 @@ + function normalizeTab(tab) { + var normalized = String(tab || '').replace(/^#/, '').toLowerCase(); + // 专家模式下,被隐藏视图放行;否则按 TAB_REDIRECTS 折回主三栏。 + if (expertModeEnabled() && EXPERT_TABS.some(function(t) { return t.id === normalized; })) { + return normalized; + } + if (TAB_REDIRECTS[normalized]) return TAB_REDIRECTS[normalized]; + return TAB_IDS.indexOf(normalized) >= 0 ? normalized : 'dashboard'; + } + + function tabFromRoute() { + try { + return normalizeTab(decodeURIComponent(window.location.hash.slice(1))); + } catch (_) { + return 'dashboard'; + } + } + + function updateTabRoute(tab, replace) { + var target = '#' + tab; + if (window.location.hash === target) return; + if (replace) { + history.replaceState(null, '', target); + } else { + history.pushState(null, '', target); + } + } + + function switchTab(tab, opts) { + opts = opts || {}; + tab = normalizeTab(tab); + if (state.activeTab === 'replay' && tab !== 'replay' && typeof stopReplayTimer === 'function') { + stopReplayTimer(); + } + if (!opts.skipRoute) { + updateTabRoute(tab, !!opts.replaceRoute); + } + state.activeTab = tab; + document.querySelectorAll('.tab-bar button').forEach(function(b) { + var isActive = b.dataset.tab === tab; + b.classList.toggle('active', isActive); + if (isActive) { + b.setAttribute('aria-current', 'page'); + } else { + b.removeAttribute('aria-current'); + } + }); + document.querySelectorAll('.view').forEach(function(v) { + v.classList.toggle('active', v.id === 'view-' + tab); + }); + if (state.flagsConfig) renderFlagBanners(state.flagsConfig); + loadTab(tab); + } + + async function loadTab(tab) { + switch(tab) { + case 'dashboard': if (!state.dashboard.loaded) await loadDashboard(); break; + case 'graph': if (!state.graph.loaded) await loadGraph(); break; + case 'memories': if (!state.memories.loaded) await loadMemories(); break; + case 'timeline': if (!state.timeline.loaded) await loadTimeline(); break; + case 'sessions': if (!state.sessions.loaded) await loadSessions(); break; + case 'lessons': if (!state.lessons.loaded) await loadLessons(); break; + case 'actions': if (!state.actions.loaded) await loadActions(); break; + case 'audit': if (!state.audit.loaded) await loadAudit(); break; + case 'activity': if (!state.activity.loaded) await loadActivity(); break; + case 'profile': if (!state.profile.loaded) await loadProfile(); break; + case 'replay': if (!state.replay.loaded) await loadReplay(); break; + } + } + diff --git a/src/viewer/parts/app/52-dashboard.js b/src/viewer/parts/app/52-dashboard.js new file mode 100644 index 00000000..dfb10cbc --- /dev/null +++ b/src/viewer/parts/app/52-dashboard.js @@ -0,0 +1,483 @@ + async function loadDashboard() { + var el = document.getElementById('view-dashboard'); + el.innerHTML = '
加载总览中...
'; + var showDebug = new URLSearchParams(window.location.search).get('debug') === '1'; + try { + var baseResults = await Promise.all([ + apiGet('health'), + apiGet('sessions'), + apiGet('actions'), + apiGet('review?status=pending&kind=action&limit=200'), + apiGet('inbox?status=awaiting&limit=50') + ]); + state.dashboard.health = baseResults[0]; + state.dashboard.sessions = ((baseResults[1] && baseResults[1].sessions) || []).filter(function(s) { return !isDemoSession(s); }); + state.dashboard.actions = ((baseResults[2] && baseResults[2].actions) || []).filter(isActionRenderable); + state.dashboard.actionReviews = ((baseResults[3] && baseResults[3].items) || []).filter(isActionReviewRenderable); + state.dashboard.inboxAwaiting = (baseResults[4] && baseResults[4].items) || []; + if (showDebug) { + var debugResults = await Promise.all([ + apiGet('memories?latest=true&limit=500'), + apiGet('graph/stats'), + apiGet('audit?limit=5'), + apiGet('semantic'), + apiGet('procedural'), + apiGet('relations'), + apiGet('lessons'), + apiGet('delivery-status') + ]); + state.dashboard.memories = (debugResults[0] && debugResults[0].memories) || []; + state.dashboard.graphStats = debugResults[1]; + state.dashboard.recentAudit = (debugResults[2] && debugResults[2].entries) || []; + state.dashboard.semantic = (debugResults[3] && debugResults[3].facts) || (debugResults[3] && debugResults[3].semantic) || []; + state.dashboard.procedural = (debugResults[4] && debugResults[4].procedures) || (debugResults[4] && debugResults[4].procedural) || []; + state.dashboard.relations = (debugResults[5] && debugResults[5].relations) || []; + state.dashboard.lessons = (debugResults[6] && debugResults[6].lessons) || []; + state.dashboard.delivery = debugResults[7] || null; + } + state.dashboard.loaded = true; + renderDashboard(); + } catch (err) { + // Without this catch, any uncaught error in the await Promise.all + // or the renderDashboard call leaves the dashboard stuck on + // "Loading dashboard..." forever with no indication to the user + // (#323). apiGet() already swallows network/HTTP errors and + // returns null, but renderDashboard can still throw on shape + // surprises (CSP-blocked event handler binding, undefined fields). + // Surface the error in the panel + log full detail to console. + var msg = (err && err.message) ? err.message : String(err); + console.error('[viewer] loadDashboard failed:', err); + el.innerHTML = + '
' + + 'Dashboard failed to load: ' + msg + + '

' + + 'Check the browser console for the full error. If you see CSP ' + + 'violations, please open an issue with the AI Todo version ' + + '(top-right of the viewer) and the violation text.' + + '
'; + } + } + + function deliveryBadgeClass(value) { + if (value === 'ready') return 'badge-green'; + if (value === 'mostly-ready') return 'badge-yellow'; + return 'badge-muted'; + } + function deliveryStatusLabel(value) { + if (value === 'ready') return '已就绪'; + if (value === 'mostly-ready') return '基本可试'; + return '等待准备'; + } + function publicReleaseLabel(value) { + if (value === 'ready') return '可发布'; + return '等待证据'; + } + function renderDeliveryStatusCard(delivery, options) { + var showDebug = options && options.debug; + if (!delivery) return ''; + if (delivery.available === false) { + return '
浏览器入口
浏览器入口还没有打包。现在可以先在本机加载 browser-extension/ 文件夹使用。
'; + } + var real = delivery.realSiteValidation || {}; + var passed = Number(real.passedCount || 0); + var required = Number(real.requiredCount || 4); + var pct = required > 0 ? Math.round((passed / required) * 100) : 0; + var external = delivery.externalTesting || 'not-ready'; + var publicState = delivery.publicRelease || 'not-ready'; + var notPassed = Array.isArray(real.notPassed) ? real.notPassed : []; + var sites = Array.isArray(real.sites) ? real.sites : []; + var zip = delivery.artifacts && delivery.artifacts.extensionZip ? delivery.artifacts.extensionZip : null; + var zipLabel = zip && zip.exists ? '插件包已生成' : '插件包未生成'; + var version = delivery.extension && delivery.extension.version ? delivery.extension.version : '0.1.0'; + var next = notPassed.length ? notPassed.join('、') : '真实站点已齐'; + var html = '
'; + html += '
'; + html += '
浏览器会话同步
打开 AI 会话页后自动同步,后续整理都在工作台完成。
'; + html += '' + esc(zipLabel) + ''; + html += '
'; + html += '
'; + html += '
' + esc(version) + ' · 支持 ChatGPT / Claude / Gemini 等 AI 会话页 · 真实页面证据 ' + passed + '/' + required + '
'; + html += ''; + html += '
'; + if (!showDebug) { + html += '
插件界面只显示同步状态,不在浏览器里做内容挑选。
'; + return html; + } + html += '
'; + html += '
插件自检
' + esc(deliveryStatusLabel(delivery.localDemo)) + '
排错用预览页
'; + html += '
真实 AI 证据
' + passed + '/' + required + '
公开发布必需
'; + html += '
公开发布
' + esc(publicReleaseLabel(publicState)) + '
需真实站点通过
'; + html += '
'; + html += '
真实 AI 页面通过证据必须包含 turnCount > 0 和会话区域 selector;插件只同步网页 AI 会话,不读取选中文本或输入草稿。
'; + html += '
AI 页面
' + pct + '%
'; + if (sites.length) { + html += '
'; + sites.forEach(function(site) { + var status = site.status || 'missing'; + var badge = status === 'passed' ? 'badge-green' : status === 'needs-fix' ? 'badge-yellow' : 'badge-muted'; + var label = status === 'passed' ? '已通过' : status === 'needs-fix' ? '待修复' : '待验收'; + var missing = Array.isArray(site.missing) && site.missing.length ? site.missing.join('、') : (status === 'passed' ? '证据完整' : '缺真实页面证据'); + html += '
'; + html += '
' + esc(site.product || 'AI') + '
' + label + '
'; + html += '
' + esc(missing) + '
'; + if (site.file) html += '
' + esc(site.file) + '
'; + html += '
'; + }); + html += '
'; + } + html += '
'; + html += '
下一步验收:' + esc(next) + '
'; + html += ''; + html += '
'; + return html; + } + function renderDashboard() { + var el = document.getElementById('view-dashboard'); + var d = state.dashboard; + var showDebug = new URLSearchParams(window.location.search).get('debug') === '1'; + var h = d.health || {}; + var snap = h.health || {}; + var healthStatus = h.status || 'unknown'; + var dotClass = healthStatus === 'healthy' ? 'healthy' : healthStatus === 'degraded' ? 'degraded' : healthStatus === 'critical' ? 'critical' : ''; + var activeSessions = d.sessions.filter(function(s) { return s.status === 'active'; }).length; + var gs = d.graphStats || {}; + var nodeCount = gs.totalNodes !== undefined ? gs.totalNodes : (gs.nodes !== undefined ? gs.nodes : (gs.nodeCount || 0)); + var edgeCount = gs.totalEdges !== undefined ? gs.totalEdges : (gs.edges !== undefined ? gs.edges : (gs.edgeCount || 0)); + var fMetrics = h.functionMetrics || []; + var cb = h.circuitBreaker || null; + var workers = snap.workers || []; + var actions = (d.actions || []).filter(isActionRenderable); + var actionReviews = (d.actionReviews || []).filter(isActionReviewRenderable); + var awaitingReplies = (d.inboxAwaiting || []).filter(function(i) { return i && i.kind === 'question'; }); + var followUps = actions.filter(function(a) { return a.status === 'pending' || a.status === 'blocked'; }); + + var html = ''; + + // First-run hero: empty dashboard = guided next step + if (d.sessions.length === 0) { + html += '
' + + '
' + t('dash.firstRun.kicker') + '
' + + '
' + t('dash.firstRun.title') + '
' + + '
' + t('dash.firstRun.body') + '
' + + '
node dist/cli.mjs demo
' + + '' + + '
'; + } + html += '
'; + var latestSessionTime = d.sessions.length ? shortDateTime(sessionRecordTime(d.sessions.slice().sort(function(a, b) { return (sessionRecordTime(b) || '').localeCompare(sessionRecordTime(a) || ''); })[0])) : t('dash.noRecord'); + html += '
' + t('dash.stat.sessions') + '
' + d.sessions.length + '
' + t('dash.stat.recent') + ' ' + esc(latestSessionTime) + '
'; + html += '
' + t('dash.stat.todos') + '
' + actions.length + '
'; + html += '
' + t('act.metric.waiting') + '
' + awaitingReplies.length + '
'; + html += '
' + t('act.metric.review') + '
' + actionReviews.length + '
'; + html += '
' + t('act.metric.followUp') + '
' + followUps.length + '
'; + var lessonCount = (d.lessons || []).length; + if (showDebug) { + html += '
' + t('dash.stat.memories') + '
' + d.memories.length + '
' + t('dash.stat.latestVersion') + '
'; + html += '
' + t('dash.stat.lessons') + '
' + lessonCount + '
' + t('dash.stat.lessonsSub') + '
'; + } + if (showDebug && (nodeCount > 0 || edgeCount > 0)) { + html += '
' + t('dash.stat.graphNodes') + '
' + nodeCount + '
' + t('dash.stat.edges') + ' ' + edgeCount + '
'; + } + if (showDebug) { + html += '
' + t('dash.stat.status') + '
' + esc(healthStatus) + '
'; + html += '
' + esc(snap.connectionState || 'unknown') + '
'; + } + if (showDebug) { + var totalCalls = fMetrics.reduce(function(a, m) { return a + (m.totalCalls || 0); }, 0); + html += '
' + t('dash.stat.toolCalls') + '
' + totalCalls + '
' + t('dash.stat.tracking') + ' ' + fMetrics.length + ' ' + t('dash.stat.functions') + '
'; + } + if (showDebug && cb) { + var cbClass = cb.state === 'closed' ? 'cb-closed' : cb.state === 'open' ? 'cb-open' : 'cb-half-open'; + html += '
' + t('dash.stat.circuitBreaker') + '
' + esc(cb.state) + '
'; + html += '
' + t('dash.stat.failed') + ' ' + (cb.failures || 0) + ' ' + t('dash.stat.times') + '
'; + } + var totalObs = d.sessions.reduce(function(a, s) { return a + (s.observationCount || 0); }, 0); + var tokenBudget = parseInt(new URLSearchParams(window.location.search).get('tokenBudget') || '2000', 10) || 2000; + var estFull = totalObs * 80; + var estInjected = d.sessions.length * tokenBudget; + var savings = estFull > 0 ? Math.round((1 - estInjected / Math.max(estFull, 1)) * 100) : 0; + if (savings < 0) savings = 0; + html += '
'; + + html += renderDeliveryStatusCard(d.delivery, { debug: showDebug }); + + if (showDebug && (snap.memory || snap.cpu)) { + html += '
' + t('dash.systemResources') + '
'; + if (snap.memory) { + var heapUsed = Math.round((snap.memory.heapUsed || 0) / 1024 / 1024); + var heapTotal = Math.round((snap.memory.heapTotal || 0) / 1024 / 1024); + var rss = Math.round((snap.memory.rss || 0) / 1024 / 1024); + var heapPct = heapTotal > 0 ? Math.round((heapUsed / heapTotal) * 100) : 0; + var rssAboveFloor = rss >= 512; + var heapColor = (heapPct > 80 && rssAboveFloor) ? 'var(--red)' : (heapPct > 60 && rssAboveFloor) ? 'var(--yellow)' : 'var(--green)'; + html += '
Heap
' + heapUsed + ' / ' + heapTotal + ' MB
'; + html += '
RSS
' + rss + ' MB
'; + if (snap.memory.external) { + var ext = Math.round(snap.memory.external / 1024 / 1024); + html += '
External
' + ext + ' MB
'; + } + } + if (snap.cpu) { + var cpuPct = snap.cpu.percent || 0; + var cpuColor = cpuPct > 80 ? 'var(--red)' : cpuPct > 50 ? 'var(--yellow)' : 'var(--green)'; + html += '
CPU
' + cpuPct.toFixed(1) + '%
'; + } + if (snap.eventLoopLagMs !== undefined) { + var lag = snap.eventLoopLagMs; + var lagColor = lag > 100 ? 'var(--red)' : lag > 20 ? 'var(--yellow)' : 'var(--green)'; + html += '
Event Loop
' + lag.toFixed(1) + ' ms
'; + } + if (snap.uptimeSeconds) { + var mins = Math.floor(snap.uptimeSeconds / 60); + var hrs = Math.floor(mins / 60); + var upStr = hrs > 0 ? hrs + 'h ' + (mins % 60) + 'm' : mins + 'm'; + html += '
UPTIME: ' + upStr + '
'; + } + html += '
'; + } + + if (showDebug && snap.alerts && snap.alerts.length > 0) { + html += '
' + t('dash.alerts') + ' (' + snap.alerts.length + ')
'; + snap.alerts.forEach(function(al) { + html += '
' + esc(al) + '
'; + }); + html += '
'; + } + + if (showDebug && snap.notes && snap.notes.length > 0) { + html += '
' + t('dash.notes') + ' (' + snap.notes.length + ')
'; + snap.notes.forEach(function(n) { + html += '
' + esc(n) + '
'; + }); + html += '
'; + } + + html += '
' + t('dash.recentSessions') + '
'; + if (d.sessions.length === 0) { + html += '

' + t('dash.emptySessions') + '

'; + } else { + var recentGroups = groupSessionsByProject(d.sessions).slice(0, 5); + html += '
'; + recentGroups.forEach(function(g) { + var latest = g.sessions && g.sessions[0]; + var latestId = sessionId(latest); + var cardName = g.hasMissingId ? t('dash.unnamedSession') : g.name; + html += ''; + }); + html += '
'; + } + html += '
'; + + if (showDebug && fMetrics.length > 0) { + var sorted = fMetrics.slice().sort(function(a, b) { return (b.totalCalls || 0) - (a.totalCalls || 0); }); + html += '
' + t('dash.functionMetrics') + '
'; + html += ''; + sorted.forEach(function(m) { + var successRate = m.totalCalls > 0 ? Math.round((m.successCount / m.totalCalls) * 100) : 0; + var rateColor = successRate >= 95 ? 'var(--green)' : successRate >= 80 ? 'var(--yellow)' : 'var(--red)'; + var latencyColor = m.avgLatencyMs > 1000 ? 'var(--red)' : m.avgLatencyMs > 200 ? 'var(--yellow)' : 'var(--green)'; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + }); + html += '
FunctionCallsSuccessFailAvg LatencyQuality
' + esc(m.functionId) + '' + m.totalCalls + '' + m.successCount + ' (' + successRate + '%)' + m.failureCount + '' + Math.round(m.avgLatencyMs) + ' ms' + (m.avgQualityScore > 0 ? m.avgQualityScore.toFixed(2) : '-') + '
'; + } + + if (showDebug && workers.length > 0) { + html += '
' + t('dash.workers') + '
'; + workers.forEach(function(w) { + var statusClass = w.status === 'running' ? 'running' : w.status === 'starting' ? 'starting' : 'stopped'; + html += '
'; + html += '' + esc(w.name) + ''; + html += '' + esc(w.status) + ''; + html += '' + esc(w.id) + '
'; + }); + html += '
'; + } + + if (showDebug && cb && cb.state !== 'closed') { + html += '
' + t('dash.circuitBreakerDetail') + '
'; + html += '
State
' + esc(cb.state) + '
'; + html += '
Failures
' + (cb.failures || 0) + '
'; + if (cb.lastFailureAt) html += '
Last Failure
' + esc(formatTime(cb.lastFailureAt)) + '
'; + if (cb.openedAt) html += '
Opened At
' + esc(formatTime(cb.openedAt)) + '
'; + html += '
'; + } + + if (showDebug) { + var semFacts = d.semantic || []; + var procItems = d.procedural || []; + var relItems = d.relations || []; + + html += '
'; + html += '
'; + + html += '
' + t('dash.semanticMemory') + '
'; + if (semFacts.length === 0) { + html += '
' + t('dash.emptySemantic') + '
'; + } else { + semFacts.slice(0, 5).forEach(function(f) { + var conf = typeof f.confidence === 'number' ? Math.round(f.confidence * 100) : null; + var str = typeof f.strength === 'number' ? Math.round(f.strength * 100) : null; + var barColor = (str || 0) > 70 ? 'var(--green)' : (str || 0) > 40 ? 'var(--yellow)' : 'var(--red)'; + html += '
'; + html += '' + esc(f.fact || f.content || f.title || 'Fact') + ''; + html += ''; + if (str !== null) html += ''; + if (conf !== null) html += '' + conf + '%'; + html += '
'; + }); + } + html += '
'; + + html += '
' + t('dash.proceduralMemory') + '
'; + if (procItems.length === 0) { + html += '
' + t('dash.emptyProcedural') + '
'; + } else { + procItems.slice(0, 5).forEach(function(p) { + html += '
'; + html += '
' + esc(p.name || p.title || 'Procedure') + '
'; + if (p.trigger || p.triggerCondition) html += '
Trigger: ' + esc(p.trigger || p.triggerCondition) + '
'; + if (p.frequency) html += '
Freq: ' + p.frequency + '
'; + if (p.steps && p.steps.length > 0) { + html += '
    '; + p.steps.slice(0, 4).forEach(function(s) { html += '
  1. ' + esc(typeof s === 'string' ? s : s.description || s.action || JSON.stringify(s)) + '
  2. '; }); + if (p.steps.length > 4) html += '
  3. + ' + (p.steps.length - 4) + ' more...
  4. '; + html += '
'; + } + html += '
'; + }); + } + html += '
'; + + html += '
'; + + html += '
' + t('dash.consolidationStatus') + '
'; + html += '
Semantic facts' + semFacts.length + '
'; + html += '
Procedures' + procItems.length + '
'; + html += '
Relations' + relItems.length + '
'; + html += '
'; + + if (relItems.length > 0) { + html += '
' + t('dash.memoryRelations') + '
'; + relItems.slice(0, 8).forEach(function(r) { + var relType = r.type || r.relationType || 'related'; + var badgeClass = relType === 'supersedes' ? 'badge-red' : relType === 'extends' ? 'badge-green' : relType === 'contradicts' ? 'badge-yellow' : 'badge-muted'; + html += '
'; + html += '' + esc(truncate(r.sourceId || r.fromId || '', 8)) + ''; + html += '' + esc(relType) + ''; + html += '' + esc(truncate(r.targetId || r.toId || '', 8)) + ''; + html += '
'; + }); + html += '
'; + } + } + + el.innerHTML = html; + } + + var dashboardTimer = null; + function refreshDashboard() { + state.dashboard.loaded = false; + loadDashboard(); + } + function isViewingSessionDetail() { + return state.activeTab === 'sessions' && !!state.sessions.selectedId; + } + function shouldDeferSessionsRefresh(reason) { + return isViewingSessionDetail() && ['timer', 'poll', 'ws', 'visibility'].indexOf(reason || '') >= 0; + } + function sessionStaleNoticeMarkup() { + if (!state.sessions.stale || !state.sessions.selectedId) return ''; + return '
有新记录,当前详情暂未自动刷新。
'; + } + function renderSessionsStaleNotice() { + var host = document.getElementById('sessions-stale-notice'); + if (host) host.innerHTML = sessionStaleNoticeMarkup(); + } + function markSessionsStale() { + state.sessions.stale = true; + if (state.activeTab === 'sessions') renderSessionsStaleNotice(); + } + function markActionsStale(message) { + state.actions.stale = true; + if (message) state.actions.extractMessage = message; + if (state.activeTab === 'actions' && !actionsScrolledAway()) renderActions(); + } + function refreshActiveTab(reason) { + reason = reason || 'manual'; + switch (state.activeTab) { + case 'dashboard': state.dashboard.loaded = false; loadDashboard(); break; + case 'memories': state.memories.loaded = false; loadMemories(); break; + case 'sessions': + if (shouldDeferSessionsRefresh(reason)) { + markSessionsStale(); + } else { + state.sessions.loaded = false; + loadSessions({ showLoading: reason === 'manual', reason: reason }); + } + break; + case 'lessons': state.lessons.loaded = false; loadLessons(); break; + case 'actions': + if (reason === 'manual') { + state.actions.loaded = false; + loadActions({ generate: false }); + } else { + markActionsStale('有新会话记录,待办暂未自动刷新。'); + } + break; + case 'activity': state.activity.loaded = false; loadActivity(); break; + } + } + function markMemoryViewsStale() { + state.dashboard.loaded = false; + state.memories.loaded = false; + state.sessions.loaded = false; + state.lessons.loaded = false; + state.actions.loaded = false; + state.activity.loaded = false; + } + function saveDashboardRefreshPrefs() { + try { + localStorage.setItem('viewer_dashboard_refresh_enabled', state.dashboardRefresh.enabled ? '1' : '0'); + localStorage.setItem('viewer_dashboard_refresh_interval', String(state.dashboardRefresh.intervalMs)); + } catch {} + } + function loadDashboardRefreshPrefs() { + try { + var enabled = localStorage.getItem('viewer_dashboard_refresh_enabled'); + var interval = parseInt(localStorage.getItem('viewer_dashboard_refresh_interval') || '', 10); + if (enabled === '0') state.dashboardRefresh.enabled = false; + if (!Number.isNaN(interval) && interval >= 10000 && interval <= 120000) state.dashboardRefresh.intervalMs = interval; + } catch {} + } + function startDashboardAutoRefresh() { + if (dashboardTimer) clearInterval(dashboardTimer); + if (!state.dashboardRefresh.enabled) return; + dashboardTimer = setInterval(function() { + if (pollTimer) return; + if (state.activeTab === 'dashboard') refreshActiveTab('timer'); + }, state.dashboardRefresh.intervalMs || 30000); + } + + var graphSim = { nodes: [], edges: [], running: false, canvas: null, ctx: null, raf: null, panX: 0, panY: 0, zoom: 1, dragNode: null, mouseX: 0, mouseY: 0, tickCount: 0, quietTicks: 0 }; + function wakeGraphSim() { + graphSim.quietTicks = 0; + if (graphSim.running && !graphSim.raf) { + graphSim.raf = requestAnimationFrame(runSimulation); + } + } + diff --git a/src/viewer/parts/app/54-legacy-graph-memories-timeline-obs-activity.js b/src/viewer/parts/app/54-legacy-graph-memories-timeline-obs-activity.js new file mode 100644 index 00000000..07c598d4 --- /dev/null +++ b/src/viewer/parts/app/54-legacy-graph-memories-timeline-obs-activity.js @@ -0,0 +1,1537 @@ + async function loadGraph() { + var el = document.getElementById('view-graph'); + el.innerHTML = '
检查关系图数据中...
'; + + var results = await Promise.all([ + apiPost('graph/query', {}), + apiGet('graph/stats') + ]); + var queryResult = results[0] || { nodes: [], edges: [] }; + state.graph.nodes = queryResult.nodes || []; + state.graph.edges = queryResult.edges || []; + state.graph.stats = results[1] || {}; + + if (state.graph.nodes.length === 0) { + state.graph.loaded = true; + el.innerHTML = + '
' + + '
🕸
' + + '
关系图暂时隐藏
' + + '
当前没有可展示的节点和连线,所以侧边栏不再显示这个入口。等系统真的提取出关系后,再把关系图作为可用功能放回来。
' + + '
需要关系图时:配置 LLM key,并开启 GRAPH_EXTRACTION_ENABLED=true 后重启。
' + + '
'; + return; + } + + el.innerHTML = '
'; + state.graph.loaded = true; + var types = {}; + state.graph.nodes.forEach(function(n) { types[n.type] = true; }); + state.graph.filters = types; + + renderGraphSidebar(); + initGraph(); + } + + var NODE_SHAPES = { + file: 'rect', function: 'circle', concept: 'circle', error: 'diamond', + decision: 'diamond', pattern: 'circle', library: 'hexagon', person: 'circle' + }; + var graphSearchTerm = ''; + + function renderGraphSidebar() { + var sb = document.getElementById('graph-sidebar'); + if (!sb) return; + var gs = state.graph.stats || {}; + var nodeCount = gs.totalNodes !== undefined ? gs.totalNodes : (gs.nodes !== undefined ? gs.nodes : (gs.nodeCount || state.graph.nodes.length)); + var edgeCount = gs.totalEdges !== undefined ? gs.totalEdges : (gs.edges !== undefined ? gs.edges : (gs.edgeCount || state.graph.edges.length)); + + var html = ''; + + html += '

图谱统计

'; + html += '
'; + html += '
' + nodeCount + '
Nodes
'; + html += '
'; + html += '
' + edgeCount + '
Edges
'; + html += '
'; + + html += '

按类型筛选

'; + Object.keys(state.graph.filters).forEach(function(type) { + var color = NODE_COLORS[type] || '#666666'; + html += ''; + }); + + html += '

图例

'; + var shapeLabels = { rect: '▭', circle: '●', diamond: '◆', hexagon: '⬢' }; + var shownShapes = {}; + Object.keys(NODE_COLORS).forEach(function(type) { + var shape = NODE_SHAPES[type] || 'circle'; + var color = NODE_COLORS[type]; + var key = type; + if (shownShapes[key]) return; + shownShapes[key] = true; + html += '
' + (shapeLabels[shape] || '●') + '' + esc(type) + '
'; + }); + html += '
'; + + html += ''; + html += '
'; + var __focus = captureSearchFocus(['graph-search']); + sb.innerHTML = html; + + sb.querySelectorAll('input[type="checkbox"]').forEach(function(cb) { + cb.addEventListener('change', function() { + state.graph.filters[this.dataset.type] = this.checked; + renderGraph(); + }); + }); + + var searchInput = document.getElementById('graph-search'); + if (searchInput) { + bindImeSafeSearch(searchInput, 200, function(v){ graphSearchTerm = v.toLowerCase(); renderGraph(); }); + } + restoreSearchFocus(__focus); + } + + function initGraph() { + var canvas = document.getElementById('graph-canvas'); + if (!canvas) return; + graphSim.canvas = canvas; + graphSim.ctx = canvas.getContext('2d'); + + function resize() { + var r = canvas.parentElement.getBoundingClientRect(); + canvas.width = r.width * window.devicePixelRatio; + canvas.height = r.height * window.devicePixelRatio; + canvas.style.width = r.width + 'px'; + canvas.style.height = r.height + 'px'; + graphSim.ctx.setTransform(window.devicePixelRatio, 0, 0, window.devicePixelRatio, 0, 0); + } + resize(); + window.addEventListener('resize', resize); + + var cw = canvas.width / window.devicePixelRatio; + var ch = canvas.height / window.devicePixelRatio; + graphSim.panX = cw / 2; + graphSim.panY = ch / 2; + + var edgeMap = {}; + state.graph.edges.forEach(function(e) { + edgeMap[e.sourceNodeId] = (edgeMap[e.sourceNodeId] || 0) + 1; + edgeMap[e.targetNodeId] = (edgeMap[e.targetNodeId] || 0) + 1; + }); + + graphSim.nodes = state.graph.nodes.map(function(n, i) { + var angle = (2 * Math.PI * i) / Math.max(state.graph.nodes.length, 1); + var radius = Math.min(cw, ch) * 0.3; + var deg = edgeMap[n.id] || 0; + return { + id: n.id, type: n.type, name: n.name, properties: n.properties, + x: Math.cos(angle) * radius + (Math.random() - 0.5) * 50, + y: Math.sin(angle) * radius + (Math.random() - 0.5) * 50, + vx: 0, vy: 0, + r: Math.max(8, Math.min(22, 8 + deg * 2.5)) + }; + }); + graphSim.edges = state.graph.edges.slice(); + graphSim.running = true; + graphSim.dragNode = null; + + setupGraphInteraction(canvas); + runSimulation(); + } + + function setupGraphInteraction(canvas) { + var isPanning = false; + var lastMX = 0, lastMY = 0; + + function canvasCoords(e) { + var rect = canvas.getBoundingClientRect(); + return { + x: (e.clientX - rect.left - graphSim.panX) / graphSim.zoom, + y: (e.clientY - rect.top - graphSim.panY) / graphSim.zoom + }; + } + function findNode(cx, cy) { + for (var i = graphSim.nodes.length - 1; i >= 0; i--) { + var n = graphSim.nodes[i]; + if (!state.graph.filters[n.type]) continue; + var dx = n.x - cx, dy = n.y - cy; + if (dx * dx + dy * dy < n.r * n.r + 25) return n; + } + return null; + } + + canvas.addEventListener('mousedown', function(e) { + var c = canvasCoords(e); + var node = findNode(c.x, c.y); + if (node) { + graphSim.dragNode = node; + } else { + isPanning = true; + } + lastMX = e.clientX; + lastMY = e.clientY; + // wake the simulation if it parked itself after settling + wakeGraphSim(); + }); + canvas.addEventListener('mousemove', function(e) { + var dx = e.clientX - lastMX; + var dy = e.clientY - lastMY; + if (graphSim.dragNode) { + graphSim.dragNode.x += dx / graphSim.zoom; + graphSim.dragNode.y += dy / graphSim.zoom; + graphSim.dragNode.vx = 0; + graphSim.dragNode.vy = 0; + } else if (isPanning) { + graphSim.panX += dx; + graphSim.panY += dy; + } + lastMX = e.clientX; + lastMY = e.clientY; + graphSim.mouseX = e.clientX; + graphSim.mouseY = e.clientY; + + var c = canvasCoords(e); + var hoverNode = findNode(c.x, c.y); + var tooltip = document.getElementById('graph-tooltip'); + if (tooltip) { + if (hoverNode && !graphSim.dragNode && !isPanning) { + var conns = graphSim.edges.filter(function(ed) { return ed.sourceNodeId === hoverNode.id || ed.targetNodeId === hoverNode.id; }).length; + var ttHtml = '
' + esc(hoverNode.name) + '
'; + ttHtml += '
' + esc(hoverNode.type) + '
'; + if (hoverNode.properties) { + var propKeys = Object.keys(hoverNode.properties).slice(0, 3); + propKeys.forEach(function(k) { + ttHtml += '
' + esc(k) + ': ' + esc(truncate(String(hoverNode.properties[k]), 30)) + '
'; + }); + } + ttHtml += '
' + conns + ' connection' + (conns !== 1 ? 's' : '') + '
'; + tooltip.innerHTML = ttHtml; + var rect = canvas.getBoundingClientRect(); + tooltip.style.left = (e.clientX - rect.left + 12) + 'px'; + tooltip.style.top = (e.clientY - rect.top + 12) + 'px'; + tooltip.classList.add('visible'); + canvas.style.cursor = 'pointer'; + } else { + tooltip.classList.remove('visible'); + canvas.style.cursor = graphSim.dragNode || isPanning ? 'grabbing' : 'grab'; + } + } + }); + canvas.addEventListener('mouseup', function(e) { + if (graphSim.dragNode && !isPanning) { + selectGraphNode(graphSim.dragNode); + } + graphSim.dragNode = null; + isPanning = false; + }); + canvas.addEventListener('wheel', function(e) { + e.preventDefault(); + var factor = e.deltaY > 0 ? 0.9 : 1.1; + graphSim.zoom = Math.max(0.1, Math.min(5, graphSim.zoom * factor)); + // zoom is visually meaningless if the rAF loop is parked — + // wake the simulation so the next frame redraws at the new scale. + wakeGraphSim(); + }, { passive: false }); + canvas.addEventListener('dblclick', function(e) { + var c = canvasCoords(e); + var node = findNode(c.x, c.y); + if (node) { + selectGraphNode(node); + expandNode(node.id); + } + }); + } + + window.zoomGraph = function(dir) { + var factor = dir > 0 ? 1.25 : 0.8; + graphSim.zoom = Math.max(0.1, Math.min(5, graphSim.zoom * factor)); + wakeGraphSim(); + }; + window.recenterGraph = function() { + graphSim.zoom = 1; + if (graphSim.canvas) { + var cw = graphSim.canvas.width / window.devicePixelRatio; + var ch = graphSim.canvas.height / window.devicePixelRatio; + graphSim.panX = cw / 2; + graphSim.panY = ch / 2; + } + wakeGraphSim(); + }; + + function selectGraphNode(simNode) { + state.graph.selectedNode = simNode; + var panel = document.getElementById('selected-node-panel'); + if (!panel) return; + var color = NODE_COLORS[simNode.type] || '#666666'; + var html = '
'; + html += '

' + esc(simNode.name) + '

'; + html += '
Type: ' + esc(simNode.type) + '
'; + if (simNode.properties) { + Object.keys(simNode.properties).forEach(function(k) { + html += '
' + esc(k) + ': ' + esc(truncate(simNode.properties[k], 50)) + '
'; + }); + } + var conns = graphSim.edges.filter(function(e) { return e.sourceNodeId === simNode.id || e.targetNodeId === simNode.id; }).length; + html += '
Connections: ' + conns + '
'; + html += ''; + html += '
'; + panel.innerHTML = html; + } + + async function expandNode(nodeId) { + var result = await apiPost('graph/query', { startNodeId: nodeId, maxDepth: 1 }); + if (!result) return; + var existingIds = {}; + graphSim.nodes.forEach(function(n) { existingIds[n.id] = true; }); + var parentNode = graphSim.nodes.find(function(n) { return n.id === nodeId; }); + var px = parentNode ? parentNode.x : 0; + var py = parentNode ? parentNode.y : 0; + + (result.nodes || []).forEach(function(n) { + if (!existingIds[n.id]) { + state.graph.nodes.push(n); + if (!state.graph.filters.hasOwnProperty(n.type)) state.graph.filters[n.type] = true; + var angle = Math.random() * Math.PI * 2; + graphSim.nodes.push({ + id: n.id, type: n.type, name: n.name, properties: n.properties, + x: px + Math.cos(angle) * 80, + y: py + Math.sin(angle) * 80, + vx: 0, vy: 0, r: 8 + }); + } + }); + + var existingEdges = {}; + graphSim.edges.forEach(function(e) { existingEdges[e.id] = true; }); + (result.edges || []).forEach(function(e) { + if (!existingEdges[e.id]) { + state.graph.edges.push(e); + graphSim.edges.push(e); + } + }); + renderGraphSidebar(); + } + + function runSimulation() { + if (!graphSim.running) return; + var nodes = graphSim.nodes; + var edges = graphSim.edges; + var nodeCount = nodes.length; + graphSim.tickCount = (graphSim.tickCount || 0) + 1; + // dense graphs (>1000 nodes) used to oscillate forever + // because the per-node force pile-up exceeded what 0.9 damping + // could bleed off each tick. Tick-decay tightens damping over + // time so the layout actually settles; a per-node velocity cap + // prevents any single node from being launched off-screen by an + // accumulated kick before damping catches up. + var coolBoost = Math.min(0.4, graphSim.tickCount / 1500); + var damping = 0.9 - coolBoost; + var repulsion = nodeCount > 1000 ? 3000 : nodeCount > 100 ? 2000 : nodeCount > 50 ? 1200 : 800; + var attraction = nodeCount > 100 ? 0.002 : 0.005; + var centerGravity = nodeCount > 1000 ? 0.012 : nodeCount > 100 ? 0.005 : 0.01; + var velocityCap = nodeCount > 1000 ? 6 : nodeCount > 200 ? 12 : 24; + + var nodeMap = {}; + nodes.forEach(function(n) { nodeMap[n.id] = n; }); + + for (var i = 0; i < nodes.length; i++) { + if (graphSim.dragNode === nodes[i]) continue; + var n = nodes[i]; + var fx = 0, fy = 0; + for (var j = 0; j < nodes.length; j++) { + if (i === j) continue; + var dx = n.x - nodes[j].x; + var dy = n.y - nodes[j].y; + var dist = Math.sqrt(dx * dx + dy * dy) || 1; + var force = repulsion / (dist * dist); + fx += (dx / dist) * force; + fy += (dy / dist) * force; + } + fx -= n.x * centerGravity; + fy -= n.y * centerGravity; + var nvx = (n.vx + fx) * damping; + var nvy = (n.vy + fy) * damping; + // Velocity cap (#563): keep any single node from being launched + // off-screen by a one-tick force spike. + if (nvx > velocityCap) nvx = velocityCap; else if (nvx < -velocityCap) nvx = -velocityCap; + if (nvy > velocityCap) nvy = velocityCap; else if (nvy < -velocityCap) nvy = -velocityCap; + n.vx = nvx; + n.vy = nvy; + } + + edges.forEach(function(e) { + var s = nodeMap[e.sourceNodeId]; + var t = nodeMap[e.targetNodeId]; + if (!s || !t) return; + var dx = t.x - s.x; + var dy = t.y - s.y; + var dist = Math.sqrt(dx * dx + dy * dy) || 1; + var f = (dist - 100) * attraction; + var fx = (dx / dist) * f; + var fy = (dy / dist) * f; + if (graphSim.dragNode !== s) { s.vx += fx; s.vy += fy; } + if (graphSim.dragNode !== t) { t.vx -= fx; t.vy -= fy; } + }); + + var totalKineticEnergy = 0; + nodes.forEach(function(n) { + if (graphSim.dragNode === n) return; + n.x += n.vx; + n.y += n.vy; + totalKineticEnergy += n.vx * n.vx + n.vy * n.vy; + }); + + // park the simulation when the layout is quiet to save CPU. + // Pick up again when a drag/interaction wakes the loop. + var rmsVelocity = nodes.length > 0 ? Math.sqrt(totalKineticEnergy / nodes.length) : 0; + if (rmsVelocity < 0.05 && graphSim.tickCount > 60 && !graphSim.dragNode) { + graphSim.quietTicks = (graphSim.quietTicks || 0) + 1; + } else { + graphSim.quietTicks = 0; + } + + renderGraph(); + if (graphSim.quietTicks > 30) { + graphSim.raf = null; + return; + } + graphSim.raf = requestAnimationFrame(runSimulation); + } + + async function rebuildGraph() { + var sb = document.getElementById('graph-sidebar'); + if (sb) sb.innerHTML = '

关系图

正在根据观察重建关系图...

'; + await apiPost('graph/build', {}); + state.graph.loaded = false; + loadGraph(); + } + + function drawNodeShape(ctx, x, y, r, type) { + var shape = NODE_SHAPES[type] || 'circle'; + switch(shape) { + case 'rect': + ctx.beginPath(); + ctx.rect(x - r, y - r * 0.75, r * 2, r * 1.5); + break; + case 'diamond': + ctx.beginPath(); + ctx.moveTo(x, y - r); + ctx.lineTo(x + r, y); + ctx.lineTo(x, y + r); + ctx.lineTo(x - r, y); + ctx.closePath(); + break; + case 'hexagon': + ctx.beginPath(); + for (var i = 0; i < 6; i++) { + var angle = (Math.PI / 3) * i - Math.PI / 2; + var hx = x + r * Math.cos(angle); + var hy = y + r * Math.sin(angle); + if (i === 0) ctx.moveTo(hx, hy); else ctx.lineTo(hx, hy); + } + ctx.closePath(); + break; + default: + ctx.beginPath(); + ctx.arc(x, y, r, 0, Math.PI * 2); + break; + } + } + + function renderGraph() { + var ctx = graphSim.ctx; + var canvas = graphSim.canvas; + if (!ctx || !canvas) return; + var w = canvas.width / window.devicePixelRatio; + var h = canvas.height / window.devicePixelRatio; + + ctx.clearRect(0, 0, w, h); + + // --- Canvas grid background --- + var gridSize = 24; + ctx.save(); + ctx.strokeStyle = isDarkMode() ? 'rgba(255,255,255,0.04)' : 'rgba(0,0,0,0.04)'; + ctx.lineWidth = 0.5; + for (var gx = 0; gx < w; gx += gridSize) { + ctx.beginPath(); ctx.moveTo(gx, 0); ctx.lineTo(gx, h); ctx.stroke(); + } + for (var gy = 0; gy < h; gy += gridSize) { + ctx.beginPath(); ctx.moveTo(0, gy); ctx.lineTo(w, gy); ctx.stroke(); + } + ctx.restore(); + + ctx.save(); + ctx.translate(graphSim.panX, graphSim.panY); + ctx.scale(graphSim.zoom, graphSim.zoom); + + var nodeMap = {}; + graphSim.nodes.forEach(function(n) { nodeMap[n.id] = n; }); + + var searchActive = graphSearchTerm.length > 0; + var totalVisible = graphSim.nodes.filter(function(n) { return state.graph.filters[n.type]; }).length; + var isDense = totalVisible > 40; + var labelZoomThreshold = isDense ? 1.5 : 0.5; + var edgeLabelZoomThreshold = isDense ? 2.5 : 1.2; + var selectedId = state.graph.selectedNode ? state.graph.selectedNode.id : null; + + // --- Hover node detection for focus effect --- + var hoverNodeId = null; + if (!graphSim.dragNode && graphSim.canvas) { + var rect = graphSim.canvas.getBoundingClientRect(); + var hx = (graphSim.mouseX - rect.left - graphSim.panX) / graphSim.zoom; + var hy = (graphSim.mouseY - rect.top - graphSim.panY) / graphSim.zoom; + for (var hi = graphSim.nodes.length - 1; hi >= 0; hi--) { + var hn = graphSim.nodes[hi]; + if (!state.graph.filters[hn.type]) continue; + var hdx = hn.x - hx, hdy = hn.y - hy; + if (hdx * hdx + hdy * hdy < hn.r * hn.r + 25) { hoverNodeId = hn.id; break; } + } + } + var focusNodeId = selectedId || hoverNodeId; + + // --- Draw edges --- + graphSim.edges.forEach(function(e) { + var s = nodeMap[e.sourceNodeId]; + var t = nodeMap[e.targetNodeId]; + if (!s || !t) return; + if (!state.graph.filters[s.type] || !state.graph.filters[t.type]) return; + + var edgeDimmed = searchActive && !(s.name.toLowerCase().includes(graphSearchTerm) || t.name.toLowerCase().includes(graphSearchTerm)); + var isConnectedToFocus = focusNodeId && (e.sourceNodeId === focusNodeId || e.targetNodeId === focusNodeId); + var isFocusActive = focusNodeId !== null; + var weight = typeof e.weight === 'number' ? e.weight : 0.5; + var lineWidth = isConnectedToFocus ? 2 + weight * 2 : 1 + weight * 1.5; + + var dx = t.x - s.x; + var dy = t.y - s.y; + var len = Math.sqrt(dx * dx + dy * dy) || 1; + var curveOffset = isDense ? 12 : 18; + var offsetX = -dy / len * curveOffset; + var offsetY = dx / len * curveOffset; + var cpx = (s.x + t.x) / 2 + offsetX; + var cpy = (s.y + t.y) / 2 + offsetY; + + // Colored edges based on source node type + var edgeColor = NODE_COLORS[s.type] || '#666666'; + var edgeAlpha; + if (edgeDimmed) { + edgeAlpha = 0.06; + } else if (isFocusActive && isConnectedToFocus) { + edgeAlpha = 0.65; + } else if (isFocusActive && !isConnectedToFocus) { + edgeAlpha = 0.06; + } else { + edgeAlpha = isDense ? 0.15 : 0.25; + } + + ctx.beginPath(); + ctx.moveTo(s.x, s.y); + ctx.quadraticCurveTo(cpx, cpy, t.x, t.y); + // Parse hex color to rgba + var r = parseInt(edgeColor.slice(1,3), 16); + var g = parseInt(edgeColor.slice(3,5), 16); + var b = parseInt(edgeColor.slice(5,7), 16); + ctx.strokeStyle = 'rgba(' + r + ',' + g + ',' + b + ',' + edgeAlpha + ')'; + ctx.lineWidth = lineWidth; + ctx.stroke(); + + if (!isDense || isConnectedToFocus) { + var arrowAngle = Math.atan2(t.y - cpy, t.x - cpx); + var arrowLen = 5 + lineWidth; + ctx.beginPath(); + ctx.moveTo(t.x - t.r * Math.cos(arrowAngle), t.y - t.r * Math.sin(arrowAngle)); + ctx.lineTo(t.x - (t.r + arrowLen) * Math.cos(arrowAngle - 0.3), t.y - (t.r + arrowLen) * Math.sin(arrowAngle - 0.3)); + ctx.lineTo(t.x - (t.r + arrowLen) * Math.cos(arrowAngle + 0.3), t.y - (t.r + arrowLen) * Math.sin(arrowAngle + 0.3)); + ctx.closePath(); + ctx.fillStyle = 'rgba(' + r + ',' + g + ',' + b + ',' + (edgeDimmed ? 0.06 : isConnectedToFocus ? 0.6 : 0.2) + ')'; + ctx.fill(); + } + + var showEdgeLabel = e.type && !edgeDimmed && (isConnectedToFocus ? graphSim.zoom > 0.6 : graphSim.zoom > edgeLabelZoomThreshold); + if (showEdgeLabel) { + var zoomInv = 1 / graphSim.zoom; + ctx.save(); + ctx.fillStyle = isDarkMode() ? (isConnectedToFocus ? 'rgba(238,238,238,0.9)' : 'rgba(180,180,180,0.7)') : (isConnectedToFocus ? 'rgba(17,17,17,0.85)' : 'rgba(80,80,80,0.7)'); + ctx.font = (isConnectedToFocus ? '600 ' : '500 ') + (11 * zoomInv).toFixed(1) + 'px Inter, sans-serif'; + ctx.textAlign = 'center'; + ctx.fillText(e.type, cpx, cpy - (4 * zoomInv)); + ctx.restore(); + } + }); + + // --- Draw nodes --- + graphSim.nodes.forEach(function(n) { + if (!state.graph.filters[n.type]) return; + var color = NODE_COLORS[n.type] || '#666666'; + var isSelected = selectedId === n.id; + var isHovered = hoverNodeId === n.id; + var matchesSearch = !searchActive || n.name.toLowerCase().includes(graphSearchTerm); + var isFocusFaded = focusNodeId && n.id !== focusNodeId && !graphSim.edges.some(function(ed) { + return (ed.sourceNodeId === focusNodeId && ed.targetNodeId === n.id) || + (ed.targetNodeId === focusNodeId && ed.sourceNodeId === n.id); + }); + + var nodeAlpha = !matchesSearch ? 0.12 : (isFocusFaded ? 0.2 : 1); + + ctx.save(); + ctx.globalAlpha = nodeAlpha; + + // Glow effect + if (matchesSearch && !isFocusFaded && (isSelected || isHovered || !searchActive)) { + ctx.shadowColor = color; + ctx.shadowBlur = isSelected ? 20 : isHovered ? 16 : (isDense ? 4 : 8); + } + + // Gradient fill + drawNodeShape(ctx, n.x, n.y, n.r, n.type); + var grad = ctx.createRadialGradient(n.x - n.r * 0.3, n.y - n.r * 0.3, 0, n.x, n.y, n.r * 1.2); + var cr = parseInt(color.slice(1,3), 16); + var cg = parseInt(color.slice(3,5), 16); + var cb = parseInt(color.slice(5,7), 16); + grad.addColorStop(0, 'rgba(' + Math.min(255, cr + 60) + ',' + Math.min(255, cg + 60) + ',' + Math.min(255, cb + 60) + ',0.95)'); + grad.addColorStop(1, color); + ctx.fillStyle = grad; + ctx.fill(); + ctx.restore(); + + // Selected ring + if (isSelected) { + ctx.save(); + drawNodeShape(ctx, n.x, n.y, n.r + 3, n.type); + ctx.strokeStyle = color; + ctx.lineWidth = 3; + ctx.shadowColor = color; + ctx.shadowBlur = 12; + ctx.stroke(); + ctx.restore(); + } else if (isHovered) { + drawNodeShape(ctx, n.x, n.y, n.r + 2, n.type); + ctx.strokeStyle = color; + ctx.lineWidth = 2; + ctx.stroke(); + } else if (searchActive && matchesSearch) { + drawNodeShape(ctx, n.x, n.y, n.r, n.type); + ctx.strokeStyle = '#CC0000'; + ctx.lineWidth = 2; + ctx.stroke(); + } + + var showLabel = matchesSearch && !isFocusFaded && ( + isSelected || isHovered || + (searchActive && matchesSearch) || + (!isDense && graphSim.zoom > labelZoomThreshold) || + (isDense && graphSim.zoom > labelZoomThreshold && n.r > 10) + ); + if (showLabel) { + var zoomInv = 1 / graphSim.zoom; + ctx.save(); + ctx.font = (isSelected || isHovered ? '600 ' : '500 ') + (13 * zoomInv).toFixed(1) + 'px Inter, sans-serif'; + ctx.textAlign = 'center'; + + var label = truncate(n.name, 18); + var textW = ctx.measureText(label).width; + var labelW = textW + (16 * zoomInv); + var labelH = 20 * zoomInv; + var labelY = n.y + n.r + (8 * zoomInv); // Top of the background pill + + ctx.fillStyle = isDarkMode() ? 'rgba(30,30,35,0.92)' : 'rgba(255,255,255,0.92)'; + ctx.beginPath(); + ctx.roundRect ? ctx.roundRect(n.x - labelW / 2, labelY, labelW, labelH, 4 * zoomInv) : ctx.rect(n.x - labelW / 2, labelY, labelW, labelH); + ctx.fill(); + + ctx.strokeStyle = isDarkMode() ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.08)'; + ctx.lineWidth = 1 * zoomInv; + ctx.stroke(); + + ctx.fillStyle = isDarkMode() ? (isSelected || isHovered ? '#eeeeee' : '#bbbbbb') : (isSelected || isHovered ? '#111111' : '#444444'); + // Vertically center text in the pill box + ctx.fillText(label, n.x, labelY + (14 * zoomInv)); + ctx.restore(); + } + }); + + ctx.restore(); + + if (graphSim.nodes.length === 0) { + ctx.fillStyle = '#999999'; + ctx.font = '14px Lora, Georgia, serif'; + ctx.textAlign = 'center'; + ctx.fillText('暂无关系图数据', w / 2, h / 2 - 16); + ctx.font = '12px Inter, sans-serif'; + ctx.fillText('Set GRAPH_EXTRACTION_ENABLED=true to enable knowledge graph extraction.', w / 2, h / 2 + 8); + } + } + + async function loadMemories() { + var el = document.getElementById('view-memories'); + el.innerHTML = '
加载记忆中...
'; + // cap at 2000 so the viewer remains responsive on large + // corpora. Older endpoints returned the full unbounded list which + // hit the iii invocation timeout and the UI fell through to 0. + var result = await apiGet('memories?latest=true&limit=2000'); + var items = (result && result.memories) || []; + // Newest first — server returns KV-insertion order. + items.sort(function(a, b) { + var ac = (a && a.createdAt) || (a && a.updatedAt) || ''; + var bc = (b && b.createdAt) || (b && b.updatedAt) || ''; + return bc.localeCompare(ac); + }); + state.memories.items = items; + state.memories.reviewItems = []; + state.memories.total = (result && typeof result.total === 'number') ? result.total : items.length; + state.memories.loaded = true; + renderMemories(); + } + + function renderMemories() { + var el = document.getElementById('view-memories'); + var items = state.memories.items; + var search = state.memories.search.toLowerCase(); + var typeFilter = state.memories.typeFilter; + var sourceFilter = state.memories.sourceFilter || ''; + + var filtered = items.filter(function(m) { + if (typeFilter && m.type !== typeFilter) return false; + if (sourceFilter) { + if (sourceFilter.indexOf('browser:') === 0) { + if (memorySourceKind(m) !== 'browser') return false; + if (browserSourceFromMemory(m) !== sourceFilter.slice('browser:'.length)) return false; + } else if (memorySourceKind(m) !== sourceFilter) { + return false; + } + } + const normalizedSearch = (search || '') + .normalize("NFKC") + .toLowerCase(); + + const normalizedTitle = (m.title || '') + .normalize("NFKC") + .toLowerCase(); + + const normalizedContent = (m.content || '') + .normalize("NFKC") + .toLowerCase(); + + if ( + search && + !normalizedTitle.includes(normalizedSearch) && + !normalizedContent.includes(normalizedSearch) + ) { + return false; + } + + return true; + }); + + var types = {}; + items.forEach(function(m) { types[m.type] = true; }); + var typeOptions = Object.keys(types).sort(); + var displayGroups = {}; + var displayCount = 0; + filtered.forEach(function(m) { + var cat = memoryCategory(m); + var cards = memoryDisplayCards(m); + if (!displayGroups[cat]) displayGroups[cat] = []; + cards.forEach(function(card) { + displayGroups[cat].push(card); + displayCount += 1; + }); + }); + + var html = ''; + + html += '
'; + html += ''; + html += '
'; + html += ''; + html += ''; + html += '
'; + html += ''; + html += '
'; + + html += '
'; + html += '
自动整理中
新的聊天记录会作为记忆线索;你可以在这里审阅、编辑或删除。
'; + html += '
'; + + if (filtered.length === 0) { + html += '
' + + '
' + + '
还没有记忆
' + + '
当 Agent 记录事实、偏好、项目和工作方式后,会在这里按主题归档。
' + + '
'; + } else { + var order = ['身份档案', '偏好', '项目与目标', '判断框架', '经历']; + Object.keys(displayGroups).forEach(function(k) { if (order.indexOf(k) < 0) order.push(k); }); + html += '
'; + order.forEach(function(cat) { + var cards = displayGroups[cat]; + if (!cards || !cards.length) return; + html += '
'; + html += '
'; + html += '
' + esc(cat) + '
'; + if (cat !== '身份档案') html += '' + cards.length + ' 张卡片'; + html += '
'; + if (cat === '身份档案') { + html += renderIdentityProfileGroup(cards); + } else { + html += '
'; + cards.forEach(function(card) { + html += renderMemoryCard(card); + }); + html += '
'; + } + html += '
'; + }); + html += '
'; + } + + var __focus = captureSearchFocus(['mem-search']); + el.innerHTML = html; + + var searchInput = document.getElementById('mem-search'); + if (searchInput) { + bindImeSafeSearch(searchInput, 200, function(v){ state.memories.search = v; renderMemories(); }); + } + var typeSelect = document.getElementById('mem-type-filter'); + if (typeSelect) { + typeSelect.addEventListener('change', function() { + state.memories.typeFilter = this.value; + renderMemories(); + }); + } + var sourceSelect = document.getElementById('mem-source-filter'); + if (sourceSelect) { + sourceSelect.addEventListener('change', function() { + state.memories.sourceFilter = this.value; + renderMemories(); + }); + } + restoreSearchFocus(__focus); + } + + function deleteMemory(id, title) { + var modal = document.getElementById('modal'); + var overlay = document.getElementById('modal-overlay'); + modal.innerHTML = '

删除记忆

确认删除「' + esc(title) + '」吗?此操作不可撤销。

'; + overlay.classList.add('open'); + } + + function openAddMemory() { + var modal = document.getElementById('modal'); + var overlay = document.getElementById('modal-overlay'); + modal.innerHTML = + '

补充记忆线索

' + + '

记忆会优先从聊天记录自动整理;这里用于补充那些没有在会话里说清楚、但希望 Agent 记住的内容。

' + + '
' + + '' + + '
' + + '' + + '' + + '
' + + '
' + + '
' + + ''; + overlay.classList.add('open'); + setTimeout(function() { + var input = document.getElementById('add-memory-content'); + if (input) input.focus(); + }, 0); + } + + async function saveNewMemory() { + var err = document.getElementById('add-memory-error'); + var content = (document.getElementById('add-memory-content') || {}).value || ''; + var area = (document.getElementById('add-memory-area') || {}).value || ''; + var project = (document.getElementById('add-memory-project') || {}).value || ''; + if (!content.trim()) { + if (err) { + err.style.display = 'block'; + err.textContent = '先写一点内容再保存。'; + } + return; + } + var result = await apiPost('remember', { + content: content.trim(), + type: memoryAreaToType(area), + project: project.trim() || undefined + }); + if (!result || result.error) { + if (err) { + err.style.display = 'block'; + err.textContent = (result && result.error) || '保存失败,请稍后再试。'; + } + return; + } + closeModal(); + state.memories.loaded = false; + await loadMemories(); + } + + function editMemory(id) { + var mem = (state.memories.items || []).find(function(m) { return m.id === id; }); + if (!mem) return; + var modal = document.getElementById('modal'); + var overlay = document.getElementById('modal-overlay'); + var area = memoryTypeToArea(mem.type, mem); + var isProfile = area === 'profile'; + modal.innerHTML = + '

' + (isProfile ? '完善档案' : '编辑记忆') + '

' + + '

' + (isProfile ? '这里会影响身份档案的完整呈现。建议按“基本信息、当前重点、沟通偏好、常用表达”补充。' : '调整内容和它在记忆库里的位置。') + '

' + + '
' + + '' + + '
' + + '' + + '' + + '
' + + '' + + '
' + + '
' + + ''; + overlay.classList.add('open'); + } + + async function saveMemory(id) { + var err = document.getElementById('edit-memory-error'); + var title = (document.getElementById('edit-memory-title') || {}).value || ''; + var area = (document.getElementById('edit-memory-area') || {}).value || ''; + var project = (document.getElementById('edit-memory-project') || {}).value || ''; + var content = (document.getElementById('edit-memory-content') || {}).value || ''; + if (!content.trim()) { + if (err) { + err.style.display = 'block'; + err.textContent = '内容不能为空。'; + } + return; + } + var result = await apiPatch('memories/' + encodeURIComponent(id), { + title: title.trim(), + type: memoryAreaToType(area), + project: project.trim(), + content: content.trim() + }); + if (!result || result.error) { + if (err) { + err.style.display = 'block'; + err.textContent = (result && result.error) || '保存失败,请稍后再试。'; + } + return; + } + closeModal(); + state.memories.loaded = false; + await loadMemories(); + } + + async function confirmDeleteMemory(id) { + closeModal(); + await apiDelete('governance/memories', { memoryIds: [id], reason: 'Deleted via viewer' }); + state.memories.loaded = false; + loadMemories(); + } + + function editReviewItem(id) { + var item = (state.memories.reviewItems || []).filter(isReviewItemDisplayable).find(function(x) { return x.id === id; }) || + (state.actions.reviewItems || []).filter(isReviewItemDisplayable).find(function(x) { return x.id === id; }); + if (!item) return; + var payload = item.payload || {}; + var isAction = item.kind === 'action'; + var sourceLabel = reviewSourceLabel(item); + var defaultTags = reviewTags(item).length ? reviewTags(item).join(', ') : ['browser', 'reviewed', payload.provider ? 'source:' + String(payload.provider).toLowerCase() : '', item.page && item.page.type ? 'page:' + item.page.type : ''].filter(Boolean).join(', '); + var defaultProject = reviewProject(item); + var modal = document.getElementById('modal'); + var overlay = document.getElementById('modal-overlay'); + modal.innerHTML = + '

审阅后保存

' + + '

这条内容来自 ' + esc(sourceLabel) + '。确认前可以调整标题、内容和归属,避免临时信息直接进入待办列表。

' + + '
' + + '' + + '' + + '
' + + '' + + '' + + '
' + + '
' + + (isAction ? '' : '') + + '' + + '
' + + '
' + + '
' + + ''; + overlay.classList.add('open'); + } + + async function approveReviewItem(id) { + var fromActionsReview = (state.actions.reviewItems || []).some(function(x) { return x.id === id; }); + var err = document.getElementById('review-error'); + var title = (document.getElementById('review-title') || {}).value || ''; + var content = (document.getElementById('review-content') || {}).value || ''; + var kind = (document.getElementById('review-kind') || {}).value || 'memory'; + var project = (document.getElementById('review-project') || {}).value || ''; + var type = (document.getElementById('review-type') || {}).value || 'fact'; + var priority = parseInt((document.getElementById('review-priority') || {}).value || '', 10); + var tags = (document.getElementById('review-tags') || {}).value || ''; + if (!content.trim()) { + if (err) { + err.style.display = 'block'; + err.textContent = '内容不能为空。'; + } + return; + } + var result = await apiPost('review/approve', { + id: id, + title: title.trim(), + content: content.trim(), + kind: kind, + project: project.trim() || undefined, + type: type, + priority: Number.isFinite(priority) ? priority : undefined, + tags: tags + }); + if (!result || result.error) { + if (err) { + err.style.display = 'block'; + err.textContent = (result && result.error) || '写入失败,请稍后再试。'; + } + return; + } + closeModal(); + state.memories.loaded = false; + if (state.activeTab === 'actions' || fromActionsReview) { + state.actions.loaded = false; + await loadActions(); + } else { + await loadMemories(); + } + } + + async function dismissReviewItem(id) { + var fromActionsReview = (state.actions.reviewItems || []).some(function(x) { return x.id === id; }); + await apiPost('review/dismiss', { id: id }); + state.memories.reviewItems = (state.memories.reviewItems || []).filter(function(x) { return x.id !== id; }); + state.actions.reviewItems = (state.actions.reviewItems || []).filter(function(x) { return x.id !== id; }); + if (state.activeTab === 'actions' || fromActionsReview) { + renderActions(); + } else { + renderMemories(); + } + } + + function closeModal() { + document.getElementById('modal-overlay').classList.remove('open'); + } + + async function loadTimeline() { + var el = document.getElementById('view-timeline'); + el.innerHTML = '
加载时间线中...
'; + var sessResult = await apiGet('sessions'); + var localSessions = await loadLocalCodexSessions(120); + var sessions = mergeSessions(((sessResult && sessResult.sessions) || []), localSessions).filter(function(s) { return !isDemoSession(s); }); + state.timeline.sessions = sessions; + state.timeline.loaded = true; + + var groups = groupSessionsByProject(sessions); + if (groups.length > 0 && !state.timeline.projectKey) { + state.timeline.projectKey = groups[0].key; + } + + renderTimelineToolbar(groups); + if (state.timeline.projectKey) await loadObservations(); + } + + function renderTimelineToolbar(groups) { + var el = document.getElementById('view-timeline'); + groups = (groups || []).map(function(g) { + return g && g.sessions ? g : groupSessionsByProject([g])[0]; + }).filter(Boolean); + var html = '
'; + html += ''; + html += ''; + html += ''; + html += ''; + html += '
'; + html += '
'; + el.innerHTML = html; + + document.getElementById('tl-project').addEventListener('change', function() { + state.timeline.projectKey = this.value; + state.timeline.sessionId = ''; + state.timeline.page = 0; + loadObservations(); + }); + } + + async function loadObservations() { + var content = document.getElementById('tl-content'); + if (!content) return; + if (!state.timeline.projectKey) { + content.innerHTML = '
🕑

请选择一个文件夹查看时间线

'; + return; + } + content.innerHTML = '
加载观察记录中...
'; + var sessions = (state.timeline.sessions || []).filter(function(s) { return sessionProjectKey(s) === state.timeline.projectKey && sessionId(s); }); + var localOnly = sessions.every(function(s) { return String(s.source || '') === 'local-codex-jsonl'; }); + if (localOnly) { + state.timeline.observations = sessions.map(function(s) { + return { + id: sessionId(s), + sessionId: sessionId(s), + timestamp: sessionRecordTime(s), + type: 'conversation', + title: sessionTitleText(s) || 'Codex 会话', + narrative: sessionBodyPreview(s, sessionTitleText(s)), + importance: 5, + _sessionId: sessionId(s), + _sessionName: sessionDisplayName(s) + }; + }).sort(function(a, b) { + return String(b.timestamp || '').localeCompare(String(a.timestamp || '')); + }); + renderObservations(); + return; + } + var results = await Promise.all(sessions.map(function(s) { + return apiGet('observations?sessionId=' + encodeURIComponent(sessionId(s))).then(function(result) { + var list = (result && result.observations) || []; + return list.map(function(o) { + o._sessionId = sessionId(s); + o._sessionName = sessionDisplayName(s); + return o; + }); + }).catch(function() { return []; }); + })); + state.timeline.observations = results.reduce(function(acc, list) { return acc.concat(list); }, []).sort(function(a, b) { + return String(b.timestamp || '').localeCompare(String(a.timestamp || '')); + }); + renderObservations(); + } + + var tlTypeFilter = ''; + + function renderObservations() { + var content = document.getElementById('tl-content'); + if (!content) return; + var obs = state.timeline.observations; + var minImp = state.timeline.minImportance; + var filtered = minImp > 0 ? obs.filter(function(o) { return (o.importance || 0) >= minImp; }) : obs; + var html = ''; + + var typeCounts = {}; + filtered.forEach(function(o) { + var t = observationType(o); + typeCounts[t] = (typeCounts[t] || 0) + 1; + }); + var typeList = Object.keys(typeCounts).sort(function(a, b) { return typeCounts[b] - typeCounts[a]; }); + + if (tlTypeFilter) { + filtered = filtered.filter(function(o) { + var t = observationType(o); + return t === tlTypeFilter; + }); + } + + var pageSize = state.timeline.pageSize; + var page = state.timeline.page; + + if (state.timeline.mode === 'episodes') { + var allEpisodes = buildTimelineEpisodes(filtered); + var episodeFilter = state.timeline.episodeFilter || 'all'; + var filterCounts = { all: allEpisodes.length, important: 0 }; + allEpisodes.forEach(function(ep) { + if (ep.kind === 'important' || ep.kind === 'user_need' || ep.kind === 'bugfix') filterCounts.important++; + }); + html += '
'; + [ + ['all', '全部'], + ['important', '重点'] + ].forEach(function(item) { + var key = item[0]; + var label = item[1]; + html += '' + label + ' (' + (filterCounts[key] || 0) + ')'; + }); + html += '
'; + var episodes = filterTimelineEpisodes(allEpisodes); + var epPageSize = Math.max(10, Math.floor(pageSize / 2)); + var epPage = page; + var epStart = epPage * epPageSize; + var epPaged = episodes.slice(epStart, epStart + epPageSize); + var epTotalPages = Math.ceil(episodes.length / epPageSize); + html += '
故事模式会把同一段工作里的底层记录合并成可读片段。需要看完整流水时,可以切到事件模式。
'; + if (epPaged.length === 0) { + html += '
🕑

暂无可整理的工作片段

'; + content.innerHTML = html; + return; + } + epPaged.forEach(function(ep) { + var accent = ep.importance >= 7 ? OBS_TYPE_COLORS.decision : ep.importance >= 4 ? OBS_TYPE_COLORS.discovery : OBS_TYPE_COLORS.other; + var expanded = !!state.timeline.expandedEpisodes[ep.key]; + var visibleActions = expanded ? ep.actions : ep.actions.slice(0, 2); + html += '
'; + html += '
' + esc(episodeKindLabel(ep.kind)) + '
'; + html += '
' + esc(ep.title) + '
'; + html += '
'; + html += '' + esc(formatTime(ep.start)) + (ep.end && ep.end !== ep.start ? ' - ' + esc(shortTime(ep.end)) : '') + ''; + html += '' + ep.count + ' 条记录'; + html += '' + esc(ep.typeText) + ''; + html += '
'; + html += '
' + esc(ep.body) + '
'; + if (visibleActions.length > 0) { + html += '
    '; + visibleActions.forEach(function(a) { html += '
  • ' + esc(a) + '
  • '; }); + html += '
'; + } + html += ''; + html += '
'; + }); + if (epTotalPages > 1) { + html += ''; + } + content.innerHTML = html; + return; + } + + var start = page * pageSize; + var paged = filtered.slice(start, start + pageSize); + var totalPages = Math.ceil(filtered.length / pageSize); + + html += '
'; + html += '全部 (' + obs.length + ')'; + typeList.forEach(function(t) { + var color = OBS_TYPE_COLORS[t] || '#666666'; + html += '' + esc(t.replace(/_/g, ' ')) + ' (' + typeCounts[t] + ')'; + }); + html += '
'; + + if (paged.length === 0) { + html += '
🕑

暂无观察记录' + (obs.length > 0 ? '(当前筛选无结果,共 ' + obs.length + ' 条)' : '(该会话下)') + '

'; + content.innerHTML = html; + return; + } + + html += '
当前文件夹共显示 ' + filtered.length + ' 条记录
'; + + html += '
'; + + var lastDateGroup = ''; + paged.forEach(function(o, idx) { + var isCompressed = !!o.narrative || !!o.type; + var isRaw = !isCompressed; + var type = observationType(o); + var isFocusEvent = type === 'decision' || type === 'error' || type === 'task' || type === 'conversation'; + var impClass = isFocusEvent ? 'high' : 'low'; + var display = observationDisplay(o); + var title = display.title; + var typeColor = OBS_TYPE_COLORS[type] || '#666666'; + var icon = OBS_TYPE_ICONS[type] || '📄'; + + var dateGroup = ''; + try { + var d = new Date(o.timestamp); + dateGroup = d.toLocaleDateString() + ' ' + d.getHours() + ':00'; + } catch(e) { dateGroup = ''; } + + if (dateGroup && dateGroup !== lastDateGroup) { + html += '
' + esc(dateGroup) + '
'; + lastDateGroup = dateGroup; + } + + var side = idx % 2 === 0 ? 'tl-left' : 'tl-right'; + + html += '
'; + html += '
'; + html += '
'; + + html += '
'; + html += '
'; + html += '
'; + html += '' + icon + ''; + html += '' + esc(title) + ''; + if (isRaw) html += 'raw'; + html += '
'; + html += '
'; + html += '' + esc(shortTime(o.timestamp)) + ''; + html += '
'; + + if (display.subtitle) html += '
' + esc(display.subtitle) + '
'; + + html += '
'; + html += '' + esc(type.replace(/_/g, ' ')) + ''; + if (o.hookType) html += '' + esc(o.hookType) + ''; + html += '
'; + + if (isRaw && o.toolInput) { + var inputStr = typeof o.toolInput === 'string' ? o.toolInput : JSON.stringify(o.toolInput); + html += '
输入:'; + html += '
' + esc(truncate(inputStr, 300)) + '
'; + } + if (isRaw && o.toolOutput) { + var outputStr = typeof o.toolOutput === 'string' ? o.toolOutput : JSON.stringify(o.toolOutput); + html += '
输出:'; + html += '
' + esc(truncate(outputStr, 300)) + '
'; + } + if (display.body) html += '
' + esc(display.body) + '
'; + if (o.facts && o.facts.length > 0) { + html += '
    '; + o.facts.forEach(function(f) { html += '
  • ' + esc(f) + '
  • '; }); + html += '
'; + } + + var hasTags = (o.concepts && o.concepts.length) || (o.files && o.files.length); + if (hasTags) { + html += '
'; + (o.concepts || []).forEach(function(c) { html += '' + esc(c) + ''; }); + (o.files || []).forEach(function(f) { + var short = f.split('/').pop(); + html += '' + esc(short) + ''; + }); + html += '
'; + } + if (isRaw && o.toolInput) { + var files = []; + var ti = o.toolInput; + if (typeof ti === 'object' && ti !== null) { + if (ti.file_path) files.push(ti.file_path); + if (ti.path) files.push(ti.path); + } + if (files.length > 0) { + html += '
'; + files.forEach(function(f) { + var short = String(f).split('/').pop(); + html += '' + esc(short) + ''; + }); + html += '
'; + } + } + html += '
'; + html += '
'; + }); + + html += '
'; + + if (totalPages > 1) { + html += ''; + } + + content.innerHTML = html; + } + + function setTlTypeFilter(type) { + tlTypeFilter = type; + state.timeline.page = 0; + renderObservations(); + } + + function tlPage(p) { + state.timeline.page = p; + renderObservations(); + } + + async function loadActivity() { + var el = document.getElementById('view-activity'); + state.activity.loadingPhase = '正在读取本地会话'; + el.innerHTML = '
加载活动中...
'; + state.activity.warnings = []; + var results = await Promise.all([ + settledData('工作台会话', { sessions: [] }, function() { return apiGet('sessions'); }), + settledData('审计记录', { entries: [] }, function() { return apiGet('audit?limit=80'); }), + settledData('本地会话', [], function() { return loadLocalCodexSessions(60); }) + ]); + state.activity.warnings = results.filter(function(r) { return r.failed; }).map(function(r) { return r.label; }); + var sessions = mergeSessions(((results[0].data && results[0].data.sessions) || []), results[2].data || []).filter(function(s) { return !isDemoSession(s); }); + var auditEntries = (results[1].data && results[1].data.entries) || []; + + var allObs = []; + var sorted = sessions.slice().sort(function(a, b) { return (b.startedAt || '').localeCompare(a.startedAt || ''); }); + var localObs = sorted.filter(function(s) { + return String(s.source || '') === 'local-codex-jsonl'; + }).map(function(s) { + return { + id: sessionId(s), + sessionId: sessionId(s), + timestamp: sessionRecordTime(s), + type: 'conversation', + title: sessionTitleText(s) || 'Codex 会话', + narrative: sessionBodyPreview(s, sessionTitleText(s)), + project: s.project, + cwd: s.cwd, + agentId: 'Codex' + }; + }).filter(function(o) { + var t = String(o.title || ''); + return t && t !== 'Codex Desktop' && t.indexOf('Automation:') !== 0; + }); + allObs = allObs.concat(localObs); + + var recentSessions = sorted.filter(function(s) { + return String(s.source || '') !== 'local-codex-jsonl'; + }).slice(0, 5); + + state.activity.sessions = sessions; + state.activity.observations = allObs; + state.activity.audit = auditEntries; + state.activity.loaded = true; + state.activity.loadingPhase = recentSessions.length ? '正在补齐最近会话细节' : ''; + renderActivity(); + + var obsResults = await Promise.all(recentSessions.filter(function(s) { return sessionId(s); }).map(function(s) { + return settledData('会话细节', null, function() { return apiGet('observations?sessionId=' + encodeURIComponent(sessionId(s))); }); + })); + obsResults.forEach(function(r) { + if (r && r.failed && state.activity.warnings.indexOf(r.label) < 0) state.activity.warnings.push(r.label); + if (r && r.data && r.data.observations) allObs = allObs.concat(r.data.observations); + }); + state.activity.observations = allObs; + state.activity.loadingPhase = ''; + renderActivity(); + } + + function renderActivity() { + var el = document.getElementById('view-activity'); + var obs = state.activity.observations; + var sessions = state.activity.sessions; + + var TOOL_TYPE_MAP = { Read: 'file_read', Write: 'file_write', Edit: 'file_edit', Bash: 'command_run', Grep: 'search', Glob: 'search', WebFetch: 'web_fetch', WebSearch: 'web_fetch', AskUserQuestion: 'conversation', Task: 'subagent' }; + + var html = ''; + + html += renderDataWarnings(state.activity.warnings, 'refresh-activity'); + + if (state.activity.loadingPhase) { + html += '
' + esc(state.activity.loadingPhase) + '
' + obs.length + ' 条
'; + } + + html += '
活动热力图(近一年)
'; + var dayCounts = {}; + obs.forEach(function(o) { + try { + var d = new Date(o.timestamp); + var key = d.toISOString().slice(0, 10); + dayCounts[key] = (dayCounts[key] || 0) + 1; + } catch(e) {} + }); + sessions.forEach(function(s) { + try { + var d = new Date(s.startedAt); + var key = d.toISOString().slice(0, 10); + dayCounts[key] = (dayCounts[key] || 0) + 1; + } catch(e) {} + }); + + var maxCount = 0; + Object.keys(dayCounts).forEach(function(k) { if (dayCounts[k] > maxCount) maxCount = dayCounts[k]; }); + + var today = new Date(); + var dayLabels = ['一', '', '三', '', '五', '', '']; + html += '
'; + dayLabels.forEach(function(l) { html += '' + l + ''; }); + html += '
'; + html += '
'; + for (var w = 51; w >= 0; w--) { + for (var d = 0; d < 7; d++) { + var cellDate = new Date(today); + cellDate.setDate(cellDate.getDate() - (w * 7 + (6 - d))); + var key = cellDate.toISOString().slice(0, 10); + var count = dayCounts[key] || 0; + var level = count === 0 ? '' : count <= (maxCount * 0.25) ? 'level-1' : count <= (maxCount * 0.5) ? 'level-2' : count <= (maxCount * 0.75) ? 'level-3' : 'level-4'; + var title = key + ':' + count + ' 条活动'; + html += '
'; + } + } + html += '
'; + html += '
较少 '; + html += '
'; + html += '
'; + html += '
'; + html += '
'; + html += '
'; + html += ' 较多
'; + html += '
'; + + var typeCounts = {}; + obs.forEach(function(o) { + var t = o.type || TOOL_TYPE_MAP[o.toolName] || (o.hookType ? o.hookType.replace(/_/g, ' ') : 'other'); + typeCounts[t] = (typeCounts[t] || 0) + 1; + }); + var typeList = Object.keys(typeCounts).sort(function(a, b) { return typeCounts[b] - typeCounts[a]; }); + var totalObs = obs.length || 1; + + html += '
'; + + html += '
类型分布
'; + if (typeList.length === 0) { + html += '
还没有观察记录
'; + } else { + html += '
'; + typeList.slice(0, 12).forEach(function(t) { + var pct = Math.round((typeCounts[t] / totalObs) * 100); + var color = OBS_TYPE_COLORS[t] || '#666666'; + html += '
' + esc(observationTypeLabel(t)) + '
' + typeCounts[t] + '
'; + }); + html += '
'; + } + html += '
'; + + html += '
活动流
'; + var sortedObs = obs.slice().sort(function(a, b) { return (b.timestamp || '').localeCompare(a.timestamp || ''); }); + if (sortedObs.length === 0) { + html += '
暂无最近活动
'; + } else { + sortedObs.slice(0, 20).forEach(function(o) { + var type = o.type || TOOL_TYPE_MAP[o.toolName] || 'other'; + var typeColor = OBS_TYPE_COLORS[type] || '#666666'; + var icon = OBS_TYPE_ICONS[type] || '📄'; + var display = observationDisplay(o); + var title = display.title; + + var sess = sessions.find(function(s) { return sessionId(s) && (sessionId(s) === o.sessionId || sessionId(s) === o.session_id); }); + var source = inferSessionSource(sess || { project: o.project || '', cwd: o.cwd || '' }); + var avatar = agentAvatarSpec(source.name); + html += '
'; + html += renderAgentAvatar(avatar); + html += '
'; + html += '
' + esc(truncate(title, 60)) + '
'; + if (display.body) html += '
' + esc(truncate(display.body, 120)) + '
'; + html += '
' + esc(source.name) + ' · ' + esc(observationTypeLabel(type)); + if (o.files && o.files.length) html += ' · ' + esc(o.files[0].split('/').pop()) + ''; + html += ' · ' + esc(shortTime(o.timestamp)) + '
'; + html += '
'; + }); + } + html += '
'; + + html += '
'; + + el.innerHTML = html; + } + diff --git a/src/viewer/parts/app/56-sessions.js b/src/viewer/parts/app/56-sessions.js new file mode 100644 index 00000000..ae7c5743 --- /dev/null +++ b/src/viewer/parts/app/56-sessions.js @@ -0,0 +1,635 @@ + async function loadSessions(options) { + options = options || {}; + var reason = options.reason || 'manual'; + var showLoading = options.showLoading !== false; + var requestSeq = (state.sessions.requestSeq || 0) + 1; + state.sessions.requestSeq = requestSeq; + var el = document.getElementById('view-sessions'); + if (showLoading || !state.sessions.loaded) el.innerHTML = '
加载会话中...
'; + var results = await Promise.all([ + settledData('工作台会话', { sessions: [] }, function() { return apiGet('sessions'); }), + settledData('本地会话', [], function() { return loadLocalCodexSessions(120); }), + settledData('浏览器对话', { items: [] }, function() { return apiGet('review?limit=200'); }) + ]); + if (state.sessions.requestSeq !== requestSeq) return; + state.sessions.warnings = results.filter(function(r) { return r.failed; }).map(function(r) { return r.label; }); + var browserSessions = browserReviewSessions((results[2].data && results[2].data.items) || []); + state.sessions.items = mergeSessions(((results[0].data && results[0].data.sessions) || []).concat(browserSessions), results[1].data || []).filter(function(s) { return !isDemoSession(s); }); + pruneSessionHighlightCache(state.sessions.items); + pruneSessionDetailCache(state.sessions.items); + if (reason === 'manual') state.sessions.stale = false; + state.sessions.loaded = true; + renderSessions(); + } + + function renderSessions() { + var el = document.getElementById('view-sessions'); + var items = state.sessions.items.slice().sort(function(a, b) { + return (sessionRecordTime(b) || '').localeCompare(sessionRecordTime(a) || ''); + }); + + var html = '
'; + html += renderDataWarnings(state.sessions.warnings, 'refresh-sessions'); + html += '
' + sessionStaleNoticeMarkup() + '
'; + if (items.length === 0) { + html += '
🗒

' + (state.sessions.warnings && state.sessions.warnings.length ? t('ses.emptyNeedRetry') : t('ses.empty')) + '

'; + if (state.sessions.warnings && state.sessions.warnings.length) html += '
'; + html += '
'; + } else { + var groupMode = state.sessions.groupMode === 'source' ? 'source' : 'folder'; + state.sessions.groupMode = groupMode; + var projectGroups = groupSessionsByProject(items); + var sourceGroups = sessionSourceGroups(items); + var folderGroups = [{ + key: 'all', + name: t('ses.allSessions'), + sessions: items, + count: items.length, + latest: projectGroups[0] ? projectGroups[0].latest : '', + observations: items.reduce(function(sum, s) { return sum + (s.observationCount || 0); }, 0), + sources: {}, + hasMissingId: false + }].concat(projectGroups); + var groups = groupMode === 'source' ? sourceGroups.map(function(g) { + return { + key: g.key, + name: g.name, + count: g.count, + latest: g.latest, + observations: g.observations, + sessions: g.key === 'all' ? items : items.filter(function(s) { return sessionSourceKey(s) === g.key; }) + }; + }) : folderGroups; + var activeGroupKey = groupMode === 'source' ? (state.sessions.sourceKey || 'all') : (state.sessions.folderKey || 'all'); + if (!groups.some(function(g) { return g.key === activeGroupKey; })) activeGroupKey = 'all'; + if (groupMode === 'source') state.sessions.sourceKey = activeGroupKey; + else state.sessions.folderKey = activeGroupKey; + var activeGroup = groups.find(function(g) { return g.key === activeGroupKey; }) || groups[0]; + var visibleItems = activeGroup.sessions || []; + var totalObs = items.reduce(function(sum, s) { return sum + (s.observationCount || 0); }, 0); + html += '
'; + html += '
' + t('ses.heroTitle') + '
' + t('ses.heroNote') + '
'; + html += '
' + items.length + ' ' + t('ses.unitSessions') + ' · ' + projectGroups.length + ' ' + t('ses.unitFolders') + ' · ' + (sourceGroups.length - 1) + ' ' + t('ses.unitSources') + ' · ' + totalObs + ' ' + t('ses.unitRecords') + '
'; + html += '
'; + html += '
'; + + html += '
'; + html += '
'; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + html += '
'; + html += '
'; + html += ''; + html += '
'; + html += '
' + esc(activeGroup ? activeGroup.name : t('ses.allSessions')) + '
' + visibleItems.length + ' ' + t('ses.unitSessions') + ' · ' + ((activeGroup && activeGroup.observations) || 0) + ' ' + t('ses.unitRecords') + '
'; + if (!visibleItems.length) { + html += '
' + (groupMode === 'source' ? t('ses.emptySource') : t('ses.emptyFolder')) + '
'; + } else { + visibleItems.forEach(function(s) { + var id = sessionId(s); + var title = sessionTitleText(s); + var preview = sessionBodyPreview(s, title) || t('ses.noPreview'); + var source = inferSessionSource(s); + var avatarLabel = (source.name || t('ses.avatarFallback')).replace(/\s+/g, '').slice(0, 2); + var selected = id && state.sessions.selectedId === id; + html += ''; + }); + } + html += '
'; + html += '
'; + } + html += '
'; + el.innerHTML = html; + + if (state.sessions.selectedId) renderSessionDetail(); + } + + function selectSession(id) { + state.sessions.selectedId = id; + renderSessions(); + setTimeout(function() { + var panel = document.getElementById('session-detail'); + if (panel) panel.scrollIntoView({ behavior: 'smooth', block: 'start' }); + }, 80); + } + + var SESSION_HIGHLIGHT_LABELS = { + goal: '用户', + agent_output: 'Agent', + tool: '工具', + mcp: 'MCP', + command: '命令', + failure: '失败', + validation: '验证', + file: '文件', + artifact: '产物', + skill: 'Skill', + todo: '待办', + follow_up: '后续' + }; + + function sessionHighlightCacheKey(s) { + if (!s) return ''; + return [ + sessionId(s), + s.status || '', + s.observationCount || 0, + s.updatedAt || '', + s.endedAt || '', + s.startedAt || '' + ].join('|'); + } + + function pruneSessionHighlightCache(items) { + var keep = {}; + (items || []).forEach(function(s) { + var id = sessionId(s); + if (id) keep[id] = sessionHighlightCacheKey(s); + }); + Object.keys(state.sessions.highlightsById || {}).forEach(function(id) { + if (!keep[id] || state.sessions.highlightsById[id].cacheKey !== keep[id]) { + delete state.sessions.highlightsById[id]; + } + }); + } + + function pruneSessionDetailCache(items) { + var keep = {}; + (items || []).forEach(function(s) { + var id = sessionId(s); + if (id) keep[id] = sessionHighlightCacheKey(s); + }); + Object.keys(state.sessions.detailCacheById || {}).forEach(function(id) { + if (!keep[id] || state.sessions.detailCacheById[id].cacheKey !== keep[id]) { + delete state.sessions.detailCacheById[id]; + } + }); + } + + function sessionDetailSections(id) { + if (!state.sessions.detailSectionsById) state.sessions.detailSectionsById = {}; + if (!state.sessions.detailSectionsById[id]) { + state.sessions.detailSectionsById[id] = { highlights: true, process: false }; + } + return state.sessions.detailSectionsById[id]; + } + + function renderSessionSectionTitle(sessionIdValue, section, title, expanded) { + return '
' + + '' + esc(title) + '' + + '' + + '
'; + } + + function toggleSessionDetailSection(id, section) { + if (!id || (section !== 'highlights' && section !== 'process')) return; + var sections = sessionDetailSections(id); + sections[section] = !sections[section]; + renderSessionDetail({ useCacheOnly: true }); + } + + async function getSessionHighlights(id, sessionRecord) { + if (!id) return { ok: false, highlights: [] }; + var cacheKey = sessionHighlightCacheKey(sessionRecord); + var cached = state.sessions.highlightsById[id]; + if (cached && cached.cacheKey === cacheKey) return cached; + var result = await apiGet('session/highlights?sessionId=' + encodeURIComponent(id) + '&maxItems=12'); + var view = result && result.success === true && Array.isArray(result.highlights) + ? { ok: true, highlights: result.highlights, cacheKey: cacheKey } + : { ok: false, highlights: [], cacheKey: cacheKey }; + state.sessions.highlightsById[id] = view; + return view; + } + + function sessionDialogueHighlights(view) { + return (Array.isArray(view && view.highlights) ? view.highlights : []).filter(function(item) { + return item && (item.category === 'goal' || item.category === 'agent_output'); + }); + } + + function renderSessionHighlights(sessionIdValue, view, expanded) { + var html = '
'; + html += renderSessionSectionTitle(sessionIdValue, 'highlights', '会话重点', expanded); + if (!expanded) return html + '
'; + if (!view || view.ok === false) { + html += '
会话重点暂不可用
'; + return html; + } + var highlights = sessionDialogueHighlights(view); + if (highlights.length === 0) { + html += '
暂无会话重点
'; + return html; + } + var compactHighlights = highlights.length > 4; + html += '
'; + highlights.forEach(function(item) { + var label = SESSION_HIGHLIGHT_LABELS[item.category] || item.category || '重点'; + html += '
'; + html += '
' + esc(label) + '
'; + html += '
'; + html += '
' + esc(item.title || label) + '
'; + if (item.summary) html += '
' + renderMarkdownSafe(item.summary) + '
'; + html += '
'; + }); + html += '
'; + if (compactHighlights) { + html += '
仅展示前几条重点;需要完整内容时使用完整对话过程。
'; + } + html += ''; + return html; + } + + function isSessionPreviewExpanded(id) { + return !!(state.sessions.previewExpandedById && state.sessions.previewExpandedById[id]); + } + + function toggleSessionPreview(id) { + if (!id) return; + if (!state.sessions.previewExpandedById) state.sessions.previewExpandedById = {}; + state.sessions.previewExpandedById[id] = !state.sessions.previewExpandedById[id]; + renderSessionDetail({ useCacheOnly: true }); + } + + async function renderSessionDetail(options) { + options = options || {}; + var panel = document.getElementById('session-detail'); + if (!panel) return; + var s = state.sessions.items.find(function(x) { return sessionId(x) === state.sessions.selectedId; }); + var id = sessionId(s); + if (!s || !id) { panel.innerHTML = ''; return; } + + var cacheKey = sessionHighlightCacheKey(s); + var cachedDetail = state.sessions.detailCacheById && state.sessions.detailCacheById[id]; + var hasFreshCache = cachedDetail && cachedDetail.cacheKey === cacheKey; + var obs; + var highlightsView; + if (hasFreshCache) { + obs = cachedDetail.observations || []; + highlightsView = cachedDetail.highlightsView || { ok: false, highlights: [], cacheKey: cacheKey }; + } else if (options.useCacheOnly) { + return; + } else { + var detailRequestSeq = (state.sessions.detailRequestSeq || 0) + 1; + state.sessions.detailRequestSeq = detailRequestSeq; + if (!panel.innerHTML) panel.innerHTML = '

加载会话详情中…

'; + obs = []; + if (!Array.isArray(s.embeddedObservations) || String(s.id || '').indexOf('browser_sync_') >= 0 || !s.id) { + obs = Array.isArray(s.embeddedObservations) ? s.embeddedObservations : []; + } + if (!obs.length && id) { + var obsRes = await apiGet('observations?sessionId=' + encodeURIComponent(id)); + obs = (obsRes && obsRes.observations) || []; + } + if ((!obs || obs.length === 0) && Array.isArray(s.embeddedObservations)) { + obs = s.embeddedObservations; + } + if ((!obs || obs.length === 0) && String(s.source || '').indexOf('local-') === 0) { + var localRes = await apiGet('local-agent-session-events?sessionId=' + encodeURIComponent(id)); + obs = (localRes && localRes.observations) || obs || []; + } + highlightsView = await getSessionHighlights(id, s); + if (state.sessions.detailRequestSeq !== detailRequestSeq || state.sessions.selectedId !== id) return; + state.sessions.detailCacheById[id] = { + cacheKey: cacheKey, + observations: obs || [], + highlightsView: highlightsView, + }; + } + + var typeCounts = {}; + var toolCounts = {}; + var fileSet = new Set(); + var firstPromptFromObs = ''; + obs.forEach(function(o) { + var t = o.type || o.hookType || 'other'; + typeCounts[t] = (typeCounts[t] || 0) + 1; + var tool = o.title || o.toolName; + if (tool && t !== 'conversation') toolCounts[tool] = (toolCounts[tool] || 0) + 1; + (o.files || []).forEach(function(f) { fileSet.add(f); }); + if (!firstPromptFromObs && (o.userPrompt || (o.type === 'conversation' && o.narrative))) { + firstPromptFromObs = o.userPrompt || o.narrative || ''; + } + }); + + var durationMs = s.endedAt ? new Date(s.endedAt).getTime() - new Date(s.startedAt).getTime() : 0; + var durationLabel = durationMs > 0 ? (durationMs < 60000 ? (durationMs / 1000).toFixed(1) + 's' : (durationMs / 60000).toFixed(1) + 'm') : '-'; + + var detailTitle = sessionTitleText(s); + var preview = sessionBodyPreview({ summary: s.summary, latestPrompt: firstPromptFromObs, firstPrompt: s.firstPrompt }, detailTitle) || firstPromptFromObs || s.summary || s.firstPrompt || ''; + var detailSections = sessionDetailSections(id); + + var html = '
'; + html += '
'; + html += '

会话时间线

' + esc(projectDisplayName(sessionProjectKey(s))) + ' · ' + esc(shortDateTime(sessionRecordTime(s))) + '
'; + html += statusIconMarkup(s.status, sessionStatusLabel(s.status)); + html += '
'; + + if (preview) { + var previewExpanded = isSessionPreviewExpanded(id); + var previewIsLong = String(preview).length > 260; + html += '
' + esc(preview) + '
'; + if (previewIsLong) { + html += ''; + } + } + + html += '
'; + html += '
记录
' + obs.length + '
'; + html += '
操作类型
' + Object.keys(typeCounts).length + '
'; + html += '
文件
' + fileSet.size + '
'; + html += '
时长
' + esc(durationLabel) + '
'; + html += '
'; + + html += renderSessionHighlights(id, highlightsView, detailSections.highlights !== false); + + var timelineObs = obs.slice().sort(function(a, b) { return (a.timestamp || '').localeCompare(b.timestamp || ''); }); + var processExpanded = detailSections.process === true; + var isLongTimeline = timelineObs.length > 80; + html += '
'; + html += renderSessionSectionTitle(id, 'process', '完整对话过程 · ' + timelineObs.length + ' 条', processExpanded); + if (processExpanded && isLongTimeline) { + html += '
记录很多,可以先跳到底部看最近进展。
'; + } + if (processExpanded && timelineObs.length === 0) { + html += '
这段会话还没有可展示的过程记录。
'; + } else if (processExpanded) { + html += '
'; + timelineObs.forEach(function(o) { + var display = observationDisplay(o); + var type = o.type || o.hookType || 'other'; + html += ''; + html += '
' + esc(shortTime(o.timestamp)) + '
'; + html += '
' + esc(display.title || observationTypeLabel(type)) + '
'; + if (display.body) html += '
' + renderMarkdownSafe(display.body) + '
'; + html += '
'; + }); + html += '
'; + if (isLongTimeline) html += '
'; + } + html += '
'; + + var topTools = Object.keys(toolCounts).sort(function(a, b) { return toolCounts[b] - toolCounts[a]; }).slice(0, 10); + if (topTools.length > 0) { + var maxC = toolCounts[topTools[0]] || 1; + html += '
工具使用
'; + html += '
'; + topTools.forEach(function(t) { + var pct = Math.round((toolCounts[t] / maxC) * 100); + html += '
' + esc(t) + '
' + toolCounts[t] + '
'; + }); + html += '
'; + } + + var typeKeys = Object.keys(typeCounts).sort(function(a, b) { return typeCounts[b] - typeCounts[a]; }); + if (typeKeys.length > 0) { + html += '
记录类型
'; + html += '
'; + typeKeys.forEach(function(t) { + html += '' + esc(typeDisplayLabel(t)) + ' · ' + typeCounts[t] + ''; + }); + html += '
'; + } + + if (fileSet.size > 0) { + var filesArr = Array.from(fileSet).slice(0, 30); + html += '
文件
'; + html += '
'; + filesArr.forEach(function(f) { html += '
• ' + esc(f) + '
'; }); + if (fileSet.size > 30) html += '
+' + (fileSet.size - 30) + ' more
'; + html += '
'; + } + + var detailId = sessionId(s); + html += '
记录来源
'; + html += '
'; + sessionSourceSummary(s, obs.length).forEach(function(item) { + html += '
' + esc(item.label) + '
' + esc(item.value || '-') + '
'; + }); + html += '
'; + html += '
查看技术细节'; + html += '
'; + html += '
id: ' + esc(detailId || '无记录 ID') + '
'; + html += '
path: ' + esc(s.cwd || '-') + '
'; + html += '
started: ' + esc(formatTime(s.startedAt)) + '
'; + if (s.endedAt) html += '
ended: ' + esc(formatTime(s.endedAt)) + '
'; + if (s.model) html += '
model: ' + esc(s.model) + '
'; + if (s.tags && s.tags.length) html += '
tags: ' + s.tags.map(esc).join(', ') + '
'; + html += '
'; + + html += '
'; + if (detailId) { + html += ''; + } else { + html += ''; + } + html += '
'; + panel.innerHTML = html; + applyPendingHighlight(); + } + + async function endSession(id) { + await apiPost('session/end', { sessionId: id }); + state.sessions.loaded = false; + loadSessions({ showLoading: true, reason: 'manual' }); + } + + async function summarizeSession(id, btn) { + if (!btn) return; + btn.textContent = '生成中...'; + btn.disabled = true; + try { + var result = await apiPost('summarize', { sessionId: id }); + state.lessons.loaded = false; + if (result && result.success) { + btn.textContent = result.fallback ? '已生成本地摘要' : '已生成摘要'; + btn.title = result.fallback ? '没有可用模型时,已用本地规则从会话记录生成摘要。' : ''; + } else { + btn.textContent = summarizeErrorLabel(result && (result.reason || result.error)); + btn.title = summarizeErrorHint(result && (result.reason || result.error)); + } + } catch (err) { + btn.textContent = '摘要服务未响应'; + btn.title = '本地工作台没有连上摘要接口,请确认 AI Todo worker 正在运行。'; + } + setTimeout(function() { btn.textContent = '生成摘要'; btn.disabled = false; }, 2600); + } + + function summarizeErrorLabel(error) { + if (error === 'no_observations') return '暂无可摘要内容'; + if (error === 'session_not_found') return '会话不存在'; + if (error === 'no_provider') return '缺少模型配置'; + return '生成失败'; + } + + function summarizeErrorHint(error) { + if (error === 'no_observations') return '这段会话还没有可整理的记录。'; + if (error === 'session_not_found') return '本地数据里没有找到这段会话。'; + if (error === 'no_provider') return '未配置模型时会使用本地规则摘要;如果仍失败,请检查本地 worker 日志。'; + return String(error || '摘要生成失败,请稍后重试。'); + } + + function skillDraftSlug(text) { + var base = String(text || 'skill').toLowerCase().replace(/[^a-z0-9\u4e00-\u9fa5]+/g, '-').replace(/^-+|-+$/g, ''); + return (base || 'memory-skill').slice(0, 48); + } + function skillDraftTitle(groupKey) { + var name = projectDisplayName(groupKey || '通用经验'); + return name === '通用经验' ? '可复用工作流 Skill' : name + ' 工作流 Skill'; + } + function buildSkillDraft(groupKey, lessons) { + var list = (lessons || []).slice().sort(function(a, b) { return (b.confidence || 0) - (a.confidence || 0); }).slice(0, 8); + var title = skillDraftTitle(groupKey); + var slug = skillDraftSlug(groupKey || title); + var tags = []; + list.forEach(function(l) { (l.tags || []).forEach(function(t) { if (tags.indexOf(t) < 0) tags.push(t); }); }); + var lines = []; + lines.push('# ' + title); + lines.push(''); + lines.push('## When to use'); + lines.push('Use this skill when the user is working on ' + projectDisplayName(groupKey || 'a reusable workflow') + ' and needs the agent to apply the repeated lessons below.'); + lines.push(''); + lines.push('## Core lessons'); + if (!list.length) lines.push('- Add confirmed lessons here before installing this skill.'); + list.forEach(function(l) { lines.push('- ' + translateLessonText(l.content)); }); + lines.push(''); + lines.push('## Operating guidance'); + lines.push('- Preserve the user-visible product intent before changing implementation details.'); + lines.push('- Prefer reviewable drafts over automatic writes when the output changes durable memory or local skills.'); + lines.push('- Keep examples, docs, and product UI aligned after every meaningful change.'); + if (tags.length) { + lines.push(''); + lines.push('## Tags'); + lines.push(tags.slice(0, 10).map(function(t) { return '`' + t + '`'; }).join(' ')); + } + lines.push(''); + lines.push('## Suggested install path'); + lines.push('`~/.codex/skills/' + slug + '/SKILL.md`'); + return lines.join('\n'); + } + function openSkillDraft(groupKey) { + var lessons = (state.lessons.items || []).filter(function(l) { + var key = l.project || (l.tags && l.tags[0]) || '通用经验'; + return key === groupKey; + }); + var draft = buildSkillDraft(groupKey, lessons); + var modal = document.getElementById('modal'); + var overlay = document.getElementById('modal-overlay'); + modal.innerHTML = + '

Skill 草稿

' + + '

这是从已确认经验生成的草稿,不会自动写入本地 Skill。确认适合长期复用后,再复制到对应 Skill 目录。

' + + '
' + esc(draft) + '
' + + ''; + overlay.classList.add('open'); + } + + function openAddLesson() { + var modal = document.getElementById('modal'); + var overlay = document.getElementById('modal-overlay'); + var projectOptions = ''; + (state.lessons.projects || []).forEach(function(projectKey) { + projectOptions += ''; + }); + modal.innerHTML = + '

创建经验

' + + '

写下一条以后可以复用、也可能沉淀进本地 Skill 的做法。

' + + '
' + + '' + + '' + + '
' + + '' + + '' + + '
' + + '
' + + '
' + + ''; + overlay.classList.add('open'); + setTimeout(function() { + var input = document.getElementById('add-lesson-content'); + if (input) input.focus(); + }, 0); + } + + async function saveNewLesson() { + var err = document.getElementById('add-lesson-error'); + var content = (document.getElementById('add-lesson-content') || {}).value || ''; + var context = (document.getElementById('add-lesson-context') || {}).value || ''; + var tags = (document.getElementById('add-lesson-tags') || {}).value || ''; + var project = (document.getElementById('add-lesson-project') || {}).value || ''; + if (!content.trim()) { + if (err) { + err.style.display = 'block'; + err.textContent = '先写一条经验再保存。'; + } + return; + } + var result = await apiPost('lessons', { + content: content.trim(), + context: context.trim(), + tags: tags.split(',').map(function(t) { return t.trim(); }).filter(Boolean), + project: project.trim() || undefined, + confidence: 0.85 + }); + if (!result || result.error) { + if (err) { + err.style.display = 'block'; + err.textContent = (result && result.error) || '保存失败,请稍后再试。'; + } + return; + } + closeModal(); + state.lessons.loaded = false; + await loadLessons(); + } + + async function openSkillDetail(path) { + var modal = document.getElementById('modal'); + var overlay = document.getElementById('modal-overlay'); + modal.innerHTML = '

Skill 详情

正在读取本地 SKILL.md...

'; + overlay.classList.add('open'); + var detail = await apiGet('local-skill-detail?path=' + encodeURIComponent(path || '')); + if (!detail || detail.error) { + modal.innerHTML = '

Skill 详情

' + esc((detail && detail.error) || '读取失败') + '

'; + return; + } + var html = '

' + esc(detail.name || 'Skill') + '

'; + if (detail.description) html += '

' + esc(detail.description) + '

'; + html += '
'; + html += '
'; + html += '
来源文件
' + esc(detail.path || '') + '
'; + html += '
最近修改
' + esc(formatTime(detail.updatedAt)) + '
'; + if (detail.argumentHint) html += '
参数提示
' + esc(detail.argumentHint) + '
'; + html += '
'; + if (detail.headings && detail.headings.length) { + html += '
结构
'; + detail.headings.forEach(function(h) { html += '' + esc(h) + ''; }); + html += '
'; + } + html += '
'; + html += '
内容预览
'; + html += '
' + esc(detail.preview || '暂无内容') + '
'; + html += '
'; + html += ''; + modal.innerHTML = html; + } + diff --git a/src/viewer/parts/app/58-legacy-lessons.js b/src/viewer/parts/app/58-legacy-lessons.js new file mode 100644 index 00000000..519b830d --- /dev/null +++ b/src/viewer/parts/app/58-legacy-lessons.js @@ -0,0 +1,157 @@ + async function loadLessons() { + var el = document.getElementById('view-lessons'); + el.innerHTML = '
正在加载经验...
'; + var results = await Promise.all([apiGet('lessons?minConfidence=0.5'), apiGet('sessions')]); + var explicitLessons = (results[0] && results[0].lessons) || []; + var sessions = (results[1] && results[1].sessions) || []; + var projects = {}; + sessions.forEach(function(s) { + var key = sessionProjectKey(s); + if (key) projects[key] = true; + }); + state.lessons.items = explicitLessons; + state.lessons.projects = Object.keys(projects).sort(function(a, b) { return projectDisplayName(a).localeCompare(projectDisplayName(b)); }); + state.lessons.mode = explicitLessons.length ? 'explicit' : 'empty'; + state.lessons.loaded = true; + renderLessons(); + } + + function renderLessons() { + var el = document.getElementById('view-lessons'); + var items = state.lessons.items; + var search = state.lessons.search.toLowerCase(); + + if (search) { + items = items.filter(function(l) { + return (l.content + ' ' + l.context + ' ' + (l.tags || []).join(' ')).toLowerCase().indexOf(search) >= 0; + }); + } + + var groups = {}; + items.forEach(function(l) { + var tags = (l.tags || []).filter(Boolean); + var key = tags[0] || l.project || '通用工作方式'; + if (!groups[key]) groups[key] = []; + groups[key].push(l); + }); + var groupKeys = Object.keys(groups).sort(function(a, b) { return groups[b].length - groups[a].length; }); + + var skillQuery = String(state.lessons.skillSearch || '').trim().toLowerCase(); + var skillRootFilter = state.lessons.skillRootFilter || 'all'; + function skillMatches(skill) { + var rootOk = skillRootFilter === 'all' || String(skill.root || '') === skillRootFilter; + if (!rootOk) return false; + if (!skillQuery) return true; + return String(skill.name || '').toLowerCase().indexOf(skillQuery) >= 0 || String(skill.path || '').toLowerCase().indexOf(skillQuery) >= 0 || String(skill.root || '').toLowerCase().indexOf(skillQuery) >= 0; + } + function skillRootMeta(root) { + var known = { + Codex: { title: 'Codex 环境', sub: '这个 agent 会加载' }, + Agents: { title: '共享目录', sub: '多个 agent 可复用' }, + Plugin: { title: '插件提供', sub: '随插件一起安装' } + }; + return known[root] || { title: root || '其他来源', sub: '自定义 agent / skill 目录' }; + } + var rootOrder = { Codex: 1, Agents: 2, Plugin: 99 }; + var allSkillRoots = Array.from(new Set((LOCAL_SKILLS || []).map(function(skill) { return skill.root || '其他'; }))).sort(function(a, b) { + return (rootOrder[a] || 50) - (rootOrder[b] || 50) || a.localeCompare(b); + }); + var visibleSkills = (LOCAL_SKILLS || []).filter(skillMatches); + var skillGroups = {}; + visibleSkills.forEach(function(skill) { + if (!skillGroups[skill.root]) skillGroups[skill.root] = []; + skillGroups[skill.root].push(skill); + }); + var skillRoots = Object.keys(skillGroups).sort(function(a, b) { + return (rootOrder[a] || 50) - (rootOrder[b] || 50) || a.localeCompare(b); + }); + + var html = '
'; + html += '
'; + html += '
本地 Skill 管理台
'; + html += '
先看已经安装的能力,再决定哪些经验值得沉淀进去。
'; + html += '
' + (LOCAL_SKILLS || []).length + ' 个 Skill
'; + html += '
'; + html += '
'; + + html += '
'; + html += '
' + (LOCAL_SKILLS || []).length + '
已安装 Skill
'; + html += '
' + allSkillRoots.length + '
来源目录
'; + html += '
' + items.length + '
待沉淀经验
'; + html += '
'; + + html += '
'; + html += ''; + allSkillRoots.forEach(function(root) { + var meta = skillRootMeta(root); + html += ''; + }); + html += '
'; + + html += '
'; + html += ''; + html += '
'; + + html += '
'; + if (skillRoots.length === 0) { + html += '
没有匹配的 Skill
'; + } + skillRoots.forEach(function(root) { + var list = (skillGroups[root] || []).slice(); + var meta = skillRootMeta(root); + html += '
'; + html += '
' + esc(meta.title) + '
' + esc(meta.sub) + '
' + list.length + '
'; + html += '
'; + list.forEach(function(skill) { + html += '
'; + html += '
' + esc(skill.name) + '
' + esc(skill.path) + '
'; + html += '
'; + html += '
'; + }); + html += '
'; + }); + html += '
'; + + html += '
可沉淀经验
这些是从工作里提炼出的规则,可以继续合并进某个本地 Skill。
' + items.length + ' 条经验
'; + + html += '
'; + html += ''; + html += '
'; + + if (items.length === 0) { + html += '
' + + '
💡
' + + '
还没有可沉淀的经验
' + + '
当某条做法被确认有复用价值,它会先进入这里,再决定是否写进某个本地 Skill。
' + + '
'; + } else { + html += '
'; + groupKeys.forEach(function(key) { + var group = groups[key].slice().sort(function(a, b) { return (b.confidence || 0) - (a.confidence || 0); }); + html += '
'; + html += '
' + esc(key) + '
' + group.length + ' 条
'; + html += '
    '; + group.slice(0, 4).forEach(function(l) { + var lessonContent = translateLessonText(l.content); + html += '
  • ' + esc(truncate(lessonContent, 120)) + '
  • '; + }); + if (group.length > 4) html += '
  • 还有 ' + (group.length - 4) + ' 条相近经验
  • '; + html += '
'; + html += '
'; + html += '
'; + }); + html += '
'; + } + html += '
'; + + var __focus = captureSearchFocus(['skills-search', 'lessons-search']); + el.innerHTML = html; + var __ss = document.getElementById('skills-search'); + if (__ss) bindImeSafeSearch(__ss, 200, function(v){ state.lessons.skillSearch = v; renderLessons(); }); + var __ls = document.getElementById('lessons-search'); + if (__ls) bindImeSafeSearch(__ls, 200, function(v){ state.lessons.search = v; renderLessons(); }); + restoreSearchFocus(__focus); + } + diff --git a/src/viewer/parts/app/60-actions-todo.js b/src/viewer/parts/app/60-actions-todo.js new file mode 100644 index 00000000..75f61d1c --- /dev/null +++ b/src/viewer/parts/app/60-actions-todo.js @@ -0,0 +1,1150 @@ + async function loadActions(opts) { + opts = opts || {}; + var el = document.getElementById('view-actions'); + el.innerHTML = '
' + esc(t('act.extract.loading')) + '
'; + var results = await Promise.all([ + apiGet('actions'), + apiGet('frontier'), + apiGet('review?status=pending&kind=action&limit=200'), + apiGet('inbox?status=awaiting&limit=50'), + apiGet('inbox?status=answered&limit=50'), + apiGet('inbox?status=dismissed&limit=50') + ]); + var explicitActions = (results[0] && results[0].actions) || []; + var frontier = (results[1] && (results[1].frontier || results[1].actions)) || []; + var reviewItems = ((results[2] && results[2].items) || []).filter(function(item) { + return item && item.status === 'pending' && item.kind === 'action' && isActionReviewRenderable(item); + }); + + state.actions.items = explicitActions; + state.actions.reviewItems = reviewItems; + state.actions.frontier = frontier; + state.actions.loaded = true; + state.actions.stale = false; + state.inbox.awaitingItems = (results[3] && results[3].items) || []; + state.inbox.answeredItems = (results[4] && results[4].items) || []; + state.inbox.dismissedItems = (results[5] && results[5].items) || []; + state.inbox.items = state.inbox.awaitingItems; + state.inbox.loaded = true; + renderActions(); + if (state.settings.open) { + loadTodoExtractorConfig().then(renderSettingsPanel).catch(function() {}); + } + if (opts.generate === true) startTodoExtraction(opts.force === true); + } + + function todoExtractionDelta(result) { + if (!result || result.success !== true) return 0; + return Number(result.directCreated || 0) + Number(result.reviewCreated || 0); + } + + function todoExtractionSummary(result) { + if (!result || result.success !== true) return t('act.extract.failedExisting'); + var delta = todoExtractionDelta(result); + var hidden = Number(result.hiddenHistory || 0); + var discarded = Number(result.discarded || 0); + var cleaned = Number(result.cleanedActions || 0) + Number(result.cleanedReviews || 0); + var engine = result.engine || 'rules'; + var prefix = engine === 'langextract' ? t('act.extract.doneLlm') : (engine === 'mixed' ? t('act.extract.doneMixed') : t('act.extract.doneRules')); + var parts = [prefix, t('act.extract.created') + ' ' + delta, t('act.extract.history') + ' ' + hidden, t('act.extract.discarded') + ' ' + discarded]; + if (cleaned > 0) parts.push(t('act.extract.cleaned') + ' ' + cleaned); + if (result.fallbackReason) parts.push(t('act.extract.reason') + ': ' + result.fallbackReason); + return parts.join(' · '); + } + + function todoExtractionUsedLlm(result) { + return !!result && (result.engine === 'langextract' || result.engine === 'mixed') && !result.llmFallback; + } + + function refreshActionListsAfterExtract() { + return Promise.all([ + apiGet('actions'), + apiGet('frontier'), + apiGet('review?status=pending&kind=action&limit=200') + ]).then(function(results) { + state.actions.items = (results[0] && results[0].actions) || state.actions.items || []; + state.actions.frontier = (results[1] && (results[1].frontier || results[1].actions)) || state.actions.frontier || []; + state.actions.reviewItems = ((results[2] && results[2].items) || []).filter(function(item) { + return item && item.status === 'pending' && item.kind === 'action' && isActionReviewRenderable(item); + }); + return null; + }); + } + + function actionsScrolledAway() { + try { + var y = window.pageYOffset || + (document.documentElement && document.documentElement.scrollTop) || + (document.body && document.body.scrollTop) || + 0; + return state.activeTab === 'actions' && y > 80; + } catch (_) { + return false; + } + } + + function loadTodoExtractorConfig() { + return apiGet('config/todo-extractor').then(function(res) { + if (res && res.config) state.actions.config = res; + return res; + }); + } + + function renderSettingsPanel() { + var panel = document.getElementById('settings-panel'); + var gear = document.getElementById('settings-gear'); + if (!panel) return; + if (gear) gear.setAttribute('aria-expanded', state.settings.open ? 'true' : 'false'); + panel.setAttribute('aria-hidden', state.settings.open ? 'false' : 'true'); + panel.classList.toggle('open', !!state.settings.open); + if (!state.settings.open) { + panel.innerHTML = ''; + return; + } + var cfg = (state.actions.config && state.actions.config.config) || {}; + var draft = state.actions.configDraft || {}; + var value = function(key, fallback) { + if (Object.prototype.hasOwnProperty.call(draft, key)) return esc(draft[key] || ''); + return esc(cfg[key] || fallback || ''); + }; + var keyLabel = cfg.LANGEXTRACT_API_KEY_CONFIGURED ? t('settings.apiKeyKeep') : t('settings.apiKeyMissing'); + var maskedKey = cfg.LANGEXTRACT_API_KEY_MASKED || ''; + var html = '
'; + html += '
' + esc(t('settings.title')) + '
' + esc(t('settings.subtitle')) + '
'; + html += ''; + html += '
'; + html += '
' + esc(t('settings.language')) + '
'; + html += '
'; + html += ''; + html += ''; + html += '
'; + html += '
' + esc(t('settings.extractor')) + '
'; + html += '
'; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + html += '
' + esc(t('settings.sinceDays')) + '
'; + html += '
' + esc(t('settings.maxInteractions')) + '
'; + html += '
'; + if (maskedKey) html += '
' + esc(t('settings.apiKeyLabel')) + ' ' + esc(maskedKey) + '
'; + html += '
'; + html += '
'; + html += ''; + if (state.actions.config && state.actions.config.envPath) html += '' + esc(state.actions.config.envPath) + ''; + html += '
'; + if (state.actions.extractMessage) html += '
' + esc(state.actions.extractMessage) + '
'; + html += '
'; + panel.innerHTML = html; + } + + function captureTodoConfigDraft() { + var draft = state.actions.configDraft || {}; + [ + 'AGENTMEMORY_TODO_EXTRACTOR', + 'LANGEXTRACT_PYTHON', + 'LANGEXTRACT_PROVIDER', + 'LANGEXTRACT_MODEL', + 'LANGEXTRACT_BASE_URL', + 'LANGEXTRACT_THINKING_DEPTH', + 'AGENTMEMORY_TODO_EXTRACT_TIMEOUT_MS', + 'AGENTMEMORY_TODO_EXTRACT_SINCE_DAYS', + 'AGENTMEMORY_TODO_EXTRACT_MAX_INTERACTIONS_PER_SESSION', + 'LANGEXTRACT_API_KEY' + ].forEach(function(key) { + var el = document.getElementById('todo-config-' + key); + if (el) draft[key] = String(el.value || ''); + }); + state.actions.configDraft = draft; + return draft; + } + + function saveTodoExtractorConfig() { + var fields = [ + 'AGENTMEMORY_TODO_EXTRACTOR', + 'LANGEXTRACT_PYTHON', + 'LANGEXTRACT_PROVIDER', + 'LANGEXTRACT_MODEL', + 'LANGEXTRACT_BASE_URL', + 'LANGEXTRACT_THINKING_DEPTH', + 'AGENTMEMORY_TODO_EXTRACT_TIMEOUT_MS', + 'AGENTMEMORY_TODO_EXTRACT_SINCE_DAYS', + 'AGENTMEMORY_TODO_EXTRACT_MAX_INTERACTIONS_PER_SESSION', + 'LANGEXTRACT_API_KEY' + ]; + var body = {}; + var draft = captureTodoConfigDraft(); + fields.forEach(function(key) { + if (String(draft[key] || '').trim()) body[key] = String(draft[key] || '').trim(); + }); + state.actions.configSaving = true; + renderSettingsPanel(); + apiPost('config/todo-extractor', body).then(function(res) { + if (res && res.config) state.actions.config = res; + state.actions.configDraft = {}; + state.actions.extractMessage = t('settings.savedRestart'); + }).catch(function() { + state.actions.extractStatus = 'error'; + state.actions.extractMessage = t('settings.saveFailed'); + }).then(function() { + state.actions.configSaving = false; + renderSettingsPanel(); + if (state.activeTab === 'actions') renderActions(); + }); + } + + function updateActionStatus(actionId, status) { + if (!actionId || !status) return; + apiPost('actions/update', { actionId: actionId, status: status }).then(function(res) { + if (!res || res.success === false) { + state.actions.extractStatus = 'error'; + state.actions.extractMessage = t('act.status.updateFailed'); + renderActions(); + return; + } + state.actions.items = (state.actions.items || []).map(function(a) { + return a.id === actionId ? Object.assign({}, a, { status: status, updatedAt: new Date().toISOString() }) : a; + }); + renderActions(); + }).catch(function() { + state.actions.extractStatus = 'error'; + state.actions.extractMessage = t('act.status.updateFailed'); + renderActions(); + }); + } + + function startTodoExtraction(force) { + if (state.actions.extractInFlight) return; + state.actions.extractInFlight = true; + state.actions.extractStatus = 'running'; + state.actions.extractMessage = t('act.extract.starting'); + if (state.activeTab === 'actions') renderActions(); + var softRefreshDone = false; + var softRefreshTimer = setTimeout(function() { + if (!state.actions.extractInFlight || softRefreshDone) return; + softRefreshDone = true; + state.actions.extractMessage = t('act.extract.background'); + if (actionsScrolledAway()) { + state.actions.stale = true; + return; + } + if (state.activeTab === 'actions') renderActions(); + refreshActionListsAfterExtract().then(function() { + if (!state.actions.extractInFlight) return; + if (actionsScrolledAway()) { + state.actions.stale = true; + return; + } + if (state.activeTab === 'actions') renderActions(); + }).catch(function() {}); + }, 12000); + // STEP-11: scope is governed by saved settings (sinceDays + max + // interactions per session) which the backend reads from env config; + // don't hard-code maxSessions/maxObservationsPerSession here or the + // settings would never take effect on this primary extraction path. + apiPost('todo-extract/generate', { + force: force === true + }).then(function(result) { + var delta = todoExtractionDelta(result); + if (!result || result.success !== true) { + state.actions.extractStatus = 'error'; + state.actions.extractMessage = t('act.extract.failedExisting'); + return null; + } + state.actions.extractStatus = 'done'; + state.actions.extractFallback = !todoExtractionUsedLlm(result); + state.actions.extractMessage = todoExtractionSummary(result); + if (actionsScrolledAway()) { + state.actions.stale = delta > 0; + return null; + } + return refreshActionListsAfterExtract(); + }).catch(function() { + state.actions.extractStatus = 'error'; + state.actions.extractMessage = t('act.extract.failedExisting'); + }).then(function() { + clearTimeout(softRefreshTimer); + state.actions.extractInFlight = false; + if (state.activeTab === 'actions' && !actionsScrolledAway()) { + renderActions(); + } else if (state.activeTab !== 'actions') { + state.actions.loaded = false; + } + }); + } + + function todoCleanupSummary(r) { + if (!r) return ''; + return t('act.cleanup.summary') + .replace('{dropped}', r.dropped || 0) + .replace('{completed}', r.completed || 0) + .replace('{rewritten}', r.rewritten || 0) + .replace('{merged}', r.merged || 0); + } + + // LLM card update (STEP-12): re-judge cards whose source session changed. + // Always dry-run first so the user confirms before anything is dropped/closed; + // apply only on confirm (or silently when there is nothing to confirm — see + // the all-KEEP branch below). + function startTodoUpdate() { + if (state.actions.cleanupInFlight || state.actions.extractInFlight) return; + state.actions.cleanupInFlight = true; + state.actions.cleanupStatus = 'running'; + state.actions.cleanupMessage = t('act.cleanup.running'); + if (state.activeTab === 'actions') renderActions(); + apiPost('todo/update', { mode: 'dry-run' }).then(function(preview) { + state.actions.cleanupInFlight = false; + if (!preview || typeof preview.scanned !== 'number') { + state.actions.cleanupStatus = 'error'; + state.actions.cleanupMessage = t('act.cleanup.failed'); + if (state.activeTab === 'actions') renderActions(); + return; + } + var touched = (preview.dropped || 0) + (preview.completed || 0) + (preview.rewritten || 0) + (preview.merged || 0); + if (touched === 0) { + if (preview.fallbackReason) { + // LLM unavailable — nothing ran, nothing to advance. + state.actions.cleanupStatus = 'done'; + state.actions.cleanupMessage = t('act.cleanup.llmUnavailable'); + if (state.activeTab === 'actions') renderActions(); + return; + } + if ((preview.scanned || 0) > 0) { + // Changed cards existed but the LLM kept them all. Silently apply so + // their checkpoints advance and they aren't re-evaluated (and re-sent + // to the LLM) on every future click. No card content changes, so no + // confirmation is needed. + state.actions.cleanupInFlight = true; + apiPost('todo/update', { mode: 'apply', decisions: preview.decisions }).then(function() { + return refreshActionListsAfterExtract(); + }).catch(function() {}).then(function() { + state.actions.cleanupInFlight = false; + state.actions.cleanupStatus = 'done'; + state.actions.cleanupMessage = t('act.cleanup.clean'); + if (state.activeTab === 'actions') renderActions(); + }); + return; + } + // Nothing changed at all. + state.actions.cleanupStatus = 'done'; + state.actions.cleanupMessage = t('act.cleanup.clean'); + if (state.activeTab === 'actions') renderActions(); + return; + } + if (!window.confirm(t('act.cleanup.confirm') + '\n\n' + todoCleanupSummary(preview))) { + state.actions.cleanupStatus = 'idle'; + state.actions.cleanupMessage = ''; + if (state.activeTab === 'actions') renderActions(); + return; + } + state.actions.cleanupInFlight = true; + state.actions.cleanupStatus = 'running'; + state.actions.cleanupMessage = t('act.cleanup.applying'); + if (state.activeTab === 'actions') renderActions(); + apiPost('todo/update', { mode: 'apply', decisions: preview.decisions }).then(function(result) { + state.actions.cleanupStatus = 'done'; + state.actions.cleanupMessage = todoCleanupSummary(result || preview); + return refreshActionListsAfterExtract(); + }).catch(function() { + state.actions.cleanupStatus = 'error'; + state.actions.cleanupMessage = t('act.cleanup.failed'); + }).then(function() { + state.actions.cleanupInFlight = false; + if (state.activeTab === 'actions') renderActions(); + }); + }).catch(function() { + state.actions.cleanupInFlight = false; + state.actions.cleanupStatus = 'error'; + state.actions.cleanupMessage = t('act.cleanup.failed'); + if (state.activeTab === 'actions') renderActions(); + }); + } + + // 待办→证据跳转:待办卡指向裸 obs_id,证据栏按 session 组织, + // 后端无 obs_id→session 反查端点。遍历已加载会话,缓存优先、未命中才请求,命中即停。 + async function resolveObsSession(obsId) { + if (!obsId) return null; + var sessions = state.sessions.items || []; + for (var i = 0; i < sessions.length; i++) { + var s = sessions[i]; + var id = sessionId(s); + if (!id) continue; + var obs = null; + var cached = state.sessions.detailCacheById && state.sessions.detailCacheById[id]; + if (cached && Array.isArray(cached.observations)) { + obs = cached.observations; + } else if (Array.isArray(s.embeddedObservations) && s.embeddedObservations.length) { + obs = s.embeddedObservations; + } + if (obs && obs.some(function(o) { return o && o.id === obsId; })) return id; + if (obs) continue; + try { + var res = await apiGet('observations?sessionId=' + encodeURIComponent(id)); + var list = (res && res.observations) || []; + if (list.some(function(o) { return o && o.id === obsId; })) return id; + } catch (e) { /* 单个会话取失败不阻断扫描 */ } + } + return null; + } + + async function jumpToEvidence(obsId) { + if (!obsId) return; + if (!state.sessions.loaded) await loadSessions({ showLoading: false, reason: 'jump' }); + var targetSessionId = await resolveObsSession(obsId); + if (!targetSessionId) { + flashHint('未找到这条待办的来源会话(可能尚未同步或已被过滤)'); + return; + } + state.sessions.selectedId = targetSessionId; + state.sessions.pendingHighlightObsId = obsId; + // 来源观测画在「完整对话过程」区,默认折叠 → 跳转前强制展开,否则锚点不存在 + if (!state.sessions.detailSectionsById) state.sessions.detailSectionsById = {}; + var sec = state.sessions.detailSectionsById[targetSessionId] || { highlights: true, process: false }; + sec.process = true; + state.sessions.detailSectionsById[targetSessionId] = sec; + switchTab('sessions'); + renderSessions(); + await renderSessionDetail(); + } + + function flashHint(message) { + var el = document.getElementById('jump-hint'); + if (!el) { + el = document.createElement('div'); + el.id = 'jump-hint'; + el.setAttribute('role', 'status'); + el.setAttribute('aria-live', 'polite'); + el.style.cssText = 'position:fixed;left:50%;bottom:28px;transform:translateX(-50%);background:var(--ink,#1a1a1a);color:#fff;padding:10px 16px;border-radius:8px;font-size:13px;z-index:9999;box-shadow:0 4px 16px rgba(0,0,0,0.2);max-width:80vw;'; + (document.body || document.documentElement).appendChild(el); + } + el.textContent = message; + el.style.opacity = '1'; + if (flashHint._t) clearTimeout(flashHint._t); + flashHint._t = setTimeout(function() { if (el) el.style.opacity = '0'; }, 2600); + } + + function applyPendingHighlight() { + var obsId = state.sessions.pendingHighlightObsId; + if (!obsId) return; + state.sessions.pendingHighlightObsId = null; + var anchor = document.getElementById('obs-anchor-' + obsId); + if (!anchor) return; + if (typeof anchor.scrollIntoView === 'function') anchor.scrollIntoView({ behavior: 'smooth', block: 'center' }); + anchor.classList.add('obs-jump-highlight'); + setTimeout(function() { anchor.classList.remove('obs-jump-highlight'); }, 2400); + } + + // STEP-C2/C3 待回应分区:接真实 inbox 数据(GET /agentmemory/inbox?status=awaiting)。 + // 按 kind 分两子区——question(🔴 Agent 在等你回)与 briefing(📋 Agent 主动整理), + // 均按 createdAt 倒序(本轮不按 priority)。C3 加动作:question 可「回应」(行内输入 + // → inbox-answer)/「转待处理」(inbox-dismiss + action-create)/「知道了」(空 answer); + // briefing 可「知道了」/「转待处理」。「看原文 →」复用 STEP-03。 + function inboxFromLabel(item) { + var from = item && item.fromAgent ? String(item.fromAgent) : ''; + return from ? '来自 ' + esc(from) : ''; + } + function inboxEvidenceButton(item) { + var ids = (item && item.sourceObservationIds) || []; + if (!ids.length) return ''; + return ''; + } + // STEP-D4 飞书投递状态徽标(只读,低干扰)。数据来自 GET /inbox 里 join 的 delivery + // 台账(mem:delivery)。sent→已推送✓;failed→推送失败⚠+短错误摘要;skipped(投递 + // 未开启/无配置)默认不显示,避免污染 UI。 + function inboxDeliveryBadge(item) { + var d = item && item.delivery; + if (!d || !d.status) return ''; + if (d.status === 'sent') { + var u = d.urgent ? ' · 加急' : ''; + return '已推送 ✓' + esc(u) + ''; + } + if (d.status === 'failed') { + var err = d.error ? String(d.error) : ''; + if (err.length > 60) err = err.slice(0, 60) + '…'; + var titleErr = d.error ? ' title="' + esc(String(d.error)) + '"' : ''; + return '推送失败 ⚠' + + (err ? ' ' + esc(err) + '' : '') + ''; + } + return ''; // skipped → silent + } + function inboxAwaiting() { + var s = state.inbox || {}; + if (Array.isArray(s.awaitingItems) && s.awaitingItems.length) return s.awaitingItems; + return Array.isArray(s.items) ? s.items : []; + } + function inboxAnswered() { + var s = state.inbox || {}; + return Array.isArray(s.answeredItems) ? s.answeredItems : []; + } + function inboxDismissed() { + var s = state.inbox || {}; + return Array.isArray(s.dismissedItems) ? s.dismissedItems : []; + } + function sortInboxItems(items) { + return (items || []).slice().sort(function(a, b) { + return new Date((b && (b.answeredAt || b.dismissedAt || b.updatedAt || b.createdAt)) || 0).getTime() - + new Date((a && (a.answeredAt || a.dismissedAt || a.updatedAt || a.createdAt)) || 0).getTime(); + }); + } + function inboxSearchText(item) { + return [ + item && item.body, + item && item.answer, + item && item.fromAgent, + item && item.status, + item && item.kind, + ].filter(Boolean).join(' ').toLowerCase(); + } + function filterInboxItems(items, search) { + if (!search) return items || []; + return (items || []).filter(function(item) { return inboxSearchText(item).indexOf(search) >= 0; }); + } + function inboxRecent(item) { + var ts = item && (item.answeredAt || item.dismissedAt || item.updatedAt || item.createdAt); + if (!ts) return false; + var t = new Date(ts).getTime(); + return Number.isFinite(t) && Date.now() - t <= 24 * 60 * 60 * 1000; + } + function renderInboxCard(item, kind) { + var marker = kind === 'question' ? '🔴' : '📋'; + var from = inboxFromLabel(item); + var id = (item && item.id) || ''; + var html = '
'; + html += '
'; + html += ''; + if (from) html += '' + from + ''; + if (item && item.createdAt) html += '' + esc(shortTime(item.createdAt)) + ''; + html += inboxDeliveryBadge(item); + html += '
'; + html += '
' + renderMarkdownSafe((item && item.body) || '') + '
'; + + var pending = isInboxPending(id); + var dis = pending ? ' disabled aria-busy="true"' : ''; + html += '
'; + if (kind === 'question') { + html += ''; + html += ''; + html += ''; + } else { + html += ''; + html += ''; + } + html += inboxEvidenceButton(item); + html += '
'; + + if (kind === 'question' && state.inbox.replyingId === id) { + var sd = pending ? ' disabled aria-busy="true"' : ''; + html += '
'; + html += ''; + html += '
'; + html += ''; + html += ''; + html += '
'; + } + html += '
'; + return html; + } + function renderInboxArchiveCard(item) { + var kind = item && item.kind === 'question' ? 'question' : 'briefing'; + var answered = item && item.status === 'answered'; + var marker = kind === 'question' ? '✅' : '✓'; + var cls = answered && kind === 'question' ? 'inbox-card-answered' : 'inbox-card-known'; + var from = inboxFromLabel(item); + var ts = (item && (item.answeredAt || item.dismissedAt || item.updatedAt || item.createdAt)) || ''; + var html = '
'; + html += '
'; + html += ''; + if (from) html += '' + from + ''; + if (ts) html += '' + esc(shortTime(ts)) + ''; + html += '
'; + html += '
' + renderMarkdownSafe((item && item.body) || '') + '
'; + if (answered && kind === 'question') { + html += '
你已回复:' + esc((item && item.answer) || '知道了') + '
'; + } + html += '
'; + return html; + } + function renderAwaitingQuestionSection() { + var search = (state.actions.search || '').toLowerCase(); + var items = filterInboxItems(sortInboxItems(inboxAwaiting()), search); + var questions = items.filter(function(i) { return i && i.kind === 'question'; }); + if (!questions.length) return ''; + var html = '
'; + html += '
'; + html += '
待回应 (' + questions.length + ')
'; + html += '
Agent 运行中抛给你的、时间敏感的问题会汇集到这里。
'; + html += '
Agent 在等你回
'; + html += '
'; + questions.forEach(function(it) { html += renderInboxCard(it, 'question'); }); + html += '
'; + return html; + } + function renderAwaitingBriefingSection() { + var search = (state.actions.search || '').toLowerCase(); + var items = filterInboxItems(sortInboxItems(inboxAwaiting()), search); + var briefings = items.filter(function(i) { return i && i.kind === 'briefing'; }); + if (!briefings.length) return ''; + var bExpanded = !!state.inbox.briefingExpanded || !!search; + var html = '
'; + html += ''; + if (bExpanded) { + html += '
'; + briefings.forEach(function(it) { html += renderInboxCard(it, 'briefing'); }); + html += '
'; + } + html += '
'; + return html; + } + function renderInboxArchiveSection() { + var search = (state.actions.search || '').toLowerCase(); + var answered = inboxAnswered(); + var dismissed = inboxDismissed(); + var all = sortInboxItems(answered.concat(dismissed)); + var visible = filterInboxItems(all, search); + if (!visible.length) return ''; + var answeredQuestions = visible.filter(function(i) { return i && i.kind === 'question' && i.status === 'answered'; }); + var known = visible.filter(function(i) { return !(i && i.kind === 'question' && i.status === 'answered'); }); + var recentCount = visible.filter(inboxRecent).length; + var expanded = !!state.inbox.answeredExpanded || !!search; + var title = answeredQuestions.length ? '已回应 ' + answeredQuestions.length + ' 条' : '已回应'; + if (recentCount && !expanded) title += ' · 最近 ' + recentCount + ' 条'; + var html = '
'; + html += ''; + if (expanded) { + if (answeredQuestions.length) { + html += '
'; + answeredQuestions.forEach(function(it) { html += renderInboxArchiveCard(it); }); + html += '
'; + } + if (known.length) { + html += '
已知悉 (' + known.length + ')briefing / 已消解
'; + html += '
'; + known.forEach(function(it) { html += renderInboxArchiveCard(it); }); + html += '
'; + } + } + html += '
'; + return html; + } + function renderAwaitingReplySection() { + var search = (state.actions.search || '').toLowerCase(); + var items = filterInboxItems(sortInboxItems(inboxAwaiting()), search); + var questions = items.filter(function(i) { return i && i.kind === 'question'; }); + var briefings = items.filter(function(i) { return i && i.kind === 'briefing'; }); + + // 搜索时无命中:整区不渲染,避免空壳占位干扰搜索结果。 + if (search && !items.length) return ''; + + var html = '
'; + html += '
'; + html += '
待回应'; + if (questions.length) html += ' (' + questions.length + ')'; + html += '
'; + html += '
Agent 运行中抛给你的、时间敏感的问题会汇集到这里。
'; + html += '
'; + if (questions.length) html += 'Agent 在等你回'; + html += '
'; + + if (!items.length) { + html += '
'; + html += '
暂无待回应
'; + html += '
Agent 在会话中抛给你、在等你回的问题会出现在这里。目前没有待回应的条目。
'; + html += '
'; + html += '
'; + return html; + } + + if (questions.length) { + html += '
'; + questions.forEach(function(it) { html += renderInboxCard(it, 'question'); }); + html += '
'; + } + if (briefings.length) { + // briefing 知悉即可、优先级低,默认折叠以缩短首屏、让 question 不被压下去。 + // 搜索命中时强制展开(否则命中的 briefing 藏在折叠里看不到)。 + var bExpanded = !!state.inbox.briefingExpanded || !!search; + html += ''; + if (bExpanded) { + html += '
'; + briefings.forEach(function(it) { html += renderInboxCard(it, 'briefing'); }); + html += '
'; + } + } + html += ''; + return html; + } + + // STEP-C3 收件箱动作。每个动作改后端状态后,从 state.inbox.items 本地剔除该项 + + // 重渲染(乐观更新),避免全量 loadActions 抖动;失败用 flashHint 提示。 + function removeInboxItemLocal(id) { + state.inbox.items = (state.inbox.items || []).filter(function(i) { return i && i.id !== id; }); + state.inbox.awaitingItems = (state.inbox.awaitingItems || []).filter(function(i) { return i && i.id !== id; }); + if (state.inbox.replyingId === id) state.inbox.replyingId = null; + } + function findInboxItem(id) { + return inboxAwaiting().filter(function(i) { return i && i.id === id; })[0] || null; + } + function archiveInboxItemLocal(id, status, answer) { + var item = findInboxItem(id); + removeInboxItemLocal(id); + if (!item) return; + var archived = Object.assign({}, item, { + status: status, + answer: answer || item.answer || '', + updatedAt: new Date().toISOString(), + }); + if (status === 'answered') { + archived.answeredAt = archived.updatedAt; + state.inbox.answeredItems = [archived].concat(state.inbox.answeredItems || []); + } else { + archived.dismissedAt = archived.updatedAt; + state.inbox.dismissedItems = [archived].concat(state.inbox.dismissedItems || []); + } + } + // 防重入:动作进行中标记 pendingById[id],按钮渲染为 disabled, + // 慢网下连点不会重复提交(尤其转待处理的 create 会重复建待办)。 + function isInboxPending(id) { + return !!(state.inbox.pendingById && state.inbox.pendingById[id]); + } + function setInboxPending(id, on) { + if (!state.inbox.pendingById) state.inbox.pendingById = {}; + if (on) state.inbox.pendingById[id] = true; + else delete state.inbox.pendingById[id]; + } + function openInboxReply(id) { + state.inbox.replyingId = (state.inbox.replyingId === id) ? null : id; + renderActions(); + if (state.inbox.replyingId === id) { + var input = document.getElementById('inbox-reply-input-' + id); + if (input && typeof input.focus === 'function') input.focus(); + } + } + function cancelInboxReply() { + state.inbox.replyingId = null; + renderActions(); + } + async function submitInboxReply(id) { + if (isInboxPending(id)) return; + var input = document.getElementById('inbox-reply-input-' + id); + var answer = input ? String(input.value || '').trim() : ''; + if (!answer) { flashHint('回应内容不能为空'); if (input && input.focus) input.focus(); return; } + setInboxPending(id, true); + renderActions(); + try { + var res = await apiPost('inbox/answer', { id: id, answer: answer }); + if (!res || res.success !== true) { flashHint('回应未送达,请确认本地 worker 在运行'); return; } + archiveInboxItemLocal(id, 'answered', answer); + flashHint('已回应,Agent 会看到'); + } finally { + setInboxPending(id, false); + renderActions(); + } + } + async function ackInboxItem(id) { + if (isInboxPending(id)) return; + setInboxPending(id, true); + renderActions(); + try { + // 空 answer = 已读/ack(briefing 或无需回的 question)。 + var res = await apiPost('inbox/answer', { id: id }); + if (!res || res.success !== true) { flashHint('操作未送达,请确认本地 worker 在运行'); return; } + archiveInboxItemLocal(id, 'answered', ''); + flashHint('已标记知道了'); + } finally { + setInboxPending(id, false); + renderActions(); + } + } + async function convertInboxToTodo(id) { + if (isInboxPending(id)) return; + // 转待处理:先建 action(标题取 body),成功后再 dismiss inbox 项; + // 串行 + 失败回滚提示,避免「dismiss 了却没建 action」的丢条目。 + var item = findInboxItem(id); + if (!item) return; + setInboxPending(id, true); + renderActions(); + try { + var title = String(item.body || '').trim().slice(0, 120) || '(来自收件箱)'; + var created = await apiPost('actions', { title: title, createdBy: 'inbox', project: item.project }); + if (!created || created.success !== true) { flashHint('转待处理失败:未能创建待办'); return; } + var dismissed = await apiPost('inbox/dismiss', { id: id }); + if (!dismissed || dismissed.success !== true) { + flashHint('已建待办,但收件箱项未消解,请手动「知道了」'); + state.actions.loaded = false; + loadActions({ generate: false }); + return; + } + archiveInboxItemLocal(id, 'dismissed', ''); + state.actions.loaded = false; + loadActions({ generate: false }); + flashHint('已转入待处理'); + } finally { + setInboxPending(id, false); + renderActions(); + } + } + + // STEP-C4「已完成」折叠区:只读现有 action.status==='done' 且当天 updatedAt 的项, + // 默认折叠(§3.2)。不新增抽取器、不动后端,纯前端筛 state.actions.items。 + function isUpdatedToday(ts) { + if (!ts) return false; + var d = new Date(ts); + if (isNaN(d.getTime())) return false; + var now = new Date(); + return d.getFullYear() === now.getFullYear() && + d.getMonth() === now.getMonth() && + d.getDate() === now.getDate(); + } + function renderDoneTodaySection(doneItems, frontierIds, cardRenderer) { + var today = (doneItems || []).filter(function(a) { return isUpdatedToday(a.updatedAt); }); + if (!today.length) return ''; + var expanded = !!state.actions.doneExpanded; + var html = '
'; + html += ''; + if (expanded && typeof cardRenderer === 'function') { + html += '
'; + today.forEach(function(a) { + html += cardRenderer(a, frontierIds.has(a.id)); + }); + html += '
'; + } + html += '
'; + return html; + } + + function renderActions() { + var el = document.getElementById('view-actions'); + var items = (state.actions.items || []).filter(isActionRenderable).slice(); + var reviewItems = (state.actions.reviewItems || []).filter(isActionReviewRenderable); + var search = state.actions.search.toLowerCase(); + var statusFilter = state.actions.statusFilter === 'all' ? '' : state.actions.statusFilter; + var frontierIds = new Set((state.actions.frontier || []).map(function(a) { return a.id; })); + + if (search) { + items = items.filter(function(a) { + return (a.title + ' ' + (a.description || '') + ' ' + (a.tags || []).join(' ') + ' ' + (a.project || '')).toLowerCase().indexOf(search) >= 0; + }); + reviewItems = reviewItems.filter(function(item) { + return ((item.title || '') + ' ' + (item.content || '') + ' ' + (reviewProject(item) || '') + ' ' + reviewTags(item).join(' ')).toLowerCase().indexOf(search) >= 0; + }); + } + var metricItems = items.slice(); + var metricReviewItems = reviewItems.slice(); + if (statusFilter && statusFilter !== 'review' && statusFilter !== 'awaiting') { + // STEP-13: the "待跟进 / Follow-up" metric card counts pending + blocked + // and filters with data-status="pending", so the pending filter must show + // blocked too — otherwise its count and its results disagree. + items = items.filter(function(a) { + return statusFilter === 'pending' ? (a.status === 'pending' || a.status === 'blocked') : a.status === statusFilter; + }); + } + if (statusFilter === 'review' || statusFilter === 'awaiting') { + items = []; + } + var showReviewItems = statusFilter === 'review'; + var showAwaitingItems = !statusFilter || statusFilter === 'awaiting'; + var showBriefingItems = !statusFilter; + + function actionAttentionKey(a, isFrontier) { + if (a.status === 'done' || a.status === 'cancelled') return ''; + if (isFrontier) return 'next'; + if (a.status === 'blocked') return 'needsWork'; + if (a.priority === 'high' || Number(a.priority) >= 8) return 'noteworthy'; + return ''; + } + function actionDescriptionText(text) { + var s = String(text || '').trim(); + if (!s) return ''; + if (I18N_LANG !== 'zh') return s; + var map = [ + [/^Execute launch promotion for GitHub, Xiaohongshu, V2EX, Reddit, X, and other target communities\..*$/i, '继续推进 GitHub、小红书、V2EX、Reddit 和 X 等渠道的发布推广。'], + [/^Execute launch promotion for (.+)$/i, '继续推进 $1 的发布推广。'], + [/^Create a Notion-style continuous-motion 30s README demo[::]?\s*(.+)$/i, '制作 30 秒 README 演示,展示从输入到结果预览的完整路径。'], + [/^Use huashu-design video workflow to produce a short README\/social demo showing how the study abroad applications skill works[::]?\s*(.+)$/i, '制作一段适合 README 和社媒使用的短演示,说明留学申请 Skill 的使用过程。'], + [/^Use huashu-design video workflow to produce a short README\/social demo showing how the study abroad applications skill works\.?$/i, '制作一段适合 README 和社媒使用的短演示,说明留学申请 Skill 的使用过程。'], + [/^Use huashu-design video workflow to produce a short README\/social demo showing how (.+?) works[::]?\s*(.+)$/i, '制作一段适合 README 和社媒使用的短演示,说明 $1 的使用过程。'], + [/^Use huashu-design video workflow to produce a short README\/social demo showing how (.+?) works\.?$/i, '制作一段适合 README 和社媒使用的短演示,说明 $1 的使用过程。'], + [/^来源:(.+)$/i, '来自 $1'], + [/^来自本地审计日志$/i, '来自本地操作记录'] + ]; + for (var i = 0; i < map.length; i++) { + if (map[i][0].test(s)) return s.replace(map[i][0], map[i][1]); + } + return s; + } + function actionSourceText(a) { + var parts = []; + if (a.project) parts.push(projectDisplayName(a.project)); + if (a.source) parts.push(a.source); + return parts.join(' · '); + } + function actionVisibleTags(tags) { + var all = Array.isArray(tags) ? tags.filter(Boolean).map(String) : []; + var visible = []; + function add(tag) { + if (tag && visible.indexOf(tag) < 0) visible.push(tag); + } + all.filter(function(tag) { + return tag === 'todo-extracted' || tag === 'todo-recheck' || /^time:[a-z_]+$/i.test(tag) || /^type:[a-z_]+$/i.test(tag); + }).forEach(add); + all.forEach(function(tag) { + if (visible.length < 5) add(tag); + }); + return visible.slice(0, 5); + } + function actionTitleText(text) { + var s = String(text || t('act.untitled')).trim(); + if (I18N_LANG !== 'zh') return s; + var map = [ + [/^Create README 30s demo GIF and backup MP4 for taught-master-applications-skill$/i, '制作留学申请 Skill 的 README 演示视频'], + [/^Create README 30s demo GIF and backup MP4 for (.+)$/i, '制作 $1 的 README 演示视频'], + [/^Create 30-second demo GIF\/video for taught-master-applications-skill$/i, '制作留学申请 Skill 的 30 秒演示视频'], + [/^Promote\s+(.+?)\s+across target platforms$/i, '推广 $1'], + [/^Build\s+(.+)$/i, '制作 $1'], + [/^Create\s+(.+)$/i, '创建 $1'], + [/^Execute\s+(.+)$/i, '执行 $1'], + [/^Review\s+(.+)$/i, '复盘 $1'], + [/^Check\s+(.+)$/i, '检查 $1'] + ]; + for (var i = 0; i < map.length; i++) { + if (map[i][0].test(s)) return s.replace(map[i][0], map[i][1]); + } + return s; + } + function priorityLabel(value) { + if (value === 'high' || Number(value) >= 8) return t('act.prio.high'); + if (value === 'normal' || Number(value) >= 5) return t('act.prio.normal'); + return t('act.prio.low'); + } + function priorityClass(value) { + if (value === 'high' || Number(value) >= 8) return 'high'; + if (value === 'normal' || Number(value) >= 5) return 'normal'; + return 'low'; + } + function compactActionTitle(text) { + var s = String(text || t('act.untitled')).replace(/^(TODO|FIXME)\s*[::-]\s*/i, '').trim(); + var headingMatch = s.match(/#{1,6}\s*([^#\n]+?)(?=\s+#{1,6}\s|$)/); + if (headingMatch && !/^(Summary|Key Changes|Test Plan|Assumptions|Public API|Implementation)\b/i.test(headingMatch[1].trim())) { + s = headingMatch[1].trim(); + } + s = s + .replace(/#{1,6}\s*(Summary|Key Changes|Test Plan|Assumptions|Public API|Implementation)\b[\s\S]*$/i, '') + .replace(/#{1,6}\s*/g, '') + .replace(/\b(Summary|Key Changes|Test Plan|Assumptions|Public API|Implementation)\b[\s\S]*$/i, '') + .replace(/\s+/g, ' ') + .trim(); + var first = s.split(/[。!?\n]/u)[0] || s; + return truncate(actionTitleText(first.replace(/[。!?;;,.,]+$/u, '').trim() || t('act.untitled')), 72); + } + function stripMarkdownPlanText(text) { + return String(text || '') + .replace(/\s+(#{1,6}\s+)/g, '\n$1') + .split(/\n+/) + .filter(function(line) { + var trimmed = line.trim(); + if (!trimmed) return false; + if (/^#{1,6}\s/.test(trimmed)) return false; + if (/^\s*[-*]\s+/.test(line)) return false; + if (/^(Summary|Key Changes|Test Plan|Assumptions|Public API|Implementation)\b/i.test(trimmed)) return false; + if (/^(执行步骤|验证命令|停止条件|修改范围)[::]?/u.test(trimmed)) return false; + return true; + }) + .join(' ') + .replace(/\s+/g, ' ') + .trim(); + } + function candidatePreviewText(item) { + var title = compactActionTitle(item && item.title); + var content = String((item && item.content) || '').trim(); + if (!content) return ''; + var isStructured = isMarkdownPlanText(content); + var cleaned = isStructured ? stripMarkdownPlanText(content) : content; + if (isStructured) { + var sentence = (cleaned.match(/(?:TODO|FIXME|下一步|后续|待办|修复|补充|实现|调整|验证|提交|创建|更新|移除|处理)[^。!?\n]{4,80}[。!?]?/u) || [])[0]; + cleaned = sentence || ''; + } + cleaned = cleaned + .replace(/^(TODO|FIXME)\s*[::-]\s*/i, '') + .replace(/^(下一步|后续|待办)\s*[::-]?\s*/u, '') + .replace(/^(请|需要|必须)\s*/u, '') + .replace(/\s+/g, ' ') + .trim(); + if (!cleaned || cleaned === title || cleaned.indexOf(title) === 0) return ''; + return truncate(actionDescriptionText(cleaned), 96); + } + function actionMetricCards(counts, reviewCount, waitingCount) { + var metrics = [ + { label: t('act.metric.waiting'), value: waitingCount, filter: 'awaiting', primary: waitingCount > 0 }, + { label: t('act.metric.review'), value: reviewCount, filter: 'review', primary: waitingCount === 0 && reviewCount > 0 }, + { label: t('act.metric.followUp'), value: counts.pending + counts.blocked, filter: 'pending', primary: waitingCount === 0 && reviewCount === 0 && (counts.pending + counts.blocked) > 0 }, + { label: t('act.metric.active'), value: counts.active, filter: 'active', primary: false }, + { label: t('act.metric.done'), value: counts.done, filter: 'done', primary: false } + ]; + var html = '
'; + metrics.forEach(function(m) { + var active = (state.actions.statusFilter || '') === m.filter; + html += ''; + }); + html += '
'; + return html; + } + function renderActionFilters() { + // STEP-13: the status-filter chip row duplicated the metric overview + // cards below (actionMetricCards) — same state.actions.statusFilter, same + // values (pending/review/active/done). Keep the metric cards (they carry + // counts) as the single status filter; this row keeps only search + the + // action buttons. + var html = '
'; + html += ''; + html += '' + metricReviewItems.length + ' ' + t('act.nToConfirm') + ' · ' + metricItems.length + ' ' + t('act.nConfirmed') + ''; + var extractTitle = state.actions.extractMessage || t('act.extract.title'); + var extractLabel = t('act.extract.run'); + if (state.actions.extractInFlight) extractLabel = t('act.extract.running'); + else if (state.actions.extractStatus === 'done') extractLabel = state.actions.extractFallback ? t('act.extract.rules') : t('act.extract.done'); + else if (state.actions.extractStatus === 'error') extractLabel = t('act.extract.error'); + html += ''; + var cleanupLabel = t('act.cleanup.run'); + if (state.actions.cleanupInFlight) cleanupLabel = t('act.cleanup.running'); + else if (state.actions.cleanupStatus === 'done') cleanupLabel = t('act.cleanup.done'); + else if (state.actions.cleanupStatus === 'error') cleanupLabel = t('act.cleanup.error'); + html += ''; + html += ''; + html += '
'; + return html; + } + function renderActionCandidateCard(item) { + var priority = reviewActionPriority(item); + var project = reviewProject(item); + var title = compactActionTitle(item.title || t('act.untitledCandidate')); + var preview = candidatePreviewText(item); + var raw = String(item.content || '').trim(); + var showOriginal = raw && isMarkdownPlanText(raw); + var html = '
'; + html += '
'; + html += '
'; + html += '
' + t('filter.review') + '' + esc(priorityLabel(priority)) + '
'; + html += '
' + esc(title) + '
'; + if (preview) html += '
' + esc(preview) + '
'; + html += '
'; + if (project) html += '' + esc(projectDisplayName(project)) + ''; + html += '
'; + if (showOriginal) { + html += '
' + t('act.viewOriginal') + '
' + esc(truncate(raw, 1200)) + '
'; + } + html += '
'; + html += '
'; + html += '
'; + return html; + } + function renderActionCard(a, isFrontier) { + var html = '
'; + html += '
'; + html += '
'; + html += '
' + esc(compactActionTitle(a.title)) + '
'; + var actionDesc = actionDescriptionText(a.description); + if (actionDesc) html += '
' + esc(truncate(actionDesc, 120)) + '
'; + // STEP-16 calm card: source + relative time are hidden at rest and fade in + // on hover; priority shows on the rail, status via the group. No badges / + // status icon / classification tags. + var metaParts = []; + var sourceText = actionSourceText(a); + if (sourceText) metaParts.push(t('act.from') + ' ' + esc(sourceText)); + if (a.updatedAt) metaParts.push(esc(relativeTime(a.updatedAt))); + if (metaParts.length) html += '
' + metaParts.join(' · ') + '
'; + html += '
'; + html += '
'; + html += '
'; + var jumpObsId = Array.isArray(a.sourceObservationIds) ? a.sourceObservationIds.find(function(id) { return typeof id === 'string' && id.length > 0; }) : ''; + if (jumpObsId) html += ''; + if (a.status !== 'cancelled') html += ''; + html += '
'; + if (a.status !== 'done') html += ''; + html += '
'; + html += '
'; + return html; + } + var statusCounts = { active: 0, blocked: 0, pending: 0, done: 0, cancelled: 0 }; + metricItems.forEach(function(a) { if (statusCounts[a.status] !== undefined) statusCounts[a.status] += 1; }); + var searchForInbox = (state.actions.search || '').toLowerCase(); + var waitingCount = filterInboxItems(sortInboxItems(inboxAwaiting()), searchForInbox).filter(function(i) { return i && i.kind === 'question'; }).length; + var awaitingQuestionsHtml = showAwaitingItems ? renderAwaitingQuestionSection() : ''; + var awaitingBriefingsHtml = showBriefingItems ? renderAwaitingBriefingSection() : ''; + var inboxArchiveHtml = showBriefingItems ? renderInboxArchiveSection() : ''; + + var html = renderActionFilters(); + html += actionMetricCards(statusCounts, metricReviewItems.length, waitingCount); + html += awaitingQuestionsHtml; + + if (showReviewItems && reviewItems.length) { + html += '
'; + html += '
' + t('filter.review') + '
' + reviewItems.length + ' ' + t('act.itemsUnit') + '
'; + html += '
'; + reviewItems.forEach(function(item) { + html += renderActionCandidateCard(item); + }); + html += '
'; + } + + html += awaitingBriefingsHtml; + + if (items.length === 0 && (!showReviewItems || reviewItems.length === 0) && !awaitingQuestionsHtml && !awaitingBriefingsHtml && !inboxArchiveHtml) { + html += '
' + + '
☑
' + + '
' + t('act.empty.title') + '
' + + '
' + t('act.empty.lead') + '
' + + '
'; + } else { + items = items.slice().sort(function(a, b) { + var order = { active: 1, blocked: 2, pending: 3, done: 4, cancelled: 5 }; + return (order[a.status] || 9) - (order[b.status] || 9) || (Number(b.priority) || 0) - (Number(a.priority) || 0); + }); + // STEP-C4:无筛选的默认视图里,done 不混在分组流中,改由底部「已完成」 + // 折叠区单独承载、且只显当天完成的(§3.2/§3.3)。点了「已完成」筛选 chip + // (statusFilter==='done')时则照常全列,不走折叠区。 + // STEP-12:cancelled(归档/被更新丢弃/被合并)也不进默认活动视图——否则 + // 合并/丢弃后卡片仍以「已取消」分组留在原处,看着像「没生效」。 + var defaultView = !statusFilter; + var inlineStatuses = defaultView + ? ['active','blocked','pending'] + : ['active','blocked','pending','done','cancelled']; + inlineStatuses.forEach(function(status) { + var group = items.filter(function(a) { return a.status === status; }); + if (!group.length) return; + html += '
'; + html += '
' + esc(statusLabel(status)) + '
' + group.length + ' ' + t('act.itemsUnit') + '
'; + html += '
'; + group.forEach(function(a) { + var isFrontier = frontierIds.has(a.id); + html += renderActionCard(a, isFrontier); + }); + html += '
'; + }); + if (defaultView) { + html += renderDoneTodaySection(items.filter(function(a) { return a.status === 'done'; }), frontierIds, renderActionCard); + } + } + html += inboxArchiveHtml; + + var __focus = captureSearchFocus(['actions-search']); + el.innerHTML = html; + var __as = document.getElementById('actions-search'); + if (__as) bindImeSafeSearch(__as, 200, function(v){ state.actions.search = v; renderActions(); }); + restoreSearchFocus(__focus); + } + diff --git a/src/viewer/parts/app/62-legacy-crystals-audit-profile.js b/src/viewer/parts/app/62-legacy-crystals-audit-profile.js new file mode 100644 index 00000000..f7dc1d9c --- /dev/null +++ b/src/viewer/parts/app/62-legacy-crystals-audit-profile.js @@ -0,0 +1,336 @@ + async function loadCrystals() { + var el = document.getElementById('view-crystals'); + el.innerHTML = '
正在加载结晶...
'; + var results = await Promise.all([apiGet('crystals'), apiGet('lessons')]); + state.crystals.items = (results[0] && results[0].crystals) || []; + var lessonMap = {}; + var lessons = (results[1] && results[1].lessons) || []; + lessons.forEach(function(l) { if (l && l.id) lessonMap[l.id] = l; }); + state.crystals.lessonMap = lessonMap; + state.crystals.loaded = true; + renderCrystals(); + } + + function renderCrystals() { + var el = document.getElementById('view-crystals'); + var items = state.crystals.items; + var search = state.crystals.search.toLowerCase(); + var lessonMap = state.crystals.lessonMap || {}; + + if (search) { + items = items.filter(function(c) { + var lessonText = (c.lessons || []) + .map(function(lid) { + var l = lessonMap[lid]; + return l && typeof l.content === 'string' ? l.content : lid; + }) + .join(' '); + var filesText = (c.filesAffected || []).join(' '); + var haystack = [ + c.narrative || '', + (c.keyOutcomes || []).join(' '), + lessonText, + filesText, + c.project || '', + ].join(' ').toLowerCase(); + return haystack.indexOf(search) >= 0; + }); + } + + var html = '
'; + html += '
工作沉淀
'; + html += '
这里保留已经整理过的工作结果:做了什么、留下了哪些文件、能复用什么经验。
'; + html += '
' + items.length + ' 条
'; + html += '
'; + + html += '
'; + html += ''; + html += '
'; + + if (items.length === 0) { + html += '
' + + '
💎
' + + '
还没有结晶
' + + '
结晶是一次完整工作的压缩摘要,用来保留关键结论、涉及文件和可复用经验。当前还没有生成结晶,但会话和记忆已经可以先使用。
' + + '
导入历史会话后,可以把重要会话整理成结晶;后续我会优先把这里做成一键整理。
' + + '' + + '
'; + } else { + html += '
'; + items.forEach(function(c) { + html += '
'; + html += '
'; + html += '
' + esc(truncate(c.narrative || '未命名工作', 180)) + '
'; + html += '
' + esc(shortDateTime(c.createdAt)) + '
'; + html += '
'; + + var pillRow = []; + if (c.project) pillRow.push('' + esc(projectDisplayName(c.project)) + ''); + if (c.filesAffected && c.filesAffected.length) pillRow.push('' + c.filesAffected.length + ' 个文件'); + if (c.lessons && c.lessons.length) pillRow.push('' + c.lessons.length + ' 条经验'); + if (pillRow.length) html += '
' + pillRow.join('') + '
'; + + if (c.filesAffected && c.filesAffected.length > 0) { + html += '
'; + html += '
'; + c.filesAffected.slice(0, 4).forEach(function(f) { + html += '
' + esc(f) + '
'; + }); + if (c.filesAffected.length > 4) html += '
还有 ' + (c.filesAffected.length - 4) + ' 个文件
'; + html += '
'; + } + + if (c.lessons && c.lessons.length > 0) { + html += '
'; + html += '
'; + c.lessons.slice(0, 3).forEach(function(lid) { + var content = lessonMap[lid] ? lessonMap[lid].content : lid; + html += '
' + esc(content) + '
'; + }); + if (c.lessons.length > 3) html += '
还有 ' + (c.lessons.length - 3) + ' 条经验
'; + html += '
'; + } + + html += '
'; + }); + html += '
'; + } + + var __focus = captureSearchFocus(['crystals-search']); + el.innerHTML = html; + var __cs = document.getElementById('crystals-search'); + if (__cs) bindImeSafeSearch(__cs, 200, function(v){ state.crystals.search = v; renderCrystals(); }); + restoreSearchFocus(__focus); + } + + async function loadAudit() { + var el = document.getElementById('view-audit'); + el.innerHTML = '
加载审计日志中...
'; + var result = await apiGet('audit?limit=100'); + state.audit.entries = (result && result.entries) || []; + state.audit.loaded = true; + renderAudit(); + } + + function renderAudit() { + var el = document.getElementById('view-audit'); + var entries = state.audit.entries; + var opFilter = state.audit.opFilter; + + var ops = {}; + entries.forEach(function(e) { ops[e.operation] = true; }); + var opList = Object.keys(ops).sort(); + + var filtered = opFilter ? entries.filter(function(e) { return e.operation === opFilter; }) : entries; + + var html = '
'; + html += ''; + html += ''; + html += '
'; + + html += '
'; + if (filtered.length === 0) { + if (entries.length === 0) { + html += '
📜

暂无审计记录

当发生删除、演化、合并等治理操作后,这里会出现记录。

'; + } else { + html += '
🔍

当前筛选下无结果

'; + } + } else { + filtered.forEach(function(a, idx) { + var badgeClass = OP_BADGES[a.operation] || 'badge-muted'; + html += '
'; + html += '
'; + html += '' + esc(a.operation) + ''; + html += '' + esc(a.functionId || '') + ''; + html += '' + esc(formatTime(a.timestamp)) + ''; + html += ''; + html += '
'; + if (a.targetIds && a.targetIds.length) { + html += '
' + a.targetIds.length + ' 个目标: ' + esc(a.targetIds.slice(0, 3).join(', ')) + (a.targetIds.length > 3 ? '...' : '') + '
'; + } + html += '
' + esc(JSON.stringify(a.details || {}, null, 2)) + '
'; + html += '
'; + }); + } + html += '
'; + + el.innerHTML = html; + + document.getElementById('audit-op-filter').addEventListener('change', function() { + state.audit.opFilter = this.value; + renderAudit(); + }); + } + + function toggleAuditDetail(idx) { + var el = document.getElementById('audit-detail-' + idx); + if (el) el.classList.toggle('open'); + } + + async function loadProfile() { + var el = document.getElementById('view-profile'); + el.innerHTML = '
加载画像中...
'; + var sessResult = await apiGet('sessions'); + var sessions = (sessResult && sessResult.sessions) || []; + + var projects = {}; + sessions.forEach(function(s) { if (s.project) projects[s.project] = true; }); + state.profile.projects = Object.keys(projects).sort(); + state.profile.loaded = true; + + if (state.profile.projects.length > 0 && !state.profile.selectedProject) { + state.profile.selectedProject = state.profile.projects[0]; + } + + renderProfileToolbar(); + if (state.profile.selectedProject) await loadProfileData(); + } + + function renderProfileToolbar() { + var el = document.getElementById('view-profile'); + var html = '
'; + html += '
'; + html += '
'; + el.innerHTML = html; + + document.getElementById('profile-project').addEventListener('change', function() { + state.profile.selectedProject = this.value; + loadProfileData(); + }); + } + + async function loadProfileData() { + var content = document.getElementById('profile-content'); + if (!content || !state.profile.selectedProject) return; + content.innerHTML = '
加载画像数据中...
'; + var result = await apiGet('profile?project=' + encodeURIComponent(state.profile.selectedProject)); + state.profile.data = (result && result.profile) ? result.profile : result; + renderProfile(); + } + + function renderProfile() { + var content = document.getElementById('profile-content'); + if (!content) return; + var p = state.profile.data; + + if (!p) { + content.innerHTML = '
📋

该项目暂无画像数据

'; + return; + } + + var html = '
'; + + html += '
高频概念
'; + var concepts = p.topConcepts || []; + if (concepts.length === 0) { + html += '
No concepts yet
'; + } else { + var maxC = Math.max.apply(null, concepts.map(function(c) { return c.frequency; })) || 1; + html += '
'; + concepts.slice(0, 10).forEach(function(c) { + var pct = Math.round((c.frequency / maxC) * 100); + html += '
' + esc(c.concept) + '
' + c.frequency + '
'; + }); + html += '
'; + } + html += '
'; + + html += '
高频文件
'; + var files = p.topFiles || []; + if (files.length === 0) { + html += '
No files yet
'; + } else { + var maxF = Math.max.apply(null, files.map(function(f) { return f.frequency; })) || 1; + html += '
'; + files.slice(0, 10).forEach(function(f) { + var pct = Math.round((f.frequency / maxF) * 100); + html += '
' + esc(f.file.split('/').pop()) + '
' + f.frequency + '
'; + }); + html += '
'; + } + html += '
'; + + html += '
'; + + html += '
约定
'; + var conventions = p.conventions || []; + if (conventions.length === 0) { + html += '
No conventions detected yet
'; + } else { + html += '
    '; + conventions.forEach(function(c) { html += '
  • ' + esc(c) + '
  • '; }); + html += '
'; + } + html += '
'; + + if (p.summary) { + html += '
项目摘要
'; + html += '

' + esc(p.summary) + '

'; + } + + var stats = '
项目统计
'; + stats += '
Sessions
' + (p.sessionCount || 0) + '
'; + stats += '
Total Obs
' + (p.totalObservations || 0) + '
'; + stats += '
Updated
' + esc(formatTime(p.updatedAt)) + '
'; + stats += '
'; + + content.innerHTML = html + stats; + } + + var wsReconnectTimer = null; + var wsRetries = 0; + var WS_MAX_RETRIES = 4; + var directFailed = false; + var directFailures = 0; + var DIRECT_FAILURE_THRESHOLD = 2; + var pollTimer = null; + var POLL_INTERVAL_MS = 10000; + + function setWsStatus(text, cls) { + var el = document.getElementById('ws-status'); + if (!el) return; + el.textContent = text; + el.className = 'ws-status ' + cls; + } + + var WS_REPROBE_EVERY_TICKS = 6; + + function startPolling() { + if (pollTimer) return; + setWsStatus('polling · ' + (POLL_INTERVAL_MS / 1000) + 's', 'disconnected'); + var tick = 0; + pollTimer = setInterval(function() { + tick++; + refreshActiveTab('poll'); + if (tick % WS_REPROBE_EVERY_TICKS === 0) { + var ws = state.ws; + if (!ws || ws.readyState !== WebSocket.OPEN) { + wsRetries = 0; + directFailures = 0; + directFailed = false; + connectWs(); + } + } + }, POLL_INTERVAL_MS); + } + + function stopPolling() { + if (!pollTimer) return; + clearInterval(pollTimer); + pollTimer = null; + } + + var WS_CONNECT_TIMEOUT_MS = 5000; + diff --git a/src/viewer/parts/app/64-ws-flags-events.js b/src/viewer/parts/app/64-ws-flags-events.js new file mode 100644 index 00000000..4f1f8408 --- /dev/null +++ b/src/viewer/parts/app/64-ws-flags-events.js @@ -0,0 +1,699 @@ + function connectWs() { + if (wsRetries >= WS_MAX_RETRIES) { + startPolling(); + return; + } + var useDirect = !directFailed; + var ws; + try { + ws = new WebSocket(useDirect ? WS_DIRECT_URL : WS_URL); + ws.__direct = useDirect; + } catch (_) { + ws = new WebSocket(WS_URL); + ws.__direct = false; + } + var connectTimer = setTimeout(function() { + if (ws.readyState === WebSocket.CONNECTING) { + try { ws.close(); } catch {} + } + }, WS_CONNECT_TIMEOUT_MS); + try { + ws.onopen = function() { + clearTimeout(connectTimer); + if (state.ws !== ws) return; + wsRetries = 0; + stopPolling(); + if (ws.__direct) { + directFailures = 0; + directFailed = false; + } + if (!ws.__direct) { + ws.send(JSON.stringify({ + type: 'join', + data: { + subscriptionId: 'viewer-' + Date.now(), + streamName: 'mem-live', + groupId: 'viewer' + } + })); + } + setWsStatus('live', 'connected'); + }; + ws.onmessage = function(e) { + if (state.ws !== ws) return; + try { + var msg = JSON.parse(e.data); + if (msg.type === 'stream' && msg.event) { + handleStreamEvent(msg); + } else if (msg.event_type && msg.data) { + handleStreamEvent({ event: { type: 'create', data: msg.data, event_type: msg.event_type } }); + } + } catch {} + }; + ws.onclose = function() { + clearTimeout(connectTimer); + if (state.ws !== ws) return; + if (ws.__direct) { + directFailures += 1; + if (directFailures >= DIRECT_FAILURE_THRESHOLD) { + directFailed = true; + } + } + wsRetries++; + if (wsRetries < WS_MAX_RETRIES) { + setWsStatus('connecting...', 'disconnected'); + wsReconnectTimer = setTimeout(connectWs, 2000 + Math.min(wsRetries * 1000, 8000)); + } else { + startPolling(); + } + }; + ws.onerror = function() { + if (state.ws !== ws) return; + try { ws.close(); } catch {} + }; + state.ws = ws; + } catch { + wsRetries++; + if (wsRetries < WS_MAX_RETRIES) { + wsReconnectTimer = setTimeout(connectWs, 2000 + Math.min(wsRetries * 1000, 8000)); + } else { + startPolling(); + } + } + } + + function looksLikeObservation(obj) { + return !!(obj && typeof obj === 'object' && obj.id && obj.timestamp); + } + + function handleStreamEvent(msg) { + var evt = msg.event; + var observation; + if (!evt) return; + if (evt.event_type && evt.event_type !== 'observation' && evt.event_type !== 'create' && evt.event_type !== 'update') { + return; + } + if (evt.type === 'event' && evt.data) { + observation = evt.data.observation || evt.data; + if (looksLikeObservation(observation)) { + routeWsMessage({ observation: observation }); + } + return; + } + if ((evt.type === 'create' || evt.type === 'update') && evt.data) { + var payload = evt.data; + observation = payload.observation || payload; + if (looksLikeObservation(observation)) { + routeWsMessage({ observation: observation }); + } + } else if (evt.type === 'sync') { + var items = Array.isArray(evt.data) ? evt.data : []; + items.forEach(function(item) { + var payload = item.data || item; + observation = payload.observation || payload; + if (looksLikeObservation(observation)) { + routeWsMessage({ observation: observation }); + } + }); + } + } + + function routeWsMessage(msg) { + markMemoryViewsStale(); + if (state.activeTab === 'timeline' && msg.observation) { + if (!state.timeline.sessionId || msg.observation.sessionId === state.timeline.sessionId) { + var existing = state.timeline.observations.findIndex(function(o) { return o.id === msg.observation.id; }); + if (existing >= 0) { + state.timeline.observations[existing] = msg.observation; + } else { + state.timeline.observations.unshift(msg.observation); + } + renderObservations(); + } + } + if (state.activeTab === 'dashboard') { + loadDashboard(); + } + if (state.activeTab === 'memories') loadMemories(); + if (state.activeTab === 'sessions') { + if (isViewingSessionDetail()) { + markSessionsStale(); + } else { + state.sessions.loaded = false; + loadSessions({ showLoading: false, reason: 'ws' }); + } + } + if (state.activeTab === 'lessons') loadLessons(); + if (state.activeTab === 'actions') markActionsStale('有新会话记录,待办暂未自动刷新。'); + if (state.activeTab === 'activity' && msg.observation) { + state.activity.observations.unshift(msg.observation); + renderActivity(); + } + } + + document.getElementById('tab-bar').addEventListener('click', function(e) { + var toggle = e.target instanceof Element ? e.target.closest('#expert-toggle') : null; + if (toggle) { + toggleExpertMode(); + return; + } + var btn = e.target instanceof Element ? e.target.closest('button[data-tab]') : null; + if (btn) { + switchTab(btn.dataset.tab); + return; + } + }); + + document.addEventListener('click', function(e) { + if (!(e.target instanceof Element)) return; + var jump = e.target.closest('[data-dashboard-jump]'); + if (!jump) return; + var tab = jump.getAttribute('data-dashboard-jump'); + if (!tab) return; + switchTab(tab); + }); + + document.querySelectorAll('[data-tab-link]').forEach(function(link) { + link.addEventListener('click', function(e) { + if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return; + e.preventDefault(); + switchTab(link.getAttribute('data-tab-link')); + }); + }); + + function syncTabFromRoute() { + switchTab(tabFromRoute(), { replaceRoute: true }); + } + window.addEventListener('hashchange', syncTabFromRoute); + window.addEventListener('popstate', syncTabFromRoute); + + // --- Feature flag banners --------------------------------------------- + function getDismissedFlags() { + if (!state.flagsDismissed) state.flagsDismissed = {}; + return state.flagsDismissed; + } + function dismissFlags(keys) { + var dismissed = getDismissedFlags(); + keys.forEach(function(key) { + if (key) dismissed[key] = true; + }); + } + function renderFlagBanners(cfg) { + var host = document.getElementById('flag-banners'); + if (!host) return; + var dismissed = getDismissedFlags(); + var banners = []; + function toHumanTitle(raw) { + if (!raw) return '功能未开启'; + var key = String(raw).toLowerCase(); + if (key.indexOf('knowledge graph') >= 0) return '关系图自动提取未开启'; + if (key.indexOf('llm provider') >= 0) return '智能分析未开启'; + if (key.indexOf('bm25-only') >= 0) return '仅关键词检索模式'; + return raw; + } + function toHumanDesc(raw) { + if (!raw) return '开启后可获得更完整的记忆分析能力。'; + var s = String(raw); + if (s.indexOf('Compression, summarization, and graph extraction') >= 0) { + return '当前只能使用基础能力;摘要、压缩和关系图能力暂不可用。'; + } + if (s.indexOf('Extracts entities and relations') >= 0) { + return '系统暂不会自动生成关系图。'; + } + if (s.indexOf('Semantic vector search is off') >= 0) { + return '当前搜索偏向关键词匹配,语义理解能力较弱。'; + } + return s; + } + // Per-flag banner (only for off flags, affecting current tab or dashboard) + (cfg.flags || []).forEach(function(f) { + if (f.enabled) return; + if (dismissed[f.key]) return; + var tabsAffected = (f.affects || []).map(function(t) { return t.toLowerCase(); }); + if (tabsAffected.length && tabsAffected.indexOf(state.activeTab) === -1 && state.activeTab !== 'dashboard') return; + banners.push({ + kind: 'warn', + icon: '◉', + title: toHumanTitle(f.label), + keyLabel: f.key, + desc: toHumanDesc(f.description) + (f.needsLlm ? ' 需要先连接智能模型。' : ''), + enable: f.enableHow, + docs: f.docsHref, + dismissKey: f.key, + }); + }); + if (cfg.provider === 'noop' && !dismissed['__provider_noop']) { + banners.unshift({ + kind: 'warn', + icon: '◉', + title: '智能分析未开启', + keyLabel: 'ANTHROPIC_API_KEY', + desc: '当前只能用基础功能;摘要、关系图与自动提炼暂不可用。', + enable: 'export ANTHROPIC_API_KEY=sk-ant-...\n# then restart: npm run start:local-memory', + docs: 'https://github.com/MaimoryLab/agentmemory-lab#quick-start', + dismissKey: '__provider_noop', + }); + } + if (cfg.embeddingProvider === 'none' && !dismissed['__embedding_none']) { + banners.push({ + kind: 'info', + icon: '◉', + title: '仅关键词检索模式', + keyLabel: 'OPENAI_API_KEY', + desc: '当前搜索偏向关键词匹配,语义理解能力较弱。', + enable: 'export OPENAI_API_KEY=sk-...\n# or VOYAGE_API_KEY, COHERE_API_KEY, OLLAMA_HOST', + docs: 'https://github.com/MaimoryLab/agentmemory-lab#embedding-providers', + dismissKey: '__embedding_none', + }); + } + if (banners.length === 0) { host.innerHTML = ''; return; } + var warnCount = banners.filter(function(b) { return b.kind === 'warn'; }).length; + var infoCount = banners.filter(function(b) { return b.kind === 'info'; }).length; + var expanded = host.getAttribute('data-expanded') === '1'; + var pills = ''; + if (warnCount) pills += '' + warnCount + ' 项待开启'; + if (infoCount) pills += '' + infoCount + ' 条提示'; + var escHtml = function(s) { + return String(s).replace(/[<>&"]/g, function(c) { + return { '<': '<', '>': '>', '&': '&', '"': '"' }[c]; + }); + }; + var listHtml = banners.map(function(b) { + return '
' + + '' + b.icon + '' + + '
' + + '
' + escHtml(b.title) + '
' + + '
' + escHtml(b.desc) + '
' + + '' + escHtml(b.enable) + '' + + (b.docs ? ' 查看设置步骤' : '') + + '
' + + '' + + '
'; + }).join(''); + host.innerHTML = '' + + '
' + listHtml + '
'; + } + async function fetchFlags() { + var res = await apiGet('config/flags'); + if (!res) return; + state.flagsConfig = res; + renderFlagBanners(res); + updateFooter(res); + } + function updateFooter(cfg) { + var fbEl = document.getElementById('footer-feedback'); + if (fbEl) { + var flagSummary = (cfg.flags || []).map(function(f) { return f.key + '=' + (f.enabled ? 'on' : 'off'); }).join(', '); + var body = encodeURIComponent( + '**Version:** ' + (cfg.version || '?') + '\n' + + '**Provider:** ' + (cfg.provider || '?') + '\n' + + '**Embedding:** ' + (cfg.embeddingProvider || '?') + '\n' + + '**Flags:** ' + flagSummary + '\n' + + '**User agent:** ' + navigator.userAgent + '\n\n' + + '### What went wrong\n\n' + + '(describe the issue)\n\n' + + '### Steps to reproduce\n\n' + + '1. \n2. \n3. \n' + ); + fbEl.href = 'https://github.com/MaimoryLab/agentmemory-lab/issues/new?title=' + + encodeURIComponent('[viewer] ') + '&body=' + body; + } + } + document.addEventListener('click', function(e) { + if (!(e.target instanceof Element)) return; + var btn = e.target.closest('[data-dismiss-flag]'); + if (btn) { + e.preventDefault(); + e.stopPropagation(); + var key = btn.getAttribute('data-dismiss-flag'); + dismissFlags([key]); + if (state.flagsConfig) renderFlagBanners(state.flagsConfig); + return; + } + var toggle = e.target.closest('[data-action="toggle-flags"]'); + if (toggle) { + var host = document.getElementById('flag-banners'); + var cur = host.getAttribute('data-expanded') === '1'; + host.setAttribute('data-expanded', cur ? '0' : '1'); + if (state.flagsConfig) renderFlagBanners(state.flagsConfig); + } + }); + fetchFlags(); + document.addEventListener('click', function(e) { + if (!(e.target instanceof Element)) return; + var target = e.target.closest('[data-action]'); + if (!target) return; + var action = target.getAttribute('data-action'); + if (!action) return; + + if (action === 'toggle-theme') { + toggleTheme(); + return; + } + if (action === 'clear-audit-filter') { + state.audit.opFilter = ''; + renderAudit(); + return; + } + if (action === 'refresh-audit') { + state.audit.loaded = false; + loadAudit(); + return; + } + if (action === 'refresh-dashboard') { + refreshDashboard(); + return; + } + if (action === 'refresh-activity') { + state.activity.loaded = false; + loadActivity(); + return; + } + if (action === 'refresh-actions') { + state.actions.loaded = false; + loadActions({ generate: false }); + return; + } + if (action === 'extract-actions') { + startTodoExtraction(true); + return; + } + if (action === 'update-cards') { + startTodoUpdate(); + return; + } + if (action === 'toggle-settings') { + state.settings.open = !state.settings.open; + renderSettingsPanel(); + if (state.settings.open && !state.actions.config) { + loadTodoExtractorConfig().then(renderSettingsPanel).catch(function() {}); + } + return; + } + if (action === 'close-settings') { + state.settings.open = false; + renderSettingsPanel(); + return; + } + if (action === 'set-ui-language') { + var lang = target.getAttribute('data-lang') || ''; + if (lang === 'zh' || lang === 'en') { + I18N_LANG = lang; + try { localStorage.setItem('agentmemory-lang', lang); } catch (_) {} + applyI18n(document); + renderSettingsPanel(); + if (state.activeTab === 'actions') renderActions(); + if (state.activeTab === 'dashboard') renderDashboard(); + if (state.activeTab === 'sessions') renderSessions(); + } + return; + } + if (action === 'save-todo-config') { + saveTodoExtractorConfig(); + return; + } + if (action === 'action-status') { + updateActionStatus(target.getAttribute('data-action-id') || '', target.getAttribute('data-status') || ''); + return; + } + if (action === 'refresh-sessions') { + state.sessions.loaded = false; + loadSessions({ showLoading: true, reason: 'manual' }); + return; + } + if (action === 'open-session-group') { + var openSessionId = target.getAttribute('data-session-id') || ''; + if (openSessionId) state.sessions.selectedId = openSessionId; + state.sessions.loaded = false; + switchTab('sessions'); + return; + } + if (action === 'jump-to-evidence') { + jumpToEvidence(target.getAttribute('data-obs-id') || ''); + return; + } + if (action === 'inbox-reply') { + openInboxReply(target.getAttribute('data-inbox-id') || ''); + return; + } + if (action === 'inbox-reply-submit') { + submitInboxReply(target.getAttribute('data-inbox-id') || ''); + return; + } + if (action === 'inbox-reply-cancel') { + cancelInboxReply(); + return; + } + if (action === 'inbox-ack') { + ackInboxItem(target.getAttribute('data-inbox-id') || ''); + return; + } + if (action === 'inbox-to-todo') { + convertInboxToTodo(target.getAttribute('data-inbox-id') || ''); + return; + } + if (action === 'toggle-done-today') { + state.actions.doneExpanded = !state.actions.doneExpanded; + renderActions(); + return; + } + if (action === 'toggle-briefings') { + state.inbox.briefingExpanded = !state.inbox.briefingExpanded; + renderActions(); + return; + } + if (action === 'toggle-inbox-archive') { + state.inbox.answeredExpanded = !state.inbox.answeredExpanded; + renderActions(); + return; + } + if (action === 'toggle-session-detail-section') { + toggleSessionDetailSection( + target.getAttribute('data-session-id') || state.sessions.selectedId || '', + target.getAttribute('data-section') || '', + ); + return; + } + if (action === 'toggle-session-preview') { + toggleSessionPreview(target.getAttribute('data-session-id') || state.sessions.selectedId || ''); + return; + } + if (action === 'zoom-graph') { + zoomGraph(parseInt(target.getAttribute('data-dir') || '0', 10)); + return; + } + if (action === 'recenter-graph') { + recenterGraph(); + return; + } + if (action === 'rebuild-graph') { + rebuildGraph(); + return; + } + if (action === 'expand-node') { + var nodeId = target.getAttribute('data-node-id'); + if (nodeId) expandNode(nodeId); + return; + } + if (action === 'delete-memory') { + deleteMemory( + target.getAttribute('data-memory-id') || '', + target.getAttribute('data-memory-title') || '', + ); + return; + } + if (action === 'edit-memory') { + var editMemoryId = target.getAttribute('data-memory-id'); + if (editMemoryId) editMemory(editMemoryId); + return; + } + if (action === 'copy-agent-prompt') { + copyAgentPrompt(); + return; + } + if (action === 'copy-skill-path') { + var skillPath = target.getAttribute('data-skill-path') || ''; + copyTextToClipboard(skillPath).then(function() { + target.setAttribute('title', '已复制'); + setTimeout(function() { target.setAttribute('title', '复制路径'); }, 1400); + }); + return; + } + if (action === 'open-skill-draft') { + openSkillDraft(target.getAttribute('data-skill-draft-key') || '通用经验'); + return; + } + if (action === 'copy-skill-draft') { + var draftEl = document.getElementById('skill-draft-preview'); + copyTextToClipboard(draftEl ? draftEl.textContent || '' : '').then(function() { + target.textContent = '已复制'; + setTimeout(function() { target.textContent = '复制草稿'; }, 1400); + }); + return; + } + if (action === 'open-add-memory') { + openAddMemory(); + return; + } + if (action === 'open-add-lesson') { + openAddLesson(); + return; + } + if (action === 'open-skill-detail') { + openSkillDetail(target.getAttribute('data-skill-path') || ''); + return; + } + if (action === 'filter-skill-root') { + state.lessons.skillRootFilter = target.getAttribute('data-root') || 'all'; + renderLessons(); + return; + } + if (action === 'filter-actions-status') { + var nextFilter = target.getAttribute('data-status') || ''; + // STEP-13: clicking the already-active filter clears it — with the chip + // row gone, this is how the metric cards restore the "show all" view. + state.actions.statusFilter = (state.actions.statusFilter || '') === nextFilter ? '' : nextFilter; + renderActions(); + return; + } + if (action === 'close-modal') { + closeModal(); + return; + } + if (action === 'save-new-memory') { + saveNewMemory(); + return; + } + if (action === 'save-new-lesson') { + saveNewLesson(); + return; + } + if (action === 'save-memory') { + var saveMemoryId = target.getAttribute('data-memory-id'); + if (saveMemoryId) saveMemory(saveMemoryId); + return; + } + if (action === 'confirm-delete-memory') { + var memoryId = target.getAttribute('data-memory-id'); + if (memoryId) confirmDeleteMemory(memoryId); + return; + } + if (action === 'edit-review') { + var editReviewId = target.getAttribute('data-review-id'); + if (editReviewId) editReviewItem(editReviewId); + return; + } + if (action === 'approve-review') { + var approveId = target.getAttribute('data-review-id'); + if (approveId) approveReviewItem(approveId); + return; + } + if (action === 'dismiss-review') { + var dismissId = target.getAttribute('data-review-id'); + if (dismissId) dismissReviewItem(dismissId); + return; + } + if (action === 'timeline-filter') { + setTlTypeFilter(target.getAttribute('data-type-filter') || ''); + return; + } + if (action === 'episode-filter') { + state.timeline.episodeFilter = target.getAttribute('data-episode-filter') || 'all'; + state.timeline.page = 0; + renderObservations(); + return; + } + if (action === 'episode-toggle') { + var episodeKey = target.getAttribute('data-episode-key'); + if (episodeKey) { + state.timeline.expandedEpisodes[episodeKey] = !state.timeline.expandedEpisodes[episodeKey]; + renderObservations(); + } + return; + } + if (action === 'timeline-page') { + var page = parseInt(target.getAttribute('data-page') || '', 10); + if (!Number.isNaN(page)) tlPage(page); + return; + } + if (action === 'timeline-mode') { + var mode = target.getAttribute('data-mode') || 'episodes'; + state.timeline.mode = mode === 'events' ? 'events' : 'episodes'; + state.timeline.page = 0; + renderTimelineToolbar(groupSessionsByProject(state.timeline.sessions || [])); + renderObservations(); + return; + } + if (action === 'select-session') { + var sessionId = target.getAttribute('data-session-id'); + if (sessionId) selectSession(sessionId); + return; + } + if (action === 'session-group-mode') { + var sessionMode = target.getAttribute('data-mode') === 'source' ? 'source' : 'folder'; + state.sessions.groupMode = sessionMode; + renderSessions(); + return; + } + if (action === 'select-session-group') { + var groupKey = target.getAttribute('data-group-key') || 'all'; + if (state.sessions.groupMode === 'source') state.sessions.sourceKey = groupKey; + else state.sessions.folderKey = groupKey; + renderSessions(); + return; + } + if (action === 'session-jump') { + var jumpTarget = target.getAttribute('data-target') === 'bottom' ? 'session-detail-bottom' : 'session-detail-top'; + var jumpEl = document.getElementById(jumpTarget); + if (jumpEl) jumpEl.scrollIntoView({ behavior: 'smooth', block: target.getAttribute('data-target') === 'bottom' ? 'end' : 'start' }); + return; + } + if (action === 'end-session') { + var endSessionId = target.getAttribute('data-session-id'); + if (endSessionId) endSession(endSessionId); + return; + } + if (action === 'summarize-session') { + var summarizeSessionId = target.getAttribute('data-session-id'); + if (summarizeSessionId) summarizeSession(summarizeSessionId, target); + return; + } + if (action === 'toggle-audit') { + var auditIndex = parseInt(target.getAttribute('data-audit-index') || '', 10); + if (!Number.isNaN(auditIndex)) toggleAuditDetail(auditIndex); + } + if (action === 'replay-select') { + var rSid = target.getAttribute('data-session-id'); + if (rSid) selectReplaySession(rSid); + return; + } + if (action === 'replay-toggle-play') { toggleReplayPlay(); return; } + if (action === 'replay-step') { + var d = parseInt(target.getAttribute('data-dir') || '1', 10); + stepReplay(d); + return; + } + if (action === 'replay-speed') { + var sp = parseFloat(target.getAttribute('data-speed') || '1'); + setReplaySpeed(sp); + return; + } + if (action === 'replay-reset') { resetReplay(); return; } + if (action === 'replay-import') { runReplayImport(); return; } + if (action === 'replay-refresh') { refreshReplaySessions(); return; } + }); + document.getElementById('modal-overlay').addEventListener('click', function(e) { + if (e.target === this) closeModal(); + }); + document.addEventListener('input', function(e) { + var target = e.target; + if (!target || !target.id || String(target.id).indexOf('todo-config-') !== 0) return; + state.actions.configDraft = state.actions.configDraft || {}; + state.actions.configDraft[String(target.id).slice('todo-config-'.length)] = String(target.value || ''); + }); + diff --git a/src/viewer/parts/app/68-legacy-replay.js b/src/viewer/parts/app/68-legacy-replay.js new file mode 100644 index 00000000..d908c65d --- /dev/null +++ b/src/viewer/parts/app/68-legacy-replay.js @@ -0,0 +1,202 @@ + async function loadReplay() { + var el = document.getElementById('view-replay'); + el.innerHTML = '
加载会话列表中…
'; + var res = await apiGet('replay/sessions'); + state.replay.sessions = (res && res.sessions) || []; + state.replay.loaded = true; + renderReplay(); + } + + async function refreshReplaySessions() { + state.replay.loaded = false; + await loadReplay(); + } + + function renderReplay() { + var el = document.getElementById('view-replay'); + var sessions = state.replay.sessions || []; + var options = '' + sessions.map(function(s) { + var id = sessionId(s); + var label = sessionDisplayName(s) + ' · ' + (shortSessionId(s, 8) || '无记录 ID') + ' · ' + (s.observationCount || 0) + ' obs'; + return ''; + }).join(''); + + var tl = state.replay.timeline; + var hasTl = tl && tl.events && tl.events.length > 0; + var cursorEvent = hasTl ? tl.events[Math.min(state.replay.cursor, tl.events.length - 1)] : null; + var progress = hasTl && tl.totalDurationMs > 0 ? Math.min(100, (state.replay.offsetAt / tl.totalDurationMs) * 100) : 0; + + el.innerHTML = + '
' + + '' + + '' + + '' + + '' + + '' + + '
' + + (hasTl + ? '
' + + '' + + '' + + '' + + '' + + '' + + '速度' + + ['0.5', '1', '2', '4'].map(function(sp) { + var active = Math.abs(state.replay.speed - parseFloat(sp)) < 0.01; + return ''; + }).join('') + + '' + + '' + (state.replay.cursor + 1) + ' / ' + tl.eventCount + '' + + '
' + + '
' + + '
' + + '
' + + tl.events.map(function(ev, i) { + var active = i === state.replay.cursor ? ' replay-event-active' : ''; + return '
' + + '' + esc(ev.kind) + '' + + '' + esc(ev.label) + '' + + '' + (ev.offsetMs / 1000).toFixed(1) + 's' + + '
'; + }).join('') + + '
' + + '
' + renderReplayDetail(cursorEvent) + '
' + + '
' + : '
请选择一个会话进行回放,或从 ~/.claude/projects 导入 Claude Code 的 JSONL 记录。
'); + + var sel = document.getElementById('replay-session-select'); + if (sel) sel.addEventListener('change', function() { selectReplaySession(sel.value); }); + } + + function renderReplayDetail(ev) { + if (!ev) return '
尚未选择事件。
'; + var blocks = []; + blocks.push('
' + esc(ev.label) + ' ' + esc(ev.kind) + '
'); + if (ev.ts) blocks.push('
' + esc(formatTime(ev.ts)) + '
'); + if (ev.body) { + blocks.push('
' + esc(ev.body) + '
'); + } + if (ev.toolName) { + blocks.push('
工具: ' + esc(ev.toolName) + '
'); + } + if (ev.toolInput !== undefined && ev.toolInput !== null) { + var inp = typeof ev.toolInput === 'string' ? ev.toolInput : JSON.stringify(ev.toolInput, null, 2); + blocks.push('
输入
' + esc(truncate(inp, 4000)) + '
'); + } + if (ev.toolOutput !== undefined && ev.toolOutput !== null) { + var out = typeof ev.toolOutput === 'string' ? ev.toolOutput : JSON.stringify(ev.toolOutput, null, 2); + blocks.push('
输出
' + esc(truncate(out, 4000)) + '
'); + } + return blocks.join(''); + } + + async function selectReplaySession(sessionId) { + stopReplayTimer(); + state.replay.selectedId = sessionId; + state.replay.timeline = null; + state.replay.cursor = 0; + state.replay.offsetAt = 0; + state.replay.playing = false; + if (!sessionId) { renderReplay(); return; } + var el = document.getElementById('view-replay'); + el.innerHTML = '
加载回放中…
'; + var res = await apiGet('replay/load?sessionId=' + encodeURIComponent(sessionId)); + if (res && res.success && res.timeline) { + state.replay.timeline = res.timeline; + } else { + state.replay.timeline = { events: [], eventCount: 0, totalDurationMs: 0 }; + } + renderReplay(); + } + + function toggleReplayPlay() { + if (!state.replay.timeline || state.replay.timeline.eventCount === 0) return; + if (state.replay.playing) { + stopReplayTimer(); + } else { + startReplayTimer(); + } + renderReplay(); + } + + function startReplayTimer() { + state.replay.playing = true; + state.replay.startAt = Date.now(); + var baseOffset = state.replay.offsetAt; + if (state.replay.timer) clearInterval(state.replay.timer); + state.replay.timer = setInterval(function() { + if (!state.replay.timeline) return; + var elapsed = (Date.now() - state.replay.startAt) * state.replay.speed; + state.replay.offsetAt = baseOffset + elapsed; + var events = state.replay.timeline.events; + var newCursor = state.replay.cursor; + for (var i = newCursor; i < events.length; i++) { + if (events[i].offsetMs <= state.replay.offsetAt) newCursor = i; + else break; + } + var changed = newCursor !== state.replay.cursor; + state.replay.cursor = newCursor; + if (state.replay.offsetAt >= state.replay.timeline.totalDurationMs) { + state.replay.offsetAt = state.replay.timeline.totalDurationMs; + stopReplayTimer(); + renderReplay(); + return; + } + if (changed) renderReplay(); + }, 100); + } + + function stopReplayTimer() { + state.replay.playing = false; + if (state.replay.timer) { + clearInterval(state.replay.timer); + state.replay.timer = null; + } + } + + function stepReplay(dir) { + if (!state.replay.timeline) return; + stopReplayTimer(); + var next = state.replay.cursor + dir; + if (next < 0) next = 0; + if (next >= state.replay.timeline.eventCount) next = state.replay.timeline.eventCount - 1; + state.replay.cursor = next; + state.replay.offsetAt = state.replay.timeline.events[next].offsetMs; + renderReplay(); + } + + function setReplaySpeed(sp) { + if (!sp || sp <= 0) return; + var wasPlaying = state.replay.playing; + stopReplayTimer(); + state.replay.speed = sp; + if (wasPlaying) startReplayTimer(); + renderReplay(); + } + + function resetReplay() { + stopReplayTimer(); + state.replay.cursor = 0; + state.replay.offsetAt = 0; + renderReplay(); + } + + async function runReplayImport() { + var input = document.getElementById('replay-import-path'); + var pathVal = input ? input.value.trim() : ''; + var body = {}; + if (pathVal) body.path = pathVal; + var el = document.getElementById('view-replay'); + var prior = el.innerHTML; + el.innerHTML = '
Importing JSONL…
'; + var res = await apiPost('replay/import-jsonl', body); + if (!res || res.success === false) { + el.innerHTML = prior; + alert((res && res.error) || '导入失败'); + return; + } + alert('已导入 ' + (res.imported || 0) + ' 个文件,生成 ' + (res.observations || 0) + ' 条观察记录'); + await refreshReplaySessions(); + } + diff --git a/src/viewer/parts/app/90-bootstrap.js b/src/viewer/parts/app/90-bootstrap.js new file mode 100644 index 00000000..cccd9b55 --- /dev/null +++ b/src/viewer/parts/app/90-bootstrap.js @@ -0,0 +1,49 @@ + document.addEventListener('keydown', function(e) { + if (state.activeTab !== 'replay') return; + if (e.target && (e.target.tagName === 'INPUT' || e.target.tagName === 'SELECT' || e.target.tagName === 'TEXTAREA')) return; + if (e.key === ' ') { e.preventDefault(); toggleReplayPlay(); } + else if (e.key === 'ArrowLeft') { e.preventDefault(); stepReplay(-1); } + else if (e.key === 'ArrowRight') { e.preventDefault(); stepReplay(1); } + }); + + // 总览页下拉刷新(顶部向下滑动触发) + var dashPullStartY = null; + var dashPullStartTop = false; + var dashPullTriggered = false; + var dashPullCooldownAt = 0; + document.addEventListener('touchstart', function(e) { + if (state.activeTab !== 'dashboard') return; + if (!e.touches || e.touches.length !== 1) return; + var scTop = (window.pageYOffset || document.documentElement.scrollTop || document.body.scrollTop || 0); + dashPullStartTop = scTop <= 2; + dashPullStartY = e.touches[0].clientY; + dashPullTriggered = false; + }, { passive: true }); + document.addEventListener('touchmove', function(e) { + if (state.activeTab !== 'dashboard') return; + if (!dashPullStartTop || dashPullStartY === null || dashPullTriggered) return; + if (!e.touches || e.touches.length !== 1) return; + var dy = e.touches[0].clientY - dashPullStartY; + if (dy < 72) return; + var now = Date.now(); + if (now - dashPullCooldownAt < 1200) return; + dashPullTriggered = true; + dashPullCooldownAt = now; + refreshDashboard(); + }, { passive: true }); + document.addEventListener('touchend', function() { + dashPullStartY = null; + dashPullStartTop = false; + dashPullTriggered = false; + }, { passive: true }); + + document.addEventListener('visibilitychange', function() { + if (document.visibilityState === 'visible') { + refreshActiveTab('visibility'); + } + }); + + renderExpertTabs(); + switchTab(tabFromRoute(), { replaceRoute: true }); + connectWs(); + startDashboardAutoRefresh(); diff --git a/src/viewer/parts/app/90-rest.js b/src/viewer/parts/app/90-rest.js deleted file mode 100644 index 3d79a878..00000000 --- a/src/viewer/parts/app/90-rest.js +++ /dev/null @@ -1,5318 +0,0 @@ - function normalizeTab(tab) { - var normalized = String(tab || '').replace(/^#/, '').toLowerCase(); - // 专家模式下,被隐藏视图放行;否则按 TAB_REDIRECTS 折回主三栏。 - if (expertModeEnabled() && EXPERT_TABS.some(function(t) { return t.id === normalized; })) { - return normalized; - } - if (TAB_REDIRECTS[normalized]) return TAB_REDIRECTS[normalized]; - return TAB_IDS.indexOf(normalized) >= 0 ? normalized : 'dashboard'; - } - - function tabFromRoute() { - try { - return normalizeTab(decodeURIComponent(window.location.hash.slice(1))); - } catch (_) { - return 'dashboard'; - } - } - - function updateTabRoute(tab, replace) { - var target = '#' + tab; - if (window.location.hash === target) return; - if (replace) { - history.replaceState(null, '', target); - } else { - history.pushState(null, '', target); - } - } - - function switchTab(tab, opts) { - opts = opts || {}; - tab = normalizeTab(tab); - if (state.activeTab === 'replay' && tab !== 'replay' && typeof stopReplayTimer === 'function') { - stopReplayTimer(); - } - if (!opts.skipRoute) { - updateTabRoute(tab, !!opts.replaceRoute); - } - state.activeTab = tab; - document.querySelectorAll('.tab-bar button').forEach(function(b) { - var isActive = b.dataset.tab === tab; - b.classList.toggle('active', isActive); - if (isActive) { - b.setAttribute('aria-current', 'page'); - } else { - b.removeAttribute('aria-current'); - } - }); - document.querySelectorAll('.view').forEach(function(v) { - v.classList.toggle('active', v.id === 'view-' + tab); - }); - if (state.flagsConfig) renderFlagBanners(state.flagsConfig); - loadTab(tab); - } - - async function loadTab(tab) { - switch(tab) { - case 'dashboard': if (!state.dashboard.loaded) await loadDashboard(); break; - case 'graph': if (!state.graph.loaded) await loadGraph(); break; - case 'memories': if (!state.memories.loaded) await loadMemories(); break; - case 'timeline': if (!state.timeline.loaded) await loadTimeline(); break; - case 'sessions': if (!state.sessions.loaded) await loadSessions(); break; - case 'lessons': if (!state.lessons.loaded) await loadLessons(); break; - case 'actions': if (!state.actions.loaded) await loadActions(); break; - case 'audit': if (!state.audit.loaded) await loadAudit(); break; - case 'activity': if (!state.activity.loaded) await loadActivity(); break; - case 'profile': if (!state.profile.loaded) await loadProfile(); break; - case 'replay': if (!state.replay.loaded) await loadReplay(); break; - } - } - - async function loadDashboard() { - var el = document.getElementById('view-dashboard'); - el.innerHTML = '
加载总览中...
'; - var showDebug = new URLSearchParams(window.location.search).get('debug') === '1'; - try { - var baseResults = await Promise.all([ - apiGet('health'), - apiGet('sessions'), - apiGet('actions'), - apiGet('review?status=pending&kind=action&limit=200'), - apiGet('inbox?status=awaiting&limit=50') - ]); - state.dashboard.health = baseResults[0]; - state.dashboard.sessions = ((baseResults[1] && baseResults[1].sessions) || []).filter(function(s) { return !isDemoSession(s); }); - state.dashboard.actions = ((baseResults[2] && baseResults[2].actions) || []).filter(isActionRenderable); - state.dashboard.actionReviews = ((baseResults[3] && baseResults[3].items) || []).filter(isActionReviewRenderable); - state.dashboard.inboxAwaiting = (baseResults[4] && baseResults[4].items) || []; - if (showDebug) { - var debugResults = await Promise.all([ - apiGet('memories?latest=true&limit=500'), - apiGet('graph/stats'), - apiGet('audit?limit=5'), - apiGet('semantic'), - apiGet('procedural'), - apiGet('relations'), - apiGet('lessons'), - apiGet('delivery-status') - ]); - state.dashboard.memories = (debugResults[0] && debugResults[0].memories) || []; - state.dashboard.graphStats = debugResults[1]; - state.dashboard.recentAudit = (debugResults[2] && debugResults[2].entries) || []; - state.dashboard.semantic = (debugResults[3] && debugResults[3].facts) || (debugResults[3] && debugResults[3].semantic) || []; - state.dashboard.procedural = (debugResults[4] && debugResults[4].procedures) || (debugResults[4] && debugResults[4].procedural) || []; - state.dashboard.relations = (debugResults[5] && debugResults[5].relations) || []; - state.dashboard.lessons = (debugResults[6] && debugResults[6].lessons) || []; - state.dashboard.delivery = debugResults[7] || null; - } - state.dashboard.loaded = true; - renderDashboard(); - } catch (err) { - // Without this catch, any uncaught error in the await Promise.all - // or the renderDashboard call leaves the dashboard stuck on - // "Loading dashboard..." forever with no indication to the user - // (#323). apiGet() already swallows network/HTTP errors and - // returns null, but renderDashboard can still throw on shape - // surprises (CSP-blocked event handler binding, undefined fields). - // Surface the error in the panel + log full detail to console. - var msg = (err && err.message) ? err.message : String(err); - console.error('[viewer] loadDashboard failed:', err); - el.innerHTML = - '
' + - 'Dashboard failed to load: ' + msg + - '

' + - 'Check the browser console for the full error. If you see CSP ' + - 'violations, please open an issue with the AI Todo version ' + - '(top-right of the viewer) and the violation text.' + - '
'; - } - } - - function deliveryBadgeClass(value) { - if (value === 'ready') return 'badge-green'; - if (value === 'mostly-ready') return 'badge-yellow'; - return 'badge-muted'; - } - function deliveryStatusLabel(value) { - if (value === 'ready') return '已就绪'; - if (value === 'mostly-ready') return '基本可试'; - return '等待准备'; - } - function publicReleaseLabel(value) { - if (value === 'ready') return '可发布'; - return '等待证据'; - } - function renderDeliveryStatusCard(delivery, options) { - var showDebug = options && options.debug; - if (!delivery) return ''; - if (delivery.available === false) { - return '
浏览器入口
浏览器入口还没有打包。现在可以先在本机加载 browser-extension/ 文件夹使用。
'; - } - var real = delivery.realSiteValidation || {}; - var passed = Number(real.passedCount || 0); - var required = Number(real.requiredCount || 4); - var pct = required > 0 ? Math.round((passed / required) * 100) : 0; - var external = delivery.externalTesting || 'not-ready'; - var publicState = delivery.publicRelease || 'not-ready'; - var notPassed = Array.isArray(real.notPassed) ? real.notPassed : []; - var sites = Array.isArray(real.sites) ? real.sites : []; - var zip = delivery.artifacts && delivery.artifacts.extensionZip ? delivery.artifacts.extensionZip : null; - var zipLabel = zip && zip.exists ? '插件包已生成' : '插件包未生成'; - var version = delivery.extension && delivery.extension.version ? delivery.extension.version : '0.1.0'; - var next = notPassed.length ? notPassed.join('、') : '真实站点已齐'; - var html = '
'; - html += '
'; - html += '
浏览器会话同步
打开 AI 会话页后自动同步,后续整理都在工作台完成。
'; - html += '' + esc(zipLabel) + ''; - html += '
'; - html += '
'; - html += '
' + esc(version) + ' · 支持 ChatGPT / Claude / Gemini 等 AI 会话页 · 真实页面证据 ' + passed + '/' + required + '
'; - html += ''; - html += '
'; - if (!showDebug) { - html += '
插件界面只显示同步状态,不在浏览器里做内容挑选。
'; - return html; - } - html += '
'; - html += '
插件自检
' + esc(deliveryStatusLabel(delivery.localDemo)) + '
排错用预览页
'; - html += '
真实 AI 证据
' + passed + '/' + required + '
公开发布必需
'; - html += '
公开发布
' + esc(publicReleaseLabel(publicState)) + '
需真实站点通过
'; - html += '
'; - html += '
真实 AI 页面通过证据必须包含 turnCount > 0 和会话区域 selector;插件只同步网页 AI 会话,不读取选中文本或输入草稿。
'; - html += '
AI 页面
' + pct + '%
'; - if (sites.length) { - html += '
'; - sites.forEach(function(site) { - var status = site.status || 'missing'; - var badge = status === 'passed' ? 'badge-green' : status === 'needs-fix' ? 'badge-yellow' : 'badge-muted'; - var label = status === 'passed' ? '已通过' : status === 'needs-fix' ? '待修复' : '待验收'; - var missing = Array.isArray(site.missing) && site.missing.length ? site.missing.join('、') : (status === 'passed' ? '证据完整' : '缺真实页面证据'); - html += '
'; - html += '
' + esc(site.product || 'AI') + '
' + label + '
'; - html += '
' + esc(missing) + '
'; - if (site.file) html += '
' + esc(site.file) + '
'; - html += '
'; - }); - html += '
'; - } - html += '
'; - html += '
下一步验收:' + esc(next) + '
'; - html += ''; - html += '
'; - return html; - } - function renderDashboard() { - var el = document.getElementById('view-dashboard'); - var d = state.dashboard; - var showDebug = new URLSearchParams(window.location.search).get('debug') === '1'; - var h = d.health || {}; - var snap = h.health || {}; - var healthStatus = h.status || 'unknown'; - var dotClass = healthStatus === 'healthy' ? 'healthy' : healthStatus === 'degraded' ? 'degraded' : healthStatus === 'critical' ? 'critical' : ''; - var activeSessions = d.sessions.filter(function(s) { return s.status === 'active'; }).length; - var gs = d.graphStats || {}; - var nodeCount = gs.totalNodes !== undefined ? gs.totalNodes : (gs.nodes !== undefined ? gs.nodes : (gs.nodeCount || 0)); - var edgeCount = gs.totalEdges !== undefined ? gs.totalEdges : (gs.edges !== undefined ? gs.edges : (gs.edgeCount || 0)); - var fMetrics = h.functionMetrics || []; - var cb = h.circuitBreaker || null; - var workers = snap.workers || []; - var actions = (d.actions || []).filter(isActionRenderable); - var actionReviews = (d.actionReviews || []).filter(isActionReviewRenderable); - var awaitingReplies = (d.inboxAwaiting || []).filter(function(i) { return i && i.kind === 'question'; }); - var followUps = actions.filter(function(a) { return a.status === 'pending' || a.status === 'blocked'; }); - - var html = ''; - - // First-run hero: empty dashboard = guided next step - if (d.sessions.length === 0) { - html += '
' + - '
' + t('dash.firstRun.kicker') + '
' + - '
' + t('dash.firstRun.title') + '
' + - '
' + t('dash.firstRun.body') + '
' + - '
node dist/cli.mjs demo
' + - '' + - '
'; - } - html += '
'; - var latestSessionTime = d.sessions.length ? shortDateTime(sessionRecordTime(d.sessions.slice().sort(function(a, b) { return (sessionRecordTime(b) || '').localeCompare(sessionRecordTime(a) || ''); })[0])) : t('dash.noRecord'); - html += '
' + t('dash.stat.sessions') + '
' + d.sessions.length + '
' + t('dash.stat.recent') + ' ' + esc(latestSessionTime) + '
'; - html += '
' + t('dash.stat.todos') + '
' + actions.length + '
'; - html += '
' + t('act.metric.waiting') + '
' + awaitingReplies.length + '
'; - html += '
' + t('act.metric.review') + '
' + actionReviews.length + '
'; - html += '
' + t('act.metric.followUp') + '
' + followUps.length + '
'; - var lessonCount = (d.lessons || []).length; - if (showDebug) { - html += '
' + t('dash.stat.memories') + '
' + d.memories.length + '
' + t('dash.stat.latestVersion') + '
'; - html += '
' + t('dash.stat.lessons') + '
' + lessonCount + '
' + t('dash.stat.lessonsSub') + '
'; - } - if (showDebug && (nodeCount > 0 || edgeCount > 0)) { - html += '
' + t('dash.stat.graphNodes') + '
' + nodeCount + '
' + t('dash.stat.edges') + ' ' + edgeCount + '
'; - } - if (showDebug) { - html += '
' + t('dash.stat.status') + '
' + esc(healthStatus) + '
'; - html += '
' + esc(snap.connectionState || 'unknown') + '
'; - } - if (showDebug) { - var totalCalls = fMetrics.reduce(function(a, m) { return a + (m.totalCalls || 0); }, 0); - html += '
' + t('dash.stat.toolCalls') + '
' + totalCalls + '
' + t('dash.stat.tracking') + ' ' + fMetrics.length + ' ' + t('dash.stat.functions') + '
'; - } - if (showDebug && cb) { - var cbClass = cb.state === 'closed' ? 'cb-closed' : cb.state === 'open' ? 'cb-open' : 'cb-half-open'; - html += '
' + t('dash.stat.circuitBreaker') + '
' + esc(cb.state) + '
'; - html += '
' + t('dash.stat.failed') + ' ' + (cb.failures || 0) + ' ' + t('dash.stat.times') + '
'; - } - var totalObs = d.sessions.reduce(function(a, s) { return a + (s.observationCount || 0); }, 0); - var tokenBudget = parseInt(new URLSearchParams(window.location.search).get('tokenBudget') || '2000', 10) || 2000; - var estFull = totalObs * 80; - var estInjected = d.sessions.length * tokenBudget; - var savings = estFull > 0 ? Math.round((1 - estInjected / Math.max(estFull, 1)) * 100) : 0; - if (savings < 0) savings = 0; - html += '
'; - - html += renderDeliveryStatusCard(d.delivery, { debug: showDebug }); - - if (showDebug && (snap.memory || snap.cpu)) { - html += '
' + t('dash.systemResources') + '
'; - if (snap.memory) { - var heapUsed = Math.round((snap.memory.heapUsed || 0) / 1024 / 1024); - var heapTotal = Math.round((snap.memory.heapTotal || 0) / 1024 / 1024); - var rss = Math.round((snap.memory.rss || 0) / 1024 / 1024); - var heapPct = heapTotal > 0 ? Math.round((heapUsed / heapTotal) * 100) : 0; - var rssAboveFloor = rss >= 512; - var heapColor = (heapPct > 80 && rssAboveFloor) ? 'var(--red)' : (heapPct > 60 && rssAboveFloor) ? 'var(--yellow)' : 'var(--green)'; - html += '
Heap
' + heapUsed + ' / ' + heapTotal + ' MB
'; - html += '
RSS
' + rss + ' MB
'; - if (snap.memory.external) { - var ext = Math.round(snap.memory.external / 1024 / 1024); - html += '
External
' + ext + ' MB
'; - } - } - if (snap.cpu) { - var cpuPct = snap.cpu.percent || 0; - var cpuColor = cpuPct > 80 ? 'var(--red)' : cpuPct > 50 ? 'var(--yellow)' : 'var(--green)'; - html += '
CPU
' + cpuPct.toFixed(1) + '%
'; - } - if (snap.eventLoopLagMs !== undefined) { - var lag = snap.eventLoopLagMs; - var lagColor = lag > 100 ? 'var(--red)' : lag > 20 ? 'var(--yellow)' : 'var(--green)'; - html += '
Event Loop
' + lag.toFixed(1) + ' ms
'; - } - if (snap.uptimeSeconds) { - var mins = Math.floor(snap.uptimeSeconds / 60); - var hrs = Math.floor(mins / 60); - var upStr = hrs > 0 ? hrs + 'h ' + (mins % 60) + 'm' : mins + 'm'; - html += '
UPTIME: ' + upStr + '
'; - } - html += '
'; - } - - if (showDebug && snap.alerts && snap.alerts.length > 0) { - html += '
' + t('dash.alerts') + ' (' + snap.alerts.length + ')
'; - snap.alerts.forEach(function(al) { - html += '
' + esc(al) + '
'; - }); - html += '
'; - } - - if (showDebug && snap.notes && snap.notes.length > 0) { - html += '
' + t('dash.notes') + ' (' + snap.notes.length + ')
'; - snap.notes.forEach(function(n) { - html += '
' + esc(n) + '
'; - }); - html += '
'; - } - - html += '
' + t('dash.recentSessions') + '
'; - if (d.sessions.length === 0) { - html += '

' + t('dash.emptySessions') + '

'; - } else { - var recentGroups = groupSessionsByProject(d.sessions).slice(0, 5); - html += '
'; - recentGroups.forEach(function(g) { - var latest = g.sessions && g.sessions[0]; - var latestId = sessionId(latest); - var cardName = g.hasMissingId ? t('dash.unnamedSession') : g.name; - html += ''; - }); - html += '
'; - } - html += '
'; - - if (showDebug && fMetrics.length > 0) { - var sorted = fMetrics.slice().sort(function(a, b) { return (b.totalCalls || 0) - (a.totalCalls || 0); }); - html += '
' + t('dash.functionMetrics') + '
'; - html += ''; - sorted.forEach(function(m) { - var successRate = m.totalCalls > 0 ? Math.round((m.successCount / m.totalCalls) * 100) : 0; - var rateColor = successRate >= 95 ? 'var(--green)' : successRate >= 80 ? 'var(--yellow)' : 'var(--red)'; - var latencyColor = m.avgLatencyMs > 1000 ? 'var(--red)' : m.avgLatencyMs > 200 ? 'var(--yellow)' : 'var(--green)'; - html += ''; - html += ''; - html += ''; - html += ''; - html += ''; - html += ''; - html += ''; - html += ''; - }); - html += '
FunctionCallsSuccessFailAvg LatencyQuality
' + esc(m.functionId) + '' + m.totalCalls + '' + m.successCount + ' (' + successRate + '%)' + m.failureCount + '' + Math.round(m.avgLatencyMs) + ' ms' + (m.avgQualityScore > 0 ? m.avgQualityScore.toFixed(2) : '-') + '
'; - } - - if (showDebug && workers.length > 0) { - html += '
' + t('dash.workers') + '
'; - workers.forEach(function(w) { - var statusClass = w.status === 'running' ? 'running' : w.status === 'starting' ? 'starting' : 'stopped'; - html += '
'; - html += '' + esc(w.name) + ''; - html += '' + esc(w.status) + ''; - html += '' + esc(w.id) + '
'; - }); - html += '
'; - } - - if (showDebug && cb && cb.state !== 'closed') { - html += '
' + t('dash.circuitBreakerDetail') + '
'; - html += '
State
' + esc(cb.state) + '
'; - html += '
Failures
' + (cb.failures || 0) + '
'; - if (cb.lastFailureAt) html += '
Last Failure
' + esc(formatTime(cb.lastFailureAt)) + '
'; - if (cb.openedAt) html += '
Opened At
' + esc(formatTime(cb.openedAt)) + '
'; - html += '
'; - } - - if (showDebug) { - var semFacts = d.semantic || []; - var procItems = d.procedural || []; - var relItems = d.relations || []; - - html += '
'; - html += '
'; - - html += '
' + t('dash.semanticMemory') + '
'; - if (semFacts.length === 0) { - html += '
' + t('dash.emptySemantic') + '
'; - } else { - semFacts.slice(0, 5).forEach(function(f) { - var conf = typeof f.confidence === 'number' ? Math.round(f.confidence * 100) : null; - var str = typeof f.strength === 'number' ? Math.round(f.strength * 100) : null; - var barColor = (str || 0) > 70 ? 'var(--green)' : (str || 0) > 40 ? 'var(--yellow)' : 'var(--red)'; - html += '
'; - html += '' + esc(f.fact || f.content || f.title || 'Fact') + ''; - html += ''; - if (str !== null) html += ''; - if (conf !== null) html += '' + conf + '%'; - html += '
'; - }); - } - html += '
'; - - html += '
' + t('dash.proceduralMemory') + '
'; - if (procItems.length === 0) { - html += '
' + t('dash.emptyProcedural') + '
'; - } else { - procItems.slice(0, 5).forEach(function(p) { - html += '
'; - html += '
' + esc(p.name || p.title || 'Procedure') + '
'; - if (p.trigger || p.triggerCondition) html += '
Trigger: ' + esc(p.trigger || p.triggerCondition) + '
'; - if (p.frequency) html += '
Freq: ' + p.frequency + '
'; - if (p.steps && p.steps.length > 0) { - html += '
    '; - p.steps.slice(0, 4).forEach(function(s) { html += '
  1. ' + esc(typeof s === 'string' ? s : s.description || s.action || JSON.stringify(s)) + '
  2. '; }); - if (p.steps.length > 4) html += '
  3. + ' + (p.steps.length - 4) + ' more...
  4. '; - html += '
'; - } - html += '
'; - }); - } - html += '
'; - - html += '
'; - - html += '
' + t('dash.consolidationStatus') + '
'; - html += '
Semantic facts' + semFacts.length + '
'; - html += '
Procedures' + procItems.length + '
'; - html += '
Relations' + relItems.length + '
'; - html += '
'; - - if (relItems.length > 0) { - html += '
' + t('dash.memoryRelations') + '
'; - relItems.slice(0, 8).forEach(function(r) { - var relType = r.type || r.relationType || 'related'; - var badgeClass = relType === 'supersedes' ? 'badge-red' : relType === 'extends' ? 'badge-green' : relType === 'contradicts' ? 'badge-yellow' : 'badge-muted'; - html += '
'; - html += '' + esc(truncate(r.sourceId || r.fromId || '', 8)) + ''; - html += '' + esc(relType) + ''; - html += '' + esc(truncate(r.targetId || r.toId || '', 8)) + ''; - html += '
'; - }); - html += '
'; - } - } - - el.innerHTML = html; - } - - var dashboardTimer = null; - function refreshDashboard() { - state.dashboard.loaded = false; - loadDashboard(); - } - function isViewingSessionDetail() { - return state.activeTab === 'sessions' && !!state.sessions.selectedId; - } - function shouldDeferSessionsRefresh(reason) { - return isViewingSessionDetail() && ['timer', 'poll', 'ws', 'visibility'].indexOf(reason || '') >= 0; - } - function sessionStaleNoticeMarkup() { - if (!state.sessions.stale || !state.sessions.selectedId) return ''; - return '
有新记录,当前详情暂未自动刷新。
'; - } - function renderSessionsStaleNotice() { - var host = document.getElementById('sessions-stale-notice'); - if (host) host.innerHTML = sessionStaleNoticeMarkup(); - } - function markSessionsStale() { - state.sessions.stale = true; - if (state.activeTab === 'sessions') renderSessionsStaleNotice(); - } - function markActionsStale(message) { - state.actions.stale = true; - if (message) state.actions.extractMessage = message; - if (state.activeTab === 'actions' && !actionsScrolledAway()) renderActions(); - } - function refreshActiveTab(reason) { - reason = reason || 'manual'; - switch (state.activeTab) { - case 'dashboard': state.dashboard.loaded = false; loadDashboard(); break; - case 'memories': state.memories.loaded = false; loadMemories(); break; - case 'sessions': - if (shouldDeferSessionsRefresh(reason)) { - markSessionsStale(); - } else { - state.sessions.loaded = false; - loadSessions({ showLoading: reason === 'manual', reason: reason }); - } - break; - case 'lessons': state.lessons.loaded = false; loadLessons(); break; - case 'actions': - if (reason === 'manual') { - state.actions.loaded = false; - loadActions({ generate: false }); - } else { - markActionsStale('有新会话记录,待办暂未自动刷新。'); - } - break; - case 'activity': state.activity.loaded = false; loadActivity(); break; - } - } - function markMemoryViewsStale() { - state.dashboard.loaded = false; - state.memories.loaded = false; - state.sessions.loaded = false; - state.lessons.loaded = false; - state.actions.loaded = false; - state.activity.loaded = false; - } - function saveDashboardRefreshPrefs() { - try { - localStorage.setItem('viewer_dashboard_refresh_enabled', state.dashboardRefresh.enabled ? '1' : '0'); - localStorage.setItem('viewer_dashboard_refresh_interval', String(state.dashboardRefresh.intervalMs)); - } catch {} - } - function loadDashboardRefreshPrefs() { - try { - var enabled = localStorage.getItem('viewer_dashboard_refresh_enabled'); - var interval = parseInt(localStorage.getItem('viewer_dashboard_refresh_interval') || '', 10); - if (enabled === '0') state.dashboardRefresh.enabled = false; - if (!Number.isNaN(interval) && interval >= 10000 && interval <= 120000) state.dashboardRefresh.intervalMs = interval; - } catch {} - } - function startDashboardAutoRefresh() { - if (dashboardTimer) clearInterval(dashboardTimer); - if (!state.dashboardRefresh.enabled) return; - dashboardTimer = setInterval(function() { - if (pollTimer) return; - if (state.activeTab === 'dashboard') refreshActiveTab('timer'); - }, state.dashboardRefresh.intervalMs || 30000); - } - - var graphSim = { nodes: [], edges: [], running: false, canvas: null, ctx: null, raf: null, panX: 0, panY: 0, zoom: 1, dragNode: null, mouseX: 0, mouseY: 0, tickCount: 0, quietTicks: 0 }; - function wakeGraphSim() { - graphSim.quietTicks = 0; - if (graphSim.running && !graphSim.raf) { - graphSim.raf = requestAnimationFrame(runSimulation); - } - } - - async function loadGraph() { - var el = document.getElementById('view-graph'); - el.innerHTML = '
检查关系图数据中...
'; - - var results = await Promise.all([ - apiPost('graph/query', {}), - apiGet('graph/stats') - ]); - var queryResult = results[0] || { nodes: [], edges: [] }; - state.graph.nodes = queryResult.nodes || []; - state.graph.edges = queryResult.edges || []; - state.graph.stats = results[1] || {}; - - if (state.graph.nodes.length === 0) { - state.graph.loaded = true; - el.innerHTML = - '
' + - '
🕸
' + - '
关系图暂时隐藏
' + - '
当前没有可展示的节点和连线,所以侧边栏不再显示这个入口。等系统真的提取出关系后,再把关系图作为可用功能放回来。
' + - '
需要关系图时:配置 LLM key,并开启 GRAPH_EXTRACTION_ENABLED=true 后重启。
' + - '
'; - return; - } - - el.innerHTML = '
'; - state.graph.loaded = true; - var types = {}; - state.graph.nodes.forEach(function(n) { types[n.type] = true; }); - state.graph.filters = types; - - renderGraphSidebar(); - initGraph(); - } - - var NODE_SHAPES = { - file: 'rect', function: 'circle', concept: 'circle', error: 'diamond', - decision: 'diamond', pattern: 'circle', library: 'hexagon', person: 'circle' - }; - var graphSearchTerm = ''; - - function renderGraphSidebar() { - var sb = document.getElementById('graph-sidebar'); - if (!sb) return; - var gs = state.graph.stats || {}; - var nodeCount = gs.totalNodes !== undefined ? gs.totalNodes : (gs.nodes !== undefined ? gs.nodes : (gs.nodeCount || state.graph.nodes.length)); - var edgeCount = gs.totalEdges !== undefined ? gs.totalEdges : (gs.edges !== undefined ? gs.edges : (gs.edgeCount || state.graph.edges.length)); - - var html = ''; - - html += '

图谱统计

'; - html += '
'; - html += '
' + nodeCount + '
Nodes
'; - html += '
'; - html += '
' + edgeCount + '
Edges
'; - html += '
'; - - html += '

按类型筛选

'; - Object.keys(state.graph.filters).forEach(function(type) { - var color = NODE_COLORS[type] || '#666666'; - html += ''; - }); - - html += '

图例

'; - var shapeLabels = { rect: '▭', circle: '●', diamond: '◆', hexagon: '⬢' }; - var shownShapes = {}; - Object.keys(NODE_COLORS).forEach(function(type) { - var shape = NODE_SHAPES[type] || 'circle'; - var color = NODE_COLORS[type]; - var key = type; - if (shownShapes[key]) return; - shownShapes[key] = true; - html += '
' + (shapeLabels[shape] || '●') + '' + esc(type) + '
'; - }); - html += '
'; - - html += ''; - html += '
'; - var __focus = captureSearchFocus(['graph-search']); - sb.innerHTML = html; - - sb.querySelectorAll('input[type="checkbox"]').forEach(function(cb) { - cb.addEventListener('change', function() { - state.graph.filters[this.dataset.type] = this.checked; - renderGraph(); - }); - }); - - var searchInput = document.getElementById('graph-search'); - if (searchInput) { - bindImeSafeSearch(searchInput, 200, function(v){ graphSearchTerm = v.toLowerCase(); renderGraph(); }); - } - restoreSearchFocus(__focus); - } - - function initGraph() { - var canvas = document.getElementById('graph-canvas'); - if (!canvas) return; - graphSim.canvas = canvas; - graphSim.ctx = canvas.getContext('2d'); - - function resize() { - var r = canvas.parentElement.getBoundingClientRect(); - canvas.width = r.width * window.devicePixelRatio; - canvas.height = r.height * window.devicePixelRatio; - canvas.style.width = r.width + 'px'; - canvas.style.height = r.height + 'px'; - graphSim.ctx.setTransform(window.devicePixelRatio, 0, 0, window.devicePixelRatio, 0, 0); - } - resize(); - window.addEventListener('resize', resize); - - var cw = canvas.width / window.devicePixelRatio; - var ch = canvas.height / window.devicePixelRatio; - graphSim.panX = cw / 2; - graphSim.panY = ch / 2; - - var edgeMap = {}; - state.graph.edges.forEach(function(e) { - edgeMap[e.sourceNodeId] = (edgeMap[e.sourceNodeId] || 0) + 1; - edgeMap[e.targetNodeId] = (edgeMap[e.targetNodeId] || 0) + 1; - }); - - graphSim.nodes = state.graph.nodes.map(function(n, i) { - var angle = (2 * Math.PI * i) / Math.max(state.graph.nodes.length, 1); - var radius = Math.min(cw, ch) * 0.3; - var deg = edgeMap[n.id] || 0; - return { - id: n.id, type: n.type, name: n.name, properties: n.properties, - x: Math.cos(angle) * radius + (Math.random() - 0.5) * 50, - y: Math.sin(angle) * radius + (Math.random() - 0.5) * 50, - vx: 0, vy: 0, - r: Math.max(8, Math.min(22, 8 + deg * 2.5)) - }; - }); - graphSim.edges = state.graph.edges.slice(); - graphSim.running = true; - graphSim.dragNode = null; - - setupGraphInteraction(canvas); - runSimulation(); - } - - function setupGraphInteraction(canvas) { - var isPanning = false; - var lastMX = 0, lastMY = 0; - - function canvasCoords(e) { - var rect = canvas.getBoundingClientRect(); - return { - x: (e.clientX - rect.left - graphSim.panX) / graphSim.zoom, - y: (e.clientY - rect.top - graphSim.panY) / graphSim.zoom - }; - } - function findNode(cx, cy) { - for (var i = graphSim.nodes.length - 1; i >= 0; i--) { - var n = graphSim.nodes[i]; - if (!state.graph.filters[n.type]) continue; - var dx = n.x - cx, dy = n.y - cy; - if (dx * dx + dy * dy < n.r * n.r + 25) return n; - } - return null; - } - - canvas.addEventListener('mousedown', function(e) { - var c = canvasCoords(e); - var node = findNode(c.x, c.y); - if (node) { - graphSim.dragNode = node; - } else { - isPanning = true; - } - lastMX = e.clientX; - lastMY = e.clientY; - // wake the simulation if it parked itself after settling - wakeGraphSim(); - }); - canvas.addEventListener('mousemove', function(e) { - var dx = e.clientX - lastMX; - var dy = e.clientY - lastMY; - if (graphSim.dragNode) { - graphSim.dragNode.x += dx / graphSim.zoom; - graphSim.dragNode.y += dy / graphSim.zoom; - graphSim.dragNode.vx = 0; - graphSim.dragNode.vy = 0; - } else if (isPanning) { - graphSim.panX += dx; - graphSim.panY += dy; - } - lastMX = e.clientX; - lastMY = e.clientY; - graphSim.mouseX = e.clientX; - graphSim.mouseY = e.clientY; - - var c = canvasCoords(e); - var hoverNode = findNode(c.x, c.y); - var tooltip = document.getElementById('graph-tooltip'); - if (tooltip) { - if (hoverNode && !graphSim.dragNode && !isPanning) { - var conns = graphSim.edges.filter(function(ed) { return ed.sourceNodeId === hoverNode.id || ed.targetNodeId === hoverNode.id; }).length; - var ttHtml = '
' + esc(hoverNode.name) + '
'; - ttHtml += '
' + esc(hoverNode.type) + '
'; - if (hoverNode.properties) { - var propKeys = Object.keys(hoverNode.properties).slice(0, 3); - propKeys.forEach(function(k) { - ttHtml += '
' + esc(k) + ': ' + esc(truncate(String(hoverNode.properties[k]), 30)) + '
'; - }); - } - ttHtml += '
' + conns + ' connection' + (conns !== 1 ? 's' : '') + '
'; - tooltip.innerHTML = ttHtml; - var rect = canvas.getBoundingClientRect(); - tooltip.style.left = (e.clientX - rect.left + 12) + 'px'; - tooltip.style.top = (e.clientY - rect.top + 12) + 'px'; - tooltip.classList.add('visible'); - canvas.style.cursor = 'pointer'; - } else { - tooltip.classList.remove('visible'); - canvas.style.cursor = graphSim.dragNode || isPanning ? 'grabbing' : 'grab'; - } - } - }); - canvas.addEventListener('mouseup', function(e) { - if (graphSim.dragNode && !isPanning) { - selectGraphNode(graphSim.dragNode); - } - graphSim.dragNode = null; - isPanning = false; - }); - canvas.addEventListener('wheel', function(e) { - e.preventDefault(); - var factor = e.deltaY > 0 ? 0.9 : 1.1; - graphSim.zoom = Math.max(0.1, Math.min(5, graphSim.zoom * factor)); - // zoom is visually meaningless if the rAF loop is parked — - // wake the simulation so the next frame redraws at the new scale. - wakeGraphSim(); - }, { passive: false }); - canvas.addEventListener('dblclick', function(e) { - var c = canvasCoords(e); - var node = findNode(c.x, c.y); - if (node) { - selectGraphNode(node); - expandNode(node.id); - } - }); - } - - window.zoomGraph = function(dir) { - var factor = dir > 0 ? 1.25 : 0.8; - graphSim.zoom = Math.max(0.1, Math.min(5, graphSim.zoom * factor)); - wakeGraphSim(); - }; - window.recenterGraph = function() { - graphSim.zoom = 1; - if (graphSim.canvas) { - var cw = graphSim.canvas.width / window.devicePixelRatio; - var ch = graphSim.canvas.height / window.devicePixelRatio; - graphSim.panX = cw / 2; - graphSim.panY = ch / 2; - } - wakeGraphSim(); - }; - - function selectGraphNode(simNode) { - state.graph.selectedNode = simNode; - var panel = document.getElementById('selected-node-panel'); - if (!panel) return; - var color = NODE_COLORS[simNode.type] || '#666666'; - var html = '
'; - html += '

' + esc(simNode.name) + '

'; - html += '
Type: ' + esc(simNode.type) + '
'; - if (simNode.properties) { - Object.keys(simNode.properties).forEach(function(k) { - html += '
' + esc(k) + ': ' + esc(truncate(simNode.properties[k], 50)) + '
'; - }); - } - var conns = graphSim.edges.filter(function(e) { return e.sourceNodeId === simNode.id || e.targetNodeId === simNode.id; }).length; - html += '
Connections: ' + conns + '
'; - html += ''; - html += '
'; - panel.innerHTML = html; - } - - async function expandNode(nodeId) { - var result = await apiPost('graph/query', { startNodeId: nodeId, maxDepth: 1 }); - if (!result) return; - var existingIds = {}; - graphSim.nodes.forEach(function(n) { existingIds[n.id] = true; }); - var parentNode = graphSim.nodes.find(function(n) { return n.id === nodeId; }); - var px = parentNode ? parentNode.x : 0; - var py = parentNode ? parentNode.y : 0; - - (result.nodes || []).forEach(function(n) { - if (!existingIds[n.id]) { - state.graph.nodes.push(n); - if (!state.graph.filters.hasOwnProperty(n.type)) state.graph.filters[n.type] = true; - var angle = Math.random() * Math.PI * 2; - graphSim.nodes.push({ - id: n.id, type: n.type, name: n.name, properties: n.properties, - x: px + Math.cos(angle) * 80, - y: py + Math.sin(angle) * 80, - vx: 0, vy: 0, r: 8 - }); - } - }); - - var existingEdges = {}; - graphSim.edges.forEach(function(e) { existingEdges[e.id] = true; }); - (result.edges || []).forEach(function(e) { - if (!existingEdges[e.id]) { - state.graph.edges.push(e); - graphSim.edges.push(e); - } - }); - renderGraphSidebar(); - } - - function runSimulation() { - if (!graphSim.running) return; - var nodes = graphSim.nodes; - var edges = graphSim.edges; - var nodeCount = nodes.length; - graphSim.tickCount = (graphSim.tickCount || 0) + 1; - // dense graphs (>1000 nodes) used to oscillate forever - // because the per-node force pile-up exceeded what 0.9 damping - // could bleed off each tick. Tick-decay tightens damping over - // time so the layout actually settles; a per-node velocity cap - // prevents any single node from being launched off-screen by an - // accumulated kick before damping catches up. - var coolBoost = Math.min(0.4, graphSim.tickCount / 1500); - var damping = 0.9 - coolBoost; - var repulsion = nodeCount > 1000 ? 3000 : nodeCount > 100 ? 2000 : nodeCount > 50 ? 1200 : 800; - var attraction = nodeCount > 100 ? 0.002 : 0.005; - var centerGravity = nodeCount > 1000 ? 0.012 : nodeCount > 100 ? 0.005 : 0.01; - var velocityCap = nodeCount > 1000 ? 6 : nodeCount > 200 ? 12 : 24; - - var nodeMap = {}; - nodes.forEach(function(n) { nodeMap[n.id] = n; }); - - for (var i = 0; i < nodes.length; i++) { - if (graphSim.dragNode === nodes[i]) continue; - var n = nodes[i]; - var fx = 0, fy = 0; - for (var j = 0; j < nodes.length; j++) { - if (i === j) continue; - var dx = n.x - nodes[j].x; - var dy = n.y - nodes[j].y; - var dist = Math.sqrt(dx * dx + dy * dy) || 1; - var force = repulsion / (dist * dist); - fx += (dx / dist) * force; - fy += (dy / dist) * force; - } - fx -= n.x * centerGravity; - fy -= n.y * centerGravity; - var nvx = (n.vx + fx) * damping; - var nvy = (n.vy + fy) * damping; - // Velocity cap (#563): keep any single node from being launched - // off-screen by a one-tick force spike. - if (nvx > velocityCap) nvx = velocityCap; else if (nvx < -velocityCap) nvx = -velocityCap; - if (nvy > velocityCap) nvy = velocityCap; else if (nvy < -velocityCap) nvy = -velocityCap; - n.vx = nvx; - n.vy = nvy; - } - - edges.forEach(function(e) { - var s = nodeMap[e.sourceNodeId]; - var t = nodeMap[e.targetNodeId]; - if (!s || !t) return; - var dx = t.x - s.x; - var dy = t.y - s.y; - var dist = Math.sqrt(dx * dx + dy * dy) || 1; - var f = (dist - 100) * attraction; - var fx = (dx / dist) * f; - var fy = (dy / dist) * f; - if (graphSim.dragNode !== s) { s.vx += fx; s.vy += fy; } - if (graphSim.dragNode !== t) { t.vx -= fx; t.vy -= fy; } - }); - - var totalKineticEnergy = 0; - nodes.forEach(function(n) { - if (graphSim.dragNode === n) return; - n.x += n.vx; - n.y += n.vy; - totalKineticEnergy += n.vx * n.vx + n.vy * n.vy; - }); - - // park the simulation when the layout is quiet to save CPU. - // Pick up again when a drag/interaction wakes the loop. - var rmsVelocity = nodes.length > 0 ? Math.sqrt(totalKineticEnergy / nodes.length) : 0; - if (rmsVelocity < 0.05 && graphSim.tickCount > 60 && !graphSim.dragNode) { - graphSim.quietTicks = (graphSim.quietTicks || 0) + 1; - } else { - graphSim.quietTicks = 0; - } - - renderGraph(); - if (graphSim.quietTicks > 30) { - graphSim.raf = null; - return; - } - graphSim.raf = requestAnimationFrame(runSimulation); - } - - async function rebuildGraph() { - var sb = document.getElementById('graph-sidebar'); - if (sb) sb.innerHTML = '

关系图

正在根据观察重建关系图...

'; - await apiPost('graph/build', {}); - state.graph.loaded = false; - loadGraph(); - } - - function drawNodeShape(ctx, x, y, r, type) { - var shape = NODE_SHAPES[type] || 'circle'; - switch(shape) { - case 'rect': - ctx.beginPath(); - ctx.rect(x - r, y - r * 0.75, r * 2, r * 1.5); - break; - case 'diamond': - ctx.beginPath(); - ctx.moveTo(x, y - r); - ctx.lineTo(x + r, y); - ctx.lineTo(x, y + r); - ctx.lineTo(x - r, y); - ctx.closePath(); - break; - case 'hexagon': - ctx.beginPath(); - for (var i = 0; i < 6; i++) { - var angle = (Math.PI / 3) * i - Math.PI / 2; - var hx = x + r * Math.cos(angle); - var hy = y + r * Math.sin(angle); - if (i === 0) ctx.moveTo(hx, hy); else ctx.lineTo(hx, hy); - } - ctx.closePath(); - break; - default: - ctx.beginPath(); - ctx.arc(x, y, r, 0, Math.PI * 2); - break; - } - } - - function renderGraph() { - var ctx = graphSim.ctx; - var canvas = graphSim.canvas; - if (!ctx || !canvas) return; - var w = canvas.width / window.devicePixelRatio; - var h = canvas.height / window.devicePixelRatio; - - ctx.clearRect(0, 0, w, h); - - // --- Canvas grid background --- - var gridSize = 24; - ctx.save(); - ctx.strokeStyle = isDarkMode() ? 'rgba(255,255,255,0.04)' : 'rgba(0,0,0,0.04)'; - ctx.lineWidth = 0.5; - for (var gx = 0; gx < w; gx += gridSize) { - ctx.beginPath(); ctx.moveTo(gx, 0); ctx.lineTo(gx, h); ctx.stroke(); - } - for (var gy = 0; gy < h; gy += gridSize) { - ctx.beginPath(); ctx.moveTo(0, gy); ctx.lineTo(w, gy); ctx.stroke(); - } - ctx.restore(); - - ctx.save(); - ctx.translate(graphSim.panX, graphSim.panY); - ctx.scale(graphSim.zoom, graphSim.zoom); - - var nodeMap = {}; - graphSim.nodes.forEach(function(n) { nodeMap[n.id] = n; }); - - var searchActive = graphSearchTerm.length > 0; - var totalVisible = graphSim.nodes.filter(function(n) { return state.graph.filters[n.type]; }).length; - var isDense = totalVisible > 40; - var labelZoomThreshold = isDense ? 1.5 : 0.5; - var edgeLabelZoomThreshold = isDense ? 2.5 : 1.2; - var selectedId = state.graph.selectedNode ? state.graph.selectedNode.id : null; - - // --- Hover node detection for focus effect --- - var hoverNodeId = null; - if (!graphSim.dragNode && graphSim.canvas) { - var rect = graphSim.canvas.getBoundingClientRect(); - var hx = (graphSim.mouseX - rect.left - graphSim.panX) / graphSim.zoom; - var hy = (graphSim.mouseY - rect.top - graphSim.panY) / graphSim.zoom; - for (var hi = graphSim.nodes.length - 1; hi >= 0; hi--) { - var hn = graphSim.nodes[hi]; - if (!state.graph.filters[hn.type]) continue; - var hdx = hn.x - hx, hdy = hn.y - hy; - if (hdx * hdx + hdy * hdy < hn.r * hn.r + 25) { hoverNodeId = hn.id; break; } - } - } - var focusNodeId = selectedId || hoverNodeId; - - // --- Draw edges --- - graphSim.edges.forEach(function(e) { - var s = nodeMap[e.sourceNodeId]; - var t = nodeMap[e.targetNodeId]; - if (!s || !t) return; - if (!state.graph.filters[s.type] || !state.graph.filters[t.type]) return; - - var edgeDimmed = searchActive && !(s.name.toLowerCase().includes(graphSearchTerm) || t.name.toLowerCase().includes(graphSearchTerm)); - var isConnectedToFocus = focusNodeId && (e.sourceNodeId === focusNodeId || e.targetNodeId === focusNodeId); - var isFocusActive = focusNodeId !== null; - var weight = typeof e.weight === 'number' ? e.weight : 0.5; - var lineWidth = isConnectedToFocus ? 2 + weight * 2 : 1 + weight * 1.5; - - var dx = t.x - s.x; - var dy = t.y - s.y; - var len = Math.sqrt(dx * dx + dy * dy) || 1; - var curveOffset = isDense ? 12 : 18; - var offsetX = -dy / len * curveOffset; - var offsetY = dx / len * curveOffset; - var cpx = (s.x + t.x) / 2 + offsetX; - var cpy = (s.y + t.y) / 2 + offsetY; - - // Colored edges based on source node type - var edgeColor = NODE_COLORS[s.type] || '#666666'; - var edgeAlpha; - if (edgeDimmed) { - edgeAlpha = 0.06; - } else if (isFocusActive && isConnectedToFocus) { - edgeAlpha = 0.65; - } else if (isFocusActive && !isConnectedToFocus) { - edgeAlpha = 0.06; - } else { - edgeAlpha = isDense ? 0.15 : 0.25; - } - - ctx.beginPath(); - ctx.moveTo(s.x, s.y); - ctx.quadraticCurveTo(cpx, cpy, t.x, t.y); - // Parse hex color to rgba - var r = parseInt(edgeColor.slice(1,3), 16); - var g = parseInt(edgeColor.slice(3,5), 16); - var b = parseInt(edgeColor.slice(5,7), 16); - ctx.strokeStyle = 'rgba(' + r + ',' + g + ',' + b + ',' + edgeAlpha + ')'; - ctx.lineWidth = lineWidth; - ctx.stroke(); - - if (!isDense || isConnectedToFocus) { - var arrowAngle = Math.atan2(t.y - cpy, t.x - cpx); - var arrowLen = 5 + lineWidth; - ctx.beginPath(); - ctx.moveTo(t.x - t.r * Math.cos(arrowAngle), t.y - t.r * Math.sin(arrowAngle)); - ctx.lineTo(t.x - (t.r + arrowLen) * Math.cos(arrowAngle - 0.3), t.y - (t.r + arrowLen) * Math.sin(arrowAngle - 0.3)); - ctx.lineTo(t.x - (t.r + arrowLen) * Math.cos(arrowAngle + 0.3), t.y - (t.r + arrowLen) * Math.sin(arrowAngle + 0.3)); - ctx.closePath(); - ctx.fillStyle = 'rgba(' + r + ',' + g + ',' + b + ',' + (edgeDimmed ? 0.06 : isConnectedToFocus ? 0.6 : 0.2) + ')'; - ctx.fill(); - } - - var showEdgeLabel = e.type && !edgeDimmed && (isConnectedToFocus ? graphSim.zoom > 0.6 : graphSim.zoom > edgeLabelZoomThreshold); - if (showEdgeLabel) { - var zoomInv = 1 / graphSim.zoom; - ctx.save(); - ctx.fillStyle = isDarkMode() ? (isConnectedToFocus ? 'rgba(238,238,238,0.9)' : 'rgba(180,180,180,0.7)') : (isConnectedToFocus ? 'rgba(17,17,17,0.85)' : 'rgba(80,80,80,0.7)'); - ctx.font = (isConnectedToFocus ? '600 ' : '500 ') + (11 * zoomInv).toFixed(1) + 'px Inter, sans-serif'; - ctx.textAlign = 'center'; - ctx.fillText(e.type, cpx, cpy - (4 * zoomInv)); - ctx.restore(); - } - }); - - // --- Draw nodes --- - graphSim.nodes.forEach(function(n) { - if (!state.graph.filters[n.type]) return; - var color = NODE_COLORS[n.type] || '#666666'; - var isSelected = selectedId === n.id; - var isHovered = hoverNodeId === n.id; - var matchesSearch = !searchActive || n.name.toLowerCase().includes(graphSearchTerm); - var isFocusFaded = focusNodeId && n.id !== focusNodeId && !graphSim.edges.some(function(ed) { - return (ed.sourceNodeId === focusNodeId && ed.targetNodeId === n.id) || - (ed.targetNodeId === focusNodeId && ed.sourceNodeId === n.id); - }); - - var nodeAlpha = !matchesSearch ? 0.12 : (isFocusFaded ? 0.2 : 1); - - ctx.save(); - ctx.globalAlpha = nodeAlpha; - - // Glow effect - if (matchesSearch && !isFocusFaded && (isSelected || isHovered || !searchActive)) { - ctx.shadowColor = color; - ctx.shadowBlur = isSelected ? 20 : isHovered ? 16 : (isDense ? 4 : 8); - } - - // Gradient fill - drawNodeShape(ctx, n.x, n.y, n.r, n.type); - var grad = ctx.createRadialGradient(n.x - n.r * 0.3, n.y - n.r * 0.3, 0, n.x, n.y, n.r * 1.2); - var cr = parseInt(color.slice(1,3), 16); - var cg = parseInt(color.slice(3,5), 16); - var cb = parseInt(color.slice(5,7), 16); - grad.addColorStop(0, 'rgba(' + Math.min(255, cr + 60) + ',' + Math.min(255, cg + 60) + ',' + Math.min(255, cb + 60) + ',0.95)'); - grad.addColorStop(1, color); - ctx.fillStyle = grad; - ctx.fill(); - ctx.restore(); - - // Selected ring - if (isSelected) { - ctx.save(); - drawNodeShape(ctx, n.x, n.y, n.r + 3, n.type); - ctx.strokeStyle = color; - ctx.lineWidth = 3; - ctx.shadowColor = color; - ctx.shadowBlur = 12; - ctx.stroke(); - ctx.restore(); - } else if (isHovered) { - drawNodeShape(ctx, n.x, n.y, n.r + 2, n.type); - ctx.strokeStyle = color; - ctx.lineWidth = 2; - ctx.stroke(); - } else if (searchActive && matchesSearch) { - drawNodeShape(ctx, n.x, n.y, n.r, n.type); - ctx.strokeStyle = '#CC0000'; - ctx.lineWidth = 2; - ctx.stroke(); - } - - var showLabel = matchesSearch && !isFocusFaded && ( - isSelected || isHovered || - (searchActive && matchesSearch) || - (!isDense && graphSim.zoom > labelZoomThreshold) || - (isDense && graphSim.zoom > labelZoomThreshold && n.r > 10) - ); - if (showLabel) { - var zoomInv = 1 / graphSim.zoom; - ctx.save(); - ctx.font = (isSelected || isHovered ? '600 ' : '500 ') + (13 * zoomInv).toFixed(1) + 'px Inter, sans-serif'; - ctx.textAlign = 'center'; - - var label = truncate(n.name, 18); - var textW = ctx.measureText(label).width; - var labelW = textW + (16 * zoomInv); - var labelH = 20 * zoomInv; - var labelY = n.y + n.r + (8 * zoomInv); // Top of the background pill - - ctx.fillStyle = isDarkMode() ? 'rgba(30,30,35,0.92)' : 'rgba(255,255,255,0.92)'; - ctx.beginPath(); - ctx.roundRect ? ctx.roundRect(n.x - labelW / 2, labelY, labelW, labelH, 4 * zoomInv) : ctx.rect(n.x - labelW / 2, labelY, labelW, labelH); - ctx.fill(); - - ctx.strokeStyle = isDarkMode() ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.08)'; - ctx.lineWidth = 1 * zoomInv; - ctx.stroke(); - - ctx.fillStyle = isDarkMode() ? (isSelected || isHovered ? '#eeeeee' : '#bbbbbb') : (isSelected || isHovered ? '#111111' : '#444444'); - // Vertically center text in the pill box - ctx.fillText(label, n.x, labelY + (14 * zoomInv)); - ctx.restore(); - } - }); - - ctx.restore(); - - if (graphSim.nodes.length === 0) { - ctx.fillStyle = '#999999'; - ctx.font = '14px Lora, Georgia, serif'; - ctx.textAlign = 'center'; - ctx.fillText('暂无关系图数据', w / 2, h / 2 - 16); - ctx.font = '12px Inter, sans-serif'; - ctx.fillText('Set GRAPH_EXTRACTION_ENABLED=true to enable knowledge graph extraction.', w / 2, h / 2 + 8); - } - } - - async function loadMemories() { - var el = document.getElementById('view-memories'); - el.innerHTML = '
加载记忆中...
'; - // cap at 2000 so the viewer remains responsive on large - // corpora. Older endpoints returned the full unbounded list which - // hit the iii invocation timeout and the UI fell through to 0. - var result = await apiGet('memories?latest=true&limit=2000'); - var items = (result && result.memories) || []; - // Newest first — server returns KV-insertion order. - items.sort(function(a, b) { - var ac = (a && a.createdAt) || (a && a.updatedAt) || ''; - var bc = (b && b.createdAt) || (b && b.updatedAt) || ''; - return bc.localeCompare(ac); - }); - state.memories.items = items; - state.memories.reviewItems = []; - state.memories.total = (result && typeof result.total === 'number') ? result.total : items.length; - state.memories.loaded = true; - renderMemories(); - } - - function renderMemories() { - var el = document.getElementById('view-memories'); - var items = state.memories.items; - var search = state.memories.search.toLowerCase(); - var typeFilter = state.memories.typeFilter; - var sourceFilter = state.memories.sourceFilter || ''; - - var filtered = items.filter(function(m) { - if (typeFilter && m.type !== typeFilter) return false; - if (sourceFilter) { - if (sourceFilter.indexOf('browser:') === 0) { - if (memorySourceKind(m) !== 'browser') return false; - if (browserSourceFromMemory(m) !== sourceFilter.slice('browser:'.length)) return false; - } else if (memorySourceKind(m) !== sourceFilter) { - return false; - } - } - const normalizedSearch = (search || '') - .normalize("NFKC") - .toLowerCase(); - - const normalizedTitle = (m.title || '') - .normalize("NFKC") - .toLowerCase(); - - const normalizedContent = (m.content || '') - .normalize("NFKC") - .toLowerCase(); - - if ( - search && - !normalizedTitle.includes(normalizedSearch) && - !normalizedContent.includes(normalizedSearch) - ) { - return false; - } - - return true; - }); - - var types = {}; - items.forEach(function(m) { types[m.type] = true; }); - var typeOptions = Object.keys(types).sort(); - var displayGroups = {}; - var displayCount = 0; - filtered.forEach(function(m) { - var cat = memoryCategory(m); - var cards = memoryDisplayCards(m); - if (!displayGroups[cat]) displayGroups[cat] = []; - cards.forEach(function(card) { - displayGroups[cat].push(card); - displayCount += 1; - }); - }); - - var html = ''; - - html += '
'; - html += ''; - html += '
'; - html += ''; - html += ''; - html += '
'; - html += ''; - html += '
'; - - html += '
'; - html += '
自动整理中
新的聊天记录会作为记忆线索;你可以在这里审阅、编辑或删除。
'; - html += '
'; - - if (filtered.length === 0) { - html += '
' + - '
' + - '
还没有记忆
' + - '
当 Agent 记录事实、偏好、项目和工作方式后,会在这里按主题归档。
' + - '
'; - } else { - var order = ['身份档案', '偏好', '项目与目标', '判断框架', '经历']; - Object.keys(displayGroups).forEach(function(k) { if (order.indexOf(k) < 0) order.push(k); }); - html += '
'; - order.forEach(function(cat) { - var cards = displayGroups[cat]; - if (!cards || !cards.length) return; - html += '
'; - html += '
'; - html += '
' + esc(cat) + '
'; - if (cat !== '身份档案') html += '' + cards.length + ' 张卡片'; - html += '
'; - if (cat === '身份档案') { - html += renderIdentityProfileGroup(cards); - } else { - html += '
'; - cards.forEach(function(card) { - html += renderMemoryCard(card); - }); - html += '
'; - } - html += '
'; - }); - html += '
'; - } - - var __focus = captureSearchFocus(['mem-search']); - el.innerHTML = html; - - var searchInput = document.getElementById('mem-search'); - if (searchInput) { - bindImeSafeSearch(searchInput, 200, function(v){ state.memories.search = v; renderMemories(); }); - } - var typeSelect = document.getElementById('mem-type-filter'); - if (typeSelect) { - typeSelect.addEventListener('change', function() { - state.memories.typeFilter = this.value; - renderMemories(); - }); - } - var sourceSelect = document.getElementById('mem-source-filter'); - if (sourceSelect) { - sourceSelect.addEventListener('change', function() { - state.memories.sourceFilter = this.value; - renderMemories(); - }); - } - restoreSearchFocus(__focus); - } - - function deleteMemory(id, title) { - var modal = document.getElementById('modal'); - var overlay = document.getElementById('modal-overlay'); - modal.innerHTML = '

删除记忆

确认删除「' + esc(title) + '」吗?此操作不可撤销。

'; - overlay.classList.add('open'); - } - - function openAddMemory() { - var modal = document.getElementById('modal'); - var overlay = document.getElementById('modal-overlay'); - modal.innerHTML = - '

补充记忆线索

' + - '

记忆会优先从聊天记录自动整理;这里用于补充那些没有在会话里说清楚、但希望 Agent 记住的内容。

' + - '
' + - '' + - '
' + - '' + - '' + - '
' + - '
' + - '
' + - ''; - overlay.classList.add('open'); - setTimeout(function() { - var input = document.getElementById('add-memory-content'); - if (input) input.focus(); - }, 0); - } - - async function saveNewMemory() { - var err = document.getElementById('add-memory-error'); - var content = (document.getElementById('add-memory-content') || {}).value || ''; - var area = (document.getElementById('add-memory-area') || {}).value || ''; - var project = (document.getElementById('add-memory-project') || {}).value || ''; - if (!content.trim()) { - if (err) { - err.style.display = 'block'; - err.textContent = '先写一点内容再保存。'; - } - return; - } - var result = await apiPost('remember', { - content: content.trim(), - type: memoryAreaToType(area), - project: project.trim() || undefined - }); - if (!result || result.error) { - if (err) { - err.style.display = 'block'; - err.textContent = (result && result.error) || '保存失败,请稍后再试。'; - } - return; - } - closeModal(); - state.memories.loaded = false; - await loadMemories(); - } - - function editMemory(id) { - var mem = (state.memories.items || []).find(function(m) { return m.id === id; }); - if (!mem) return; - var modal = document.getElementById('modal'); - var overlay = document.getElementById('modal-overlay'); - var area = memoryTypeToArea(mem.type, mem); - var isProfile = area === 'profile'; - modal.innerHTML = - '

' + (isProfile ? '完善档案' : '编辑记忆') + '

' + - '

' + (isProfile ? '这里会影响身份档案的完整呈现。建议按“基本信息、当前重点、沟通偏好、常用表达”补充。' : '调整内容和它在记忆库里的位置。') + '

' + - '
' + - '' + - '
' + - '' + - '' + - '
' + - '' + - '
' + - '
' + - ''; - overlay.classList.add('open'); - } - - async function saveMemory(id) { - var err = document.getElementById('edit-memory-error'); - var title = (document.getElementById('edit-memory-title') || {}).value || ''; - var area = (document.getElementById('edit-memory-area') || {}).value || ''; - var project = (document.getElementById('edit-memory-project') || {}).value || ''; - var content = (document.getElementById('edit-memory-content') || {}).value || ''; - if (!content.trim()) { - if (err) { - err.style.display = 'block'; - err.textContent = '内容不能为空。'; - } - return; - } - var result = await apiPatch('memories/' + encodeURIComponent(id), { - title: title.trim(), - type: memoryAreaToType(area), - project: project.trim(), - content: content.trim() - }); - if (!result || result.error) { - if (err) { - err.style.display = 'block'; - err.textContent = (result && result.error) || '保存失败,请稍后再试。'; - } - return; - } - closeModal(); - state.memories.loaded = false; - await loadMemories(); - } - - async function confirmDeleteMemory(id) { - closeModal(); - await apiDelete('governance/memories', { memoryIds: [id], reason: 'Deleted via viewer' }); - state.memories.loaded = false; - loadMemories(); - } - - function editReviewItem(id) { - var item = (state.memories.reviewItems || []).filter(isReviewItemDisplayable).find(function(x) { return x.id === id; }) || - (state.actions.reviewItems || []).filter(isReviewItemDisplayable).find(function(x) { return x.id === id; }); - if (!item) return; - var payload = item.payload || {}; - var isAction = item.kind === 'action'; - var sourceLabel = reviewSourceLabel(item); - var defaultTags = reviewTags(item).length ? reviewTags(item).join(', ') : ['browser', 'reviewed', payload.provider ? 'source:' + String(payload.provider).toLowerCase() : '', item.page && item.page.type ? 'page:' + item.page.type : ''].filter(Boolean).join(', '); - var defaultProject = reviewProject(item); - var modal = document.getElementById('modal'); - var overlay = document.getElementById('modal-overlay'); - modal.innerHTML = - '

审阅后保存

' + - '

这条内容来自 ' + esc(sourceLabel) + '。确认前可以调整标题、内容和归属,避免临时信息直接进入待办列表。

' + - '
' + - '' + - '' + - '
' + - '' + - '' + - '
' + - '
' + - (isAction ? '' : '') + - '' + - '
' + - '
' + - '
' + - ''; - overlay.classList.add('open'); - } - - async function approveReviewItem(id) { - var fromActionsReview = (state.actions.reviewItems || []).some(function(x) { return x.id === id; }); - var err = document.getElementById('review-error'); - var title = (document.getElementById('review-title') || {}).value || ''; - var content = (document.getElementById('review-content') || {}).value || ''; - var kind = (document.getElementById('review-kind') || {}).value || 'memory'; - var project = (document.getElementById('review-project') || {}).value || ''; - var type = (document.getElementById('review-type') || {}).value || 'fact'; - var priority = parseInt((document.getElementById('review-priority') || {}).value || '', 10); - var tags = (document.getElementById('review-tags') || {}).value || ''; - if (!content.trim()) { - if (err) { - err.style.display = 'block'; - err.textContent = '内容不能为空。'; - } - return; - } - var result = await apiPost('review/approve', { - id: id, - title: title.trim(), - content: content.trim(), - kind: kind, - project: project.trim() || undefined, - type: type, - priority: Number.isFinite(priority) ? priority : undefined, - tags: tags - }); - if (!result || result.error) { - if (err) { - err.style.display = 'block'; - err.textContent = (result && result.error) || '写入失败,请稍后再试。'; - } - return; - } - closeModal(); - state.memories.loaded = false; - if (state.activeTab === 'actions' || fromActionsReview) { - state.actions.loaded = false; - await loadActions(); - } else { - await loadMemories(); - } - } - - async function dismissReviewItem(id) { - var fromActionsReview = (state.actions.reviewItems || []).some(function(x) { return x.id === id; }); - await apiPost('review/dismiss', { id: id }); - state.memories.reviewItems = (state.memories.reviewItems || []).filter(function(x) { return x.id !== id; }); - state.actions.reviewItems = (state.actions.reviewItems || []).filter(function(x) { return x.id !== id; }); - if (state.activeTab === 'actions' || fromActionsReview) { - renderActions(); - } else { - renderMemories(); - } - } - - function closeModal() { - document.getElementById('modal-overlay').classList.remove('open'); - } - - async function loadTimeline() { - var el = document.getElementById('view-timeline'); - el.innerHTML = '
加载时间线中...
'; - var sessResult = await apiGet('sessions'); - var localSessions = await loadLocalCodexSessions(120); - var sessions = mergeSessions(((sessResult && sessResult.sessions) || []), localSessions).filter(function(s) { return !isDemoSession(s); }); - state.timeline.sessions = sessions; - state.timeline.loaded = true; - - var groups = groupSessionsByProject(sessions); - if (groups.length > 0 && !state.timeline.projectKey) { - state.timeline.projectKey = groups[0].key; - } - - renderTimelineToolbar(groups); - if (state.timeline.projectKey) await loadObservations(); - } - - function renderTimelineToolbar(groups) { - var el = document.getElementById('view-timeline'); - groups = (groups || []).map(function(g) { - return g && g.sessions ? g : groupSessionsByProject([g])[0]; - }).filter(Boolean); - var html = '
'; - html += ''; - html += ''; - html += ''; - html += ''; - html += '
'; - html += '
'; - el.innerHTML = html; - - document.getElementById('tl-project').addEventListener('change', function() { - state.timeline.projectKey = this.value; - state.timeline.sessionId = ''; - state.timeline.page = 0; - loadObservations(); - }); - } - - async function loadObservations() { - var content = document.getElementById('tl-content'); - if (!content) return; - if (!state.timeline.projectKey) { - content.innerHTML = '
🕑

请选择一个文件夹查看时间线

'; - return; - } - content.innerHTML = '
加载观察记录中...
'; - var sessions = (state.timeline.sessions || []).filter(function(s) { return sessionProjectKey(s) === state.timeline.projectKey && sessionId(s); }); - var localOnly = sessions.every(function(s) { return String(s.source || '') === 'local-codex-jsonl'; }); - if (localOnly) { - state.timeline.observations = sessions.map(function(s) { - return { - id: sessionId(s), - sessionId: sessionId(s), - timestamp: sessionRecordTime(s), - type: 'conversation', - title: sessionTitleText(s) || 'Codex 会话', - narrative: sessionBodyPreview(s, sessionTitleText(s)), - importance: 5, - _sessionId: sessionId(s), - _sessionName: sessionDisplayName(s) - }; - }).sort(function(a, b) { - return String(b.timestamp || '').localeCompare(String(a.timestamp || '')); - }); - renderObservations(); - return; - } - var results = await Promise.all(sessions.map(function(s) { - return apiGet('observations?sessionId=' + encodeURIComponent(sessionId(s))).then(function(result) { - var list = (result && result.observations) || []; - return list.map(function(o) { - o._sessionId = sessionId(s); - o._sessionName = sessionDisplayName(s); - return o; - }); - }).catch(function() { return []; }); - })); - state.timeline.observations = results.reduce(function(acc, list) { return acc.concat(list); }, []).sort(function(a, b) { - return String(b.timestamp || '').localeCompare(String(a.timestamp || '')); - }); - renderObservations(); - } - - var tlTypeFilter = ''; - - function renderObservations() { - var content = document.getElementById('tl-content'); - if (!content) return; - var obs = state.timeline.observations; - var minImp = state.timeline.minImportance; - var filtered = minImp > 0 ? obs.filter(function(o) { return (o.importance || 0) >= minImp; }) : obs; - var html = ''; - - var typeCounts = {}; - filtered.forEach(function(o) { - var t = observationType(o); - typeCounts[t] = (typeCounts[t] || 0) + 1; - }); - var typeList = Object.keys(typeCounts).sort(function(a, b) { return typeCounts[b] - typeCounts[a]; }); - - if (tlTypeFilter) { - filtered = filtered.filter(function(o) { - var t = observationType(o); - return t === tlTypeFilter; - }); - } - - var pageSize = state.timeline.pageSize; - var page = state.timeline.page; - - if (state.timeline.mode === 'episodes') { - var allEpisodes = buildTimelineEpisodes(filtered); - var episodeFilter = state.timeline.episodeFilter || 'all'; - var filterCounts = { all: allEpisodes.length, important: 0 }; - allEpisodes.forEach(function(ep) { - if (ep.kind === 'important' || ep.kind === 'user_need' || ep.kind === 'bugfix') filterCounts.important++; - }); - html += '
'; - [ - ['all', '全部'], - ['important', '重点'] - ].forEach(function(item) { - var key = item[0]; - var label = item[1]; - html += '' + label + ' (' + (filterCounts[key] || 0) + ')'; - }); - html += '
'; - var episodes = filterTimelineEpisodes(allEpisodes); - var epPageSize = Math.max(10, Math.floor(pageSize / 2)); - var epPage = page; - var epStart = epPage * epPageSize; - var epPaged = episodes.slice(epStart, epStart + epPageSize); - var epTotalPages = Math.ceil(episodes.length / epPageSize); - html += '
故事模式会把同一段工作里的底层记录合并成可读片段。需要看完整流水时,可以切到事件模式。
'; - if (epPaged.length === 0) { - html += '
🕑

暂无可整理的工作片段

'; - content.innerHTML = html; - return; - } - epPaged.forEach(function(ep) { - var accent = ep.importance >= 7 ? OBS_TYPE_COLORS.decision : ep.importance >= 4 ? OBS_TYPE_COLORS.discovery : OBS_TYPE_COLORS.other; - var expanded = !!state.timeline.expandedEpisodes[ep.key]; - var visibleActions = expanded ? ep.actions : ep.actions.slice(0, 2); - html += '
'; - html += '
' + esc(episodeKindLabel(ep.kind)) + '
'; - html += '
' + esc(ep.title) + '
'; - html += '
'; - html += '' + esc(formatTime(ep.start)) + (ep.end && ep.end !== ep.start ? ' - ' + esc(shortTime(ep.end)) : '') + ''; - html += '' + ep.count + ' 条记录'; - html += '' + esc(ep.typeText) + ''; - html += '
'; - html += '
' + esc(ep.body) + '
'; - if (visibleActions.length > 0) { - html += '
    '; - visibleActions.forEach(function(a) { html += '
  • ' + esc(a) + '
  • '; }); - html += '
'; - } - html += ''; - html += '
'; - }); - if (epTotalPages > 1) { - html += ''; - } - content.innerHTML = html; - return; - } - - var start = page * pageSize; - var paged = filtered.slice(start, start + pageSize); - var totalPages = Math.ceil(filtered.length / pageSize); - - html += '
'; - html += '全部 (' + obs.length + ')'; - typeList.forEach(function(t) { - var color = OBS_TYPE_COLORS[t] || '#666666'; - html += '' + esc(t.replace(/_/g, ' ')) + ' (' + typeCounts[t] + ')'; - }); - html += '
'; - - if (paged.length === 0) { - html += '
🕑

暂无观察记录' + (obs.length > 0 ? '(当前筛选无结果,共 ' + obs.length + ' 条)' : '(该会话下)') + '

'; - content.innerHTML = html; - return; - } - - html += '
当前文件夹共显示 ' + filtered.length + ' 条记录
'; - - html += '
'; - - var lastDateGroup = ''; - paged.forEach(function(o, idx) { - var isCompressed = !!o.narrative || !!o.type; - var isRaw = !isCompressed; - var type = observationType(o); - var isFocusEvent = type === 'decision' || type === 'error' || type === 'task' || type === 'conversation'; - var impClass = isFocusEvent ? 'high' : 'low'; - var display = observationDisplay(o); - var title = display.title; - var typeColor = OBS_TYPE_COLORS[type] || '#666666'; - var icon = OBS_TYPE_ICONS[type] || '📄'; - - var dateGroup = ''; - try { - var d = new Date(o.timestamp); - dateGroup = d.toLocaleDateString() + ' ' + d.getHours() + ':00'; - } catch(e) { dateGroup = ''; } - - if (dateGroup && dateGroup !== lastDateGroup) { - html += '
' + esc(dateGroup) + '
'; - lastDateGroup = dateGroup; - } - - var side = idx % 2 === 0 ? 'tl-left' : 'tl-right'; - - html += '
'; - html += '
'; - html += '
'; - - html += '
'; - html += '
'; - html += '
'; - html += '' + icon + ''; - html += '' + esc(title) + ''; - if (isRaw) html += 'raw'; - html += '
'; - html += '
'; - html += '' + esc(shortTime(o.timestamp)) + ''; - html += '
'; - - if (display.subtitle) html += '
' + esc(display.subtitle) + '
'; - - html += '
'; - html += '' + esc(type.replace(/_/g, ' ')) + ''; - if (o.hookType) html += '' + esc(o.hookType) + ''; - html += '
'; - - if (isRaw && o.toolInput) { - var inputStr = typeof o.toolInput === 'string' ? o.toolInput : JSON.stringify(o.toolInput); - html += '
输入:'; - html += '
' + esc(truncate(inputStr, 300)) + '
'; - } - if (isRaw && o.toolOutput) { - var outputStr = typeof o.toolOutput === 'string' ? o.toolOutput : JSON.stringify(o.toolOutput); - html += '
输出:'; - html += '
' + esc(truncate(outputStr, 300)) + '
'; - } - if (display.body) html += '
' + esc(display.body) + '
'; - if (o.facts && o.facts.length > 0) { - html += '
    '; - o.facts.forEach(function(f) { html += '
  • ' + esc(f) + '
  • '; }); - html += '
'; - } - - var hasTags = (o.concepts && o.concepts.length) || (o.files && o.files.length); - if (hasTags) { - html += '
'; - (o.concepts || []).forEach(function(c) { html += '' + esc(c) + ''; }); - (o.files || []).forEach(function(f) { - var short = f.split('/').pop(); - html += '' + esc(short) + ''; - }); - html += '
'; - } - if (isRaw && o.toolInput) { - var files = []; - var ti = o.toolInput; - if (typeof ti === 'object' && ti !== null) { - if (ti.file_path) files.push(ti.file_path); - if (ti.path) files.push(ti.path); - } - if (files.length > 0) { - html += '
'; - files.forEach(function(f) { - var short = String(f).split('/').pop(); - html += '' + esc(short) + ''; - }); - html += '
'; - } - } - html += '
'; - html += '
'; - }); - - html += '
'; - - if (totalPages > 1) { - html += ''; - } - - content.innerHTML = html; - } - - function setTlTypeFilter(type) { - tlTypeFilter = type; - state.timeline.page = 0; - renderObservations(); - } - - function tlPage(p) { - state.timeline.page = p; - renderObservations(); - } - - async function loadActivity() { - var el = document.getElementById('view-activity'); - state.activity.loadingPhase = '正在读取本地会话'; - el.innerHTML = '
加载活动中...
'; - state.activity.warnings = []; - var results = await Promise.all([ - settledData('工作台会话', { sessions: [] }, function() { return apiGet('sessions'); }), - settledData('审计记录', { entries: [] }, function() { return apiGet('audit?limit=80'); }), - settledData('本地会话', [], function() { return loadLocalCodexSessions(60); }) - ]); - state.activity.warnings = results.filter(function(r) { return r.failed; }).map(function(r) { return r.label; }); - var sessions = mergeSessions(((results[0].data && results[0].data.sessions) || []), results[2].data || []).filter(function(s) { return !isDemoSession(s); }); - var auditEntries = (results[1].data && results[1].data.entries) || []; - - var allObs = []; - var sorted = sessions.slice().sort(function(a, b) { return (b.startedAt || '').localeCompare(a.startedAt || ''); }); - var localObs = sorted.filter(function(s) { - return String(s.source || '') === 'local-codex-jsonl'; - }).map(function(s) { - return { - id: sessionId(s), - sessionId: sessionId(s), - timestamp: sessionRecordTime(s), - type: 'conversation', - title: sessionTitleText(s) || 'Codex 会话', - narrative: sessionBodyPreview(s, sessionTitleText(s)), - project: s.project, - cwd: s.cwd, - agentId: 'Codex' - }; - }).filter(function(o) { - var t = String(o.title || ''); - return t && t !== 'Codex Desktop' && t.indexOf('Automation:') !== 0; - }); - allObs = allObs.concat(localObs); - - var recentSessions = sorted.filter(function(s) { - return String(s.source || '') !== 'local-codex-jsonl'; - }).slice(0, 5); - - state.activity.sessions = sessions; - state.activity.observations = allObs; - state.activity.audit = auditEntries; - state.activity.loaded = true; - state.activity.loadingPhase = recentSessions.length ? '正在补齐最近会话细节' : ''; - renderActivity(); - - var obsResults = await Promise.all(recentSessions.filter(function(s) { return sessionId(s); }).map(function(s) { - return settledData('会话细节', null, function() { return apiGet('observations?sessionId=' + encodeURIComponent(sessionId(s))); }); - })); - obsResults.forEach(function(r) { - if (r && r.failed && state.activity.warnings.indexOf(r.label) < 0) state.activity.warnings.push(r.label); - if (r && r.data && r.data.observations) allObs = allObs.concat(r.data.observations); - }); - state.activity.observations = allObs; - state.activity.loadingPhase = ''; - renderActivity(); - } - - function renderActivity() { - var el = document.getElementById('view-activity'); - var obs = state.activity.observations; - var sessions = state.activity.sessions; - - var TOOL_TYPE_MAP = { Read: 'file_read', Write: 'file_write', Edit: 'file_edit', Bash: 'command_run', Grep: 'search', Glob: 'search', WebFetch: 'web_fetch', WebSearch: 'web_fetch', AskUserQuestion: 'conversation', Task: 'subagent' }; - - var html = ''; - - html += renderDataWarnings(state.activity.warnings, 'refresh-activity'); - - if (state.activity.loadingPhase) { - html += '
' + esc(state.activity.loadingPhase) + '
' + obs.length + ' 条
'; - } - - html += '
活动热力图(近一年)
'; - var dayCounts = {}; - obs.forEach(function(o) { - try { - var d = new Date(o.timestamp); - var key = d.toISOString().slice(0, 10); - dayCounts[key] = (dayCounts[key] || 0) + 1; - } catch(e) {} - }); - sessions.forEach(function(s) { - try { - var d = new Date(s.startedAt); - var key = d.toISOString().slice(0, 10); - dayCounts[key] = (dayCounts[key] || 0) + 1; - } catch(e) {} - }); - - var maxCount = 0; - Object.keys(dayCounts).forEach(function(k) { if (dayCounts[k] > maxCount) maxCount = dayCounts[k]; }); - - var today = new Date(); - var dayLabels = ['一', '', '三', '', '五', '', '']; - html += '
'; - dayLabels.forEach(function(l) { html += '' + l + ''; }); - html += '
'; - html += '
'; - for (var w = 51; w >= 0; w--) { - for (var d = 0; d < 7; d++) { - var cellDate = new Date(today); - cellDate.setDate(cellDate.getDate() - (w * 7 + (6 - d))); - var key = cellDate.toISOString().slice(0, 10); - var count = dayCounts[key] || 0; - var level = count === 0 ? '' : count <= (maxCount * 0.25) ? 'level-1' : count <= (maxCount * 0.5) ? 'level-2' : count <= (maxCount * 0.75) ? 'level-3' : 'level-4'; - var title = key + ':' + count + ' 条活动'; - html += '
'; - } - } - html += '
'; - html += '
较少 '; - html += '
'; - html += '
'; - html += '
'; - html += '
'; - html += '
'; - html += ' 较多
'; - html += '
'; - - var typeCounts = {}; - obs.forEach(function(o) { - var t = o.type || TOOL_TYPE_MAP[o.toolName] || (o.hookType ? o.hookType.replace(/_/g, ' ') : 'other'); - typeCounts[t] = (typeCounts[t] || 0) + 1; - }); - var typeList = Object.keys(typeCounts).sort(function(a, b) { return typeCounts[b] - typeCounts[a]; }); - var totalObs = obs.length || 1; - - html += '
'; - - html += '
类型分布
'; - if (typeList.length === 0) { - html += '
还没有观察记录
'; - } else { - html += '
'; - typeList.slice(0, 12).forEach(function(t) { - var pct = Math.round((typeCounts[t] / totalObs) * 100); - var color = OBS_TYPE_COLORS[t] || '#666666'; - html += '
' + esc(observationTypeLabel(t)) + '
' + typeCounts[t] + '
'; - }); - html += '
'; - } - html += '
'; - - html += '
活动流
'; - var sortedObs = obs.slice().sort(function(a, b) { return (b.timestamp || '').localeCompare(a.timestamp || ''); }); - if (sortedObs.length === 0) { - html += '
暂无最近活动
'; - } else { - sortedObs.slice(0, 20).forEach(function(o) { - var type = o.type || TOOL_TYPE_MAP[o.toolName] || 'other'; - var typeColor = OBS_TYPE_COLORS[type] || '#666666'; - var icon = OBS_TYPE_ICONS[type] || '📄'; - var display = observationDisplay(o); - var title = display.title; - - var sess = sessions.find(function(s) { return sessionId(s) && (sessionId(s) === o.sessionId || sessionId(s) === o.session_id); }); - var source = inferSessionSource(sess || { project: o.project || '', cwd: o.cwd || '' }); - var avatar = agentAvatarSpec(source.name); - html += '
'; - html += renderAgentAvatar(avatar); - html += '
'; - html += '
' + esc(truncate(title, 60)) + '
'; - if (display.body) html += '
' + esc(truncate(display.body, 120)) + '
'; - html += '
' + esc(source.name) + ' · ' + esc(observationTypeLabel(type)); - if (o.files && o.files.length) html += ' · ' + esc(o.files[0].split('/').pop()) + ''; - html += ' · ' + esc(shortTime(o.timestamp)) + '
'; - html += '
'; - }); - } - html += '
'; - - html += '
'; - - el.innerHTML = html; - } - - async function loadSessions(options) { - options = options || {}; - var reason = options.reason || 'manual'; - var showLoading = options.showLoading !== false; - var requestSeq = (state.sessions.requestSeq || 0) + 1; - state.sessions.requestSeq = requestSeq; - var el = document.getElementById('view-sessions'); - if (showLoading || !state.sessions.loaded) el.innerHTML = '
加载会话中...
'; - var results = await Promise.all([ - settledData('工作台会话', { sessions: [] }, function() { return apiGet('sessions'); }), - settledData('本地会话', [], function() { return loadLocalCodexSessions(120); }), - settledData('浏览器对话', { items: [] }, function() { return apiGet('review?limit=200'); }) - ]); - if (state.sessions.requestSeq !== requestSeq) return; - state.sessions.warnings = results.filter(function(r) { return r.failed; }).map(function(r) { return r.label; }); - var browserSessions = browserReviewSessions((results[2].data && results[2].data.items) || []); - state.sessions.items = mergeSessions(((results[0].data && results[0].data.sessions) || []).concat(browserSessions), results[1].data || []).filter(function(s) { return !isDemoSession(s); }); - pruneSessionHighlightCache(state.sessions.items); - pruneSessionDetailCache(state.sessions.items); - if (reason === 'manual') state.sessions.stale = false; - state.sessions.loaded = true; - renderSessions(); - } - - function renderSessions() { - var el = document.getElementById('view-sessions'); - var items = state.sessions.items.slice().sort(function(a, b) { - return (sessionRecordTime(b) || '').localeCompare(sessionRecordTime(a) || ''); - }); - - var html = '
'; - html += renderDataWarnings(state.sessions.warnings, 'refresh-sessions'); - html += '
' + sessionStaleNoticeMarkup() + '
'; - if (items.length === 0) { - html += '
🗒

' + (state.sessions.warnings && state.sessions.warnings.length ? t('ses.emptyNeedRetry') : t('ses.empty')) + '

'; - if (state.sessions.warnings && state.sessions.warnings.length) html += '
'; - html += '
'; - } else { - var groupMode = state.sessions.groupMode === 'source' ? 'source' : 'folder'; - state.sessions.groupMode = groupMode; - var projectGroups = groupSessionsByProject(items); - var sourceGroups = sessionSourceGroups(items); - var folderGroups = [{ - key: 'all', - name: t('ses.allSessions'), - sessions: items, - count: items.length, - latest: projectGroups[0] ? projectGroups[0].latest : '', - observations: items.reduce(function(sum, s) { return sum + (s.observationCount || 0); }, 0), - sources: {}, - hasMissingId: false - }].concat(projectGroups); - var groups = groupMode === 'source' ? sourceGroups.map(function(g) { - return { - key: g.key, - name: g.name, - count: g.count, - latest: g.latest, - observations: g.observations, - sessions: g.key === 'all' ? items : items.filter(function(s) { return sessionSourceKey(s) === g.key; }) - }; - }) : folderGroups; - var activeGroupKey = groupMode === 'source' ? (state.sessions.sourceKey || 'all') : (state.sessions.folderKey || 'all'); - if (!groups.some(function(g) { return g.key === activeGroupKey; })) activeGroupKey = 'all'; - if (groupMode === 'source') state.sessions.sourceKey = activeGroupKey; - else state.sessions.folderKey = activeGroupKey; - var activeGroup = groups.find(function(g) { return g.key === activeGroupKey; }) || groups[0]; - var visibleItems = activeGroup.sessions || []; - var totalObs = items.reduce(function(sum, s) { return sum + (s.observationCount || 0); }, 0); - html += '
'; - html += '
' + t('ses.heroTitle') + '
' + t('ses.heroNote') + '
'; - html += '
' + items.length + ' ' + t('ses.unitSessions') + ' · ' + projectGroups.length + ' ' + t('ses.unitFolders') + ' · ' + (sourceGroups.length - 1) + ' ' + t('ses.unitSources') + ' · ' + totalObs + ' ' + t('ses.unitRecords') + '
'; - html += '
'; - html += '
'; - - html += '
'; - html += '
'; - html += ''; - html += ''; - html += ''; - html += ''; - html += ''; - html += '
'; - html += '
'; - html += ''; - html += '
'; - html += '
' + esc(activeGroup ? activeGroup.name : t('ses.allSessions')) + '
' + visibleItems.length + ' ' + t('ses.unitSessions') + ' · ' + ((activeGroup && activeGroup.observations) || 0) + ' ' + t('ses.unitRecords') + '
'; - if (!visibleItems.length) { - html += '
' + (groupMode === 'source' ? t('ses.emptySource') : t('ses.emptyFolder')) + '
'; - } else { - visibleItems.forEach(function(s) { - var id = sessionId(s); - var title = sessionTitleText(s); - var preview = sessionBodyPreview(s, title) || t('ses.noPreview'); - var source = inferSessionSource(s); - var avatarLabel = (source.name || t('ses.avatarFallback')).replace(/\s+/g, '').slice(0, 2); - var selected = id && state.sessions.selectedId === id; - html += ''; - }); - } - html += '
'; - html += '
'; - } - html += '
'; - el.innerHTML = html; - - if (state.sessions.selectedId) renderSessionDetail(); - } - - function selectSession(id) { - state.sessions.selectedId = id; - renderSessions(); - setTimeout(function() { - var panel = document.getElementById('session-detail'); - if (panel) panel.scrollIntoView({ behavior: 'smooth', block: 'start' }); - }, 80); - } - - var SESSION_HIGHLIGHT_LABELS = { - goal: '用户', - agent_output: 'Agent', - tool: '工具', - mcp: 'MCP', - command: '命令', - failure: '失败', - validation: '验证', - file: '文件', - artifact: '产物', - skill: 'Skill', - todo: '待办', - follow_up: '后续' - }; - - function sessionHighlightCacheKey(s) { - if (!s) return ''; - return [ - sessionId(s), - s.status || '', - s.observationCount || 0, - s.updatedAt || '', - s.endedAt || '', - s.startedAt || '' - ].join('|'); - } - - function pruneSessionHighlightCache(items) { - var keep = {}; - (items || []).forEach(function(s) { - var id = sessionId(s); - if (id) keep[id] = sessionHighlightCacheKey(s); - }); - Object.keys(state.sessions.highlightsById || {}).forEach(function(id) { - if (!keep[id] || state.sessions.highlightsById[id].cacheKey !== keep[id]) { - delete state.sessions.highlightsById[id]; - } - }); - } - - function pruneSessionDetailCache(items) { - var keep = {}; - (items || []).forEach(function(s) { - var id = sessionId(s); - if (id) keep[id] = sessionHighlightCacheKey(s); - }); - Object.keys(state.sessions.detailCacheById || {}).forEach(function(id) { - if (!keep[id] || state.sessions.detailCacheById[id].cacheKey !== keep[id]) { - delete state.sessions.detailCacheById[id]; - } - }); - } - - function sessionDetailSections(id) { - if (!state.sessions.detailSectionsById) state.sessions.detailSectionsById = {}; - if (!state.sessions.detailSectionsById[id]) { - state.sessions.detailSectionsById[id] = { highlights: true, process: false }; - } - return state.sessions.detailSectionsById[id]; - } - - function renderSessionSectionTitle(sessionIdValue, section, title, expanded) { - return '
' + - '' + esc(title) + '' + - '' + - '
'; - } - - function toggleSessionDetailSection(id, section) { - if (!id || (section !== 'highlights' && section !== 'process')) return; - var sections = sessionDetailSections(id); - sections[section] = !sections[section]; - renderSessionDetail({ useCacheOnly: true }); - } - - async function getSessionHighlights(id, sessionRecord) { - if (!id) return { ok: false, highlights: [] }; - var cacheKey = sessionHighlightCacheKey(sessionRecord); - var cached = state.sessions.highlightsById[id]; - if (cached && cached.cacheKey === cacheKey) return cached; - var result = await apiGet('session/highlights?sessionId=' + encodeURIComponent(id) + '&maxItems=12'); - var view = result && result.success === true && Array.isArray(result.highlights) - ? { ok: true, highlights: result.highlights, cacheKey: cacheKey } - : { ok: false, highlights: [], cacheKey: cacheKey }; - state.sessions.highlightsById[id] = view; - return view; - } - - function sessionDialogueHighlights(view) { - return (Array.isArray(view && view.highlights) ? view.highlights : []).filter(function(item) { - return item && (item.category === 'goal' || item.category === 'agent_output'); - }); - } - - function renderSessionHighlights(sessionIdValue, view, expanded) { - var html = '
'; - html += renderSessionSectionTitle(sessionIdValue, 'highlights', '会话重点', expanded); - if (!expanded) return html + '
'; - if (!view || view.ok === false) { - html += '
会话重点暂不可用
'; - return html; - } - var highlights = sessionDialogueHighlights(view); - if (highlights.length === 0) { - html += '
暂无会话重点
'; - return html; - } - var compactHighlights = highlights.length > 4; - html += '
'; - highlights.forEach(function(item) { - var label = SESSION_HIGHLIGHT_LABELS[item.category] || item.category || '重点'; - html += '
'; - html += '
' + esc(label) + '
'; - html += '
'; - html += '
' + esc(item.title || label) + '
'; - if (item.summary) html += '
' + renderMarkdownSafe(item.summary) + '
'; - html += '
'; - }); - html += '
'; - if (compactHighlights) { - html += '
仅展示前几条重点;需要完整内容时使用完整对话过程。
'; - } - html += ''; - return html; - } - - function isSessionPreviewExpanded(id) { - return !!(state.sessions.previewExpandedById && state.sessions.previewExpandedById[id]); - } - - function toggleSessionPreview(id) { - if (!id) return; - if (!state.sessions.previewExpandedById) state.sessions.previewExpandedById = {}; - state.sessions.previewExpandedById[id] = !state.sessions.previewExpandedById[id]; - renderSessionDetail({ useCacheOnly: true }); - } - - async function renderSessionDetail(options) { - options = options || {}; - var panel = document.getElementById('session-detail'); - if (!panel) return; - var s = state.sessions.items.find(function(x) { return sessionId(x) === state.sessions.selectedId; }); - var id = sessionId(s); - if (!s || !id) { panel.innerHTML = ''; return; } - - var cacheKey = sessionHighlightCacheKey(s); - var cachedDetail = state.sessions.detailCacheById && state.sessions.detailCacheById[id]; - var hasFreshCache = cachedDetail && cachedDetail.cacheKey === cacheKey; - var obs; - var highlightsView; - if (hasFreshCache) { - obs = cachedDetail.observations || []; - highlightsView = cachedDetail.highlightsView || { ok: false, highlights: [], cacheKey: cacheKey }; - } else if (options.useCacheOnly) { - return; - } else { - var detailRequestSeq = (state.sessions.detailRequestSeq || 0) + 1; - state.sessions.detailRequestSeq = detailRequestSeq; - if (!panel.innerHTML) panel.innerHTML = '

加载会话详情中…

'; - obs = []; - if (!Array.isArray(s.embeddedObservations) || String(s.id || '').indexOf('browser_sync_') >= 0 || !s.id) { - obs = Array.isArray(s.embeddedObservations) ? s.embeddedObservations : []; - } - if (!obs.length && id) { - var obsRes = await apiGet('observations?sessionId=' + encodeURIComponent(id)); - obs = (obsRes && obsRes.observations) || []; - } - if ((!obs || obs.length === 0) && Array.isArray(s.embeddedObservations)) { - obs = s.embeddedObservations; - } - if ((!obs || obs.length === 0) && String(s.source || '').indexOf('local-') === 0) { - var localRes = await apiGet('local-agent-session-events?sessionId=' + encodeURIComponent(id)); - obs = (localRes && localRes.observations) || obs || []; - } - highlightsView = await getSessionHighlights(id, s); - if (state.sessions.detailRequestSeq !== detailRequestSeq || state.sessions.selectedId !== id) return; - state.sessions.detailCacheById[id] = { - cacheKey: cacheKey, - observations: obs || [], - highlightsView: highlightsView, - }; - } - - var typeCounts = {}; - var toolCounts = {}; - var fileSet = new Set(); - var firstPromptFromObs = ''; - obs.forEach(function(o) { - var t = o.type || o.hookType || 'other'; - typeCounts[t] = (typeCounts[t] || 0) + 1; - var tool = o.title || o.toolName; - if (tool && t !== 'conversation') toolCounts[tool] = (toolCounts[tool] || 0) + 1; - (o.files || []).forEach(function(f) { fileSet.add(f); }); - if (!firstPromptFromObs && (o.userPrompt || (o.type === 'conversation' && o.narrative))) { - firstPromptFromObs = o.userPrompt || o.narrative || ''; - } - }); - - var durationMs = s.endedAt ? new Date(s.endedAt).getTime() - new Date(s.startedAt).getTime() : 0; - var durationLabel = durationMs > 0 ? (durationMs < 60000 ? (durationMs / 1000).toFixed(1) + 's' : (durationMs / 60000).toFixed(1) + 'm') : '-'; - - var detailTitle = sessionTitleText(s); - var preview = sessionBodyPreview({ summary: s.summary, latestPrompt: firstPromptFromObs, firstPrompt: s.firstPrompt }, detailTitle) || firstPromptFromObs || s.summary || s.firstPrompt || ''; - var detailSections = sessionDetailSections(id); - - var html = '
'; - html += '
'; - html += '

会话时间线

' + esc(projectDisplayName(sessionProjectKey(s))) + ' · ' + esc(shortDateTime(sessionRecordTime(s))) + '
'; - html += statusIconMarkup(s.status, sessionStatusLabel(s.status)); - html += '
'; - - if (preview) { - var previewExpanded = isSessionPreviewExpanded(id); - var previewIsLong = String(preview).length > 260; - html += '
' + esc(preview) + '
'; - if (previewIsLong) { - html += ''; - } - } - - html += '
'; - html += '
记录
' + obs.length + '
'; - html += '
操作类型
' + Object.keys(typeCounts).length + '
'; - html += '
文件
' + fileSet.size + '
'; - html += '
时长
' + esc(durationLabel) + '
'; - html += '
'; - - html += renderSessionHighlights(id, highlightsView, detailSections.highlights !== false); - - var timelineObs = obs.slice().sort(function(a, b) { return (a.timestamp || '').localeCompare(b.timestamp || ''); }); - var processExpanded = detailSections.process === true; - var isLongTimeline = timelineObs.length > 80; - html += '
'; - html += renderSessionSectionTitle(id, 'process', '完整对话过程 · ' + timelineObs.length + ' 条', processExpanded); - if (processExpanded && isLongTimeline) { - html += '
记录很多,可以先跳到底部看最近进展。
'; - } - if (processExpanded && timelineObs.length === 0) { - html += '
这段会话还没有可展示的过程记录。
'; - } else if (processExpanded) { - html += '
'; - timelineObs.forEach(function(o) { - var display = observationDisplay(o); - var type = o.type || o.hookType || 'other'; - html += ''; - html += '
' + esc(shortTime(o.timestamp)) + '
'; - html += '
' + esc(display.title || observationTypeLabel(type)) + '
'; - if (display.body) html += '
' + renderMarkdownSafe(display.body) + '
'; - html += '
'; - }); - html += '
'; - if (isLongTimeline) html += '
'; - } - html += '
'; - - var topTools = Object.keys(toolCounts).sort(function(a, b) { return toolCounts[b] - toolCounts[a]; }).slice(0, 10); - if (topTools.length > 0) { - var maxC = toolCounts[topTools[0]] || 1; - html += '
工具使用
'; - html += '
'; - topTools.forEach(function(t) { - var pct = Math.round((toolCounts[t] / maxC) * 100); - html += '
' + esc(t) + '
' + toolCounts[t] + '
'; - }); - html += '
'; - } - - var typeKeys = Object.keys(typeCounts).sort(function(a, b) { return typeCounts[b] - typeCounts[a]; }); - if (typeKeys.length > 0) { - html += '
记录类型
'; - html += '
'; - typeKeys.forEach(function(t) { - html += '' + esc(typeDisplayLabel(t)) + ' · ' + typeCounts[t] + ''; - }); - html += '
'; - } - - if (fileSet.size > 0) { - var filesArr = Array.from(fileSet).slice(0, 30); - html += '
文件
'; - html += '
'; - filesArr.forEach(function(f) { html += '
• ' + esc(f) + '
'; }); - if (fileSet.size > 30) html += '
+' + (fileSet.size - 30) + ' more
'; - html += '
'; - } - - var detailId = sessionId(s); - html += '
记录来源
'; - html += '
'; - sessionSourceSummary(s, obs.length).forEach(function(item) { - html += '
' + esc(item.label) + '
' + esc(item.value || '-') + '
'; - }); - html += '
'; - html += '
查看技术细节'; - html += '
'; - html += '
id: ' + esc(detailId || '无记录 ID') + '
'; - html += '
path: ' + esc(s.cwd || '-') + '
'; - html += '
started: ' + esc(formatTime(s.startedAt)) + '
'; - if (s.endedAt) html += '
ended: ' + esc(formatTime(s.endedAt)) + '
'; - if (s.model) html += '
model: ' + esc(s.model) + '
'; - if (s.tags && s.tags.length) html += '
tags: ' + s.tags.map(esc).join(', ') + '
'; - html += '
'; - - html += '
'; - if (detailId) { - html += ''; - } else { - html += ''; - } - html += '
'; - panel.innerHTML = html; - applyPendingHighlight(); - } - - async function endSession(id) { - await apiPost('session/end', { sessionId: id }); - state.sessions.loaded = false; - loadSessions({ showLoading: true, reason: 'manual' }); - } - - async function summarizeSession(id, btn) { - if (!btn) return; - btn.textContent = '生成中...'; - btn.disabled = true; - try { - var result = await apiPost('summarize', { sessionId: id }); - state.lessons.loaded = false; - if (result && result.success) { - btn.textContent = result.fallback ? '已生成本地摘要' : '已生成摘要'; - btn.title = result.fallback ? '没有可用模型时,已用本地规则从会话记录生成摘要。' : ''; - } else { - btn.textContent = summarizeErrorLabel(result && (result.reason || result.error)); - btn.title = summarizeErrorHint(result && (result.reason || result.error)); - } - } catch (err) { - btn.textContent = '摘要服务未响应'; - btn.title = '本地工作台没有连上摘要接口,请确认 AI Todo worker 正在运行。'; - } - setTimeout(function() { btn.textContent = '生成摘要'; btn.disabled = false; }, 2600); - } - - function summarizeErrorLabel(error) { - if (error === 'no_observations') return '暂无可摘要内容'; - if (error === 'session_not_found') return '会话不存在'; - if (error === 'no_provider') return '缺少模型配置'; - return '生成失败'; - } - - function summarizeErrorHint(error) { - if (error === 'no_observations') return '这段会话还没有可整理的记录。'; - if (error === 'session_not_found') return '本地数据里没有找到这段会话。'; - if (error === 'no_provider') return '未配置模型时会使用本地规则摘要;如果仍失败,请检查本地 worker 日志。'; - return String(error || '摘要生成失败,请稍后重试。'); - } - - function skillDraftSlug(text) { - var base = String(text || 'skill').toLowerCase().replace(/[^a-z0-9\u4e00-\u9fa5]+/g, '-').replace(/^-+|-+$/g, ''); - return (base || 'memory-skill').slice(0, 48); - } - function skillDraftTitle(groupKey) { - var name = projectDisplayName(groupKey || '通用经验'); - return name === '通用经验' ? '可复用工作流 Skill' : name + ' 工作流 Skill'; - } - function buildSkillDraft(groupKey, lessons) { - var list = (lessons || []).slice().sort(function(a, b) { return (b.confidence || 0) - (a.confidence || 0); }).slice(0, 8); - var title = skillDraftTitle(groupKey); - var slug = skillDraftSlug(groupKey || title); - var tags = []; - list.forEach(function(l) { (l.tags || []).forEach(function(t) { if (tags.indexOf(t) < 0) tags.push(t); }); }); - var lines = []; - lines.push('# ' + title); - lines.push(''); - lines.push('## When to use'); - lines.push('Use this skill when the user is working on ' + projectDisplayName(groupKey || 'a reusable workflow') + ' and needs the agent to apply the repeated lessons below.'); - lines.push(''); - lines.push('## Core lessons'); - if (!list.length) lines.push('- Add confirmed lessons here before installing this skill.'); - list.forEach(function(l) { lines.push('- ' + translateLessonText(l.content)); }); - lines.push(''); - lines.push('## Operating guidance'); - lines.push('- Preserve the user-visible product intent before changing implementation details.'); - lines.push('- Prefer reviewable drafts over automatic writes when the output changes durable memory or local skills.'); - lines.push('- Keep examples, docs, and product UI aligned after every meaningful change.'); - if (tags.length) { - lines.push(''); - lines.push('## Tags'); - lines.push(tags.slice(0, 10).map(function(t) { return '`' + t + '`'; }).join(' ')); - } - lines.push(''); - lines.push('## Suggested install path'); - lines.push('`~/.codex/skills/' + slug + '/SKILL.md`'); - return lines.join('\n'); - } - function openSkillDraft(groupKey) { - var lessons = (state.lessons.items || []).filter(function(l) { - var key = l.project || (l.tags && l.tags[0]) || '通用经验'; - return key === groupKey; - }); - var draft = buildSkillDraft(groupKey, lessons); - var modal = document.getElementById('modal'); - var overlay = document.getElementById('modal-overlay'); - modal.innerHTML = - '

Skill 草稿

' + - '

这是从已确认经验生成的草稿,不会自动写入本地 Skill。确认适合长期复用后,再复制到对应 Skill 目录。

' + - '
' + esc(draft) + '
' + - ''; - overlay.classList.add('open'); - } - - function openAddLesson() { - var modal = document.getElementById('modal'); - var overlay = document.getElementById('modal-overlay'); - var projectOptions = ''; - (state.lessons.projects || []).forEach(function(projectKey) { - projectOptions += ''; - }); - modal.innerHTML = - '

创建经验

' + - '

写下一条以后可以复用、也可能沉淀进本地 Skill 的做法。

' + - '
' + - '' + - '' + - '
' + - '' + - '' + - '
' + - '
' + - '
' + - ''; - overlay.classList.add('open'); - setTimeout(function() { - var input = document.getElementById('add-lesson-content'); - if (input) input.focus(); - }, 0); - } - - async function saveNewLesson() { - var err = document.getElementById('add-lesson-error'); - var content = (document.getElementById('add-lesson-content') || {}).value || ''; - var context = (document.getElementById('add-lesson-context') || {}).value || ''; - var tags = (document.getElementById('add-lesson-tags') || {}).value || ''; - var project = (document.getElementById('add-lesson-project') || {}).value || ''; - if (!content.trim()) { - if (err) { - err.style.display = 'block'; - err.textContent = '先写一条经验再保存。'; - } - return; - } - var result = await apiPost('lessons', { - content: content.trim(), - context: context.trim(), - tags: tags.split(',').map(function(t) { return t.trim(); }).filter(Boolean), - project: project.trim() || undefined, - confidence: 0.85 - }); - if (!result || result.error) { - if (err) { - err.style.display = 'block'; - err.textContent = (result && result.error) || '保存失败,请稍后再试。'; - } - return; - } - closeModal(); - state.lessons.loaded = false; - await loadLessons(); - } - - async function openSkillDetail(path) { - var modal = document.getElementById('modal'); - var overlay = document.getElementById('modal-overlay'); - modal.innerHTML = '

Skill 详情

正在读取本地 SKILL.md...

'; - overlay.classList.add('open'); - var detail = await apiGet('local-skill-detail?path=' + encodeURIComponent(path || '')); - if (!detail || detail.error) { - modal.innerHTML = '

Skill 详情

' + esc((detail && detail.error) || '读取失败') + '

'; - return; - } - var html = '

' + esc(detail.name || 'Skill') + '

'; - if (detail.description) html += '

' + esc(detail.description) + '

'; - html += '
'; - html += '
'; - html += '
来源文件
' + esc(detail.path || '') + '
'; - html += '
最近修改
' + esc(formatTime(detail.updatedAt)) + '
'; - if (detail.argumentHint) html += '
参数提示
' + esc(detail.argumentHint) + '
'; - html += '
'; - if (detail.headings && detail.headings.length) { - html += '
结构
'; - detail.headings.forEach(function(h) { html += '' + esc(h) + ''; }); - html += '
'; - } - html += '
'; - html += '
内容预览
'; - html += '
' + esc(detail.preview || '暂无内容') + '
'; - html += '
'; - html += ''; - modal.innerHTML = html; - } - - async function loadLessons() { - var el = document.getElementById('view-lessons'); - el.innerHTML = '
正在加载经验...
'; - var results = await Promise.all([apiGet('lessons?minConfidence=0.5'), apiGet('sessions')]); - var explicitLessons = (results[0] && results[0].lessons) || []; - var sessions = (results[1] && results[1].sessions) || []; - var projects = {}; - sessions.forEach(function(s) { - var key = sessionProjectKey(s); - if (key) projects[key] = true; - }); - state.lessons.items = explicitLessons; - state.lessons.projects = Object.keys(projects).sort(function(a, b) { return projectDisplayName(a).localeCompare(projectDisplayName(b)); }); - state.lessons.mode = explicitLessons.length ? 'explicit' : 'empty'; - state.lessons.loaded = true; - renderLessons(); - } - - function renderLessons() { - var el = document.getElementById('view-lessons'); - var items = state.lessons.items; - var search = state.lessons.search.toLowerCase(); - - if (search) { - items = items.filter(function(l) { - return (l.content + ' ' + l.context + ' ' + (l.tags || []).join(' ')).toLowerCase().indexOf(search) >= 0; - }); - } - - var groups = {}; - items.forEach(function(l) { - var tags = (l.tags || []).filter(Boolean); - var key = tags[0] || l.project || '通用工作方式'; - if (!groups[key]) groups[key] = []; - groups[key].push(l); - }); - var groupKeys = Object.keys(groups).sort(function(a, b) { return groups[b].length - groups[a].length; }); - - var skillQuery = String(state.lessons.skillSearch || '').trim().toLowerCase(); - var skillRootFilter = state.lessons.skillRootFilter || 'all'; - function skillMatches(skill) { - var rootOk = skillRootFilter === 'all' || String(skill.root || '') === skillRootFilter; - if (!rootOk) return false; - if (!skillQuery) return true; - return String(skill.name || '').toLowerCase().indexOf(skillQuery) >= 0 || String(skill.path || '').toLowerCase().indexOf(skillQuery) >= 0 || String(skill.root || '').toLowerCase().indexOf(skillQuery) >= 0; - } - function skillRootMeta(root) { - var known = { - Codex: { title: 'Codex 环境', sub: '这个 agent 会加载' }, - Agents: { title: '共享目录', sub: '多个 agent 可复用' }, - Plugin: { title: '插件提供', sub: '随插件一起安装' } - }; - return known[root] || { title: root || '其他来源', sub: '自定义 agent / skill 目录' }; - } - var rootOrder = { Codex: 1, Agents: 2, Plugin: 99 }; - var allSkillRoots = Array.from(new Set((LOCAL_SKILLS || []).map(function(skill) { return skill.root || '其他'; }))).sort(function(a, b) { - return (rootOrder[a] || 50) - (rootOrder[b] || 50) || a.localeCompare(b); - }); - var visibleSkills = (LOCAL_SKILLS || []).filter(skillMatches); - var skillGroups = {}; - visibleSkills.forEach(function(skill) { - if (!skillGroups[skill.root]) skillGroups[skill.root] = []; - skillGroups[skill.root].push(skill); - }); - var skillRoots = Object.keys(skillGroups).sort(function(a, b) { - return (rootOrder[a] || 50) - (rootOrder[b] || 50) || a.localeCompare(b); - }); - - var html = '
'; - html += '
'; - html += '
本地 Skill 管理台
'; - html += '
先看已经安装的能力,再决定哪些经验值得沉淀进去。
'; - html += '
' + (LOCAL_SKILLS || []).length + ' 个 Skill
'; - html += '
'; - html += '
'; - - html += '
'; - html += '
' + (LOCAL_SKILLS || []).length + '
已安装 Skill
'; - html += '
' + allSkillRoots.length + '
来源目录
'; - html += '
' + items.length + '
待沉淀经验
'; - html += '
'; - - html += '
'; - html += ''; - allSkillRoots.forEach(function(root) { - var meta = skillRootMeta(root); - html += ''; - }); - html += '
'; - - html += '
'; - html += ''; - html += '
'; - - html += '
'; - if (skillRoots.length === 0) { - html += '
没有匹配的 Skill
'; - } - skillRoots.forEach(function(root) { - var list = (skillGroups[root] || []).slice(); - var meta = skillRootMeta(root); - html += '
'; - html += '
' + esc(meta.title) + '
' + esc(meta.sub) + '
' + list.length + '
'; - html += '
'; - list.forEach(function(skill) { - html += '
'; - html += '
' + esc(skill.name) + '
' + esc(skill.path) + '
'; - html += '
'; - html += '
'; - }); - html += '
'; - }); - html += '
'; - - html += '
可沉淀经验
这些是从工作里提炼出的规则,可以继续合并进某个本地 Skill。
' + items.length + ' 条经验
'; - - html += '
'; - html += ''; - html += '
'; - - if (items.length === 0) { - html += '
' + - '
💡
' + - '
还没有可沉淀的经验
' + - '
当某条做法被确认有复用价值,它会先进入这里,再决定是否写进某个本地 Skill。
' + - '
'; - } else { - html += '
'; - groupKeys.forEach(function(key) { - var group = groups[key].slice().sort(function(a, b) { return (b.confidence || 0) - (a.confidence || 0); }); - html += '
'; - html += '
' + esc(key) + '
' + group.length + ' 条
'; - html += '
    '; - group.slice(0, 4).forEach(function(l) { - var lessonContent = translateLessonText(l.content); - html += '
  • ' + esc(truncate(lessonContent, 120)) + '
  • '; - }); - if (group.length > 4) html += '
  • 还有 ' + (group.length - 4) + ' 条相近经验
  • '; - html += '
'; - html += '
'; - html += '
'; - }); - html += '
'; - } - html += '
'; - - var __focus = captureSearchFocus(['skills-search', 'lessons-search']); - el.innerHTML = html; - var __ss = document.getElementById('skills-search'); - if (__ss) bindImeSafeSearch(__ss, 200, function(v){ state.lessons.skillSearch = v; renderLessons(); }); - var __ls = document.getElementById('lessons-search'); - if (__ls) bindImeSafeSearch(__ls, 200, function(v){ state.lessons.search = v; renderLessons(); }); - restoreSearchFocus(__focus); - } - - async function loadActions(opts) { - opts = opts || {}; - var el = document.getElementById('view-actions'); - el.innerHTML = '
' + esc(t('act.extract.loading')) + '
'; - var results = await Promise.all([ - apiGet('actions'), - apiGet('frontier'), - apiGet('review?status=pending&kind=action&limit=200'), - apiGet('inbox?status=awaiting&limit=50'), - apiGet('inbox?status=answered&limit=50'), - apiGet('inbox?status=dismissed&limit=50') - ]); - var explicitActions = (results[0] && results[0].actions) || []; - var frontier = (results[1] && (results[1].frontier || results[1].actions)) || []; - var reviewItems = ((results[2] && results[2].items) || []).filter(function(item) { - return item && item.status === 'pending' && item.kind === 'action' && isActionReviewRenderable(item); - }); - - state.actions.items = explicitActions; - state.actions.reviewItems = reviewItems; - state.actions.frontier = frontier; - state.actions.loaded = true; - state.actions.stale = false; - state.inbox.awaitingItems = (results[3] && results[3].items) || []; - state.inbox.answeredItems = (results[4] && results[4].items) || []; - state.inbox.dismissedItems = (results[5] && results[5].items) || []; - state.inbox.items = state.inbox.awaitingItems; - state.inbox.loaded = true; - renderActions(); - if (state.settings.open) { - loadTodoExtractorConfig().then(renderSettingsPanel).catch(function() {}); - } - if (opts.generate === true) startTodoExtraction(opts.force === true); - } - - function todoExtractionDelta(result) { - if (!result || result.success !== true) return 0; - return Number(result.directCreated || 0) + Number(result.reviewCreated || 0); - } - - function todoExtractionSummary(result) { - if (!result || result.success !== true) return t('act.extract.failedExisting'); - var delta = todoExtractionDelta(result); - var hidden = Number(result.hiddenHistory || 0); - var discarded = Number(result.discarded || 0); - var cleaned = Number(result.cleanedActions || 0) + Number(result.cleanedReviews || 0); - var engine = result.engine || 'rules'; - var prefix = engine === 'langextract' ? t('act.extract.doneLlm') : (engine === 'mixed' ? t('act.extract.doneMixed') : t('act.extract.doneRules')); - var parts = [prefix, t('act.extract.created') + ' ' + delta, t('act.extract.history') + ' ' + hidden, t('act.extract.discarded') + ' ' + discarded]; - if (cleaned > 0) parts.push(t('act.extract.cleaned') + ' ' + cleaned); - if (result.fallbackReason) parts.push(t('act.extract.reason') + ': ' + result.fallbackReason); - return parts.join(' · '); - } - - function todoExtractionUsedLlm(result) { - return !!result && (result.engine === 'langextract' || result.engine === 'mixed') && !result.llmFallback; - } - - function refreshActionListsAfterExtract() { - return Promise.all([ - apiGet('actions'), - apiGet('frontier'), - apiGet('review?status=pending&kind=action&limit=200') - ]).then(function(results) { - state.actions.items = (results[0] && results[0].actions) || state.actions.items || []; - state.actions.frontier = (results[1] && (results[1].frontier || results[1].actions)) || state.actions.frontier || []; - state.actions.reviewItems = ((results[2] && results[2].items) || []).filter(function(item) { - return item && item.status === 'pending' && item.kind === 'action' && isActionReviewRenderable(item); - }); - return null; - }); - } - - function actionsScrolledAway() { - try { - var y = window.pageYOffset || - (document.documentElement && document.documentElement.scrollTop) || - (document.body && document.body.scrollTop) || - 0; - return state.activeTab === 'actions' && y > 80; - } catch (_) { - return false; - } - } - - function loadTodoExtractorConfig() { - return apiGet('config/todo-extractor').then(function(res) { - if (res && res.config) state.actions.config = res; - return res; - }); - } - - function renderSettingsPanel() { - var panel = document.getElementById('settings-panel'); - var gear = document.getElementById('settings-gear'); - if (!panel) return; - if (gear) gear.setAttribute('aria-expanded', state.settings.open ? 'true' : 'false'); - panel.setAttribute('aria-hidden', state.settings.open ? 'false' : 'true'); - panel.classList.toggle('open', !!state.settings.open); - if (!state.settings.open) { - panel.innerHTML = ''; - return; - } - var cfg = (state.actions.config && state.actions.config.config) || {}; - var draft = state.actions.configDraft || {}; - var value = function(key, fallback) { - if (Object.prototype.hasOwnProperty.call(draft, key)) return esc(draft[key] || ''); - return esc(cfg[key] || fallback || ''); - }; - var keyLabel = cfg.LANGEXTRACT_API_KEY_CONFIGURED ? t('settings.apiKeyKeep') : t('settings.apiKeyMissing'); - var maskedKey = cfg.LANGEXTRACT_API_KEY_MASKED || ''; - var html = '
'; - html += '
' + esc(t('settings.title')) + '
' + esc(t('settings.subtitle')) + '
'; - html += ''; - html += '
'; - html += '
' + esc(t('settings.language')) + '
'; - html += '
'; - html += ''; - html += ''; - html += '
'; - html += '
' + esc(t('settings.extractor')) + '
'; - html += '
'; - html += ''; - html += ''; - html += ''; - html += ''; - html += ''; - html += ''; - html += ''; - html += '
' + esc(t('settings.sinceDays')) + '
'; - html += '
' + esc(t('settings.maxInteractions')) + '
'; - html += '
'; - if (maskedKey) html += '
' + esc(t('settings.apiKeyLabel')) + ' ' + esc(maskedKey) + '
'; - html += '
'; - html += '
'; - html += ''; - if (state.actions.config && state.actions.config.envPath) html += '' + esc(state.actions.config.envPath) + ''; - html += '
'; - if (state.actions.extractMessage) html += '
' + esc(state.actions.extractMessage) + '
'; - html += '
'; - panel.innerHTML = html; - } - - function captureTodoConfigDraft() { - var draft = state.actions.configDraft || {}; - [ - 'AGENTMEMORY_TODO_EXTRACTOR', - 'LANGEXTRACT_PYTHON', - 'LANGEXTRACT_PROVIDER', - 'LANGEXTRACT_MODEL', - 'LANGEXTRACT_BASE_URL', - 'LANGEXTRACT_THINKING_DEPTH', - 'AGENTMEMORY_TODO_EXTRACT_TIMEOUT_MS', - 'AGENTMEMORY_TODO_EXTRACT_SINCE_DAYS', - 'AGENTMEMORY_TODO_EXTRACT_MAX_INTERACTIONS_PER_SESSION', - 'LANGEXTRACT_API_KEY' - ].forEach(function(key) { - var el = document.getElementById('todo-config-' + key); - if (el) draft[key] = String(el.value || ''); - }); - state.actions.configDraft = draft; - return draft; - } - - function saveTodoExtractorConfig() { - var fields = [ - 'AGENTMEMORY_TODO_EXTRACTOR', - 'LANGEXTRACT_PYTHON', - 'LANGEXTRACT_PROVIDER', - 'LANGEXTRACT_MODEL', - 'LANGEXTRACT_BASE_URL', - 'LANGEXTRACT_THINKING_DEPTH', - 'AGENTMEMORY_TODO_EXTRACT_TIMEOUT_MS', - 'AGENTMEMORY_TODO_EXTRACT_SINCE_DAYS', - 'AGENTMEMORY_TODO_EXTRACT_MAX_INTERACTIONS_PER_SESSION', - 'LANGEXTRACT_API_KEY' - ]; - var body = {}; - var draft = captureTodoConfigDraft(); - fields.forEach(function(key) { - if (String(draft[key] || '').trim()) body[key] = String(draft[key] || '').trim(); - }); - state.actions.configSaving = true; - renderSettingsPanel(); - apiPost('config/todo-extractor', body).then(function(res) { - if (res && res.config) state.actions.config = res; - state.actions.configDraft = {}; - state.actions.extractMessage = t('settings.savedRestart'); - }).catch(function() { - state.actions.extractStatus = 'error'; - state.actions.extractMessage = t('settings.saveFailed'); - }).then(function() { - state.actions.configSaving = false; - renderSettingsPanel(); - if (state.activeTab === 'actions') renderActions(); - }); - } - - function updateActionStatus(actionId, status) { - if (!actionId || !status) return; - apiPost('actions/update', { actionId: actionId, status: status }).then(function(res) { - if (!res || res.success === false) { - state.actions.extractStatus = 'error'; - state.actions.extractMessage = t('act.status.updateFailed'); - renderActions(); - return; - } - state.actions.items = (state.actions.items || []).map(function(a) { - return a.id === actionId ? Object.assign({}, a, { status: status, updatedAt: new Date().toISOString() }) : a; - }); - renderActions(); - }).catch(function() { - state.actions.extractStatus = 'error'; - state.actions.extractMessage = t('act.status.updateFailed'); - renderActions(); - }); - } - - function startTodoExtraction(force) { - if (state.actions.extractInFlight) return; - state.actions.extractInFlight = true; - state.actions.extractStatus = 'running'; - state.actions.extractMessage = t('act.extract.starting'); - if (state.activeTab === 'actions') renderActions(); - var softRefreshDone = false; - var softRefreshTimer = setTimeout(function() { - if (!state.actions.extractInFlight || softRefreshDone) return; - softRefreshDone = true; - state.actions.extractMessage = t('act.extract.background'); - if (actionsScrolledAway()) { - state.actions.stale = true; - return; - } - if (state.activeTab === 'actions') renderActions(); - refreshActionListsAfterExtract().then(function() { - if (!state.actions.extractInFlight) return; - if (actionsScrolledAway()) { - state.actions.stale = true; - return; - } - if (state.activeTab === 'actions') renderActions(); - }).catch(function() {}); - }, 12000); - // STEP-11: scope is governed by saved settings (sinceDays + max - // interactions per session) which the backend reads from env config; - // don't hard-code maxSessions/maxObservationsPerSession here or the - // settings would never take effect on this primary extraction path. - apiPost('todo-extract/generate', { - force: force === true - }).then(function(result) { - var delta = todoExtractionDelta(result); - if (!result || result.success !== true) { - state.actions.extractStatus = 'error'; - state.actions.extractMessage = t('act.extract.failedExisting'); - return null; - } - state.actions.extractStatus = 'done'; - state.actions.extractFallback = !todoExtractionUsedLlm(result); - state.actions.extractMessage = todoExtractionSummary(result); - if (actionsScrolledAway()) { - state.actions.stale = delta > 0; - return null; - } - return refreshActionListsAfterExtract(); - }).catch(function() { - state.actions.extractStatus = 'error'; - state.actions.extractMessage = t('act.extract.failedExisting'); - }).then(function() { - clearTimeout(softRefreshTimer); - state.actions.extractInFlight = false; - if (state.activeTab === 'actions' && !actionsScrolledAway()) { - renderActions(); - } else if (state.activeTab !== 'actions') { - state.actions.loaded = false; - } - }); - } - - function todoCleanupSummary(r) { - if (!r) return ''; - return t('act.cleanup.summary') - .replace('{dropped}', r.dropped || 0) - .replace('{completed}', r.completed || 0) - .replace('{rewritten}', r.rewritten || 0) - .replace('{merged}', r.merged || 0); - } - - // LLM card update (STEP-12): re-judge cards whose source session changed. - // Always dry-run first so the user confirms before anything is dropped/closed; - // apply only on confirm (or silently when there is nothing to confirm — see - // the all-KEEP branch below). - function startTodoUpdate() { - if (state.actions.cleanupInFlight || state.actions.extractInFlight) return; - state.actions.cleanupInFlight = true; - state.actions.cleanupStatus = 'running'; - state.actions.cleanupMessage = t('act.cleanup.running'); - if (state.activeTab === 'actions') renderActions(); - apiPost('todo/update', { mode: 'dry-run' }).then(function(preview) { - state.actions.cleanupInFlight = false; - if (!preview || typeof preview.scanned !== 'number') { - state.actions.cleanupStatus = 'error'; - state.actions.cleanupMessage = t('act.cleanup.failed'); - if (state.activeTab === 'actions') renderActions(); - return; - } - var touched = (preview.dropped || 0) + (preview.completed || 0) + (preview.rewritten || 0) + (preview.merged || 0); - if (touched === 0) { - if (preview.fallbackReason) { - // LLM unavailable — nothing ran, nothing to advance. - state.actions.cleanupStatus = 'done'; - state.actions.cleanupMessage = t('act.cleanup.llmUnavailable'); - if (state.activeTab === 'actions') renderActions(); - return; - } - if ((preview.scanned || 0) > 0) { - // Changed cards existed but the LLM kept them all. Silently apply so - // their checkpoints advance and they aren't re-evaluated (and re-sent - // to the LLM) on every future click. No card content changes, so no - // confirmation is needed. - state.actions.cleanupInFlight = true; - apiPost('todo/update', { mode: 'apply', decisions: preview.decisions }).then(function() { - return refreshActionListsAfterExtract(); - }).catch(function() {}).then(function() { - state.actions.cleanupInFlight = false; - state.actions.cleanupStatus = 'done'; - state.actions.cleanupMessage = t('act.cleanup.clean'); - if (state.activeTab === 'actions') renderActions(); - }); - return; - } - // Nothing changed at all. - state.actions.cleanupStatus = 'done'; - state.actions.cleanupMessage = t('act.cleanup.clean'); - if (state.activeTab === 'actions') renderActions(); - return; - } - if (!window.confirm(t('act.cleanup.confirm') + '\n\n' + todoCleanupSummary(preview))) { - state.actions.cleanupStatus = 'idle'; - state.actions.cleanupMessage = ''; - if (state.activeTab === 'actions') renderActions(); - return; - } - state.actions.cleanupInFlight = true; - state.actions.cleanupStatus = 'running'; - state.actions.cleanupMessage = t('act.cleanup.applying'); - if (state.activeTab === 'actions') renderActions(); - apiPost('todo/update', { mode: 'apply', decisions: preview.decisions }).then(function(result) { - state.actions.cleanupStatus = 'done'; - state.actions.cleanupMessage = todoCleanupSummary(result || preview); - return refreshActionListsAfterExtract(); - }).catch(function() { - state.actions.cleanupStatus = 'error'; - state.actions.cleanupMessage = t('act.cleanup.failed'); - }).then(function() { - state.actions.cleanupInFlight = false; - if (state.activeTab === 'actions') renderActions(); - }); - }).catch(function() { - state.actions.cleanupInFlight = false; - state.actions.cleanupStatus = 'error'; - state.actions.cleanupMessage = t('act.cleanup.failed'); - if (state.activeTab === 'actions') renderActions(); - }); - } - - // 待办→证据跳转:待办卡指向裸 obs_id,证据栏按 session 组织, - // 后端无 obs_id→session 反查端点。遍历已加载会话,缓存优先、未命中才请求,命中即停。 - async function resolveObsSession(obsId) { - if (!obsId) return null; - var sessions = state.sessions.items || []; - for (var i = 0; i < sessions.length; i++) { - var s = sessions[i]; - var id = sessionId(s); - if (!id) continue; - var obs = null; - var cached = state.sessions.detailCacheById && state.sessions.detailCacheById[id]; - if (cached && Array.isArray(cached.observations)) { - obs = cached.observations; - } else if (Array.isArray(s.embeddedObservations) && s.embeddedObservations.length) { - obs = s.embeddedObservations; - } - if (obs && obs.some(function(o) { return o && o.id === obsId; })) return id; - if (obs) continue; - try { - var res = await apiGet('observations?sessionId=' + encodeURIComponent(id)); - var list = (res && res.observations) || []; - if (list.some(function(o) { return o && o.id === obsId; })) return id; - } catch (e) { /* 单个会话取失败不阻断扫描 */ } - } - return null; - } - - async function jumpToEvidence(obsId) { - if (!obsId) return; - if (!state.sessions.loaded) await loadSessions({ showLoading: false, reason: 'jump' }); - var targetSessionId = await resolveObsSession(obsId); - if (!targetSessionId) { - flashHint('未找到这条待办的来源会话(可能尚未同步或已被过滤)'); - return; - } - state.sessions.selectedId = targetSessionId; - state.sessions.pendingHighlightObsId = obsId; - // 来源观测画在「完整对话过程」区,默认折叠 → 跳转前强制展开,否则锚点不存在 - if (!state.sessions.detailSectionsById) state.sessions.detailSectionsById = {}; - var sec = state.sessions.detailSectionsById[targetSessionId] || { highlights: true, process: false }; - sec.process = true; - state.sessions.detailSectionsById[targetSessionId] = sec; - switchTab('sessions'); - renderSessions(); - await renderSessionDetail(); - } - - function flashHint(message) { - var el = document.getElementById('jump-hint'); - if (!el) { - el = document.createElement('div'); - el.id = 'jump-hint'; - el.setAttribute('role', 'status'); - el.setAttribute('aria-live', 'polite'); - el.style.cssText = 'position:fixed;left:50%;bottom:28px;transform:translateX(-50%);background:var(--ink,#1a1a1a);color:#fff;padding:10px 16px;border-radius:8px;font-size:13px;z-index:9999;box-shadow:0 4px 16px rgba(0,0,0,0.2);max-width:80vw;'; - (document.body || document.documentElement).appendChild(el); - } - el.textContent = message; - el.style.opacity = '1'; - if (flashHint._t) clearTimeout(flashHint._t); - flashHint._t = setTimeout(function() { if (el) el.style.opacity = '0'; }, 2600); - } - - function applyPendingHighlight() { - var obsId = state.sessions.pendingHighlightObsId; - if (!obsId) return; - state.sessions.pendingHighlightObsId = null; - var anchor = document.getElementById('obs-anchor-' + obsId); - if (!anchor) return; - if (typeof anchor.scrollIntoView === 'function') anchor.scrollIntoView({ behavior: 'smooth', block: 'center' }); - anchor.classList.add('obs-jump-highlight'); - setTimeout(function() { anchor.classList.remove('obs-jump-highlight'); }, 2400); - } - - // STEP-C2/C3 待回应分区:接真实 inbox 数据(GET /agentmemory/inbox?status=awaiting)。 - // 按 kind 分两子区——question(🔴 Agent 在等你回)与 briefing(📋 Agent 主动整理), - // 均按 createdAt 倒序(本轮不按 priority)。C3 加动作:question 可「回应」(行内输入 - // → inbox-answer)/「转待处理」(inbox-dismiss + action-create)/「知道了」(空 answer); - // briefing 可「知道了」/「转待处理」。「看原文 →」复用 STEP-03。 - function inboxFromLabel(item) { - var from = item && item.fromAgent ? String(item.fromAgent) : ''; - return from ? '来自 ' + esc(from) : ''; - } - function inboxEvidenceButton(item) { - var ids = (item && item.sourceObservationIds) || []; - if (!ids.length) return ''; - return ''; - } - // STEP-D4 飞书投递状态徽标(只读,低干扰)。数据来自 GET /inbox 里 join 的 delivery - // 台账(mem:delivery)。sent→已推送✓;failed→推送失败⚠+短错误摘要;skipped(投递 - // 未开启/无配置)默认不显示,避免污染 UI。 - function inboxDeliveryBadge(item) { - var d = item && item.delivery; - if (!d || !d.status) return ''; - if (d.status === 'sent') { - var u = d.urgent ? ' · 加急' : ''; - return '已推送 ✓' + esc(u) + ''; - } - if (d.status === 'failed') { - var err = d.error ? String(d.error) : ''; - if (err.length > 60) err = err.slice(0, 60) + '…'; - var titleErr = d.error ? ' title="' + esc(String(d.error)) + '"' : ''; - return '推送失败 ⚠' + - (err ? ' ' + esc(err) + '' : '') + ''; - } - return ''; // skipped → silent - } - function inboxAwaiting() { - var s = state.inbox || {}; - if (Array.isArray(s.awaitingItems) && s.awaitingItems.length) return s.awaitingItems; - return Array.isArray(s.items) ? s.items : []; - } - function inboxAnswered() { - var s = state.inbox || {}; - return Array.isArray(s.answeredItems) ? s.answeredItems : []; - } - function inboxDismissed() { - var s = state.inbox || {}; - return Array.isArray(s.dismissedItems) ? s.dismissedItems : []; - } - function sortInboxItems(items) { - return (items || []).slice().sort(function(a, b) { - return new Date((b && (b.answeredAt || b.dismissedAt || b.updatedAt || b.createdAt)) || 0).getTime() - - new Date((a && (a.answeredAt || a.dismissedAt || a.updatedAt || a.createdAt)) || 0).getTime(); - }); - } - function inboxSearchText(item) { - return [ - item && item.body, - item && item.answer, - item && item.fromAgent, - item && item.status, - item && item.kind, - ].filter(Boolean).join(' ').toLowerCase(); - } - function filterInboxItems(items, search) { - if (!search) return items || []; - return (items || []).filter(function(item) { return inboxSearchText(item).indexOf(search) >= 0; }); - } - function inboxRecent(item) { - var ts = item && (item.answeredAt || item.dismissedAt || item.updatedAt || item.createdAt); - if (!ts) return false; - var t = new Date(ts).getTime(); - return Number.isFinite(t) && Date.now() - t <= 24 * 60 * 60 * 1000; - } - function renderInboxCard(item, kind) { - var marker = kind === 'question' ? '🔴' : '📋'; - var from = inboxFromLabel(item); - var id = (item && item.id) || ''; - var html = '
'; - html += '
'; - html += ''; - if (from) html += '' + from + ''; - if (item && item.createdAt) html += '' + esc(shortTime(item.createdAt)) + ''; - html += inboxDeliveryBadge(item); - html += '
'; - html += '
' + renderMarkdownSafe((item && item.body) || '') + '
'; - - var pending = isInboxPending(id); - var dis = pending ? ' disabled aria-busy="true"' : ''; - html += '
'; - if (kind === 'question') { - html += ''; - html += ''; - html += ''; - } else { - html += ''; - html += ''; - } - html += inboxEvidenceButton(item); - html += '
'; - - if (kind === 'question' && state.inbox.replyingId === id) { - var sd = pending ? ' disabled aria-busy="true"' : ''; - html += '
'; - html += ''; - html += '
'; - html += ''; - html += ''; - html += '
'; - } - html += '
'; - return html; - } - function renderInboxArchiveCard(item) { - var kind = item && item.kind === 'question' ? 'question' : 'briefing'; - var answered = item && item.status === 'answered'; - var marker = kind === 'question' ? '✅' : '✓'; - var cls = answered && kind === 'question' ? 'inbox-card-answered' : 'inbox-card-known'; - var from = inboxFromLabel(item); - var ts = (item && (item.answeredAt || item.dismissedAt || item.updatedAt || item.createdAt)) || ''; - var html = '
'; - html += '
'; - html += ''; - if (from) html += '' + from + ''; - if (ts) html += '' + esc(shortTime(ts)) + ''; - html += '
'; - html += '
' + renderMarkdownSafe((item && item.body) || '') + '
'; - if (answered && kind === 'question') { - html += '
你已回复:' + esc((item && item.answer) || '知道了') + '
'; - } - html += '
'; - return html; - } - function renderAwaitingQuestionSection() { - var search = (state.actions.search || '').toLowerCase(); - var items = filterInboxItems(sortInboxItems(inboxAwaiting()), search); - var questions = items.filter(function(i) { return i && i.kind === 'question'; }); - if (!questions.length) return ''; - var html = '
'; - html += '
'; - html += '
待回应 (' + questions.length + ')
'; - html += '
Agent 运行中抛给你的、时间敏感的问题会汇集到这里。
'; - html += '
Agent 在等你回
'; - html += '
'; - questions.forEach(function(it) { html += renderInboxCard(it, 'question'); }); - html += '
'; - return html; - } - function renderAwaitingBriefingSection() { - var search = (state.actions.search || '').toLowerCase(); - var items = filterInboxItems(sortInboxItems(inboxAwaiting()), search); - var briefings = items.filter(function(i) { return i && i.kind === 'briefing'; }); - if (!briefings.length) return ''; - var bExpanded = !!state.inbox.briefingExpanded || !!search; - var html = '
'; - html += ''; - if (bExpanded) { - html += '
'; - briefings.forEach(function(it) { html += renderInboxCard(it, 'briefing'); }); - html += '
'; - } - html += '
'; - return html; - } - function renderInboxArchiveSection() { - var search = (state.actions.search || '').toLowerCase(); - var answered = inboxAnswered(); - var dismissed = inboxDismissed(); - var all = sortInboxItems(answered.concat(dismissed)); - var visible = filterInboxItems(all, search); - if (!visible.length) return ''; - var answeredQuestions = visible.filter(function(i) { return i && i.kind === 'question' && i.status === 'answered'; }); - var known = visible.filter(function(i) { return !(i && i.kind === 'question' && i.status === 'answered'); }); - var recentCount = visible.filter(inboxRecent).length; - var expanded = !!state.inbox.answeredExpanded || !!search; - var title = answeredQuestions.length ? '已回应 ' + answeredQuestions.length + ' 条' : '已回应'; - if (recentCount && !expanded) title += ' · 最近 ' + recentCount + ' 条'; - var html = '
'; - html += ''; - if (expanded) { - if (answeredQuestions.length) { - html += '
'; - answeredQuestions.forEach(function(it) { html += renderInboxArchiveCard(it); }); - html += '
'; - } - if (known.length) { - html += '
已知悉 (' + known.length + ')briefing / 已消解
'; - html += '
'; - known.forEach(function(it) { html += renderInboxArchiveCard(it); }); - html += '
'; - } - } - html += '
'; - return html; - } - function renderAwaitingReplySection() { - var search = (state.actions.search || '').toLowerCase(); - var items = filterInboxItems(sortInboxItems(inboxAwaiting()), search); - var questions = items.filter(function(i) { return i && i.kind === 'question'; }); - var briefings = items.filter(function(i) { return i && i.kind === 'briefing'; }); - - // 搜索时无命中:整区不渲染,避免空壳占位干扰搜索结果。 - if (search && !items.length) return ''; - - var html = '
'; - html += '
'; - html += '
待回应'; - if (questions.length) html += ' (' + questions.length + ')'; - html += '
'; - html += '
Agent 运行中抛给你的、时间敏感的问题会汇集到这里。
'; - html += '
'; - if (questions.length) html += 'Agent 在等你回'; - html += '
'; - - if (!items.length) { - html += '
'; - html += '
暂无待回应
'; - html += '
Agent 在会话中抛给你、在等你回的问题会出现在这里。目前没有待回应的条目。
'; - html += '
'; - html += '
'; - return html; - } - - if (questions.length) { - html += '
'; - questions.forEach(function(it) { html += renderInboxCard(it, 'question'); }); - html += '
'; - } - if (briefings.length) { - // briefing 知悉即可、优先级低,默认折叠以缩短首屏、让 question 不被压下去。 - // 搜索命中时强制展开(否则命中的 briefing 藏在折叠里看不到)。 - var bExpanded = !!state.inbox.briefingExpanded || !!search; - html += ''; - if (bExpanded) { - html += '
'; - briefings.forEach(function(it) { html += renderInboxCard(it, 'briefing'); }); - html += '
'; - } - } - html += ''; - return html; - } - - // STEP-C3 收件箱动作。每个动作改后端状态后,从 state.inbox.items 本地剔除该项 + - // 重渲染(乐观更新),避免全量 loadActions 抖动;失败用 flashHint 提示。 - function removeInboxItemLocal(id) { - state.inbox.items = (state.inbox.items || []).filter(function(i) { return i && i.id !== id; }); - state.inbox.awaitingItems = (state.inbox.awaitingItems || []).filter(function(i) { return i && i.id !== id; }); - if (state.inbox.replyingId === id) state.inbox.replyingId = null; - } - function findInboxItem(id) { - return inboxAwaiting().filter(function(i) { return i && i.id === id; })[0] || null; - } - function archiveInboxItemLocal(id, status, answer) { - var item = findInboxItem(id); - removeInboxItemLocal(id); - if (!item) return; - var archived = Object.assign({}, item, { - status: status, - answer: answer || item.answer || '', - updatedAt: new Date().toISOString(), - }); - if (status === 'answered') { - archived.answeredAt = archived.updatedAt; - state.inbox.answeredItems = [archived].concat(state.inbox.answeredItems || []); - } else { - archived.dismissedAt = archived.updatedAt; - state.inbox.dismissedItems = [archived].concat(state.inbox.dismissedItems || []); - } - } - // 防重入:动作进行中标记 pendingById[id],按钮渲染为 disabled, - // 慢网下连点不会重复提交(尤其转待处理的 create 会重复建待办)。 - function isInboxPending(id) { - return !!(state.inbox.pendingById && state.inbox.pendingById[id]); - } - function setInboxPending(id, on) { - if (!state.inbox.pendingById) state.inbox.pendingById = {}; - if (on) state.inbox.pendingById[id] = true; - else delete state.inbox.pendingById[id]; - } - function openInboxReply(id) { - state.inbox.replyingId = (state.inbox.replyingId === id) ? null : id; - renderActions(); - if (state.inbox.replyingId === id) { - var input = document.getElementById('inbox-reply-input-' + id); - if (input && typeof input.focus === 'function') input.focus(); - } - } - function cancelInboxReply() { - state.inbox.replyingId = null; - renderActions(); - } - async function submitInboxReply(id) { - if (isInboxPending(id)) return; - var input = document.getElementById('inbox-reply-input-' + id); - var answer = input ? String(input.value || '').trim() : ''; - if (!answer) { flashHint('回应内容不能为空'); if (input && input.focus) input.focus(); return; } - setInboxPending(id, true); - renderActions(); - try { - var res = await apiPost('inbox/answer', { id: id, answer: answer }); - if (!res || res.success !== true) { flashHint('回应未送达,请确认本地 worker 在运行'); return; } - archiveInboxItemLocal(id, 'answered', answer); - flashHint('已回应,Agent 会看到'); - } finally { - setInboxPending(id, false); - renderActions(); - } - } - async function ackInboxItem(id) { - if (isInboxPending(id)) return; - setInboxPending(id, true); - renderActions(); - try { - // 空 answer = 已读/ack(briefing 或无需回的 question)。 - var res = await apiPost('inbox/answer', { id: id }); - if (!res || res.success !== true) { flashHint('操作未送达,请确认本地 worker 在运行'); return; } - archiveInboxItemLocal(id, 'answered', ''); - flashHint('已标记知道了'); - } finally { - setInboxPending(id, false); - renderActions(); - } - } - async function convertInboxToTodo(id) { - if (isInboxPending(id)) return; - // 转待处理:先建 action(标题取 body),成功后再 dismiss inbox 项; - // 串行 + 失败回滚提示,避免「dismiss 了却没建 action」的丢条目。 - var item = findInboxItem(id); - if (!item) return; - setInboxPending(id, true); - renderActions(); - try { - var title = String(item.body || '').trim().slice(0, 120) || '(来自收件箱)'; - var created = await apiPost('actions', { title: title, createdBy: 'inbox', project: item.project }); - if (!created || created.success !== true) { flashHint('转待处理失败:未能创建待办'); return; } - var dismissed = await apiPost('inbox/dismiss', { id: id }); - if (!dismissed || dismissed.success !== true) { - flashHint('已建待办,但收件箱项未消解,请手动「知道了」'); - state.actions.loaded = false; - loadActions({ generate: false }); - return; - } - archiveInboxItemLocal(id, 'dismissed', ''); - state.actions.loaded = false; - loadActions({ generate: false }); - flashHint('已转入待处理'); - } finally { - setInboxPending(id, false); - renderActions(); - } - } - - // STEP-C4「已完成」折叠区:只读现有 action.status==='done' 且当天 updatedAt 的项, - // 默认折叠(§3.2)。不新增抽取器、不动后端,纯前端筛 state.actions.items。 - function isUpdatedToday(ts) { - if (!ts) return false; - var d = new Date(ts); - if (isNaN(d.getTime())) return false; - var now = new Date(); - return d.getFullYear() === now.getFullYear() && - d.getMonth() === now.getMonth() && - d.getDate() === now.getDate(); - } - function renderDoneTodaySection(doneItems, frontierIds, cardRenderer) { - var today = (doneItems || []).filter(function(a) { return isUpdatedToday(a.updatedAt); }); - if (!today.length) return ''; - var expanded = !!state.actions.doneExpanded; - var html = '
'; - html += ''; - if (expanded && typeof cardRenderer === 'function') { - html += '
'; - today.forEach(function(a) { - html += cardRenderer(a, frontierIds.has(a.id)); - }); - html += '
'; - } - html += '
'; - return html; - } - - function renderActions() { - var el = document.getElementById('view-actions'); - var items = (state.actions.items || []).filter(isActionRenderable).slice(); - var reviewItems = (state.actions.reviewItems || []).filter(isActionReviewRenderable); - var search = state.actions.search.toLowerCase(); - var statusFilter = state.actions.statusFilter === 'all' ? '' : state.actions.statusFilter; - var frontierIds = new Set((state.actions.frontier || []).map(function(a) { return a.id; })); - - if (search) { - items = items.filter(function(a) { - return (a.title + ' ' + (a.description || '') + ' ' + (a.tags || []).join(' ') + ' ' + (a.project || '')).toLowerCase().indexOf(search) >= 0; - }); - reviewItems = reviewItems.filter(function(item) { - return ((item.title || '') + ' ' + (item.content || '') + ' ' + (reviewProject(item) || '') + ' ' + reviewTags(item).join(' ')).toLowerCase().indexOf(search) >= 0; - }); - } - var metricItems = items.slice(); - var metricReviewItems = reviewItems.slice(); - if (statusFilter && statusFilter !== 'review' && statusFilter !== 'awaiting') { - // STEP-13: the "待跟进 / Follow-up" metric card counts pending + blocked - // and filters with data-status="pending", so the pending filter must show - // blocked too — otherwise its count and its results disagree. - items = items.filter(function(a) { - return statusFilter === 'pending' ? (a.status === 'pending' || a.status === 'blocked') : a.status === statusFilter; - }); - } - if (statusFilter === 'review' || statusFilter === 'awaiting') { - items = []; - } - var showReviewItems = statusFilter === 'review'; - var showAwaitingItems = !statusFilter || statusFilter === 'awaiting'; - var showBriefingItems = !statusFilter; - - function actionAttentionKey(a, isFrontier) { - if (a.status === 'done' || a.status === 'cancelled') return ''; - if (isFrontier) return 'next'; - if (a.status === 'blocked') return 'needsWork'; - if (a.priority === 'high' || Number(a.priority) >= 8) return 'noteworthy'; - return ''; - } - function actionDescriptionText(text) { - var s = String(text || '').trim(); - if (!s) return ''; - if (I18N_LANG !== 'zh') return s; - var map = [ - [/^Execute launch promotion for GitHub, Xiaohongshu, V2EX, Reddit, X, and other target communities\..*$/i, '继续推进 GitHub、小红书、V2EX、Reddit 和 X 等渠道的发布推广。'], - [/^Execute launch promotion for (.+)$/i, '继续推进 $1 的发布推广。'], - [/^Create a Notion-style continuous-motion 30s README demo[::]?\s*(.+)$/i, '制作 30 秒 README 演示,展示从输入到结果预览的完整路径。'], - [/^Use huashu-design video workflow to produce a short README\/social demo showing how the study abroad applications skill works[::]?\s*(.+)$/i, '制作一段适合 README 和社媒使用的短演示,说明留学申请 Skill 的使用过程。'], - [/^Use huashu-design video workflow to produce a short README\/social demo showing how the study abroad applications skill works\.?$/i, '制作一段适合 README 和社媒使用的短演示,说明留学申请 Skill 的使用过程。'], - [/^Use huashu-design video workflow to produce a short README\/social demo showing how (.+?) works[::]?\s*(.+)$/i, '制作一段适合 README 和社媒使用的短演示,说明 $1 的使用过程。'], - [/^Use huashu-design video workflow to produce a short README\/social demo showing how (.+?) works\.?$/i, '制作一段适合 README 和社媒使用的短演示,说明 $1 的使用过程。'], - [/^来源:(.+)$/i, '来自 $1'], - [/^来自本地审计日志$/i, '来自本地操作记录'] - ]; - for (var i = 0; i < map.length; i++) { - if (map[i][0].test(s)) return s.replace(map[i][0], map[i][1]); - } - return s; - } - function actionSourceText(a) { - var parts = []; - if (a.project) parts.push(projectDisplayName(a.project)); - if (a.source) parts.push(a.source); - return parts.join(' · '); - } - function actionVisibleTags(tags) { - var all = Array.isArray(tags) ? tags.filter(Boolean).map(String) : []; - var visible = []; - function add(tag) { - if (tag && visible.indexOf(tag) < 0) visible.push(tag); - } - all.filter(function(tag) { - return tag === 'todo-extracted' || tag === 'todo-recheck' || /^time:[a-z_]+$/i.test(tag) || /^type:[a-z_]+$/i.test(tag); - }).forEach(add); - all.forEach(function(tag) { - if (visible.length < 5) add(tag); - }); - return visible.slice(0, 5); - } - function actionTitleText(text) { - var s = String(text || t('act.untitled')).trim(); - if (I18N_LANG !== 'zh') return s; - var map = [ - [/^Create README 30s demo GIF and backup MP4 for taught-master-applications-skill$/i, '制作留学申请 Skill 的 README 演示视频'], - [/^Create README 30s demo GIF and backup MP4 for (.+)$/i, '制作 $1 的 README 演示视频'], - [/^Create 30-second demo GIF\/video for taught-master-applications-skill$/i, '制作留学申请 Skill 的 30 秒演示视频'], - [/^Promote\s+(.+?)\s+across target platforms$/i, '推广 $1'], - [/^Build\s+(.+)$/i, '制作 $1'], - [/^Create\s+(.+)$/i, '创建 $1'], - [/^Execute\s+(.+)$/i, '执行 $1'], - [/^Review\s+(.+)$/i, '复盘 $1'], - [/^Check\s+(.+)$/i, '检查 $1'] - ]; - for (var i = 0; i < map.length; i++) { - if (map[i][0].test(s)) return s.replace(map[i][0], map[i][1]); - } - return s; - } - function priorityLabel(value) { - if (value === 'high' || Number(value) >= 8) return t('act.prio.high'); - if (value === 'normal' || Number(value) >= 5) return t('act.prio.normal'); - return t('act.prio.low'); - } - function priorityClass(value) { - if (value === 'high' || Number(value) >= 8) return 'high'; - if (value === 'normal' || Number(value) >= 5) return 'normal'; - return 'low'; - } - function compactActionTitle(text) { - var s = String(text || t('act.untitled')).replace(/^(TODO|FIXME)\s*[::-]\s*/i, '').trim(); - var headingMatch = s.match(/#{1,6}\s*([^#\n]+?)(?=\s+#{1,6}\s|$)/); - if (headingMatch && !/^(Summary|Key Changes|Test Plan|Assumptions|Public API|Implementation)\b/i.test(headingMatch[1].trim())) { - s = headingMatch[1].trim(); - } - s = s - .replace(/#{1,6}\s*(Summary|Key Changes|Test Plan|Assumptions|Public API|Implementation)\b[\s\S]*$/i, '') - .replace(/#{1,6}\s*/g, '') - .replace(/\b(Summary|Key Changes|Test Plan|Assumptions|Public API|Implementation)\b[\s\S]*$/i, '') - .replace(/\s+/g, ' ') - .trim(); - var first = s.split(/[。!?\n]/u)[0] || s; - return truncate(actionTitleText(first.replace(/[。!?;;,.,]+$/u, '').trim() || t('act.untitled')), 72); - } - function stripMarkdownPlanText(text) { - return String(text || '') - .replace(/\s+(#{1,6}\s+)/g, '\n$1') - .split(/\n+/) - .filter(function(line) { - var trimmed = line.trim(); - if (!trimmed) return false; - if (/^#{1,6}\s/.test(trimmed)) return false; - if (/^\s*[-*]\s+/.test(line)) return false; - if (/^(Summary|Key Changes|Test Plan|Assumptions|Public API|Implementation)\b/i.test(trimmed)) return false; - if (/^(执行步骤|验证命令|停止条件|修改范围)[::]?/u.test(trimmed)) return false; - return true; - }) - .join(' ') - .replace(/\s+/g, ' ') - .trim(); - } - function candidatePreviewText(item) { - var title = compactActionTitle(item && item.title); - var content = String((item && item.content) || '').trim(); - if (!content) return ''; - var isStructured = isMarkdownPlanText(content); - var cleaned = isStructured ? stripMarkdownPlanText(content) : content; - if (isStructured) { - var sentence = (cleaned.match(/(?:TODO|FIXME|下一步|后续|待办|修复|补充|实现|调整|验证|提交|创建|更新|移除|处理)[^。!?\n]{4,80}[。!?]?/u) || [])[0]; - cleaned = sentence || ''; - } - cleaned = cleaned - .replace(/^(TODO|FIXME)\s*[::-]\s*/i, '') - .replace(/^(下一步|后续|待办)\s*[::-]?\s*/u, '') - .replace(/^(请|需要|必须)\s*/u, '') - .replace(/\s+/g, ' ') - .trim(); - if (!cleaned || cleaned === title || cleaned.indexOf(title) === 0) return ''; - return truncate(actionDescriptionText(cleaned), 96); - } - function actionMetricCards(counts, reviewCount, waitingCount) { - var metrics = [ - { label: t('act.metric.waiting'), value: waitingCount, filter: 'awaiting', primary: waitingCount > 0 }, - { label: t('act.metric.review'), value: reviewCount, filter: 'review', primary: waitingCount === 0 && reviewCount > 0 }, - { label: t('act.metric.followUp'), value: counts.pending + counts.blocked, filter: 'pending', primary: waitingCount === 0 && reviewCount === 0 && (counts.pending + counts.blocked) > 0 }, - { label: t('act.metric.active'), value: counts.active, filter: 'active', primary: false }, - { label: t('act.metric.done'), value: counts.done, filter: 'done', primary: false } - ]; - var html = '
'; - metrics.forEach(function(m) { - var active = (state.actions.statusFilter || '') === m.filter; - html += ''; - }); - html += '
'; - return html; - } - function renderActionFilters() { - // STEP-13: the status-filter chip row duplicated the metric overview - // cards below (actionMetricCards) — same state.actions.statusFilter, same - // values (pending/review/active/done). Keep the metric cards (they carry - // counts) as the single status filter; this row keeps only search + the - // action buttons. - var html = '
'; - html += ''; - html += '' + metricReviewItems.length + ' ' + t('act.nToConfirm') + ' · ' + metricItems.length + ' ' + t('act.nConfirmed') + ''; - var extractTitle = state.actions.extractMessage || t('act.extract.title'); - var extractLabel = t('act.extract.run'); - if (state.actions.extractInFlight) extractLabel = t('act.extract.running'); - else if (state.actions.extractStatus === 'done') extractLabel = state.actions.extractFallback ? t('act.extract.rules') : t('act.extract.done'); - else if (state.actions.extractStatus === 'error') extractLabel = t('act.extract.error'); - html += ''; - var cleanupLabel = t('act.cleanup.run'); - if (state.actions.cleanupInFlight) cleanupLabel = t('act.cleanup.running'); - else if (state.actions.cleanupStatus === 'done') cleanupLabel = t('act.cleanup.done'); - else if (state.actions.cleanupStatus === 'error') cleanupLabel = t('act.cleanup.error'); - html += ''; - html += ''; - html += '
'; - return html; - } - function renderActionCandidateCard(item) { - var priority = reviewActionPriority(item); - var project = reviewProject(item); - var title = compactActionTitle(item.title || t('act.untitledCandidate')); - var preview = candidatePreviewText(item); - var raw = String(item.content || '').trim(); - var showOriginal = raw && isMarkdownPlanText(raw); - var html = '
'; - html += '
'; - html += '
'; - html += '
' + t('filter.review') + '' + esc(priorityLabel(priority)) + '
'; - html += '
' + esc(title) + '
'; - if (preview) html += '
' + esc(preview) + '
'; - html += '
'; - if (project) html += '' + esc(projectDisplayName(project)) + ''; - html += '
'; - if (showOriginal) { - html += '
' + t('act.viewOriginal') + '
' + esc(truncate(raw, 1200)) + '
'; - } - html += '
'; - html += '
'; - html += '
'; - return html; - } - function renderActionCard(a, isFrontier) { - var html = '
'; - html += '
'; - html += '
'; - html += '
' + esc(compactActionTitle(a.title)) + '
'; - var actionDesc = actionDescriptionText(a.description); - if (actionDesc) html += '
' + esc(truncate(actionDesc, 120)) + '
'; - // STEP-16 calm card: source + relative time are hidden at rest and fade in - // on hover; priority shows on the rail, status via the group. No badges / - // status icon / classification tags. - var metaParts = []; - var sourceText = actionSourceText(a); - if (sourceText) metaParts.push(t('act.from') + ' ' + esc(sourceText)); - if (a.updatedAt) metaParts.push(esc(relativeTime(a.updatedAt))); - if (metaParts.length) html += '
' + metaParts.join(' · ') + '
'; - html += '
'; - html += '
'; - html += '
'; - var jumpObsId = Array.isArray(a.sourceObservationIds) ? a.sourceObservationIds.find(function(id) { return typeof id === 'string' && id.length > 0; }) : ''; - if (jumpObsId) html += ''; - if (a.status !== 'cancelled') html += ''; - html += '
'; - if (a.status !== 'done') html += ''; - html += '
'; - html += '
'; - return html; - } - var statusCounts = { active: 0, blocked: 0, pending: 0, done: 0, cancelled: 0 }; - metricItems.forEach(function(a) { if (statusCounts[a.status] !== undefined) statusCounts[a.status] += 1; }); - var searchForInbox = (state.actions.search || '').toLowerCase(); - var waitingCount = filterInboxItems(sortInboxItems(inboxAwaiting()), searchForInbox).filter(function(i) { return i && i.kind === 'question'; }).length; - var awaitingQuestionsHtml = showAwaitingItems ? renderAwaitingQuestionSection() : ''; - var awaitingBriefingsHtml = showBriefingItems ? renderAwaitingBriefingSection() : ''; - var inboxArchiveHtml = showBriefingItems ? renderInboxArchiveSection() : ''; - - var html = renderActionFilters(); - html += actionMetricCards(statusCounts, metricReviewItems.length, waitingCount); - html += awaitingQuestionsHtml; - - if (showReviewItems && reviewItems.length) { - html += '
'; - html += '
' + t('filter.review') + '
' + reviewItems.length + ' ' + t('act.itemsUnit') + '
'; - html += '
'; - reviewItems.forEach(function(item) { - html += renderActionCandidateCard(item); - }); - html += '
'; - } - - html += awaitingBriefingsHtml; - - if (items.length === 0 && (!showReviewItems || reviewItems.length === 0) && !awaitingQuestionsHtml && !awaitingBriefingsHtml && !inboxArchiveHtml) { - html += '
' + - '
☑
' + - '
' + t('act.empty.title') + '
' + - '
' + t('act.empty.lead') + '
' + - '
'; - } else { - items = items.slice().sort(function(a, b) { - var order = { active: 1, blocked: 2, pending: 3, done: 4, cancelled: 5 }; - return (order[a.status] || 9) - (order[b.status] || 9) || (Number(b.priority) || 0) - (Number(a.priority) || 0); - }); - // STEP-C4:无筛选的默认视图里,done 不混在分组流中,改由底部「已完成」 - // 折叠区单独承载、且只显当天完成的(§3.2/§3.3)。点了「已完成」筛选 chip - // (statusFilter==='done')时则照常全列,不走折叠区。 - // STEP-12:cancelled(归档/被更新丢弃/被合并)也不进默认活动视图——否则 - // 合并/丢弃后卡片仍以「已取消」分组留在原处,看着像「没生效」。 - var defaultView = !statusFilter; - var inlineStatuses = defaultView - ? ['active','blocked','pending'] - : ['active','blocked','pending','done','cancelled']; - inlineStatuses.forEach(function(status) { - var group = items.filter(function(a) { return a.status === status; }); - if (!group.length) return; - html += '
'; - html += '
' + esc(statusLabel(status)) + '
' + group.length + ' ' + t('act.itemsUnit') + '
'; - html += '
'; - group.forEach(function(a) { - var isFrontier = frontierIds.has(a.id); - html += renderActionCard(a, isFrontier); - }); - html += '
'; - }); - if (defaultView) { - html += renderDoneTodaySection(items.filter(function(a) { return a.status === 'done'; }), frontierIds, renderActionCard); - } - } - html += inboxArchiveHtml; - - var __focus = captureSearchFocus(['actions-search']); - el.innerHTML = html; - var __as = document.getElementById('actions-search'); - if (__as) bindImeSafeSearch(__as, 200, function(v){ state.actions.search = v; renderActions(); }); - restoreSearchFocus(__focus); - } - - async function loadCrystals() { - var el = document.getElementById('view-crystals'); - el.innerHTML = '
正在加载结晶...
'; - var results = await Promise.all([apiGet('crystals'), apiGet('lessons')]); - state.crystals.items = (results[0] && results[0].crystals) || []; - var lessonMap = {}; - var lessons = (results[1] && results[1].lessons) || []; - lessons.forEach(function(l) { if (l && l.id) lessonMap[l.id] = l; }); - state.crystals.lessonMap = lessonMap; - state.crystals.loaded = true; - renderCrystals(); - } - - function renderCrystals() { - var el = document.getElementById('view-crystals'); - var items = state.crystals.items; - var search = state.crystals.search.toLowerCase(); - var lessonMap = state.crystals.lessonMap || {}; - - if (search) { - items = items.filter(function(c) { - var lessonText = (c.lessons || []) - .map(function(lid) { - var l = lessonMap[lid]; - return l && typeof l.content === 'string' ? l.content : lid; - }) - .join(' '); - var filesText = (c.filesAffected || []).join(' '); - var haystack = [ - c.narrative || '', - (c.keyOutcomes || []).join(' '), - lessonText, - filesText, - c.project || '', - ].join(' ').toLowerCase(); - return haystack.indexOf(search) >= 0; - }); - } - - var html = '
'; - html += '
工作沉淀
'; - html += '
这里保留已经整理过的工作结果:做了什么、留下了哪些文件、能复用什么经验。
'; - html += '
' + items.length + ' 条
'; - html += '
'; - - html += '
'; - html += ''; - html += '
'; - - if (items.length === 0) { - html += '
' + - '
💎
' + - '
还没有结晶
' + - '
结晶是一次完整工作的压缩摘要,用来保留关键结论、涉及文件和可复用经验。当前还没有生成结晶,但会话和记忆已经可以先使用。
' + - '
导入历史会话后,可以把重要会话整理成结晶;后续我会优先把这里做成一键整理。
' + - '' + - '
'; - } else { - html += '
'; - items.forEach(function(c) { - html += '
'; - html += '
'; - html += '
' + esc(truncate(c.narrative || '未命名工作', 180)) + '
'; - html += '
' + esc(shortDateTime(c.createdAt)) + '
'; - html += '
'; - - var pillRow = []; - if (c.project) pillRow.push('' + esc(projectDisplayName(c.project)) + ''); - if (c.filesAffected && c.filesAffected.length) pillRow.push('' + c.filesAffected.length + ' 个文件'); - if (c.lessons && c.lessons.length) pillRow.push('' + c.lessons.length + ' 条经验'); - if (pillRow.length) html += '
' + pillRow.join('') + '
'; - - if (c.filesAffected && c.filesAffected.length > 0) { - html += '
'; - html += '
'; - c.filesAffected.slice(0, 4).forEach(function(f) { - html += '
' + esc(f) + '
'; - }); - if (c.filesAffected.length > 4) html += '
还有 ' + (c.filesAffected.length - 4) + ' 个文件
'; - html += '
'; - } - - if (c.lessons && c.lessons.length > 0) { - html += '
'; - html += '
'; - c.lessons.slice(0, 3).forEach(function(lid) { - var content = lessonMap[lid] ? lessonMap[lid].content : lid; - html += '
' + esc(content) + '
'; - }); - if (c.lessons.length > 3) html += '
还有 ' + (c.lessons.length - 3) + ' 条经验
'; - html += '
'; - } - - html += '
'; - }); - html += '
'; - } - - var __focus = captureSearchFocus(['crystals-search']); - el.innerHTML = html; - var __cs = document.getElementById('crystals-search'); - if (__cs) bindImeSafeSearch(__cs, 200, function(v){ state.crystals.search = v; renderCrystals(); }); - restoreSearchFocus(__focus); - } - - async function loadAudit() { - var el = document.getElementById('view-audit'); - el.innerHTML = '
加载审计日志中...
'; - var result = await apiGet('audit?limit=100'); - state.audit.entries = (result && result.entries) || []; - state.audit.loaded = true; - renderAudit(); - } - - function renderAudit() { - var el = document.getElementById('view-audit'); - var entries = state.audit.entries; - var opFilter = state.audit.opFilter; - - var ops = {}; - entries.forEach(function(e) { ops[e.operation] = true; }); - var opList = Object.keys(ops).sort(); - - var filtered = opFilter ? entries.filter(function(e) { return e.operation === opFilter; }) : entries; - - var html = '
'; - html += ''; - html += ''; - html += '
'; - - html += '
'; - if (filtered.length === 0) { - if (entries.length === 0) { - html += '
📜

暂无审计记录

当发生删除、演化、合并等治理操作后,这里会出现记录。

'; - } else { - html += '
🔍

当前筛选下无结果

'; - } - } else { - filtered.forEach(function(a, idx) { - var badgeClass = OP_BADGES[a.operation] || 'badge-muted'; - html += '
'; - html += '
'; - html += '' + esc(a.operation) + ''; - html += '' + esc(a.functionId || '') + ''; - html += '' + esc(formatTime(a.timestamp)) + ''; - html += ''; - html += '
'; - if (a.targetIds && a.targetIds.length) { - html += '
' + a.targetIds.length + ' 个目标: ' + esc(a.targetIds.slice(0, 3).join(', ')) + (a.targetIds.length > 3 ? '...' : '') + '
'; - } - html += '
' + esc(JSON.stringify(a.details || {}, null, 2)) + '
'; - html += '
'; - }); - } - html += '
'; - - el.innerHTML = html; - - document.getElementById('audit-op-filter').addEventListener('change', function() { - state.audit.opFilter = this.value; - renderAudit(); - }); - } - - function toggleAuditDetail(idx) { - var el = document.getElementById('audit-detail-' + idx); - if (el) el.classList.toggle('open'); - } - - async function loadProfile() { - var el = document.getElementById('view-profile'); - el.innerHTML = '
加载画像中...
'; - var sessResult = await apiGet('sessions'); - var sessions = (sessResult && sessResult.sessions) || []; - - var projects = {}; - sessions.forEach(function(s) { if (s.project) projects[s.project] = true; }); - state.profile.projects = Object.keys(projects).sort(); - state.profile.loaded = true; - - if (state.profile.projects.length > 0 && !state.profile.selectedProject) { - state.profile.selectedProject = state.profile.projects[0]; - } - - renderProfileToolbar(); - if (state.profile.selectedProject) await loadProfileData(); - } - - function renderProfileToolbar() { - var el = document.getElementById('view-profile'); - var html = '
'; - html += '
'; - html += '
'; - el.innerHTML = html; - - document.getElementById('profile-project').addEventListener('change', function() { - state.profile.selectedProject = this.value; - loadProfileData(); - }); - } - - async function loadProfileData() { - var content = document.getElementById('profile-content'); - if (!content || !state.profile.selectedProject) return; - content.innerHTML = '
加载画像数据中...
'; - var result = await apiGet('profile?project=' + encodeURIComponent(state.profile.selectedProject)); - state.profile.data = (result && result.profile) ? result.profile : result; - renderProfile(); - } - - function renderProfile() { - var content = document.getElementById('profile-content'); - if (!content) return; - var p = state.profile.data; - - if (!p) { - content.innerHTML = '
📋

该项目暂无画像数据

'; - return; - } - - var html = '
'; - - html += '
高频概念
'; - var concepts = p.topConcepts || []; - if (concepts.length === 0) { - html += '
No concepts yet
'; - } else { - var maxC = Math.max.apply(null, concepts.map(function(c) { return c.frequency; })) || 1; - html += '
'; - concepts.slice(0, 10).forEach(function(c) { - var pct = Math.round((c.frequency / maxC) * 100); - html += '
' + esc(c.concept) + '
' + c.frequency + '
'; - }); - html += '
'; - } - html += '
'; - - html += '
高频文件
'; - var files = p.topFiles || []; - if (files.length === 0) { - html += '
No files yet
'; - } else { - var maxF = Math.max.apply(null, files.map(function(f) { return f.frequency; })) || 1; - html += '
'; - files.slice(0, 10).forEach(function(f) { - var pct = Math.round((f.frequency / maxF) * 100); - html += '
' + esc(f.file.split('/').pop()) + '
' + f.frequency + '
'; - }); - html += '
'; - } - html += '
'; - - html += '
'; - - html += '
约定
'; - var conventions = p.conventions || []; - if (conventions.length === 0) { - html += '
No conventions detected yet
'; - } else { - html += '
    '; - conventions.forEach(function(c) { html += '
  • ' + esc(c) + '
  • '; }); - html += '
'; - } - html += '
'; - - if (p.summary) { - html += '
项目摘要
'; - html += '

' + esc(p.summary) + '

'; - } - - var stats = '
项目统计
'; - stats += '
Sessions
' + (p.sessionCount || 0) + '
'; - stats += '
Total Obs
' + (p.totalObservations || 0) + '
'; - stats += '
Updated
' + esc(formatTime(p.updatedAt)) + '
'; - stats += '
'; - - content.innerHTML = html + stats; - } - - var wsReconnectTimer = null; - var wsRetries = 0; - var WS_MAX_RETRIES = 4; - var directFailed = false; - var directFailures = 0; - var DIRECT_FAILURE_THRESHOLD = 2; - var pollTimer = null; - var POLL_INTERVAL_MS = 10000; - - function setWsStatus(text, cls) { - var el = document.getElementById('ws-status'); - if (!el) return; - el.textContent = text; - el.className = 'ws-status ' + cls; - } - - var WS_REPROBE_EVERY_TICKS = 6; - - function startPolling() { - if (pollTimer) return; - setWsStatus('polling · ' + (POLL_INTERVAL_MS / 1000) + 's', 'disconnected'); - var tick = 0; - pollTimer = setInterval(function() { - tick++; - refreshActiveTab('poll'); - if (tick % WS_REPROBE_EVERY_TICKS === 0) { - var ws = state.ws; - if (!ws || ws.readyState !== WebSocket.OPEN) { - wsRetries = 0; - directFailures = 0; - directFailed = false; - connectWs(); - } - } - }, POLL_INTERVAL_MS); - } - - function stopPolling() { - if (!pollTimer) return; - clearInterval(pollTimer); - pollTimer = null; - } - - var WS_CONNECT_TIMEOUT_MS = 5000; - - function connectWs() { - if (wsRetries >= WS_MAX_RETRIES) { - startPolling(); - return; - } - var useDirect = !directFailed; - var ws; - try { - ws = new WebSocket(useDirect ? WS_DIRECT_URL : WS_URL); - ws.__direct = useDirect; - } catch (_) { - ws = new WebSocket(WS_URL); - ws.__direct = false; - } - var connectTimer = setTimeout(function() { - if (ws.readyState === WebSocket.CONNECTING) { - try { ws.close(); } catch {} - } - }, WS_CONNECT_TIMEOUT_MS); - try { - ws.onopen = function() { - clearTimeout(connectTimer); - if (state.ws !== ws) return; - wsRetries = 0; - stopPolling(); - if (ws.__direct) { - directFailures = 0; - directFailed = false; - } - if (!ws.__direct) { - ws.send(JSON.stringify({ - type: 'join', - data: { - subscriptionId: 'viewer-' + Date.now(), - streamName: 'mem-live', - groupId: 'viewer' - } - })); - } - setWsStatus('live', 'connected'); - }; - ws.onmessage = function(e) { - if (state.ws !== ws) return; - try { - var msg = JSON.parse(e.data); - if (msg.type === 'stream' && msg.event) { - handleStreamEvent(msg); - } else if (msg.event_type && msg.data) { - handleStreamEvent({ event: { type: 'create', data: msg.data, event_type: msg.event_type } }); - } - } catch {} - }; - ws.onclose = function() { - clearTimeout(connectTimer); - if (state.ws !== ws) return; - if (ws.__direct) { - directFailures += 1; - if (directFailures >= DIRECT_FAILURE_THRESHOLD) { - directFailed = true; - } - } - wsRetries++; - if (wsRetries < WS_MAX_RETRIES) { - setWsStatus('connecting...', 'disconnected'); - wsReconnectTimer = setTimeout(connectWs, 2000 + Math.min(wsRetries * 1000, 8000)); - } else { - startPolling(); - } - }; - ws.onerror = function() { - if (state.ws !== ws) return; - try { ws.close(); } catch {} - }; - state.ws = ws; - } catch { - wsRetries++; - if (wsRetries < WS_MAX_RETRIES) { - wsReconnectTimer = setTimeout(connectWs, 2000 + Math.min(wsRetries * 1000, 8000)); - } else { - startPolling(); - } - } - } - - function looksLikeObservation(obj) { - return !!(obj && typeof obj === 'object' && obj.id && obj.timestamp); - } - - function handleStreamEvent(msg) { - var evt = msg.event; - var observation; - if (!evt) return; - if (evt.event_type && evt.event_type !== 'observation' && evt.event_type !== 'create' && evt.event_type !== 'update') { - return; - } - if (evt.type === 'event' && evt.data) { - observation = evt.data.observation || evt.data; - if (looksLikeObservation(observation)) { - routeWsMessage({ observation: observation }); - } - return; - } - if ((evt.type === 'create' || evt.type === 'update') && evt.data) { - var payload = evt.data; - observation = payload.observation || payload; - if (looksLikeObservation(observation)) { - routeWsMessage({ observation: observation }); - } - } else if (evt.type === 'sync') { - var items = Array.isArray(evt.data) ? evt.data : []; - items.forEach(function(item) { - var payload = item.data || item; - observation = payload.observation || payload; - if (looksLikeObservation(observation)) { - routeWsMessage({ observation: observation }); - } - }); - } - } - - function routeWsMessage(msg) { - markMemoryViewsStale(); - if (state.activeTab === 'timeline' && msg.observation) { - if (!state.timeline.sessionId || msg.observation.sessionId === state.timeline.sessionId) { - var existing = state.timeline.observations.findIndex(function(o) { return o.id === msg.observation.id; }); - if (existing >= 0) { - state.timeline.observations[existing] = msg.observation; - } else { - state.timeline.observations.unshift(msg.observation); - } - renderObservations(); - } - } - if (state.activeTab === 'dashboard') { - loadDashboard(); - } - if (state.activeTab === 'memories') loadMemories(); - if (state.activeTab === 'sessions') { - if (isViewingSessionDetail()) { - markSessionsStale(); - } else { - state.sessions.loaded = false; - loadSessions({ showLoading: false, reason: 'ws' }); - } - } - if (state.activeTab === 'lessons') loadLessons(); - if (state.activeTab === 'actions') markActionsStale('有新会话记录,待办暂未自动刷新。'); - if (state.activeTab === 'activity' && msg.observation) { - state.activity.observations.unshift(msg.observation); - renderActivity(); - } - } - - document.getElementById('tab-bar').addEventListener('click', function(e) { - var toggle = e.target instanceof Element ? e.target.closest('#expert-toggle') : null; - if (toggle) { - toggleExpertMode(); - return; - } - var btn = e.target instanceof Element ? e.target.closest('button[data-tab]') : null; - if (btn) { - switchTab(btn.dataset.tab); - return; - } - }); - - document.addEventListener('click', function(e) { - if (!(e.target instanceof Element)) return; - var jump = e.target.closest('[data-dashboard-jump]'); - if (!jump) return; - var tab = jump.getAttribute('data-dashboard-jump'); - if (!tab) return; - switchTab(tab); - }); - - document.querySelectorAll('[data-tab-link]').forEach(function(link) { - link.addEventListener('click', function(e) { - if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return; - e.preventDefault(); - switchTab(link.getAttribute('data-tab-link')); - }); - }); - - function syncTabFromRoute() { - switchTab(tabFromRoute(), { replaceRoute: true }); - } - window.addEventListener('hashchange', syncTabFromRoute); - window.addEventListener('popstate', syncTabFromRoute); - - // --- Feature flag banners --------------------------------------------- - function getDismissedFlags() { - if (!state.flagsDismissed) state.flagsDismissed = {}; - return state.flagsDismissed; - } - function dismissFlags(keys) { - var dismissed = getDismissedFlags(); - keys.forEach(function(key) { - if (key) dismissed[key] = true; - }); - } - function renderFlagBanners(cfg) { - var host = document.getElementById('flag-banners'); - if (!host) return; - var dismissed = getDismissedFlags(); - var banners = []; - function toHumanTitle(raw) { - if (!raw) return '功能未开启'; - var key = String(raw).toLowerCase(); - if (key.indexOf('knowledge graph') >= 0) return '关系图自动提取未开启'; - if (key.indexOf('llm provider') >= 0) return '智能分析未开启'; - if (key.indexOf('bm25-only') >= 0) return '仅关键词检索模式'; - return raw; - } - function toHumanDesc(raw) { - if (!raw) return '开启后可获得更完整的记忆分析能力。'; - var s = String(raw); - if (s.indexOf('Compression, summarization, and graph extraction') >= 0) { - return '当前只能使用基础能力;摘要、压缩和关系图能力暂不可用。'; - } - if (s.indexOf('Extracts entities and relations') >= 0) { - return '系统暂不会自动生成关系图。'; - } - if (s.indexOf('Semantic vector search is off') >= 0) { - return '当前搜索偏向关键词匹配,语义理解能力较弱。'; - } - return s; - } - // Per-flag banner (only for off flags, affecting current tab or dashboard) - (cfg.flags || []).forEach(function(f) { - if (f.enabled) return; - if (dismissed[f.key]) return; - var tabsAffected = (f.affects || []).map(function(t) { return t.toLowerCase(); }); - if (tabsAffected.length && tabsAffected.indexOf(state.activeTab) === -1 && state.activeTab !== 'dashboard') return; - banners.push({ - kind: 'warn', - icon: '◉', - title: toHumanTitle(f.label), - keyLabel: f.key, - desc: toHumanDesc(f.description) + (f.needsLlm ? ' 需要先连接智能模型。' : ''), - enable: f.enableHow, - docs: f.docsHref, - dismissKey: f.key, - }); - }); - if (cfg.provider === 'noop' && !dismissed['__provider_noop']) { - banners.unshift({ - kind: 'warn', - icon: '◉', - title: '智能分析未开启', - keyLabel: 'ANTHROPIC_API_KEY', - desc: '当前只能用基础功能;摘要、关系图与自动提炼暂不可用。', - enable: 'export ANTHROPIC_API_KEY=sk-ant-...\n# then restart: npm run start:local-memory', - docs: 'https://github.com/MaimoryLab/agentmemory-lab#quick-start', - dismissKey: '__provider_noop', - }); - } - if (cfg.embeddingProvider === 'none' && !dismissed['__embedding_none']) { - banners.push({ - kind: 'info', - icon: '◉', - title: '仅关键词检索模式', - keyLabel: 'OPENAI_API_KEY', - desc: '当前搜索偏向关键词匹配,语义理解能力较弱。', - enable: 'export OPENAI_API_KEY=sk-...\n# or VOYAGE_API_KEY, COHERE_API_KEY, OLLAMA_HOST', - docs: 'https://github.com/MaimoryLab/agentmemory-lab#embedding-providers', - dismissKey: '__embedding_none', - }); - } - if (banners.length === 0) { host.innerHTML = ''; return; } - var warnCount = banners.filter(function(b) { return b.kind === 'warn'; }).length; - var infoCount = banners.filter(function(b) { return b.kind === 'info'; }).length; - var expanded = host.getAttribute('data-expanded') === '1'; - var pills = ''; - if (warnCount) pills += '' + warnCount + ' 项待开启'; - if (infoCount) pills += '' + infoCount + ' 条提示'; - var escHtml = function(s) { - return String(s).replace(/[<>&"]/g, function(c) { - return { '<': '<', '>': '>', '&': '&', '"': '"' }[c]; - }); - }; - var listHtml = banners.map(function(b) { - return '
' + - '' + b.icon + '' + - '
' + - '
' + escHtml(b.title) + '
' + - '
' + escHtml(b.desc) + '
' + - '' + escHtml(b.enable) + '' + - (b.docs ? ' 查看设置步骤' : '') + - '
' + - '' + - '
'; - }).join(''); - host.innerHTML = '' + - '
' + listHtml + '
'; - } - async function fetchFlags() { - var res = await apiGet('config/flags'); - if (!res) return; - state.flagsConfig = res; - renderFlagBanners(res); - updateFooter(res); - } - function updateFooter(cfg) { - var fbEl = document.getElementById('footer-feedback'); - if (fbEl) { - var flagSummary = (cfg.flags || []).map(function(f) { return f.key + '=' + (f.enabled ? 'on' : 'off'); }).join(', '); - var body = encodeURIComponent( - '**Version:** ' + (cfg.version || '?') + '\n' + - '**Provider:** ' + (cfg.provider || '?') + '\n' + - '**Embedding:** ' + (cfg.embeddingProvider || '?') + '\n' + - '**Flags:** ' + flagSummary + '\n' + - '**User agent:** ' + navigator.userAgent + '\n\n' + - '### What went wrong\n\n' + - '(describe the issue)\n\n' + - '### Steps to reproduce\n\n' + - '1. \n2. \n3. \n' - ); - fbEl.href = 'https://github.com/MaimoryLab/agentmemory-lab/issues/new?title=' + - encodeURIComponent('[viewer] ') + '&body=' + body; - } - } - document.addEventListener('click', function(e) { - if (!(e.target instanceof Element)) return; - var btn = e.target.closest('[data-dismiss-flag]'); - if (btn) { - e.preventDefault(); - e.stopPropagation(); - var key = btn.getAttribute('data-dismiss-flag'); - dismissFlags([key]); - if (state.flagsConfig) renderFlagBanners(state.flagsConfig); - return; - } - var toggle = e.target.closest('[data-action="toggle-flags"]'); - if (toggle) { - var host = document.getElementById('flag-banners'); - var cur = host.getAttribute('data-expanded') === '1'; - host.setAttribute('data-expanded', cur ? '0' : '1'); - if (state.flagsConfig) renderFlagBanners(state.flagsConfig); - } - }); - fetchFlags(); - document.addEventListener('click', function(e) { - if (!(e.target instanceof Element)) return; - var target = e.target.closest('[data-action]'); - if (!target) return; - var action = target.getAttribute('data-action'); - if (!action) return; - - if (action === 'toggle-theme') { - toggleTheme(); - return; - } - if (action === 'clear-audit-filter') { - state.audit.opFilter = ''; - renderAudit(); - return; - } - if (action === 'refresh-audit') { - state.audit.loaded = false; - loadAudit(); - return; - } - if (action === 'refresh-dashboard') { - refreshDashboard(); - return; - } - if (action === 'refresh-activity') { - state.activity.loaded = false; - loadActivity(); - return; - } - if (action === 'refresh-actions') { - state.actions.loaded = false; - loadActions({ generate: false }); - return; - } - if (action === 'extract-actions') { - startTodoExtraction(true); - return; - } - if (action === 'update-cards') { - startTodoUpdate(); - return; - } - if (action === 'toggle-settings') { - state.settings.open = !state.settings.open; - renderSettingsPanel(); - if (state.settings.open && !state.actions.config) { - loadTodoExtractorConfig().then(renderSettingsPanel).catch(function() {}); - } - return; - } - if (action === 'close-settings') { - state.settings.open = false; - renderSettingsPanel(); - return; - } - if (action === 'set-ui-language') { - var lang = target.getAttribute('data-lang') || ''; - if (lang === 'zh' || lang === 'en') { - I18N_LANG = lang; - try { localStorage.setItem('agentmemory-lang', lang); } catch (_) {} - applyI18n(document); - renderSettingsPanel(); - if (state.activeTab === 'actions') renderActions(); - if (state.activeTab === 'dashboard') renderDashboard(); - if (state.activeTab === 'sessions') renderSessions(); - } - return; - } - if (action === 'save-todo-config') { - saveTodoExtractorConfig(); - return; - } - if (action === 'action-status') { - updateActionStatus(target.getAttribute('data-action-id') || '', target.getAttribute('data-status') || ''); - return; - } - if (action === 'refresh-sessions') { - state.sessions.loaded = false; - loadSessions({ showLoading: true, reason: 'manual' }); - return; - } - if (action === 'open-session-group') { - var openSessionId = target.getAttribute('data-session-id') || ''; - if (openSessionId) state.sessions.selectedId = openSessionId; - state.sessions.loaded = false; - switchTab('sessions'); - return; - } - if (action === 'jump-to-evidence') { - jumpToEvidence(target.getAttribute('data-obs-id') || ''); - return; - } - if (action === 'inbox-reply') { - openInboxReply(target.getAttribute('data-inbox-id') || ''); - return; - } - if (action === 'inbox-reply-submit') { - submitInboxReply(target.getAttribute('data-inbox-id') || ''); - return; - } - if (action === 'inbox-reply-cancel') { - cancelInboxReply(); - return; - } - if (action === 'inbox-ack') { - ackInboxItem(target.getAttribute('data-inbox-id') || ''); - return; - } - if (action === 'inbox-to-todo') { - convertInboxToTodo(target.getAttribute('data-inbox-id') || ''); - return; - } - if (action === 'toggle-done-today') { - state.actions.doneExpanded = !state.actions.doneExpanded; - renderActions(); - return; - } - if (action === 'toggle-briefings') { - state.inbox.briefingExpanded = !state.inbox.briefingExpanded; - renderActions(); - return; - } - if (action === 'toggle-inbox-archive') { - state.inbox.answeredExpanded = !state.inbox.answeredExpanded; - renderActions(); - return; - } - if (action === 'toggle-session-detail-section') { - toggleSessionDetailSection( - target.getAttribute('data-session-id') || state.sessions.selectedId || '', - target.getAttribute('data-section') || '', - ); - return; - } - if (action === 'toggle-session-preview') { - toggleSessionPreview(target.getAttribute('data-session-id') || state.sessions.selectedId || ''); - return; - } - if (action === 'zoom-graph') { - zoomGraph(parseInt(target.getAttribute('data-dir') || '0', 10)); - return; - } - if (action === 'recenter-graph') { - recenterGraph(); - return; - } - if (action === 'rebuild-graph') { - rebuildGraph(); - return; - } - if (action === 'expand-node') { - var nodeId = target.getAttribute('data-node-id'); - if (nodeId) expandNode(nodeId); - return; - } - if (action === 'delete-memory') { - deleteMemory( - target.getAttribute('data-memory-id') || '', - target.getAttribute('data-memory-title') || '', - ); - return; - } - if (action === 'edit-memory') { - var editMemoryId = target.getAttribute('data-memory-id'); - if (editMemoryId) editMemory(editMemoryId); - return; - } - if (action === 'copy-agent-prompt') { - copyAgentPrompt(); - return; - } - if (action === 'copy-skill-path') { - var skillPath = target.getAttribute('data-skill-path') || ''; - copyTextToClipboard(skillPath).then(function() { - target.setAttribute('title', '已复制'); - setTimeout(function() { target.setAttribute('title', '复制路径'); }, 1400); - }); - return; - } - if (action === 'open-skill-draft') { - openSkillDraft(target.getAttribute('data-skill-draft-key') || '通用经验'); - return; - } - if (action === 'copy-skill-draft') { - var draftEl = document.getElementById('skill-draft-preview'); - copyTextToClipboard(draftEl ? draftEl.textContent || '' : '').then(function() { - target.textContent = '已复制'; - setTimeout(function() { target.textContent = '复制草稿'; }, 1400); - }); - return; - } - if (action === 'open-add-memory') { - openAddMemory(); - return; - } - if (action === 'open-add-lesson') { - openAddLesson(); - return; - } - if (action === 'open-skill-detail') { - openSkillDetail(target.getAttribute('data-skill-path') || ''); - return; - } - if (action === 'filter-skill-root') { - state.lessons.skillRootFilter = target.getAttribute('data-root') || 'all'; - renderLessons(); - return; - } - if (action === 'filter-actions-status') { - var nextFilter = target.getAttribute('data-status') || ''; - // STEP-13: clicking the already-active filter clears it — with the chip - // row gone, this is how the metric cards restore the "show all" view. - state.actions.statusFilter = (state.actions.statusFilter || '') === nextFilter ? '' : nextFilter; - renderActions(); - return; - } - if (action === 'close-modal') { - closeModal(); - return; - } - if (action === 'save-new-memory') { - saveNewMemory(); - return; - } - if (action === 'save-new-lesson') { - saveNewLesson(); - return; - } - if (action === 'save-memory') { - var saveMemoryId = target.getAttribute('data-memory-id'); - if (saveMemoryId) saveMemory(saveMemoryId); - return; - } - if (action === 'confirm-delete-memory') { - var memoryId = target.getAttribute('data-memory-id'); - if (memoryId) confirmDeleteMemory(memoryId); - return; - } - if (action === 'edit-review') { - var editReviewId = target.getAttribute('data-review-id'); - if (editReviewId) editReviewItem(editReviewId); - return; - } - if (action === 'approve-review') { - var approveId = target.getAttribute('data-review-id'); - if (approveId) approveReviewItem(approveId); - return; - } - if (action === 'dismiss-review') { - var dismissId = target.getAttribute('data-review-id'); - if (dismissId) dismissReviewItem(dismissId); - return; - } - if (action === 'timeline-filter') { - setTlTypeFilter(target.getAttribute('data-type-filter') || ''); - return; - } - if (action === 'episode-filter') { - state.timeline.episodeFilter = target.getAttribute('data-episode-filter') || 'all'; - state.timeline.page = 0; - renderObservations(); - return; - } - if (action === 'episode-toggle') { - var episodeKey = target.getAttribute('data-episode-key'); - if (episodeKey) { - state.timeline.expandedEpisodes[episodeKey] = !state.timeline.expandedEpisodes[episodeKey]; - renderObservations(); - } - return; - } - if (action === 'timeline-page') { - var page = parseInt(target.getAttribute('data-page') || '', 10); - if (!Number.isNaN(page)) tlPage(page); - return; - } - if (action === 'timeline-mode') { - var mode = target.getAttribute('data-mode') || 'episodes'; - state.timeline.mode = mode === 'events' ? 'events' : 'episodes'; - state.timeline.page = 0; - renderTimelineToolbar(groupSessionsByProject(state.timeline.sessions || [])); - renderObservations(); - return; - } - if (action === 'select-session') { - var sessionId = target.getAttribute('data-session-id'); - if (sessionId) selectSession(sessionId); - return; - } - if (action === 'session-group-mode') { - var sessionMode = target.getAttribute('data-mode') === 'source' ? 'source' : 'folder'; - state.sessions.groupMode = sessionMode; - renderSessions(); - return; - } - if (action === 'select-session-group') { - var groupKey = target.getAttribute('data-group-key') || 'all'; - if (state.sessions.groupMode === 'source') state.sessions.sourceKey = groupKey; - else state.sessions.folderKey = groupKey; - renderSessions(); - return; - } - if (action === 'session-jump') { - var jumpTarget = target.getAttribute('data-target') === 'bottom' ? 'session-detail-bottom' : 'session-detail-top'; - var jumpEl = document.getElementById(jumpTarget); - if (jumpEl) jumpEl.scrollIntoView({ behavior: 'smooth', block: target.getAttribute('data-target') === 'bottom' ? 'end' : 'start' }); - return; - } - if (action === 'end-session') { - var endSessionId = target.getAttribute('data-session-id'); - if (endSessionId) endSession(endSessionId); - return; - } - if (action === 'summarize-session') { - var summarizeSessionId = target.getAttribute('data-session-id'); - if (summarizeSessionId) summarizeSession(summarizeSessionId, target); - return; - } - if (action === 'toggle-audit') { - var auditIndex = parseInt(target.getAttribute('data-audit-index') || '', 10); - if (!Number.isNaN(auditIndex)) toggleAuditDetail(auditIndex); - } - if (action === 'replay-select') { - var rSid = target.getAttribute('data-session-id'); - if (rSid) selectReplaySession(rSid); - return; - } - if (action === 'replay-toggle-play') { toggleReplayPlay(); return; } - if (action === 'replay-step') { - var d = parseInt(target.getAttribute('data-dir') || '1', 10); - stepReplay(d); - return; - } - if (action === 'replay-speed') { - var sp = parseFloat(target.getAttribute('data-speed') || '1'); - setReplaySpeed(sp); - return; - } - if (action === 'replay-reset') { resetReplay(); return; } - if (action === 'replay-import') { runReplayImport(); return; } - if (action === 'replay-refresh') { refreshReplaySessions(); return; } - }); - document.getElementById('modal-overlay').addEventListener('click', function(e) { - if (e.target === this) closeModal(); - }); - document.addEventListener('input', function(e) { - var target = e.target; - if (!target || !target.id || String(target.id).indexOf('todo-config-') !== 0) return; - state.actions.configDraft = state.actions.configDraft || {}; - state.actions.configDraft[String(target.id).slice('todo-config-'.length)] = String(target.value || ''); - }); - - async function loadReplay() { - var el = document.getElementById('view-replay'); - el.innerHTML = '
加载会话列表中…
'; - var res = await apiGet('replay/sessions'); - state.replay.sessions = (res && res.sessions) || []; - state.replay.loaded = true; - renderReplay(); - } - - async function refreshReplaySessions() { - state.replay.loaded = false; - await loadReplay(); - } - - function renderReplay() { - var el = document.getElementById('view-replay'); - var sessions = state.replay.sessions || []; - var options = '' + sessions.map(function(s) { - var id = sessionId(s); - var label = sessionDisplayName(s) + ' · ' + (shortSessionId(s, 8) || '无记录 ID') + ' · ' + (s.observationCount || 0) + ' obs'; - return ''; - }).join(''); - - var tl = state.replay.timeline; - var hasTl = tl && tl.events && tl.events.length > 0; - var cursorEvent = hasTl ? tl.events[Math.min(state.replay.cursor, tl.events.length - 1)] : null; - var progress = hasTl && tl.totalDurationMs > 0 ? Math.min(100, (state.replay.offsetAt / tl.totalDurationMs) * 100) : 0; - - el.innerHTML = - '
' + - '' + - '' + - '' + - '' + - '' + - '
' + - (hasTl - ? '
' + - '' + - '' + - '' + - '' + - '' + - '速度' + - ['0.5', '1', '2', '4'].map(function(sp) { - var active = Math.abs(state.replay.speed - parseFloat(sp)) < 0.01; - return ''; - }).join('') + - '' + - '' + (state.replay.cursor + 1) + ' / ' + tl.eventCount + '' + - '
' + - '
' + - '
' + - '
' + - tl.events.map(function(ev, i) { - var active = i === state.replay.cursor ? ' replay-event-active' : ''; - return '
' + - '' + esc(ev.kind) + '' + - '' + esc(ev.label) + '' + - '' + (ev.offsetMs / 1000).toFixed(1) + 's' + - '
'; - }).join('') + - '
' + - '
' + renderReplayDetail(cursorEvent) + '
' + - '
' - : '
请选择一个会话进行回放,或从 ~/.claude/projects 导入 Claude Code 的 JSONL 记录。
'); - - var sel = document.getElementById('replay-session-select'); - if (sel) sel.addEventListener('change', function() { selectReplaySession(sel.value); }); - } - - function renderReplayDetail(ev) { - if (!ev) return '
尚未选择事件。
'; - var blocks = []; - blocks.push('
' + esc(ev.label) + ' ' + esc(ev.kind) + '
'); - if (ev.ts) blocks.push('
' + esc(formatTime(ev.ts)) + '
'); - if (ev.body) { - blocks.push('
' + esc(ev.body) + '
'); - } - if (ev.toolName) { - blocks.push('
工具: ' + esc(ev.toolName) + '
'); - } - if (ev.toolInput !== undefined && ev.toolInput !== null) { - var inp = typeof ev.toolInput === 'string' ? ev.toolInput : JSON.stringify(ev.toolInput, null, 2); - blocks.push('
输入
' + esc(truncate(inp, 4000)) + '
'); - } - if (ev.toolOutput !== undefined && ev.toolOutput !== null) { - var out = typeof ev.toolOutput === 'string' ? ev.toolOutput : JSON.stringify(ev.toolOutput, null, 2); - blocks.push('
输出
' + esc(truncate(out, 4000)) + '
'); - } - return blocks.join(''); - } - - async function selectReplaySession(sessionId) { - stopReplayTimer(); - state.replay.selectedId = sessionId; - state.replay.timeline = null; - state.replay.cursor = 0; - state.replay.offsetAt = 0; - state.replay.playing = false; - if (!sessionId) { renderReplay(); return; } - var el = document.getElementById('view-replay'); - el.innerHTML = '
加载回放中…
'; - var res = await apiGet('replay/load?sessionId=' + encodeURIComponent(sessionId)); - if (res && res.success && res.timeline) { - state.replay.timeline = res.timeline; - } else { - state.replay.timeline = { events: [], eventCount: 0, totalDurationMs: 0 }; - } - renderReplay(); - } - - function toggleReplayPlay() { - if (!state.replay.timeline || state.replay.timeline.eventCount === 0) return; - if (state.replay.playing) { - stopReplayTimer(); - } else { - startReplayTimer(); - } - renderReplay(); - } - - function startReplayTimer() { - state.replay.playing = true; - state.replay.startAt = Date.now(); - var baseOffset = state.replay.offsetAt; - if (state.replay.timer) clearInterval(state.replay.timer); - state.replay.timer = setInterval(function() { - if (!state.replay.timeline) return; - var elapsed = (Date.now() - state.replay.startAt) * state.replay.speed; - state.replay.offsetAt = baseOffset + elapsed; - var events = state.replay.timeline.events; - var newCursor = state.replay.cursor; - for (var i = newCursor; i < events.length; i++) { - if (events[i].offsetMs <= state.replay.offsetAt) newCursor = i; - else break; - } - var changed = newCursor !== state.replay.cursor; - state.replay.cursor = newCursor; - if (state.replay.offsetAt >= state.replay.timeline.totalDurationMs) { - state.replay.offsetAt = state.replay.timeline.totalDurationMs; - stopReplayTimer(); - renderReplay(); - return; - } - if (changed) renderReplay(); - }, 100); - } - - function stopReplayTimer() { - state.replay.playing = false; - if (state.replay.timer) { - clearInterval(state.replay.timer); - state.replay.timer = null; - } - } - - function stepReplay(dir) { - if (!state.replay.timeline) return; - stopReplayTimer(); - var next = state.replay.cursor + dir; - if (next < 0) next = 0; - if (next >= state.replay.timeline.eventCount) next = state.replay.timeline.eventCount - 1; - state.replay.cursor = next; - state.replay.offsetAt = state.replay.timeline.events[next].offsetMs; - renderReplay(); - } - - function setReplaySpeed(sp) { - if (!sp || sp <= 0) return; - var wasPlaying = state.replay.playing; - stopReplayTimer(); - state.replay.speed = sp; - if (wasPlaying) startReplayTimer(); - renderReplay(); - } - - function resetReplay() { - stopReplayTimer(); - state.replay.cursor = 0; - state.replay.offsetAt = 0; - renderReplay(); - } - - async function runReplayImport() { - var input = document.getElementById('replay-import-path'); - var pathVal = input ? input.value.trim() : ''; - var body = {}; - if (pathVal) body.path = pathVal; - var el = document.getElementById('view-replay'); - var prior = el.innerHTML; - el.innerHTML = '
Importing JSONL…
'; - var res = await apiPost('replay/import-jsonl', body); - if (!res || res.success === false) { - el.innerHTML = prior; - alert((res && res.error) || '导入失败'); - return; - } - alert('已导入 ' + (res.imported || 0) + ' 个文件,生成 ' + (res.observations || 0) + ' 条观察记录'); - await refreshReplaySessions(); - } - - document.addEventListener('keydown', function(e) { - if (state.activeTab !== 'replay') return; - if (e.target && (e.target.tagName === 'INPUT' || e.target.tagName === 'SELECT' || e.target.tagName === 'TEXTAREA')) return; - if (e.key === ' ') { e.preventDefault(); toggleReplayPlay(); } - else if (e.key === 'ArrowLeft') { e.preventDefault(); stepReplay(-1); } - else if (e.key === 'ArrowRight') { e.preventDefault(); stepReplay(1); } - }); - - // 总览页下拉刷新(顶部向下滑动触发) - var dashPullStartY = null; - var dashPullStartTop = false; - var dashPullTriggered = false; - var dashPullCooldownAt = 0; - document.addEventListener('touchstart', function(e) { - if (state.activeTab !== 'dashboard') return; - if (!e.touches || e.touches.length !== 1) return; - var scTop = (window.pageYOffset || document.documentElement.scrollTop || document.body.scrollTop || 0); - dashPullStartTop = scTop <= 2; - dashPullStartY = e.touches[0].clientY; - dashPullTriggered = false; - }, { passive: true }); - document.addEventListener('touchmove', function(e) { - if (state.activeTab !== 'dashboard') return; - if (!dashPullStartTop || dashPullStartY === null || dashPullTriggered) return; - if (!e.touches || e.touches.length !== 1) return; - var dy = e.touches[0].clientY - dashPullStartY; - if (dy < 72) return; - var now = Date.now(); - if (now - dashPullCooldownAt < 1200) return; - dashPullTriggered = true; - dashPullCooldownAt = now; - refreshDashboard(); - }, { passive: true }); - document.addEventListener('touchend', function() { - dashPullStartY = null; - dashPullStartTop = false; - dashPullTriggered = false; - }, { passive: true }); - - document.addEventListener('visibilitychange', function() { - if (document.visibilityState === 'visible') { - refreshActiveTab('visibility'); - } - }); - - renderExpertTabs(); - switchTab(tabFromRoute(), { replaceRoute: true }); - connectWs(); - startDashboardAutoRefresh(); diff --git a/src/viewer/parts/manifest.json b/src/viewer/parts/manifest.json index 10dfdd1e..02d28750 100644 --- a/src/viewer/parts/manifest.json +++ b/src/viewer/parts/manifest.json @@ -18,6 +18,15 @@ "app/20-format-utils.js", "app/40-data-helpers.js", "app/45-transport.js", - "app/90-rest.js", + "app/50-routing.js", + "app/52-dashboard.js", + "app/54-legacy-graph-memories-timeline-obs-activity.js", + "app/56-sessions.js", + "app/58-legacy-lessons.js", + "app/60-actions-todo.js", + "app/62-legacy-crystals-audit-profile.js", + "app/64-ws-flags-events.js", + "app/68-legacy-replay.js", + "app/90-bootstrap.js", "40-tail.html" ] diff --git a/test/viewer-assembly.test.ts b/test/viewer-assembly.test.ts index 3a03d20c..87f3ca9b 100644 --- a/test/viewer-assembly.test.ts +++ b/test/viewer-assembly.test.ts @@ -115,5 +115,20 @@ describe("viewer i18n + transport extraction (PLAN-007 STEP-04)", () => { }); }); +describe("viewer render/dispatch fragmentation (PLAN-007 STEP-05)", () => { + it("splits the render layer into per-view app fragments + a bootstrap tail", () => { + const m = manifest(); + expect(m).not.toContain("app/90-rest.js"); + expect(m.some((n) => /^app\/.*(actions|todo)/.test(n)), "no To-Do render fragment").toBe(true); + expect(m.some((n) => /^app\/.*bootstrap/.test(n)), "no bootstrap fragment").toBe(true); + }); + + it("isolates the To-Do render (renderActions) into app/60-actions-todo.js", () => { + const todo = readFileSync(join(PARTS_DIR, "app", "60-actions-todo.js"), "utf-8"); + expect(todo).toMatch(/function renderActions\(/); + }); +}); + +