Compare commits
134
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ff61da28a0 | ||
|
|
362fe50402 | ||
|
|
273a7fa7a5 | ||
|
|
74efbcdc17 | ||
|
|
e89db96b85 | ||
|
|
ae4c6d2423 | ||
|
|
91edcf5e8b | ||
|
|
0dd4d1d642 | ||
|
|
75e50fc194 | ||
|
|
29a134b804 | ||
|
|
59d36c2258 | ||
|
|
39fc15efe0 | ||
|
|
5d0ca44b22 | ||
|
|
78beade289 | ||
|
|
bf9850a6a9 | ||
|
|
0e9c9d4927 | ||
|
|
352da66dbd | ||
|
|
6aab78b9fa | ||
|
|
2adee13638 | ||
|
|
942fe3206c | ||
|
|
6b634c5d6b | ||
|
|
739f26702b | ||
|
|
48d7526b7e | ||
|
|
492f1d208a | ||
|
|
651475d17a | ||
|
|
2e3d2e77a9 | ||
|
|
41be9f8076 | ||
|
|
010aafb6f4 | ||
|
|
68cc0792e1 | ||
|
|
b28ddd4ba8 | ||
|
|
805acc3fce | ||
|
|
a620c22676 | ||
|
|
10065da8ac | ||
|
|
b2eb86a0b4 | ||
|
|
f4458afe4b | ||
|
|
10c5162811 | ||
|
|
9d03217f99 | ||
|
|
55f554d13c | ||
|
|
9527402003 | ||
|
|
50481deaa1 | ||
|
|
baa6defc0c | ||
|
|
775763eee0 | ||
|
|
82107ad86a | ||
|
|
b6c7de5f91 | ||
|
|
663aa85e1a | ||
|
|
3c31f0b499 | ||
|
|
169941ceda | ||
|
|
fdf7d83b20 | ||
|
|
f571301764 | ||
|
|
53b9ecbcae | ||
|
|
d859cbfdeb | ||
|
|
855b941aeb | ||
|
|
a1a1d24022 | ||
|
|
9d8477b728 | ||
|
|
0fd2f7e3c8 | ||
|
|
7aad78752b | ||
|
|
6ced760c1a | ||
|
|
09c415bd41 | ||
|
|
e97263c79e | ||
|
|
02a6f5c4e7 | ||
|
|
dc635d185e | ||
|
|
4d1ce38dad | ||
|
|
c3fa262832 | ||
|
|
0ce9606c89 | ||
|
|
ee4eb737e0 | ||
|
|
1f420cd042 | ||
|
|
43be465286 | ||
|
|
5f1ff24333 | ||
|
|
d773f5e961 | ||
|
|
90ea33ef5c | ||
|
|
983cdd4e05 | ||
|
|
bf6c4a91a2 | ||
|
|
1e10935d22 | ||
|
|
5dc65457d9 | ||
|
|
3d4e9577b5 | ||
|
|
80579ee421 | ||
|
|
8280158edc | ||
|
|
5368e0ecf4 | ||
|
|
20317c21b5 | ||
|
|
b1129cedec | ||
|
|
2e5c348dce | ||
|
|
f4d8fe7f06 | ||
|
|
dd29f309f1 | ||
|
|
b02ce31886 | ||
|
|
7cb74cce78 | ||
|
|
5e77118508 | ||
|
|
227507de8f | ||
|
|
c8f327fc4e | ||
|
|
016f987026 | ||
|
|
2c5300d031 | ||
|
|
a3b41bf2b1 | ||
|
|
0a02a17cb6 | ||
|
|
d2ceff8db5 | ||
|
|
8201c9c27e | ||
|
|
cc451f698d | ||
|
|
9cc5dc1244 | ||
|
|
f7f501a8e3 | ||
|
|
e4f7b8d993 | ||
|
|
b6d24ada87 | ||
|
|
c23dca65e8 | ||
|
|
bf64181c85 | ||
|
|
3be55348a2 | ||
|
|
89fc100d4f | ||
|
|
6edfc0046b | ||
|
|
ab85e72f01 | ||
|
|
80edd15438 | ||
|
|
7fb23d9b6b | ||
|
|
925d410247 | ||
|
|
2d8e58e995 | ||
|
|
6293ef105d | ||
|
|
d30f794184 | ||
|
|
608a9a5fec | ||
|
|
a662b16f5e | ||
|
|
911eed5e67 | ||
|
|
ed47e2c2a5 | ||
|
|
9c88ca20f3 | ||
|
|
d1b92deff3 | ||
|
|
9a9170441e | ||
|
|
3c9229ac7a | ||
|
|
86347af0b1 | ||
|
|
4baf4ec142 | ||
|
|
1603871f7c | ||
|
|
393a07ccdf | ||
|
|
50e8fde64c | ||
|
|
13ff182858 | ||
|
|
594222a2b5 | ||
|
|
1c6264dd87 | ||
|
|
120076a13d | ||
|
|
d8f0ef1301 | ||
|
|
10f3b4f2c1 | ||
|
|
58e6808f1f | ||
|
|
8650907b75 | ||
|
|
c11f204b17 | ||
|
|
be23c1346b |
@@ -182,6 +182,30 @@ npm run dist:linux:arm64
|
||||
已由 Vite 打包的 `@antv/g6` 归类为构建期依赖,不会再作为独立 Node 模块复制进桌面
|
||||
安装包。
|
||||
|
||||
Desktop packaging includes only `out/main`, `out/preload`, and `out/renderer`;
|
||||
validation builds elsewhere under `out` are not shipped. Renderer-only React,
|
||||
Xterm, Mermaid, Fabric, Quill, i18next, and Markdown/math dependencies are build-time
|
||||
dependencies: Vite ships their browser bundles, while the existing license
|
||||
resources remain included. Install development dependencies before building;
|
||||
do not prune them before bundling and copying license resources.
|
||||
|
||||
The desktop DS Web resource filter excludes JavaScript/TypeScript source maps,
|
||||
`.d.ts` / `.d.mts` / `.d.cts` declarations, and `.pdb` debug symbols. It preserves
|
||||
the installed dependency layout, plugin configuration, browser assets, licenses,
|
||||
native binaries, and non-code `.map` assets. This filter does not change the
|
||||
separately built remote Agent or Runtime payloads.
|
||||
|
||||
Windows x64 development validation on 2026-09-28 compared the existing 0.15.2
|
||||
portable directory (excluding `data`) with a fresh unpacked test build:
|
||||
`app.asar` decreased from 609.4 to 350.5 MiB, and DS Web decreased from 28,701
|
||||
files / 463.4 MiB to 14,984 files / 357.0 MiB. Total measured size decreased from
|
||||
1,758.3 to 1,438.4 MiB despite the test build retaining 45.6 MiB more Electron
|
||||
locales than the portable build. These are unpacked sizes, not installer download
|
||||
sizes. Builder-copy regression tests, production build/type checks, packaged
|
||||
Harness/OpenCode/npm smoke checks, DS Web authentication/page scripts/workspace
|
||||
creation, and packaged App/settings/notes-page loading passed. Full editor/canvas
|
||||
interaction and other platform packages were not revalidated in this check.
|
||||
|
||||
以上 `dist*` 与 `portable` 命令用于普通本地桌面打包。它们会携带
|
||||
`agent-runtime-lock.json`、`remote-runtime-lock.json` 与公开的
|
||||
`agent-release-keys.json`,但所有桌面构建路径(包括本地 `portable`)都不会嵌入
|
||||
|
||||
+160
-33
@@ -57,14 +57,16 @@ records are listed separately and do not introduce another feature status.
|
||||
- [x] **Optional desktop task notifications**: A default-on switch under
|
||||
Platform Features / General immediately controls system task reminders.
|
||||
In-app notifications remain available independently; General settings group
|
||||
shortcuts, conversations and notifications, and workspace and downloads.
|
||||
shortcuts, conversations and notifications, and the working directory.
|
||||
- [x] **On-demand contextual help**: Supplementary page and settings
|
||||
explanations use shared title- or field-adjacent help controls with mouse
|
||||
and keyboard access. Important operation consequences and errors remain visible.
|
||||
- [x] **Compact conversation controls**: Composer options share a compact
|
||||
settings panel while Runtime, mode, sending, and queue controls remain
|
||||
available. Conversation search has an inline clear action that restores the
|
||||
scoped list and returns focus to the input.
|
||||
scoped list and returns focus to the input. New conversation and expandable
|
||||
search share one row. Composer shortcut hints reflect successful registration
|
||||
and saved settings, without being overwritten by late startup responses.
|
||||
- [x] **On-demand conversation history**: Lists load lightweight summaries
|
||||
rather than every conversation's process metadata. Opened and active
|
||||
conversations retain full details; search, copy, export, continued requests,
|
||||
@@ -141,15 +143,18 @@ records are listed separately and do not introduce another feature status.
|
||||
models, approval policy, budgets, and timeouts in a Project, with reusable
|
||||
templates.
|
||||
- [x] **Application center and navigation**:
|
||||
Clicking the bottom App Center opens a lightweight upward anchored popup with enabled apps,
|
||||
The footer shows equal-height Apps and Settings buttons with visible labels and a subtle vertical divider.
|
||||
Clicking Apps, marked with an upward chevron, opens a lightweight upward anchored popup with enabled apps,
|
||||
regardless of pinning or opening history, and no modal backdrop. Clicking an app row closes
|
||||
the popup; Local Inference Monitor opens a separate modal preserving the workspace, while other apps
|
||||
open their main content pages. Manage Apps opens searchable cards with settings details;
|
||||
open their main content pages. Manage Apps uses a LayoutGrid icon and opens Application Center with searchable cards and settings details;
|
||||
layout and interaction rules are defined in the [UI design](./docs/features/application-tool-navigation/ui-design.md).
|
||||
All four application cards support arrow and drag reordering regardless of enablement or pinning.
|
||||
All five application rows support arrow and drag reordering regardless of enablement or pinning.
|
||||
Cards, the launcher, and the filtered sidebar share one persisted order. Knowledge
|
||||
is always enabled and pinned, with Open and reorder controls. Supervisor, Notes,
|
||||
and Local Inference Monitor have enablement and pinning settings. Supervisor uses
|
||||
and Local Inference Monitor have enablement and pinning settings. Device Sharing
|
||||
is optional and off by default during testing, with an enable switch but no pinning.
|
||||
Supervisor uses
|
||||
the existing Heartbeat identity and defaults to off when no preference is saved;
|
||||
explicit saved choices are preserved. Disabling it retains plans and history,
|
||||
blocks new reviews, and hides sidebar feedback; in-flight work may finish.
|
||||
@@ -168,6 +173,16 @@ records are listed separately and do not introduce another feature status.
|
||||
local-engine checks, and remaining platform/package coverage. Historical
|
||||
failures are not the current full-suite result. See the
|
||||
[validation progress](./docs/features/application-tool-navigation/progress.md).
|
||||
- [x] **Local device-sharing metadata** (implemented in source): Enable Device Sharing
|
||||
in Application Center first; it is off by default during testing. Saved explicit
|
||||
enablement is preserved. Disabling leaves the page without clearing catalog records
|
||||
or revoking publications. Open it from Apps or Application Center, save a service URL and device name, register the
|
||||
device, browse returned records, publish capability or knowledge metadata, and
|
||||
revoke this device ID's entries. The default service is `http://127.0.0.1:8787`.
|
||||
This unauthenticated local service uses IDs for identification only. Server source
|
||||
mode does not upload or sync content, and independent knowledge search/read/download
|
||||
flags are declarations, not content access or remote execution. See
|
||||
[scope and validation](./docs/features/device-sharing/README.md).
|
||||
- [ ] **Privately deployable application marketplace** (planned): Extends
|
||||
application management with an organization-owned catalog and application
|
||||
distribution. Current management covers built-in apps only, not marketplace
|
||||
@@ -330,7 +345,7 @@ records are listed separately and do not introduce another feature status.
|
||||
failure, Agent `SIGKILL`/restart, and recovery from a reopened Desktop SQLite
|
||||
database. Successful tool START/END events appear exactly once, with no
|
||||
Prompt, provider, or tool replay observed. The current Agent source lock is
|
||||
`0.15.0`, paired with the current Desktop release candidate `0.15.2`; formal
|
||||
`0.15.3`, paired with the current Desktop release candidate `0.15.10`; formal
|
||||
publication status follows the separate Agent and Desktop
|
||||
release channels. Previous macOS validation covered native package installation,
|
||||
detached lifecycle, Attach, real Ask/Execute, and cancellation of tools in
|
||||
@@ -443,6 +458,8 @@ records are listed separately and do not introduce another feature status.
|
||||
waiting for another approval. See the [interaction boundaries](./docs/features/assistant-workbar/runtime-interactions.md).
|
||||
- [x] **Native execution checklists**: OpenCode and Continue update a
|
||||
read-only checklist above the conversation, with progress saved in history.
|
||||
Details expand in an anchored, scrollable floating panel without shifting chat
|
||||
content; outside clicks close it and Escape restores focus to its trigger.
|
||||
Explicit clears and remote replay retain request ownership; cancellation
|
||||
does not mark unfinished items complete. Remote delivery requires the matching
|
||||
Agent package. See the
|
||||
@@ -467,10 +484,19 @@ records are listed separately and do not introduce another feature status.
|
||||
direct models report their own backoff phase. Footer dots are static, and
|
||||
unsupported Runtime retry details are not invented. Conversation-list
|
||||
indicators and send/stop behavior remain unchanged.
|
||||
- [x] **Simplified Runtime selection**: Conversation and project menus no
|
||||
longer enumerate every Runtime/model combination. Configure Runtime models
|
||||
in system settings; saved fixed project selections remain usable and
|
||||
explicitly display their fixed model.
|
||||
Cancellation and failure retain existing replies and use a single status;
|
||||
cancellation is neutral, and Edit again appears only when input can be restored.
|
||||
Model-visible history preserves interrupted partial replies or placeholders
|
||||
so cancelled questions are not merged into later requests. Reused local
|
||||
OpenCode sessions receive a one-time interruption notice.
|
||||
- [x] **Layered Runtime and model selection**: Resolve Runtime and model per
|
||||
field through conversation, project, and global defaults. The two-column
|
||||
composer picker shows sources and supports overrides or restoring project
|
||||
defaults; project and channel settings share the same rules. Main resolves
|
||||
each request again, including queued dispatch. Deleted or incompatible
|
||||
connections fall back, while missing credentials remain explicit errors.
|
||||
Schema 49 migrates stored choices. Purpose-specific models remain planned;
|
||||
see the [selection design](./docs/features/model-connections/runtime-selection-design.md).
|
||||
- [x] **Role-bound model connections**: Each role can inherit the default model
|
||||
or select an independent text-model connection. Invalid connections safely
|
||||
fall back to the default; the synthesis role always inherits the default.
|
||||
@@ -495,7 +521,12 @@ records are listed separately and do not introduce another feature status.
|
||||
fields take precedence, and these ordinary connection settings must not be
|
||||
used to store API keys or other secrets. See
|
||||
[model request customization](./docs/features/model-connections/README.md).
|
||||
- [x] **Context usage and automatic compaction**: Direct models update usage
|
||||
- [x] **Context usage and compaction**: Local direct text-model conversations
|
||||
also support manual compaction below the automatic threshold or with automatic
|
||||
compaction disabled. The selected summary model may incur charges; recent turns
|
||||
and full stored history remain available, and saved summaries are reused.
|
||||
Image connections and remote direct-model entry points are excluded.
|
||||
Direct models update usage
|
||||
from each successful provider call. Images and tool rounds use the same
|
||||
accounting, with estimation only when the provider omits usage. The UI
|
||||
distinguishes This Model Call from Post-Compaction Conversation Estimate,
|
||||
@@ -689,7 +720,9 @@ records are listed separately and do not introduce another feature status.
|
||||
records without replacing editors. Successful saves establish the editor's
|
||||
normalized baseline; only unsaved changes require confirmation on leaving. Opening
|
||||
a note shows a blank text/canvas composer above the history, and creation resets it
|
||||
for the next record. Explicit editing updates a saved record. The separate To-dos view offers search, status filters,
|
||||
for the next record. Explicit editing updates a saved record and exits after a
|
||||
successful save; automatic comments do not reopen the editor, and failed saves
|
||||
retain drafts. The separate To-dos view offers search, status filters,
|
||||
grouped tasks and direct completion, with instructions, source entries and AI
|
||||
comments in a right detail pane beside a stable task list. Narrow views offer
|
||||
on-demand detail with an explicit return to the list. Wide views support a
|
||||
@@ -757,7 +790,11 @@ records are listed separately and do not introduce another feature status.
|
||||
model and normalizes the different OpenAI-compatible and Anthropic Messages
|
||||
cache-reporting semantics when showing cache hit rate. Project and conversation
|
||||
usage groups show collapsed totals by default and expand to Runtime/model child
|
||||
rows; model grouping remains flat. Activity is grouped
|
||||
rows; model grouping remains flat. A separate System tasks tab groups
|
||||
background usage by source, with manual refresh and updates when usage is
|
||||
recorded. Supervisor reviews, image regeneration, graph extraction, and remote
|
||||
embeddings feed usage records; missing historical/provider data is not reconstructed.
|
||||
Activity is grouped
|
||||
by conversation and collapsed by default so long histories do not fill the
|
||||
page.
|
||||
- [x] **Compact conversation tool records**: Expand individual tool records
|
||||
@@ -789,16 +826,28 @@ records are listed separately and do not introduce another feature status.
|
||||
[Scheduled Task PRD](./docs/features/task-and-job/scheduled-task-prd.md).
|
||||
Status counts and filters identify running tasks and those needing attention;
|
||||
task approvals remain actionable in their task cards.
|
||||
- [x] **Memory and Smart Heartbeat**: Provides periodic review, suggested
|
||||
memories, insights, follow-up tasks, and auditable run history.
|
||||
- [x] **Automatic supervision settings and scope**: Supervisor / Automatic supervision is
|
||||
the sole authoritative plan configuration, automatic report, and suggestion entry. Daily or weekly plans can be
|
||||
created and edited for Global or one or more selected Projects. Legacy
|
||||
single-project settings migrate without loss, and project-level memory and
|
||||
action output must explicitly target a Project in scope. Task Center and
|
||||
Settings no longer duplicate the form. Partition-aware review, candidate
|
||||
generation, and recall triggers remain to be designed. See the
|
||||
[Smart Heartbeat PRD](./docs/features/smart-heartbeat/prd.md).
|
||||
- [x] **Memory and Smart Heartbeat**: Plans trigger shared incremental reviews
|
||||
instead of separate heartbeat reports. Choose memory/graph updates only or
|
||||
evidence-backed suggestions for open items, disagreements, revisions, and
|
||||
candidate conventions. Accepted open items create paused tasks; confirmed
|
||||
conventions become long-term background. Suggestion failures can be retried
|
||||
independently without undoing reviews.
|
||||
- [x] **Smart heartbeat settings and scope**: Supervisor / Smart heartbeat
|
||||
manages daily or weekly Global or selected-Project plans and suggestions.
|
||||
Task Center and Settings do not duplicate this form. Pending suggestions are
|
||||
not repeated; dismissed suggestions can return with changed evidence.
|
||||
Project/global processing progress share one source-version timeline; real-time conversation
|
||||
intervention remains planned. See the
|
||||
[implemented rules](./docs/features/conversation-supervision/logic-design.md).
|
||||
- [x] **Stories, experiences, and time-spiral navigation**: Reviews organize
|
||||
events into project features, threads, and optional cross-project stories.
|
||||
Users can rename, merge, reassign, remove, and undo adjustments. Experiences
|
||||
retain formation/application evidence and human-edit markers; heartbeat
|
||||
suggestions can identify stalled stories or potentially useful experiences.
|
||||
Flat and lazy-loaded WebGL spiral views share selection and sources, with
|
||||
a flat-view fallback and graphics cleanup. Organization and suggestions may
|
||||
incur model costs and require source verification. See the
|
||||
[implementation evidence](./docs/features/conversation-supervision/progress.md).
|
||||
- [x] **Supervisor reviews, story graphs, and activity**: Review a selected scope
|
||||
and period, read dated supervision results and history in Work review, inspect saved result graphs and sources, confirm or revise entities,
|
||||
and continue a discussion with source-linked review context. Sidebar feedback follows or pins a
|
||||
@@ -807,8 +856,10 @@ records are listed separately and do not introduce another feature status.
|
||||
focus in narrow sidebars. Activity combines heartbeat
|
||||
and downstream review stages, including failures and links to older results.
|
||||
Automatic stages process only new, changed, or unprocessed source portions;
|
||||
no-change checks skip model calls. Manual review pages through the selected history
|
||||
and saves batches with their facts and sources. Runs can pause and resume from
|
||||
no-change checks skip model calls. Manual review is also incremental by default;
|
||||
explicit reanalysis rereads the period without changing shared progress.
|
||||
Automatic reviews do not resume user-paused runs. Reviews save batches with
|
||||
their facts and sources. Runs can pause and resume from
|
||||
saved progress, without fixed-duration automatic pauses; settings expose
|
||||
concurrency, per-request timeouts, batch sizes, and response capacity.
|
||||
Activity emphasizes connected stages and saved-batch coverage, with secondary
|
||||
@@ -823,7 +874,8 @@ records are listed separately and do not introduce another feature status.
|
||||
Continue discussion opens directly from feedback or graph sources, with an
|
||||
editable question and explicit target-conversation navigation. The UI no
|
||||
longer exposes knowledge-entity writeback controls or redundant context
|
||||
explanations, and Automatic supervision hides empty metrics until activity exists.
|
||||
explanations. Smart heartbeat shows actionable suggestions rather than report
|
||||
metrics and trends.
|
||||
- [ ] **Further supervision capabilities** (planned): Full event-by-event replay,
|
||||
Experiment targets, chronological manual-edit audits, event
|
||||
triggers, and broader execution observation remain incomplete. Current automatic
|
||||
@@ -833,12 +885,15 @@ records are listed separately and do not introduce another feature status.
|
||||
- [ ] **Batch runs and comparison lab** (planned): Compares model, Prompt,
|
||||
role, and workflow configurations in batches and summarizes quality,
|
||||
duration, tokens, cost, failure rate, and artifact differences.
|
||||
- **Story Graph read tools (unreleased source)**: Agents can search saved work,
|
||||
- **Story Graph read tools (Desktop 0.15.9 / Agent 0.15.3)**: Agents can search saved work,
|
||||
read current claims or timelines, and page through source evidence. Supervisor
|
||||
enablement and Runtime assignment control discovery and calls, including old
|
||||
sessions. Model, local Harness, and local/remote OpenCode and Continue share
|
||||
the desktop reader. Queries make no model calls and do not infer replacement
|
||||
from newer reviews. Cross-scope fact reuse and `as_of` remain deferred; see the
|
||||
from newer reviews. Shared source-version facts, stories, and experiences are
|
||||
available across permitted scopes; `as_of` remains deferred. Keyword matching
|
||||
and tool guidance support model-selected lookups without automatic full-graph
|
||||
context injection; experiences remain advice, not instructions. See the
|
||||
[design and validation](./docs/features/conversation-supervision/story-graph-mcp-design.md).
|
||||
- [ ] **Temporal memory and fact-conflict detection** (planned): Adds validity
|
||||
periods, current/expired/conflicting fact detection, fact checking, and
|
||||
@@ -889,9 +944,12 @@ records are listed separately and do not introduce another feature status.
|
||||
- [x] **WeCom and DingTalk connections**: Main-only encrypted settings,
|
||||
read-only environment-variable overrides, connection tests, dynamic
|
||||
enable/disable, sender scope, and status diagnostics.
|
||||
- [x] **Managed local-model download source**: Platform Features / General
|
||||
selects ModelScope (default) or Hugging Face globally for future speech-input
|
||||
and OCR model downloads. A source without a complete verified file is
|
||||
- [x] **Managed local-model download source**: The Model Connections header shows
|
||||
the current source and a secondary button, right-aligned on the same row as the
|
||||
model-type tabs below Save. Its dialog selects ModelScope (default) or Hugging Face
|
||||
globally for future managed local-model downloads; changes save only on Confirm,
|
||||
Cancel leaves the saved source unchanged, and errors allow retry. Embedding, speech,
|
||||
and OCR source links open this dialog directly. A source without a complete verified file is
|
||||
explicitly unavailable and never silently falls back or combines files.
|
||||
- [x] **Optional local speech-model management**: Model weights are not bundled.
|
||||
Provides verified downloads, progress and cancellation, source links, ZIP or
|
||||
@@ -920,9 +978,78 @@ records are listed separately and do not introduce another feature status.
|
||||
|
||||
### Open source, builds, and releases
|
||||
|
||||
- The current Desktop candidate is `0.15.2` and the Agent candidate is `0.15.0`, with
|
||||
- The current Desktop candidate is `0.15.10`; published Agent `0.15.3` requires Desktop `0.15.9`, with
|
||||
OpenCode pinned to `1.18.29` and Continue to `1.5.47`. Publication status follows the independent
|
||||
Desktop and Agent release channels.
|
||||
- Desktop `0.15.10` unifies project/conversation navigation with ten recent
|
||||
conversations, adds independent Supervisor model selection and a default-on
|
||||
per-conversation Story Graph switch. Indexed activity paging and complete
|
||||
summaries, document Workers, lazy startup, narrower render subscriptions,
|
||||
windowed lists, terminal batching and old Runtime cache cleanup reduce repeated
|
||||
work without promising universal speedups. Schema 58 requires a pre-upgrade
|
||||
database and notes backup. Frequent local writes use NORMAL and can lose a
|
||||
recent suffix on power/system failure; remote acknowledgments, channel delivery
|
||||
and task lifecycle writes retain FULL. Agent stays at 0.15.3. Four real Host
|
||||
model requests and a 1,000-line Unicode SSH terminal check passed; exact-source
|
||||
CI and six native packages remain release gates, and reported remote resume
|
||||
failures are not claimed fixed. See the
|
||||
[preparation record](./docs/development/release-preparation-0.15.10.md).
|
||||
- Desktop `0.15.9` adds shared review progress, stories, experiences, and spiral
|
||||
graph navigation, with schema 56 backup/rollback requirements and possible
|
||||
model costs. Conversation/message windowing, Markdown caching, read-only
|
||||
Workers, incremental activity writes, and checkpoint event batches reduce
|
||||
repeated work without promising universal speedups. In-page find excludes
|
||||
unmounted rows, rapid scrolling can show placeholders, and unavailable Workers
|
||||
can fall back to synchronous reads. Execute configuration changes no longer
|
||||
require duplicate native confirmation. Electron is 44.5.1. Four current-source
|
||||
real Host model requests passed; exact-candidate CI and packaging remain required.
|
||||
See the [preparation record](./docs/development/release-preparation-0.15.9.md).
|
||||
- Desktop `0.15.8` and Agent `0.15.2` add Execute-only saving of conversation
|
||||
images to absolute local or Host paths, with explicit overwrite and no WebP
|
||||
conversion. MCP settings list image tools without promising availability in
|
||||
every mode. Notes gain new-entry cancellation, edit-focus restoration and full
|
||||
available content width. Missing browser and frosted-appearance preferences
|
||||
default to enabled; saved choices remain unchanged. Schema stays at 52.
|
||||
Real Linux x64 Host tool-layer evidence and remaining exact-candidate CI and
|
||||
package checks are separated in the
|
||||
[preparation record](./docs/development/release-preparation-0.15.8.md).
|
||||
- Desktop `0.15.7` reuses completed streaming Markdown blocks and conversation
|
||||
derived values without changing the default streaming cadence. Activity records
|
||||
load on entry, refresh every 15 seconds while visible, and offer manual refresh.
|
||||
Local custom MCP discovery failures are isolated per service. Managed model
|
||||
source selection moves to a confirmation dialog in Model Connections, and Device
|
||||
Sharing becomes an opt-in test feature. Schema remains 52. Exact-candidate CI
|
||||
and packaging are still required; component benchmarks are not full-App results.
|
||||
See the [preparation record](./docs/development/release-preparation-0.15.7.md).
|
||||
- Desktop `0.15.6` adds optional, persisted frosted-glass header/checklist surfaces
|
||||
(off by default, without OS desktop transparency), reduces repeated chat renders,
|
||||
and supports direct previews for draft/queued images. Settings are grouped with
|
||||
right-aligned switches, and note headers/editor spacing are tighter.
|
||||
Local OpenCode retries explicit transient MCP transport failures once after
|
||||
cleanup and retires uncertain names; it does not replay prompts or tool calls.
|
||||
Redacted MCP diagnostics support feedback. Schema 52 adds execution-statistics
|
||||
indexes; back up the database and notes before upgrading.
|
||||
See the [preparation record](./docs/development/release-preparation-0.15.6.md).
|
||||
- Desktop `0.15.5` updates Smart heartbeat and incremental review, preserves
|
||||
cancelled-turn history, floats checklist details, and exits note editing after
|
||||
saving. It also adds manual note tags with AND filtering and rename/merge/delete,
|
||||
a persistent two-pane note layout with a full-width overview, sticky workspace
|
||||
file controls, cached OpenCode terminal dependencies, and shorter MCP names.
|
||||
Schema 51 adds tag storage alongside the migration of pending legacy memory
|
||||
proposals into unconfirmed conventions; back up the database and adjacent notes before upgrading.
|
||||
See the [preparation record](./docs/development/release-preparation-0.15.5.md).
|
||||
- Desktop `0.15.4` introduces layered Runtime/model choices and separate
|
||||
system-task usage, and reduces duplicate Runtime instructions and history.
|
||||
Schema 49 migrates project/conversation choices; rollback requires the complete
|
||||
pre-upgrade database and adjacent notes backup. Agent `0.15.1` requires Desktop
|
||||
`0.15.4` and fixes remote Continue history wrapping without changing locked
|
||||
Runtime versions. See the [0.15.4 preparation record](./docs/development/release-preparation-0.15.4.md).
|
||||
- Desktop `0.15.3` restores local direct-model manual compaction, retains tool
|
||||
authorization for the request lifetime, clarifies cancellation and sidebar/note
|
||||
controls, avoids redundant startup/storage work, and trims duplicated package
|
||||
dependencies and development files. It does not claim a proven overall rendering
|
||||
speedup. Office live editing remains a design, not a delivered capability.
|
||||
See the [0.15.3 preparation record](./docs/development/release-preparation-0.15.3.md).
|
||||
- Desktop `0.15.1` carries forward the unpublished `0.15.0` Obsidian integration, conversation capture into Magic
|
||||
Notes, and preview local native clients, plus knowledge, Supervisor, and UI
|
||||
fixes. Schema 48 adds conversation-source metadata; older clients require a
|
||||
|
||||
+25
-13
@@ -23,7 +23,7 @@
|
||||
后台会话成功完成后,在本次客户端运行期间保留完成提示,直到对应聊天可见且加载完成;提示不跨重启保留。
|
||||
- [x] **会话置顶与完整历史**:常用会话可置顶,重启后保留。列表和搜索不再限于最近 100 个会话,连续发送不再截断超过 500 条的已保存历史;模型上下文限制仍有效,已丢失的历史无法恢复。
|
||||
置顶与非置顶分组按最近一条消息的创建时间排序,流式文本和工具进度更新不再反复改变位置。
|
||||
- [x] **可选桌面任务通知**:“平台功能 / 通用设置”提供默认开启、保存后立即生效的系统任务提醒开关,应用内提示独立保留;通用设置按快捷键、会话与通知、工作目录与下载分组。
|
||||
- [x] **可选桌面任务通知**:“平台功能 / 通用设置”提供默认开启、保存后立即生效的系统任务提醒开关,应用内提示独立保留;通用设置按快捷键、会话与通知、工作目录分组。
|
||||
- [x] **按需上下文帮助**:页面和设置中的补充说明使用标题或字段旁的共享帮助入口,支持鼠标与键盘;关键操作后果和错误提示保持可见。
|
||||
- [x] **紧凑会话控件**:输入选项集中在紧凑设置面板,Runtime、工作模式、发送和队列操作保持可用;会话搜索提供内嵌清除按钮,清除后恢复当前范围列表并把焦点返回输入框。
|
||||
- [x] **会话历史按需加载**:列表只加载轻量摘要,不再一次读入全部会话的过程元数据;已打开和活动会话保留完整详情,搜索、复制、导出、续聊和远程待答问题保持可用。图片合并不会重新发送未变化消息,已确认保存的空闲详情随现有视图缓存释放。详见[历史读取与保留规则](./docs/features/assistant-workbar/execution-history-storage.md#会话列表读取与前端保留)。
|
||||
@@ -36,7 +36,8 @@
|
||||
- [x] **AI 回复与完整会话复制**:已完成的 AI 回复可从消息底部复制不含推理、工具日志和引用元数据的 Markdown 原文;完整会话复制复用同一条经过 Preload/Main 校验的剪贴板路径。
|
||||
- [x] **助手工作栏、多终端与可调布局**:工作栏开关位于明暗主题按钮旁;右侧工作栏使用持久“+”能力目录和应用 Tab,任务中心与工作区为不可关闭的单实例,浏览器可打开多个独立实例。任务中心可切换当前项目和所有项目,范围随布局保存;无项目任务在所有项目中标注“未绑定项目”。用户可为当前本机或托管 SSH 项目打开多个独立终端,获得有界输出、调整尺寸、结束与显式重连。关闭终端 Tab 会结束 Shell,应用重启只恢复已结束的 Tab 描述,不自动重启 Shell。主侧栏和魔法笔记编辑器的 AI 评论栏支持鼠标与键盘调宽;AI 评论栏记住显示状态及宽度,窄窗口下移至编辑器底部。
|
||||
- [ ] **项目 Agent Space**(规划中):在 Project 中统一角色、知识、Skills/MCP、模型、审批策略、预算和超时,并支持模板复用。
|
||||
- [x] **应用中心与导航**:底部应用中心向上展开轻量锚定菜单,无模态遮罩,只列已启用应用,不按打开历史或常驻筛选。点击应用行关闭菜单,本机推理监控打开保留底层工作区的独立 Modal,其余应用打开主内容区;底部“管理应用”打开可搜索卡片及设置详情,布局与交互见 [UI 设计](./docs/features/application-tool-navigation/ui-design.md)。四张应用卡片均支持上下移动和拖动排序,不受启用或常驻状态限制;卡片、菜单和经过过滤的侧栏共用一份保存顺序。知识库始终启用并常驻,提供“打开”和排序;监督者、魔法笔记和本机推理监控可设置启用与常驻。监督者沿用智能心跳身份,缺失启用设置时默认关闭,已明确保存的选择保留;关闭后保留计划和历史,拒绝新回顾并隐藏侧栏反馈,在途运行可完成。启用应用不会自动创建计划。旧应用顺序补齐默认项并保留已有相对顺序。设置持久化后的事件同步各入口,包括工具配置写入;重开中心刷新期间锁定修改,新快照覆盖迟到的旧读取。`local-inference` Modal 直接显示服务列表,不展示任务历史或外部连接;向量服务显示独立进程 CPU 与工作集内存,ASR/OCR 说明共享进程无法独立统计的原因。ASR 就绪状态未知,不从活动请求推断运行中。受支持的服务操作保留影响确认,失败后刷新列表并要求重新确认;TTS 显示不可用。取消中的推理在 Worker 确认或退出前仍计入活动任务及服务停止影响。实现已接入生产 App;验收记录分别列出完整 App、组件 fixture、真实本地引擎和剩余平台/安装包覆盖,不以历史失败代替当前全量结果,详见[验证进度](./docs/features/application-tool-navigation/progress.md)。
|
||||
- [x] **应用中心与导航**:底部“应用”和“设置”为显示文字的等高按钮,中间使用弱竖分隔线。“应用”带向上箭头,点击后向上展开轻量锚定菜单,无模态遮罩,只列已启用应用,不按打开历史或常驻筛选。点击应用行关闭菜单,本机推理监控打开保留底层工作区的独立 Modal,其余应用打开主内容区;底部“管理应用”使用 `LayoutGrid` 图标,打开标题仍为“应用中心”的可搜索列表及设置详情,布局与交互见 [UI 设计](./docs/features/application-tool-navigation/ui-design.md)。五个应用行均支持上下移动和拖动排序,不受启用或常驻状态限制;列表、菜单和经过过滤的侧栏共用一份保存顺序。知识库始终启用并常驻,提供“打开”和排序;监督者、魔法笔记和本机推理监控可设置启用与常驻。设备共享在测试阶段默认关闭,提供启用开关,不提供常驻开关。监督者沿用智能心跳身份,缺失启用设置时默认关闭,已明确保存的选择保留;关闭后保留计划和历史,拒绝新回顾并隐藏侧栏反馈,在途运行可完成。启用应用不会自动创建计划。旧应用顺序补齐默认项并保留已有相对顺序。设置持久化后的事件同步各入口,包括工具配置写入;重开中心刷新期间锁定修改,新快照覆盖迟到的旧读取。`local-inference` Modal 直接显示服务列表,不展示任务历史或外部连接;向量服务显示独立进程 CPU 与工作集内存,ASR/OCR 说明共享进程无法独立统计的原因。ASR 就绪状态未知,不从活动请求推断运行中。受支持的服务操作保留影响确认,失败后刷新列表并要求重新确认;TTS 显示不可用。取消中的推理在 Worker 确认或退出前仍计入活动任务及服务停止影响。实现已接入生产 App;验收记录分别列出完整 App、组件 fixture、真实本地引擎和剩余平台/安装包覆盖,不以历史失败代替当前全量结果,详见[验证进度](./docs/features/application-tool-navigation/progress.md)。
|
||||
- [x] **本地设备共享元数据**(源码已实现):测试阶段默认关闭,先在应用中心启用,再从应用菜单或应用中心打开;已明确保存的启用选择保留,关闭会退出页面,但不清除目录或撤销已发布记录。可保存服务地址和设备名称、注册本机、浏览服务返回的目录、发布能力或知识元数据并撤销本机 ID 的条目。默认服务为 `http://127.0.0.1:8787`。服务未认证,ID 只用于识别;服务器来源不上传或同步内容,知识搜索/读取/下载标志是独立声明,不提供正文访问或远程执行。详见[范围与验证](./docs/features/device-sharing/README.md)。
|
||||
- [ ] **私有化应用市场**(规划中):在应用管理基础上提供组织自己的目录与应用分发。当前只管理内置应用,尚无市场浏览或安装能力,详见 [FR-15](./docs/features/application-tool-navigation/prd.md#fr-15-私有化市场与-yaml-交换后续)。
|
||||
- [ ] **通用助手工作栏与执行空间后续能力**(规划中):在现有工作栏和多终端基础上继续加入更完整的执行监督、统一 Runtime 监控、受管进程、可固定目标的工作区/浏览器/成果实例、底部停靠与独立窗口。Task Center 保持 Task 的单例索引,附件与知识库继续由会话输入区管理,未来的记忆与历史执行上下文归入关联 Task。详见 [Feature PRD](./docs/features/assistant-workbar/prd.md)。
|
||||
- [ ] **ShareServer Office 协同编辑**(设计中):通过可选 ShareServer 集成 ONLYOFFICE Docs,在助手工作栏中以文件名打开多个文档 Tab,支持 DOCX、XLSX、PPTX 的人工编辑、保存、撤销、源文件冲突保护和后续 AI 选区修改。Office 正文会由所选 ShareServer 和编辑引擎处理;Desktop 不内置或启动 Document Server,未配置服务时不宣称离线编辑。详见[设计文档](./docs/features/office-document-editing/README.md)。
|
||||
@@ -52,7 +53,7 @@
|
||||
- [x] **高效直连模型工作区工具**:直连模型使用随包 ripgrep 的原生参数发现文件和搜索内容,按行分页读取大型 UTF-8 文件,并在 Execute 中应用多文件补丁。Ask 限于工作区只读搜索和读取,Execute 按当前账号权限搜索;用户无需另行安装 ripgrep。搜索保留原生输出与退出码,大结果通过 `output_read` 完整续读,可处理的参数错误允许模型调整后继续。
|
||||
- [x] **长上下文与工具输出分页**:历史消息、长回复和文档解析文本不再采用旧的固定截断限制。直连模型可分页续读已保存的搜索、命令和 Subagent 输出;所选模型的上下文窗口及传输边界仍然有效。
|
||||
- [x] **OpenCode 与 Continue**:使用隔离子进程、环境变量白名单、统一配置、取消、启动与控制请求时限、有界流式输出和活动记录;共享进程回收逻辑保留 Windows 完整进程树终止,并对采用独立进程组的 POSIX 子进程执行组回收。聊天状态检查使用一次性探测 Runtime 并在返回后立即回收,不进入按模型与项目隔离的执行 Runtime 缓存;本机 GoodBuddy 管理的 OpenCode 在该被动检查中只确认所选路径和模型凭据,不启动用后即毁的 Server,设置中的显式连接测试和原生能力清单仍执行完整启动及健康检查,原生能力清单随后与首次实际请求复用同一个执行 Runtime,不再重复冷启动;执行 Runtime 仍按项目复用,但 OpenCode 配置依赖和按内容摘要生成的 Skill 快照在所有本机项目间全局复用,会话数据、工具输出及请求级 MCP 状态继续隔离。本机 GoodBuddy 管理的 OpenCode 只对同一会话内的请求保持顺序,不同会话即使属于同一项目也可并行,不同项目同样可并行;请求级动态 MCP 通过默认通配禁用和当前请求精确放行保持隔离。托管的本机和远端 OpenCode 跳过界面未使用的自动 Git 快照,避免同步差异计算拖住会话,文件工具、子代理与工作区 Git 差异保留;Continue 持续排空不用展示的宿主 stdout,防止控制台输出填满管道。这些调整随新启动的 Runtime 生效,不改写外部 OpenCode Server 配置。OpenCode 与 Continue 不再按固定工具调用数或活动数中止单次运行,已观察到的工具与 Subagent 活动全部保留在本地会话,不再在原生任务继续执行时丢弃后续详情。交互提问只由前台对话回答,定时任务、远程通道和委派等后台执行遇到提问时会立即失败并提示改为前台运行,避免无限等待。本机 OpenCode 启动时直接使用已选择的绝对路径,不预先检查文件或运行 `--version`,路径失效时由实际启动返回错误;托管 Linux ARM Host 激活 Runtime 时复用准备阶段已验证的 registry 和 manifest,不重新哈希或检查完整 OpenCode 二进制。
|
||||
- [x] **托管 SSH OpenCode 与 Continue**:该能力由“设置 > 平台功能”中的独立“远程项目”页签控制并默认关闭;关闭时不影响本地项目、普通桌面功能或桌面发布。支持 Linux x64、Linux arm64 和 macOS arm64 Host,不支持 Intel Mac;通过 SSH 按需启动 detached Agent,不要求开机服务。Ask 在 Runtime 工具边界保持只读,Execute 使用所选 SSH 账号的完整权限。Agent 持有 Prompt、模型和工具轮次、Runtime 进程及语义 transcript,Desktop 断开后重新 Attach 原操作而不重发 Prompt;模型凭据仅在当前操作内存中使用,不写入 Renderer、SSH 参数、环境或磁盘。当前 Agent 可以常驻,更新替换的旧 Agent 排空活动任务后退出。任务完成后回收空闲 Runtime,共享进程仍有任务或待答问题时保留;下一轮重建 Runtime 并恢复对话历史。设置页按平台和架构显示包状态,用户显式下载、更新或导入/导出包含 Agent、固定 Node、OpenCode 和 Continue 的签名 `.gbagent`;三个目标共用一个签名 catalog,继续校验签名和 SHA-256。Continue 需要更新后的联合包与配套 Desktop。首次发布混合 catalog 前需先升级 Desktop,旧版 Linux-only 目录读取器无法解析包含 Darwin 的目录。历史 macOS OpenCode 原生安装、生命周期、Attach、真实 Ask/Execute 和独立进程组工具取消已在真实 Host 验证,不代表 Continue 的同等实机覆盖。当前 Agent 源码锁为 `0.15.0`,配套 Desktop 候选为 `0.15.2`;详细运行边界和 Linux 历史回归证据见[远程主机技术设计](docs/features/remote-host/technical-design.md),正式发布状态以独立发布渠道为准。
|
||||
- [x] **托管 SSH OpenCode 与 Continue**:该能力由“设置 > 平台功能”中的独立“远程项目”页签控制并默认关闭;关闭时不影响本地项目、普通桌面功能或桌面发布。支持 Linux x64、Linux arm64 和 macOS arm64 Host,不支持 Intel Mac;通过 SSH 按需启动 detached Agent,不要求开机服务。Ask 在 Runtime 工具边界保持只读,Execute 使用所选 SSH 账号的完整权限。Agent 持有 Prompt、模型和工具轮次、Runtime 进程及语义 transcript,Desktop 断开后重新 Attach 原操作而不重发 Prompt;模型凭据仅在当前操作内存中使用,不写入 Renderer、SSH 参数、环境或磁盘。当前 Agent 可以常驻,更新替换的旧 Agent 排空活动任务后退出。任务完成后回收空闲 Runtime,共享进程仍有任务或待答问题时保留;下一轮重建 Runtime 并恢复对话历史。设置页按平台和架构显示包状态,用户显式下载、更新或导入/导出包含 Agent、固定 Node、OpenCode 和 Continue 的签名 `.gbagent`;三个目标共用一个签名 catalog,继续校验签名和 SHA-256。Continue 需要更新后的联合包与配套 Desktop。首次发布混合 catalog 前需先升级 Desktop,旧版 Linux-only 目录读取器无法解析包含 Darwin 的目录。历史 macOS OpenCode 原生安装、生命周期、Attach、真实 Ask/Execute 和独立进程组工具取消已在真实 Host 验证,不代表 Continue 的同等实机覆盖。当前 Agent 源码锁为 `0.15.3`,配套 Desktop 候选为 `0.15.10`;详细运行边界和 Linux 历史回归证据见[远程主机技术设计](docs/features/remote-host/technical-design.md),正式发布状态以独立发布渠道为准。
|
||||
- [x] **SSH Host 手动环境准备源码链路**:Host Key、认证和系统探针成功后先保存 Host,并只读探测共享 Agent/Runtime;保存 Host、打开项目都不自动安装。Host 卡片只有一个按版本事实显示“安装远程环境”“更新远程环境”或“重新安装”的主按钮,次级 SegmentedControl 选择默认且不持久化的“自动”、Host 下载或 GoodBuddy 传输;“版本匹配”badge 不等同环境健康。自动模式只在 operation/prepare 前探测并择一,显式选择保持有效,任何 prepare、commit 或 adoption 失败都不跨 acquisition 自动 fallback。两种方式把同一签名 compound `.gbagent` 交付到固定 staging 后,共用 control-plane prepare、commit、Agent activate/health、Runtime activate、finalize 与显式 cleanup。GoodBuddy 路径可在同一次操作下载并验证缺失候选、缓存并取得 lease,再有界流式 SFTP 上传一个归档和其中已验证的 bootstrap Node,不把约 294 MiB 整包读入 Main `Buffer`;Host 在解包时完成一次完整 payload 校验。未完成操作只记录暂存 cleanup 所需的 operation ID;下次更新尽力清理旧暂存后重新 prepare,不保存远端 metadata 副本,也不让 cleanup 失败阻塞新更新或回滚健康环境。已有项目在实际使用 Workspace/Runtime 时按需解析 Host current identity 并执行固定 `attach-or-bootstrap`,注册后的 health、capabilities 和 prompt 启动不扫描完整 payload。详见 [设计说明](./docs/features/remote-host/environment-provisioning-technical-design.md)。
|
||||
- **SSH Host 环境准备验证记录**:当前源码已在隔离 Linux x64 环境通过包安装、Ask/Execute、原生子代理工作区外写入、重连及 stop/bootstrap。现有记录未覆盖 Host 卡片的完整 GitHub、北京镜像、Linux x64/arm64、取消和离线 GoodBuddy 传输矩阵,候选 CI/原生打包与系统休眠唤醒也尚未验证;开发记录不代表版本已经发布。
|
||||
- [x] **远程工作区与长任务更新**:Agent `0.11.23` 新增远程文件/Git 管理和可选模型限额,随包 Runtime 不再施加固定十分钟 Prompt 时限;无限请求时长仍保留独立连接超时。请先升级 Desktop 至 `0.12.11`,再下载并更新 Host 上的 Agent。
|
||||
@@ -63,16 +64,18 @@
|
||||
- **本地 Runtime 原生客户端(预览)**:输入区可按当前本地项目与模型,在工作栏终端打开 Continue/OpenCode,或在系统浏览器打开官方 DS Web。客户端使用独立会话,不导入 GoodBuddy 对话历史;关闭 DS 网页不停止服务,可在 GoodBuddy 中停止。Continue/OpenCode Ask 已通过原生权限映射 Main 绑定的只读工具。远程快捷入口未接通,托管标准 Node 交付和完整跨平台安装包验收仍有缺口;Windows 真实模型证据不代表全部会话能力等价,详见[实现与剩余工作](./docs/features/assistant-workbar/progress.md#2026-09-27-local-native-client-validation)。
|
||||
- **Runtime 遥测关闭配置**:本地 DS Web、远程 Runtime 和模型桥启动共用遥测关闭环境,不限制模型、MCP 或用户主动联网。Continue 在自动更新关闭时跳过检查;远程改动随配套 Agent 新启动的 Runtime 生效,当前源码 Linux x64 Agent/model 验证见 [0.15.0 发布准备](./docs/development/release-preparation-0.15.0.md)。
|
||||
- [x] **Runtime 原生交互转交**:OpenCode 与 Continue 的选择、yes/no、自由文本回答和跳过使用现有问答卡片,本机与托管 SSH OpenCode 同时转交属于当前请求的子会话提问。托管 SSH OpenCode 问答需要 Desktop `0.13.0`、Agent `0.11.24` 和新启动的托管 Runtime,不扩展到任意 ACP 服务。回答或跳过成功后在原位置保留问题与答案,支持多轮记录和本地会话重载;旧版本已丢弃的答案不能恢复。并行问题按顺序等待回答,重复事件保留草稿,提交失败可重试;取消远程待答后可在同一会话继续发送。Execute 权限确认自动处理,不等待第二次审批;具体支持范围见[交互边界](./docs/features/assistant-workbar/runtime-interactions.md)。
|
||||
- [x] **原生执行清单**:OpenCode 和 Continue 在对话顶部更新只读执行清单,进度随会话保存。显式清空和远程重放保留请求归属,取消不把未完成条目标成完成;远程交付需要配套 Agent,详见[清单契约](./docs/features/assistant-workbar/runtime-checklist-technical-design.md)。
|
||||
- [x] **原生执行清单**:OpenCode 和 Continue 在对话顶部更新只读执行清单,进度随会话保存。详情在可滚动的锚定浮层中展开,不挤动聊天内容;点击外部关闭,Escape 关闭后焦点返回入口。显式清空和远程重放保留请求归属,取消不把未完成条目标成完成;远程交付需要配套 Agent,详见[清单契约](./docs/features/assistant-workbar/runtime-checklist-technical-design.md)。
|
||||
- **取消后的历史**:模型可见历史保留已中断的部分回复或占位标记,避免旧问题合入下一轮;本地 OpenCode 复用会话时附带一次中断说明。
|
||||
- [x] **专家与 Subagent**:支持显式专家、团队分析和最多三个专家并行分析。专家继承父请求 Ask/Execute 模式,可使用已启用的本机直连模型工具,Ask 仍只读;这些工具不是远程 OpenCode 子会话。聊天先展示可逐项展开的专家完整输出,再在其下展示总 Agent 的综合结果,并随会话保存。
|
||||
- [x] **OpenCode 子代理过程与最终结果**:子代理卡片按顺序显示文字、推理和工具过程,最终结果独立展示。远程实时过程需要 Agent `0.11.20`,旧包仍显示最终结果但不补造缺失过程。
|
||||
- [x] **OpenCode 事件连接回收**:聊天与原生上下文压缩在结束事件迭代前关闭各自订阅,覆盖完成、失败、取消和消费方提前结束,不取消其他并行会话。
|
||||
- [x] **准确的消息底部状态**:区分请求准备、等待重试、开始重试、工具活动、待回答和终态;本机 OpenCode 使用原生重试次数和计划时间,直连模型显示自身退避阶段。消息底部状态点保持静态,未接入的 Runtime 重试不编造提示,会话列表闪点及发送、停止行为不变。
|
||||
- [x] **直连模型命令工作目录**:Execute 命令可通过绝对路径、相对路径或符号链接使用工作区外目录;工作区仍为默认目录和相对路径基准,Ask 保持只读。
|
||||
- [x] **简化 Runtime 选择**:会话与项目菜单不再枚举每个 Runtime 与模型的组合,Runtime 模型在系统设置中配置;已保存的项目固定模型仍可使用,并明确显示其固定模型。
|
||||
- [x] **Runtime 与模型分层选择**:按会话、项目、全局逐字段解析 Runtime 和模型。会话双栏选择器显示来源,可覆盖或恢复项目默认;项目与通道设置共用规则,Main 在每次请求及排队派发时重新解析。已删除或不兼容的连接会回退,缺少凭据仍明确报错。schema 49 迁移已保存选择,用途模型仍为规划,详见[选择设计](./docs/features/model-connections/runtime-selection-design.md)。
|
||||
- [x] **角色绑定模型连接**:每个角色可继承默认模型或选择独立文本模型连接,失效连接安全回退默认模型,综合角色始终继承默认模型。
|
||||
- [x] **多协议模型配置**:支持 Anthropic Messages、OpenAI Responses、OpenAI Chat Completions、OpenAI Images 和无认证本机模型;新用户默认连接为本机 Ollama 兼容地址,不预置第三方云模型服务。升级时只替换从未配置凭据且仍完全等于旧内置值的历史默认连接,用户显式保存或加密凭据的连接保持不变;已有部署仅使用兼容环境变量提供凭据时继续沿用对应的历史连接参数,通用模型环境变量仍优先。“保存并测试模型”会发送有界的真实文本或图片生成请求并校验生成结果,而不是只检查 HTTP 连通性,因此可能产生少量服务商用量费用。
|
||||
- [x] **模型请求定制**:每个 LLM 连接可填写有界 JSON 自定义请求头和顶层请求体。直连与 Continue 支持两者;本地 OpenCode 和 DeepSeek Harness 只使用各自原生支持的请求头,受管 SSH OpenCode 模型网关支持两者。Runtime、协议和认证字段始终优先,这些普通连接设置不能用于保存 API Key 或其他密钥。详见[模型连接请求定制](./docs/features/model-connections/README.md)。
|
||||
- [x] **本地直连手动压缩**:文本模型会话可在自动压缩关闭或未到阈值时手动生成摘要,保留最近轮次和完整历史,保存的摘要用于后续对话;图像连接和远程直连入口不适用。摘要模型调用可能产生费用。
|
||||
- [x] **上下文用量与自动压缩**:直连模型按每次成功调用更新供应商用量,图片与工具轮次使用同一口径,供应商缺失 usage 时才回退估算;界面明确区分“本次模型调用”和“压缩后对话估算”,压缩线始终根据当前设置与所选模型窗口即时计算,不在每个对话中保存旧配置;压缩标识的前后值使用同一估算口径,运行记录仍保留各次模型调用的供应商 usage。对话与多轮工具 Agent 可在已完成调用越过阈值后自动重复压缩,规划时先为固定提示、工具定义和摘要预留预算;同一回复会分别保留 Agent 工具上下文与对话历史的压缩标识,并在应用重启或较早消息滚出本地历史窗口后继续复用摘要。
|
||||
- [x] **持久凭据保护**:API Key 由 Main 使用系统安全存储加密且不暴露给 Renderer。托管 SSH accepted Prompt 只把当前 profile 与密钥放入 Agent 内存,不写入 SSH 参数、远端环境或磁盘;其他路径仍在 Main 内使用。密钥随对应模型连接保存,修改服务地址或临时切换为无需认证不会要求重新输入;只有用户显式清除凭据或删除连接时才移除。
|
||||
- [x] **有界故障诊断**:Desktop 在用户数据目录中轮转保存启动、Runtime 和远程连接的固定阶段失败,最多 4 个 256 KiB 文件;GoodBuddy Agent 在各 installation 的私有 state 目录中轮转保存 daemon、连接、恢复和 Runtime 生命周期,最多 3 个 64 KiB 文件,并可通过固定 `diagnostics --installation-id` 命令读取。两端都只记录白名单阶段、稳定错误码/类型和固定短消息,不保存 Prompt、凭据、文件内容、路径、环境、SSH 参数或 Provider 原始响应;诊断写入失败不改变正常运行。
|
||||
@@ -115,21 +118,22 @@
|
||||
|
||||
### 工作管理、长期协作与工作流
|
||||
|
||||
- [x] **任务、活动与成果**:集中管理任务状态、审计活动和独立成果文件;普通聊天回复只保留在会话中,不再自动复制到成果栏,已有重复聊天 Markdown 从成果列表隐藏但不物理删除。运行记录改由 Main SQLite 保存,不再受旧 Renderer 的 500 条、单详情 4,000 字符或约 2 MB 上限影响,页面按批继续显示;不再常驻旧缓存截断警告,但不会恢复此前已丢失的历史。Token 用量按 Runtime 与模型归类,并针对 OpenAI 兼容与 Anthropic Messages 的不同上报口径归一化展示缓存命中率;用量按项目或会话分组时默认收起并显示汇总,展开后显示 Runtime / 模型子行,按模型分组时保持平铺;活动按会话分组并默认收起,避免长历史占满页面。
|
||||
- [x] **任务、活动与成果**:集中管理任务状态、审计活动和独立成果文件;普通聊天回复只保留在会话中,不再自动复制到成果栏,已有重复聊天 Markdown 从成果列表隐藏但不物理删除。运行记录改由 Main SQLite 保存,不再受旧 Renderer 的 500 条、单详情 4,000 字符或约 2 MB 上限影响,页面按批继续显示;不再常驻旧缓存截断警告,但不会恢复此前已丢失的历史。Token 用量按 Runtime 与模型归类,并针对 OpenAI 兼容与 Anthropic Messages 的不同上报口径归一化展示缓存命中率;用量按项目或会话分组时默认收起并显示汇总,展开后显示 Runtime / 模型子行,按模型分组时保持平铺。独立“系统任务”页签按来源汇总后台用量,支持手动刷新和记录更新后刷新;监督回顾、图片重新生成、图谱提取和远程嵌入补充用量记录,不补算缺失的历史或供应商数据。活动按会话分组并默认收起,避免长历史占满页面。
|
||||
- [x] **紧凑会话工具记录**:逐项展开工具记录即可查看和复制结果、错误及输入参数;会话与子任务进度使用简洁行,减少重复 Runtime 摘要。OpenCode、Continue、DeepSeek Harness 和直连模型可显示已有文件路径、命令或搜索摘要,并在工具完成和会话重载后保留。
|
||||
- [x] **Task 与定制任务体验**:每个产品级 Task 只关联一条 Conversation,一条 Conversation 可承载多个 Task;左侧会话列表通过行首展开按钮显示带共享状态点的 Task 子项,父会话行不重复任务标签,UI 只展示到 Task,不暴露 Job/Run 层级。新建定制任务可关联当前或新 Conversation,默认 Execute 并沿用 Runtime、工具和审批边界;重复触发复用同一 Task,文本结果回写 Conversation,独立文件和图片保留为成果。普通消息与到期 Scheduled Task 共用 Conversation 级持久发送队列,同一会话一次只执行一项;当前回复期间仍可继续发送,队列按顺序续跑,并允许删除或“立即中断并插入”。Task Center 继续作为完整索引,不建设独立 Automation Center。当前计划触发支持单次、每日和每周;高级时区、Cron、事件触发与重试治理仍按 PRD 逐步实现。详见 [Task Center PRD](./docs/features/task-and-job/task-center-prd.md) 和 [Scheduled Task PRD](./docs/features/task-and-job/scheduled-task-prd.md)。
|
||||
- [x] **记忆与智能心跳**:当前提供周期回顾、建议记忆、洞察、后续任务和可审计运行轨迹。
|
||||
- [x] **记忆与智能心跳**:计划触发共享增量回顾,不再单独生成心跳报告;可只更新记忆图谱,或从未决事项、分歧、修订和候选约定生成有依据的建议。接受未决事项创建暂停任务,确认约定写入长期背景;建议失败可单独重试,不撤销已完成回顾。
|
||||
- [x] **定时消息沿用会话**:定时消息使用会话当前历史、Runtime、工作模式和已保存的知识检索设置;任务详情显示实际运行模式,当前 occurrence 未结束时禁止重复“立即运行”。
|
||||
任务中心通过状态数量和筛选定位运行中及待处理任务,审批操作保留在对应任务卡片中。
|
||||
- [x] **自动监督设置与范围**:“监督者 > 自动监督”统一管理计划、自动监督报告和建议,支持创建和编辑 Global 或指定一个、多个 Project 的每日、每周计划;工作回顾阅读带日期的统一监督结果及历史。旧单项目配置无损迁移,项目级记忆与行动输出必须显式指定范围内的 Project。Task Center 和设置中心不再复制心跳表单;按分区回顾、候选生成和唤起条件仍待设计。详见 [智能心跳 PRD](./docs/features/smart-heartbeat/prd.md)。
|
||||
- [x] **监督者回顾、故事线图谱与活动**:按范围和时间回顾工作,查看保存结果的图谱及来源,确认或修订实体,并带入相关回顾上下文继续讨论。侧栏可跟随或固定 Conversation、Task,并打开匹配结果。活动合并心跳报告与下游监督的阶段状态,保留失败和旧结果入口。自动阶段只处理新增、修改及未处理的来源片段,无变化时跳过模型;手动回顾重新整理有界历史。回顾只读且禁止工具,模型调用可能产生费用,详见[实施与验证记录](./docs/features/conversation-supervision/progress.md)。
|
||||
- [x] **智能心跳设置与范围**:“监督者 > 智能心跳”统一管理 Global 或指定 Project 的每日、每周计划及建议,Task Center 和设置中心不复制表单。待处理建议不重复,忽略后有新依据才再次提出;项目与全局共用按来源版本维护的时间线进度,会话级实时主动介入仍为规划。详见[实现规则](./docs/features/conversation-supervision/logic-design.md)。
|
||||
- [x] **故事、经验与时间螺旋**:事件可组织为项目功能、子线索及可选跨项目故事,支持重命名、合并、调整归属、移除和撤销。经验保留形成及应用依据与人工调整标记,心跳可提示暂无进展故事或可能适用的经验。平铺和懒加载 WebGL 时间螺旋共用选择与来源,保留平铺回退及图形资源释放;整理与建议可能增加模型费用,自动结果须核对来源。详见[实施证据](./docs/features/conversation-supervision/progress.md)。
|
||||
- [x] **监督者回顾、故事线图谱与活动**:按范围和时间回顾工作,查看保存结果的图谱及来源,确认或修订实体,并带入相关回顾上下文继续讨论。侧栏可跟随或固定 Conversation、Task,并打开匹配结果。活动合并心跳触发与下游监督的阶段状态,保留失败和旧结果入口。自动与默认手动回顾只处理新增、修改及未处理来源,无变化时跳过模型;显式“重新整理”重读区间,但不推进或重置共享进度,自动回顾不续跑用户暂停的运行。回顾只读且禁止工具,模型调用可能产生费用,详见[实施与验证记录](./docs/features/conversation-supervision/progress.md)。
|
||||
手动开始、自动触发和继续共用一个活动回顾;活动记录可取消并保留已保存批次,清理结束前不接纳新回顾,已取消回顾不可继续。故事线事件、实体和关系按独立页签展示数量,并与画布选择同步。
|
||||
可从监督反馈或图谱来源直接继续讨论,编辑问题并明确打开目标会话;界面不再提供知识实体写回控件或重复上下文说明,自动监督没有活动时隐藏空指标。
|
||||
可从监督反馈或图谱来源直接继续讨论,编辑问题并明确打开目标会话;界面不再提供知识实体写回控件或重复上下文说明,智能心跳用可处理的监督建议代替报告指标和趋势。
|
||||
- 手动回顾分页处理所选历史,并按批保存事实与来源;不再按固定时长自动暂停,仍支持主动暂停和从保存进度继续。设置提供并发、单次请求超时、批次大小和响应容量;活动记录突出相连阶段、已保存批次与剩余来源,次要统计可展开查看。工作回顾采用全宽纵向布局,运行状态单独显示,空态不再挤在控件旁。
|
||||
- [ ] **监督后续能力**(规划中):逐事件完整回放、Experiment 目标、人工修改时间序列审计、事件触发及更完整的执行观察仍未完成。当前自动回顾受输入预算和滚动窗口限制,删除报告不会重置处理进度或重建图谱。详见 [会话监督 PRD](./docs/features/conversation-supervision/prd.md)。
|
||||
- [ ] **批量运行与对比实验室**(规划中):对模型、Prompt、角色和工作流配置执行批量对比,汇总质量、耗时、Token、费用、失败率和成果差异。
|
||||
- [ ] **时态记忆与事实冲突检测**(规划中):为记忆和知识图谱增加有效期、当前事实、过期与矛盾检测、事实核验及证据回溯。
|
||||
- **Story Graph 只读工具(未发布源码)**:Agent 可搜索已保存的工作,读取当前主张或时间线,并分页核对来源。监督者应用开关及 Runtime 分配同时控制发现和调用,旧会话也受限制。Model、本地 Harness、本地与远程 OpenCode/Continue 共用桌面读取入口;查询不调用模型,不以较新回顾推断替代关系。跨 scope 事实复用及 `as_of` 仍待实现,详见[设计与验证](./docs/features/conversation-supervision/story-graph-mcp-design.md)。
|
||||
- **Story Graph 只读工具(Desktop 0.15.9 / Agent 0.15.3)**:Agent 可搜索已保存工作、故事和经验,读取上下文并分页核对来源。监督者开关及 Runtime 分配同时控制发现和调用,旧会话也受限制;本地和远程路径共用桌面读取入口。共享来源版本事实按允许范围复用,关键词匹配和工具说明引导模型按需查询,不自动注入全图;经验是参考而非指令,查询本身不调用模型,`as_of` 仍未实现。详见[设计与验证](./docs/features/conversation-supervision/story-graph-mcp-design.md)。
|
||||
- [ ] **可视化受控工作流**(规划中):提供版本化 DAG、条件分支、审批、取消和恢复,执行节点继续经过 Main Runtime 边界。
|
||||
- [ ] **统一 Run Graph 与回放**(规划中):关联任务、Subagent、模型、知识、工具审批、用量和成果,支持失败定位、重试和脱敏导出。
|
||||
|
||||
@@ -143,7 +147,7 @@
|
||||
- [x] **微信 ClawBot 扫码与媒体**:通过独立 Sidecar 完成本机扫码、验证码、加密凭据和文字收发;支持个人微信私聊图片与文件,单条消息最多 4 个附件、解密后合计不超过 12MB。
|
||||
- [x] **微信安全回传**:支持返回当前任务生成的图片,或在用户明确要求时将本次最终文本生成为 Markdown 附件;不自动读取或发送已有工作区文件。
|
||||
- [x] **企业微信与钉钉连接**:支持 Main-only 加密设置、环境变量只读覆盖、连接测试、动态启停、发送者范围和状态诊断。
|
||||
- [x] **受管本地模型下载源**:在“平台功能 / 通用设置”中为后续语音输入与 OCR 模型下载全局选择 ModelScope(默认)或 Hugging Face;所选来源缺少完整已验证文件时明确不可用,不静默换源或混合文件。
|
||||
- [x] **受管本地模型下载源**:“模型连接”页头在模型类型页签同行右侧、保存操作下方显示当前来源与次按钮。弹窗为后续受管本地模型下载全局选择 ModelScope(默认)或 Hugging Face,确认才保存,取消保持已保存来源不变,错误可重试;向量、语音和 OCR 的更换下载源入口直接打开该弹窗。所选来源缺少完整已验证文件时明确不可用,不静默换源或混合文件。
|
||||
- [x] **可选本地语音模型管理**:应用不内置模型权重;提供校验下载、进度与取消、来源链接、ZIP 或本地目录导入、切换和删除。
|
||||
- [x] **本地录音与离线转写**:采集麦克风音频并使用已选择的本地模型离线转写,支持停止、取消、状态反馈和资源释放。
|
||||
- [x] **版本检查与镜像节点**:在“关于与更新”中选择 GitHub(默认)或镜像节点;手动检查、启动时检查和下载页使用同一选择,并只读取固定可信的发布索引,不自动下载或安装。
|
||||
@@ -153,7 +157,15 @@
|
||||
|
||||
### 开源、构建与发布
|
||||
|
||||
- 当前 Desktop 候选为 `0.15.2`,Agent 候选为 `0.15.0`,OpenCode 固定为 `1.18.29`、Continue 为 `1.5.47`;正式发布状态以独立发布渠道为准。
|
||||
- Desktop `0.15.3` 修复本地直连手动压缩和请求有效期间的工具授权,区分取消与失败并去除重复提示,优化侧栏搜索、注册成功后的快捷键提示及笔记操作布局,减少启动和存储中的重复处理,并精简重复依赖及开发文件。不宣称整体渲染性能已显著提升;Office 实时编辑仍为设计。证据见[0.15.3 发布准备](./docs/development/release-preparation-0.15.3.md)。
|
||||
- 当前 Desktop 候选为 `0.15.10`,已发布 Agent 为 `0.15.3`、最低要求 Desktop `0.15.9`,OpenCode 固定为 `1.18.29`、Continue 为 `1.5.47`;正式发布状态以独立发布渠道为准。
|
||||
- Desktop `0.15.10` 统一项目/会话菜单并显示十个最近会话,增加独立监督模型选择及默认开启的会话 Story Graph 开关。活动索引分页与完整汇总、文档 Worker、延迟启动、窄渲染订阅、列表窗口化、终端批次和旧 Runtime 缓存清理减少重复工作,不承诺通用提速比例。schema 58 要求升级前备份数据库及笔记;高频本地写入改用 NORMAL,断电/系统崩溃可能丢失最近部分写入,远程确认、通道投递和任务生命周期仍用 FULL。Agent 保持 0.15.3;四次真实 Host 模型请求及 1000 行 Unicode SSH 终端检查通过,精确候选 CI 与六平台打包仍为发布门槛,不宣称已修复尚未定位的远程恢复错误。见[发布准备](./docs/development/release-preparation-0.15.10.md)。
|
||||
- Desktop `0.15.9` 增加共享回顾进度、故事经验与时间螺旋,schema 56 要求升级前备份,相关模型整理可能增加费用。会话与消息窗口化、Markdown 缓存、只读 Worker、活动差量保存及远程 checkpoint 批次落库减少重复工作,不承诺所有机器相同比例提速;页内查找不覆盖未挂载行,快速滚动可能显示占位,Worker 不可用时同步回退。Execute 配置修改取消重复原生确认,Electron 为 44.5.1。当前源码四次真实 Host 模型请求通过,仍需精确候选 CI 与打包验证,见[发布准备](./docs/development/release-preparation-0.15.9.md)。
|
||||
- Desktop `0.15.8` 与 Agent `0.15.2` 支持 Execute 模式保存会话图片到本机或 Host 绝对路径,覆盖需明确指定,不支持转换为 WebP。MCP 设置列出图像工具,不代表所有模式均可用。笔记增加新记录取消、编辑焦点返回及可用内容全宽;缺少浏览器和磨砂设置时默认开启,已保存选择不变。schema 仍为 52。真实 Linux x64 Host 工具层证据与待完成的精确候选 CI、打包验证分别记录在[发布准备](./docs/development/release-preparation-0.15.8.md)。
|
||||
- Desktop `0.15.7` 复用已完成的流式 Markdown 块与会话派生值,不改默认流式节奏。运行记录进入时读取、可见时每 15 秒刷新,也可手动刷新;本地自定义 MCP 发现故障按服务隔离。受管模型下载源移至模型连接中的确认弹窗,设备共享改为显式启用的测试功能。schema 仍为 52,仍需精确候选 CI 与打包验证,组件测量不等同完整 App 结果。详见[发布准备](./docs/development/release-preparation-0.15.7.md)。
|
||||
- Desktop `0.15.6` 增加默认关闭、可保存的顶栏与清单磨砂外观,不透出系统桌面;减少聊天重复渲染,草稿与队列图片支持直接预览,设置分组及开关右对齐,笔记标题和编辑间距更紧凑。本地 OpenCode 在清理后对明确暂时性 MCP 传输错误最多重试一次,作废结果不确定的名称,不重放 Prompt 或工具调用;反馈包含脱敏连接诊断。schema 52 增加执行统计索引,升级前备份数据库和笔记。详见[发布准备](./docs/development/release-preparation-0.15.6.md)。
|
||||
- Desktop `0.15.5` 更新智能心跳与增量回顾,保留中断轮次历史,执行清单改为浮层,笔记保存成功后退出编辑;自动评论不重新打开编辑器,失败保留草稿。新增手动笔记标签、AND 筛选及重命名/合并/删除,双栏笔记与全宽概览,固定工作区文件操作栏,缓存 OpenCode 终端依赖并缩短 MCP 名称。schema 51 增加标签存储,同时迁移旧待确认记忆为待处理约定;升级前备份数据库与相邻笔记。证据见[发布准备](./docs/development/release-preparation-0.15.5.md)。
|
||||
- Desktop `0.15.4` 增加 Runtime/模型分层选择及独立系统任务用量,减少 Runtime 指令和历史重复。schema 49 迁移项目与会话选择,回退须恢复完整升级前数据库和相邻笔记备份。Agent `0.15.1` 要求 Desktop `0.15.4`,修复远程 Continue 历史重复封装,Runtime 锁版本不变。证据见[0.15.4 发布准备](./docs/development/release-preparation-0.15.4.md)。
|
||||
- Desktop `0.15.1` 已完成六平台原生打包及 GitHub/OSS 发布。Desktop `0.15.2` 新增 Story Graph 只读工具和完整可编辑的监督讨论消息,简化笔记采集并补充退出诊断;远程图谱读取需配套 Agent `0.15.0`。主机与云环境管理应用仍为待实现规划,当前候选证据见[0.15.2 发布准备](./docs/development/release-preparation-0.15.2.md)。
|
||||
- Desktop `0.15.1` 承接未发布的 `0.15.0` Obsidian 接入、会话采集到魔法笔记和预览中的本地原生客户端,并修复知识库、监督者与界面操作。schema 48 新增会话来源元数据,回退旧客户端需使用升级前完整备份。Agent `0.15.0` 要求 Desktop `0.15.2`,保留 Node `24.19.0`,补齐远程遥测关闭配置及 Continue 跳过已关闭的更新检查,不新增远程客户端快捷入口。
|
||||
- Desktop `0.13.15` 承接未发布的 `0.13.14`,改善监督者分页回顾与续跑、ripgrep 原生搜索、待办布局和远程结果未知后的显式续聊。数据库升级至 schema 47,回退旧客户端需使用升级前完整备份。
|
||||
|
||||
+44
-24
@@ -43,6 +43,10 @@
|
||||
|
||||
两种主题必须保持相同的信息层级,但不要求机械地反转明暗。浅色主题以白色主内容画布、冰蓝灰侧栏和轻微着色的顶栏建立空间关系;深色主题使用深海军蓝与蓝灰表面逐层提亮,避免纯黑。蓝色承担主要选择和交互,青绿色主要承担成功与可用状态,二者不得混用语义。
|
||||
|
||||
外观设置提供“透明磨砂特效”开关,默认开启,文字在左、共享 Switch 在右。新用户及缺少该字段的旧配置使用开启状态;已保存的开关选择保持不变。选择保存在应用设置中,保存成功后立即生效,重启后恢复。开启时,右侧内容框架顶栏的背景不透明度在浅色主题为 65%、深色主题为 70%,保留 18px 背景模糊与淡分隔线。聊天滚动视口向上延伸 58px,消息上滚时实际经过顶栏背后;不使用人工渐变充当背景。初始留白放在滚动内容中,包含顶栏高度和当前任务/待办条的实测高度,首条消息仍从这些控件下方开始。任务/待办条固定在顶栏下方并保持实色;展开的 Runtime 待办卡片使用两主题各自的 75% 不透明背景、18px 背景模糊和清晰的文字颜色。
|
||||
|
||||
已保存的开关状态同步到文档根节点,卸载时清理,使 body/top layer 中的待办卡片也能应用样式;其他菜单保持原有表面。输入区、右侧工作栏和非聊天页面保持原有位置,聊天加载态及无活动消息面板时不扩展视口。窗口拖拽和控制按钮保持原有行为;该效果不透出操作系统桌面。关闭后恢复顶栏下方的聊天布局,顶栏与待办卡片均恢复实色。`tests/frosted-glass.electron.test.ts` 使用真实 `ChatHistoryPane` 检查消息与顶栏的可见矩形交叠、初始留白、关闭恢复、浅深主题、跳底、历史加载锚点和会话切换。
|
||||
|
||||
## 3. 设计令牌
|
||||
|
||||
令牌以 CSS 自定义属性实现。`:root` 提供浅色值,`[data-theme="dark"]` 覆盖深色值。组件样式不得新增只服务于单个页面的颜色、阴影、圆角或间距常量。
|
||||
@@ -293,13 +297,17 @@
|
||||
- 浅色侧栏使用冰蓝灰表面,与白色主内容画布形成明确边界;深色侧栏使用比主画布略亮的蓝灰表面。
|
||||
- 一级导航与最近会话之间、最近会话与底部账户区之间必须有可见结构分隔线。浅色主题使用 `--border-default`,深色主题可在可辨前提下使用 `--border-subtle`。
|
||||
- 当前导航项和当前会话使用一致的淡蓝选中背景、强调色文字和中等字重,不额外描边或添加侧边标记;导航图标随选中状态使用强调色,悬停时保留选中状态。浅深主题遵循相同规则,强制颜色模式保留系统选中边框。
|
||||
- 固定导航行高至少 `36px`、行间距 `4px`,图标统一为 `16px`;导航与最近会话分隔线上下各留 `16px`。普通待办和待处理建议数量使用中性色徽标,不使用危险红色;徽标高度不得改变导航行高。搜索框获得内部焦点时显示完整焦点环。
|
||||
- 固定导航行高至少 `36px`、行间距 `4px`,图标统一为 `16px`;导航与会话区域之间、会话区域与底部入口之间的分隔线上下均各留 `8px`(`--space-2`)。普通待办和待处理建议数量使用中性色徽标,不使用危险红色;徽标高度不得改变导航行高。搜索框获得内部焦点时显示完整焦点环。
|
||||
- 未选中项保持平整,不为每一行添加卡片边框或阴影。悬停反馈不得强于选中状态。
|
||||
- 最近会话列表在精细指针设备上使用细原生滚动条,滑块使用 `--border-control`、轨道透明;强制颜色与粗指针设备保留系统默认。
|
||||
- 会话区域使用 `--surface-raised` 与主导航区分,浅色为白底,深色为相邻的深蓝灰底。不显示“最近会话”标题,保留区域的无障碍名称,列表独立滚动。搜索与新对话共用主导航上方的一行,不在会话区域另设搜索头部;展开、关闭和键盘规则见[会话搜索](./docs/features/application-tool-navigation/ui-design.md#会话搜索)。搜索框使用透明背景、`--border-control` 边框与完整焦点环。
|
||||
- 窗口高度不超过 `600px` 时,侧栏允许整体纵向滚动,会话区域至少保留 `200px` 高度,避免搜索框、列表与底部入口相互覆盖。
|
||||
- 最近会话的顺序不随流式输出增量反复变化;置顶与新消息排序遵守
|
||||
[会话列表排序规则](./docs/features/application-tool-navigation/ui-design.md#会话置顶与操作菜单)。
|
||||
- 项目选择器顶行保留项目切换与新建按钮;项目设置统一从下拉列表对应行的管理按钮进入,关闭后焦点返回项目选择器。
|
||||
- 项目选择器与下方默认的新建对话按钮等宽,两行右侧图标按钮均宽 `36px`,与左侧控件间距均为 `8px`;搜索展开时项目选择器宽度保持不变。
|
||||
- 项目下拉列表通过 `FloatingPortal` 浮在侧栏与会话区上方,默认宽度 `380px`,不受侧栏裁剪;距窗口边缘至少 `16px`,窄窗口自动收窄,长列表内部滚动。浮层锚定项目选择按钮,窗口、滚动和尺寸变化后重新定位。
|
||||
- 项目选择器下方仅在有运行中、待处理或已完成未查看会话时显示跨项目活动汇总,无活动时隐藏;项目计数与项目、会话级联菜单遵守
|
||||
- 项目选择器使用固定双行单按钮,第一行显示项目名,第二行显示跨项目活动汇总或“暂无活动”;点击任一行打开统一菜单并默认预览当前项目,全部项目范围在菜单内切换。尺寸、焦点、项目计数与菜单遵守
|
||||
[项目活动汇总 UI 规则](./docs/features/application-tool-navigation/ui-design.md#10-项目活动汇总)。
|
||||
活动数量不改变项目菜单的稳定分组和顺序。
|
||||
- 桌面停靠状态下,主侧栏宽度允许在 `220px` 至 `420px` 范围内通过指针或键盘调整,
|
||||
@@ -328,10 +336,10 @@
|
||||
接受用户本机选择的 PNG、JPEG 或 WebP 内联数据,宽高不超过 `4096 × 4096`、总像素不
|
||||
超过 1600 万且大小不超过 `512 KB`;不得读取远程 Logo URL。名称或副标题溢出品牌区时
|
||||
单行截断,不能撑宽或挤压侧栏。
|
||||
- 侧栏底部提供应用中心入口,设置按钮独立靠右。点击应用中心向上展开轻量锚定菜单,无模态遮罩,只列已启用应用,不按打开历史或常驻状态筛选;点击行关闭菜单,知识库、智能心跳和魔法笔记在主内容区打开页面。本机推理监控从菜单、常驻入口或管理 Modal 打开时均使用独立 Modal,保留底层工作区。菜单底部“管理应用”打开既有整窗管理 Modal,共同应用设置沿用设置 Modal。
|
||||
四个内置应用的管理列表、菜单和侧栏共用保存顺序,菜单按启用过滤,侧栏按启用且常驻过滤。知识库、智能心跳始终启用并常驻,可调整位置;管理 Modal 提供“打开”和排序,不提供启用、常驻开关或通用空设置,标签为“始终显示”(英文 `Always shown`)。原工作区配置及心跳单条计划 enabled 保持不变。只有魔法笔记、本机推理监控可修改启用和常驻,取消常驻不关闭功能。当前对话、运行记录和设置保持固定系统位置,不参与应用排序。
|
||||
- 侧栏底部提供“应用 / Apps”和“设置 / Settings”两个横向等高 `nav-item` 按钮,均显示图标和文字,中间使用弱竖分隔线。“应用”带向下箭头,点击后向上展开轻量锚定菜单,无模态遮罩,只列已启用应用,不按打开历史或常驻状态筛选;点击行关闭菜单,知识库、智能心跳和魔法笔记在主内容区打开页面。本机推理监控从菜单、常驻入口或管理 Modal 打开时均使用独立 Modal,保留底层工作区。菜单底部“管理应用”使用 `LayoutGrid` 图标,打开既有整窗管理 Modal,标题仍为“应用中心 / Application Center”,共同应用设置沿用设置 Modal。
|
||||
五个内置应用的管理列表、菜单和侧栏共用保存顺序,菜单按启用过滤,侧栏按启用且常驻过滤。知识库始终启用并常驻,可调整位置;管理 Modal 提供“打开”和排序,不提供启用、常驻开关或通用空设置,标签为“始终显示”(英文 `Always shown`)。监督者沿用智能心跳身份,可修改启用和常驻,执行边界见[监督者逻辑](./docs/features/conversation-supervision/logic-design.md#应用启停与执行)。魔法笔记、本机推理监控同样可修改启用和常驻。设备共享在测试阶段默认关闭,标签为“可选”及启用状态,提供启用设置和排序,不提供常驻开关;启用后可从应用菜单及管理 Modal 在主内容区打开元数据页面,关闭时退出该页,规则见[设备共享 UI](./docs/features/device-sharing/ui-design.md#当前首期页面)。当前对话、运行记录和设置保持固定系统位置,不参与应用排序。
|
||||
设置持久化后的事件同步各入口及工具配置写入;重开中心刷新期间锁定修改,新快照不被旧读取覆盖。文件、终端、浏览器和资源监控只通过右侧工作栏访问,不在左侧复制入口。
|
||||
管理 Modal 始终使用紧凑单列横向列表,纵向对应保存顺序;左侧为图标、名称及简短用途,右侧为打开、可用的设置及上下移动操作,行间使用弱分隔线,不加卡片外框。窄窗口操作换行且不溢出,保留共享键盘焦点环。可选应用关闭时禁用“打开”。启用、常驻位于紧凑设置详情;四行均提供上下移动及拖动排序,不受启用或常驻影响,箭头按完整顺序判断边界。搜索时禁用拖动,箭头仍按完整顺序移动,不丢失未命中项。尺寸、返回与滚动重置规则见[应用中心 UI 设计](./docs/features/application-tool-navigation/ui-design.md)。已有稳定设置入口时,不在顶栏重复提供同一入口。
|
||||
管理 Modal 始终使用紧凑单列横向列表,纵向对应保存顺序;左侧为图标、名称及简短用途,右侧为打开、可用的设置及上下移动操作,行间使用弱分隔线,不加卡片外框。窄窗口操作换行且不溢出,保留共享键盘焦点环。可选应用关闭时禁用“打开”。启用、常驻位于紧凑设置详情;五行均提供上下移动及拖动排序,不受启用或常驻影响,箭头按完整顺序判断边界。搜索时禁用拖动,箭头仍按完整顺序移动,不丢失未命中项。尺寸、返回与滚动重置规则见[应用中心 UI 设计](./docs/features/application-tool-navigation/ui-design.md)。已有稳定设置入口时,不在顶栏重复提供同一入口。
|
||||
- 本机推理监控 Modal 复用整窗 Portal、背景隔离和焦点循环;停止、重启及释放使用其上方的紧凑影响确认。Escape 只关闭最上层,操作提交期间禁止关闭及重复提交;焦点恢复与尺寸见[本机推理监控 UI 规则](./docs/features/application-tool-navigation/ui-design.md#91-本机推理管理)。
|
||||
|
||||
### 6.9 助手工作栏
|
||||
@@ -363,7 +371,7 @@
|
||||
- 已打开实例保留 `tablist`、`tab`、`tabpanel`、方向键、Home、End 和焦点恢复语义。
|
||||
实例过多时 Tab 区单行横向滚动,“+”保持可见,不换行或改成下拉框。不可关闭实例不渲染
|
||||
关闭按钮,也不响应关闭快捷命令。
|
||||
“+”位于滚动容器外并占用固定宽度;滚动箭头限定在剩余的页签区域内,不能覆盖添加入口。
|
||||
“+”位于滚动容器外并占用固定宽度;溢出时左右滚动箭头在滚动视口两侧各占独立宽度,不能覆盖页签、关闭按钮或添加入口。箭头隐藏或禁用时保留占位,避免悬停或滚动引起布局跳动。
|
||||
- 当前工作栏 Tab 必须同时使用连续内容表面、可见边框、顶部指示、图标或较高字重中的至少
|
||||
两种信号;悬停反馈不得强于当前选中态。Tab 关闭按钮保留固定布局空间,默认仅在对应 Tab 悬停或
|
||||
`focus-within` 时显示;无悬停或粗指针设备始终显示,键盘与辅助技术始终可访问。
|
||||
@@ -375,7 +383,7 @@
|
||||
记忆功能实现前不展示占位内容,未来的记忆与历史执行上下文归入关联 Task 的任务中心详情。
|
||||
- 徽标可以提示未解决意见、等待审批、失败或连接状态,但不能成为唯一状态信号,也不能无条件抢占当前栏目。
|
||||
- 浏览器 Tab 的内容顶部固定使用一条紧凑工具栏,按“返回、刷新/停止加载、地址输入、
|
||||
前往、交互、关闭浏览器”排列。“刷新”在当前页面加载时原位切换为“停止加载”;
|
||||
前往、交互、关闭浏览器”排列。“前往”使用纯右箭头图标按钮,保留悬停提示和可访问名称。“刷新”在当前页面加载时原位切换为“停止加载”;
|
||||
“关闭浏览器”必须使用明确文字和与页面导航控件清楚区分的危险操作视觉,不能继续标为
|
||||
含义模糊的“停止浏览器”。地址输入显示浏览器权威提交 URL,但用户编辑期间保留本地草稿,
|
||||
不被画面或状态刷新覆盖;提交裸域名时自动添加 `http://`,提交、失焦后的同步规则不得让
|
||||
@@ -484,6 +492,7 @@ Switch 用于在两个持久状态之间立即切换,例如启用能力、开
|
||||
- Checkbox 保留原生 Checkbox 语义和方形勾选视觉,不得添加 `role="switch"`。多项分配、列表选择、确认声明和“保存时清除密钥”等一次性选择均属于 Checkbox。
|
||||
- 不创建页面专属 Switch 样式。需要紧凑布局时仍复用同一轨道、滑块、焦点环、禁用状态和动效,只调整共享组件支持的布局变体。
|
||||
- Switch 支持 Tab 聚焦和 Space 切换,键盘焦点至少显示 `2px` 高对比焦点环。可见标签应描述被控制的能力,不能只显示“开 / 关”。
|
||||
- 共享开关行统一为标签在左、Switch 靠右,说明放在标签下方;不因 JSX 中输入控件与文字的先后顺序改变视觉位置。窄窗口允许文字换行,开关保持固定尺寸。Checkbox 和 Radio 保留各自的选择布局。
|
||||
- 异步切换期间禁用重复操作并保留原状态。失败时恢复或保留最后确认状态,通过应用通知或就地可恢复错误说明原因。
|
||||
- 涉及联网、上传、电脑控制或其他外部影响的 Switch,附近必须持续说明数据去向、权限范围或风险,不能只靠设置名称表达影响。
|
||||
- 自动化测试应按 `switch` 角色查询二元开关,按 `checkbox` 角色查询多选或确认项,防止视觉迁移后语义回退。
|
||||
@@ -695,7 +704,7 @@ GoodBuddy 是可调整窗口大小的桌面应用。响应式设计优先保证
|
||||
- 已选择的工作模式在触发按钮中只显示 `Ask` 或 `Execute`;完整中文含义和说明保留在菜单选项、可访问名称及输入区下方的模式说明中。
|
||||
- 宽度大于 `700px` 时,主工具栏的添加内容、Runtime、模式、会话选项与发送保持同一行。窄输入区按容器宽度换行,保留发送及停止入口;选项浮层不得超出输入区宽度,不再常驻独立的 Runtime 专属功能行。
|
||||
- 输入框原生支持 `Ctrl+V`:文本直接进入草稿,系统截图和从文件管理器复制的一个或多个支持文件加入本次消息附件。文件粘贴与上传按钮共用读取、解析进度和数量限制;读取期间阻止重复导入及发送,错误就地关联输入框和附件按钮。具体类型、大小及批次失败规则见[聊天附件要求](./docs/features/document-processing/prd.md#321-聊天附件入口)。默认工具栏不提供独立“读取剪贴板”“截取当前屏幕”和“选择应用窗口”入口。
|
||||
- “Enter 发送 · Shift+Enter 换行 · Ctrl+V 粘贴文件、图片或文本”等输入操作提示放在空输入框内部,作为主占位文案的次级行;不得在输入框下方单独占用第二行。输入框下方只保留一行当前模式、安全边界或全局快捷键说明。
|
||||
- 空输入框使用三行原生占位文案:第一行为消息提示(生图模式保留图片描述提示),第二行为“Enter 发送 · Shift+Enter 换行 · Ctrl+V 粘贴”,第三行为“Ctrl+N 新建对话 · 实际已注册快捷键 快捷唤起”。新建与粘贴快捷键按平台显示,macOS 使用 Command;全局快捷键不可用时省略该项。输入文字后占位提示自然隐藏,输入框下方不再重复全局快捷键,保留模式、安全边界和其他元信息。
|
||||
- 输入操作提示不能替代表单的可访问名称,输入框始终保留持久的程序化标签。
|
||||
- 草稿与消息中的附件操作使用紧凑图标按钮,并提供悬停和键盘焦点提示;文件名、解析状态与操作行的布局遵循[附件操作规则](./docs/features/document-processing/ui-design.md#12-附件操作菜单)。
|
||||
- 空对话展示可执行的起始建议,发送失败保留输入并提供重试。
|
||||
@@ -727,14 +736,14 @@ GoodBuddy 是可调整窗口大小的桌面应用。响应式设计优先保证
|
||||
### 13.4 智能心跳
|
||||
|
||||
- 监督者入口使用 `supervisor` 壳层;智能心跳设置位于同一壳层内。
|
||||
- 顶层页签为“工作回顾 / 故事线图谱 / 自动监督 / 活动记录 / 设置”,使用一套共享 PageTabs,窄窗口单行横向滚动。页签下不重复同名标题,也不保留空标题容器;所选回顾使用“监督结果”标题,历史结果不得标为最新。
|
||||
- 工作回顾只展示统一监督结果、历史选择和紧凑的新回顾控件;结果保留自身范围、覆盖区间和生成时间,图谱读取同一结果 ID。自动监督集中展示当前计划、运行概览、指标、趋势、自动监督报告、记忆与行动建议及心跳运行审计;活动记录只展示统一执行活动、进度和详情。每页保留所属数据的一处刷新入口,范围与时间选择仅作用于新回顾请求。详细布局与阅读规则见[监督者 UI 设计](docs/features/conversation-supervision/ui-design.md#应用入口)。
|
||||
- 顶层页签为“工作回顾 / 故事线图谱 / 智能心跳 / 活动记录 / 设置”,使用一套共享 PageTabs,窄窗口单行横向滚动。页签下不重复同名标题,也不保留空标题容器;所选回顾使用“监督结果”标题,历史结果不得标为最新。
|
||||
- 工作回顾只展示统一监督结果、历史选择和紧凑的新回顾控件;结果保留自身范围、覆盖区间和生成时间,图谱读取同一结果 ID。智能心跳集中展示心跳计划、监督建议和只读的历史心跳报告;活动记录只展示统一执行活动、进度和详情。每页保留所属数据的一处刷新入口,范围与时间选择仅作用于新回顾请求。详细布局与阅读规则见[监督者 UI 设计](docs/features/conversation-supervision/ui-design.md#应用入口)。
|
||||
- 监督者保留统一页面壳层;工作回顾与活动子面板铺满壳层可用内容宽度,与页签左右对齐。工具栏、历史导航、状态和结果共享宽度,不再增加子面板宽度上限。工作回顾按纵向排列,空态独占工具栏下方整行并水平居中。活动筛选按内容紧邻排列,阶段使用编号圆点和连接线,次要统计收进详情;具体布局见[监督者 UI 设计](docs/features/conversation-supervision/ui-design.md)。
|
||||
- 范围明确区分 Global 与指定的一个或多个 Project;Global 与指定项目互斥,多项目选择使用
|
||||
Checkbox,不能依靠进入页面时的当前项目推断。
|
||||
- 状态卡片使用统一状态令牌,不只依赖颜色。
|
||||
- 活动记录先显示运行状态、时间、范围及实际阶段,恢复操作紧邻进度。原始错误默认折叠,展开后在有界面板内滚动并可选择复制;配置归入次要详情。任务层级只展示可查询的记录,缺失阶段明确标注,不能用剩余来源为零推断发布成功。具体阶段与分页规则见[监督者活动设计](docs/features/conversation-supervision/ui-design.md#活动)。
|
||||
- 自动监督的当前状态下显示计划列表,仅保留列表的一个创建按钮;无计划时使用列表的未配置说明,不叠加第二个空态或创建入口。创建与编辑复用同一份表单及共享 Modal 焦点、背景隔离和遮罩样式;未保存关闭须确认,保存失败保留草稿,编辑暂停计划不恢复运行。“执行记录”进入按该计划 ID 筛选的活动页,活动页不重复显示心跳审计列表。
|
||||
- 智能心跳的“心跳计划”卡片内直接排列状态说明、创建按钮和计划列表,不再嵌套第二层边框;仅保留一个创建按钮,说明文字按无计划、全部暂停、正常三种状态只显示一条。创建与编辑复用同一份表单及共享 Modal 焦点、背景隔离和遮罩样式;未保存关闭须确认,保存失败保留草稿,编辑暂停计划不恢复运行。“执行记录”进入按该计划 ID 筛选的活动页,活动页不重复显示心跳审计列表。
|
||||
- 设置只展示模型超时与并发、分页、分批和单次响应容量,不含计划、报告、审计或嵌套页签,不显示整页全局徽标或独立刷新行。
|
||||
- 监督者的设置、帮助、空态及错误文案不得向终端用户展示内部路线图、交付进度或设计与实现缺项;只说明当前行为、适用范围和可执行的下一步。不得因此夸大为提供商全局限流或完整语义识别;真实的运行失败、超时、暂停、来源失效及恢复操作必须保留。
|
||||
- 智能心跳菜单入口是完整配置的权威位置;任务中心和设置中心不得复制同一 CRUD 表单。
|
||||
@@ -774,7 +783,7 @@ GoodBuddy 是可调整窗口大小的桌面应用。响应式设计优先保证
|
||||
|
||||
会话联动已接入:右侧提供可关闭的单实例笔记 Tab,通过工作栏“+ → 笔记”打开;左下角和聊天页头不再提供重复的“快速笔记”入口。助手消息和会话标题菜单可打开文字采集预览;保存记录在面板和完整工作区显示独立来源条。草稿由 App 保留,关闭、替换及禁用前确认。具体交互由[魔法笔记会话联动界面设计](./docs/features/magic-notes/ui-design.md)维护。真实 Electron 验收已通过,全仓验证未全绿,见[验证进度](./docs/features/magic-notes/progress.md)。下列规则描述独立工作区。
|
||||
|
||||
- 笔记总览与待办使用一个普通按钮切换,放在页头“新建笔记”按钮左边。按钮显示目标“切换到待办”或“切换到笔记”及对应图标,按内容宽度显示,不拉伸;窄窗口操作组可换行到标题下方,保留控件高度。
|
||||
- 未打开笔记详情时,笔记列表与待办使用页头普通按钮切换。按钮显示目标“切换到待办”或“切换到笔记”及对应图标,按内容宽度显示,不拉伸;笔记的新建入口位于左侧列表标题行,待办页的新建入口位于页头。窄窗口操作组可换行到标题下方,保留控件高度。
|
||||
- 切换按钮不使用页签语义,切换后焦点回到同一个按钮;内容 section 持续提供当前列表的 `aria-label`。切换沿用草稿保护入口,待办状态仍使用独立的 `SegmentedControl`。
|
||||
- 文字记录编辑器保留 Quill 原生列表快捷输入:输入 `-`、`*`、`1.`、`[ ]` 或 `[x]` 后按空格,分别转换为项目符号、编号或待办清单。快捷输入只生成现有 Delta 富文本,不引入 Markdown 源码模式、第二套文字记录持久化格式或第三方 Markdown 插件;新建文字记录的操作提示收进类型选择旁的 `InlineHelp`。
|
||||
- 新记录可选择“文字 / 画布”;已保存画布提供分页阅读和“展开编辑画布”。画布使用 PeopleLib 的 Fabric 分页批注与 Quill 跨页正文,支持纸张模板、增删页、笔迹、高亮、整对象橡皮、选择及移动缩放旋转、浮动文字、图片和 PDF 底版。画布有固定页面边界;已保存记录、待办来源和编辑态默认容纳完整当前页及工具栏,高度随当前页实际显示尺寸变化,纵向由外层笔记流滚动。多页仍属于一条记录,通过页码翻页;窄窗口工具栏换行,纸张仅在自身视口内横向滚动,不撑宽外层页面。
|
||||
@@ -785,26 +794,34 @@ GoodBuddy 是可调整窗口大小的桌面应用。响应式设计优先保证
|
||||
- 魔法笔记设置提供“发送画布页数”单选框,复用设置表单 `.field` 控件样式,范围 1~8,默认 1。标题同行的问号使用 `InlineHelp`,下拉框通过稳定的帮助 ID 关联展开的说明,交互遵循 6.14。说明包含当前页序前 N 页、不足时发送全部页、数量指单个画布页数而非记录数,以及完整保存与导出不受影响。修改后沿用设置保存反馈,下次分析生效。
|
||||
- 草稿、已保存画布记录及来源为画布的待办,按默认模型的 `supportsImageInput` 使用所选前 N 页图像与文字,或只发送同一范围文字并标注“仅文字分析(未使用画布图像)”。所选页只有视觉内容的记录/草稿遇到文字模型时提示切换支持图像输入的默认模型,不声称已理解笔迹或布局。待办分析同时保留任务标题、说明和所选来源画布页的上下文。
|
||||
- 画布文字评论在提取的 `plainText` 不变时保留;图像评论随画布内容或布局变化失效,在保存后自动评论模式下重新分析。草稿内容变化清除旧草稿分析,评论区显示实际使用的输入模式。存储和判定细节见[画布技术设计](./docs/features/magic-notes/technical-design.md#canvas-analysis-and-comments)。
|
||||
- 默认进入笔记总览,不自动打开第一篇笔记。笔记以自适应卡片网格平铺,卡片显示标题、最多两行内容预览、记录数和本地化更新时间。点击笔记进入详情,详情不保留内部左列表;“返回总览”恢复搜索、滚动位置和入口焦点。新建入口在笔记总览与待办页可见,创建成功后进入笔记详情。
|
||||
- 总览是全宽的“全部笔记”页,以自适应卡片网格(最小 `260px`)显示全部笔记,不显示右侧空白详情,也不自动打开第一篇;卡片显示标题、短日期、两行预览和记录数。打开笔记后切为左侧紧凑列表与右侧详情,页头右上角显示带文字的“返回总览”,点击回到全部笔记页并恢复搜索、滚动位置与卡片焦点;“切换到待办”只在总览显示。列表按紧凑两行行项显示:第一行为标题与短日期(今天显示时间,其余显示月/日,完整时间在工具提示中),第二行为单行预览;记录数只提供给读屏。行项无边框、透明底色,悬停为次级底色,选中为淡蓝底色加左侧 `3px` 强调标记;“更多”按钮悬停或聚焦时覆盖在日期位置。保留搜索、置顶菜单和当前选中状态。打开笔记只更新右侧详情;列表独立滚动,切换保留搜索和列表位置,并在工作区挂载期间记住各篇笔记的阅读位置。明确定位某条记录时优先跳到该记录。
|
||||
- 笔记列表默认展开、宽 `280px`,支持拖动分隔线在 `240–420px` 内调宽,方向键每次调整 `16px`,Home/End 跳到边界;桌面端详情页提供列表显隐开关,记住宽度和展开偏好。工作区宽度不超过 `900px` 时切换显示列表与详情,返回恢复列表位置和入口焦点,不覆盖桌面偏好。新建成功后打开新笔记;切换、新建和删除当前笔记都保护未保存的正文与标题。
|
||||
- 待办页是独立任务视图:搜索任务标题、说明或来源笔记,搜索框右侧同一工具栏放置“全部 / 未完成 / 已完成”筛选,显示匹配数与总数;任务按来源笔记分组。列表独立滚动,页头、搜索和筛选保持可见,窄屏工具栏空间不足时换行。直接勾选完成或恢复未完成,不自动选择任务;状态变化后按当前筛选更新列表。
|
||||
- 待办内容容器宽于 `700px` 时,任务列表稳定在左侧,右侧显示当前任务说明、来源记录和 AI 评论;未选择时右侧提示选择任务。点击任务只更新右侧,不改变列表宽度、行位置或滚动位置,再次点击取消选择。AI 不增加第三栏。容器不超过 `700px` 时,按需详情替换同一内容区域,并提供“返回待办列表”;列表保持挂载,返回恢复滚动和任务行焦点,不将列表与详情上下堆叠。任务按钮提供 `aria-expanded` 和 `aria-controls`。
|
||||
- 待办左右栏分割线支持拖拽,并记住列表宽度;键盘左右方向键每次调整 `16px`,Home/End 调整至边界。列表默认 `320px`、最小 `240px`,详情至少保留 `300px`;窄屏隐藏分割线,临时缩窄窗口不覆盖已保存宽度。每个来源笔记标题可独立收起或展开待办列表,保留标题与数量,通过 `aria-expanded` 和 `aria-controls` 表明状态;收起不清除右侧当前详情。
|
||||
- 待办 AI 评论保留方向选择、流式输出、重新分析和已有结果;来源加载失败通过应用通知提示,并提供就地重试。从待办打开原笔记后,“返回待办”恢复任务选择、搜索、筛选和列表滚动;宽屏焦点返回任务行,窄屏返回待办详情中的返回按钮。
|
||||
- 笔记详情主区按新到旧连续展示全部记录。打开笔记即在标题下、历史记录上显示空白输入区,保留文字/画布类型选择和保存操作,标题栏与索引均不提供“新增记录”按钮。新增保存成功后重建空白输入区,保留所选类型;自动分析结束也不转入旧记录编辑。旧记录通过显式编辑入口修改,保存使用同一 ID 和返回的修订号。点击索引或当前笔记内的 AI 来源只按 entry ID 滚动定位,不卸载编辑器、不清草稿、不询问离开。空白输入区离开不提示,返回总览、打开其他笔记或改为编辑另一条记录时仍保护未保存修改。
|
||||
- 详情页的一级标题显示当前笔记已保存的标题,不显示模块说明;全局范围、返回导航和索引开关保持可见。原标题输入框使用正文大小,保留原有编辑、失焦保存与键盘操作,不形成第二个醒目标题。新建文字记录的输入区初始约四行,随内容自然增高,保留原有调整大小与滚动方式;此高度不作用于旧记录编辑器或画布。笔记内部列表、记录流、AI 区域和文字编辑区在精细指针设备上使用细原生滚动条,轨道透明;强制颜色与粗指针设备保留系统默认。说明、摘要和输入提示使用 `--font-body` 与 `--text-secondary`,时间和短标签保留 `--font-caption`。
|
||||
- 左侧记录索引默认 `168px`,可拖动右边界在 `140–320px` 内调整栏宽,缩略图随列宽等比适配。开关独立位于详情标题左侧,提供 `aria-controls`、展开状态和动作提示;收起后完全移除索引列、分隔条、间隙、边框及轨道,不占宽度,隐藏内容不可聚焦。右侧 AI 评论独立显示、滚动和收起,不与索引共享页签。每条记录一个导航项:画布显示真实首页缩略图及总页数,文字显示摘要,两者均显示类型和时间。两侧分别记住桌面展开状态和宽度,共用原有布局偏好;宽布局分隔条共用样式及 `separator` 语义,方向键每次调整 `16px`,Home、End 跳到当前上下限。两侧宽度按可用空间协调限制,为主区保留至少 `300px`,窗口缩小时不覆盖保存的偏好。内容容器不超过 `800px` 时索引默认隐藏,由同一标题栏按钮开闭标题栏下方固定 `168px` 的抽屉,不显示调宽分隔条;标题栏自身保留控件高度,不保留轨道。选择记录或按 Escape 后关闭,Escape 将焦点返回开关。AI 移至底部,高度不超过可用布局的 40% 且最多 `280px`,不显示分隔条。
|
||||
AI 评论的唯一显隐入口位于页面右上角,使用紧凑面板图标按钮;展开时应用共享选中样式,图标随显示或隐藏动作切换,并提供对应的工具提示、可访问名称、`aria-expanded` 和 `aria-controls`。评论面板内部不再显示重复的“AI 评论”标题或隐藏按钮行,保留区域的可访问名称、评论方向选择及各记录的分析操作。
|
||||
- 打开笔记后页面一级标题仍为“魔法笔记”;笔记标题只在记录流顶部出现一次,使用 `22px` 的可编辑大标题,悬停显示下划线,聚焦显示强调色下划线,保留失焦保存、Enter 与 Escape。标题上方为吸顶的细工具栏,依次放置收起笔记列表、记录索引、条数与更新时间、AI 评论开关,均为带工具提示和可访问名称的图标按钮。记录流左右留白 `24px`(窄于 `600px` 时 `12px`),正文最大宽度 `880px` 并居中;吸顶工具栏不受此限制,始终铺满记录流宽度,AI 开关贴在右边缘,底部分隔线横贯整栏。
|
||||
- 桌面笔记详情的列表标题行、正文工具栏与 AI 评论方向标签行顶部对齐,首行统一使用 `40px` 控件高度并垂直居中;AI 区域不叠加面板和表单的顶部留白。
|
||||
- 文字记录的新建与编辑区域铺满卡片内沿,格式工具栏紧接头部,输入区域紧接工具栏;编辑器外层不再增加内边距或圆角。正文保留编辑器内部的文字留白,底部操作栏独立保留内边距与顶部分隔线;画布仍保留 `12px` 内容间距。
|
||||
- 新建记录输入区空闲时收成一行虚线提示框,隐藏类型切换、格式工具栏和保存栏;聚焦、已有草稿、选择画布或出现校验错误时展开,初始约四行并随内容增高。保存成功或切换笔记后恢复为一行。此规则不作用于旧记录编辑器。
|
||||
- 已保存记录的 AI 分析、编辑、删除为 `28px` 图标按钮,精细指针设备上悬停或键盘聚焦记录时才显示;删除悬停时使用危险色,确认条保持原样。粗指针设备始终显示。笔记内部列表、记录流、AI 区域和文字编辑区在精细指针设备上使用细原生滚动条,轨道透明;强制颜色与粗指针设备保留系统默认。说明、摘要和输入提示使用 `--font-body` 与 `--text-secondary`,时间和短标签保留 `--font-caption`。
|
||||
- 左侧记录索引默认收起,只在用户展开后记住展开状态;展开宽度默认 `168px`,可拖动右边界在 `140–320px` 内调整栏宽,缩略图随列宽等比适配。开关位于笔记工具栏,提供 `aria-controls`、展开状态和动作提示;收起后完全移除索引列、分隔条、间隙、边框及轨道,不占宽度,隐藏内容不可聚焦。右侧 AI 评论独立显示、滚动和收起,不与索引共享页签。每条记录一个导航项:画布显示真实首页缩略图及总页数,文字显示摘要,两者均显示类型和时间。两侧分别记住桌面展开状态和宽度,共用原有布局偏好;宽布局分隔条共用样式及 `separator` 语义,方向键每次调整 `16px`,Home、End 跳到当前上下限。两侧宽度按可用空间协调限制,为主区保留至少 `300px`,窗口缩小时不覆盖保存的偏好。内容容器不超过 `800px` 时索引默认隐藏,由同一标题栏按钮开闭标题栏下方固定 `168px` 的抽屉,不显示调宽分隔条;标题栏自身保留控件高度,不保留轨道。选择记录或按 Escape 后关闭,Escape 将焦点返回开关。AI 移至底部,高度不超过可用布局的 40% 且最多 `280px`,不显示分隔条。
|
||||
AI 评论默认不占列:当前笔记已有评论、正在分析或存在草稿评论时自动展开;用户对该笔记手动收起后保持收起,直到发起新的分析。无评论时手动展开即视为固定,固定状态跨笔记和重启保留,再次收起即取消固定。AI 评论的唯一显隐入口位于笔记工具栏右端,使用紧凑面板图标按钮;展开时应用共享选中样式,图标随显示或隐藏动作切换,并提供对应的工具提示、可访问名称、`aria-expanded` 和 `aria-controls`。评论面板内部不再显示重复的“AI 评论”标题或隐藏按钮行,保留区域的可访问名称、评论方向选择及各记录的分析操作。
|
||||
- 笔记详情的内部记录索引使用透明底色,与主内容表面连续,仅保留细调宽分隔线。每条索引记录保留 `1px` 的 `--border-subtle` 淡边框,不加卡片底色或阴影,悬停使用轻微次级底色;选中记录使用淡蓝背景和左侧 `3px` 蓝色内侧标记,悬停时保留选中态。缩略图保留纸张边界,键盘焦点环与强制颜色模式选中边框保持可见。窄屏抽屉使用不透明的主内容表面,避免下层正文透出;深色主题沿用语义令牌。
|
||||
- 笔记详情的标题、正文和记录流不再包裹额外的大外框,记录流容器不加内边距;缩略图列表顶部与正文区顶部对齐,左边界与索引栏左边界对齐,仅在右侧和底部保留 `8px` 留白,键盘焦点环向内绘制以避免滚动容器裁切。每条已保存记录使用一层 `1px` 的 `--border-subtle` 淡边框和 `--radius-control` 圆角,保留透明底色与记录间留白,不加阴影或头部下划线。记录编辑时保留输入区边框、页面安全边距及 AI 区域分隔。此规则仅作用于笔记详情,不改变卡片总览与待办独立布局。
|
||||
- 笔记详情的标题、正文和记录流不再包裹额外的大外框,记录流容器只保留上述左右留白;缩略图列表顶部与正文区顶部对齐,左边界与索引栏左边界对齐,仅在右侧和底部保留 `8px` 留白,键盘焦点环向内绘制以避免滚动容器裁切。每条已保存记录使用一层 `1px` 的 `--border-subtle` 淡边框和 `--radius-control` 圆角,保留透明底色与记录间留白,不加阴影或头部下划线。记录编辑时保留输入区边框、页面安全边距及 AI 区域分隔。此规则仅作用于笔记详情,笔记列表与待办保持独立布局。
|
||||
- 画布新建、编辑和只读态统一保留 `12px` 内容间距;详情中已保存画布去掉预览外框和工具栏底色、下划线,保留灰色画布衬底与白色纸张,编辑态保留编辑器边框。记录头部操作按内容宽度排列,空间不足时自然换行。文字新建的保存区与编辑器属于同一卡片,空白编辑区使用最小高度,不随容器百分比高度拉长。文字工具栏图标、菜单和原生滚动条须跟随主题。
|
||||
- 整篇笔记的置顶、取消置顶和删除放在卡片的“更多”菜单,标题编辑区不重复提供入口。按钮在悬停、键盘聚焦或菜单展开时显示,触屏设备常显;菜单沿用会话列表样式,通过 Portal 避免滚动区域裁切,支持方向键、Home、End 和 Escape。删除在菜单内二次确认并说明笔记标题及全部记录;打开菜单不进入详情。
|
||||
- 标签使用中性胶囊样式(`--surface-subtle` 底色、`--border-subtle` 边框、`--font-caption`),不使用颜色区分。总览搜索框下方是标签筛选行:“全部”加各标签和对应篇数,均为 `aria-pressed` 切换按钮;可以多选,多选时只显示同时带有全部所选标签的笔记。选中时使用 `--accent-subtle` 底色和强调色边框。卡片最多显示 3 个标签,其余合并为“+N”。打开笔记后,左侧列表不显示筛选行,只显示“已筛选:A · B”提示条和清除按钮。
|
||||
- 笔记标题下方是标签编辑行:已有标签后带 × 移除按钮,末尾是虚线“+ 标签”按钮。点击后展开组合框,按输入内容过滤已有标签,也可以新建。方向键切换选项,Enter、逗号或中文逗号确认,Escape 关闭并把焦点还给“+ 标签”。达到 10 个时,“+ 标签”禁用并说明上限。
|
||||
- 列表标题行右侧只放“管理标签”,不再放 `+` 新建按钮。“新建笔记”统一放在页头右上角,是主按钮,始终位于“切换到待办 / 切换到笔记”或“返回总览”的右侧。打开笔记时点击,桌面端展开左侧列表并显示标题输入,窄屏先回到列表。“管理标签”在总览显示图标加文字,打开笔记后只显示图标。点击后打开共享 `custom-task-dialog` 对话框,按使用篇数列出标签,每行提供重命名和删除图标按钮。重命名在行内编辑;新名称与已有标签相同时,提示保存后会合并。删除在行内二次确认,并说明涉及的篇数。Escape 依次退出行内编辑或确认,最后关闭对话框。
|
||||
- 整篇笔记的置顶、取消置顶和删除放在列表行的“更多”菜单,打开详情时仍可操作,标题编辑区不重复提供入口。按钮在悬停、键盘聚焦或菜单展开时显示,触屏设备常显;菜单沿用会话列表样式,通过 Portal 避免滚动区域裁切,支持方向键、Home、End 和 Escape。删除在菜单内二次确认并说明笔记标题及全部记录;删除当前笔记还须处理未保存草稿,删除其他笔记保留当前内容与草稿。打开菜单不切换笔记。
|
||||
- 左侧“魔法笔记”入口可显示未完成待办数量。数量为 0 时隐藏徽标,超过 99 时显示 `99+`,可访问名称仍说明精确数量;勾选、取消勾选以及 Agent / MCP 对笔记清单的写入都必须刷新数量。
|
||||
- 未完成待办徽标的高度不得超过主导航图标;数量从 0 变为可见或再次隐藏时,主导航项行高和相邻菜单位置必须保持不变。
|
||||
- 在待办面板勾选或取消勾选后,当前缓存中的源笔记正文必须立即同步对应清单状态,不得依赖离开工作区后重新加载。
|
||||
- Agent / MCP 或其他入口成功写入笔记后,已打开的工作台自动更新笔记列表、当前详情、待办及来源记录,不增加刷新按钮。保留仍存在的笔记与待办选择、搜索和筛选,以及未保存的标题、输入区和记录编辑草稿;普通后台刷新不重建编辑器。
|
||||
- 外部删除当前笔记时,无草稿则返回总览;有草稿则保留编辑区并提示先复制草稿。外部删除正在编辑的记录时,同样保留该编辑器并明确提示记录已删除;保存失败不得清空草稿。
|
||||
- 当前记录草稿非空或标题尚未保存时,返回总览必须先就地确认;编辑另一条记录等会丢弃正文草稿的操作沿用同一保护。继续编辑时保留草稿并恢复编辑焦点,只有明确选择放弃后才切换。
|
||||
- 保存成功后,以该修订的编辑器初始化并完成 flush 后的内容作为修改比较基线;没有新编辑时返回不再提示。编辑器归一化不算用户修改,后续输入、画布变更和保存失败仍保留离开保护;同修订的后台刷新不替换已建立的基线。
|
||||
- 文字和画布记录保存修改成功后退出编辑状态,显示已保存正文;后续自动评论成功或失败均不重新打开编辑器。再次修改须点击编辑入口,以该修订的编辑器初始化内容作为修改比较基线。编辑器归一化不算用户修改,后续输入、画布变更和保存失败仍保留离开保护;同修订的后台刷新不替换已建立的基线。
|
||||
- 创建、保存、更新、删除和 AI 评论完成等短期结果进入应用级通知,不在编辑区或列表上方堆放页内通知。
|
||||
- 标题或正文校验、删除确认、同步进度和可就地恢复的错误仍靠近对应编辑器或操作呈现。
|
||||
|
||||
@@ -813,24 +830,27 @@ GoodBuddy 是可调整窗口大小的桌面应用。响应式设计优先保证
|
||||
- 设置中心以整窗遮罩上的大 Modal 打开,保留底层工作区、会话草稿和助手工作栏,不切换一级页面。固定标题区和右上角关闭按钮,左侧分类与右侧内容各自滚动。关闭后返回原工作区并恢复入口焦点。
|
||||
- 新建项目与项目设置弹窗固定标题、关闭按钮和底部操作区,仅表单内容滚动;滚动条不进入标题区,关闭按钮保留至少 40 × 40px 的点击区域。
|
||||
- 项目弹窗通过 Portal 挂载到 `document.body`,避免侧栏内的弹窗与主窗口标题栏拖动区域重叠时,Electron 截获关闭按钮的鼠标输入。
|
||||
- Modal 最大尺寸为 `1200px × 900px`,常规外边距为 `24px`;窗口宽度不超过 `1020px` 或高度不超过 `720px` 时接近全屏,保留 `16px` 外边距。左侧分类宽度依次为 `220px`、`180px`(宽度不超过 `1020px`)、`120px`(不超过 `720px`);极窄窗口隐藏分类辅助说明,仍保留左右布局。内容区在可用空间内伸缩,最大宽度为 `960px`。
|
||||
- Modal 最大尺寸为 `1200px × 900px`,常规外边距为 `24px`;窗口宽度不超过 `1020px` 或高度不超过 `720px` 时接近全屏,保留 `16px` 外边距。左侧分类宽度依次为 `220px`、`180px`(宽度不超过 `1020px`)、`120px`(不超过 `720px`);窄窗口仍保留左右布局,长分类名称可换行。内容区在可用空间内伸缩,最大宽度为 `960px`。
|
||||
- 设置 Modal 通过 Portal 挂载到 `document.body`,复用共享背景隔离与焦点恢复机制。打开后聚焦关闭按钮,Tab 在弹窗内循环;Escape 先交由嵌套弹窗处理,设置自身的 Escape 与关闭按钮共用未保存确认,确认出现后再按 Escape 返回编辑。点击遮罩不关闭;保存、测试和清理数据期间禁止关闭。每次打开重新挂载设置,模型和消息通道等直达入口使用本次指定分类,普通入口默认打开 Agent Runtime。
|
||||
- 设置中心不显示全局操作页脚,避免重复关闭入口和没有功能意义的整宽分隔线。
|
||||
- 所有分类使用共享的 `SettingsCategoryHeader` 呈现分类标题、说明、错误与操作,不得在内容卡片内复制分类标题或创建页面专属操作栏。左侧分类名称与说明来自同一份分类定义,新增分类时不得分别维护导航和内容标题。
|
||||
- 所有分类使用共享的 `SettingsCategoryHeader` 呈现分类标题、说明、错误与操作,不得在内容卡片内复制分类标题或创建页面专属操作栏。左侧分类名称与右侧标题来自同一份分类定义,新增分类时不得分别维护导航和内容标题。
|
||||
- 左侧按“常规 / 模型与执行 / 功能与扩展 / 系统”分组,顺序与键盘导航一致。分类按钮最小高度 `40px`,只显示名称;分类说明集中在右侧页头,外层标题不再重复功能简介。组名使用辅助字号,普通项使用正文和中等字重,选中项使用强调底色与加粗文字。
|
||||
- 设置区块使用共享圆角、间距和表面令牌;标题为 `--font-section-title`,持久说明至少为 `--font-caption`。嵌套设置区块以顶部细分隔线和留白组织,不叠加卡片背景与四周边框。“能力与工具”的上级页签使用默认 `PageTabs` 外观,与 MCP 内部的分段页签区分。
|
||||
- 当前分类存在“保存”或“测试”等未提交配置操作时,统一放在分类页头右侧;主保存操作在最右侧,测试等次操作排列在其左侧。
|
||||
- 自动生效、仅执行即时命令或自行管理编辑流程的分类不显示全局保存操作。窄窗口下操作区可以换行,但保存入口必须保持清晰可见。
|
||||
- 智能心跳的单条配置不在设置中心重复管理。设置中心如需呈现平台级说明,只提供
|
||||
“打开智能心跳”导航,不复制创建、暂停、恢复或删除表单。
|
||||
- 保存或测试成功统一进入应用通知视口,并按全局规则自动消失,不在分类页头或内容卡片中保留持久成功文案。加载、保存和测试错误显示在分类页头下方,并保留可处理的上下文。
|
||||
- “模型连接”的认证字段按“认证方式 → API Key → 凭据状态”排列;只有选择 API Key 时显示输入框。已保存的 API Key 属于该连接,修改模型服务地址或临时选择“无需认证”不得自动清除,也不得要求重新输入;仅由明确的“清除凭据”或“删除连接”操作移除。
|
||||
- “模型连接”页头在保存操作下方,将模型类型页签与全局模型下载源入口放在同一行;当前来源文字与“模型下载源”次按钮整体右对齐,窄窗口保持同行,空间不足时导航行可横向滚动。按钮打开挂载到 `document.body` 的 Portal 弹窗,复用共享 Modal 焦点管理;弹窗内使用 `fieldset`、持久 `legend` 与整行可点击的原生 Radio 卡片,选中状态同时依靠 Radio、边框和背景表达。Radio 只修改草稿,点击“确认”才保存;取消或关闭不改变已保存来源。读取失败提供重试并禁用确认,不以默认值伪装已保存选择;保存失败保留草稿并允许确认重试,保存期间禁止重复提交和关闭。向量、语音和 OCR 的更换下载源入口直接打开模型连接页的同一弹窗,跨分类时沿用未保存修改保护。详细规则见[模型下载源设计](docs/architecture/model-download-source-design.md#7-模型下载源界面)。
|
||||
- 设置中的连接列表与详情共用 `.model-connection-manager`,按容器可用宽度响应:不超过 `640px` 时上下排列,列表内部滚动高度最多 `180px`;更宽时恢复左右排列。LLM、向量连接和角色编辑复用此规则,不以整窗宽度代替表单宽度。接口协议的中英文当前值须完整可读。回归范围见[窄容器布局修复记录](docs/quality/responsive-controls-review-2026-09-27.md)。
|
||||
- “保存并测试模型”必须在保存后发送一次有界的真实模型请求。文本连接校验本次随机测试文本,图像连接校验实际返回的内联图片;仅收到成功 HTTP 状态、模型列表或健康检查响应不算测试成功。界面持续说明该操作可能产生少量服务商用量费用,成功反馈明确写为“真实生成测试通过”。
|
||||
- 所有显式保存的设置草稿都参与离开保护:关闭、切换分类、主侧栏或工作区导航及托盘导航不得静默丢弃,统一通过设置中心的就地确认提供继续编辑与明确放弃入口,保存失败后保留输入。“平台功能 / 通用设置”承载全局快捷唤起的共享 Switch、可访问 accelerator 录制输入、恢复默认、保存及注册、停用或冲突状态,不新增分类或页签;注册或持久化失败时保留上一组可用快捷键和当前草稿,保存或停用成功后同步更新输入区的快捷键提示。
|
||||
- “平台功能 / 通用设置”同时提供默认开启的“在会话中渲染 HTML”持久 Switch。说明必须
|
||||
明确该能力只生成禁用脚本、联网、表单提交和窗口操作的静态预览;保存成功后当前会话立即
|
||||
更新,保存失败时保留最后确认状态。
|
||||
- 通用设置按快捷唤起、会话与通知、工作目录与下载分组,复用现有卡片。“桌面通知”为默认开启的持久 Switch,保存后立即控制窗口未聚焦时的任务完成、失败和取消系统通知,关闭不影响应用内通知。旧配置缺少该字段时默认开启,明确关闭后重启仍保持关闭;保存失败保留最后确认状态并显示应用内错误通知。
|
||||
- “平台功能”使用共享 `PageTabs` 区分“通用设置”和“远程项目”,默认进入通用设置。“远程项目”页将持久 Switch 与用户数据目录中的 Linux x64、Linux arm64、macOS arm64 Agent 包清单分成独立卡片;每张卡片与下载进度按平台和架构区分,不能把两个 arm64 目标合并。清单延迟到首次打开该页签时由 Main 完整校验,并读取所选更新源的小型签名目录,显示本地版本、在线最新兼容版本以及“有更新”“已是最新”“未下载”“校验失败”。打开页签或刷新不得自动下载 `.gbagent`;只有用户点击带目标版本的下载/更新按钮后才传输包。签名目录不可用时继续显示并允许导出已验证本地包,提供就地重试错误;清单不混入 Host 已安装状态、缓存路径或敏感签名细节。下载和导入期间保留上下文进度,成功进入全局通知视口。全局模型下载源使用 `fieldset`、持久 `legend` 与整行可点击的原生 Radio 卡片;选中状态同时依靠 Radio、边框和背景表达,读取失败时不得用默认值伪装为已保存选择。
|
||||
- 通用设置按快捷唤起、会话与通知、工作目录分组,复用现有卡片。“桌面通知”为默认开启的持久 Switch,保存后立即控制窗口未聚焦时的任务完成、失败和取消系统通知,关闭不影响应用内通知。旧配置缺少该字段时默认开启,明确关闭后重启仍保持关闭;保存失败保留最后确认状态并显示应用内错误通知。
|
||||
- “平台功能”使用共享 `PageTabs` 区分“通用设置”和“远程项目”,默认进入通用设置。“远程项目”页将持久 Switch 与用户数据目录中的 Linux x64、Linux arm64、macOS arm64 Agent 包清单分成独立卡片;每张卡片与下载进度按平台和架构区分,不能把两个 arm64 目标合并。清单延迟到首次打开该页签时由 Main 完整校验,并读取所选更新源的小型签名目录,显示本地版本、在线最新兼容版本以及“有更新”“已是最新”“未下载”“校验失败”。打开页签或刷新不得自动下载 `.gbagent`;只有用户点击带目标版本的下载/更新按钮后才传输包。签名目录不可用时继续显示并允许导出已验证本地包,提供就地重试错误;清单不混入 Host 已安装状态、缓存路径或敏感签名细节。下载和导入期间保留上下文进度,成功进入全局通知视口。
|
||||
- “关于与更新”的更新源位于“启动时检查新版本”开关下方,常规宽度下将标签、同行帮助和原生单选下拉框放在同一行,用途说明收进 `InlineHelp`,并复用设置表单的统一控件样式;关闭启动检查后仍可选择更新源,手动检查继续使用当前选择。选项显示“GitHub(默认)”和中性的“镜像节点”。该选择同时控制手动检查、启动时检查、下载页和 Agent 包在线下载,不显示底层服务商名称。
|
||||
- “关于与更新”在版本信息之后提供应用内反馈卡片。反馈对话框使用原生字段、明确隐私说明和一张可选截图;另提供默认关闭的原生 Checkbox,让用户主动附加最近桌面诊断摘要。Checkbox 附近必须持续说明摘要只含时间、组件、阶段、稳定错误码、错误类型和固定短消息,不含对话、Prompt、凭据、文件内容、路径、Provider 原始响应或远端 Agent 日志。勾选时为诊断摘要预留描述字符预算,超长草稿必须保留并显示字段错误,不得静默截断。失败时保留草稿、勾选状态和请求编号并提供就地重试,成功时在对话框内保留可复制编号而不重复发送全局通知;对话框遵循统一 Modal 的背景隔离、初始焦点、Tab 循环、Escape、焦点恢复和窄窗口布局。
|
||||
- Agent Runtime 分类页头的“保存设置”同时保存 Runtime 基础配置与 Runtime 原生定制,不在原生定制卡片内提供第二个保存入口。原生定制存在未保存更改时持续显示状态和撤销入口;切换设置分类或 Runtime 不丢弃草稿,关闭设置中心前必须先保存或撤销。
|
||||
@@ -913,7 +933,7 @@ GoodBuddy 是可调整窗口大小的桌面应用。响应式设计优先保证
|
||||
- [ ] 将输入快捷键与附件提示置于空输入框内部,输入区下方保持单行说明。
|
||||
- [ ] 最近对话迁移到 `standard`,统一搜索、范围、时间和删除行为。
|
||||
- [ ] 知识库迁移到 `master-detail`,清除内联浅色样式并补齐窄窗口单面板流程。
|
||||
- [x] 监督者使用 `supervisor`,工作回顾读取统一监督结果;自动监督包含 Global / 多 Project 计划、概览、自动监督报告、建议与审计;活动记录显示统一执行,设置只保留算法参数。
|
||||
- [x] 监督者使用 `supervisor`,工作回顾读取统一监督结果;智能心跳包含 Global / 多 Project 心跳计划、监督建议与历史心跳报告;活动记录显示统一执行,设置只保留算法参数。
|
||||
- [x] 在现有工作栏中完善任务中心,统一范围、状态、最近进展和筛选,不新建平行任务平台。
|
||||
- [ ] 活动记录迁移到 `dashboard`,统一导航、筛选和表格行为。
|
||||
- [ ] 设置中心使用共享分类定义与 `SettingsCategoryHeader`,将保存与测试操作统一放到分类页头右侧,并把成功反馈接入应用通知。
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"formatVersion": 1,
|
||||
"agentVersion": "0.15.0",
|
||||
"agentVersion": "0.15.3",
|
||||
"protocol": {
|
||||
"major": 2,
|
||||
"minor": 0
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
// PERF-17 regression gate for the PERF-11 full-App benchmark report.
|
||||
// Usage: node build/check-perf-thresholds.cjs <result.json | report directory> [thresholds.json]
|
||||
// Exit codes: 0 all limits met, 1 a limit, scenario or status failed,
|
||||
// 2 the report cannot be judged (throttled run, unreadable input).
|
||||
const { existsSync, readFileSync, statSync } = require('node:fs')
|
||||
const { join, resolve } = require('node:path')
|
||||
|
||||
const defaultThresholdsPath = join(__dirname, 'perf-thresholds.json')
|
||||
|
||||
function metricValue(scenario, path) {
|
||||
let value = scenario
|
||||
for (const key of path.split('.')) {
|
||||
if (value === null || value === undefined) return undefined
|
||||
value = value[key]
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
// Pure: judges a parsed report against parsed thresholds.
|
||||
// Returns { ok, refused, reason?, rows: [{ scenario, metric, value, limit, pass, note? }] }.
|
||||
function evaluate(report, thresholds) {
|
||||
const rows = []
|
||||
const throttle = Number(report?.environment?.rendererCpuThrottle ?? 1)
|
||||
const maxThrottle = Number(thresholds.maxRendererCpuThrottle ?? 1)
|
||||
if (!(throttle <= maxThrottle)) {
|
||||
return { ok: false, refused: true, reason: `renderer CPU throttle ${throttle}x exceeds ${maxThrottle}x; thresholds apply to non-throttled runs only`, rows }
|
||||
}
|
||||
const requiredStatus = thresholds.requiredStatus ?? 'passed'
|
||||
rows.push({ scenario: '(report)', metric: 'status', value: report?.status ?? null, limit: `= ${requiredStatus}`, pass: report?.status === requiredStatus })
|
||||
const scenarios = Array.isArray(report?.scenarios) ? report.scenarios : []
|
||||
for (const [id, limits] of Object.entries(thresholds.scenarios ?? {})) {
|
||||
const scenario = scenarios.find(entry => entry?.id === id)
|
||||
if (!scenario) {
|
||||
const optional = limits.optional === true
|
||||
rows.push({ scenario: id, metric: '(present)', value: 'missing', limit: optional ? 'optional' : 'required', pass: optional })
|
||||
continue
|
||||
}
|
||||
for (const [metric, bound] of Object.entries(limits)) {
|
||||
if (metric === 'optional') continue
|
||||
const raw = metricValue(scenario, metric)
|
||||
const value = typeof raw === 'number' && Number.isFinite(raw) ? raw : null
|
||||
let pass = value !== null
|
||||
const limitParts = []
|
||||
if (typeof bound.max === 'number') { limitParts.push(`<= ${bound.max}`); pass = pass && value <= bound.max }
|
||||
if (typeof bound.min === 'number') { limitParts.push(`>= ${bound.min}`); pass = pass && value >= bound.min }
|
||||
rows.push({ scenario: id, metric, value, limit: limitParts.join(', '), pass, ...(value === null ? { note: 'metric missing' } : {}) })
|
||||
}
|
||||
}
|
||||
return { ok: rows.every(row => row.pass), refused: false, rows }
|
||||
}
|
||||
|
||||
function resolveReportPath(input) {
|
||||
const path = resolve(input)
|
||||
return existsSync(path) && statSync(path).isDirectory() ? join(path, 'result.json') : path
|
||||
}
|
||||
|
||||
function formatValue(value) {
|
||||
if (typeof value !== 'number') return String(value)
|
||||
return Number.isInteger(value) ? String(value) : value.toFixed(value < 1 ? 3 : 1)
|
||||
}
|
||||
|
||||
function run(argv) {
|
||||
const [input, thresholdsArg] = argv
|
||||
if (!input) {
|
||||
console.error('usage: node build/check-perf-thresholds.cjs <result.json | report directory> [thresholds.json]')
|
||||
return 2
|
||||
}
|
||||
const reportPath = resolveReportPath(input)
|
||||
const thresholdsPath = resolve(thresholdsArg || defaultThresholdsPath)
|
||||
let report
|
||||
let thresholds
|
||||
try {
|
||||
report = JSON.parse(readFileSync(reportPath, 'utf8'))
|
||||
thresholds = JSON.parse(readFileSync(thresholdsPath, 'utf8'))
|
||||
} catch (error) {
|
||||
console.error(`cannot read report or thresholds: ${error instanceof Error ? error.message : error}`)
|
||||
return 2
|
||||
}
|
||||
const result = evaluate(report, thresholds)
|
||||
console.log(`\nperf thresholds: ${reportPath}`)
|
||||
if (result.refused) {
|
||||
console.error(`refused: ${result.reason}`)
|
||||
return 2
|
||||
}
|
||||
console.table(result.rows.map(row => ({
|
||||
scenario: row.scenario,
|
||||
metric: row.metric,
|
||||
value: formatValue(row.value),
|
||||
limit: row.limit,
|
||||
pass: row.pass ? 'ok' : `FAIL${row.note ? ` (${row.note})` : ''}`
|
||||
})))
|
||||
const failed = result.rows.filter(row => !row.pass).length
|
||||
console.log(failed ? `${failed} of ${result.rows.length} checks failed` : `all ${result.rows.length} checks passed`)
|
||||
return result.ok ? 0 : 1
|
||||
}
|
||||
|
||||
module.exports = { evaluate, run, defaultThresholdsPath }
|
||||
|
||||
if (require.main === module) process.exitCode = run(process.argv.slice(2))
|
||||
@@ -0,0 +1,89 @@
|
||||
{
|
||||
"note": "PERF-17 regression ceilings for the PERF-11 full-App benchmark (npm run perf:app). They apply ONLY to non-throttled runs (environment.rendererCpuThrottle = 1, the default) with the default GB_PERF_* scale, on the reference machine of the 2026-10-02 baseline (Intel Core Ultra X7 358H x16, 32 GB, DPR 1, 120 Hz). Limits are about 2-3x the 2026-10-02 values so that run-to-run noise does not fail; they catch regressions of the size seen before PERF-12 (e.g. ~130 long tasks / 9 s while streaming). Tighten them as PERF-12..PERF-14 land; do not loosen them to make a regression pass. Every listed scenario must be present unless marked optional, and report.status must equal requiredStatus.",
|
||||
"requiredStatus": "passed",
|
||||
"maxRendererCpuThrottle": 1,
|
||||
"scenarios": {
|
||||
"idle": {
|
||||
"renderer.longTasks.count": { "max": 2 },
|
||||
"renderer.longTasks.totalMs": { "max": 200 },
|
||||
"renderer.frames.p95": { "max": 20 },
|
||||
"renderer.frames.jankRatio": { "max": 0.05 },
|
||||
"main.eventLoopDelay.p99": { "max": 100 }
|
||||
},
|
||||
"typing-small": {
|
||||
"renderer.longTasks.count": { "max": 3 },
|
||||
"renderer.longTasks.totalMs": { "max": 300 },
|
||||
"renderer.frames.p95": { "max": 20 },
|
||||
"renderer.frames.jankRatio": { "max": 0.05 },
|
||||
"latency.p95": { "max": 25 },
|
||||
"latency.max": { "max": 100 },
|
||||
"main.eventLoopDelay.p99": { "max": 100 }
|
||||
},
|
||||
"idle-seeded": {
|
||||
"renderer.longTasks.count": { "max": 2 },
|
||||
"renderer.longTasks.totalMs": { "max": 200 },
|
||||
"renderer.frames.p95": { "max": 20 },
|
||||
"renderer.frames.jankRatio": { "max": 0.05 },
|
||||
"main.eventLoopDelay.p99": { "max": 100 }
|
||||
},
|
||||
"typing-seeded": {
|
||||
"renderer.longTasks.count": { "max": 3 },
|
||||
"renderer.longTasks.totalMs": { "max": 300 },
|
||||
"renderer.frames.p95": { "max": 20 },
|
||||
"renderer.frames.jankRatio": { "max": 0.05 },
|
||||
"latency.p95": { "max": 25 },
|
||||
"latency.max": { "max": 100 },
|
||||
"main.eventLoopDelay.p99": { "max": 100 }
|
||||
},
|
||||
"switch-conversations": {
|
||||
"renderer.longTasks.count": { "max": 5 },
|
||||
"renderer.longTasks.totalMs": { "max": 500 },
|
||||
"renderer.frames.jankRatio": { "max": 0.15 },
|
||||
"latency.p95": { "max": 350 },
|
||||
"main.eventLoopDelay.p99": { "max": 100 }
|
||||
},
|
||||
"switch-conversations-warm": {
|
||||
"renderer.longTasks.count": { "max": 3 },
|
||||
"renderer.longTasks.totalMs": { "max": 300 },
|
||||
"renderer.frames.jankRatio": { "max": 0.15 },
|
||||
"latency.p95": { "max": 200 },
|
||||
"main.eventLoopDelay.p99": { "max": 100 }
|
||||
},
|
||||
"switch-conversations-hot": {
|
||||
"optional": true,
|
||||
"renderer.longTasks.count": { "max": 3 },
|
||||
"renderer.longTasks.totalMs": { "max": 200 },
|
||||
"renderer.frames.jankRatio": { "max": 0.1 },
|
||||
"latency.p95": { "max": 100 },
|
||||
"main.eventLoopDelay.p99": { "max": 100 }
|
||||
},
|
||||
"open-long-conversation": {
|
||||
"renderer.longTasks.count": { "max": 4 },
|
||||
"renderer.longTasks.totalMs": { "max": 400 },
|
||||
"latency.p95": { "max": 400 },
|
||||
"main.eventLoopDelay.p99": { "max": 100 }
|
||||
},
|
||||
"scroll-long-conversation": {
|
||||
"renderer.longTasks.count": { "max": 2 },
|
||||
"renderer.longTasks.totalMs": { "max": 200 },
|
||||
"renderer.frames.p95": { "max": 20 },
|
||||
"renderer.frames.jankRatio": { "max": 0.05 },
|
||||
"main.eventLoopDelay.p99": { "max": 100 }
|
||||
},
|
||||
"stream-seeded": {
|
||||
"renderer.longTasks.count": { "max": 10 },
|
||||
"renderer.longTasks.totalMs": { "max": 1500 },
|
||||
"renderer.frames.p95": { "max": 34 },
|
||||
"renderer.frames.jankRatio": { "max": 0.15 },
|
||||
"main.eventLoopDelay.p99": { "max": 100 }
|
||||
},
|
||||
"stream-and-type": {
|
||||
"renderer.longTasks.count": { "max": 10 },
|
||||
"renderer.longTasks.totalMs": { "max": 1500 },
|
||||
"renderer.frames.p95": { "max": 34 },
|
||||
"renderer.frames.jankRatio": { "max": 0.2 },
|
||||
"latency.p95": { "max": 50 },
|
||||
"main.eventLoopDelay.p99": { "max": 100 }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
// Full-App interaction benchmark supervisor (PERF-11).
|
||||
// Starts a loopback fake OpenAI-compatible model on 127.0.0.1:11434 (the
|
||||
// fresh-profile default model endpoint), launches the current production
|
||||
// build in an isolated profile through tests/support/app-perf-driver.mjs,
|
||||
// enforces a total deadline and prints a summary of the driver report.
|
||||
const { spawn } = require('node:child_process')
|
||||
const { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } = require('node:fs')
|
||||
const { createServer } = require('node:http')
|
||||
const { tmpdir } = require('node:os')
|
||||
const { join, resolve } = require('node:path')
|
||||
|
||||
const root = resolve(__dirname, '..')
|
||||
// 11434 is the fresh-profile default endpoint. Another GoodBuddy (or Ollama)
|
||||
// may already use it; GB_PERF_MODEL_PORT moves the fake model and points the
|
||||
// isolated profile at it through the GOODBUDDY_MODEL_* environment variables.
|
||||
const defaultModelPort = 11434
|
||||
const modelPort = Number(process.env.GB_PERF_MODEL_PORT || defaultModelPort)
|
||||
const totalTimeoutMs = Number(process.env.GB_PERF_TIMEOUT_MS || 600_000)
|
||||
const keepProfile = process.env.GB_PERF_KEEP_PROFILE === '1'
|
||||
const streamBytes = Number(process.env.GB_PERF_STREAM_BYTES || 40_000)
|
||||
const chunkChars = Number(process.env.GB_PERF_CHUNK_CHARS || 12)
|
||||
const streamCharsPerSecond = Number(process.env.GB_PERF_STREAM_CPS || 2_000)
|
||||
const tickIntervalMs = 10
|
||||
|
||||
function buildAnswer(targetBytes) {
|
||||
const blocks = [
|
||||
'## Section {n}\n\nThis paragraph explains the {n}th step of the benchmark answer with **bold**, `inline code`, and a [link](https://example.com/{n}).\n\n',
|
||||
'- First item for block {n}\n- Second item with *emphasis*\n - Nested item {n}\n- Third item\n\n',
|
||||
'```ts\nexport function step{n}(input: number): number {\n const value = input * {n}\n return value + 1\n}\n```\n\n',
|
||||
'| Column | Value {n} | Notes |\n| --- | --- | --- |\n| alpha | {n} | first |\n| beta | {n}1 | second |\n\n',
|
||||
// Single-line display math is common in model output. GB_PERF_CORPUS=multiline-math
|
||||
// puts the delimiters on their own lines instead.
|
||||
process.env.GB_PERF_CORPUS === 'multiline-math'
|
||||
? 'Inline math $a_{n} = b^2 + c$ and display math:\n\n$$\n\\sum_{i=1}^{{n}} i = \\frac{{n}({n}+1)}{2}\n$$\n\n'
|
||||
: 'Inline math $a_{n} = b^2 + c$ and display math:\n\n$$\\sum_{i=1}^{{n}} i = \\frac{{n}({n}+1)}{2}$$\n\n',
|
||||
'> Quoted note {n}: keep the streaming renderer honest with mixed blocks.\n\n'
|
||||
]
|
||||
let text = ''
|
||||
let n = 1
|
||||
while (Buffer.byteLength(text) < targetBytes) {
|
||||
text += blocks[n % blocks.length].replaceAll('{n}', String(n))
|
||||
n += 1
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
function startModelServer(ledger) {
|
||||
const answer = buildAnswer(streamBytes)
|
||||
const server = createServer((request, response) => {
|
||||
const started = Date.now()
|
||||
const entry = { method: request.method, path: request.url, startedAt: started }
|
||||
ledger.push(entry)
|
||||
if (request.method === 'GET' && request.url?.endsWith('/models')) {
|
||||
response.writeHead(200, { 'content-type': 'application/json' })
|
||||
response.end(JSON.stringify({ object: 'list', data: [{ id: 'qwen3', object: 'model' }] }))
|
||||
return
|
||||
}
|
||||
if (request.method !== 'POST' || !request.url?.endsWith('/chat/completions')) {
|
||||
response.writeHead(404).end()
|
||||
return
|
||||
}
|
||||
let body = ''
|
||||
request.on('data', chunk => { body += chunk })
|
||||
request.on('end', () => {
|
||||
let parsed = {}
|
||||
try { parsed = JSON.parse(body) } catch { /* counted below */ }
|
||||
const messages = Array.isArray(parsed.messages) ? parsed.messages : []
|
||||
const last = messages.at(-1)
|
||||
const lastText = typeof last?.content === 'string' ? last.content : JSON.stringify(last?.content ?? '')
|
||||
const benchmark = lastText.includes('perf-stream')
|
||||
entry.kind = benchmark ? 'benchmark-stream' : 'auxiliary'
|
||||
entry.stream = parsed.stream === true
|
||||
const content = benchmark ? answer : 'Benchmark title'
|
||||
if (!entry.stream) {
|
||||
response.writeHead(200, { 'content-type': 'application/json' })
|
||||
response.end(JSON.stringify({
|
||||
id: 'perf', object: 'chat.completion', model: 'qwen3',
|
||||
choices: [{ index: 0, finish_reason: 'stop', message: { role: 'assistant', content } }],
|
||||
usage: { prompt_tokens: 10, completion_tokens: 10, total_tokens: 20 }
|
||||
}))
|
||||
entry.finishedAt = Date.now()
|
||||
return
|
||||
}
|
||||
response.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-cache', connection: 'keep-alive' })
|
||||
const send = payload => response.write(`data: ${JSON.stringify(payload)}\n\n`)
|
||||
const delta = text => send({ id: 'perf', object: 'chat.completion.chunk', model: 'qwen3', choices: [{ index: 0, delta: { content: text }, finish_reason: null }] })
|
||||
send({ id: 'perf', object: 'chat.completion.chunk', model: 'qwen3', choices: [{ index: 0, delta: { role: 'assistant' }, finish_reason: null }] })
|
||||
let offset = 0
|
||||
let chunks = 0
|
||||
const finish = () => {
|
||||
send({ id: 'perf', object: 'chat.completion.chunk', model: 'qwen3', choices: [{ index: 0, delta: {}, finish_reason: 'stop' }] })
|
||||
send({ id: 'perf', object: 'chat.completion.chunk', model: 'qwen3', choices: [], usage: { prompt_tokens: 10, completion_tokens: Math.ceil(content.length / 4), total_tokens: 10 + Math.ceil(content.length / 4) } })
|
||||
response.write('data: [DONE]\n\n')
|
||||
response.end()
|
||||
entry.finishedAt = Date.now()
|
||||
entry.chunks = chunks
|
||||
entry.bytes = Buffer.byteLength(content)
|
||||
}
|
||||
if (!benchmark) { delta(content); finish(); return }
|
||||
// Emit at a fixed character rate, split into small SSE events like a
|
||||
// fast token stream. Pacing is time-based so coarse timers do not
|
||||
// change throughput.
|
||||
const streamStart = Date.now()
|
||||
const tick = () => {
|
||||
if (response.destroyed) { entry.aborted = true; return }
|
||||
const due = Math.min(content.length, Math.ceil(((Date.now() - streamStart) / 1000) * streamCharsPerSecond))
|
||||
while (offset < due) {
|
||||
delta(content.slice(offset, offset + chunkChars))
|
||||
offset += chunkChars
|
||||
chunks += 1
|
||||
}
|
||||
if (offset >= content.length) { finish(); return }
|
||||
setTimeout(tick, tickIntervalMs)
|
||||
}
|
||||
tick()
|
||||
})
|
||||
})
|
||||
return new Promise((resolvePromise, reject) => {
|
||||
server.once('error', reject)
|
||||
server.listen(modelPort, '127.0.0.1', () => resolvePromise(server))
|
||||
})
|
||||
}
|
||||
|
||||
function format(value, digits = 1) {
|
||||
return value === null || value === undefined || Number.isNaN(value) ? '-' : Number(value).toFixed(digits)
|
||||
}
|
||||
|
||||
function printSummary(report) {
|
||||
console.log(`\nGoodBuddy full-App benchmark (${report.status})`)
|
||||
console.log(`source ${report.source?.commit ?? '?'}${report.source?.dirty ? ' (dirty)' : ''}, Electron ${report.environment?.electron}, DPR ${report.environment?.devicePixelRatio}, ${report.environment?.cpu}`)
|
||||
if (report.startup) {
|
||||
console.log(`startup: window ${format(report.startup.windowCreatedMs, 0)} ms, ready-to-show ${format(report.startup.readyToShowMs, 0)} ms, composer ready ${format(report.startup.composerReadyMs, 0)} ms`)
|
||||
}
|
||||
const rows = (report.scenarios ?? []).map(s => ({
|
||||
scenario: s.id,
|
||||
ms: format(s.durationMs, 0),
|
||||
longTasks: `${s.renderer?.longTasks?.count ?? '-'} / ${format(s.renderer?.longTasks?.totalMs, 0)} / ${format(s.renderer?.longTasks?.maxMs, 0)}`,
|
||||
'frame p50/p95/max': `${format(s.renderer?.frames?.p50)} / ${format(s.renderer?.frames?.p95)} / ${format(s.renderer?.frames?.max, 0)}`,
|
||||
'jank%': format(s.renderer?.frames?.jankRatio * 100),
|
||||
'latency p50/p95/max': s.latency ? `${format(s.latency.p50)} / ${format(s.latency.p95)} / ${format(s.latency.max)}` : '-',
|
||||
'mainLag p99/max': `${format(s.main?.eventLoopDelay?.p99)} / ${format(s.main?.eventLoopDelay?.max)}`,
|
||||
'cpu% main/renderer/gpu': `${format(s.processes?.cpu?.Browser, 0)} / ${format(s.processes?.cpu?.Tab, 0)} / ${format(s.processes?.cpu?.GPU, 0)}`,
|
||||
domNodes: s.renderer?.domNodes ?? '-',
|
||||
heapMB: format(s.renderer?.heapUsedMB)
|
||||
}))
|
||||
if (rows.length) console.table(rows)
|
||||
if (report.memoryGrowth) {
|
||||
console.log(`memory growth (${report.memoryGrowth.description}):`)
|
||||
console.table(report.memoryGrowth.samples)
|
||||
}
|
||||
if (report.environment?.rendererCpuThrottle > 1) console.log(`renderer CPU throttled ${report.environment.rendererCpuThrottle}x`)
|
||||
if (report.error) console.log(`error: ${report.error}`)
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const required = ['out/main/index.js', 'out/preload/index.cjs', 'out/renderer/index.html']
|
||||
const missing = required.filter(file => !existsSync(join(root, file)))
|
||||
if (missing.length) throw new Error(`Missing production build (${missing.join(', ')}); run npm run build:bundle first`)
|
||||
const runDirectory = mkdtempSync(join(tmpdir(), 'goodbuddy-app-perf-'))
|
||||
const outputDirectory = resolve(process.env.GB_PERF_OUTPUT || join(runDirectory, 'artifacts'))
|
||||
mkdirSync(outputDirectory, { recursive: true })
|
||||
const ledger = []
|
||||
let server
|
||||
try {
|
||||
server = await startModelServer(ledger)
|
||||
} catch (error) {
|
||||
throw new Error(`Cannot listen on 127.0.0.1:${modelPort} for the fake model (${error.code ?? error.message}); stop the local service using that port, or set GB_PERF_MODEL_PORT to a free port`, { cause: error })
|
||||
}
|
||||
const environment = { ...process.env, GB_PERF_DIRECTORY: runDirectory, GB_PERF_ARTIFACTS: outputDirectory, GB_PERF_ROOT: root }
|
||||
delete environment.ELECTRON_RUN_AS_NODE
|
||||
delete environment.ELECTRON_RENDERER_URL
|
||||
for (const key of ['GOODBUDDY_MODEL_API_KEY', 'GOODBUDDY_MODEL_BASE_URL', 'GOODBUDDY_MODEL_NAME', 'GOODBUDDY_BIGTOKEN_API_KEY', 'GOODBUDDY_BIGTOKEN_BASE_URL', 'GOODBUDDY_BIGTOKEN_MODEL']) delete environment[key]
|
||||
if (modelPort !== defaultModelPort) {
|
||||
// The fake model ignores the key; it only makes the default profile use the URL.
|
||||
environment.GOODBUDDY_MODEL_API_KEY = 'perf-loopback'
|
||||
environment.GOODBUDDY_MODEL_BASE_URL = `http://127.0.0.1:${modelPort}/v1`
|
||||
environment.GOODBUDDY_MODEL_NAME = 'qwen3'
|
||||
}
|
||||
environment.GOODBUDDY_WORKSPACE = join(runDirectory, 'workspace')
|
||||
mkdirSync(environment.GOODBUDDY_WORKSPACE, { recursive: true })
|
||||
const started = Date.now()
|
||||
const child = spawn(require('electron'), [join(root, 'tests', 'support', 'app-perf-driver.mjs')], { cwd: root, env: environment, stdio: 'inherit', windowsHide: false })
|
||||
let timedOut = false
|
||||
const timer = setTimeout(() => {
|
||||
timedOut = true
|
||||
if (process.platform === 'win32') spawn('taskkill', ['/pid', String(child.pid), '/T', '/F'], { stdio: 'ignore' })
|
||||
else child.kill('SIGKILL')
|
||||
}, totalTimeoutMs)
|
||||
const exit = await new Promise(resolvePromise => child.once('exit', (code, signal) => resolvePromise({ code, signal })))
|
||||
clearTimeout(timer)
|
||||
await new Promise(resolvePromise => server.close(() => resolvePromise()))
|
||||
const resultPath = join(outputDirectory, 'result.json')
|
||||
let report
|
||||
if (existsSync(resultPath)) report = JSON.parse(readFileSync(resultPath, 'utf8'))
|
||||
else report = { status: 'failed', error: timedOut ? 'Supervisor timeout before report' : 'Driver exited without a report' }
|
||||
report.supervisor = { elapsedMs: Date.now() - started, timeoutMs: totalTimeoutMs, timedOut, exitCode: exit.code, signal: exit.signal }
|
||||
report.corpus = { kind: process.env.GB_PERF_CORPUS || 'single-line-math', bytes: streamBytes, charsPerSecond: streamCharsPerSecond, charsPerEvent: chunkChars }
|
||||
report.modelRequests = {
|
||||
boundary: 'loopback fake model only; no external requests',
|
||||
total: ledger.length,
|
||||
benchmarkStreams: ledger.filter(entry => entry.kind === 'benchmark-stream').length,
|
||||
auxiliary: ledger.filter(entry => entry.kind === 'auxiliary').length,
|
||||
ledger: ledger.map(({ method, path, kind, stream, chunks, bytes, aborted, startedAt, finishedAt }) => ({ method, path, kind, stream, chunks, bytes, aborted, durationMs: finishedAt ? finishedAt - startedAt : null }))
|
||||
}
|
||||
if (timedOut || exit.code !== 0) report.status = 'failed'
|
||||
writeFileSync(resultPath, JSON.stringify(report, null, 2))
|
||||
printSummary(report)
|
||||
console.log(`\nreport: ${resultPath}`)
|
||||
if (!keepProfile) rmSync(join(runDirectory, 'profile'), { recursive: true, force: true, maxRetries: 5, retryDelay: 200 })
|
||||
process.exitCode = String(report.status).startsWith('passed') ? 0 : 1
|
||||
// PERF-17: GB_PERF_CHECK=1 gates the run on build/perf-thresholds.json
|
||||
// (exit 1 on a regression, 2 when the report cannot be judged, e.g. throttled).
|
||||
if (process.env.GB_PERF_CHECK === '1') process.exitCode = require('./check-perf-thresholds.cjs').run([resultPath])
|
||||
}
|
||||
|
||||
main().catch(error => {
|
||||
console.error(error instanceof Error ? error.message : error)
|
||||
process.exitCode = 1
|
||||
})
|
||||
@@ -0,0 +1,109 @@
|
||||
// Document parse benchmark (PERF-15/16, P7). Bundles the production parser
|
||||
// client and worker with esbuild, then imports synthetic large documents
|
||||
// (hash -> parse -> chunk, as Knowledge import does) twice per round: inline on
|
||||
// the main thread, then through the parse worker. Records the main event-loop
|
||||
// max block with perf_hooks.monitorEventLoopDelay.
|
||||
//
|
||||
// node build/run-document-parse-perf.cjs
|
||||
// GB_DOCPERF_ROUNDS=3 GB_DOCPERF_NODE=1 (skip Electron) GB_DOCPERF_OUTPUT=file.json
|
||||
const { buildSync } = require('esbuild')
|
||||
const { mkdirSync, rmSync, writeFileSync } = require('node:fs')
|
||||
const { join, resolve } = require('node:path')
|
||||
const { monitorEventLoopDelay, performance } = require('node:perf_hooks')
|
||||
|
||||
const root = resolve(__dirname, '..')
|
||||
|
||||
// Measure on the project's Electron (the production runtime), like perf:knowledge.
|
||||
if (process.env.GB_DOCPERF_NODE !== '1') {
|
||||
let electron
|
||||
let version
|
||||
try { electron = require('electron'); version = require('electron/package.json').version } catch { electron = undefined }
|
||||
if (typeof electron === 'string' && process.versions.electron !== version) {
|
||||
const { status } = require('node:child_process').spawnSync(electron, [__filename], {
|
||||
stdio: 'inherit', env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' }
|
||||
})
|
||||
process.exit(status ?? 1)
|
||||
}
|
||||
}
|
||||
|
||||
const rounds = Number(process.env.GB_DOCPERF_ROUNDS || 3)
|
||||
// Under node_modules so the external pdfjs-dist resolves from the repository.
|
||||
const directory = join(root, 'node_modules', '.cache', `goodbuddy-document-parse-perf-${process.pid}`)
|
||||
mkdirSync(directory, { recursive: true })
|
||||
const harness = join(directory, 'harness.cjs')
|
||||
const workerBundle = join(directory, 'document-parse-worker.cjs')
|
||||
const harnessSource = join(directory, 'harness.ts')
|
||||
writeFileSync(harnessSource, [
|
||||
`export * from ${JSON.stringify(join(root, 'src/main/document-parse-client.ts'))}`,
|
||||
`export * from ${JSON.stringify(join(root, 'tests/support/document-parse-worker-fixture.ts'))}`
|
||||
].join('\n'))
|
||||
for (const [entry, outfile] of [[harnessSource, harness], [join(root, 'src/main/document-parse-worker.ts'), workerBundle]]) {
|
||||
buildSync({
|
||||
entryPoints: [entry], bundle: true, platform: 'node', format: 'cjs', target: 'node22',
|
||||
outfile, logLevel: 'error', external: ['pdfjs-dist', 'electron', 'esbuild']
|
||||
})
|
||||
}
|
||||
const {
|
||||
DocumentParseWorkerClient, chunkDocumentOffMain, parseDocumentOffMain, sha256OffMain,
|
||||
createDocx, createLargePdf, sentenceGenerator
|
||||
} = require(harness)
|
||||
|
||||
const settings = {
|
||||
version: 1, mode: 'structure', targetCharacters: 1600, overlapCharacters: 100,
|
||||
parentCharacters: 1600, childCharacters: 400, contextualIndexingEnabled: false
|
||||
}
|
||||
const round = value => Math.round(value * 10) / 10
|
||||
|
||||
async function importOnce(name, buffer, client) {
|
||||
await sha256OffMain(buffer, client)
|
||||
const parsed = await parseDocumentOffMain(name, buffer, undefined, client)
|
||||
return (await chunkDocumentOffMain(parsed, settings, client)).length
|
||||
}
|
||||
|
||||
async function measure(name, buffer, client) {
|
||||
await importOnce(name, buffer, client) // warm modules / worker
|
||||
await new Promise(resolve => setTimeout(resolve, 50))
|
||||
const histogram = monitorEventLoopDelay({ resolution: 5 })
|
||||
histogram.enable()
|
||||
// The histogram only measures from its first timer tick on; a block that
|
||||
// starts before that tick (the inline path is all microtasks) is not seen.
|
||||
await new Promise(resolve => setTimeout(resolve, 30))
|
||||
const started = performance.now()
|
||||
const chunks = await importOnce(name, buffer, client)
|
||||
const wallMs = performance.now() - started
|
||||
await new Promise(resolve => setTimeout(resolve, 20))
|
||||
histogram.disable()
|
||||
return { maxMs: round(histogram.max / 1e6), p99Ms: round(histogram.percentile(99) / 1e6), wallMs: round(wallMs), chunks }
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log(`[docperf] runtime: node ${process.version}${process.versions.electron ? ` (electron ${process.versions.electron})` : ''}`)
|
||||
const sentence = sentenceGenerator(11)
|
||||
const inputs = [
|
||||
{ name: 'large.pdf', buffer: createLargePdf(500, 60) },
|
||||
{ name: 'large.docx', buffer: createDocx(Array.from({ length: 4000 }, () => sentence(25))) },
|
||||
{ name: 'large.md', buffer: Buffer.from(Array.from({ length: 4000 }, (_, index) => `${index % 50 === 0 ? '## Section\n' : ''}${sentence(60)}.`).join('\n')) }
|
||||
]
|
||||
const client = new DocumentParseWorkerClient(workerBundle)
|
||||
const results = []
|
||||
try {
|
||||
for (const input of inputs) {
|
||||
for (let index = 0; index < rounds; index += 1) {
|
||||
// Alternate inline / worker within each round (P7).
|
||||
const inline = await measure(input.name, input.buffer, undefined)
|
||||
const worker = await measure(input.name, input.buffer, client)
|
||||
if (worker.chunks !== inline.chunks) throw new Error(`chunk count mismatch for ${input.name}`)
|
||||
results.push({ input: input.name, bytes: input.buffer.byteLength, round: index + 1, inline, worker })
|
||||
console.log(`[docperf] ${input.name} round ${index + 1}: Main loop max ${inline.maxMs} -> ${worker.maxMs} ms; wall ${inline.wallMs} / ${worker.wallMs} ms; ${inline.chunks} chunks`)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
client.close()
|
||||
}
|
||||
const output = process.env.GB_DOCPERF_OUTPUT
|
||||
if (output) writeFileSync(output, JSON.stringify({ kind: 'goodbuddy-document-parse-benchmark', node: process.version, results }, null, 2))
|
||||
console.table(results.map(r => ({ input: r.input, round: r.round, 'loop max inline/worker ms': `${r.inline.maxMs} / ${r.worker.maxMs}`, 'wall inline/worker ms': `${r.inline.wallMs} / ${r.worker.wallMs}` })))
|
||||
}
|
||||
main().catch(error => { process.exitCode = 1; console.error(error) }).finally(() => {
|
||||
rmSync(directory, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 })
|
||||
})
|
||||
@@ -0,0 +1,148 @@
|
||||
// Knowledge retrieval benchmark (PERF-05). Bundles the production
|
||||
// KnowledgeDatabase with esbuild, seeds synthetic libraries of increasing size
|
||||
// in a temporary SQLite file and times the synchronous search calls that the
|
||||
// Electron Main process runs inline during retrieval. Because these calls are
|
||||
// synchronous, their duration is the time Main is blocked per query.
|
||||
const { buildSync } = require('esbuild')
|
||||
const { createHash } = require('node:crypto')
|
||||
const { mkdtempSync, rmSync, writeFileSync } = require('node:fs')
|
||||
const { tmpdir } = require('node:os')
|
||||
const { join, resolve } = require('node:path')
|
||||
const { monitorEventLoopDelay, performance } = require('node:perf_hooks')
|
||||
|
||||
const root = resolve(__dirname, '..')
|
||||
|
||||
// Measure on the project's Electron (the production runtime). Older runtimes
|
||||
// (Node 24.18 / Electron 43) stall worker threads for hundreds of ms on many
|
||||
// small ArrayBuffer allocations (SQLite BLOB rows); GB_KPERF_NODE=1 opts out.
|
||||
if (process.env.GB_KPERF_NODE !== '1') {
|
||||
let electron
|
||||
let version
|
||||
try { electron = require('electron'); version = require('electron/package.json').version } catch { electron = undefined }
|
||||
if (typeof electron === 'string' && process.versions.electron !== version) {
|
||||
const { status } = require('node:child_process').spawnSync(electron, [__filename], {
|
||||
stdio: 'inherit', env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' }
|
||||
})
|
||||
process.exit(status ?? 1)
|
||||
}
|
||||
}
|
||||
const sizes = (process.env.GB_KPERF_SIZES || '5000,20000,50000').split(',').map(Number)
|
||||
const dimensions = Number(process.env.GB_KPERF_DIMENSIONS || 384)
|
||||
const chunksPerDocument = 100
|
||||
const queries = Number(process.env.GB_KPERF_QUERIES || 15)
|
||||
|
||||
const directory = mkdtempSync(join(tmpdir(), 'goodbuddy-knowledge-perf-'))
|
||||
const bundle = join(directory, 'knowledge-database.cjs')
|
||||
const workerBundle = join(directory, 'readonly-query-worker.cjs')
|
||||
for (const [entry, outfile] of [
|
||||
['src/main/knowledge/knowledge-database.ts', bundle],
|
||||
['src/main/readonly-query-worker.ts', workerBundle]
|
||||
]) {
|
||||
buildSync({
|
||||
entryPoints: [join(root, entry)],
|
||||
bundle: true,
|
||||
platform: 'node',
|
||||
format: 'cjs',
|
||||
target: 'node22',
|
||||
outfile,
|
||||
logLevel: 'error',
|
||||
// The worker reuses AssistantDatabase, which imports Electron-only modules lazily.
|
||||
external: ['electron']
|
||||
})
|
||||
}
|
||||
const { KnowledgeDatabase } = require(bundle)
|
||||
|
||||
// Deterministic pseudo-random generator so runs are comparable.
|
||||
let seed = 42
|
||||
const random = () => ((seed = (seed * 1664525 + 1013904223) >>> 0) / 4294967296)
|
||||
const words = 'lighthouse harbor policy engine renderer stream vector index budget latency schedule archive network storage contract invoice release window memory review'.split(' ')
|
||||
const sentence = n => Array.from({ length: n }, () => words[Math.floor(random() * words.length)]).join(' ')
|
||||
const vector = () => Array.from({ length: dimensions }, () => random() * 2 - 1)
|
||||
const checksum = value => createHash('sha256').update(value).digest('hex')
|
||||
|
||||
function percentile(values, p) {
|
||||
const sorted = [...values].sort((a, b) => a - b)
|
||||
return sorted[Math.min(sorted.length - 1, Math.max(0, Math.ceil((p / 100) * sorted.length) - 1))]
|
||||
}
|
||||
function time(body) {
|
||||
const samples = []
|
||||
body() // warm statement cache and page cache
|
||||
for (let index = 0; index < queries; index += 1) {
|
||||
const started = performance.now()
|
||||
body()
|
||||
samples.push(performance.now() - started)
|
||||
}
|
||||
return { p50: round(percentile(samples, 50)), p95: round(percentile(samples, 95)), max: round(Math.max(...samples)) }
|
||||
}
|
||||
const round = value => Math.round(value * 10) / 10
|
||||
|
||||
// Main event-loop delay while `queries` searches run back to back. A sync
|
||||
// search blocks the loop for its whole duration; a worker search should not.
|
||||
async function loopDelay(body) {
|
||||
const histogram = monitorEventLoopDelay({ resolution: 5 })
|
||||
await body() // warm worker / caches outside the measurement
|
||||
// Let the warm-up's blocked interval drain before sampling.
|
||||
await new Promise(resolve => setTimeout(resolve, 50))
|
||||
histogram.enable()
|
||||
const started = performance.now()
|
||||
for (let index = 0; index < queries; index += 1) {
|
||||
await body()
|
||||
// Yield like a real IPC handler, so the histogram samples between queries.
|
||||
await new Promise(resolve => setImmediate(resolve))
|
||||
}
|
||||
const wallMs = performance.now() - started
|
||||
await new Promise(resolve => setTimeout(resolve, 20))
|
||||
histogram.disable()
|
||||
const ms = value => round(value / 1e6)
|
||||
return { p50: ms(histogram.percentile(50)), p99: ms(histogram.percentile(99)), max: ms(histogram.max), perQueryMs: round(wallMs / queries) }
|
||||
}
|
||||
|
||||
const results = []
|
||||
async function main() {
|
||||
console.log(`[kperf] runtime: node ${process.version}${process.versions.electron ? ` (electron ${process.versions.electron})` : ''}`)
|
||||
for (const size of sizes) {
|
||||
const path = join(directory, `knowledge-${size}.sqlite`)
|
||||
const database = new KnowledgeDatabase(path)
|
||||
database.initialize()
|
||||
const library = database.createKnowledgeBase({ name: `Perf ${size}`, storageMode: 'reference' })
|
||||
const sqlite = database.database
|
||||
const seedStarted = performance.now()
|
||||
for (let documentIndex = 0; documentIndex < size / chunksPerDocument; documentIndex += 1) {
|
||||
const source = database.upsertSource({ knowledgeBaseId: library.id, type: 'file', location: `C:\\perf\\doc-${documentIndex}.md`, displayName: `doc-${documentIndex}.md`, status: 'ready' })
|
||||
const chunks = Array.from({ length: chunksPerDocument }, (_, ordinal) => ({ id: `d${documentIndex}-c${ordinal}`, ordinal, content: sentence(80), location: `line ${ordinal + 1}` }))
|
||||
const document = database.upsertDocument({ knowledgeBaseId: library.id, sourceId: source.id, externalId: `doc-${documentIndex}`, title: `doc-${documentIndex}`, sourceLocation: source.location }, chunks)
|
||||
const stored = sqlite.prepare("SELECT id, index_content FROM chunks WHERE document_id = ? AND enabled = 1 AND role <> 'parent'").all(document.id)
|
||||
database.replaceDocumentEmbeddings(document.id, 'perf', 'perf-model', stored.map(row => ({ chunkId: row.id, contentChecksum: checksum(row.index_content), vector: vector() })))
|
||||
}
|
||||
const seedMs = performance.now() - seedStarted
|
||||
database.close()
|
||||
|
||||
// Reopen so timings start from a fresh connection, like an app restart.
|
||||
const reopened = new KnowledgeDatabase(path)
|
||||
reopened.initialize()
|
||||
const query = vector()
|
||||
const entry = {
|
||||
chunks: size,
|
||||
dimensions,
|
||||
seedSeconds: round(seedMs / 1000),
|
||||
ftsMs: time(() => reopened.search({ knowledgeBaseId: library.id, query: 'lighthouse budget', limit: 40 })),
|
||||
vectorMs: time(() => reopened.vectorSearch({ knowledgeBaseId: library.id, provider: 'perf', model: 'perf-model', vector: query, limit: 40 })),
|
||||
hybridMs: time(() => reopened.hybridSearchWithDiagnostics(hybridOptions(library.id, query)))
|
||||
}
|
||||
// Main event-loop delay: synchronous search on Main vs the readonly worker.
|
||||
entry.mainLoopDelaySyncMs = await loopDelay(async () => reopened.hybridSearchWithDiagnostics(hybridOptions(library.id, query)))
|
||||
reopened.enableReadonlyWorker(workerBundle)
|
||||
entry.mainLoopDelayWorkerMs = await loopDelay(() => reopened.hybridSearchWithDiagnosticsAsync(hybridOptions(library.id, query)))
|
||||
reopened.close()
|
||||
results.push(entry)
|
||||
console.log(`[kperf] ${size} chunks: fts p95 ${entry.ftsMs.p95} ms, vector p95 ${entry.vectorMs.p95} ms, hybrid p95 ${entry.hybridMs.p95} ms (seed ${entry.seedSeconds} s)`)
|
||||
console.log(`[kperf] ${size} chunks: Main loop delay max sync ${entry.mainLoopDelaySyncMs.max} ms vs worker ${entry.mainLoopDelayWorkerMs.max} ms (p99 ${entry.mainLoopDelaySyncMs.p99} / ${entry.mainLoopDelayWorkerMs.p99} ms; per query ${entry.mainLoopDelaySyncMs.perQueryMs} / ${entry.mainLoopDelayWorkerMs.perQueryMs} ms)`)
|
||||
}
|
||||
const output = process.env.GB_KPERF_OUTPUT
|
||||
if (output) writeFileSync(output, JSON.stringify({ kind: 'goodbuddy-knowledge-retrieval-benchmark', node: process.version, results }, null, 2))
|
||||
console.table(results.map(r => ({ chunks: r.chunks, 'fts p50/p95': `${r.ftsMs.p50} / ${r.ftsMs.p95}`, 'vector p50/p95': `${r.vectorMs.p50} / ${r.vectorMs.p95}`, 'hybrid p50/p95': `${r.hybridMs.p50} / ${r.hybridMs.p95}`, 'loop max sync/worker': `${r.mainLoopDelaySyncMs.max} / ${r.mainLoopDelayWorkerMs.max}` })))
|
||||
}
|
||||
const hybridOptions = (knowledgeBaseId, vector) => ({ knowledgeBaseId, query: 'lighthouse budget', limit: 40, provider: 'perf', model: 'perf-model', vector, graphEnabled: false, candidateMultiplier: 1 })
|
||||
main().catch(error => { process.exitCode = 1; console.error(error) }).finally(() => {
|
||||
rmSync(directory, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 })
|
||||
})
|
||||
@@ -0,0 +1,272 @@
|
||||
// Main-process database benchmark (PERF-15). Bundles the production
|
||||
// AssistantDatabase and readonly worker with esbuild, seeds a temporary
|
||||
// assistant database (default 1,000 conversations / 20,000 messages and 24,000
|
||||
// activity records) and measures, for the IPC paths the renderer hits often:
|
||||
// - per-call time
|
||||
// - Main event-loop delay while the calls run back to back
|
||||
// for the previous synchronous path ("sync") and the current path ("after").
|
||||
// conversations:list-summaries sync listConversationSummaries vs worker
|
||||
// conversations:get sync getConversation vs worker
|
||||
// activity-history:replace legacy replace (zod parse + BEGIN IMMEDIATE +
|
||||
// JSON.stringify of every record) vs current
|
||||
// activity-history:update one changed record via the incremental API
|
||||
// activity startup load whole list (pre-paging get) vs first page (200)
|
||||
// + summary on the readonly worker
|
||||
// activity page / summary sync paths, for reference
|
||||
// Env: GB_MPERF_CONVERSATIONS, GB_MPERF_MESSAGES, GB_MPERF_ACTIVITY,
|
||||
// GB_MPERF_CALLS, GB_MPERF_OUTPUT (JSON), GB_MPERF_NODE=1 (skip Electron).
|
||||
const { buildSync } = require('esbuild')
|
||||
const { mkdtempSync, rmSync, writeFileSync, mkdirSync } = require('node:fs')
|
||||
const { tmpdir } = require('node:os')
|
||||
const { join, resolve } = require('node:path')
|
||||
const { randomUUID } = require('node:crypto')
|
||||
const { monitorEventLoopDelay, performance } = require('node:perf_hooks')
|
||||
|
||||
const root = resolve(__dirname, '..')
|
||||
|
||||
if (process.env.GB_MPERF_NODE !== '1') {
|
||||
let electron
|
||||
let version
|
||||
try { electron = require('electron'); version = require('electron/package.json').version } catch { electron = undefined }
|
||||
if (typeof electron === 'string' && process.versions.electron !== version) {
|
||||
const { status } = require('node:child_process').spawnSync(electron, [__filename], {
|
||||
stdio: 'inherit', env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' }
|
||||
})
|
||||
process.exit(status ?? 1)
|
||||
}
|
||||
}
|
||||
|
||||
const conversationCount = Number(process.env.GB_MPERF_CONVERSATIONS || 1000)
|
||||
const messageCount = Number(process.env.GB_MPERF_MESSAGES || 20000)
|
||||
const activityCount = Number(process.env.GB_MPERF_ACTIVITY || 24000)
|
||||
const calls = Number(process.env.GB_MPERF_CALLS || 15)
|
||||
|
||||
const directory = mkdtempSync(join(tmpdir(), 'goodbuddy-main-perf-'))
|
||||
const bundle = join(directory, 'assistant-database.cjs')
|
||||
const workerBundle = join(directory, 'readonly-query-worker.cjs')
|
||||
const schemaBundle = join(directory, 'assistant-contracts.cjs')
|
||||
for (const [entry, outfile] of [
|
||||
['src/main/assistant/assistant-database.ts', bundle],
|
||||
['src/main/readonly-query-worker.ts', workerBundle],
|
||||
['src/shared/assistant-contracts.ts', schemaBundle]
|
||||
]) {
|
||||
buildSync({
|
||||
entryPoints: [join(root, entry)], bundle: true, platform: 'node', format: 'cjs',
|
||||
target: 'node22', outfile, logLevel: 'error', external: ['electron']
|
||||
})
|
||||
}
|
||||
const { AssistantDatabase } = require(bundle)
|
||||
const { activityHistorySnapshotSchema } = require(schemaBundle)
|
||||
|
||||
let seed = 42
|
||||
const random = () => ((seed = (seed * 1664525 + 1013904223) >>> 0) / 4294967296)
|
||||
const words = 'lighthouse harbor policy engine renderer stream vector index budget latency schedule archive network storage contract invoice release window memory review 发布 审批 流程'.split(' ')
|
||||
const sentence = n => Array.from({ length: n }, () => words[Math.floor(random() * words.length)]).join(' ')
|
||||
const round = value => Math.round(value * 10) / 10
|
||||
const percentile = (values, p) => {
|
||||
const sorted = [...values].sort((a, b) => a - b)
|
||||
return sorted[Math.min(sorted.length - 1, Math.max(0, Math.ceil((p / 100) * sorted.length) - 1))]
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs `body` `calls` times (after one warm-up) and reports the per-call wall
|
||||
* time and the Main event-loop delay histogram (p99/max) over the run.
|
||||
*/
|
||||
async function measure(body) {
|
||||
await body(-1)
|
||||
await new Promise(resolve => setTimeout(resolve, 50))
|
||||
// monitorEventLoopDelay is bound to the ~15 ms Windows timer granularity, so
|
||||
// the longest block is measured as the largest gap between setImmediate
|
||||
// ticks (each tick is one event-loop turn).
|
||||
const histogram = monitorEventLoopDelay({ resolution: 1 })
|
||||
histogram.enable()
|
||||
let probing = true
|
||||
let last = performance.now()
|
||||
let maxGap = 0
|
||||
const tick = () => {
|
||||
const now = performance.now()
|
||||
maxGap = Math.max(maxGap, now - last)
|
||||
last = now
|
||||
if (probing) setImmediate(tick)
|
||||
}
|
||||
setImmediate(tick)
|
||||
const samples = []
|
||||
for (let index = 0; index < calls; index += 1) {
|
||||
const started = performance.now()
|
||||
await body(index)
|
||||
samples.push(performance.now() - started)
|
||||
// Yield like the IPC loop does between messages.
|
||||
await new Promise(resolve => setImmediate(resolve))
|
||||
}
|
||||
await new Promise(resolve => setTimeout(resolve, 20))
|
||||
probing = false
|
||||
histogram.disable()
|
||||
return {
|
||||
callP50: round(percentile(samples, 50)), callP95: round(percentile(samples, 95)),
|
||||
loopP99: round(histogram.percentile(99) / 1e6), loopMax: round(maxGap)
|
||||
}
|
||||
}
|
||||
|
||||
/** The pre-PERF-15 replace implementation, kept as the "before" reference. */
|
||||
function legacyReplace(raw, cache, input) {
|
||||
const snapshot = activityHistorySnapshotSchema.parse(input)
|
||||
raw.exec('BEGIN IMMEDIATE')
|
||||
try {
|
||||
const { data_version: dataVersion } = raw.prepare('PRAGMA data_version').get()
|
||||
let previous = cache.value?.dataVersion === dataVersion ? cache.value.records : undefined
|
||||
if (!previous) {
|
||||
previous = new Map(raw.prepare('SELECT record_key, record_json FROM activity_history_records').all()
|
||||
.map(row => [row.record_key, row.record_json]))
|
||||
}
|
||||
const records = new Map()
|
||||
const occurrences = new Map()
|
||||
const order = []
|
||||
// order_seq only satisfies the current schema; the old code kept order in the JSON list below.
|
||||
const upsert = raw.prepare(`INSERT INTO activity_history_records (record_key, record_json, order_seq) VALUES (?, ?, ?)
|
||||
ON CONFLICT(record_key) DO UPDATE SET record_json = excluded.record_json`)
|
||||
for (const record of snapshot.records) {
|
||||
const occurrence = occurrences.get(record.id) ?? 0
|
||||
occurrences.set(record.id, occurrence + 1)
|
||||
const key = JSON.stringify([record.id, occurrence])
|
||||
const json = JSON.stringify(record)
|
||||
order.push(key)
|
||||
if (previous.get(key) !== json) upsert.run(key, json, snapshot.records.length - order.length + 1)
|
||||
records.set(key, json)
|
||||
}
|
||||
const remove = raw.prepare('DELETE FROM activity_history_records WHERE record_key = ?')
|
||||
for (const key of previous.keys()) if (!records.has(key)) remove.run(key)
|
||||
const orderJson = JSON.stringify(order)
|
||||
const incomplete = Number(snapshot.legacyHistoryMayBeIncomplete)
|
||||
raw.prepare(`UPDATE activity_history SET record_order_json = ?, legacy_history_may_be_incomplete = ?
|
||||
WHERE singleton = 1 AND (record_order_json != ? OR legacy_history_may_be_incomplete != ?)`)
|
||||
.run(orderJson, incomplete, orderJson, incomplete)
|
||||
raw.exec('COMMIT')
|
||||
cache.value = { records, dataVersion }
|
||||
} catch (error) {
|
||||
raw.exec('ROLLBACK')
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log(`[mperf] runtime: node ${process.version}${process.versions.electron ? ` (electron ${process.versions.electron})` : ''}`)
|
||||
const workspace = join(directory, 'workspace')
|
||||
mkdirSync(workspace)
|
||||
const path = join(directory, 'assistant.sqlite')
|
||||
const seeding = new AssistantDatabase(path)
|
||||
seeding.initialize(workspace)
|
||||
const seedStarted = performance.now()
|
||||
const projectId = seeding.listProjects()[0].id
|
||||
// Message counts follow a skewed distribution; the first conversation is the largest.
|
||||
const weights = Array.from({ length: conversationCount }, (_, index) => 1 / (1 + index * 0.01))
|
||||
const weightSum = weights.reduce((sum, value) => sum + value, 0)
|
||||
const counts = weights.map(weight => Math.max(1, Math.round(messageCount * weight / weightSum)))
|
||||
const ids = counts.map(() => randomUUID())
|
||||
for (let start = 0; start < conversationCount; start += 50) {
|
||||
seeding.saveLocalConversations(ids.slice(start, start + 50).map((id, offset) => {
|
||||
const index = start + offset
|
||||
return {
|
||||
header: { id, projectId, title: `Conversation ${index} ${sentence(3)}`, updatedAt: 1_700_000_000_000 + index },
|
||||
messages: Array.from({ length: counts[index] }, (_, at) => ({
|
||||
id: randomUUID(), role: at % 2 === 0 ? 'user' : 'assistant', state: 'complete',
|
||||
content: sentence(at % 2 === 0 ? 30 : 120), createdAt: 1_700_000_000_000 + index * 1000 + at
|
||||
}))
|
||||
}
|
||||
}))
|
||||
}
|
||||
const records = Array.from({ length: activityCount }, (_, index) => ({
|
||||
id: randomUUID(), conversationId: ids[index % conversationCount], requestId: randomUUID(),
|
||||
...(index % 2 === 0 ? { callId: `call-${index}` } : {}),
|
||||
scope: index % 3 === 0 ? { kind: 'project', projectId, projectName: 'Perf' } : { kind: 'global' },
|
||||
kind: index % 5 === 0 ? 'request' : 'tool', title: `Tool ${index}`, detail: sentence(40),
|
||||
status: 'completed', createdAt: 1_700_000_000_000 + index
|
||||
}))
|
||||
seeding.replaceActivityHistory({ records, legacyHistoryMayBeIncomplete: false })
|
||||
const seedSeconds = round((performance.now() - seedStarted) / 1000)
|
||||
seeding.close()
|
||||
|
||||
const database = new AssistantDatabase(path)
|
||||
database.initialize(workspace)
|
||||
const raw = database.database
|
||||
const totalMessages = counts.reduce((sum, value) => sum + value, 0)
|
||||
const largest = ids[0]
|
||||
const typical = ids[Math.floor(conversationCount / 2)]
|
||||
// The renderer retains the current conversation's history.
|
||||
const detailIds = [ids[1]]
|
||||
const results = { conversations: conversationCount, messages: totalMessages, activityRecords: activityCount,
|
||||
largestConversationMessages: counts[0], seedSeconds }
|
||||
|
||||
results.listSummariesSync = await measure(async () => database.listConversationSummaries(detailIds))
|
||||
results.getLargestSync = await measure(async () => database.getConversation(largest))
|
||||
results.getTypicalSync = await measure(async () => database.getConversation(typical))
|
||||
database.enableReadonlyWorker(workerBundle)
|
||||
results.listSummariesWorker = await measure(() => database.listConversationSummariesAsync(detailIds))
|
||||
results.getLargestWorker = await measure(() => database.getConversationAsync(largest))
|
||||
results.getTypicalWorker = await measure(() => database.getConversationAsync(typical))
|
||||
|
||||
// The renderer sends a fresh structured clone of the whole list every save.
|
||||
const unchanged = () => structuredClone({ records, legacyHistoryMayBeIncomplete: false })
|
||||
const oneChanged = index => {
|
||||
const snapshot = unchanged()
|
||||
const record = snapshot.records[(index + 2) % 50]
|
||||
record.detail = `${record.detail} ${index}`
|
||||
return snapshot
|
||||
}
|
||||
// Inputs are cloned up front: cloning happens in the renderer / IPC layer.
|
||||
const run = async (makeInput, call) => {
|
||||
const inputs = Array.from({ length: calls + 1 }, (_, index) => makeInput(index - 1))
|
||||
return measure(async index => call(inputs[index + 1]))
|
||||
}
|
||||
const legacyCache = {}
|
||||
results.replaceUnchangedLegacy = await run(unchanged, input => legacyReplace(raw, legacyCache, input))
|
||||
results.replaceOneChangedLegacy = await run(oneChanged, input => legacyReplace(raw, legacyCache, input))
|
||||
// Restore a consistent state for the current implementation's cache.
|
||||
database.replaceActivityHistory({ records, legacyHistoryMayBeIncomplete: false })
|
||||
results.replaceUnchangedAfter = await run(unchanged, input => database.replaceActivityHistory(input))
|
||||
results.replaceOneChangedAfter = await run(oneChanged, input => database.replaceActivityHistory(input))
|
||||
results.updateOneRecord = await run(index => {
|
||||
const record = { ...records[(index + 2) % 50], detail: `incremental ${index}`, status: 'running' }
|
||||
return { changes: [{ type: 'upsert', position: 'front', record }] }
|
||||
}, input => database.updateActivityHistory(input))
|
||||
|
||||
// Startup: the renderer used to load the whole list; now the first page and the summary.
|
||||
const firstPageRequest = { limit: 200 }
|
||||
const summaryRequest = () => ({ conversationIds: [...new Set(database.getActivityHistoryPage(firstPageRequest).records.map(record => record.conversationId))] })
|
||||
results.activityFullLoad = await measure(async () => database.getActivityHistory())
|
||||
results.activityFirstPageSync = await measure(async () => database.getActivityHistoryPage(firstPageRequest))
|
||||
results.activitySummarySync = await measure(async () => database.getActivityHistorySummary(summaryRequest()))
|
||||
results.activityStartupWorker = await measure(async () => {
|
||||
const page = await database.getActivityHistoryPageAsync(firstPageRequest)
|
||||
await database.getActivityHistorySummaryAsync({ conversationIds: [...new Set(page.records.map(record => record.conversationId))] })
|
||||
})
|
||||
results.activityFullLoadBytes = JSON.stringify(database.getActivityHistory()).length
|
||||
results.activityFirstPageBytes = JSON.stringify(database.getActivityHistoryPage(firstPageRequest)).length
|
||||
database.close()
|
||||
|
||||
console.log(`[mperf] seeded ${results.conversations} conversations / ${results.messages} messages (largest ${results.largestConversationMessages}), ${results.activityRecords} activity records in ${seedSeconds} s`)
|
||||
const rows = [
|
||||
['conversations:list-summaries', results.listSummariesSync, results.listSummariesWorker],
|
||||
[`conversations:get (${counts[0]} msgs)`, results.getLargestSync, results.getLargestWorker],
|
||||
[`conversations:get (${counts[Math.floor(conversationCount / 2)]} msgs)`, results.getTypicalSync, results.getTypicalWorker],
|
||||
['activity replace, unchanged', results.replaceUnchangedLegacy, results.replaceUnchangedAfter],
|
||||
['activity replace, 1 changed', results.replaceOneChangedLegacy, results.replaceOneChangedAfter],
|
||||
['activity update, 1 record', results.replaceOneChangedLegacy, results.updateOneRecord],
|
||||
['activity startup: full list vs page+summary (worker)', results.activityFullLoad, results.activityStartupWorker],
|
||||
['activity first page (200), sync', results.activityFullLoad, results.activityFirstPageSync],
|
||||
['activity summary, sync', results.activityFullLoad, results.activitySummarySync]
|
||||
]
|
||||
console.table(rows.map(([name, before, after]) => ({
|
||||
path: name,
|
||||
'before call p50/p95 ms': `${before.callP50} / ${before.callP95}`,
|
||||
'before loop max ms': before.loopMax,
|
||||
'after call p50/p95 ms': `${after.callP50} / ${after.callP95}`,
|
||||
'after loop max ms': after.loopMax
|
||||
})))
|
||||
console.log(`[mperf] activity payload: full list ${results.activityFullLoadBytes} bytes, first page ${results.activityFirstPageBytes} bytes`)
|
||||
const output = process.env.GB_MPERF_OUTPUT
|
||||
if (output) writeFileSync(output, JSON.stringify({ kind: 'goodbuddy-main-database-benchmark', node: process.version, results }, null, 2))
|
||||
}
|
||||
|
||||
main().catch(error => { process.exitCode = 1; console.error(error) }).finally(() => {
|
||||
rmSync(directory, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 })
|
||||
})
|
||||
@@ -148,7 +148,7 @@ async function main() {
|
||||
[
|
||||
'appId: live.digiman.goodbuddy.harness-smoke',
|
||||
'productName: GoodBuddyHarnessSmoke',
|
||||
'electronVersion: "43.2.0"',
|
||||
`electronVersion: "${require('electron/package.json').version}"`,
|
||||
'asar: true',
|
||||
'npmRebuild: false',
|
||||
'files:',
|
||||
|
||||
@@ -0,0 +1,381 @@
|
||||
// Cold-start benchmark supervisor (PERF-01).
|
||||
// Launches the current production build (out/, from `npm run build:bundle`)
|
||||
// GB_PERF_STARTUP_RUNS times (default 5) per variant through
|
||||
// tests/support/startup-perf-driver.mjs, each launch in a fresh temporary
|
||||
// user-data directory, and reports median / p95 per startup phase.
|
||||
//
|
||||
// Variants (interleaved each round, per docs P7):
|
||||
// first-run empty user-data dir (creates databases, shows release notes)
|
||||
// empty copy of a prepared profile: release notes seen, startup update
|
||||
// check off, 0 conversations
|
||||
// seeded-300 same as empty plus GB_PERF_STARTUP_SEED (default 300)
|
||||
// synthetic conversations, reusing the PERF-11 seed shape
|
||||
//
|
||||
// Isolation: temporary profile/session/logs, a loopback fake model on an
|
||||
// ephemeral port (GB_PERF_MODEL_PORT to pin one; 11434 is left alone), all
|
||||
// non-loopback requests blocked in the driver. Output has phase names, times
|
||||
// and counts only.
|
||||
//
|
||||
// Env: GB_PERF_STARTUP_RUNS, GB_PERF_STARTUP_SEED, GB_PERF_STARTUP_VARIANTS
|
||||
// (comma list), GB_PERF_STARTUP_SKIP_BUILD=1, GB_PERF_STARTUP_PROFILE=1 (one
|
||||
// extra CPU-profiled launch per variant, excluded from the table),
|
||||
// GB_PERF_OUTPUT (report directory), GB_PERF_KEEP_PROFILE=1,
|
||||
// GB_PERF_STARTUP_APP_ROOT (checkout/worktree to build and launch; default .).
|
||||
// Variant "real" (opt-in) launches on a fresh copy of GB_PERF_STARTUP_REAL_DATA.
|
||||
const { spawn, spawnSync, execFileSync } = require('node:child_process')
|
||||
const { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } = require('node:fs')
|
||||
const { createServer } = require('node:http')
|
||||
const { cpus, tmpdir, totalmem } = require('node:os')
|
||||
const { join, resolve } = require('node:path')
|
||||
const { npmInvocation } = require('./npm-invocation.cjs')
|
||||
|
||||
const root = resolve(__dirname, '..')
|
||||
// The app under test. Defaults to this checkout; point it at a temporary
|
||||
// worktree to measure a baseline or a clean HEAD while this tree is mid-edit.
|
||||
const appRoot = resolve(process.env.GB_PERF_STARTUP_APP_ROOT || root)
|
||||
const runs = Math.max(1, Number(process.env.GB_PERF_STARTUP_RUNS || 5))
|
||||
const seedCount = Number(process.env.GB_PERF_STARTUP_SEED || 300)
|
||||
const seededName = `seeded-${seedCount}`
|
||||
const variants = (process.env.GB_PERF_STARTUP_VARIANTS || `first-run,empty,${seededName}`).split(',').map(v => v.trim()).filter(Boolean)
|
||||
const requestedPort = Number(process.env.GB_PERF_MODEL_PORT || 0)
|
||||
const launchTimeoutMs = Number(process.env.GB_PERF_STARTUP_LAUNCH_TIMEOUT_MS || 150_000)
|
||||
const keepProfile = process.env.GB_PERF_KEEP_PROFILE === '1'
|
||||
const cpuProfile = process.env.GB_PERF_STARTUP_PROFILE === '1'
|
||||
|
||||
// ------------------------------------------------------------------ build
|
||||
|
||||
function newestMtime(path) {
|
||||
let newest = 0
|
||||
const stack = [path]
|
||||
while (stack.length) {
|
||||
const current = stack.pop()
|
||||
let info
|
||||
try { info = statSync(current) } catch { continue }
|
||||
if (info.isDirectory()) {
|
||||
for (const entry of readdirSync(current)) if (entry !== 'node_modules') stack.push(join(current, entry))
|
||||
} else if (!/\.test\.tsx?$/.test(current)) {
|
||||
newest = Math.max(newest, info.mtimeMs)
|
||||
}
|
||||
}
|
||||
return newest
|
||||
}
|
||||
|
||||
function ensureBuild() {
|
||||
const outputs = ['out/main/index.js', 'out/preload/index.cjs', 'out/renderer/index.html'].map(file => join(appRoot, file))
|
||||
const missing = outputs.some(file => !existsSync(file))
|
||||
const builtAt = missing ? 0 : Math.min(...outputs.map(file => statSync(file).mtimeMs))
|
||||
const sourceAt = Math.max(...['src/main', 'src/preload', 'src/renderer', 'src/shared', 'electron.vite.config.ts', 'package.json'].map(p => newestMtime(join(appRoot, p))))
|
||||
const stale = missing || sourceAt > builtAt
|
||||
if (!stale) return { rebuilt: false }
|
||||
if (process.env.GB_PERF_STARTUP_SKIP_BUILD === '1') {
|
||||
if (missing) throw new Error('Missing production build and GB_PERF_STARTUP_SKIP_BUILD=1')
|
||||
console.log('[startup] build is older than src/, measuring it anyway (GB_PERF_STARTUP_SKIP_BUILD=1)')
|
||||
return { rebuilt: false, stale: true }
|
||||
}
|
||||
console.log(`[startup] ${missing ? 'no production build' : 'production build is older than src/'}; running npm run build:bundle`)
|
||||
const npm = npmInvocation(appRoot)
|
||||
const result = spawnSync(npm.command, [...npm.prefixArgs, 'run', 'build:bundle'], { cwd: appRoot, stdio: 'inherit', shell: npm.prefixArgs.length === 0 && process.platform === 'win32' })
|
||||
if (result.status !== 0) throw new Error(`npm run build:bundle failed (${result.status})`)
|
||||
return { rebuilt: true }
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------- fake model
|
||||
|
||||
function startModelServer(ledger) {
|
||||
const server = createServer((request, response) => {
|
||||
ledger.push({ method: request.method, path: request.url })
|
||||
if (request.method === 'GET' && request.url?.endsWith('/models')) {
|
||||
response.writeHead(200, { 'content-type': 'application/json' })
|
||||
response.end(JSON.stringify({ object: 'list', data: [{ id: 'qwen3', object: 'model' }] }))
|
||||
return
|
||||
}
|
||||
if (request.method === 'POST' && request.url?.endsWith('/chat/completions')) {
|
||||
request.resume()
|
||||
request.on('end', () => {
|
||||
response.writeHead(200, { 'content-type': 'application/json' })
|
||||
response.end(JSON.stringify({ id: 'perf', object: 'chat.completion', model: 'qwen3', choices: [{ index: 0, finish_reason: 'stop', message: { role: 'assistant', content: 'ok' } }], usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 } }))
|
||||
})
|
||||
return
|
||||
}
|
||||
response.writeHead(404).end()
|
||||
})
|
||||
return new Promise((resolvePromise, reject) => {
|
||||
server.once('error', reject)
|
||||
server.listen(requestedPort, '127.0.0.1', () => resolvePromise(server))
|
||||
})
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ launch
|
||||
|
||||
function baseEnvironment(modelPort, workspace) {
|
||||
const environment = { ...process.env, GB_PERF_ROOT: appRoot, GB_PERF_STARTUP_MARKS: '1' }
|
||||
delete environment.ELECTRON_RUN_AS_NODE
|
||||
delete environment.ELECTRON_RENDERER_URL
|
||||
for (const key of ['GOODBUDDY_MODEL_API_KEY', 'GOODBUDDY_MODEL_BASE_URL', 'GOODBUDDY_MODEL_NAME', 'GOODBUDDY_BIGTOKEN_API_KEY', 'GOODBUDDY_BIGTOKEN_BASE_URL', 'GOODBUDDY_BIGTOKEN_MODEL']) delete environment[key]
|
||||
environment.GOODBUDDY_MODEL_API_KEY = 'perf-loopback'
|
||||
environment.GOODBUDDY_MODEL_BASE_URL = `http://127.0.0.1:${modelPort}/v1`
|
||||
environment.GOODBUDDY_MODEL_NAME = 'qwen3'
|
||||
environment.GOODBUDDY_WORKSPACE = workspace
|
||||
return environment
|
||||
}
|
||||
|
||||
async function launch({ directory, environment, mode = 'measure', seed = 0, expectConversations = 0, profilePath }) {
|
||||
const resultPath = join(directory, 'startup-result.json')
|
||||
const env = { ...environment, GB_STARTUP_DIR: directory, GB_STARTUP_RESULT: resultPath, GB_STARTUP_MODE: mode, GB_STARTUP_SEED: String(seed), GB_STARTUP_EXPECT_CONVERSATIONS: String(expectConversations) }
|
||||
if (profilePath) env.GB_STARTUP_CPU_PROFILE = profilePath
|
||||
const stderr = []
|
||||
const spawnAt = Date.now()
|
||||
const child = spawn(require('electron'), [join(root, 'tests', 'support', 'startup-perf-driver.mjs')], { cwd: root, env, stdio: ['ignore', 'ignore', 'pipe'], windowsHide: false })
|
||||
child.stderr.on('data', chunk => { if (stderr.length < 200) stderr.push(String(chunk)) })
|
||||
let timedOut = false
|
||||
const timer = setTimeout(() => {
|
||||
timedOut = true
|
||||
if (process.platform === 'win32') spawn('taskkill', ['/pid', String(child.pid), '/T', '/F'], { stdio: 'ignore' })
|
||||
else child.kill('SIGKILL')
|
||||
}, launchTimeoutMs)
|
||||
const exit = await new Promise(resolvePromise => child.once('exit', code => resolvePromise(code)))
|
||||
const exitAt = Date.now()
|
||||
clearTimeout(timer)
|
||||
const report = existsSync(resultPath) ? JSON.parse(readFileSync(resultPath, 'utf8')) : { status: 'failed', error: timedOut ? 'launch timeout' : 'driver exited without a report' }
|
||||
if (exit !== 0 || timedOut) report.status = 'failed'
|
||||
report.spawnAt = spawnAt
|
||||
report.exitMs = exitAt - spawnAt
|
||||
// stderr is kept only for failures, and only its first line (no user data exists in these profiles).
|
||||
if (report.status !== 'passed') report.stderrHead = stderr.join('').split(/\r?\n/).filter(Boolean).slice(0, 8)
|
||||
return report
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------- analysis
|
||||
|
||||
function phases(report) {
|
||||
const t = report.times
|
||||
const r = report.renderer
|
||||
const s = report.spawnAt
|
||||
const marks = Object.fromEntries((report.startupMarks ?? []).map(m => [m.name, m.at]))
|
||||
const d = (from, to) => (from === undefined || from === null || to === undefined || to === null) ? null : to - from
|
||||
const result = {
|
||||
'spawn -> driver start (Electron boot)': d(s, t.driverStart),
|
||||
'driver start -> Main import done': d(t.driverStart, t.mainImportEnd),
|
||||
'spawn -> app ready': d(s, t.appReady),
|
||||
'app ready -> BrowserWindow created': d(t.appReady, t.windowCreated),
|
||||
'window created -> loadMainWindow': d(t.windowCreated, marks['main:load-main-window']),
|
||||
'loadMainWindow -> ready-to-show': d(marks['main:load-main-window'], t.readyToShow),
|
||||
'window created -> ready-to-show': d(t.windowCreated, t.readyToShow),
|
||||
'ready-to-show -> first visible frame': d(t.readyToShow, r.firstVisibleFrame),
|
||||
'ready-to-show -> composer interactive': d(t.readyToShow, r.composerInteractive),
|
||||
'spawn -> ready-to-show': d(s, t.readyToShow),
|
||||
'spawn -> first visible frame': d(s, r.firstVisibleFrame),
|
||||
// Only reported by Chromium when the first paint happens while visible.
|
||||
'spawn -> first contentful paint': d(s, r.firstContentfulPaint),
|
||||
'spawn -> composer present': d(s, r.composerPresent),
|
||||
'spawn -> composer interactive': d(s, r.composerInteractive),
|
||||
'spawn -> sidebar populated (seeded)': d(s, r.sidebarPopulated),
|
||||
'composer interactive -> sidebar populated': d(r.composerInteractive, r.sidebarPopulated)
|
||||
}
|
||||
// Main phases (GB_PERF_STARTUP_MARKS spans; parallel spans overlap).
|
||||
const main = {
|
||||
'main: module import (index.js eval)': d(t.mainImportStart, marks['main:module-evaluated']),
|
||||
'main: whenReady -> window created': d(marks['main:when-ready'], marks['main:create-window:end']),
|
||||
'main: createMainWindow()': d(marks['main:create-window:start'], marks['main:create-window:end']),
|
||||
'main: tray': d(marks['main:create-window:end'], marks['main:tray-built'])
|
||||
}
|
||||
for (const m of report.startupMarks ?? []) {
|
||||
if (!m.name.endsWith(':start')) continue
|
||||
const base = m.name.slice(0, -':start'.length)
|
||||
if (base === 'main:create-window') continue
|
||||
main[`${base.replace(/^main:/, 'main: ')}`] = d(m.at, marks[`${base}:end`])
|
||||
// Synchronous part of the span: from the call until the first await yields.
|
||||
if (marks[`${base}:sync-end`] !== undefined) main[`${base.replace(/^main:/, 'main: ')} (sync part)`] = d(m.at, marks[`${base}:sync-end`])
|
||||
}
|
||||
main['main: whenReady -> loadMainWindow (total)'] = d(marks['main:when-ready'], marks['main:load-main-window'])
|
||||
// Critical path in Main: consecutive top-level marks (spans that run in
|
||||
// parallel inside the prerequisites are excluded), so each gap is the time
|
||||
// Main spent between two points of the sequential startup function.
|
||||
const nested = /^main:(knowledge-init|knowledge-gateway-start|runtime-hydrate|assistant-db-init)/
|
||||
const sequence = (report.startupMarks ?? []).filter(m => !nested.test(m.name) && !m.name.endsWith(':sync-end')).sort((a, b) => a.at - b.at)
|
||||
for (let index = 1; index < sequence.length; index += 1) {
|
||||
main[`gap: ${sequence[index - 1].name} -> ${sequence[index].name}`] = sequence[index].at - sequence[index - 1].at
|
||||
}
|
||||
const renderer = {
|
||||
'renderer: navigation start -> DOMContentLoaded': d(r.navigationStart, r.domContentLoaded),
|
||||
'renderer: DOMContentLoaded -> composer present': d(r.domContentLoaded, r.composerPresent),
|
||||
'renderer: navigation start -> composer present': d(r.navigationStart, r.composerPresent)
|
||||
}
|
||||
return { ...result, ...main, ...renderer }
|
||||
}
|
||||
|
||||
function percentile(values, p) {
|
||||
if (!values.length) return null
|
||||
const sorted = [...values].sort((a, b) => a - b)
|
||||
// Nearest rank; with 5 samples p95 is the maximum.
|
||||
return sorted[Math.min(sorted.length - 1, Math.max(0, Math.ceil((p / 100) * sorted.length) - 1))]
|
||||
}
|
||||
|
||||
function summarize(reportsByVariant) {
|
||||
const rows = new Map()
|
||||
for (const [variant, reports] of Object.entries(reportsByVariant)) {
|
||||
const passed = reports.filter(r => r.status === 'passed')
|
||||
const perRun = passed.map(phases)
|
||||
for (const name of new Set(perRun.flatMap(Object.keys))) {
|
||||
const values = perRun.map(p => p[name]).filter(v => typeof v === 'number' && Number.isFinite(v))
|
||||
const row = rows.get(name) ?? {}
|
||||
row[variant] = values.length ? { median: percentile(values, 50), p95: percentile(values, 95), n: values.length } : null
|
||||
rows.set(name, row)
|
||||
}
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
function sourceInfo() {
|
||||
try {
|
||||
const commit = execFileSync('git', ['rev-parse', '--short', 'HEAD'], { cwd: appRoot, encoding: 'utf8' }).trim()
|
||||
const dirtyFiles = execFileSync('git', ['status', '--porcelain'], { cwd: appRoot, encoding: 'utf8' }).split('\n').filter(Boolean).length
|
||||
return { commit, dirty: dirtyFiles > 0, dirtyFiles, appRoot: appRoot === root ? '.' : 'GB_PERF_STARTUP_APP_ROOT' }
|
||||
} catch {
|
||||
return { commit: null }
|
||||
}
|
||||
}
|
||||
|
||||
const fmt = v => (v === null || v === undefined ? '-' : Math.round(v).toString())
|
||||
|
||||
// ------------------------------------------------------------------- main
|
||||
|
||||
async function main() {
|
||||
const build = ensureBuild()
|
||||
const runDirectory = mkdtempSync(join(tmpdir(), 'goodbuddy-startup-perf-'))
|
||||
const outputDirectory = resolve(process.env.GB_PERF_OUTPUT || join(runDirectory, 'artifacts'))
|
||||
mkdirSync(outputDirectory, { recursive: true })
|
||||
const workspace = join(runDirectory, 'workspace')
|
||||
mkdirSync(workspace, { recursive: true })
|
||||
const ledger = []
|
||||
let server
|
||||
try {
|
||||
server = await startModelServer(ledger)
|
||||
} catch (error) {
|
||||
throw new Error(`Cannot listen on 127.0.0.1:${requestedPort} for the fake model (${error.code ?? error.message}); set GB_PERF_MODEL_PORT to a free port or leave it unset for an ephemeral port`, { cause: error })
|
||||
}
|
||||
const modelPort = server.address().port
|
||||
const environment = baseEnvironment(modelPort, workspace)
|
||||
const templates = {}
|
||||
const prepareReports = {}
|
||||
try {
|
||||
// Templates are prepared once and copied per launch, so every measured
|
||||
// launch starts from an identical, never-used-before directory.
|
||||
for (const variant of variants) {
|
||||
if (variant === 'first-run') continue
|
||||
if (variant === 'real') {
|
||||
// A copy of a real data directory (GB_PERF_STARTUP_REAL_DATA, for
|
||||
// example the portable build's data folder). Only the stores startup
|
||||
// reads are copied; the source is never opened by the app. The driver
|
||||
// blocks every non-loopback request, so configured remote models are
|
||||
// not called.
|
||||
const source = process.env.GB_PERF_STARTUP_REAL_DATA
|
||||
if (!source || !existsSync(join(source, 'assistant.sqlite'))) throw new Error('variant "real" needs GB_PERF_STARTUP_REAL_DATA with assistant.sqlite')
|
||||
const directory = join(runDirectory, 'template-real')
|
||||
const profile = join(directory, 'profile')
|
||||
mkdirSync(profile, { recursive: true })
|
||||
for (const entry of readdirSync(source)) {
|
||||
const from = join(source, entry)
|
||||
const keep = /\.(sqlite|sqlite-wal|sqlite-shm|json)$/i.test(entry) || entry === 'notes' || entry === 'Local Storage'
|
||||
if (keep && entry !== 'DevToolsActivePort') cpSync(from, join(profile, entry), { recursive: true })
|
||||
}
|
||||
templates[variant] = directory
|
||||
continue
|
||||
}
|
||||
const seed = variant === 'empty' ? 0 : variant === seededName ? seedCount : null
|
||||
if (seed === null) throw new Error(`Unknown variant ${variant}`)
|
||||
const directory = join(runDirectory, `template-${variant}`)
|
||||
mkdirSync(directory, { recursive: true })
|
||||
console.log(`[startup] preparing ${variant} profile${seed ? ` (${seed} conversations)` : ''}`)
|
||||
const report = await launch({ directory, environment, mode: 'prepare', seed })
|
||||
prepareReports[variant] = { status: report.status, error: report.error, seeded: report.seeded, exitMs: report.exitMs, stderrHead: report.stderrHead }
|
||||
if (report.status !== 'passed') throw new Error(`Preparing ${variant} failed: ${report.error ?? 'unknown'}\n${(report.stderrHead ?? []).join('\n')}`)
|
||||
rmSync(join(directory, 'startup-result.json'), { force: true })
|
||||
templates[variant] = directory
|
||||
}
|
||||
|
||||
const fresh = (variant, label) => {
|
||||
const directory = join(runDirectory, `${label}-${variant}`)
|
||||
if (templates[variant]) {
|
||||
cpSync(templates[variant], directory, { recursive: true })
|
||||
} else {
|
||||
mkdirSync(directory, { recursive: true })
|
||||
}
|
||||
return directory
|
||||
}
|
||||
|
||||
// The real profile's sidebar is ready once its first rows show.
|
||||
const expectedFor = variant => (variant === seededName ? seedCount : variant === 'real' ? 10 : 0)
|
||||
const results = Object.fromEntries(variants.map(v => [v, []]))
|
||||
// A discarded warm-up launch per invocation so the first measured launch
|
||||
// does not also pay for loading Electron binaries from a cold disk cache.
|
||||
console.log('[startup] warm-up launch (discarded)')
|
||||
await launch({ directory: fresh(variants.includes('empty') ? 'empty' : variants[0], 'warmup'), environment })
|
||||
for (let round = 0; round < runs; round += 1) {
|
||||
for (const variant of variants) {
|
||||
const directory = fresh(variant, `run${round + 1}`)
|
||||
const report = await launch({ directory, environment, expectConversations: expectedFor(variant) })
|
||||
report.round = round + 1
|
||||
results[variant].push(report)
|
||||
const p = report.status === 'passed' ? phases(report) : {}
|
||||
console.log(`[startup] round ${round + 1} ${variant}: ${report.status}, ready ${fmt(p['spawn -> app ready'])} ms, ready-to-show ${fmt(p['spawn -> ready-to-show'])} ms, first frame ${fmt(p['spawn -> first visible frame'])} ms, composer interactive ${fmt(p['spawn -> composer interactive'])} ms${report.error ? `, error ${report.error}` : ''}`)
|
||||
if (!keepProfile) rmSync(directory, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 })
|
||||
}
|
||||
}
|
||||
|
||||
const profiles = {}
|
||||
if (cpuProfile) {
|
||||
for (const variant of variants) {
|
||||
const directory = fresh(variant, 'profiled')
|
||||
const profilePath = join(outputDirectory, `main-startup-${variant}.cpuprofile`)
|
||||
const report = await launch({ directory, environment, profilePath, expectConversations: expectedFor(variant) })
|
||||
profiles[variant] = { status: report.status, cpuProfile: report.cpuProfile, phases: report.status === 'passed' ? phases(report) : null, file: profilePath }
|
||||
console.log(`[startup] CPU-profiled ${variant}: ${report.status} -> ${profilePath}`)
|
||||
}
|
||||
}
|
||||
|
||||
const table = summarize(results)
|
||||
const report = {
|
||||
schemaVersion: 1,
|
||||
kind: 'goodbuddy-startup-benchmark',
|
||||
source: { ...sourceInfo(), build },
|
||||
environment: { platform: process.platform, arch: process.arch, electron: require('electron/package.json').version, cpu: `${cpus()[0]?.model?.trim()} x${cpus().length}`, memoryGB: Math.round(totalmem() / 1073741824) },
|
||||
config: { runs, variants, seedConversations: seedCount, percentile: 'nearest-rank' },
|
||||
prepare: prepareReports,
|
||||
phases: Object.fromEntries(table),
|
||||
runs: Object.fromEntries(Object.entries(results).map(([variant, reports]) => [variant, reports.map(r => ({
|
||||
round: r.round, status: r.status, error: r.error, stderrHead: r.stderrHead, exitMs: r.exitMs,
|
||||
phases: r.status === 'passed' ? phases(r) : null,
|
||||
renderer: r.renderer ? { domNodes: r.renderer.domNodes, conversationRows: r.renderer.conversationRows, scriptResources: r.renderer.scriptResources, scriptBytes: r.renderer.scriptBytes, jsHeapMB: r.renderer.jsHeapMB, startupDialog: r.renderer.startupDialog !== undefined, composerPresentBeforeProbe: r.renderer.alreadyAtInstall === true, longAnimationFrames: r.renderer.longAnimationFrames && { count: r.renderer.longAnimationFrames.count, totalMs: r.renderer.longAnimationFrames.totalMs, maxMs: r.renderer.longAnimationFrames.maxMs, scriptMs: r.renderer.longAnimationFrames.scriptMs } } : null,
|
||||
mainEventLoopDelay: r.mainEventLoopDelay,
|
||||
blockedRequests: r.blockedRequests && { fetch: r.blockedRequests.fetch.length, renderer: r.blockedRequests.renderer.length, hosts: [...new Set([...r.blockedRequests.fetch, ...r.blockedRequests.renderer])] }
|
||||
}))])),
|
||||
cpuProfiles: profiles,
|
||||
modelRequests: { boundary: 'loopback fake model only; non-loopback requests blocked', total: ledger.length, paths: [...new Set(ledger.map(e => `${e.method} ${e.path}`))] }
|
||||
}
|
||||
const resultPath = join(outputDirectory, 'startup-result.json')
|
||||
writeFileSync(resultPath, JSON.stringify(report, null, 2))
|
||||
|
||||
console.log(`\nGoodBuddy cold-start benchmark: ${runs} runs per variant, ms (median / p95, nearest rank)`)
|
||||
console.log(`source ${report.source.commit ?? '?'}${report.source.dirty ? ` (dirty, ${report.source.dirtyFiles} files)` : ''}, Electron ${report.environment.electron}, ${report.environment.cpu}, ${report.environment.memoryGB} GB`)
|
||||
console.table(Object.fromEntries([...table].map(([name, row]) => [name, Object.fromEntries(variants.map(v => [v, row[v] ? `${fmt(row[v].median)} / ${fmt(row[v].p95)}${row[v].n < runs ? ` (n=${row[v].n})` : ''}` : '-']))])))
|
||||
const failed = Object.values(results).flat().filter(r => r.status !== 'passed').length
|
||||
if (failed) console.log(`${failed} launch(es) failed; see report`)
|
||||
const blocked = Object.values(results).flat().flatMap(r => [...(r.blockedRequests?.fetch ?? []), ...(r.blockedRequests?.renderer ?? [])])
|
||||
console.log(`blocked non-loopback requests: ${blocked.length}${blocked.length ? ` (${[...new Set(blocked)].join(', ')})` : ''}; fake model requests: ${ledger.length}`)
|
||||
console.log(`\nreport: ${resultPath}`)
|
||||
process.exitCode = failed ? 1 : 0
|
||||
} finally {
|
||||
await new Promise(resolvePromise => server.close(() => resolvePromise()))
|
||||
if (!keepProfile) {
|
||||
for (const entry of readdirSync(runDirectory)) {
|
||||
if (entry !== 'artifacts') rmSync(join(runDirectory, entry), { recursive: true, force: true, maxRetries: 5, retryDelay: 200 })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(error => {
|
||||
console.error(error instanceof Error ? error.message : error)
|
||||
process.exitCode = 1
|
||||
})
|
||||
@@ -0,0 +1,235 @@
|
||||
// Main-process database WRITE benchmark on a real data directory (PERF-15).
|
||||
// Copies assistant.sqlite (+ -wal/-shm) from GB_WPERF_DATA (for example the
|
||||
// portable build's data folder) into a temporary directory - the source is
|
||||
// never opened - then measures, with the production AssistantDatabase, the
|
||||
// writes Main performs synchronously while a run streams:
|
||||
// task-events:subagent replay of the largest real subagent progress stream
|
||||
// (restored events, re-compacted on write, as live)
|
||||
// task-events:tool replay of real tool events
|
||||
// task-events:text 250 ms text batches (persisted buffer)
|
||||
// conversations:save the renderer's 500 ms local save (header + the
|
||||
// streaming assistant message) for a real conversation
|
||||
// activity:update one incremental activity change
|
||||
// For each: per-call time and the longest Main event-loop block.
|
||||
// Event replays are paced at GB_WPERF_RATE events/s (default 270, the busiest
|
||||
// second in real data; 0 = back to back).
|
||||
// Env: GB_WPERF_DATA (required), GB_WPERF_EVENTS (default 2000),
|
||||
// GB_WPERF_OUTPUT (JSON), GB_WPERF_KEEP=1 (keep the copy).
|
||||
const { buildSync } = require('esbuild')
|
||||
const { mkdtempSync, rmSync, writeFileSync, mkdirSync, copyFileSync, cpSync, existsSync } = require('node:fs')
|
||||
const { tmpdir } = require('node:os')
|
||||
const { join, resolve } = require('node:path')
|
||||
const { randomUUID } = require('node:crypto')
|
||||
const { performance } = require('node:perf_hooks')
|
||||
|
||||
const root = resolve(__dirname, '..')
|
||||
|
||||
if (process.env.GB_WPERF_NODE !== '1') {
|
||||
let electron
|
||||
let version
|
||||
try { electron = require('electron'); version = require('electron/package.json').version } catch { electron = undefined }
|
||||
if (typeof electron === 'string' && process.versions.electron !== version) {
|
||||
const { status } = require('node:child_process').spawnSync(electron, [__filename], {
|
||||
stdio: 'inherit', env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' }
|
||||
})
|
||||
process.exit(status ?? 1)
|
||||
}
|
||||
}
|
||||
|
||||
const source = process.env.GB_WPERF_DATA
|
||||
if (!source || !existsSync(join(source, 'assistant.sqlite'))) {
|
||||
console.error('[wperf] set GB_WPERF_DATA to a GoodBuddy data directory containing assistant.sqlite')
|
||||
process.exit(2)
|
||||
}
|
||||
const eventLimit = Number(process.env.GB_WPERF_EVENTS || 2000)
|
||||
|
||||
const directory = mkdtempSync(join(tmpdir(), 'goodbuddy-write-perf-'))
|
||||
const bundle = join(directory, 'assistant-database.cjs')
|
||||
const progressBundle = join(directory, 'subagent-progress.cjs')
|
||||
for (const [entry, outfile] of [
|
||||
['src/main/assistant/assistant-database.ts', bundle],
|
||||
['src/main/assistant/subagent-progress-storage.ts', progressBundle],
|
||||
['src/main/readonly-query-worker.ts', join(directory, 'readonly-query-worker.cjs')]
|
||||
]) {
|
||||
buildSync({
|
||||
entryPoints: [join(root, entry)], bundle: true, platform: 'node', format: 'cjs',
|
||||
target: 'node22', outfile, logLevel: 'error', external: ['electron']
|
||||
})
|
||||
}
|
||||
const { AssistantDatabase } = require(bundle)
|
||||
const { restoreSubagentPayload } = require(progressBundle)
|
||||
|
||||
const round = value => Math.round(value * 10) / 10
|
||||
const percentile = (values, p) => {
|
||||
const sorted = [...values].sort((a, b) => a - b)
|
||||
return sorted[Math.min(sorted.length - 1, Math.max(0, Math.ceil((p / 100) * sorted.length) - 1))]
|
||||
}
|
||||
|
||||
/** Per-call times plus the longest gap between event-loop turns. */
|
||||
async function measure(count, body, { yieldEvery = 1, rate = 0 } = {}) {
|
||||
let probing = true
|
||||
let last = performance.now()
|
||||
let maxGap = 0
|
||||
const tick = () => {
|
||||
const now = performance.now()
|
||||
maxGap = Math.max(maxGap, now - last)
|
||||
last = now
|
||||
if (probing) setImmediate(tick)
|
||||
}
|
||||
setImmediate(tick)
|
||||
const samples = []
|
||||
const slow = []
|
||||
const started = performance.now()
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
const at = performance.now()
|
||||
body(index)
|
||||
const elapsed = performance.now() - at
|
||||
samples.push(elapsed)
|
||||
if (elapsed > 50) slow.push([index, round(elapsed)])
|
||||
if (rate > 0) {
|
||||
// Real streams arrive at a rate; wait until this call's scheduled time.
|
||||
const due = started + ((index + 1) * 1000) / rate
|
||||
const wait = due - performance.now()
|
||||
await new Promise(resolve => (wait > 1 ? setTimeout(resolve, wait) : setImmediate(resolve)))
|
||||
} else if ((index + 1) % yieldEvery === 0) await new Promise(resolve => setImmediate(resolve))
|
||||
}
|
||||
const total = performance.now() - started
|
||||
await new Promise(resolve => setTimeout(resolve, 20))
|
||||
probing = false
|
||||
return {
|
||||
calls: count, totalMs: round(total), callP50: round(percentile(samples, 50)),
|
||||
callP95: round(percentile(samples, 95)), callP99: round(percentile(samples, 99)),
|
||||
callMax: round(Math.max(...samples)), loopMax: round(maxGap), slowCalls: slow.slice(0, 10)
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log(`[wperf] runtime: node ${process.version}${process.versions.electron ? ` (electron ${process.versions.electron})` : ''}`)
|
||||
const copyStarted = performance.now()
|
||||
for (const name of ['assistant.sqlite', 'assistant.sqlite-wal', 'assistant.sqlite-shm']) {
|
||||
if (existsSync(join(source, name))) copyFileSync(join(source, name), join(directory, name))
|
||||
}
|
||||
// Initialization checks note files against the database.
|
||||
if (existsSync(join(source, 'notes'))) cpSync(join(source, 'notes'), join(directory, 'notes'), { recursive: true })
|
||||
console.log(`[wperf] copied data in ${round((performance.now() - copyStarted) / 1000)} s`)
|
||||
const workspace = join(directory, 'workspace')
|
||||
mkdirSync(workspace)
|
||||
const database = new AssistantDatabase(join(directory, 'assistant.sqlite'))
|
||||
const openStarted = performance.now()
|
||||
database.initialize(workspace)
|
||||
const raw = database.database
|
||||
const results = { openMs: round(performance.now() - openStarted) }
|
||||
// Experiment switch: override the durability level after initialization.
|
||||
if (process.env.GB_WPERF_SYNCHRONOUS) raw.exec(`PRAGMA synchronous = ${process.env.GB_WPERF_SYNCHRONOUS}`)
|
||||
results.synchronous = raw.prepare('PRAGMA synchronous').get().synchronous
|
||||
if (process.env.GB_WPERF_AUTOCHECKPOINT) raw.exec(`PRAGMA wal_autocheckpoint = ${process.env.GB_WPERF_AUTOCHECKPOINT}`)
|
||||
// As in production: checkpoints run on a worker thread (GB_WPERF_CHECKPOINT_WORKER=0 disables).
|
||||
if (process.env.GB_WPERF_CHECKPOINT_WORKER !== '0' && typeof database.enableWalCheckpointWorker === 'function') {
|
||||
database.enableWalCheckpointWorker(join(directory, 'readonly-query-worker.cjs'))
|
||||
await new Promise(resolve => setTimeout(resolve, 500))
|
||||
}
|
||||
results.walAutocheckpoint = raw.prepare('PRAGMA wal_autocheckpoint').get().wal_autocheckpoint
|
||||
const count = (sql, ...args) => Number(raw.prepare(sql).get(...args).n)
|
||||
results.taskEvents = count('SELECT COUNT(*) AS n FROM task_events')
|
||||
results.messages = count('SELECT COUNT(*) AS n FROM messages')
|
||||
results.activityRecords = count('SELECT COUNT(*) AS n FROM activity_history_records')
|
||||
|
||||
// The real task with the most subagent events, restored in order.
|
||||
const sourceTask = raw.prepare(
|
||||
`SELECT task_id FROM task_events WHERE kind = 'subagent' GROUP BY task_id ORDER BY COUNT(*) DESC LIMIT 1`
|
||||
).get()?.task_id
|
||||
const blocks = new Map()
|
||||
const subagentEvents = sourceTask ? raw.prepare(
|
||||
`SELECT payload_json FROM task_events WHERE task_id = ? AND kind = 'subagent' ORDER BY id LIMIT ?`
|
||||
).all(sourceTask, eventLimit).map(row => restoreSubagentPayload(JSON.parse(row.payload_json), blocks)) : []
|
||||
const toolEvents = raw.prepare(
|
||||
`SELECT payload_json FROM task_events WHERE kind = 'tool' ORDER BY id DESC LIMIT ?`
|
||||
).all(eventLimit).map(row => JSON.parse(row.payload_json))
|
||||
|
||||
const conversation = raw.prepare(
|
||||
`SELECT conversation_id AS id FROM messages GROUP BY conversation_id ORDER BY COUNT(*) DESC LIMIT 1`
|
||||
).get()?.id
|
||||
const taskId = randomUUID()
|
||||
database.createTask({ id: taskId, title: 'write perf', instructions: 'replay', workMode: 'ask', conversationId: conversation })
|
||||
|
||||
const replay = (events, kind) => index => {
|
||||
const event = { ...events[index % events.length], requestId: taskId }
|
||||
database.appendTaskEvent(taskId, kind, event)
|
||||
}
|
||||
if (process.env.GB_WPERF_INSPECT) {
|
||||
const at = Number(process.env.GB_WPERF_INSPECT)
|
||||
const { compactSubagentPayload } = require(progressBundle)
|
||||
const event = subagentEvents[at]
|
||||
const t0 = performance.now()
|
||||
const json = JSON.stringify(event)
|
||||
const t1 = performance.now()
|
||||
const restoredBlocks = new Map()
|
||||
for (let i = 0; i < at; i += 1) restoreSubagentPayload(JSON.parse(JSON.stringify(compactSubagentPayload(subagentEvents[i], restoredBlocks))), restoredBlocks)
|
||||
const t2 = performance.now()
|
||||
const compact = JSON.stringify(compactSubagentPayload(event, restoredBlocks))
|
||||
const t3 = performance.now()
|
||||
console.log(`[wperf] event ${at}: full ${json.length} B (stringify ${round(t1 - t0)} ms), compact ${compact.length} B (compact ${round(t3 - t2)} ms)`)
|
||||
console.log(`[wperf] event ${at} progress blocks: ${(event.progress ?? []).length}, updates: ${(event.progressUpdates ?? []).length}`)
|
||||
}
|
||||
// Paced at the busiest second seen in real data (269 subagent events/s); 0 = back to back.
|
||||
const rate = Number(process.env.GB_WPERF_RATE ?? 270)
|
||||
if (subagentEvents.length) {
|
||||
results.subagentEvents = await measure(subagentEvents.length, replay(subagentEvents, 'subagent'), { rate })
|
||||
}
|
||||
if (toolEvents.length) results.toolEvents = await measure(toolEvents.length, replay(toolEvents, 'tool'), { rate })
|
||||
const delta = 'streamed text '.repeat(40)
|
||||
results.textBatches = await measure(400, index => database.appendTaskEvent(taskId, 'text',
|
||||
{ type: 'text', requestId: taskId, delta: `${delta}${index}` }))
|
||||
|
||||
// Renderer local save: the conversation header and the streaming assistant message growing.
|
||||
if (conversation) {
|
||||
const snapshot = database.getConversation(conversation)
|
||||
const header = {
|
||||
id: snapshot.id, projectId: snapshot.projectId, title: snapshot.title,
|
||||
runtimeSelection: snapshot.runtimeSelection, updatedAt: Date.now()
|
||||
}
|
||||
const messageId = randomUUID()
|
||||
let content = ''
|
||||
results.conversationSave = await measure(200, index => {
|
||||
content += 'Lorem ipsum dolor sit amet, streaming reply chunk. '.repeat(8)
|
||||
database.saveLocalConversations([{
|
||||
header: { ...header, updatedAt: Date.now() + index },
|
||||
messages: [{ id: messageId, role: 'assistant', state: 'streaming', content, createdAt: Date.now() }]
|
||||
}])
|
||||
})
|
||||
results.conversationGet = await measure(20, () => database.getConversation(conversation))
|
||||
}
|
||||
|
||||
const sample = database.getActivityHistoryPage({ limit: 50 }).records
|
||||
if (sample.length) {
|
||||
results.activityUpdate = await measure(200, index => database.updateActivityHistory({
|
||||
changes: [{ type: 'upsert', position: 'in-place', record: { ...sample[index % sample.length], detail: `perf ${index}` } }]
|
||||
}))
|
||||
}
|
||||
results.walBytes = existsSync(join(directory, 'assistant.sqlite-wal'))
|
||||
? require('node:fs').statSync(join(directory, 'assistant.sqlite-wal')).size : 0
|
||||
database.close()
|
||||
|
||||
console.log(`[wperf] ${results.taskEvents} task events, ${results.messages} messages, ${results.activityRecords} activity records; open ${results.openMs} ms`)
|
||||
const rows = [
|
||||
['task event: subagent (real stream)', results.subagentEvents],
|
||||
['task event: tool (real)', results.toolEvents],
|
||||
['task event: text batch', results.textBatches],
|
||||
['conversation save (500 ms tick)', results.conversationSave],
|
||||
['conversation get (largest)', results.conversationGet],
|
||||
['activity update, 1 record', results.activityUpdate]
|
||||
].filter(([, value]) => value)
|
||||
console.table(rows.map(([name, r]) => ({
|
||||
path: name, calls: r.calls, 'p50 ms': r.callP50, 'p95 ms': r.callP95, 'p99 ms': r.callP99,
|
||||
'max ms': r.callMax, 'loop max ms': r.loopMax, 'calls/s': Math.round(r.calls / (r.totalMs / 1000))
|
||||
})))
|
||||
for (const [name, r] of rows) if (r.slowCalls.length) console.log(`[wperf] slow calls (index, ms) ${name}: ${JSON.stringify(r.slowCalls)}`)
|
||||
console.log(`[wperf] synchronous=${results.synchronous} wal_autocheckpoint=${results.walAutocheckpoint} wal=${results.walBytes} B`)
|
||||
const output = process.env.GB_WPERF_OUTPUT
|
||||
if (output) writeFileSync(output, JSON.stringify({ kind: 'goodbuddy-main-write-benchmark', node: process.version, results }, null, 2))
|
||||
}
|
||||
|
||||
main().catch(error => { process.exitCode = 1; console.error(error) }).finally(() => {
|
||||
if (process.env.GB_WPERF_KEEP === '1') console.log(`[wperf] kept ${directory}`)
|
||||
else rmSync(directory, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 })
|
||||
})
|
||||
@@ -12,8 +12,8 @@
|
||||
| 目标平台 | Windows、macOS、Linux,x64 与 arm64 |
|
||||
| 相关基线 | [统一界面设计系统](../../UI-DESIGN.md)、[文档解析与本地 OCR](../features/document-processing/prd.md)、[本地文本向量模型与连接设计](../features/knowledge-base/local-embedding-technical-design.md)、[全双工实时语音交互设计](../features/full-duplex-voice/technical-design.md) |
|
||||
|
||||
本文定义“设置 → 平台功能”的二级页签结构,以及所有 GoodBuddy 托管本地模型共同使用的
|
||||
“模型下载源”设置。首期下载源为 ModelScope 和 Hugging Face,默认使用 ModelScope。
|
||||
本文定义“设置 → 平台功能”的二级页签结构,以及位于“设置 → 模型连接”、所有 GoodBuddy
|
||||
托管本地模型共同使用的“模型下载源”设置。首期下载源为 ModelScope 和 Hugging Face,默认使用 ModelScope。
|
||||
|
||||
模型下载源是用户明确选择的供应链路径。当前来源不可用或没有所选模型时,GoodBuddy
|
||||
必须明确失败并提供设置入口,**不能静默切换到另一个下载源。**
|
||||
@@ -25,7 +25,8 @@
|
||||
1. “平台功能”保留现有一级设置分类,在分类内部增加共享 `PageTabs`:
|
||||
- **通用设置**
|
||||
- **远程项目**
|
||||
2. “通用设置”首个功能为“模型下载源”。
|
||||
2. “模型下载源”位于“模型连接”页头,与模型类型页签同一行右对齐,在保存操作下方;
|
||||
显示当前来源和次按钮,点击后在弹窗中选择并确认保存。
|
||||
3. 首期提供两个互斥选项:
|
||||
- `modelscope`,用户文案为 **ModelScope**,默认值。
|
||||
- `hugging-face`,用户文案为 **Hugging Face**。
|
||||
@@ -47,8 +48,8 @@
|
||||
同一模型包的部分文件从 ModelScope 下载、部分文件从 Hugging Face 下载。
|
||||
8. 下载任务启动时冻结来源。任务运行期间切换全局设置,不改变、重启或迁移该任务;新的
|
||||
下载使用新来源。
|
||||
9. 当前来源失败时,不自动重试另一个来源。错误可以提供“前往通用设置更换下载源”,由
|
||||
用户明确选择。
|
||||
9. 当前来源失败时,不自动重试另一个来源。向量、语音和 OCR 的更换下载源入口直接打开
|
||||
“模型连接”页的下载源弹窗,由用户明确选择并确认。
|
||||
10. 魔法笔记设置由应用中心与笔记页共享,不在全局设置中重复提供入口。
|
||||
|
||||
---
|
||||
@@ -59,10 +60,11 @@
|
||||
|
||||
“平台功能”现已使用两个二级页签:
|
||||
|
||||
- “通用设置”承载全局模型下载源。
|
||||
- “通用设置”承载快捷唤起、会话与通知、工作目录设置。
|
||||
- “远程项目”承载远程项目开关与 Agent 包管理。
|
||||
|
||||
页签复用共享 `PageTabs`,默认打开“通用设置”,不会把当前页签写入应用设置。
|
||||
全局模型下载源由“模型连接”页头提供入口,布局和弹窗行为见第 7 节。
|
||||
|
||||
### 2.2 受管模型下载
|
||||
|
||||
@@ -99,7 +101,7 @@
|
||||
- 切换来源时不影响已安装、已选择或正在使用的模型。
|
||||
- 下载失败时知道实际使用了哪个来源。
|
||||
- 所选来源没有模型时得到明确说明,并可以主动前往设置切换。
|
||||
- 在“平台功能”的“通用设置”中选择模型下载源。
|
||||
- 在“模型连接”页头打开模型下载源弹窗,选择后确认保存。
|
||||
|
||||
### 3.2 产品目标
|
||||
|
||||
@@ -163,9 +165,12 @@
|
||||
|
||||
```text
|
||||
设置
|
||||
├─ 模型连接
|
||||
│ └─ 页头:模型类型页签同行右侧的当前来源与“模型下载源”按钮
|
||||
│ └─ 模型下载源弹窗
|
||||
└─ 平台功能
|
||||
├─ 通用设置
|
||||
│ └─ 模型下载源
|
||||
│ └─ 快捷唤起、会话与通知、工作目录
|
||||
└─ 远程项目
|
||||
├─ 远程项目开关
|
||||
└─ Agent 包管理
|
||||
@@ -221,21 +226,20 @@
|
||||
|
||||
---
|
||||
|
||||
## 7. 通用设置界面
|
||||
## 7. 模型下载源界面
|
||||
|
||||
### 7.1 布局
|
||||
|
||||
通用设置按快捷唤起、会话与通知、工作目录与下载分成三张卡片。模型下载源位于
|
||||
“工作目录与下载”卡片内的工作目录输入之后;以下仅展示下载源控件,完整分组及通知
|
||||
开关行为见 [UI 设计规范](../../UI-DESIGN.md#138-设置中心)。
|
||||
“模型连接”页头在保存操作下方,将模型类型页签与下载源控件放在同一行:页签居左,
|
||||
“当前来源:ModelScope”和“模型下载源”次按钮整体右对齐。窄窗口保持同行,空间不足时
|
||||
导航行可横向滚动。点击按钮打开弹窗;通用设置的工作目录卡片不再提供下载源控件。共享布局规则见
|
||||
[UI 设计规范](../../UI-DESIGN.md#138-设置中心)。
|
||||
|
||||
```text
|
||||
平台功能
|
||||
管理通用平台行为与可选工作区能力
|
||||
模型连接 [保存]
|
||||
[文本] [向量] [重排] [语音] 当前来源:ModelScope [模型下载源]
|
||||
|
||||
[ 通用设置 ] [ 远程项目 ]
|
||||
|
||||
┌ 模型下载源 ────────────────────────────────────────────┐
|
||||
┌ 模型下载源(弹窗)───────────────────────────────── [×]┐
|
||||
│ 选择 GoodBuddy 托管本地模型后续下载使用的平台。 │
|
||||
│ 已安装模型和 ZIP 导入不受影响。 │
|
||||
│ │
|
||||
@@ -244,19 +248,19 @@
|
||||
│ │
|
||||
│ ○ Hugging Face │
|
||||
│ 适合可以稳定访问 Hugging Face 的网络环境 │
|
||||
│ │
|
||||
│ [取消] [确认] │
|
||||
└────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
模型下载源保存属于即时设置,不增加与来源选项竞争的
|
||||
大型“保存全部”按钮。
|
||||
弹窗通过 Portal 挂载到 `document.body`,复用共享 Modal 样式、背景隔离和焦点管理。
|
||||
模型连接页头的保存操作与下载源弹窗的“确认”各自保存对应设置。
|
||||
|
||||
### 7.2 控件
|
||||
|
||||
来源选项包含说明和未来可能的不可用原因,因此使用语义化 Radio Group,而不是原生
|
||||
`select` 或 `SegmentedControl`:
|
||||
|
||||
- `fieldset` + `legend` 表达“模型下载源”。
|
||||
- 弹窗内使用 `fieldset` + 持久的屏幕阅读器 `legend` 表达“模型下载源”。
|
||||
- 每个选项使用 Radio 和整行可点击卡片。
|
||||
- 当前项同时显示选中 Radio、强调边框和选中背景。
|
||||
- 不能只用站点 Logo 或颜色表达选择。
|
||||
@@ -285,12 +289,16 @@
|
||||
|
||||
### 7.4 保存交互
|
||||
|
||||
- 用户选择另一个 Radio 后立即调用应用设置更新。
|
||||
- 保存期间禁用两个选项,保留最后确认值。
|
||||
- 成功后更新当前选择,并通过应用通知提示:
|
||||
- 打开弹窗时以已保存来源初始化草稿;选择 Radio 只修改草稿,不调用应用设置更新。
|
||||
- 点击“确认”才保存;草稿与已保存来源相同时直接关闭,不重复写入。
|
||||
- 取消、关闭按钮、Escape 或点击遮罩均丢弃草稿,已保存来源不变。
|
||||
- 保存期间禁用选项、确认、取消和关闭,阻止重复提交及遮罩、Escape 关闭。
|
||||
- 成功后更新页头当前来源并关闭弹窗,通过应用通知提示:
|
||||
`模型下载源已切换为 Hugging Face。`
|
||||
- 失败时恢复最后确认值,在 Radio Group 附近显示可重试错误。
|
||||
- 保存失败时保留弹窗和草稿,已保存来源不变;就地显示错误,允许再次点击“确认”重试。
|
||||
- 读取失败时显示错误和“重试”,禁用确认,不以默认值伪装为已保存来源。
|
||||
- 同一个失败不再同时显示页面横幅和全局错误通知。
|
||||
- 弹窗打开后聚焦关闭按钮,Tab 在弹窗内循环;关闭后焦点回到页头“模型下载源”按钮。
|
||||
|
||||
来源变化不触发:
|
||||
|
||||
@@ -631,7 +639,7 @@ resolveModelDownloadPackage(files, source)
|
||||
|
||||
### 12.1 来源状态
|
||||
|
||||
语音和 OCR 的当前模型详情各保留一个全局下载源标签。向量内置连接不重复显示平台功能中
|
||||
语音和 OCR 的当前模型详情各保留一个全局下载源标签。向量内置连接不重复显示模型连接页头中
|
||||
已经明确的全局来源,也不显示固定本地安装路径;下载仍使用已保存的全局来源。
|
||||
|
||||
所有模型管理界面都显示当前模型是否可下载、活动操作的进度与取消入口,以及已安装模型
|
||||
@@ -649,10 +657,12 @@ resolveModelDownloadPackage(files, source)
|
||||
|
||||
```text
|
||||
Hugging Face 暂不提供此模型的完整已验证文件。
|
||||
[前往通用设置]
|
||||
[模型下载源]
|
||||
```
|
||||
|
||||
不能显示可点击“下载”后才告诉用户缺少某个文件。
|
||||
向量、语音和 OCR 的“模型下载源”入口均导航到“模型连接”并直接打开同一弹窗。
|
||||
跨分类导航沿用设置中心的未保存修改确认,用户取消离开时保留原表单。
|
||||
|
||||
### 12.2 已安装模型
|
||||
|
||||
@@ -697,7 +707,7 @@ ZIP 是来源无关的离线迁移方式:
|
||||
- `正在从 {{source}} 下载`
|
||||
- `打开 {{source}} 模型仓库`
|
||||
- `当前下载源暂不提供此模型`
|
||||
- `前往通用设置更换下载源`
|
||||
- `模型下载源`(打开下载源弹窗)
|
||||
|
||||
不再写死:
|
||||
|
||||
@@ -743,7 +753,7 @@ ZIP 是来源无关的离线迁移方式:
|
||||
失败状态提供:
|
||||
|
||||
1. 重试当前来源。
|
||||
2. 前往“平台功能 → 通用设置”。
|
||||
2. 打开“模型连接”的模型下载源弹窗,选择并确认保存。
|
||||
3. 取消。
|
||||
|
||||
用户切换来源后需要重新点击下载。不能在设置保存后自动恢复上一失败任务。
|
||||
@@ -810,7 +820,7 @@ ModelScope 和 Hugging Face 都可能跳转到文件存储或 CDN。每个来源
|
||||
|
||||
| 设置 | 位置 | 作用 |
|
||||
| --- | --- | --- |
|
||||
| 模型下载源 | 平台功能 → 通用设置 | GoodBuddy 托管本地模型 |
|
||||
| 模型下载源 | 模型连接页头 → 模型下载源弹窗 | GoodBuddy 托管本地模型 |
|
||||
| 检查更新源 | 关于与更新 | GoodBuddy 版本检查和下载页 |
|
||||
|
||||
切换任一设置不得修改另一个设置。用户选择 ModelScope 不表示应用更新使用镜像节点;选择
|
||||
@@ -850,9 +860,9 @@ Ollama 的模型下载由用户和 Ollama 管理。GoodBuddy 的模型下载源
|
||||
|
||||
| 场景 | 行为 |
|
||||
| --- | --- |
|
||||
| 读取设置失败 | 显示平台功能阻塞错误,不猜测或写入来源 |
|
||||
| 保存来源失败 | 保留上一个确认值,显示就地错误 |
|
||||
| 当前来源无完整模型包 | 下载按钮禁用,显示前往通用设置 |
|
||||
| 读取设置失败 | 页头显示读取失败,弹窗提供就地重试并禁用确认,不猜测或写入来源 |
|
||||
| 保存来源失败 | 已保存来源不变,保留弹窗和草稿,显示就地错误并允许确认重试 |
|
||||
| 当前来源无完整模型包 | 下载按钮禁用,提供直接打开模型连接页下载源弹窗的入口 |
|
||||
| 当前来源网络不可用 | 当前下载失败或允许同来源重试,不请求另一来源 |
|
||||
| 当前来源返回 404 | 显示来源缺少固定文件,不请求另一来源 |
|
||||
| 重定向 Host 不受信任 | 立即失败并删除本次暂存 |
|
||||
@@ -880,15 +890,19 @@ Ollama 的模型下载由用户和 Ollama 管理。GoodBuddy 的模型下载源
|
||||
- 更高未知版本拒绝,不覆盖文件。
|
||||
- 并发设置更新保持完整 JSON。
|
||||
|
||||
### 17.2 平台功能界面
|
||||
### 17.2 设置界面
|
||||
|
||||
- 默认选中“通用设置”Tab。
|
||||
- Tab 使用 `tablist`、`tab`、`tabpanel` 和 `aria-selected`。
|
||||
- 方向键、Home、End 和焦点恢复正确。
|
||||
- 切换平台功能页签不改变任何设置,全局设置不显示魔法笔记入口。
|
||||
- 模型连接页头的当前来源与次按钮位于模型类型页签同一行右侧、保存操作下方;通用设置不再显示下载源控件。
|
||||
- Radio Group 具有持久 Label。
|
||||
- 选择来源时保存一次,保存期间防止重复提交。
|
||||
- 保存失败恢复原值并保留可重试错误。
|
||||
- 选择 Radio 只更新草稿,确认后保存一次;未改变来源时确认不写入。
|
||||
- 取消、关闭、Escape 和遮罩关闭不改变已保存来源;保存期间禁止关闭及重复提交。
|
||||
- 保存失败保留草稿并允许确认重试;读取失败提供重试且禁用确认。
|
||||
- 弹窗使用 body Portal、焦点循环和关闭后焦点恢复。
|
||||
- 向量、语音和 OCR 的更换下载源入口直接打开模型连接页弹窗,跨分类时保留离开保护。
|
||||
- 窄窗口不隐藏页签或来源名称。
|
||||
|
||||
### 17.3 目录
|
||||
@@ -939,7 +953,7 @@ Ollama 的模型下载由用户和 Ollama 管理。GoodBuddy 的模型下载源
|
||||
- 新增 `ModelDownloadSource` Schema 和默认值。
|
||||
- 将应用设置版本升级并迁移旧版本。
|
||||
- 在“平台功能”中增加 `PageTabs`。
|
||||
- 增加“通用设置”与模型下载源 Radio Group。
|
||||
- “通用设置”保留平台行为;模型下载源已移至模型连接页头,通过弹窗 Radio 草稿和确认保存。
|
||||
- 魔法笔记卡已迁入应用共同设置,全局页签已移除,见第 8 节。
|
||||
|
||||
### 阶段 2:共享目录和来源解析(已完成)
|
||||
@@ -971,7 +985,9 @@ Ollama 的模型下载由用户和 Ollama 管理。GoodBuddy 的模型下载源
|
||||
|
||||
- “平台功能”显示“通用设置”和“远程项目”两个可访问页签。
|
||||
- 默认打开“通用设置”。
|
||||
- “通用设置”提供 ModelScope 和 Hugging Face,默认 ModelScope。
|
||||
- “模型连接”页头在模型类型页签同行右侧、保存操作下方显示当前来源及“模型下载源”次按钮。
|
||||
- 下载源弹窗提供 ModelScope 和 Hugging Face,默认 ModelScope;Radio 只编辑草稿,确认才保存,取消不改变已保存来源。
|
||||
- 读取与保存失败均可就地重试;向量、语音和 OCR 的更换下载源入口直接打开该弹窗。
|
||||
- 来源选择说明影响范围,并明确已安装模型、ZIP、Ollama 和应用更新不受影响。
|
||||
- 切换来源不会下载、删除、切换或重新验证任何已安装模型。
|
||||
- 当前来源没有模型时,在点击下载前即可看到不可用原因。
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
# GoodBuddy 性能原则
|
||||
|
||||
## 文档信息
|
||||
|
||||
- **适用范围:** Desktop(Electron Main、Preload、Renderer)及其后台进程和线程。
|
||||
- **用途:** 新功能设计和代码评审的约束。多个 agent 并行修改代码时,以本文为统一标准。
|
||||
- **关联文档:** `docs/roadmap/product-performance-experience-improvement-plan.md`(测量记录与实施进度)。
|
||||
- **参照:** VS Code 的进程分工和渲染做法。我们没有在同一台机器上实测过 VS Code,下面的预算按常见的流畅标准设定。
|
||||
|
||||
## 1. 性能预算
|
||||
|
||||
“流畅”用下面的数字判断,不靠感觉。“标准机”指不降速运行 `npm run perf:app`,“低端机”指加上 `GB_PERF_CPU_THROTTLE=4`。
|
||||
|
||||
| 指标 | 标准机 | 低端机 |
|
||||
| --- | --- | --- |
|
||||
| 按键到下一帧,p95 | ≤ 16 ms | ≤ 50 ms |
|
||||
| 点击到界面响应(切换会话、打开面板),p95 | ≤ 100 ms | ≤ 300 ms |
|
||||
| 稳定交互期间(打字、滚动、流式输出)超过 50 ms 的长任务 | 0 | 尽量为 0,总时长不超过场景时长的 5% |
|
||||
| 流式输出、滚动时的帧间隔,p95 | ≤ 17 ms | ≤ 33 ms |
|
||||
| Main 事件循环单次阻塞 | ≤ 16 ms | — |
|
||||
| Main 事件循环延迟,p99 | ≤ 30 ms | — |
|
||||
| DOM 节点数 | 不随会话数、消息数增长 | 同左 |
|
||||
|
||||
标准机的阈值由 `npm run perf:check` 检查。低端机的数值目前只做记录,等结果稳定后再加进阈值检查。
|
||||
|
||||
## 2. 原则
|
||||
|
||||
### P1 Main 只做调度
|
||||
|
||||
Main 负责窗口、生命周期、权限和消息转发。数据库查询、文档解析、哈希、向量计算、批量文件读写,都放到 worker 线程或 `utilityProcess` 里执行。
|
||||
|
||||
- 新代码不能在 Main 里使用 `DatabaseSync` 和同步文件 API。lint 已有白名单,名单只能减少。
|
||||
- 暂时必须留在 Main 的同步操作,单次耗时要能证明在 16 ms 以内,并在代码里注明理由。
|
||||
- 耗时可能超过 16 ms 的操作,必须支持取消和进度上报,不能一直占着 Main。
|
||||
|
||||
### P2 界面状态局部订阅
|
||||
|
||||
状态按领域放进外部 store,组件通过 selector 只订阅自己用到的那一部分。
|
||||
|
||||
- 不再往 `App.tsx` 里加 `useState`、`useEffect` 或领域逻辑。`App.tsx` 有行数上限,只能往下调。
|
||||
- IPC 订阅放在 store 模块里,组件不直接调用 `window.goodbuddy.*.on*`。
|
||||
- 一个按键、一个流式片段,只能让显示它的那一小块界面重新渲染。
|
||||
- 传给 memo 组件的回调和 context 值,引用必须稳定。
|
||||
|
||||
**模块约定(便于维护,所有拆分都按这个结构做):**
|
||||
|
||||
| 层 | 文件命名 | 内容 | 不允许 |
|
||||
| --- | --- | --- | --- |
|
||||
| Store | `<domain>-store.ts` | `create<Domain>Store()` 返回 `getState / subscribe / 领域动作`;纯 TS,不依赖 React;可单测 | 访问 DOM、调用 React hook |
|
||||
| 选择器 / Hook | `<domain>-selectors.ts` 或 `use-<domain>.ts` | 基于 `useSyncExternalStore` 的 selector,返回值引用稳定 | 在 selector 里新建对象却不做相等比较 |
|
||||
| 同步层 | `<domain>-sync.ts` | 与 Main 的 IPC:首次加载、订阅推送、增量保存队列、失败重试、退出前刷盘 | 放在组件里;整份数据回写 |
|
||||
| 视图 | `<Domain>*.tsx` | `memo` 组件,只从 selector 和稳定的动作读数据 | 接收 App 的大对象或内联回调 |
|
||||
|
||||
- `App.tsx` 只负责布局外壳、路由和把 store 实例通过 context 提供给子树。新增领域状态一律走上表四层。
|
||||
- 每个 store 的单元测试和 store 文件放在一起(`<domain>-store.test.ts`),覆盖顺序、失败重试和边界情况。
|
||||
- Main 侧对应:`<domain>` 的数据库访问集中在一个 repository 类里,IPC handler 只做校验和转发。
|
||||
|
||||
### P3 高频数据走专门通道,按帧合并
|
||||
|
||||
流式文本、终端输出、进度、滚动这类高频数据,不进全局状态。
|
||||
|
||||
- 多条更新合并后,每帧最多提交一次;同步到全局状态的频率要更低,目前是 250 ms。
|
||||
- 高频路径上不能读取布局(`getBoundingClientRect`、`scrollHeight` 等),布局读取要集中到每帧一次。
|
||||
- 合并更新不能丢失、重复或打乱顺序。每条数据带序号,并有测试覆盖。
|
||||
|
||||
### P4 数据只有一份来源,只传变化部分
|
||||
|
||||
- 每类数据只有一个权威来源,由数据所在进程负责持久化。界面不把整份数据写回。
|
||||
- 跨进程的通知直接带上变化的内容,接收方不再“收到通知后重新查全量”。
|
||||
- 写入合并成批次和事务,不按每个事件单独开一个事务。
|
||||
|
||||
### P5 工作量和可见内容成正比
|
||||
|
||||
- 长列表只渲染可见部分,包括消息、会话、笔记、文件树和知识库列表。
|
||||
- 解析和渲染的结果要缓存,比如 Markdown 语法树。内容没变,就不重新计算。
|
||||
- 增量处理:只解析变化的部分。没有把握时,退回到慢但正确的完整处理,并记录在测试里。
|
||||
|
||||
### P6 启动只做首屏需要的工作
|
||||
|
||||
- 首屏之前只做显示应用壳和恢复上次界面所需的工作。
|
||||
- 其他初始化(Provider、Runtime、知识库、通知渠道等)按需执行,或等首屏显示后再空闲执行。
|
||||
- 后台模块在第一次使用时才加载。
|
||||
|
||||
### P7 先测量,再修改
|
||||
|
||||
- 先跑测量,拿到数据再改代码;每项改动都要有前后对比,基线用临时 worktree 构建。
|
||||
- 每个版本至少跑 3 轮,交替运行;结果差异小于同一版本多次运行之间的波动时,不能说成“有提升”。
|
||||
- 测量用隔离的用户目录和本地假模型,不发出外部模型请求,结果里不含用户内容。
|
||||
|
||||
### P8 正确性优先于速度
|
||||
|
||||
- 事件顺序、持久化、取消、崩溃恢复和显示结果都不能因为性能而改变。
|
||||
- 异步改造要先确认“先写入、后读取”的顺序,没有测试覆盖就不合并。
|
||||
- 风险高、收益小,或者会改变现有功能的优化先不做,在计划文档里记录理由。
|
||||
|
||||
### P10 体验不倒退
|
||||
|
||||
性能优化只能让体验变好,不能用体验换速度。“行为不变”不仅指数据和结果,也包括用户能感知到的交互:
|
||||
|
||||
- **动画和过渡保持原样。** 平滑滚动(如“到底部”按钮)、展开收起、悬停、淡入淡出都不能因为优化而消失。任何瞬间写入 `scrollTop`/`scrollTo` 的代码都会打断正在进行的平滑滚动,必须在平滑滚动期间暂停。
|
||||
- **只渲染可见部分(P5)时,用户状态不能丢。** 行被卸载后,它的局部状态也会消失。以下行必须保持挂载,或者把状态存到行外:有焦点的行、用户展开过 `<details>` 或输入过内容的行、选中文本的两端、待回答的问题和待审批项、打开菜单或正在重命名的行。
|
||||
- **只渲染可见部分时,基本操作要和完整列表一致:** 跨行选择和复制、滚动位置不因上方插入或删除而跳动(关闭原生 `overflow-anchor` 时要自己做锚定)、快速拖动滚动条时没有空白帧、渲染结果缓存让重新挂载的行不出现“加载中”。
|
||||
- **合并和批处理不能增加交互延迟。** 第一条数据立即处理(leading edge),只合并之后同一帧内的数据。打字回显、终端回显不能多等一帧。
|
||||
- **低频同步不能让用户看到旧数据。** 复制、导出、保存这类读取全局状态的操作,要先把待同步的数据刷进去。
|
||||
- **体验要有测试。** 涉及动画、滚动、焦点的改动,需要真实 Electron 测试(逐帧采样)或单元测试,并确认测试在旧代码上会失败。
|
||||
|
||||
### P9 用工具守住规则
|
||||
|
||||
- lint 规则和性能阈值是合并前的硬性条件,不靠人工记得。
|
||||
- 规则只能收紧,白名单和上限只能往下调。
|
||||
- 每个 agent 只提交自己改的文件和片段,不把别人未提交的改动带进自己的提交。
|
||||
|
||||
## 3. 评审清单
|
||||
|
||||
提交性能相关改动,或者会影响热点路径的功能改动时,逐项确认:
|
||||
|
||||
1. Main 里有没有新增同步数据库调用、同步文件 IO,或者耗时可能超过 16 ms 的计算?
|
||||
2. 有没有往 `App.tsx` 里加状态?一次按键或一个流式片段,会让多大范围的界面重新渲染?
|
||||
3. 高频路径上有没有读取布局,有没有每次事件都提交状态?
|
||||
4. 有没有整份数据回写,或者“收到通知后重新查全量”?
|
||||
5. 列表的 DOM 数量会不会随数据量增长?
|
||||
6. 有没有在启动阶段新增同步初始化?
|
||||
7. 是否附上了 `perf:app`、`perf:main` 或 `perf:knowledge` 的前后对比?
|
||||
8. 顺序、取消、持久化和恢复,有没有测试覆盖?
|
||||
9. 动画、平滑滚动、过渡还在吗?有没有新增会打断平滑滚动的瞬间滚动写入?
|
||||
10. 只渲染可见部分的列表,滚走再滚回来,展开状态、输入内容、选区、焦点还在吗?
|
||||
11. 合并或延迟处理后,第一次响应(打字回显、点击反馈)有没有变慢?复制、导出读到的是不是屏幕上的最新内容?
|
||||
|
||||
## 4. 当前差距
|
||||
|
||||
截至 2026-10-04(第一期结项,见 `docs/review/performance-phase-1-report-2026-10-04.md`),按原则对照的主要差距:
|
||||
|
||||
| 原则 | 差距 |
|
||||
| --- | --- |
|
||||
| P1 | 同步数据库调用仍在 Main,没有独立数据进程;但热点读已在 worker,高频写单次 0.1–0.3 ms,WAL checkpoint 在后台线程,启动恢复按索引读取。文档解析、分块和大文件哈希已移到 worker。消息附加数据冗余(子任务工具输出、`tools` 与 `blocks` 重复),大会话读取约 170 ms |
|
||||
| P2 | `App.tsx` 约 7,900 行。输入区、隐藏路由、任务和结果事件、切换会话已不再整体重渲染 App;当前会话的流式吸收(每 250 ms)、队列和活动记录仍经过 App |
|
||||
| P3 | 已基本满足:流式输出和终端输出都已按帧合并 |
|
||||
| P4 | 活动记录已改为增量保存、按页读取(界面只保留已加载的页,计数和分组来自 Main 的汇总);部分 `*Changed` 通知之后还会重新查全量 |
|
||||
| P5 | 已基本满足:消息、会话、笔记、文件树、知识库文档表格都只渲染可见部分 |
|
||||
| P6 | 空配置启动到输入框可用约 0.96 秒;真实数据(2.4 GB)约 1.36 秒。本地工具环境已后台初始化,低频模块按需加载 |
|
||||
| P9 | 性能阈值还没有接入 CI,也没有真实低端机的基线 |
|
||||
@@ -10,7 +10,7 @@ Debian `loong64` 桌面,不代表 GoodBuddy 已将龙芯纳入正式发布支
|
||||
均为 LoongArch ELF。
|
||||
|
||||
预览版使用社区维护的 Electron 42.3.0 LoongArch 构建。GoodBuddy 正式版本
|
||||
当前使用 Electron 43.2.0,因此该预览包只用于移植和真机验证,不能作为正式
|
||||
当前使用 Electron 44.5.1,因此该预览包只用于移植和真机验证,不能作为正式
|
||||
发布基线。Electron 官方目前也不提供 Linux LoongArch 预编译包。
|
||||
|
||||
已完成的实际构建结果:
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
# Desktop 0.15.10 发布准备
|
||||
|
||||
日期:2026-10-04。基线 `v0.15.9`,冻结已提交源码
|
||||
`74efbcdc1703e6893764dbb2c48e3d1c47b21fce`,35 个提交。
|
||||
仅纳入已提交实现和发布准备,保留并排除工作区中的导航、文档处理等并行修改。
|
||||
标准六平台 Desktop,不含 LoongArch;Agent 保持已发布的 0.15.3。
|
||||
|
||||
## 范围与兼容性
|
||||
|
||||
- 统一项目与会话菜单、当前/全部范围、活动及十个最近会话、悬停预览。
|
||||
- 监督者独立文本模型选择及默认开启的会话 Story Graph 开关。
|
||||
- 活动差量保存、索引分页与完整汇总,schema 57 保留历史并迁移排序;
|
||||
schema 58 增加启动恢复候选消息索引。升级前关闭应用并备份完整数据库
|
||||
和相邻 notes 目录,旧客户端回退须恢复升级前备份。
|
||||
- 文档解析 Worker、依赖延迟加载、本地工具环境后台准备、Composer 和
|
||||
路由等渲染边界、笔记/文件树/知识文档窗口化、滚动和焦点保留。
|
||||
- 终端 Main 合并与 xterm 帧批次,远程事件 16ms 定时 flush;
|
||||
checkpoint 前落库、事务顺序和回放去重保留。
|
||||
- 旧 Runtime 缓存按明确版本和布局命名清理,跳过符号链接;
|
||||
保留当前布局、运行记录、会话和配置。工作栏间距及操作修复。
|
||||
- 高频本地写入临时切换 NORMAL 并恢复原 PRAGMA;嵌套事务由外层决定。
|
||||
保留应用崩溃原子性,但断电/系统崩溃可能丢失最近部分本地写入。
|
||||
远程确认、通道投递和任务生命周期继续 FULL,后台 PASSIVE checkpoint
|
||||
不代表已经验证破坏性断电恢复。
|
||||
- Agent 守护进程只有测试变更,Continue host-v7 仅提取常量,协议和部署
|
||||
行为未变,因此不发布新 Agent。Electron/Node/OpenCode/Continue 锁不变。
|
||||
|
||||
不承诺通用提速比例。窗口化未挂载行不支持页内查找,快速滚动可显示占位;
|
||||
只读 Worker 失效可同步回退。开发报告中的 stable controller generation resume
|
||||
及 Runtime 未到终态错误仍是调查项,本轮不声称修复。
|
||||
|
||||
## 本轮真实 Host 验证
|
||||
|
||||
中断前已针对 `e89db96` 源码使用固定 Host 身份与既有凭据存储,
|
||||
在隔离测试目录经生产 Agent、模型桥、远程 ACP、RemoteEventBatcher
|
||||
和 SQLite 持久化入口执行。后续 `74efbcd` 仅修改工作栏 CSS 间距,
|
||||
不改变已验证的远程实现。OpenCode 探针显式使用已有 Continue 模型配置,
|
||||
不是产品静默回退;未构建生产安装包。
|
||||
|
||||
**4 次真实模型请求,4 次 HTTP 200**:
|
||||
|
||||
| Runtime / 模式 | 批次 | 事件 | checkpoint |
|
||||
| --- | ---: | ---: | ---: |
|
||||
| OpenCode Ask | 3 | 4 | 2 |
|
||||
| OpenCode Execute | 22 | 42 | 21 |
|
||||
| Continue Ask | 3 | 4 | 2 |
|
||||
| Continue Execute | 21 | 40 | 20 |
|
||||
|
||||
checkpoint 前落库、完整已保存流再次写入返回已存在、Runtime 重建后历史恢复,
|
||||
本轮拥有进程清零。生产 SSH 连接池与终端会话另输出 1000 行 Unicode,
|
||||
13 个输出事件;CRLF 归一化后内容完全一致、序号递增、输出先于退出。
|
||||
最初终端探针选错 Host 路由,发生在模型请求前,真实调用数为零;
|
||||
随后复用成功固定的路由完成。临时 Electron 缓存警告非致命,不算产品修复。
|
||||
|
||||
这些证据不是完整 Renderer、已安装 Agent 升级或全部断线恢复矩阵,
|
||||
也没有验证所有消息投影恢复分支。开发阶段其他模型调用不计入本轮四次。
|
||||
|
||||
## 发布门槛
|
||||
|
||||
按用户持续授权,直接提交、推送并跟进 CI 和发布,不逐步询问。
|
||||
不运行本地测试套件、类型检查、lint 或生产构建;完整验证和构建由精确候选
|
||||
main CI 执行,通过后才创建新的不可变 Desktop 标签。双语说明与 FEATURES
|
||||
同时更新;Agent 不打新标签。开发文档记载的未通过测试不能视为已解决,
|
||||
由此次精确候选 CI 决定,失败须定位修复。
|
||||
|
||||
最终需核验六平台打包、macOS 签名与公证、GitHub Latest 和 20 项资产、
|
||||
北京 OSS 索引一致性与网站 12 种下载选择。不下载或启动安装包,
|
||||
不将候选或推送成功称为发布完成。
|
||||
|
||||
### 首轮候选 CI
|
||||
|
||||
`273a7fa` 的 Desktop CI `37174433765`:5953 通过、59 跳过、3 失败,
|
||||
生产构建未执行;Agent CI `37174433777` 通过。
|
||||
监督设置测试需要先进入“回顾整理”页签再操作其可见保存按钮,
|
||||
Electron 布局测试更新为真实标题“模型与运行限制”,截图锚点限定可见设置面板。
|
||||
保留保存值、再次打开恢复、布局及溢出断言。
|
||||
1200 条活动历史测试会实际显示至 1000 行,专用超时从 30 秒改为 60 秒;
|
||||
不减少数据、不移除分页、清空总数或离页裁剪断言,等待下一次 CI 验证。
|
||||
|
||||
`362fe50` 的 CI `37175597089`:上述三个测试均通过,5955 通过、59 跳过;
|
||||
另一个工作区 KeepAlive 测试在默认一秒内未找到魔法笔记标题。
|
||||
仅将该轮换测试的异步标题等待设为五秒,保留聊天 DOM 身份、旧工作区淘汰
|
||||
及缓存数量断言。CI 观察命令曾出现 GitHub API EOF,最终失败以工作流结果为准。
|
||||
@@ -0,0 +1,36 @@
|
||||
# Desktop 0.15.3 发布准备
|
||||
|
||||
日期:2026-09-28。用户确认沿用双远端推送、精确提交 CI 和生产构建通过后自动打标签的流程;
|
||||
本次仅发布 Desktop 0.15.3,已发布 Agent 0.15.0 保持不变,不构建 LoongArch。
|
||||
|
||||
## 范围
|
||||
|
||||
- 已发布基线:`v0.15.2`,提交 `47ba54f47db4f22f69375310925eedab020b3cf0`。
|
||||
- 本轮已核对 15 个提交,截止 `86347af0b1cc8726c1166b3f9fa3d7f12a0cc5e3`。
|
||||
包含手动压缩、请求级工具授权、取消/失败状态、聊天/笔记重复工作、
|
||||
侧栏与工作栏布局、打包依赖精简。起始工作区干净。
|
||||
- Office 同会话实时编辑仅更新需求与验收设计,不计入交付能力。
|
||||
- `package.json`、lockfile 两处 Desktop 版本统一为 0.15.3。
|
||||
已发布 0.15.2 与 Agent 0.15.0 的正文保持不变;新双语说明单独追加。
|
||||
- 两份 FEATURES 对照实现更新手动压缩、状态反馈、搜索、箭头方向及发布状态。
|
||||
不用局部 Markdown 基准宣称整体渲染性能提升,不提前承诺跨平台安装包缩小比例。
|
||||
|
||||
## Agent 与验证边界
|
||||
|
||||
`src/agent-daemon`、`src/main/remote-agent`、Agent 和 Runtime 锁在本轮未变。
|
||||
授权修复属于 Desktop 的 `KnowledgeMcpGateway`:请求有效期间不再按固定十分钟撤销,
|
||||
取消和清理继续撤销;不修改远程工具通道或部署包,不需要重发 Agent 0.15.0。
|
||||
手动直连压缩仅用于本地文本会话,UI 与 IPC 均排除 SSH/远程直连入口。
|
||||
|
||||
开发期证据分别见[直连模型进度](../features/direct-model-agent/progress.md)、
|
||||
[导航进度](../features/application-tool-navigation/progress.md)和
|
||||
[性能基准](../roadmap/product-performance-experience-improvement-plan.md)。
|
||||
开发期全量曾报告侧栏和笔记 Electron 用例失败,不把这些历史结果计为本候选通过。
|
||||
本轮 `npm run typecheck`、`npm run lint`、`npm run release:notes:verify` 通过。
|
||||
网关、Obsidian 授权、桌面资源与 DS 打包、手动压缩真实 Electron 生产链路共 6 文件:
|
||||
**85 通过、1 跳过**。跳过的是 Windows 不适用的 POSIX 符号链接用例。
|
||||
压缩测试使用本机 HTTP fixture,不冒充外部模型调用;本轮没有新增付费模型请求。
|
||||
沿用用户确认的直接推送等待 CI 流程,完整测试及生产构建以精确候选 CI 为发布门槛。
|
||||
不进行本地生产构建、打包或安装包启动。
|
||||
|
||||
schema 保持 48;从更早版本升级前备份完整数据库及相邻 `notes/` 目录。
|
||||
@@ -0,0 +1,55 @@
|
||||
# Desktop 0.15.4 与 Agent 0.15.1 发布准备
|
||||
|
||||
日期:2026-09-29。用户确认两个补丁版本,提交并推送两个远端;
|
||||
候选 CI 和生产构建成功后自动打标签发布,不构建 LoongArch。
|
||||
|
||||
## 范围与说明
|
||||
|
||||
- Desktop 已发布基线为 `v0.15.3`(`3c9229ac7a57071fc955adb1095c499ffdfd006a`)。
|
||||
本轮源码截止 `9c88ca20f3c62ca29b9aca179dc191b460db37d8`,开始时工作区干净。
|
||||
- 三个提交包含系统任务 Token 统计、Runtime/模型分层和宣传稿归档。
|
||||
正式功能说明以实现为准;用途模型和旧全局 provider 移除仍为规划。
|
||||
- Agent 基线为已发布 `agent-v0.15.0`。本轮 Continue helper 识别首次历史封装、
|
||||
去除保存消息中的工作模式前缀,因此发布新 Agent 0.15.1,不覆盖旧产物。
|
||||
Node 24.19.0、OpenCode 1.18.29、Continue 1.5.47 和协议锁保持不变。
|
||||
- 正文仅维护在 Desktop 0.15.4 JSON 条目及 Agent 0.15.1 Markdown;
|
||||
已发布版本正文保留。FEATURES 中修正旧的简化选择描述,并同步用量、版本和限制。
|
||||
|
||||
## 数据与兼容
|
||||
|
||||
schema 49 在既有 SQLite 事务中迁移项目与会话选择,不引入额外恢复系统。
|
||||
已读迁移指南并核对合成 schema 48 数据迁移回归;本轮不改写用户原库。
|
||||
关闭应用后备份完整数据库及相邻 `notes/`,旧客户端不能打开升级后的库,
|
||||
降级需要恢复完整升级前备份。远程先升级 Desktop 0.15.4,再更新 Agent 0.15.1。
|
||||
统计无法补算历史未记录的调用或缺失的供应商 usage。
|
||||
|
||||
## 验证
|
||||
|
||||
- `npm run typecheck`、`npm run lint`、`npm run release:notes:verify` 通过。
|
||||
- 10 个聚焦文件、221 项通过:包含数据库迁移/用量分类、选择解析、选择器、
|
||||
Activity UI、后台用量记录和 Continue helper。
|
||||
- 当前源码隔离 Agent 在共享 Linux x64 Host 验证 OpenCode 与 Continue Ask、Execute、
|
||||
空闲进程回收及冷启动历史恢复。成功场景每个 Runtime 两次真实模型请求。
|
||||
OpenCode 原模型连接前三次尝试未完成,第三次脱敏诊断明确为上游 `ECONNRESET`;
|
||||
随后测试显式使用 Continue 已配置且成功的模型连接,未修改用户设置或产品回退规则。
|
||||
本轮共 7 次上游请求分发尝试,4 次成功、3 次未完成;不推断失败尝试的计费情况。
|
||||
所有探针结束时归属进程为 0,用户设置只在临时加密副本中读取,测试副本已清理。
|
||||
- 既有开发记录缺少完整 UI 目测及全仓通过结果,保留边界,不将局部通过冒充全量。
|
||||
完整测试与生产构建交由精确提交 CI;不执行本地生产构建、打包或安装包启动。
|
||||
|
||||
## 首轮候选 CI
|
||||
|
||||
`ed47e2c` 已同步两个远端。Agent `36580699297` 三平台验证通过。
|
||||
Desktop `36580699255` 为 5,472 通过、52 跳过、1 失败,生产构建未执行,
|
||||
尚未创建任何本轮发布标签。问答测试在 Runtime 就绪前点击仍禁用的发送按钮,
|
||||
没有产生首个请求;按现有异步同步机制等待按钮可用,保留全部问答与重载断言。
|
||||
该修正仅影响测试和发布记录,运行时源码、锁文件及双语发布说明不变。
|
||||
修正后完整 `App.test.tsx` 314 项通过,类型检查与 lint 再次通过。
|
||||
|
||||
## 发布前界面修正
|
||||
|
||||
用户要求移除会话 Runtime 按钮图标与名称间的圆点,并确认继续提交、推送和发布。
|
||||
删除专用覆盖标记 class 及其伪元素样式,保留菜单内来源提示和恢复默认操作,
|
||||
同步双语说明及选择设计。完整 App 与样式回归共 351 项通过,类型检查和 lint 通过。
|
||||
该改动仅在 Renderer,不影响 Agent 或桌面至 Agent 协议,无需新增真实模型请求。
|
||||
此前中断的测试不计为通过;本条结果来自重新执行。最终新提交仍须等待独立 CI。
|
||||
@@ -0,0 +1,74 @@
|
||||
# Desktop 0.15.5 发布准备
|
||||
|
||||
日期:2026-09-30。用户授权按新增改动确定版本、提交推送两个远端,
|
||||
CI 与生产构建通过后自动打标签发布;本轮不构建 LoongArch。
|
||||
|
||||
## 范围
|
||||
|
||||
已发布基线 `v0.15.4`:`a662b16f5e076f67788457653d29c0d2fbacdab0`。
|
||||
初始工作区干净,产品源码截止 `2d8e58e995c4ac2e3d885b028e588d49d06f05cf`。
|
||||
四个提交分别为取消轮次历史、执行清单浮层、智能心跳增量回顾与监督建议、
|
||||
笔记保存后退出编辑。双语正文维护在 `resources/release-notes.json`;
|
||||
已发布正文保留,两个 FEATURES 同步当前能力和剩余边界。
|
||||
|
||||
Agent 保持 0.15.1,不发布新标签或修改其历史说明。Agent daemon、协议实现和
|
||||
Runtime 锁没有变化;共享合同新增的是桌面监督配置/API,历史构建在 Renderer/Main
|
||||
进行,远程继续通过现有 history 字段传输。因桌面至 Agent 历史输入改变,
|
||||
仍对当前源码完成真实 Host 取消后续聊验证。
|
||||
|
||||
## 数据与行为边界
|
||||
|
||||
schema 50 在既有事务中增加心跳介入方式、建议状态和建议表;旧报告中仍 proposed
|
||||
的记忆迁为 pending 约定,不确认或删除原记录。已读迁移指南并核对 49→50 回归;
|
||||
没有读取或升级用户原库,也不声明大规模真实旧库性能已验证。
|
||||
升级前关闭应用,备份完整数据库及相邻 `notes/`;降级需恢复完整升级前备份。
|
||||
|
||||
手动回顾默认增量,显式重新整理不推进或重置共享进度。建议以本次结果中的依据为准,
|
||||
有候选才进行额外模型调用;项目与全局分别维护进度,会话级实时介入仍未实现。
|
||||
本地 OpenCode 的取消会话标记不等同于 Agent 实现;远程使用共享桌面历史输入与
|
||||
原有取消/会话恢复路径,不声称复用了本地 OpenCode 类。
|
||||
|
||||
## 验证记录
|
||||
|
||||
- 类型检查和 lint 通过。
|
||||
- 首轮定向 34 文件:1092 通过、1 跳过、1 失败。会话合并用例只等待 Runtime
|
||||
mock 调用便触发新会话,单独复跑通过;补充等待已发送消息可见,保留两次同批
|
||||
新建请求和最终会话数量断言。最终 34 文件全部通过,1093 项通过、1 项既有条件跳过;
|
||||
类型检查、lint、双语发布说明及差异检查再次通过。
|
||||
- 当前源码在共享 Linux x64 Host:OpenCode、Continue 各在 Ask 输出后主动取消,
|
||||
用生产 `buildRuntimeHistory` 生成中断历史,再以 Execute 发送无工具的新问题。
|
||||
两条路径均只回答新问题,未续写原列表;归属进程最终为 0。
|
||||
本轮恰好 4 次真实上游请求,全部 HTTP 200,无重试或假模型;用户设置只读取
|
||||
临时加密副本,未修改正式 Agent 安装和工作目录。
|
||||
- 智能心跳开发记录中的 4 次 DeepSeek 请求是该功能开发证据,不计入本轮新增调用。
|
||||
参见[实施记录](../features/conversation-supervision/progress.md)。
|
||||
- 不执行本地生产构建、打包或安装包启动。全仓验证和生产构建以最终提交的 CI
|
||||
为准,标签只能在该门槛通过后创建。
|
||||
|
||||
## 中断后扩展候选
|
||||
|
||||
用户新增四个提交并授权再次推送继续发布;源码范围扩至
|
||||
`6edfc0046b83150f2027167352000f161fb0dd0a`,恢复时工作区干净。
|
||||
新增 OpenCode 终端依赖缓存、短 MCP 名称与并行占用、工作区固定工具栏、
|
||||
笔记双栏布局及手动标签。说明和功能矩阵已同步,目标 schema 改为 51。
|
||||
标签支持多选 AND 筛选、重命名/合并/删除及桌面笔记工具字段,非自动分类;
|
||||
Agent daemon 和协议不变,Agent 仍为 0.15.1。
|
||||
|
||||
上一候选 `925d410` 的 Agent CI 通过,Desktop `36664224641` 为 5481 通过、
|
||||
55 跳过、3 失败,生产构建未执行,未创建标签。两个笔记分析用例的旧编辑按钮
|
||||
假设已由新提交修正;新源码真实 Electron 复验已通过。监督布局仍查找旧记忆建议
|
||||
标题及已移除的心跳运行审计,修正为监督建议区域和两条建议断言、审计不重复展示,
|
||||
保留页签、宽度、历史报告和导航检查。
|
||||
|
||||
扩展候选复验:15 文件、502 项通过,包含上述三个失败场景、笔记标签与布局、
|
||||
工作区滚动布局、schema 51 数据库、MCP 网关/名称及原生终端缓存测试;
|
||||
类型检查、lint、双语说明校验和差异检查通过。该轮测试使用隔离 fixture,
|
||||
没有新增付费模型调用;先前 4 次真实 Host 请求仅对应取消后续聊范围,
|
||||
不作为新增标签工具的完整远程 UI 实测声明。
|
||||
|
||||
`89fc100` 的 Agent CI 通过;Desktop `36707706849` 为 5507 通过、55 跳过、
|
||||
2 失败,前述三个失败场景均已通过。笔记滚动在请求完成回调中早于 DOM 提交,
|
||||
以立即执行动画帧的回归稳定复现后,改在布局 Effect 中定位已挂载记录。
|
||||
手动压缩独立复跑通过,夹具同步当前分层选择格式并等待目标会话历史显示;
|
||||
尚不将独立通过视为 Linux 全仓通过。修正后完整 App/笔记组件及真实 Electron
|
||||
导航/压缩共 450 项通过,类型检查和 lint 通过,无新增外部模型调用。
|
||||
@@ -0,0 +1,70 @@
|
||||
# Desktop 0.15.6 发布准备
|
||||
|
||||
日期:2026-10-01。用户授权提交当前未提交改动并继续双远端推送与发布,
|
||||
如 Agent 实际受影响可升补丁版本;另明确本轮仅标准六平台,不含 LoongArch。
|
||||
|
||||
## 候选范围
|
||||
|
||||
基线 `v0.15.5` 为 `3be55348a215f4343326b1950a69529247cbae6a`,
|
||||
六个新增提交截止 `9cc5dc124464a67ed93014c53cbc93f96764caa2`。
|
||||
初始未提交改动仅 `application-center.css` 一行选择器:
|
||||
侧栏底部仅对第二个直接子元素文字 span 禁止伸缩,避免影响其他 span。
|
||||
已核对并纳入本轮,未包含凭据、日志或临时产物。
|
||||
|
||||
新增内容包括磨砂外观、输入/缓存会话切换渲染优化、直接图片预览、设置与笔记布局、
|
||||
本地 MCP 恢复及不确定名称退役、诊断和执行统计索引。
|
||||
双语说明和功能矩阵按实现更新,不将已有开发记录中的全量失败记为成功。
|
||||
|
||||
Agent 保持 0.15.1:相对其发布标签,daemon 与锁文件无变化。
|
||||
本地 OpenCode 的 HTTP 注册恢复属于 `OpenCodeRuntime`,远端使用
|
||||
`RuntimeAcpBackend`/`AgentImageToolMcp`,不经过这条 HTTP 注册路径。
|
||||
没有据此声称远程恢复得到同样改进,也没有新发布 Agent 包。
|
||||
|
||||
## 数据与验证
|
||||
|
||||
已读数据库迁移指南。schema 52 在事务内增加任务事件部分索引,不转换消息正文,
|
||||
不执行 VACUUM;建立索引的耗时受历史规模影响,本轮未测真实大库升级。
|
||||
升级前关闭应用并备份数据库和相邻 `notes/`,降级需要完整升级前备份。
|
||||
|
||||
- 当前源码 17 个定向文件、886 项通过,包含真实 OpenCode + HTTP 网关合成服务的
|
||||
MCP 故障恢复和晚到初始化回归,数据库/统计、诊断、设置、App、图片附件和真实
|
||||
Electron 磨砂、笔记布局、工作栏设置布局。
|
||||
- `npm run typecheck`、`npm run lint` 通过;本轮新增付费模型调用 0 次。
|
||||
- 完整测试和生产构建等待精确候选 CI,不运行本地生产构建、打包或安装包探针。
|
||||
|
||||
## 首轮 CI
|
||||
|
||||
候选 `cc451f6` 已含用户未提交样式修正并同步两个远端。Agent `36743915140`
|
||||
通过,Desktop `36743915207` 为 5573 通过、57 跳过、1 失败,生产构建未执行。
|
||||
磨砂测试切换主题后立即读取样式,窗口按钮 color 正在过渡,导致深色关闭态与
|
||||
先前基线不同。测试改为等待当前 CSS Transition 完成,保留完整样式、几何、
|
||||
拖拽、对比度及原生交互断言,不修改产品外观或全局超时。
|
||||
修正后完整磨砂 Electron 回归、类型检查及 lint 通过;双语说明与产品源码不变。
|
||||
|
||||
`8201c9c` 的 CI `36746918608` 中磨砂回归通过,唯一失败为 161 条历史的多次
|
||||
热切换 App 用例超过默认 5 秒(5573 通过、57 跳过)。只将该用例预算设为 15 秒,
|
||||
保留消息数量、DOM 复用、零额外渲染、草稿、附件、展开状态和滚动位置的全部断言;
|
||||
不修改全局超时或将运行时性能要求替换为等待通过。
|
||||
调整后完整 App 319 项、类型检查及 lint 通过。
|
||||
|
||||
## 再次扩展候选
|
||||
|
||||
用户要求将当前未提交改动一起提交。新增 `0a02a17`(新建笔记入口)和
|
||||
`a3b41bf`(设备共享元数据),并纳入五份应用导航文档修改。
|
||||
设备共享需另行运行 ShareServer,首期面向本地未认证服务,仅注册设备及管理元数据;
|
||||
不执行远程能力,不传输或检索知识正文,设备 ID 不构成所有权认证。
|
||||
Agent 实现和锁不变,仍不升版。
|
||||
|
||||
扩展源码类型检查和 lint 已通过。用户随后明确要求停止本地测试、直接推送等 CI,
|
||||
进行中的定向测试已中断,不计为通过,也不沿用旧候选的全量结果。
|
||||
最终发布取决于包含新增提交和文档的精确候选 CI 与生产构建。
|
||||
|
||||
## 扩展候选焦点回归
|
||||
|
||||
`2c5300d` 的 Desktop CI `36753498640`:5580 通过、58 跳过、1 失败,
|
||||
生产构建未执行;Agent CI `36753498643` 通过。
|
||||
笔记导航用例在打开并取消带 `autoFocus` 标题输入框的新建表单后,
|
||||
才断言此前打开详情时的返回按钮焦点,混合了两个不同交互阶段。
|
||||
将原焦点断言移至打开新建表单之前,并断言新建标题输入框取得焦点;
|
||||
保留返回列表、滚动位置、搜索内容和原卡片焦点断言,不改产品代码或延长超时。
|
||||
按用户要求不运行本地测试,由新候选 CI 验证。
|
||||
@@ -0,0 +1,53 @@
|
||||
# Desktop 0.15.7 发布准备
|
||||
|
||||
日期:2026-10-01。基线为 `v0.15.6`,源码候选 `c8f327f`。
|
||||
检查时工作区干净,用户要求纳入的先前改动已由该提交包含,共 75 文件。
|
||||
本轮 Desktop 升至 0.15.7,Agent 保持 0.15.1,仅标准六平台,不含 LoongArch。
|
||||
|
||||
## 范围
|
||||
|
||||
- 流式 Markdown 分段复用、会话行与派生值缓存。默认 16ms 节奏不变。
|
||||
- 运行记录进入时读取、可见时 15 秒刷新及页头手动刷新,不是逐事件实时视图。
|
||||
- 本地自定义 MCP 发现与动态目录故障按服务隔离,取消和撤销仍生效。
|
||||
- 模型连接中的受管模型下载源弹窗,确认保存、取消不变及相关入口统一。
|
||||
- 设备共享默认关闭的测试入口,显式启用,关闭不撤销服务端记录。
|
||||
|
||||
网关修改只涉及本地自定义 MCP 发现方法;远程 Agent 的独立 MCP 实现与
|
||||
Story Graph 绑定不经过这些方法,Agent 源码、锁及包未变。边界与开发验证见
|
||||
[工作栏进度](../features/assistant-workbar/progress.md)。
|
||||
数据库仍为 schema 52,无新增迁移。
|
||||
|
||||
## 证据与发布门槛
|
||||
|
||||
本轮遵照用户要求,不运行本地测试、类型检查、lint 或生产构建,由新候选 CI 验证。
|
||||
已有开发会话记录不等于本候选全仓通过;网关会话的历史全仓失败也不掩盖。
|
||||
流式输入测量见 [组件测量记录](../../tests/support/streaming-input-results.md),
|
||||
不据此声称完整 App 或所有机器的相同比例提速。本轮外部模型调用为 0。
|
||||
|
||||
中英文 FEATURES 已核对设备共享默认值、下载源位置及实现边界,并补充本轮性能和活动行为。
|
||||
发布说明保留 schema 52 备份提醒,不修改历史已发布条目。
|
||||
双远端推送精确候选后,必须通过主分支全量验证与生产构建,并确认候选及双语说明后,
|
||||
才能创建不可变 `v0.15.7` 标签。
|
||||
|
||||
## 首轮 CI
|
||||
|
||||
`227507d` 的 Desktop CI `36872224138`:5613 通过、59 跳过、2 失败,
|
||||
生产构建未执行;Agent CI `36872224140` 通过。
|
||||
英文设置布局 fixture 在 640×420 窗口将控件居中到包含固定页头的整个滚动区,
|
||||
点击检测被较高的英文页头遮挡。测试共用滚动辅助函数改为扣除实际页头高度的
|
||||
可见区域,仍检查命中、溢出、文字宽度和原生键盘交互,不改产品布局。
|
||||
旧 outbox fixture 先建当前数据库,再降为 schema 23 并执行完整迁移,
|
||||
超过默认 5 秒;仅该用例设 15 秒,保留终态、尝试次数及附件清理断言。
|
||||
修复不修改发布说明或运行时代码,按用户要求继续交新候选 CI 验证。
|
||||
|
||||
## 第二轮 CI
|
||||
|
||||
`7cb74cc` 的 CI `36874700588`:首轮两项失败已通过,但仍有 5613 通过、
|
||||
59 跳过、2 失败,生产构建未执行。检查提交链确认先前提交期间并行加入了
|
||||
`5e77118`,移除文字输入框占位提示的问候语,App 单元测试已同步为两行,
|
||||
Electron 侧栏探针却仍要求三行,导致断言失败。探针同步为两行并检查首行键盘提示,
|
||||
保留平台快捷键、输入、清空、焦点、布局和搜索检查。
|
||||
Markdown 用例逐一比较超过 1500 个流式前缀,超过默认 5 秒;
|
||||
仅该用例改为 15 秒,保留全部 DOM 等价、前缀数及多段覆盖断言。
|
||||
用户明确授权连续修复、双远端推送和 CI 通过后的标签发布,不再中途确认。
|
||||
本轮仍不运行本地测试,以上修复须由下一次精确候选 CI 验证。
|
||||
@@ -0,0 +1,57 @@
|
||||
# Desktop 0.15.8 / Agent 0.15.2 发布准备
|
||||
|
||||
日期:2026-10-02。Desktop 基线 `v0.15.7`,Agent 基线 `agent-v0.15.1`;
|
||||
已提交源码固定为 `b1129cedec5e4890d82fcddc186b13467f466a6f`。
|
||||
仅纳入四个已提交功能修正及本次必要发布准备,不提交已有 Story Graph、
|
||||
会话监督设计或宣传文章等未提交文件。
|
||||
|
||||
## 范围
|
||||
|
||||
- `dd29f30`:保存会话图片到本机或 Host、设置中列出图像工具。
|
||||
- `f4d8fe7`:新记录取消、编辑焦点返回、笔记正文使用可用宽度。
|
||||
- `2e5c348`:缺失内置浏览器设置时默认启用,保留已保存配置。
|
||||
- `b1129ce`:缺失磨砂外观字段时默认开启,保留已保存配置。
|
||||
- 数据库仍为 schema 52;Desktop 标准六平台,无 LoongArch。
|
||||
- Agent 保存路径涉及守护进程、工具声明与桌面图像二进制回复,升至 0.15.2,
|
||||
最低 Desktop 0.15.8;协议主版本及 Runtime 锁版本不变。
|
||||
|
||||
## 当前源码共享 Host 验证
|
||||
|
||||
共享 Linux x64 Host 的现有 SSH 固定身份可达。在独立 `/root/tmp` 目录运行
|
||||
当前源码 AgentImageToolMcp,通过真实 SSH 传递工具消息,与 Desktop 的
|
||||
MainImageToolSession、ImageGenerationService、真实 SQLite 会话协作。
|
||||
图像为本轮生成的合法随机 PNG,196992 字节,超过单个 128 KiB 分块。
|
||||
|
||||
保存后的 Host 文件 SHA-256 与 Desktop 原始内容相同;默认覆盖拒绝、明确覆盖成功、
|
||||
其他会话图片 ID 拒绝、Ask 写入拒绝且无文件落盘。共 8 个回复帧,
|
||||
最大 174892 字节,低于 256 KiB 上限。外部模型调用 0 次。
|
||||
这是生产保存服务与 Agent MCP 的真实 Host 工具层验证,不冒充完整 App、
|
||||
模型自动选工具、安装包或三个 Host 架构验证。使用 SSH 流作为该探针传输;
|
||||
未改变用户工作区、产品配置或 Host 已安装 Agent。
|
||||
|
||||
临时探针最初因打包 Zod 初始化顺序、混入仅 Desktop 依赖而无法启动,
|
||||
修正探针依赖边界后验证通过;这些失败不计为产品模型调用或产品修复。
|
||||
探针和包不进入仓库。用户要求不跑本地测试套件,精确候选由 CI
|
||||
执行全量验证和生产构建后,才允许打 Desktop 和 Agent 不可变标签。
|
||||
|
||||
## 发布核对
|
||||
|
||||
中英文 FEATURES、Desktop 与 Agent 双语说明必须与上述实现边界一致。
|
||||
只暂存必要发布文件,保留工作区用户文件;双远端核验候选和标签一致。
|
||||
公开验证包括六平台 Desktop、三平台 Agent 签名目录、GitHub/OSS 同步及 Latest 分离。
|
||||
|
||||
## 首轮 CI 修正
|
||||
|
||||
`20317c2`:Agent CI `36960575058` 成功;Desktop `36960575157` 为
|
||||
5620 通过、59 跳过、4 失败,构建未执行。
|
||||
Obsidian 专项 IPC fixture 未安装浏览器服务,显式关闭无关浏览器能力;
|
||||
MCP 列表断言纳入新增图像卡片的数量和名称;覆盖层原生点击先将控件居中,
|
||||
避免磨砂顶栏覆盖 `nearest` 保留在顶部的控件,并补充命中遮挡诊断。
|
||||
保留所有业务、覆盖层和原生交互断言,不改变产品默认值、关闭沙箱或跳过测试。
|
||||
这些修正由新候选 CI 验证。
|
||||
|
||||
`5368e0e` 的 CI `36962139369` 中上述四项通过,余下新记录取消焦点用例失败,
|
||||
5623 通过、59 跳过、1 失败。测试用 `fireEvent.click` 点击取消不会像原生点击
|
||||
那样移动焦点,确认框记住的仍是先前的返回总览按钮。用例先等打开详情的焦点完成,
|
||||
明确聚焦取消按钮后再点击,并等继续编辑实际聚焦编辑器后进行下一步;
|
||||
保留放弃草稿后回到记录与未保存新记录的断言,不改产品焦点规则或延长超时。
|
||||
@@ -0,0 +1,56 @@
|
||||
# Desktop 0.15.9 / Agent 0.15.3 发布准备
|
||||
|
||||
日期:2026-10-03。Desktop 基线 `v0.15.8`,源码
|
||||
`50481deaa1f7d17184500bb6f5abb3e1a0c8bbd5`,37 个提交、163 个文件。
|
||||
检查时工作区干净,范围仅为已提交实现与必要发布准备。
|
||||
标准六平台 Desktop、三平台 Agent,不含 LoongArch。
|
||||
|
||||
## 范围与边界
|
||||
|
||||
- 监督共享时间线、故事归属与人工调整、经验抽取、故事回顾与心跳建议;
|
||||
平铺与时间螺旋、紧凑图谱控件及 WebGL 生命周期修复。
|
||||
- schema 53~56:合并既有进度,增加故事、经验及建议类型,保留历史结果。
|
||||
旧数据库副本验证和模型质量抽查见[监督进度](../features/conversation-supervision/progress.md),
|
||||
不将 schema 53 的单库耗时当作本轮完整迁移保证。
|
||||
- 会话外部存储、侧栏和消息窗口化、Markdown 缓存、只读 Worker、活动差量写入、
|
||||
远程事件 checkpoint 批次持久化;保留丢失焦点、滚动、去重与提交顺序检查。
|
||||
- Execute 配置工具不再重复弹原生确认,Ask 只读边界不变。
|
||||
- Electron 44.5.1;Node、OpenCode、Continue 锁不变。
|
||||
- Agent 虽无守护进程文件变更,但打包的 Story Graph 工具 schema 增加 story/experience,
|
||||
旧 Agent 不支持这些输入,因此升级 0.15.3,最低 Desktop 0.15.9。
|
||||
|
||||
性能范围及暂缓项见[性能计划](../roadmap/product-performance-experience-improvement-plan.md)。
|
||||
不把组件或特定机器的指标作为普遍提速保证;向量仍可全表扫描,Worker 失效会同步回退,
|
||||
窗口化的未挂载行不支持页内查找,快速滚动可能短暂显示占位。
|
||||
故事、经验、建议与输出重试有模型费用,自动结果须人工核对。
|
||||
|
||||
## 本轮真实 Host 验证
|
||||
|
||||
当前源码临时打包到隔离测试目录,不构建生产安装包。使用现有固定 Host 身份及
|
||||
凭据存储,通过生产 Agent Attach、模型桥及远程 ACP 路径执行 OpenCode 与 Continue
|
||||
各一次 Ask、一次 Execute。OpenCode 探针显式选择既有 Continue 模型配置,
|
||||
不修改产品设置,也不是产品静默降级。
|
||||
|
||||
共 **4 次真实提供方请求,4 次 HTTP 200**,无模拟推理、无自动请求重试。
|
||||
验证 Runtime 释放后重建、历史恢复和本轮进程清零。
|
||||
每次消费真实远程语义流,通过生产 `RemoteEventBatcher` 与
|
||||
`AssistantDatabase.appendRemoteTaskEventsOnce` 在 checkpoint 前落库;
|
||||
对完整已提交事件再次写入均返回已存在。四次请求分别记录:
|
||||
|
||||
| Runtime / 模式 | 批次 | 事件 | checkpoint |
|
||||
| --- | ---: | ---: | ---: |
|
||||
| OpenCode Ask | 3 | 4 | 2 |
|
||||
| OpenCode Execute | 21 | 40 | 20 |
|
||||
| Continue Ask | 3 | 4 | 2 |
|
||||
| Continue Execute | 23 | 44 | 22 |
|
||||
|
||||
验证没有启动完整 Renderer,也不声称覆盖会话消息投影的所有恢复分支。
|
||||
Story Graph 新对象与来源语义由本轮源码测试及开发记录覆盖,以上四次请求不调用图谱工具。
|
||||
开发文档中的模型调用不计入本轮 4 次。临时探针的两次编译钩子定位失败均发生在请求之前。
|
||||
|
||||
## 发布门槛
|
||||
|
||||
按用户要求,不执行本地测试套件、lint、类型检查或生产打包;
|
||||
精确候选推送双远端后由 CI 做全量验证和生产构建,再打不可变双版本标签。
|
||||
中英文 FEATURES 需同步共享进度、已实现故事经验、成本和窗口化限制。
|
||||
公开核验 Desktop Latest、标准产物、Agent 签名目录、两个源及网站,保留任何并行未提交工作。
|
||||
@@ -1,9 +1,13 @@
|
||||
# 应用导航与系统工具入口
|
||||
|
||||
本功能整理 GoodBuddy 主侧栏的入口层级:系统入口保持固定,四个内置应用共用可调整的保存顺序,知识库始终启用并常驻;
|
||||
智能心跳、魔法笔记和本机推理监控可通过应用管理控制启用和常驻。底部应用中心向上展开轻量锚定菜单,列出已启用应用;底部“管理应用”打开既有管理 Modal。系统工具由右侧工作栏 Tab 承载。应用商店是后续功能,首期不提供
|
||||
本功能整理 GoodBuddy 主侧栏的入口层级:系统入口保持固定,五个内置应用共用可调整的保存顺序,知识库始终启用并常驻;
|
||||
智能心跳、魔法笔记和本机推理监控可通过应用管理控制启用和常驻。底部“应用”向上展开轻量锚定菜单,列出已启用应用;底部“管理应用”打开既有“应用中心”管理 Modal。系统工具由右侧工作栏 Tab 承载。应用商店是后续功能,首期不提供
|
||||
商店入口、推荐位或安装流程。
|
||||
|
||||
设备共享作为第五项追加到原有应用顺序,测试阶段默认关闭,启用后可从菜单和管理 Modal 打开,不提供常驻
|
||||
开关;服务地址与设备名称在其页面保存。首期仅支持本地服务的设备注册与元数据目录,详见
|
||||
[设备共享](../device-sharing/README.md)。
|
||||
|
||||
当前分期见 [PRD 第 8 节](./prd.md#8-分期),实施步骤见[技术设计第 11 节](./technical-design.md#11-实施顺序)。
|
||||
本轮 A–D 已接入生产源码:应用菜单、应用管理 Modal、共享应用设置、持久化偏好及本机推理监控。菜单不按打开历史或常驻状态筛选,无模态遮罩;点击应用行关闭菜单,知识库、智能心跳和魔法笔记打开主内容区页面。本机推理监控从菜单、常驻入口或管理 Modal 打开时均使用独立 Modal,保留底层工作区。管理列表、菜单和侧栏按同一保存顺序排列,侧栏按启用和常驻过滤。知识库在管理 Modal 中提供“打开”和排序,不提供启用、常驻开关或通用空设置;原工作区配置及心跳单条计划 enabled 保持原义。验证范围及集成缺口见[实施进度](./progress.md)。
|
||||
|
||||
@@ -16,7 +20,8 @@
|
||||
相关验收见 [User Stories](./user-stories.md#7-后续需求场景)。
|
||||
|
||||
项目活动汇总独立于上述应用中心计划,范围见 [FR-14](./prd.md#fr-14-项目活动汇总)。
|
||||
计数规则由[活动逻辑](./logic-design.md#9-项目活动汇总)定义,入口与级联菜单见
|
||||
生产项目入口是一个固定双行按钮:第一行显示项目名,第二行显示全局活动或“暂无活动”,点击任一行都打开统一工作空间菜单并默认预览当前项目。项目分类依次为“全部项目、本地、远程、消息通道”,点击“全部项目”切换全局范围,悬停预览会话,点击项目直接进入。生产最近会话上限为 10 条,验证状态见实施进度。
|
||||
计数规则由[活动逻辑](./logic-design.md#9-项目活动汇总)定义,统一菜单见
|
||||
[活动界面](./ui-design.md#10-项目活动汇总),数据加载与会话跳转见
|
||||
[技术设计](./technical-design.md#12-项目活动汇总)。此处“活动”包括当前运行、待处理及本次客户端
|
||||
会话内已完成但未查看的会话;完成提示在实际进入聊天后清除,不累计历史完成总数。
|
||||
@@ -25,7 +30,7 @@
|
||||
|
||||
待实现的应用扩展见 [FR-17](./prd.md#fr-17-主机管理与云环境管理待实现):
|
||||
主机管理从设置中心迁入独立工作页面,云环境管理作为同级应用;业务定义由
|
||||
[远程主机功能文档](../remote-host/README.md)维护。上文四应用描述仍为当前实现。
|
||||
[远程主机功能文档](../remote-host/README.md)维护。该计划不包含已接入的设备共享元数据页面。
|
||||
|
||||
现有 `heartbeat` 入口承载监督者工作回顾与故事线图谱,可管理启用、常驻和排序。应用默认关闭;完整执行边界见[应用启停与执行](../conversation-supervision/logic-design.md#应用启停与执行)。未来市场复用同一应用身份,不增加平级重复入口,目标定义见[监督者产品设计](../conversation-supervision/supervisor-prd.md)。
|
||||
|
||||
@@ -36,8 +41,24 @@
|
||||
| [功能逻辑设计](./logic-design.md) | 分类规则、入口状态、优先级与失败结果 |
|
||||
| [UI 设计](./ui-design.md) | 侧栏信息架构、控件、文案、键盘和响应式行为 |
|
||||
| [技术设计](./technical-design.md) | 现有模块映射、状态契约、持久化和实施顺序 |
|
||||
| [实施进度](./progress.md) | 应用中心、设置及项目活动级联的实现与验证记录 |
|
||||
| [实施进度](./progress.md) | 应用中心、设置及统一工作空间菜单的实现与验证记录 |
|
||||
| [应用中心交互 Demo](./navigation-demo.html) | 对比六个候选方案;配套 CSS 和 JS 仅服务原型 |
|
||||
| [工作空间交互 Demo](./workspace-demo.html) | 左上角开始菜单式项目与并发会话候选方案;配套 `workspace-demo.css` 与 `workspace-demo.js` 仅服务原型 |
|
||||
|
||||
以下保留工作空间 Demo 的历史原型说明,包括最近 5 条的旧上限。生产实现已接入独立的 `WorkspaceMenu`,以功能设计和实施进度为准;Demo 的分类布局、排序、快捷键及模拟设置不作为生产契约。
|
||||
|
||||
工作空间 Demo 可直接打开,展示项目工作空间、会话窗口、全局活动、状态筛选、待办处理与完成待查看。
|
||||
左上角项目和活动摘要打开同一个锚定下拉菜单,没有独立总览页面或全屏遮罩。左栏筛选项目,右栏按状态分组显示紧凑会话列表,列表在菜单内滚动。
|
||||
菜单桌面宽度扩至 920px、高度至 640px,并受视口约束;左栏按本地项目、远程项目、消息通道分组。远程项目按主机归属展示,主机连接状态与会话活动计数分开;分组、主机和通道均使用演示数据。
|
||||
左侧通过本地、远程、消息通道 Tab 点击切换分类,项目悬停预览;演示包含 20 个本地项目、20 个远程项目和 3 个消息通道。搜索跨分类匹配项目名、主机及类型,远程主机分组支持折叠。“全部项目”、搜索和分类固定,项目列表独立滚动。
|
||||
项目行最小高度 52px,无活动时显示名称、工作目录两行;有活动时显示名称、活动状态、工作目录三行,高度随内容增长。当前项目以勾选区分于悬停预览。设置按钮独立,悬停或聚焦显示,触屏常显。设置弹窗可修改演示名称、说明、目录、默认模式及 Runtime;通道名称只读,执行空间不可修改。所有目录均为示例路径,不访问文件系统;原型未模拟真实目录选择、归档删除或远程恢复流程。
|
||||
点击项目入口默认筛选当前项目,点击活动摘要查看全部项目;悬停左侧项目只预览右侧会话。点击项目直接进入并恢复上次打开的会话,尚无会话则创建新会话;点击右侧会话直接进入指定会话。设置按钮独立,不触发项目切换,会话草稿保留。
|
||||
支持浅深主题、窄屏、点击外部关闭、Escape 关闭及 `Ctrl/Command+Shift+K` 切换菜单。
|
||||
菜单底部“新建项目”展开名称表单,创建后进入新项目的首个会话;“新建会话”与状态筛选同排,在所选项目创建会话,全部项目视图下使用当前项目。窄屏状态筛选横向滚动,创建按钮保持可见。均只修改演示数据,重置后清除。
|
||||
页面顶部可模拟后台完成,进入会话后清除该会话的完成提醒;已查看会话仍保留在最近会话中,不额外标记状态。
|
||||
状态沿用正式活动入口:待处理、运行中、已完成;待处理会话区分“等待你的回答”和“等待审批”。不提供“可继续”状态或筛选。“已完成”仍表示完成未查看提醒,不是全部历史完成会话。
|
||||
默认列表将待处理、运行中置顶,其余会话归入“最近会话”,按最近进入、发送或状态更新排序,最多显示 5 条;完成未查看在会话行标注。待处理和运行中不受此上限限制,也不重复列入最近会话。“已完成”筛选展示全部未查看结果,不受最近 5 条限制。没有活动时仍显示最近会话。
|
||||
所有执行、审批和消息均为内存中的模拟,不访问真实项目、不调用 AI;该候选方案尚未接入正式产品。
|
||||
|
||||
Demo 可直接在支持原生 dialog、popover 的现代浏览器中打开,无需依赖或构建。
|
||||
六个方案共用本次页面会话内的配置,刷新后全部恢复默认,不持久化或修改真实设置。
|
||||
@@ -54,7 +75,7 @@ Demo 的模型管理和 F 方案均使用模拟数据;正式本机推理监控
|
||||
列表,悬停或点击项目向右展开会话子菜单,点击会话才切换项目与对话。子菜单与项目列表
|
||||
相接,项目行保留子菜单箭头,会话行只显示名称与状态。支持上下方向键选择、右键进入、左键或 Esc 返回,顶层 Esc 关闭;窄屏在同一浮层内
|
||||
进入会话层,并提供返回按钮。页面上方可切换“有待处理、仅运行中、仅当前项目运行、全部
|
||||
空闲”四种场景。项目与会话均为演示数据;该级联交互已接入正式产品活动入口,范围与
|
||||
空闲”四种场景。项目与会话均为演示数据;该旧级联交互已由生产统一工作空间菜单替代,范围与
|
||||
验证记录见[实施进度](./progress.md)。
|
||||
Demo 已移除左侧系统工具快捷区:文件、终端、浏览器和资源监控只在右侧
|
||||
工作栏 Tab 中演示,不列入应用中心。旧原型仍直接打开整窗 Modal,其中使用可搜索应用列表。
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
2. 运行记录不进入工具注册表、应用显隐设置或插件市场。
|
||||
3. 文件浏览器、终端、浏览器和资源监控始终是系统工具,不能卸载或被插件替换。
|
||||
4. 左下角提供应用中心和独立固定设置入口;文件、终端、浏览器、资源监控只通过右侧工作栏 Tab 和“+”访问,不进入应用中心。
|
||||
5. 四个内置应用共享保存顺序。知识库始终启用并常驻,位置可调整;智能心跳、魔法笔记、本机推理监控可独立设置启用和常驻。取消常驻不关闭功能,关闭不删除数据或导航偏好。
|
||||
5. 五个内置应用共享保存顺序。知识库始终启用并常驻,位置可调整;智能心跳、魔法笔记、本机推理监控可独立设置启用和常驻。设备共享在测试阶段默认关闭,提供启用开关,启用后可从应用菜单打开,不常驻侧栏。关闭设备共享时退出其页面,不清除目录或撤销已发布记录。取消常驻不关闭功能,关闭不删除数据或导航偏好。
|
||||
6. 入口不能扩大执行空间、文件、PTY、网络或浏览器权限。
|
||||
7. 应用中心始终可达,点击向上展开无模态遮罩的锚定菜单,只列已启用应用,不使用打开历史或常驻过滤。菜单底部“管理应用”打开既有 Modal,关闭的可选应用在其中仍可搜索、配置和重新开启。
|
||||
8. 所有入口和新请求的功能判断读取同一份保存成功的权威设置;保存中的目标值不提前生效。
|
||||
@@ -25,6 +25,7 @@
|
||||
|
||||
```ts
|
||||
type NavigationContribution =
|
||||
| { type: 'application'; id: 'device-sharing'; source: 'builtin'; enabled: true; pinned: false }
|
||||
| {
|
||||
type: 'system-entry'
|
||||
id: 'chat' | 'activity' | 'application-center' | 'settings'
|
||||
@@ -201,7 +202,7 @@ User Stories 已同步上述入口规则,工具实例行为沿用右侧工作
|
||||
首期覆盖应用中心访问、启用与常驻组合、顺序恢复、默认值与旧值迁移、共同设置、保存失败、
|
||||
知识库固定入口、心跳应用启停、可选应用已打开页面恢复,以及右侧实例选择、本机 Home、托管 SSH、无会话、
|
||||
目录失效、资源部分可用与采样停止。工具内部取消、超时、连接恢复及业务后台生命周期由
|
||||
相应功能负责,本功能不复制状态机。底部入口打开轻量菜单,“管理应用”才进入与设置相同的整窗 Modal。实现与验证的剩余范围见[实施进度](./progress.md)。
|
||||
相应功能负责,本功能不复制状态机。底部入口打开轻量菜单,“管理应用”才进入与设置相同的整窗 Modal。FR-14 覆盖统一菜单的入口与范围、预览与进入、最近条数、状态筛选、通道创建限制及会话期恢复,规则见第 9 节。实现与验证的剩余范围见[实施进度](./progress.md)。
|
||||
|
||||
## 8. 后续产品决策
|
||||
|
||||
@@ -256,14 +257,18 @@ ASR 按请求加载与释放,支持独立取消;TTS 尚无执行服务。具
|
||||
- 通知只保存在 Renderer 内存,不跨重载或重启恢复,也不从普通未读状态推导。
|
||||
- 仅当目标会话实际成为当前聊天、设置未打开且文档未隐藏时清除通知;普通侧栏导航和活动菜单
|
||||
导航采用同一查看条件。已在查看的会话完成后不保留通知,恢复可见聊天时也清除通知。
|
||||
- 展开活动项目、查看菜单、设置覆盖下保留挂载的聊天、隐藏文档以及取消设置离开都不算查看。
|
||||
- 预览项目、查看菜单、设置覆盖下保留挂载的聊天、隐藏文档以及取消设置离开都不算查看。
|
||||
|
||||
没有实时状态且没有已完成未查看通知时才移除活动行。
|
||||
|
||||
级联菜单先按项目归属聚合活动行,沿用本地、SSH Host、通道的稳定项目顺序;缺少项目
|
||||
元数据的归属组及“未归属项目”组放在已知项目之后。无归属组只用于访问这些会话,不增加
|
||||
项目计数。每个项目内依次列待处理、运行中、已完成会话,同类别保留来源顺序。
|
||||
统一菜单的分类、预览范围、状态筛选和项目搜索相互独立。项目名与活动摘要属于同一个按钮,点击任一行打开时分类为“全部项目”,预览范围为当前项目;点击菜单内“全部项目”分类将范围重置为全部。悬停或聚焦项目只更新预览范围,不导航、不读取会话详情。分类按全部项目、本地、远程、消息通道排列;搜索跨分类匹配名称、目录、主机名/地址及通道,搜索期间展开命中的主机组。远程能力关闭时隐藏 SSH 用户项目及其活动,消息通道仍可见。
|
||||
|
||||
展开项目只改变菜单层级,不导航。选中项目的活动行全部移除后返回项目列表;全局无活动行后
|
||||
关闭菜单并隐藏摘要入口。后台更新保留仍存在的焦点项,删除焦点项时将焦点
|
||||
移至当前层可用项。关闭菜单后,新的活动只恢复摘要,不会自动重新打开菜单。
|
||||
左栏沿用本地、SSH Host、消息通道的项目顺序,主机支持折叠,不按活动数量排序。右栏合并内存会话摘要与活动行,按 Conversation ID 去重;摘要尚未到达的活动仍可显示。无项目归属的会话只在全部项目范围显示,不增加项目计数;已不存在或不可见项目的会话排除,通道项目只显示通道会话。
|
||||
|
||||
默认筛选依次列待处理、运行中、最近会话。前两组不限条数且不重复列入最近;组内排序时间取最新消息创建时间与本次应用会话内访问/状态变化时间的最大值,无消息时用会话更新时间参与比较,同时间按 ID 排序,不按置顶优先排序。最近组最多 10 条,已完成未查看会话归入最近组并标注状态;“已完成”筛选列出全部完成未查看会话,不受 10 条限制。
|
||||
|
||||
首次活动状态快照只建立基线,不更新最近时间;后续状态变化更新时间,唯独从已完成变为无活动的提醒清除不更新时间。实际打开会话仍记录访问时间;悬停预览不记录。访问与状态时间仅保留在当前 Store 内存中,不跨重载或重启恢复。
|
||||
|
||||
点击项目经离开检查进入聊天,优先恢复本次应用会话内为该项目记住的有效 Conversation ID,否则按最新消息时间从候选会话中选最近一条,无消息时取会话更新时间。普通项目无会话时创建;通道项目只选通道会话,无会话时显示空态。记忆不跨重载或重启保存。点击会话仍按确切 ID 导航;设置按钮独立,不触发项目切换。“新建会话”使用预览项目,全部项目范围使用当前项目;目标缺失或为消息通道时禁用。
|
||||
|
||||
全局无活动时第二行显示“暂无活动”,按钮仍可打开菜单并预览当前项目的最近会话或空态,也可在菜单内切换全部项目范围。活动归零不改变控件高度,也不关闭已打开的菜单。点击外部、焦点离开、Escape 或隐藏侧栏关闭菜单,后台活动不自动打开。查看预览不清除完成通知,实际进入可见聊天才按上述规则清除。
|
||||
|
||||
@@ -46,7 +46,7 @@
|
||||
- 用户从侧栏底部进入应用中心,查找、打开和配置内置应用。
|
||||
- 主导航承载应用入口,本机推理监控打开 Modal,其余内置应用打开完整页面;辅助工具由工作栏承载。
|
||||
- 运行记录始终可发现,不受应用显示设置和插件状态影响。
|
||||
- 四个内置应用均支持排序;智能心跳、魔法笔记和本机推理监控可独立设置启用与常驻,知识库始终显示,未常驻的已启用应用仍可从应用菜单打开。
|
||||
- 五个内置应用均支持排序;智能心跳、魔法笔记和本机推理监控可独立设置启用与常驻,知识库始终显示,设备共享在测试阶段默认关闭,可启用但不常驻侧栏。未常驻的已启用应用仍可从应用菜单打开。
|
||||
- 为插件应用和插件工具保留明确的入口契约,不提前建设插件安装能力。
|
||||
|
||||
## 4. 非目标
|
||||
@@ -75,7 +75,7 @@
|
||||
|
||||
### FR-3 内置应用区
|
||||
|
||||
主导航在当前对话之后按共享保存顺序显示应用,知识库始终显示,可选应用仅显示已启用且常驻项;运行记录位于应用区之后。四个应用均可排序。本机推理监控入口打开独立 Modal,其余应用打开对应一级页面,保留
|
||||
主导航在当前对话之后按共享保存顺序显示应用,知识库始终显示,可选应用仅显示已启用且常驻项;运行记录位于应用区之后。五个应用均可排序。本机推理监控入口打开独立 Modal,其余应用打开对应一级页面,保留
|
||||
现有徽标、范围和页面状态。管理 Modal 列出全部内置应用,知识库只提供“打开”和排序。
|
||||
|
||||
### FR-4 隐藏应用
|
||||
@@ -86,7 +86,7 @@
|
||||
|
||||
### FR-5 应用中心入口
|
||||
|
||||
侧栏底部提供应用中心和独立设置入口。点击应用中心向上展开锚定按钮的轻量菜单,无模态遮罩。菜单列出已启用应用,不按是否打开过、历史记录或常驻状态筛选;点击应用行关闭菜单,本机推理监控打开 Modal,其余应用在主内容区打开页面。
|
||||
侧栏底部提供“应用”(英文 `Apps`)和独立“设置”入口,两个按钮均显示文字。点击“应用”向上展开锚定按钮的轻量菜单,无模态遮罩。菜单列出已启用应用,不按是否打开过、历史记录或常驻状态筛选;点击应用行关闭菜单,本机推理监控打开 Modal,其余应用在主内容区打开页面。
|
||||
菜单底部“管理应用”关闭菜单并打开既有管理 Modal,在其中浏览、搜索全部内置应用,为已关闭的可选应用提供设置与重新开启入口。无搜索结果时显示空状态并可清除搜索。
|
||||
首期不展示商店、安装、推荐或系统工具清单。入口具有可访问名称,Escape 关闭后恢复触发位置的焦点。
|
||||
|
||||
@@ -127,13 +127,13 @@ Renderer 内存中最多保留最近 5 分钟。任一条件不满足时停止
|
||||
|
||||
### FR-11 应用导航设置
|
||||
|
||||
管理 Modal 的四个应用行均提供上下移动和拖动排序,不受启用或常驻状态限制;管理列表、菜单和侧栏共用一份顺序。箭头边界按完整顺序计算,搜索过滤不丢失隐藏项。智能心跳、魔法笔记和本机推理监控另提供应用设置、常驻管理;知识库提供“打开”和排序,不添加通用空设置页或开关。原工作区配置入口及心跳单条计划 enabled 保持不变。设置中“启用应用”与“常驻左侧菜单”分别保存,
|
||||
管理 Modal 的五个应用行均提供上下移动和拖动排序,不受启用或常驻状态限制;管理列表、菜单和侧栏共用一份顺序。箭头边界按完整顺序计算,搜索过滤不丢失隐藏项。智能心跳、魔法笔记和本机推理监控另提供应用设置、常驻管理;知识库提供“打开”和排序,不添加通用空设置页或开关。设备共享提供启用设置、打开和排序,不提供常驻。原工作区配置入口及心跳单条计划 enabled 保持不变。设置中“启用应用”与“常驻左侧菜单”分别保存,
|
||||
修改即时提交;请求期间串行处理并禁用会修改同一配置的控件,失败就地反馈。
|
||||
魔法笔记设置统一从应用中心对应行进入,笔记页与全局设置不再提供重复入口。持久化成功后的设置变更同步覆盖工具配置写入;每次重开中心先刷新并锁定修改,较新的确认快照优先于迟到的读取结果。
|
||||
|
||||
### FR-12 偏好持久化
|
||||
|
||||
四个应用的顺序及可选应用启用、常驻按设备持久化,重启后恢复。默认顺序为知识库、智能心跳、魔法笔记、本机推理监控。心跳应用默认关闭,其余应用默认启用;心跳和魔法笔记默认常驻,本机推理监控默认不常驻;
|
||||
五个应用的顺序及可选应用启用、常驻按设备持久化,重启后恢复。默认顺序为知识库、智能心跳、魔法笔记、本机推理监控、设备共享。心跳和测试阶段的设备共享默认关闭,其余应用默认启用;心跳和魔法笔记默认常驻,本机推理监控默认不常驻,设备共享不提供常驻开关;
|
||||
魔法笔记待办数量默认显示,评论方式与形式沿用现有默认值。迁移保留已保存的启用、常驻和排序选择,
|
||||
旧魔法笔记入口开关绑定业务启用值,迁为“启用应用”并保留原值;常驻使用独立配置,
|
||||
只有已发布的独立显隐偏好才映射到常驻,不能由常驻推导业务启停。旧可选应用顺序补入知识库、智能心跳默认位置并保留已有相对顺序,具体补齐规则见[技术设计](./technical-design.md#43-持久化与-ipc)。`heartbeatEnabled` 的缺失值与保存值按[应用启停与执行](../conversation-supervision/logic-design.md#应用启停与执行)处理;不读取未发布草案中的 `knowledgeEnabled`。
|
||||
@@ -146,10 +146,11 @@ Renderer 内存中最多保留最近 5 分钟。任一条件不满足时停止
|
||||
### FR-14 项目活动汇总
|
||||
|
||||
用户在任一项目中都能查看跨项目的待处理、运行中与已完成未查看会话,并从活动列表直接进入所选会话。
|
||||
汇总入口仅在有上述活动行时显示在项目选择器下方,无活动行时整行隐藏。点击后先显示有活动会话的项目,
|
||||
悬停或点击项目展开会话子菜单,点击会话进入该条对话。项目菜单保留原有分组与顺序,并显示
|
||||
各项目的活动数量;级联菜单内的项目顺序不随各类活动数量变化。
|
||||
汇总不受当前项目、搜索条件或最近历史条数限制。计数与排除规则见
|
||||
左上角使用一个固定两行的按钮:第一行显示项目名,第二行显示全局活动摘要,无活动时显示“暂无活动”,按钮高度不随活动变化。点击任一行打开同一个锚定工作空间菜单,默认预览当前项目,全部项目范围从菜单内切换;无活动时仍可查看最近会话。
|
||||
左栏依次提供“全部项目、本地、远程、消息通道”分类,远程能力关闭时隐藏远程分类及其项目;支持跨分类搜索、远程主机折叠及独立项目设置。项目行按名称、活动数量、目录分行显示,无活动时省略数量行,项目顺序不随活动数量变化。
|
||||
悬停或键盘聚焦项目只预览右栏,点击项目直接进入,优先恢复本次应用会话内上次打开的有效会话,否则选择最近会话;普通项目无会话时创建,消息通道无会话时保持空态。点击右栏会话进入确切会话。
|
||||
右栏默认依次列待处理、运行中、最近会话,最近会话最多 10 条;已完成未查看在行内标注。“全部、待处理、运行中、已完成”筛选与“新建会话”同排,新建使用预览项目,全部项目范围使用当前项目,消息通道禁用手动新建。左栏底部“新建项目”进入真实项目创建流程。
|
||||
活动计数、待处理、运行中及已完成筛选不受最近 10 条限制;菜单不继承侧栏会话搜索条件。计数与排除规则见
|
||||
[活动逻辑](./logic-design.md#9-项目活动汇总),交互见[活动界面](./ui-design.md#10-项目活动汇总)。
|
||||
|
||||
验收覆盖跨项目后台运行、同会话多任务去重、提问与审批优先、较早的仍在运行或等待处理的
|
||||
@@ -173,7 +174,7 @@ Renderer 内存中最多保留最近 5 分钟。任一条件不满足时停止
|
||||
- 主导航能明确区分固定系统入口和可配置内置应用。
|
||||
- 运行记录在所有应用显隐组合下保持可见并可打开。
|
||||
- 左下角不再用“本地工作区”与系统架构占据主要空间。
|
||||
- 应用菜单只列已启用应用,点击行关闭菜单;本机推理监控打开保留底层工作区的 Modal,其余应用进入主内容区。“管理应用”打开可搜索的既有 Modal。四个应用均可排序,知识库始终可用,三个可选应用可管理启用与常驻,首期无商店入口。
|
||||
- 应用菜单只列已启用应用,点击行关闭菜单;本机推理监控打开保留底层工作区的 Modal,其余应用进入主内容区。“管理应用”打开可搜索的既有 Modal。五个应用均可排序,知识库始终可用,四个可选应用可管理启用,其中设备共享不提供常驻,首期无商店入口。
|
||||
- 系统工具仅从右侧 Tab 与“+”访问;选择 Tab 不创建或改绑实例,“+”可显式创建额外浏览器或终端。
|
||||
- 资源监控以 2 秒间隔更新,保留不超过 150 个样本;面板不活动、工作栏收起或窗口隐藏、
|
||||
最小化后停止请求新样本。
|
||||
@@ -188,7 +189,7 @@ Renderer 内存中最多保留最近 5 分钟。任一条件不满足时停止
|
||||
|
||||
- 侧栏最底部应用中心打开向上锚定的轻量菜单,无模态遮罩;底部“管理应用”进入与设置相同的整窗 Modal 和可搜索应用列表,设置按钮保持独立。
|
||||
- 应用中心提供魔法笔记、知识库、智能心跳及本轮追加的本机推理监控入口,不要求安装。本机推理监控 Modal 与服务单独实现和验证。
|
||||
- 四个应用共享排序;支持智能心跳、魔法笔记、本机推理监控常驻主导航及取消常驻,知识库始终显示。已启用未常驻应用仍可从菜单打开,取消常驻不删除数据或停止后台任务。
|
||||
- 五个应用共享排序;支持智能心跳、魔法笔记、本机推理监控常驻主导航及取消常驻,知识库始终显示。设备共享仅从应用菜单和管理 Modal 进入,业务定义见[设备共享](../device-sharing/README.md)。已启用未常驻应用仍可从菜单打开,取消常驻不删除数据或停止后台任务。
|
||||
- 通用应用设置统一从应用中心对应行进入;应用页面保留实际业务配置,本机推理监控保留“模型与连接设置”。
|
||||
魔法笔记优先接入待办数量、AI 评论方式和评论形式,入口显示与常驻控制统一,避免重复开关。
|
||||
- 文件、终端、浏览器保留在右侧工作栏 Tab,不在左下角复制入口,也不纳入应用中心。
|
||||
@@ -274,4 +275,4 @@ Modal 只有服务列表,显示名称、简短状态、引擎、模型、资
|
||||
导航扩展须保留已有应用排序、常驻偏好和远程能力启用值;取消主机常驻不影响云环境使用
|
||||
主机能力。两个应用的能力依赖、迁移范围及产品验收由
|
||||
[主机与云环境 PRD](../remote-host/prd.md)和[功能逻辑](../remote-host/logic-design.md)定义,
|
||||
业务页面布局见[界面设计](../remote-host/ui-design.md)。该项尚未实现,不改变上文四应用的当前状态描述。
|
||||
业务页面布局见[界面设计](../remote-host/ui-design.md)。该项尚未实现,不改变上文五应用的当前状态描述。
|
||||
|
||||
@@ -1,5 +1,124 @@
|
||||
# 应用导航实施进度
|
||||
|
||||
## 2026-10-04:双行入口合并为单个按钮
|
||||
|
||||
对应 FR-14、US-E1 至 US-E3。项目名和活动摘要现在属于同一个 `60px` 按钮,共用键盘焦点、展开状态和菜单关联。点击任一行默认预览当前项目,全部项目范围在菜单内切换;活动描述和按钮外的恢复反馈通过 `aria-describedby` 关联。移除了独立摘要触发引用与入口范围状态,保留最近 10 条及现有活动计数,没有新增轮询。
|
||||
|
||||
本次验证:`ProjectSwitcher`、`WorkspaceMenu`、`WorkspacePrimitives` 共 80 项通过;App 活动、审批、问题队列和流式侧栏相关测试 37 项通过;`npm run typecheck` 与 `npm run lint` 通过。真实 Electron overlay harness 在 `1280×800`、`1280×480`、`560×640` 的浅深主题下通过,验证单按钮、空闲与活动状态的 `60px` 高度、第二行点击、菜单布局和 Escape 焦点恢复,模型调用 0 次。
|
||||
|
||||
## 2026-10-04:双行入口与菜单布局调整
|
||||
|
||||
本节保留此次单按钮更正前的实现与验证记录;现行入口规则见上节。
|
||||
|
||||
对应 FR-14、US-E1 至 US-E3。已核对 `ProjectSwitcher.tsx`、`WorkspaceMenu.tsx` 和 `workspace-menu.css`:左上角控件固定高 `60px`,项目名和全局活动为同级按钮,第二行无活动时显示“暂无活动”;恢复反馈位于控件下方并关联可访问描述。左栏搜索框使用 `border-box` 限制边界,两栏之间使用 `--border-control` 分隔线,全部项目及搜索结果补充分组标题,右栏状态工具栏收紧间距。菜单项目行继续按名称、活动、目录分行显示,高度随内容增长。具体规则见[活动界面](./ui-design.md#10-项目活动汇总)。
|
||||
|
||||
以下结果由实现方提供,本次文档更新未重跑产品测试:
|
||||
|
||||
- 79 项定向组件测试、68 项项目相关 App 测试通过。
|
||||
- 完整类型检查与修改范围 lint 通过。
|
||||
- 真实 Electron 在 `1280×800`、`1280×480`、`560×640` 的浅深主题下验证搜索框边界、分隔线、紧凑工具栏、项目三行信息,以及左上角控件在空闲和活动状态下的固定高度与焦点。
|
||||
- 模型调用 0 次。
|
||||
|
||||
最近会话上限已由 5 条调整为 10 条,生产设计与验收条目已同步。最终运行 `npx vitest run --project unit src/renderer/src/workspace-menu-selectors.test.ts src/renderer/src/WorkspaceMenu.test.tsx src/renderer/src/ProjectSwitcher.test.tsx`,3 个文件共 44 项通过,覆盖最近 10 条、活动不占最近名额、已完成筛选超过 10 条,以及完成后保留历史。Demo 文件未修改,仍保留原型的最近 5 条行为。
|
||||
|
||||
## 2026-10-04:生产统一工作空间菜单
|
||||
|
||||
本节保留首次接入时的历史实现与验证范围,其中最近 5 条为当时上限,现行调整见上节。
|
||||
|
||||
对应 FR-14、US-E1 至 US-E3。已核对生产 `WorkspaceMenu.tsx`、`workspace-menu-selectors.ts`、`ProjectSwitcher.tsx` 与 `App.tsx` 差异:项目入口与活动摘要共用菜单,替代独立 `ProjectActivity` 级联。已接入全部项目优先的分类 PageTabs、悬停预览与点击进入、最近 5 条、同排状态筛选与新建会话、真实项目设置和创建、项目三行信息,以及会话期内上次打开记录。预览只用内存数据,项目和会话列表复用共享窗口化;规则见[活动逻辑](./logic-design.md#9-项目活动汇总)与[技术设计](./technical-design.md#12-项目活动汇总)。
|
||||
|
||||
最终源码补充:菜单最近时间取最新消息时间与 Store 会话期访问/状态时间的最大值;`recordActivityStatuses` 首次只建立基线,清除完成提醒不更新时间。项目上次打开 ID 仍只保留在内存,获准后的项目进入及新建回调使用 `commitView`,避免重复离开检查。
|
||||
|
||||
以下为实现代理提供的验证结果,本次仅更新文档,未重跑产品测试:
|
||||
|
||||
- 82 项定向测试通过。
|
||||
- 31 项选定 App 测试通过。
|
||||
- 1 项真实 Electron overlay 测试通过,最终场景也在全量测试运行中通过。
|
||||
- 最终完整 `npm run typecheck`、`npm run lint` 通过。
|
||||
- 全量 `npm test` 在 600 秒超时前未完成。已结束的 App 文件共 347 项,346 项通过、1 项监督者设置测试失败;`tests/supervisor-layout.electron.test.ts` 也失败。尚无证据将这些失败归为既有问题,全量测试未通过。
|
||||
- 首次 200 秒尝试报告 `activity-history-repository` worker 失败;第二次截至超时未报告该处失败。同期有并行性能修改,未确定失败原因,也不据此认定已修复。
|
||||
|
||||
本次没有性能基准结论。README 保留未提交的工作空间 Demo 描述,并区分生产菜单与旧演示;下方级联菜单记录保留当时实现和验证范围,不再定义当前生产交互。
|
||||
|
||||
## 2026-10-01:设备共享入口
|
||||
|
||||
新增 `device-sharing`,启用后应用菜单及管理 Modal 均进入主内容区页面;管理行可排序。
|
||||
首期初始实现没有启用开关,同日后续改为测试阶段默认关闭、可手动启用,不提供常驻开关。
|
||||
旧顺序通过现有存储归一化追加新项,保留原设置;权威写入要求五项完整顺序。
|
||||
ApplicationCenter、ApplicationMenu、设置迁移以及 App 入口/排序回归已通过,真实 Electron
|
||||
从应用中心进入页面并完成后端流程。精确命令、完整范围及全仓测试记录见
|
||||
[设备共享实施进度](../device-sharing/progress.md)。下方历史四应用记录保留原验证时的范围。
|
||||
|
||||
## 2026-09-28: Composer shortcut initialization follow-up
|
||||
|
||||
The composer now retains successful shortcut-settings snapshots independently of
|
||||
the startup app-info request. A failed or pending app-info read no longer drops a
|
||||
successful shortcut save, and a late startup response cannot restore a disabled
|
||||
hint. Registration remains authoritative; no default shortcut is advertised when
|
||||
registration is unavailable. This preserves the existing [composer rule](../../../UI-DESIGN.md#131-聊天).
|
||||
|
||||
The failed/pending read regressions reproduced the missing hint before the fix.
|
||||
The reported screenshot's actual registration status and IPC result were not
|
||||
captured, so its specific runtime cause remains unconfirmed.
|
||||
|
||||
Validation: App, sidebar layout, main IPC, and shortcut-service suites passed
|
||||
(500 tests, one skipped); `npm run typecheck`, ESLint on the four changed source
|
||||
and test files, and `git diff --check` passed. The first broader run hit the
|
||||
60-second command limit; the rerun completed in 95 seconds. No full-repository
|
||||
suite, native Electron screenshot reproduction, packaging, or OS registration
|
||||
probe was run for this follow-up.
|
||||
|
||||
## 2026-09-28:新建对话与搜索共用一行
|
||||
|
||||
已按[会话搜索](./ui-design.md#会话搜索)接入收起与展开状态,移除列表上方的常驻搜索头部。
|
||||
沿用会话创建、延迟搜索和项目过滤接口。后续按用户反馈恢复默认按钮的聊天加号图标、原有
|
||||
字号字重和整体左对齐;搜索展开后保留居中的同一图标。快捷键说明移至聊天输入框原生占位文案,
|
||||
具体规则见[聊天 UI 规范](../../../UI-DESIGN.md#131-聊天)。
|
||||
|
||||
本次调整验证:`App.test.tsx` 与 `AppSidebarLayout.test.ts` 合计 316 项通过;最终长标签换行
|
||||
调整后,布局 5 项与 `magic-notes-capture.electron.test.ts` 1 项复验通过。Windows Electron
|
||||
在上述 6 种侧栏组合检查原字号字重、左对齐、图标居中、文字无溢出及搜索交互,并验证
|
||||
原生输入后占位提示隐藏、清空后恢复。App 回归覆盖中英文、生图提示、macOS 快捷键格式、
|
||||
全局快捷键缺省以及设置保存后的更新和停用。类型检查、修改文件 ESLint 与差异空白检查通过。
|
||||
未运行全仓测试、打包或 macOS/Linux 原生窗口验证。模型调用 0 次。
|
||||
|
||||
此前搜索展开改动的验证记录:
|
||||
|
||||
```text
|
||||
npm test -- src/renderer/src/App.test.tsx src/renderer/src/AppSidebarLayout.test.ts tests/magic-notes-capture.electron.test.ts
|
||||
npm run typecheck
|
||||
```
|
||||
|
||||
三文件合计 311 项通过。最终动画边界与文字宽度调整后,布局及 Electron 共 5 项、App 搜索
|
||||
交互 1 项复跑通过,typecheck 通过。Electron 复用现有 App、生产 Preload、IPC 和临时 SQLite
|
||||
测试环境,以英文界面检查浅深主题下 220px、420px 侧栏和短窗口抽屉,共 6 种组合;检查
|
||||
展开过程及结束后的右边缘、列表位置、完整按钮文字、原生鼠标与 Escape、自动聚焦、关闭
|
||||
焦点恢复、点击列表保留关键词及减少动态效果。中文 App 回归同时覆盖标题、正文、项目
|
||||
过滤和加号创建。模型调用 0 次。
|
||||
|
||||
修改文件的 ESLint 与 `git diff --check` 通过。全仓 ESLint 有 10 个错误,均来自本任务未修改
|
||||
的未跟踪 `sidebar-final-probe.ts` 和 `sidebar-final-run.mjs`。未跑全仓测试、打包程序或
|
||||
macOS/Linux 窗口验证。中文新增规则经 deai-writing 扫描无阻断项,并已人工核对。
|
||||
|
||||
## 2026-10-02:补齐五秒排序检查
|
||||
|
||||
会话列表已接入 `useConversationListOrder`,保留实时行数据,定时调整展示顺序。
|
||||
交互暂停与即时操作规则见[会话置顶与操作菜单](./ui-design.md#会话置顶与操作菜单)。
|
||||
本地和消息通道共用此 Renderer 路径,未修改 Agent 执行、同步或持久化。
|
||||
|
||||
验证命令:
|
||||
|
||||
```text
|
||||
npx vitest run src/renderer/src/use-conversation-list-order.test.tsx src/renderer/src/chat-conversation.test.ts src/renderer/src/App.test.tsx -t "useConversationListOrder|conversation display order|parallel streaming conversations|summary and detail order|sorts pinned conversations|merges persisted pin metadata|dedicated pin update"
|
||||
```
|
||||
|
||||
3 个文件、23 项通过,覆盖五秒边界、连续增量、交互暂停与恢复、立即增删与置顶、
|
||||
项目/搜索范围切换、计时器清理,以及 App 中本地/通道摘要更新的实际列表接线。
|
||||
新增 hook 与相关测试的定向 ESLint、`git diff --check` 通过。未运行 Electron 窗口验证。
|
||||
全仓检查未通过:typecheck 报并行修改中的 `App.tsx:5436` 缺少 `startTransition`;
|
||||
ESLint 报打包文件及现有会话视图缓存的 refs 错误;`npm test` 在 120 秒后超时。
|
||||
额外运行的草稿置顶失败测试未通过,实际显示持久化错误而非预期的置顶错误。
|
||||
|
||||
## 2026-09-28:并行会话输出时保持列表顺序
|
||||
|
||||
修复多个会话交替输出时,左侧列表随每个增量反复换位的问题。展示顺序统一使用消息创建
|
||||
@@ -349,7 +468,7 @@ Windows Electron 43.2.0 / Chromium 150 的当前组件 fixture 验证浅深主
|
||||
|
||||
## 2026-09-13:生产活动入口
|
||||
|
||||
对应 [FR-14](./prd.md#fr-14-项目活动汇总)、[US-E1 / US-E2](./user-stories.md#6-epic-e项目活动级联)。
|
||||
对应 [FR-14](./prd.md#fr-14-项目活动汇总)、[US-E1 / US-E2](./user-stories.md#6-epic-e统一工作空间菜单)。
|
||||
本页只记录项目活动入口,不表示应用中心计划已交付。
|
||||
|
||||
`App` 已接入 `ProjectActivity` 常驻摘要和锚定级联菜单。会话和项目计数继续使用现有
|
||||
|
||||
@@ -22,6 +22,7 @@ type WorkspaceView =
|
||||
| 'knowledge'
|
||||
| 'heartbeat'
|
||||
| 'local-inference'
|
||||
| 'device-sharing'
|
||||
| 'activity'
|
||||
| 'settings'
|
||||
```
|
||||
@@ -31,7 +32,7 @@ type WorkspaceView =
|
||||
实现以[浏览器多实例技术设计](../assistant-workbar/browser-tabs-technical-design.md)为准。代码中尚无资源指标采集和资源监控
|
||||
面板。侧栏底部已替换为应用中心与独立设置按钮。
|
||||
|
||||
应用中心、四应用共享排序、可选应用常驻及启用联动、共同设置页和本机推理监控已接入生产源码;本机资源监控的
|
||||
应用中心、五应用共享排序、可选应用常驻及启用联动、共同设置页和本机推理监控已接入生产源码;本机资源监控的
|
||||
Main/Preload/Renderer 实现仍待交付。系统工具只使用右侧工作栏已有 Tab 和“+”链路。
|
||||
行为由[功能逻辑](./logic-design.md)定义,验证范围见[实施进度](./progress.md)。
|
||||
|
||||
@@ -61,7 +62,7 @@ Main/Preload/Renderer 实现仍待交付。系统工具只使用右侧工作栏
|
||||
### 3.1 内置应用清单
|
||||
|
||||
```ts
|
||||
type BuiltInApplicationId = 'magic-notes' | 'knowledge' | 'heartbeat' | 'local-inference'
|
||||
type BuiltInApplicationId = 'magic-notes' | 'knowledge' | 'heartbeat' | 'local-inference' | 'device-sharing'
|
||||
|
||||
type BuiltInApplicationDefinition = {
|
||||
id: BuiltInApplicationId
|
||||
@@ -81,7 +82,7 @@ type BuiltInApplicationDefinition = {
|
||||
|
||||
应用中心调用既有 `navigateFromSidebar` 或同一页面导航函数,沿用未保存离开检查和焦点
|
||||
策略。打开前检查确认的启用值;未常驻但已启用的应用不附带常驻写入。本机推理监控从常驻入口、菜单及管理 Modal 均经 `setView('local-inference')` 打开独立 Modal,保留底层工作区与设置草稿;其他应用继续页面导航。
|
||||
`ApplicationMenu` 按四个应用共用的持久化顺序派生清单,仅按启用过滤,不读取常驻或打开历史。应用行先关闭菜单再导航;底部“管理应用”关闭菜单、恢复锚点焦点并打开既有 `ApplicationCenter` Modal。
|
||||
`ApplicationMenu` 按五个应用共用的持久化顺序派生清单,仅按启用过滤,不读取常驻或打开历史。应用行先关闭菜单再导航;底部“管理应用”关闭菜单、恢复锚点焦点并打开既有 `ApplicationCenter` Modal。设备共享使用默认 `false` 的 `deviceSharingEnabled`,启用后进入主内容区页面,无常驻字段;迁移与关闭规则见[设备共享技术设计](../device-sharing/technical-design.md#当前首期实现)。
|
||||
应用中心对应行的设置操作仅接受 `EditableApplicationId`;应用页和全局设置不提供重复入口。知识库的管理行提供“打开”和排序,原工作区实际配置保留,不添加通用空设置页。
|
||||
共同页复用设置 Modal 容器、配置读取及更新方法,不建立独立配置存储或重复表单。
|
||||
|
||||
@@ -124,9 +125,9 @@ type ApplicationNavigationSettings = {
|
||||
}
|
||||
```
|
||||
|
||||
`applicationNavigation` 保存四个应用的完整顺序和三个可选应用的常驻布尔值。默认顺序为
|
||||
`knowledge`、`heartbeat`、`magic-notes`、`local-inference`;心跳和魔法笔记常驻默认 `true`,本机推理监控默认 `false`。
|
||||
权威返回 schema 和更新 schema 均要求四个 ID 各出现一次,拒绝重复、未知和缺失 ID;`pinned` 包含三个可选应用,缺失 `heartbeat` 时补 `true`,显式布尔值保留。Renderer 直接读取该顺序,再按各入口的可见性过滤。知识库始终启用、常驻,无可写开关键。
|
||||
`applicationNavigation` 保存五个应用的完整顺序和三个可选应用的常驻布尔值。默认顺序为
|
||||
`knowledge`、`heartbeat`、`magic-notes`、`local-inference`、`device-sharing`;心跳和魔法笔记常驻默认 `true`,本机推理监控默认 `false`,设备共享无常驻开关。
|
||||
权威返回 schema 和更新 schema 均要求五个 ID 各出现一次,拒绝重复、未知和缺失 ID;`pinned` 包含三个可选应用,缺失 `heartbeat` 时补 `true`,显式布尔值保留。Renderer 直接读取该顺序,再按各入口的可见性过滤。知识库始终启用、常驻,无可写开关键。
|
||||
按[排序规则](./logic-design.md#3-主导航组合规则)提交完整顺序;不持久化可推导的可见 ID 列表。
|
||||
首期不预建插件字段,也不为未发布的旧隐藏 ID 草案增加迁移读取器。
|
||||
|
||||
@@ -166,7 +167,7 @@ Main 继续校验可信 sender、Zod 输入,复用 `ApplicationSettingsStore`
|
||||
`true`、`false`、单选值和无关设置,不迁移或清理笔记、知识及会话业务数据。
|
||||
新安装和升级时缺失整个 `applicationNavigation` 均使用共享默认值,本机推理监控启用但不常驻;本次不增加存储版本。已保存的常驻值均保留,无法区分旧默认 `true` 与用户主动固定,不强制改为 `false`。
|
||||
|
||||
仅存储读取允许补齐旧数据:缺失的 `knowledge`、`heartbeat` 按此顺序前置,再保留已有 ID 的相对顺序,最后按默认顺序追加缺失的可选应用。旧 `[local-inference, magic-notes]` 因此变为 `[knowledge, heartbeat, local-inference, magic-notes]`;完整四项顺序原样保留。缺失或空顺序采用默认顺序,部分 `pinned` 映射仅补缺失键,显式 `false` 保留。重复或未知 ID 仍按现有损坏文件处理规则处理,不静默去重或丢弃。版本 11 迁移时写入归一化结果;版本 12 读取时归一化,下一次设置保存时写入完整结果。IPC 和配置工具写入不接受这些部分数据。
|
||||
仅存储读取允许补齐旧数据:缺失的 `knowledge`、`heartbeat` 按此顺序前置,再保留已有 ID 的相对顺序,最后按默认顺序追加缺失项。旧 `[local-inference, magic-notes]` 因此变为 `[knowledge, heartbeat, local-inference, magic-notes, device-sharing]`;旧完整四项顺序保留并在末尾追加 `device-sharing`。缺失或空顺序采用默认顺序,部分 `pinned` 映射仅补缺失键,显式 `false` 保留。重复或未知 ID 仍按现有损坏文件处理规则处理,不静默去重或丢弃。版本 11 迁移时写入归一化结果;版本 12 读取时归一化,下一次设置保存时写入完整结果。IPC 和配置工具写入不接受这些部分数据。
|
||||
|
||||
版本 12 属于未发布的分支格式,原草案 `knowledgeEnabled` 已移除,不增加兼容读取器。`heartbeatEnabled` 是当前应用启用字段,由 Store 归一化缺失值并保留显式保存值。
|
||||
|
||||
@@ -175,17 +176,21 @@ Main 继续校验可信 sender、Zod 输入,复用 `ApplicationSettingsStore`
|
||||
原 `user-card` 整行设置按钮已替换为容器和相邻独立按钮:
|
||||
|
||||
```tsx
|
||||
<div className="sidebar-footer">
|
||||
<button onClick={toggleApplicationMenu} aria-haspopup="menu" aria-expanded={applicationMenuOpen} ...>
|
||||
{t('navigation.applicationCenter')}
|
||||
<div className="sidebar-footer sidebar-footer--applications">
|
||||
<button className="nav-item" onClick={toggleApplicationMenu} aria-haspopup="menu" aria-expanded={applicationMenuOpen} ...>
|
||||
<Grid2X2 aria-hidden="true" />
|
||||
<span>{t('applications.menuLabel')}</span>
|
||||
<ChevronUp aria-hidden="true" />
|
||||
</button>
|
||||
<button aria-label={t('navigation.settings')} ...>
|
||||
<div className="sidebar-footer__divider" aria-hidden="true" />
|
||||
<button className="nav-item" aria-label={t('navigation.settings')} ...>
|
||||
<Settings aria-hidden="true" />
|
||||
<span>{t('navigation.settings')}</span>
|
||||
</button>
|
||||
</div>
|
||||
```
|
||||
|
||||
应用中心和设置为相邻独立按钮。设置按钮继续调用 `navigateFromSidebar('settings', trigger)`,
|
||||
“应用”(`applications.menuLabel`:中文“应用”、英文 `Apps`)和设置为横向等高独立按钮,使用 `nav-item` 样式和弱竖分隔线;“管理应用”菜单项使用 `LayoutGrid` 图标,管理 Modal 标题保持“应用中心” / `Application Center`。设置按钮继续调用 `navigateFromSidebar('settings', trigger)`,
|
||||
保留设置预加载、焦点恢复和未保存离开确认。该调用只打开设置 Modal,不更新当前工作区或
|
||||
KeepAlive 缓存;`settingsOpen` 独立管理打开状态。关闭后卸载设置内容,下次按入口指定的分类
|
||||
和消息通道重新挂载,避免缓存上次分类覆盖本次直达目标。底层工作区继续挂载,普通关闭不跳回聊天。
|
||||
@@ -348,7 +353,7 @@ resourceMonitor.getLocalSnapshot(input: {
|
||||
|
||||
### 10.2 Main 与契约测试
|
||||
|
||||
- 顺序包含四个应用 ID 各一次;公开写入拒绝重复、缺失、未知 ID 及无效常驻字段;旧存储顺序按迁移规则补齐,不为未发布的 `knowledgeEnabled` 添加迁移。
|
||||
- 顺序包含五个应用 ID 各一次;公开写入拒绝重复、缺失、未知 ID 及无效常驻字段;旧存储顺序按迁移规则补齐,不为未发布的 `knowledgeEnabled` 添加迁移。
|
||||
- 未配置的心跳应用默认关闭,其余应用启用及笔记布尔项默认开启,本机推理监控默认不常驻;已保存的启用、常驻值和评论单选值经迁移及重启保留。
|
||||
- 更新常驻或顺序不修改启用、计划或业务数据;笔记既有配置键保持语义。
|
||||
- 串行 patch 在 Main 最新快照上合并,原子写入失败不发布目标状态;响应丢失后重读实际结果。
|
||||
@@ -463,7 +468,7 @@ OCR 模型可用性由 Main 校验,Worker 运行状态由实际持有它的 Re
|
||||
|
||||
- `conversation-activity.ts` 从 `App.tsx` 的会话、`activeConversationIds`、全部可见
|
||||
`assistantTasks`、`completedConversationIds` 和项目元数据派生会话行、全局及项目计数,不新增持久化活动副本。
|
||||
`ProjectActivity` 在有活动时渲染汇总与项目、会话级联菜单,`ProjectSwitcher` 复用项目计数。
|
||||
`ProjectSwitcher` 使用单个固定双行按钮显示项目名与全局活动,活动为空时第二行显示空闲文案,统一打开 `WorkspaceMenu` 并默认预览当前项目;生产 `App` 不再挂载独立 `ProjectActivity` 级联菜单。按钮通过 `aria-describedby` 关联活动描述及控件外的恢复反馈,尺寸与样式见活动界面。
|
||||
- `use-unviewed-completions.ts` 在 Renderer 内存维护完成集合和上一轮 Task 状态;已观察到的
|
||||
顶层 Task 转为 `completed` 时加入集合,初始历史不生成通知。`App` 的本地 `done` 事件和
|
||||
活动运行持久化快照的成功终态调用 `markConversationCompleted`,失败、取消不调用。
|
||||
@@ -472,16 +477,15 @@ OCR 模型可用性由 Main 校验,Worker 运行状态由实际持有它的 Re
|
||||
`visibilitychange`,文档不为 `hidden` 时才按当前会话清除通知。这使普通侧栏、活动菜单
|
||||
导航和可见性恢复共用查看规则,KeepAlive 挂载本身不代表查看。集合不写入 SQLite 或设置。
|
||||
- 聚合状态新增 `completed`,全局和项目计数包含 `attention`、`running`、`completed`,
|
||||
同一 Conversation ID 只按最高优先级计数;活动行为空才隐藏摘要。
|
||||
- `App` 向 `ProjectActivity` 传入项目元数据、活动行、侧栏可见状态与既有精确跳转回调。
|
||||
菜单按项目 ID 分组并记忆化派生集合;排序仅影响展示,不改聚合状态或持久化数据。
|
||||
- 两层菜单在同一 body Portal 内相接,桌面会话层使用独立 fixed 定位,不参与项目层高度计算。
|
||||
项目层锚定摘要,会话层锚定选中项目行并独立限制窗口边界。布局在打开、层级变化、实时数据更新、窗口缩放和
|
||||
滚动时按锚点重算;窄窗口隐藏项目面板并保留返回操作。菜单不调用 Modal 焦点隔离,
|
||||
捕获阶段消费 Escape,防止触发窄侧栏的文档级监听;Tab 关闭后交还正常焦点顺序。
|
||||
菜单关闭与焦点恢复在导航回调之前同步完成。
|
||||
- `role="menu"` 使浮层直接进入共享 `browser-viewport-occlusion.ts` 相交检测。未增加
|
||||
浏览器实例的特殊隐藏条件,也不关闭或重建浏览器会话。
|
||||
同一 Conversation ID 只按最高优先级计数;活动行为空时以“暂无活动”替换计数,保留全局入口。
|
||||
- `App` 向 `ProjectSwitcher` 传入 `conversationStore`、活动行、侧栏可见状态和确切会话跳转、新建回调。`WorkspaceMenu` 持有分类、预览范围、搜索及状态筛选;分类复用 `PageTabs`,状态筛选复用 `SegmentedControl`,项目行与真实设置/创建表单继续由 `ProjectSwitcher` 提供。
|
||||
- `workspace-menu-selectors.ts` 订阅会话 Store,投影 ID、项目、标题、排序时间及通道标记,以字段相等比较保留未变化结果;排序时间为 `Math.max(conversationActivityTime(conversation), activityTimes.get(conversation.id) ?? 0)`。与活动行按 ID 合并后派生分组和最近 10 条;仅有活动、尚无摘要的行使用 Store 时间,缺失时取 0。悬停只改变 Renderer 预览范围,不发 IPC、不加载详情或请求模型。打开菜单所需的既有主机快照读取与悬停预览分开。
|
||||
- Store 的 `activityTimes` 保存本次应用会话内的访问/状态变化时间,更新时间为 `Math.max(Date.now(), lastActivityTime + 1)`。`ProjectSwitcher` 调用 `recordActivityStatuses`:首次快照只建立基线,后续状态变化触发更新,但 `completed` 变为无状态的完成提醒清除不触发更新。`WorkspaceMenu` 经 `useSyncExternalStore` 订阅该时间映射;时间不持久化,悬停不更新,普通侧栏原有消息排序规则不变。
|
||||
- 项目、会话列表均使用共享 `useListWindow`,分别估算 `64px`、`56px` 行高并测量实际高度,保留键盘目标行;范围改变时重置滚动。窗口化仅限制挂载行数,不截断活动集合,不代表已有性能基准结果。
|
||||
- 菜单通过 `FloatingPortal` 显示同一双栏表面,定位和焦点恢复共用项目按钮的引用;尺寸、窗口和滚动变化时重算位置。非模态 `dialog` 不调用焦点隔离,Escape 关闭整个菜单,Tab 可访问内部控件,焦点离开后关闭。窄窗口仍为双栏,侧栏隐藏时卸载菜单。
|
||||
- 浮层沿用共享 `browser-viewport-occlusion.ts` 相交检测,不增加浏览器实例的特殊隐藏条件,也不关闭或重建浏览器会话。
|
||||
- `conversationStore.rememberOpened` 在 `App.setActiveId` 时为存在的会话记录访问时间,并记住其项目最后打开的 ID。`projectConversation` 优先返回仍在候选集合中的记忆 ID,否则按 `conversationActivityTime` 选择最近会话,不使用菜单的访问/状态时间;通道项目只选带 `remote` 的会话。记忆仅存在当前 Store 的 Map 中,不持久化、不跨重载恢复。普通项目无会话时创建,通道无会话时清空活动 ID。
|
||||
- 项目进入、新建会话及创建项目后的导航均经过 `requestWorkspaceLeave`。获准后的 `commitProjectSelection` 与新建会话 `ready` 回调直接调用 `commitView('chat')`,避免经 `setView` 再次触发离开检查;新建会话就绪后才提交项目、会话及输入框焦点。
|
||||
- 持久化快照刷新时,同一条仍在流式输出的消息保留本地待审批、待回答信息,持久化终态
|
||||
则清除这些信息。响应成功后对应 Task 立即退出等待状态;响应期间到达的新问题、审批或
|
||||
终态不能被旧响应覆盖。
|
||||
@@ -498,5 +502,5 @@ OCR 模型可用性由 Main 校验,Worker 运行状态由实际持有它的 Re
|
||||
- 远程恢复成功提示沿用独立的自动消失计时器;活动汇总更新不重置或替代该计时器。
|
||||
|
||||
完成通知的定向验证应覆盖本地成功、持久化活动运行成功、后台 Task 状态转换、初始历史、
|
||||
失败和取消、实时状态优先、三类计数、普通侧栏查看、设置覆盖、隐藏文档与恢复可见、无活动行隐藏。
|
||||
验证记录见[项目活动级联实施进度](./progress.md);源码核对不能替代真实 Electron 路径验证。
|
||||
失败和取消、实时状态优先、三类计数、普通侧栏查看、设置覆盖、隐藏文档与恢复可见、无活动时的空闲占位。
|
||||
统一菜单还应覆盖固定双行单按钮及焦点、当前项目默认范围和菜单内全部项目切换、分类与跨分类搜索、搜索框边界与两栏分隔、紧凑工具栏、预览无 IPC、最近 10 条与不限量活动筛选、真实设置/创建入口、会话期恢复及窗口化键盘导航。验证记录见[实施进度](./progress.md);源码核对不能替代真实 Electron 路径验证。
|
||||
|
||||
@@ -19,10 +19,9 @@
|
||||
|
||||
```text
|
||||
品牌
|
||||
项目选择
|
||||
项目活动汇总(仅有活动时显示)
|
||||
新对话
|
||||
搜索
|
||||
项目名称(固定双行控件的第一行)
|
||||
全局活动汇总/暂无活动(第二行)
|
||||
[新对话] [搜索图标]
|
||||
|
||||
当前对话
|
||||
应用(按保存顺序;知识库始终显示,可选应用仅显示启用且常驻项)
|
||||
@@ -32,24 +31,37 @@
|
||||
...
|
||||
|
||||
────────────────────────
|
||||
[应用中心] [设置]
|
||||
[应用 v] | [设置]
|
||||
```
|
||||
|
||||
“当前对话”和“运行记录”属于固定系统入口,常驻应用位于二者之间。首期应用清单为魔法笔记、
|
||||
知识库、智能心跳及本轮追加的本机推理监控,应用中心与设置始终可见。侧栏底部不再显示“本地工作区”和 `platform · arch`;
|
||||
排障信息移至关于或诊断页面。左侧不再提供文件、终端、浏览器、资源监控的重复入口。
|
||||
|
||||
### 会话搜索
|
||||
|
||||
新建对话与搜索共用原新建对话位置的一行。默认显示 `MessageSquarePlus` 图标与完整“新建对话”文字,整体左对齐,沿用原有 13px、600 字重和 12px 左内边距,右侧为搜索图标;
|
||||
点击搜索后,输入框从右向左展开,新建对话只显示居中的同一图标,保留“新建对话”可访问名称与悬停提示。
|
||||
最窄侧栏中的长标签允许在固定行高内换行,保留字号和图标。搜索右边缘和整行高度保持不变,列表不因展开或关闭而上下移动。宽度动画使用
|
||||
`--motion-normal`,减少动态效果时立即切换;沿用桌面侧栏调宽和窄窗口抽屉尺寸。
|
||||
通道项目继续不提供新对话按钮,搜索展开后使用整行宽度。
|
||||
控件行与新建对话按钮高 `44px`;搜索框本体高 `36px`,上下各留 `4px`,垂直居中。保留 `2px` 焦点外框与 `2px` 外间距,聚焦后外沿与新建对话按钮上下对齐。
|
||||
|
||||
展开时聚焦输入框。X 始终可用,X 或控件行内的 Escape 清空关键词并收起输入框,
|
||||
焦点返回搜索图标。失焦、点击会话列表保持搜索状态和关键词;新建对话图标继续调用原新对话操作。
|
||||
无结果区域的“清除搜索”只清空关键词,保留展开状态。项目活动跳转仍沿用既有清空搜索规则。
|
||||
|
||||
## 2. 底部应用中心入口
|
||||
|
||||
底部使用连续平整表面和顶部分隔线,应用中心在前,固定设置靠右,二者为独立按钮:
|
||||
底部使用连续平整表面和顶部分隔线,“应用”在前,“设置”靠右,二者为横向排列的等宽、等高 `nav-item` 独立按钮,内容各自居中,竖分隔线位于底部入口区域的正中间:
|
||||
|
||||
| 位置 | 图标 | 可见工具提示 | 点击结果 |
|
||||
| 位置 | 图标 | 可见文字 | 点击结果 |
|
||||
| --- | --- | --- | --- |
|
||||
| 1 | `Grid2X2` | 应用中心 | 向上展开已启用应用菜单,底部“管理应用”进入管理 Modal |
|
||||
| 末位 | `Settings` | 设置 | 在当前工作区上打开设置 Modal |
|
||||
| 1 | `Grid2X2` + `ChevronUp` | 应用 / Apps | 向上展开已启用应用菜单,底部“管理应用”进入管理 Modal |
|
||||
| 末位 | `Settings` | 设置 / Settings | 在当前工作区上打开设置 Modal |
|
||||
|
||||
图标使用项目现有 Lucide 库。应用中心保留文字,设置图标采用统一的 `34px × 34px` 点击区域。
|
||||
底部使用共享间距和焦点样式,设置通过弹性空白靠右;不得把整个容器做成设置按钮。
|
||||
图标使用项目现有 Lucide 库。两个按钮均保留可见文字,沿用主导航的高度、间距和焦点样式;
|
||||
“应用”文字后显示向上箭头,与菜单展开方向一致。竖分隔线宽 1px、高 `--space-6`,使用 `--border-default`,与上方横分隔线颜色一致。不得把整个容器做成设置按钮。
|
||||
|
||||
### 2.1 状态
|
||||
|
||||
@@ -73,18 +85,22 @@
|
||||
```
|
||||
|
||||
取消常驻或关闭可选应用后不留下空白。知识库始终启用并显示;心跳按启用和常驻显示,位置遵守共享应用顺序。运行记录始终位于应用区之后。
|
||||
四个应用均在管理行上通过拖动及“上移”“下移”按钮排序;首尾无目标时禁用对应按钮。两种方式提交完整应用顺序,保存成功后更新,重启保留。当前对话、运行记录和底部系统入口不参与排序。
|
||||
五个应用均在管理行上通过拖动及“上移”“下移”按钮排序;首尾无目标时禁用对应按钮。两种方式提交完整应用顺序,保存成功后更新,重启保留。当前对话、运行记录和底部系统入口不参与排序。
|
||||
|
||||
取消常驻不关闭当前页面或本机推理监控 Modal。重新常驻按已保存顺序恢复,关闭应用保留常驻与顺序偏好。
|
||||
主侧栏始终读取最后确认配置,不展示尚未保存成功的排序或开关目标值。
|
||||
|
||||
## 4. 应用中心与共同设置
|
||||
|
||||
设备共享追加到默认顺序末尾,测试阶段默认关闭。管理行显示“可选”及启用状态,提供打开、
|
||||
设置与排序按钮;设置内提供启用 Switch,不提供常驻开关。关闭时禁用“打开”,应用菜单只在
|
||||
启用时显示该项,点击后在主内容区打开[设备共享页面](../device-sharing/ui-design.md#当前首期页面)。
|
||||
|
||||
底部点击打开轻量菜单,锚定按钮向上展开,无模态遮罩、背景隔离或焦点循环。菜单按共享保存顺序列出已启用应用,不按打开历史或常驻状态过滤。点击应用行关闭菜单,本机推理监控打开独立 Modal,知识库、智能心跳和魔法笔记在主内容区打开页面。分隔线下的“管理应用”关闭菜单并打开既有管理 Modal。
|
||||
|
||||
菜单通过共享 `AnchoredMenu` 挂载到 body,宽度最多 `280px`,距窗口边缘至少 `16px`,与按钮上缘间隔 `8px`;短窗口限制高度并内部滚动,滚动、窗口和内容尺寸变化时重算位置。原生浏览器相交遮挡复用共享机制。
|
||||
|
||||
管理 Modal 沿用设置 Modal 的表面、遮罩和焦点规则,提供全部内置应用的名称及用途搜索、打开,以及可选应用的常驻和设置:
|
||||
“管理应用”菜单项使用 `LayoutGrid` 图标。管理 Modal 标题仍为“应用中心”(英文 `Application Center`),沿用设置 Modal 的表面、遮罩和焦点规则,提供全部内置应用的名称及用途搜索、打开,以及可选应用的常驻和设置:
|
||||
|
||||
应用管理始终采用紧凑单列横向列表,从上到下对应保存顺序;行间使用 `--border-subtle` 弱分隔线,不加卡片外框。概览与设置详情共用 `640px` 最大宽度,窄窗口保留两侧各 `16px` 外边距,高度随内容增长并受窗口限制。
|
||||
每行左侧显示图标、名称、状态和简短用途,右侧对齐打开、可用的设置及上下移动操作;空间不足时操作组换到下一行并保持右对齐,组内也可换行,上下箭头保持成组。名称旁区分
|
||||
@@ -189,7 +205,8 @@ CPU 4% · 内存 620 MB · 桌面进程 7
|
||||
|
||||
| 对象 | 文案 |
|
||||
| --- | --- |
|
||||
| 底部入口 | 应用中心 |
|
||||
| 底部入口 | 应用 / Apps |
|
||||
| 管理 Modal 标题 | 应用中心 / Application Center |
|
||||
| 菜单底部管理入口 | 管理应用 |
|
||||
| 应用设置开关 | 启用应用 |
|
||||
| 文件系统工具 | 文件 |
|
||||
@@ -232,12 +249,17 @@ CPU 4% · 内存 620 MB · 桌面进程 7
|
||||
会话行的“更多会话操作”提供“置顶会话/取消置顶”,本地与消息通道会话均可使用。
|
||||
置顶会话显示带“已置顶”可访问名称的图标;当前项目和搜索过滤仍生效,过滤后的列表先显示
|
||||
置顶会话,两组内各按最新消息的创建时间倒序;时间相同按会话 ID 固定顺序,无消息时使用
|
||||
会话更新时间。新增消息可以前移会话,同一消息的文字、推理、工具进度和完成状态更新不
|
||||
会话更新时间。新增消息在每 5 秒的排序检查时可以前移会话,仅顺序变化时调整位置;同一消息的文字、推理、工具进度和完成状态更新不
|
||||
改变排序。有消息的会话重命名或修改设置也不前移。摘要刷新、打开详情和重新启动沿用同一
|
||||
规则,行右侧仍显示实际更新时间。取消置顶恢复消息时间排序,不改变会话更新时间。
|
||||
置顶状态重启后保留;保存成功后更新图标和顺序,失败保留原状态并显示应用错误通知。
|
||||
存储尚未就绪或置顶请求进行中时禁用置顶操作。
|
||||
|
||||
消息内容、标题与运行状态实时更新。鼠标停留在列表内、焦点在列表内、滚动尚未停止
|
||||
200ms 或会话操作菜单打开时,跳过自动排序;操作结束后在下一次 5 秒检查时调整。
|
||||
新建、删除、置顶和取消置顶立即反映到列表中,不提前应用其他会话待处理的位置变化。
|
||||
切换项目或搜索范围立即显示对应列表;排序计时器不因消息增量而重新计时。
|
||||
|
||||
操作菜单通过 body Portal 浮在会话列表上方,不撑开会话行。菜单锚定更多按钮,默认位于
|
||||
按钮下方,随滚动、窗口及菜单尺寸变化重新定位,距窗口边缘至少 `8px`;空间不足时限制
|
||||
宽高并允许菜单内部滚动。使用共享表面色、边框、圆角与浮层阴影。
|
||||
@@ -285,27 +307,20 @@ OCR 忙碌或模型不可用时禁用加载与释放,并在执行模块再次
|
||||
对应 [FR-14](./prd.md#fr-14-项目活动汇总),状态与计数遵守
|
||||
[活动逻辑](./logic-design.md#9-项目活动汇总)。
|
||||
|
||||
- 全局汇总按钮仅在有活动行时显示在项目选择器下方,紧凑显示待处理、运行中与已完成数量,无活动行时整行隐藏,
|
||||
右侧显示展开箭头,与下方新建会话保留 `8px` 间距。可访问名称明确数量属于“全项目活动”。
|
||||
- 项目菜单在项目行显示同口径数量,保留“本地项目 / 远程项目 / 远程通道”分组和 SSH Host
|
||||
层级,不因活动状态重排项目。数量为零时不显示标记,连接状态仍独立显示。
|
||||
项目名独占一行,状态数量排列在名称下方,避免窄侧栏中数量挤占名称宽度。
|
||||
- 点击汇总在按钮下方打开项目菜单,只列有活动会话的项目,每项显示项目名和同口径计数。
|
||||
桌面鼠标悬停或点击项目展开右侧会话菜单,右侧空间不足且左侧可容纳时向左展开。两个面板
|
||||
相接,各自拥有边框、圆角与阴影;会话层对齐所选项目行,底部空间不足时仅上移会话层,
|
||||
不改变项目层的位置或高度。指针离开项目行进入会话层时不收起。展开项目不切换当前会话。
|
||||
- 会话层顶部显示项目名,每行显示完整可换行的会话标题与明确状态文字,待处理会话排在
|
||||
运行中会话之前,已完成未查看会话排在最后并显示“已完成”。项目行保留子菜单箭头,会话行不显示箭头。不展开 Task、Job 或 Run 列表。
|
||||
- 点击会话先关闭菜单并恢复入口焦点,再请求进入该条会话;允许离开当前页面后,
|
||||
选择所属项目、清除搜索和该会话未读标记、清除任务选择与会话操作菜单,窄窗口同时关闭侧栏。
|
||||
设置页沿用未保存修改的离开检查,取消离开时保留原页面和完成通知。完成通知在目标聊天
|
||||
实际可见后清除,从普通侧栏进入也相同;设置覆盖下的聊天或隐藏文档不触发清除。
|
||||
- 菜单挂载到 body,使用 `menu`、`menuitem`、`aria-haspopup`、`aria-expanded` 与控制关系,
|
||||
不隔离背景、不增加遮罩。原生浏览器相交遮挡沿用共享机制。
|
||||
- 打开后焦点进入首个项目;上下方向键循环选择当前层项目或会话,Home、End 到首尾,
|
||||
Enter、Space 激活,右方向键进入会话层。左方向键或 Escape 返回项目层并恢复对应项目
|
||||
焦点;顶层 Escape 关闭并恢复汇总焦点,不继续关闭窄侧栏。Tab 关闭菜单后继续正常焦点
|
||||
顺序;点击外部、焦点离开、再次点击入口或隐藏侧栏也关闭菜单。
|
||||
- 单面板宽度为 `264px`,窗口小于 `620px` 或项目菜单两侧均无法容纳子菜单时,就地进入会话层并显示“返回项目列表”,
|
||||
触控无需悬停。浮层距窗口边缘至少 `16px`,每层最大高度为 `480px`,短窗口进一步限制
|
||||
高度并独立滚动。成功完成但尚未查看的会话继续保留;所有活动行移除后,关闭已打开菜单并隐藏摘要。
|
||||
左上角是固定高 `60px` 的单个按钮,第一行显示项目名称,第二行显示全局待处理、运行中、已完成数量,无活动时显示“暂无活动”。点击任一行都打开锚定整个按钮的工作空间菜单,默认预览当前项目;菜单内点击“全部项目”切换至全局范围。按钮只有一个键盘焦点、焦点环及展开状态,`aria-controls` 关联菜单,`aria-describedby` 关联活动描述和控件下方的远程恢复反馈。活动变化不改变按钮高度,也不关闭已打开的菜单;长内容单行截断。
|
||||
|
||||
左栏固定搜索、分类及底部“新建项目”,项目列表独立滚动。分类使用共享 `PageTabs` 的 `segmented` 变体,依次为“全部项目、本地、远程、消息通道”,保留 `tablist`/`tab`/`tabpanel`、游标焦点及方向键语义;远程能力关闭时隐藏远程分类。搜索跨分类匹配,SSH Host 分组可折叠,连接状态独立于会话数量。
|
||||
|
||||
左栏使用 `--surface-subtle`,与右栏之间以 `1px` 的 `--border-control` 竖线分隔。搜索框使用 `border-box`,宽度包含边框和内边距,并扣除左右外边距,避免越过分隔线。全部项目及搜索结果按实际存在的本地、远程、消息通道显示分组标题,组间用横线和留白区分;远程组内保留 Host 标题。
|
||||
|
||||
项目行显示名称、活动数量、目录三行,无活动时省略数量行,高度随内容增长;恢复状态继续显示在目录下方。当前项目以勾选和 `aria-checked` 标识,预览使用单独背景。设置按钮独立,桌面悬停或聚焦显示,窄窗口及粗指针设备可直接访问;打开既有真实项目设置,保留目录选择、Runtime、模式、删除及远程恢复等适用操作,不触发项目切换。悬停或聚焦项目只预览右栏,点击项目直接进入;通道无会话时保留空态。
|
||||
|
||||
右栏顶部用共享 `SegmentedControl` 显示“全部、待处理、运行中、已完成”,采用辅助字号和紧凑内边距,与靠右的“新建会话”同排。新建使用预览项目,全部项目范围使用当前项目;消息通道禁用并显示自动创建说明。默认会话列表按待处理、运行中、最近会话分组,最近最多 10 条,完成未查看在行内标注;“已完成”筛选不受此上限限制。会话标题单行截断,全部项目范围补充项目名,不展开 Task、Job 或 Run。无活动仍显示最近会话,无匹配会话时显示空态。
|
||||
|
||||
点击会话关闭菜单并请求确切 ID 导航;通过既有未保存离开检查后,选择所属项目、清除侧栏搜索、未读标记、任务选择和会话菜单,窄窗口关闭侧栏。取消离开保留原页面和完成通知。完成通知仅在目标聊天实际可见后清除,预览、设置覆盖或隐藏文档不触发清除。
|
||||
|
||||
菜单使用共享 `FloatingPortal`,外层为非模态 `dialog`,入口提供 `aria-haspopup="dialog"`、展开状态及控制关系,不增加遮罩或背景隔离。项目列表使用 `menu`/`menuitemradio`,独立操作使用 `menuitem`;会话列表使用 `list`/`listitem` 和按钮。原生浏览器相交遮挡沿用共享机制。
|
||||
|
||||
打开后聚焦选中分类。列表内上下方向键循环移动,Home、End 到首尾;右方向键进入会话列表,左方向键返回预览项目,项目不可见时回到选中分类。Enter、Space 激活按钮;Tab 在控件间正常移动,焦点离开后关闭。Escape 关闭整个菜单并恢复仍可用的触发入口焦点,触发入口已卸载时回到项目入口,不继续关闭窄侧栏。点击外部、再次点击入口或隐藏侧栏也关闭。
|
||||
|
||||
菜单最大宽度 `920px`,高度 `640px` 且受视口限制,距窗口边缘至少 `16px`,锚点下方间隔 `8px`。桌面左栏为 `240px` 至 `360px`,窄窗口仍保留双栏并各自滚动,不使用旧级联返回页。状态筛选可横向滚动,新建按钮保持可见。项目和会话列表均采用共享窗口化渲染,键盘可到达完整列表首尾。
|
||||
|
||||
@@ -42,12 +42,12 @@
|
||||
|
||||
- Given 任意主页面和任意侧栏宽度
|
||||
- When 用户查看侧栏底部
|
||||
- Then 设置与应用中心为始终可见的独立按钮,点击区域不重叠
|
||||
- Then “设置”与“应用”为始终显示文字的独立按钮,点击区域不重叠
|
||||
- And 点击后继续遵守未保存内容的离开确认
|
||||
|
||||
## 3. Epic B:管理应用入口
|
||||
|
||||
本节的启用、常驻和共同设置适用于智能心跳、魔法笔记、本机推理监控;四个应用均可排序,知识库始终启用并常驻。心跳的默认关闭及执行边界按[应用启停与执行](../conversation-supervision/logic-design.md#应用启停与执行)验收,保留既有故事 ID。
|
||||
本节的启用、常驻和共同设置适用于智能心跳、魔法笔记、本机推理监控;五个应用均可排序,知识库始终启用并常驻。设备共享在测试阶段默认关闭,提供启用开关,不提供常驻开关;启用后可从应用菜单和管理 Modal 打开,关闭后退出当前页面并保留目录及发布记录,旧设置缺失时默认关闭,明确启用值在重启后保留。业务验收见[设备共享首期需求](../device-sharing/prd.md#当前首期本地元数据)。心跳的默认关闭及执行边界按[应用启停与执行](../conversation-supervision/logic-design.md#应用启停与执行)验收,保留既有故事 ID。
|
||||
|
||||
### US-B1 识别完整应用
|
||||
|
||||
@@ -139,14 +139,14 @@
|
||||
验收:
|
||||
|
||||
- Given 首次使用且未配置导航偏好
|
||||
- Then 默认顺序为知识库、智能心跳、魔法笔记、本机推理监控;心跳默认关闭且保留默认常驻偏好,魔法笔记默认常驻,本机推理监控默认启用但不常驻,仍可从应用菜单打开;侧栏只按启用与常驻过滤可选项
|
||||
- Then 默认顺序为知识库、智能心跳、魔法笔记、本机推理监控、设备共享;心跳默认关闭且保留默认常驻偏好,魔法笔记默认常驻,本机推理监控默认启用但不常驻,仍可从应用菜单打开;设备共享默认关闭,启用后只在应用菜单和管理 Modal 提供入口;侧栏只按启用与常驻过滤可选项
|
||||
- When 用户修改常驻,或通过拖动、键盘可操作的上移和下移按钮排序并保存成功
|
||||
- Then 管理列表从上到下、菜单和侧栏按同一保存顺序显示,知识库和智能心跳可移动;当前对话、运行记录及底部系统入口位置不变,重启后恢复保存结果
|
||||
- And 每行均提供上下移动,以完整保存顺序中的相邻应用为目标;未常驻及关闭应用仍可排序,设置详情不提供排序控件
|
||||
- And 搜索时箭头仍按完整顺序移动,保留未命中应用,拖动禁用;清除搜索后显示完整保存结果
|
||||
- Given 旧设置仅保存本机推理监控、魔法笔记的顺序
|
||||
- When 升级后读取设置
|
||||
- Then 知识库、智能心跳补在前面,两个可选应用保留原有相对顺序和常驻值
|
||||
- Then 知识库、智能心跳补在前面,两个可选应用保留原有相对顺序和常驻值,设备共享追加到末尾
|
||||
- Given 应用已关闭或取消常驻
|
||||
- When 用户重新开启或常驻
|
||||
- Then 按保存顺序恢复入口;关闭或取消常驻不删除顺序,已关闭应用仍可管理常驻偏好
|
||||
@@ -356,7 +356,7 @@
|
||||
- Given 右侧工作栏有多个工具实例
|
||||
- Then Tab 单行横向滚动且“+”持续可见,工作栏停靠回流,不遮罩主页面;布局和 Tab 交互仍由工作栏负责
|
||||
|
||||
## 6. Epic E:项目活动级联
|
||||
## 6. Epic E:统一工作空间菜单
|
||||
|
||||
### US-E1 从摘要进入确切会话
|
||||
|
||||
@@ -364,8 +364,11 @@
|
||||
查看跨项目活动,先定位项目,再进入需要处理的会话。
|
||||
|
||||
- Given 多个项目存在运行中和待处理会话,当前搜索仅匹配当前项目
|
||||
- When 打开全项目活动并悬停或点击另一个项目
|
||||
- Then 项目顺序保持稳定,该项目会话按待处理优先展示,当前页面和会话保持原选择
|
||||
- When 打开全项目活动并悬停或键盘聚焦另一个项目
|
||||
- Then 同一菜单右栏预览该项目会话,待处理、运行中优先,最近会话最多 10 条,当前页面和会话保持原选择
|
||||
- And 分类依次为全部项目、本地、远程、消息通道;远程能力关闭时隐藏远程分类及项目,搜索跨分类,远程主机可折叠
|
||||
- When 点击项目
|
||||
- Then 进入该项目,恢复本次应用会话内上次打开的有效会话,否则选择最近会话;普通项目没有会话时新建,消息通道保持空态
|
||||
- When 点击会话
|
||||
- Then 进入该确切 Conversation ID,清除搜索、任务选择和会话菜单,窄窗口关闭侧栏
|
||||
- Given 设置页有未保存修改
|
||||
@@ -373,20 +376,30 @@
|
||||
- Given 用户选择的确切 Conversation ID 未在内存中或已被删除
|
||||
- When 请求进入该会话
|
||||
- Then 刷新持久化会话列表后仍只查找同一 ID;刷新失败或会话不存在时显示通知并保留原页面和选择,不回退到项目最新会话或其他会话
|
||||
- Given 从项目入口打开菜单
|
||||
- Then 默认预览当前项目;点击“全部项目”恢复全局范围,“已完成”筛选列出全部完成未查看会话,不受最近 10 条限制
|
||||
- When 实际打开会话、产生新消息或活动状态变化
|
||||
- Then 菜单按最新消息与本次应用会话内访问/状态变化时间中的较晚者排序;首次状态载入和仅清除完成提醒不更新时间,悬停不改变排序
|
||||
- When 点击与状态筛选同排的“新建会话”
|
||||
- Then 在预览项目创建,全部项目范围使用当前项目;消息通道禁用手动新建并说明自动创建规则
|
||||
- When 点击项目行独立设置按钮或左栏底部“新建项目”
|
||||
- Then 打开真实设置或创建表单;设置不触发项目切换,创建成功后经离开检查进入新项目
|
||||
- And 项目进入或新建会话已获准离开后,提交聊天视图不再次触发同一离开检查
|
||||
|
||||
### US-E2 键盘、窄窗口与实时结束
|
||||
|
||||
- Given 菜单已打开
|
||||
- Then 上下方向键与 Home、End 选择当前层,右方向键或 Enter 进入会话层,左方向键或
|
||||
Escape 返回;再次 Escape 关闭活动菜单并恢复摘要焦点,窄侧栏保持打开
|
||||
- Given 窗口小于 `620px`
|
||||
- Then 点击项目后仅显示会话层,可通过返回按钮回到项目列表;长列表可滚动到最后一项
|
||||
- Given 鼠标从项目行移向相接的会话面板
|
||||
- Then 子菜单保持打开,点击会话正常导航
|
||||
- Then 初始焦点进入选中分类,上下方向键与 Home、End 移动列表焦点,右方向键进入会话列表,左方向键返回预览项目或选中分类;Enter、Space 激活按钮
|
||||
- When 按 Escape
|
||||
- Then 关闭整个工作空间菜单并恢复可用触发入口焦点,窄侧栏保持打开
|
||||
- Given 窄窗口或长列表
|
||||
- Then 两栏有清晰分隔并独立滚动,搜索框不越过左栏边界,全部项目及搜索结果按本地、远程、消息通道显示分组标题;紧凑状态筛选可横向滚动,新建按钮保持可见,触控点击项目直接进入
|
||||
- Given 当前项目已无待处理、运行中或已完成未查看会话
|
||||
- Then 返回项目层并恢复可用焦点;全局无活动时整行隐藏摘要并关闭菜单,新活动到来时只恢复摘要
|
||||
- When 点击外部、焦点离开、按 Tab 或隐藏侧栏
|
||||
- Then 显示最近会话或空态;全局无活动时第二行显示“暂无活动”,仍可打开菜单并在其中切换全部项目范围,已打开的菜单仍可使用,新活动不自动打开菜单
|
||||
- And 左上角是一个固定两行高度的按钮,项目名和全局活动共用一个键盘焦点;点击任一行都默认预览当前项目,恢复提示显示在按钮下方并关联可访问描述
|
||||
- When 点击外部、焦点离开或隐藏侧栏
|
||||
- Then 菜单关闭,后台新活动不自动重新展开
|
||||
- And Tab 可在菜单内访问搜索、分类、项目设置、创建及会话按钮,焦点离开后关闭
|
||||
|
||||
### US-E3 完成后保留至查看
|
||||
|
||||
@@ -396,7 +409,7 @@
|
||||
- When 本次应用会话内的运行成功完成,或已观察到的后台任务转为完成
|
||||
- Then 活动会话保留为“已完成”,全局与项目的已完成计数同步更新
|
||||
- When 从活动菜单或普通侧栏实际进入该会话的可见聊天
|
||||
- Then 清除其完成通知;没有其他活动行时隐藏摘要并关闭菜单
|
||||
- Then 清除其完成通知;没有其他活动行时第二行恢复“暂无活动”,已查看会话仍可出现在最近会话中
|
||||
- Given 聊天被设置覆盖或文档隐藏
|
||||
- When 对应运行完成
|
||||
- Then 完成通知保留,直到设置关闭且目标聊天在可见文档中显示;取消设置离开不清除通知
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--surface-canvas: #e8f0f5; --surface-raised: #fff; --surface-subtle: #f4f8fb;
|
||||
--text-primary: #17263a; --text-secondary: #4f6278; --border-default: #cedce7;
|
||||
--accent: #086fc7; --accent-subtle: #e8f4ff; --accent-selected: #a7cfea;
|
||||
--success: #147566; --success-subtle: #e5f4ee; --warning: #865200;
|
||||
--radius-control: 10px; --radius-card: 14px;
|
||||
--shadow: 0 24px 80px #17263a26;
|
||||
font-family: "Inter Variable", "PingFang SC", "Microsoft YaHei UI", system-ui, sans-serif;
|
||||
}
|
||||
[data-theme="dark"] { color-scheme: dark; --surface-canvas: #07131f; --surface-raised: #102335; --surface-subtle: #162c40; --text-primary: #edf7ff; --text-secondary: #b5c6d5; --border-default: #29445a; --accent: #72c5ff; --accent-subtle: #123b5b; --accent-selected: #28668f; --success: #72d8be; --success-subtle: #123d37; --warning: #f2bd66; }
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; padding: 24px 32px; background: var(--surface-subtle); color: var(--text-primary); font-size: 13px; line-height: 1.6; }
|
||||
button, textarea, input { font: inherit; color: inherit; }
|
||||
button { border: 1px solid transparent; border-radius: var(--radius-control); background: transparent; padding: 8px 12px; cursor: pointer; }
|
||||
button:hover { background: var(--accent-subtle); }
|
||||
button:focus-visible, textarea:focus-visible, input:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }
|
||||
button:disabled { opacity: .5; cursor: default; }
|
||||
h1, h2, h3, p { margin: 0; } .muted, .eyebrow, .section-label { color: var(--text-secondary); }
|
||||
.eyebrow, .section-label { font-size: 11px; letter-spacing: 1px; }
|
||||
.lab, .lab-foot { max-width: 1440px; margin: auto; display: flex; justify-content: space-between; gap: 16px; align-items: center; }
|
||||
.lab { margin-bottom: 24px; }.lab strong { font-size: 15px; }.lab span { margin-left: 24px; color: var(--text-secondary); }.tools { display: flex; gap: 4px; }.tools button { border-color: var(--border-default); }
|
||||
.shell { max-width: 1440px; margin: auto; height: calc(100dvh - 160px); min-height: 580px; display: grid; grid-template-columns: 256px minmax(0, 1fr); border: 1px solid var(--border-default); border-radius: var(--radius-card); overflow: hidden; background: var(--surface-raised); }
|
||||
.sidebar { background: var(--surface-canvas); padding: 24px 12px 12px; display: flex; flex-direction: column; border-right: 1px solid var(--border-default); }.brand { display: flex; align-items: center; gap: 8px; margin: 0 8px 32px; }.brand .muted { font-size: 11px; margin-left: auto; }.logo { background: var(--text-primary); color: var(--surface-raised); border-radius: var(--radius-control); padding: 4px 10px; }
|
||||
.workspace-control { background: var(--surface-raised); border: 1px solid var(--border-default); border-radius: var(--radius-card); overflow: hidden; }#workspace-trigger { position: relative; display: grid; text-align: left; width: 100%; padding: 12px; }#project-name { font-size: 16px; }.expand { position: absolute; right: 12px; top: 20px; font-size: 22px; color: var(--accent); }#activity-trigger { border-top: 1px solid var(--border-default); border-radius: 0; width: 100%; font-size: 10px; padding: 8px; display: flex; gap: 4px; justify-content: space-between; }
|
||||
.new { margin: 16px 0 24px; text-align: left; border-color: var(--border-default); }.section-label { margin: 0 12px 8px; }#sessions { overflow: auto; }#sessions button { width: 100%; text-align: left; display: flex; gap: 8px; align-items: center; }#sessions button span:last-child { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }[aria-current="true"], button[aria-pressed="true"] { background: var(--accent-subtle); border-color: var(--accent-selected); color: var(--accent); }.sidebar-bottom { margin-top: auto; padding-top: 24px; }.sidebar-bottom > span { display: block; margin: 8px 12px; font-size: 11px; }.sidebar-bottom button { width: 100%; text-align: left; }kbd { font: 10px/1.5 inherit; border: 1px solid var(--border-default); border-radius: 4px; padding: 2px 4px; color: var(--text-secondary); margin-left: 8px; }
|
||||
.workspace { display: flex; flex-direction: column; min-width: 0; overflow: hidden; }.titlebar { padding: 16px 24px; border-bottom: 1px solid var(--border-default); background: var(--surface-subtle); display: flex; justify-content: space-between; gap: 12px; }#conversation { flex: 1; overflow: auto; padding: 48px max(32px, calc((100% - 740px) / 2)); }#conversation h1 { font-size: 28px; font-weight: 550; letter-spacing: -1px; margin: 12px 0 32px; }.user-message { margin: 0 0 32px 48px; padding: 16px 24px; background: var(--surface-subtle); border-radius: var(--radius-card); }.assistant-label { color: var(--accent); font-size: 12px; margin-bottom: 12px; }.answer { font-size: 15px; line-height: 1.9; white-space: pre-wrap; }.execution { margin-top: 24px; padding: 16px; border: 1px solid var(--border-default); border-radius: var(--radius-control); }.execution p { margin: 8px 0 16px; color: var(--text-secondary); }.primary { background: var(--accent); color: var(--surface-raised); }.primary:hover { background: var(--text-primary); color: var(--surface-raised); }#composer { margin: 16px 32px 24px; border: 1px solid var(--border-default); border-radius: var(--radius-card); padding: 12px 16px; }textarea { display: block; width: 100%; resize: vertical; border: 0; background: transparent; padding: 8px 0; }#composer > div { display: flex; align-items: center; justify-content: space-between; }.lab-foot { margin-top: 16px; color: var(--text-secondary); font-size: 11px; }
|
||||
.waiting { color: var(--warning); }.running { color: var(--accent); }.done { color: var(--success); }.idle { color: var(--text-secondary); }.empty { text-align: center; padding: 48px 16px; color: var(--text-secondary); }#toast { position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%); padding: 12px 24px; border: 1px solid var(--accent-selected); background: var(--surface-raised); color: var(--text-primary); border-radius: var(--radius-card); box-shadow: var(--shadow); z-index: 5; }
|
||||
@media (max-width: 800px) { body { padding: 12px; }.lab { flex-wrap: wrap; margin-bottom: 12px; }.lab span { display: none; }.shell { grid-template-columns: 208px minmax(0, 1fr); }.brand .muted { display: none; }#conversation { padding: 24px; }.lab-foot span:last-child { display: none; } }
|
||||
@media (max-width: 560px) { .shell { display: flex; flex-direction: column; height: auto; min-height: calc(100dvh - 150px); }.sidebar { border-right: 0; border-bottom: 1px solid var(--border-default); padding: 12px; }.brand, .new, .section-label, #sessions, .sidebar-bottom { display: none; }.workspace-control { display: flex; }#workspace-trigger { width: 48%; }#activity-trigger { width: 52%; flex-wrap: wrap; align-content: center; border-top: 0; border-left: 1px solid var(--border-default); }.workspace { min-height: 540px; }.titlebar { padding: 12px; }.titlebar > .muted { display: none; }#conversation { padding: 24px 16px; }#composer { margin: 12px; }.user-message { margin-left: 16px; } }
|
||||
#overview { position: fixed; inset: auto; margin: 0; padding: 0; width: min(920px, calc(100vw - 16px)); height: 640px; border: 1px solid var(--border-default); border-radius: var(--radius-card); box-shadow: var(--shadow); background: var(--surface-raised); color: var(--text-primary); overflow: hidden; }
|
||||
#overview:popover-open { display: flex; flex-direction: column; }
|
||||
#activity-trigger[hidden] { display: none; }
|
||||
.menu-header { display: flex; align-items: center; justify-content: space-between; padding: 8px 16px; border-bottom: 1px solid var(--border-default); flex-shrink: 0; }.menu-header button { font-size: 11px; }
|
||||
.menu-layout { display: grid; grid-template-columns: 360px minmax(0, 1fr); flex: 1; min-height: 0; }
|
||||
.project-pane { display: flex; flex-direction: column; min-height: 0; min-width: 0; background: var(--surface-subtle); border-right: 1px solid var(--border-default); }
|
||||
#project-search { margin: 12px 8px 8px; width: calc(100% - 16px); min-width: 0; border: 1px solid var(--border-default); background: var(--surface-raised); border-radius: 6px; padding: 8px; font-size: 12px; }
|
||||
#all-projects { text-align: left; margin: 0 8px 8px; padding: 8px; }
|
||||
#project-tabs { display: flex; gap: 4px; padding: 0 8px 8px; border-bottom: 1px solid var(--border-default); flex-shrink: 0; }#project-tabs[hidden] { display: none; }#project-tabs button { flex: 1; white-space: nowrap; font-size: 11px; padding: 6px 4px; }#project-tabs [aria-selected="true"] { color: var(--accent); background: var(--accent-subtle); border-color: var(--accent-selected); }
|
||||
#desktops { display: block; padding: 8px; overflow-y: auto; flex: 1; min-height: 0; overscroll-behavior: contain; }
|
||||
.search-result > small { display: block; padding: 0 8px 8px 32px; font-size: 10px; color: var(--text-secondary); }.search-result .desktop { margin-bottom: 0; }
|
||||
.host-heading { cursor: pointer; }.host-heading > span:first-child::before { content: '▸'; margin-right: 4px; }details[open] > .host-heading > span:first-child::before { content: '▾'; }
|
||||
.project-row { position: relative; margin-bottom: 4px; }
|
||||
.project-row .desktop { display: grid; grid-template-columns: 16px minmax(0, 1fr) 14px; min-height: 52px; padding: 8px 44px 8px 8px; gap: 8px; margin: 0; }
|
||||
.project-copy { min-width: 0; display: grid; gap: 2px; }.project-heading { display: flex; align-items: center; gap: 8px; min-width: 0; }.project-heading strong { font-size: 13px; font-weight: 650; }.project-path { display: block; font-size: 11px; line-height: 16px; color: var(--text-secondary); overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }.project-row .project-counts { width: auto; padding: 0; flex-wrap: nowrap; }.current-project { color: var(--accent); font-size: 13px; }
|
||||
.project-settings-button { position: absolute; top: 50%; right: 4px; transform: translateY(-50%); width: 32px; height: 32px; padding: 8px; opacity: 0; pointer-events: none; color: var(--text-secondary); }.project-row:hover .project-settings-button, .project-row:focus-within .project-settings-button { opacity: 1; pointer-events: auto; }
|
||||
.project-copy > .project-counts { font-size: 11px; line-height: 16px; flex-wrap: wrap; gap: 2px 8px; }.project-copy > .project-counts > span { white-space: nowrap; }
|
||||
@media (hover: none) { .project-settings-button { opacity: 1; pointer-events: auto; } }
|
||||
#project-settings { width: min(480px, calc(100vw - 24px)); max-height: calc(100dvh - 32px); overflow-y: auto; border: 1px solid var(--border-default); border-radius: var(--radius-card); background: var(--surface-raised); color: var(--text-primary); padding: 24px; box-shadow: var(--shadow); }#project-settings::backdrop { background: color-mix(in srgb, var(--text-primary) 25%, transparent); }
|
||||
#settings-form { display: grid; gap: 16px; }#settings-form header, #settings-form footer { display: flex; align-items: center; gap: 8px; }#settings-form header strong, #settings-form footer small { flex: 1; }#settings-form label { display: grid; gap: 4px; font-size: 12px; }#settings-form input, #settings-form select, #settings-form textarea { width: 100%; min-width: 0; border: 1px solid var(--border-default); border-radius: 6px; background: var(--surface-subtle); color: var(--text-primary); padding: 8px; font: inherit; }
|
||||
.desktop { display: flex; align-items: center; gap: 8px; width: 100%; padding: 8px; margin-bottom: 4px; text-align: left; border-color: transparent; background: transparent; }.desktop strong { display: block; margin: 0; min-width: 0; flex: 1; font-size: 12px; font-weight: 500; }.project-counts { display: flex; gap: 8px; font-size: 10px; color: var(--text-secondary); flex-shrink: 0; }
|
||||
#desktops svg { width: 15px; height: 15px; fill: none; stroke: currentColor; stroke-width: 1.6; stroke-linecap: round; stroke-linejoin: round; flex-shrink: 0; }
|
||||
.project-section { margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--border-default); }.project-section h2 { display: flex; align-items: center; gap: 8px; margin: 0 8px 8px; font-size: 11px; font-weight: 500; color: var(--text-secondary); }.host-group + .host-group { margin-top: 12px; }.host-heading { display: flex; justify-content: space-between; align-items: center; gap: 8px; padding: 4px 8px; font-size: 11px; }.host-status { border: 1px solid var(--border-default); border-radius: 4px; padding: 0 4px; font-size: 10px; }.host-group .desktop { padding-left: 16px; }
|
||||
@media (min-width: 561px) and (max-width: 800px) { .menu-layout { grid-template-columns: 300px minmax(0, 1fr); } }
|
||||
.activity-pane { display: flex; flex-direction: column; min-height: 0; min-width: 0; }
|
||||
#menu-new-chat { color: var(--accent); font-size: 11px; padding: 4px 8px; white-space: nowrap; flex-shrink: 0; }
|
||||
#project-form:not([hidden]) { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; padding: 12px; border-top: 1px solid var(--border-default); flex-shrink: 0; }#project-form input { min-width: 80px; width: 120px; flex: 1; background: var(--surface-raised); border: 1px solid var(--border-default); border-radius: 6px; padding: 8px; }.desktop strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.overview-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 12px; border-top: 0; border-bottom: 1px solid var(--border-default); }
|
||||
#filters { display: flex; gap: 4px; overflow-x: auto; min-width: 0; }#filters button { font-size: 11px; padding: 4px 8px; white-space: nowrap; flex-shrink: 0; }
|
||||
#windows { overflow-y: auto; padding: 8px; flex: 1; min-height: 0; overscroll-behavior: contain; }
|
||||
.window-group { margin: 0 0 16px; }.group-title { display: flex; align-items: center; gap: 8px; font-size: 11px; font-weight: 500; margin: 8px; }
|
||||
.session-row { display: flex; align-items: center; gap: 12px; text-align: left; width: 100%; padding: 12px 8px; border-radius: 6px; }.session-copy { flex: 1; min-width: 0; }.session-copy strong { display: block; font-weight: 500; }.session-copy small { display: block; font-size: 11px; color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }.row-action { font-size: 10px; flex-shrink: 0; }
|
||||
.overview-footer { padding: 8px 16px; background: var(--surface-subtle); border-top: 1px solid var(--border-default); min-height: 48px; flex-shrink: 0; display: flex; align-items: center; justify-content: space-between; gap: 12px; color: var(--text-secondary); font-size: 11px; }
|
||||
@media (max-width: 560px) {
|
||||
.project-row .desktop { grid-template-columns: minmax(0, 1fr) 12px; padding: 8px 36px 8px 4px; gap: 4px; }.project-row .desktop > svg { display: none; }.project-heading { flex-wrap: wrap; gap: 0; }.project-heading strong { flex-basis: 100%; }.project-settings-button { right: 0; }.project-row .project-counts { font-size: 9px; }.project-row .project-path { font-size: 10px; }
|
||||
#project-tabs { overflow-x: auto; gap: 0; padding-inline: 4px; }#project-tabs button { flex: 0 0 auto; }#project-search { font-size: 11px; padding: 6px; }.search-result > small { padding-left: 8px; }
|
||||
.desktop { flex-wrap: wrap; gap: 4px; }.desktop strong { flex-basis: calc(100% - 24px); }.project-counts { width: 100%; padding-left: 19px; }.host-heading { flex-wrap: wrap; gap: 4px; }.host-group .desktop { padding-left: 8px; }
|
||||
.menu-layout { grid-template-columns: 176px minmax(0, 1fr); }#desktops { padding: 4px; }.desktop { padding: 8px; }.desktop strong { font-size: 12px; }.project-counts { gap: 4px; flex-wrap: wrap; }.overview-toolbar { padding: 8px; }#filters { gap: 0; }#filters button { padding: 4px; }.session-row { align-items: flex-start; gap: 4px; flex-direction: column; }.session-copy { width: 100%; }.overview-footer { padding: 8px; gap: 4px; }.overview-footer span { max-width: 55%; }
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>GoodBuddy · 工作空间实验</title>
|
||||
<link rel="stylesheet" href="workspace-demo.css">
|
||||
<script src="workspace-demo.js" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<header class="lab"><strong>GoodBuddy / 工作空间实验</strong><div class="tools"><button id="simulate">模拟后台完成</button><button id="theme">切换深色</button><button id="reset">重置</button></div></header>
|
||||
<main class="shell">
|
||||
<aside class="sidebar">
|
||||
<div class="brand"><b class="logo">G</b><strong>GoodBuddy</strong><span class="muted">个人空间</span></div>
|
||||
<div class="workspace-control"><button id="workspace-trigger" aria-haspopup="dialog" aria-controls="overview" aria-expanded="false"><span class="eyebrow">工作空间</span><strong id="project-name"></strong><span class="expand">▾</span></button><button id="activity-trigger" aria-haspopup="dialog" aria-controls="overview" aria-expanded="false" aria-label="查看全部项目活动"></button></div>
|
||||
<button class="new" id="new-chat">+ 新会话</button>
|
||||
<div class="section-label">会话</div><nav id="sessions" aria-label="会话窗口"></nav>
|
||||
</aside>
|
||||
<section class="workspace">
|
||||
<header class="titlebar"><span id="breadcrumb"></span><span class="muted">会话窗口</span></header>
|
||||
<article id="conversation" aria-live="polite"></article>
|
||||
<form id="composer"><label for="message" class="eyebrow">继续这项工作</label><textarea id="message" placeholder="输入消息,继续与 GoodBuddy 协作…" rows="2" required></textarea><div><span class="muted">OpenCode · Execute</span><button class="primary" type="submit">发送 ↑</button></div></form>
|
||||
</section>
|
||||
</main>
|
||||
<footer class="lab-foot"><span>Demo · 模拟数据</span></footer>
|
||||
<section id="overview" popover="auto" role="dialog" aria-labelledby="overview-title">
|
||||
<header class="menu-header"><strong id="overview-title">项目与活动</strong><button id="close-overview" aria-label="关闭项目与活动菜单">×</button></header>
|
||||
<div class="menu-layout">
|
||||
<aside class="project-pane">
|
||||
<input id="project-search" type="search" aria-label="搜索项目" placeholder="搜索项目…" autocomplete="off">
|
||||
<button id="all-projects" data-scope="all" aria-pressed="false">全部项目</button>
|
||||
<div id="project-tabs" role="tablist" aria-label="项目类型"></div>
|
||||
<nav id="desktops" role="tabpanel" aria-label="项目列表"></nav>
|
||||
</aside>
|
||||
<section class="activity-pane">
|
||||
<div class="overview-toolbar"><div id="filters" role="group" aria-label="筛选会话状态"></div><button id="menu-new-chat">+ 新建会话</button></div>
|
||||
<div id="windows"></div>
|
||||
</section>
|
||||
</div>
|
||||
<form id="project-form" hidden><label for="project-input">项目名称</label><input id="project-input" maxlength="40" required autocomplete="off"><button type="submit" class="primary">创建</button><button type="button" id="cancel-project">取消</button></form>
|
||||
<footer class="overview-footer"><button id="new-project" aria-expanded="false" aria-controls="project-form">+ 新建项目</button></footer>
|
||||
</section>
|
||||
<dialog id="project-settings" aria-labelledby="settings-title">
|
||||
<form id="settings-form">
|
||||
<header><strong id="settings-title">项目设置</strong><button type="button" id="close-settings" aria-label="关闭项目设置">×</button></header>
|
||||
<label>名称<input id="settings-name" maxlength="120" required></label>
|
||||
<label>说明<textarea id="settings-description" rows="2"></textarea></label>
|
||||
<label>执行空间<input id="settings-space" readonly></label>
|
||||
<label>工作目录<input id="settings-path" required></label>
|
||||
<label>默认模式<select id="settings-mode"><option value="ask">Ask · 只读问答</option><option value="execute">Execute · 完全权限</option></select></label>
|
||||
<label>新对话默认 Runtime<select id="settings-runtime"><option>OpenCode</option><option>Continue</option><option>直连模型</option></select></label>
|
||||
<footer><small class="muted">仅修改 Demo 数据</small><button type="button" id="cancel-settings">取消</button><button type="submit" class="primary">保存</button></footer>
|
||||
</form>
|
||||
</dialog>
|
||||
<div id="toast" role="status" hidden></div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,355 @@
|
||||
const projects = [
|
||||
{ id: 'buddy', name: 'GoodBuddy', kind: 'local', location: '本地工作空间' },
|
||||
{ id: 'web', name: '品牌官网', kind: 'local', location: '本地工作空间' },
|
||||
{ id: 'data', name: '数据分析', kind: 'remote', host: 'dev-server', location: '远程 · dev-server' },
|
||||
{ id: 'api', name: '后端服务', kind: 'remote', host: 'dev-server', location: '远程 · dev-server' },
|
||||
{ id: 'lab', name: '模型实验', kind: 'remote', host: 'gpu-lab', location: '远程 · gpu-lab' },
|
||||
{ id: 'channel', name: '产品讨论群', kind: 'channel', location: '消息通道' }
|
||||
];
|
||||
const hosts = [{ name: 'dev-server', status: '就绪' }, { name: 'gpu-lab', status: '未连接' }];
|
||||
for (let index = 3; index <= 20; index++) {
|
||||
projects.push({ id: `local-${index}`, name: `本地项目 ${String(index).padStart(2, '0')}`, kind: 'local', location: '本地工作空间' });
|
||||
}
|
||||
for (let index = 4; index <= 20; index++) {
|
||||
const host = index % 2 ? 'gpu-lab' : 'dev-server';
|
||||
projects.push({ id: `remote-${index}`, name: `远程项目 ${String(index).padStart(2, '0')}`, kind: 'remote', host, location: `远程 · ${host}` });
|
||||
}
|
||||
projects.push(
|
||||
{ id: 'channel-2', name: '研发协作群', kind: 'channel', location: '消息通道' },
|
||||
{ id: 'channel-3', name: '运维通知群', kind: 'channel', location: '消息通道' }
|
||||
);
|
||||
const icons = {
|
||||
all: '<rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/>',
|
||||
local: '<rect x="3" y="4" width="18" height="13" rx="2"/><path d="M8 21h8m-4-4v4"/>',
|
||||
remote: '<rect x="3" y="3" width="18" height="7" rx="2"/><rect x="3" y="14" width="18" height="7" rx="2"/><path d="M7 6h.01M7 17h.01"/>',
|
||||
channel: '<path d="M21 11a8 8 0 0 1-8 8H6l-4 3V11a8 8 0 0 1 8-8h3a8 8 0 0 1 8 8Z"/><path d="M7 9h9M7 13h6"/>',
|
||||
folder: '<path d="M3 7V5a2 2 0 0 1 2-2h5l2 3h7a2 2 0 0 1 2 2v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z"/>'
|
||||
};
|
||||
const icon = (name) => `<svg viewBox="0 0 24 24" aria-hidden="true">${icons[name]}</svg>`;
|
||||
for (const project of projects) {
|
||||
project.rootPath = project.kind === 'remote' ? `/srv/projects/${project.id}` : `D:/workspace/${project.id}`;
|
||||
project.description = '';
|
||||
project.defaultMode = 'ask';
|
||||
project.runtime = 'OpenCode';
|
||||
}
|
||||
const initialProjects = structuredClone(projects);
|
||||
const initial = [
|
||||
{ id: 1, project: 'buddy', title: '工作空间交互设计', state: 'idle', detail: '设计讨论已保存,随时可以继续。', prompt: '让项目与活动会话的关系更直观,做出操作系统的感觉。', answer: '左上角就是工作入口。\n\n展开项目与活动菜单,可以查看多个项目正在进行的会话,也可以直接处理等待你的工作。选择项目只筛选列表,进入会话才切换工作现场。\n\n所有状态都在这个下拉菜单中,不需要再打开独立页面。' },
|
||||
{ id: 2, project: 'buddy', title: '登录流程调整', state: 'waiting', detail: '需要你选择:使用单点登录还是邮箱登录?', prompt: '帮我完善内部用户的登录流程。', answer: '已梳理现有认证入口。需要确定首选登录方式,然后继续调整界面。', question: true },
|
||||
{ id: 3, project: 'buddy', title: '导航组件测试', state: 'running', detail: '正在检查键盘导航与焦点恢复。', prompt: '检查导航组件的交互和测试覆盖。', answer: '已经完成组件检查,正在验证键盘导航与焦点恢复。' },
|
||||
{ id: 4, project: 'web', title: '发布新版首页', state: 'waiting', detail: '预览构建已就绪,等待你批准部署。', prompt: '构建新版首页,发布前让我确认。', answer: '预览构建已完成,页面检查通过。请确认是否继续部署。' },
|
||||
{ id: 5, project: 'web', title: '首页文案精修', state: 'done', detail: '完成 3 处文案调整,结果等待查看。', prompt: '把首页介绍改得更简洁、自然。', answer: '首页文案调整已完成。\n\n首屏标题:把想法,变成已完成的工作。\n产品介绍:查资料、写方案、改代码。在一个工作空间里,与 AI 一起推进。\n行动按钮:开始你的第一项工作。' },
|
||||
{ id: 6, project: 'data', title: '本周使用情况分析', state: 'running', detail: '正在汇总各项目的使用趋势。', prompt: '整理本周各项目的使用情况。', answer: '已完成示例数据整理,正在汇总各项目的使用趋势。' },
|
||||
{ id: 7, project: 'api', title: '接口变更记录', state: 'idle', detail: '昨天的会话', prompt: '整理本次接口变更。', answer: '接口变更记录已整理。' },
|
||||
{ id: 8, project: 'lab', title: '推理参数对比', state: 'idle', detail: '上次会话', prompt: '对比两组推理参数。', answer: '上次对比结果已保存。' },
|
||||
{ id: 9, project: 'channel', title: '版本发布讨论', state: 'idle', detail: '今天的会话', prompt: '整理群里的版本讨论。', answer: '本轮发布讨论已整理。' }
|
||||
];
|
||||
for (const project of projects) {
|
||||
if (!initial.some((session) => session.project === project.id)) {
|
||||
initial.push({ id: initial.length + 1, project: project.id, title: '最近工作记录', state: 'idle', detail: '上次会话', prompt: '整理当前工作。', answer: '工作记录已保存。' });
|
||||
}
|
||||
}
|
||||
const labels = { waiting: '待处理', running: '运行中', done: '已完成' };
|
||||
const symbols = { waiting: '!', running: '◌', done: '✓' };
|
||||
const statusLabel = (session) => session.state === 'waiting'
|
||||
? session.question ? '等待你的回答' : '等待审批'
|
||||
: labels[session.state] || '';
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const escapeHtml = (value) => String(value).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]);
|
||||
let sessions, current, scope = 'all', filter = 'all', projectTab = 'local', toastTimer, menuTrigger, activityOrder, settingsProjectId;
|
||||
const remembered = {};
|
||||
const drafts = {};
|
||||
const overview = $('overview');
|
||||
const projectOf = (session) => projects.find((p) => p.id === session.project);
|
||||
const badge = (state) => `<span class="${state}">${symbols[state]} ${labels[state]}</span>`;
|
||||
function notify(text) {
|
||||
$('toast').textContent = text;
|
||||
$('toast').hidden = false;
|
||||
clearTimeout(toastTimer);
|
||||
toastTimer = setTimeout(() => { $('toast').hidden = true; }, 3500);
|
||||
}
|
||||
function render() {
|
||||
const session = sessions.find((s) => s.id === current);
|
||||
const project = projectOf(session);
|
||||
$('project-name').textContent = project.name;
|
||||
$('breadcrumb').textContent = `${project.name} / ${session.title}`;
|
||||
$('activity-trigger').innerHTML = '<span class="muted">全部</span>' + ['waiting', 'running', 'done'].filter((state) => sessions.some((s) => s.state === state)).map((state) => `<span class="${state}">${symbols[state]} ${sessions.filter((s) => s.state === state).length} ${labels[state]}</span>`).join('');
|
||||
$('activity-trigger').hidden = !sessions.some((s) => labels[s.state]);
|
||||
$('sessions').innerHTML = sessions.filter((s) => s.project === session.project).map((s) => `<button data-open="${s.id}" aria-current="${s.id === current}">${statusLabel(s) ? `<span class="${s.state}" aria-label="${statusLabel(s)}">${symbols[s.state]}</span>` : ''}<span>${escapeHtml(s.title)}</span></button>`).join('');
|
||||
$('conversation').innerHTML = `<span class="eyebrow">${escapeHtml(project.location)}${statusLabel(session) ? ` / ${statusLabel(session)}` : ''}</span><h1>${escapeHtml(session.title)}</h1><div class="user-message">${escapeHtml(session.prompt)}</div><div class="assistant-label">G / GoodBuddy</div><div class="answer">${escapeHtml(session.answer)}</div>${session.state === 'waiting' ? `<section class="execution"><span class="waiting">! ${statusLabel(session)}</span><p>${escapeHtml(session.detail)}</p>${session.question ? '<button class="primary" data-resolve="单点登录">使用单点登录</button> <button data-resolve="邮箱登录">使用邮箱登录</button>' : '<button class="primary" data-resolve="批准部署">批准并继续</button> <button data-resolve="暂不部署">暂不部署</button>'}</section>` : session.state === 'running' ? `<section class="execution">${badge('running')}<p>${escapeHtml(session.detail)} 切换到其他工作空间后仍会继续。</p></section>` : ''}`;
|
||||
$('simulate').disabled = !sessions.some((s) => s.state === 'running');
|
||||
renderOverview();
|
||||
}
|
||||
function renderOverview() {
|
||||
const focused = document.activeElement;
|
||||
const focusScope = focused?.dataset.scope;
|
||||
const focusFilter = focused?.dataset.filter;
|
||||
const projectButton = (p) => {
|
||||
const list = sessions.filter((s) => p.id === 'all' || s.project === p.id);
|
||||
const counts = ['waiting', 'running', 'done'].filter((state) => list.some((s) => s.state === state)).map((state) => `<span class="${state}">${symbols[state]} ${list.filter((s) => s.state === state).length} 个${labels[state]}</span>`).join(' ');
|
||||
const active = sessions.find((s) => s.id === current).project === p.id;
|
||||
const detail = p.kind === 'local' ? `本地目录 · ${p.rootPath}` : p.kind === 'channel' ? `消息通道 · ${p.rootPath}` : p.rootPath;
|
||||
return `<div class="project-row"><button class="desktop" data-scope="${p.id}" title="${escapeHtml(p.name)}\n${escapeHtml(detail)}">${icon(p.kind === 'channel' ? 'channel' : 'folder')}<span class="project-copy"><span class="project-heading"><strong>${escapeHtml(p.name)}</strong></span>${counts ? `<span class="project-counts">${counts}</span>` : ''}<small class="project-path">${escapeHtml(detail)}</small></span><span class="current-project" ${active ? 'aria-label="当前项目"' : 'aria-hidden="true"'}>${active ? '✓' : ''}</span></button><button class="project-settings-button" data-settings="${p.id}" aria-label="管理项目 ${escapeHtml(p.name)}" title="项目设置"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="m9 3-.7 3-2 .9-2.7-.8-2 3.4 2 2.2v2.6l-2 2.2 2 3.4 2.7-.8 2 .9L9 23h4l.7-3 2-.9 2.7.8 2-3.4-2-2.2v-2.6l2-2.2-2-3.4-2.7.8-2-.9L13 3Z" transform="translate(1 -1) scale(.95)"/><circle cx="12" cy="12" r="3"/></svg></button></div>`;
|
||||
};
|
||||
const query = $('project-search').value.trim().toLocaleLowerCase();
|
||||
const kinds = { local: '本地', remote: '远程', channel: '消息通道' };
|
||||
if (!$('project-tabs').children.length) {
|
||||
$('project-tabs').innerHTML = Object.entries(kinds).map(([kind, label]) => `<button id="tab-${kind}" role="tab" data-tab="${kind}" aria-controls="desktops">${label} <small></small></button>`).join('');
|
||||
}
|
||||
$('project-tabs').hidden = Boolean(query);
|
||||
$('project-tabs').querySelectorAll('[data-tab]').forEach((button) => {
|
||||
const selected = button.dataset.tab === projectTab;
|
||||
button.setAttribute('aria-selected', String(selected));
|
||||
button.tabIndex = selected ? 0 : -1;
|
||||
button.querySelector('small').textContent = projects.filter((p) => p.kind === button.dataset.tab).length;
|
||||
});
|
||||
$('desktops').setAttribute('aria-label', query ? '搜索结果' : kinds[projectTab]);
|
||||
$('all-projects').setAttribute('aria-pressed', String(scope === 'all'));
|
||||
const shown = projects.filter((p) => query
|
||||
? `${p.name} ${p.rootPath} ${p.host || ''} ${kinds[p.kind]}`.toLocaleLowerCase().includes(query)
|
||||
: p.kind === projectTab);
|
||||
const projectMarkup = query
|
||||
? shown.map((p) => `<div class="search-result">${projectButton(p)}<small>${kinds[p.kind]}${p.host ? ` · ${escapeHtml(p.host)}` : ''}</small></div>`).join('')
|
||||
: projectTab === 'remote'
|
||||
? hosts.map((host) => `<section class="host-group" aria-label="${host.name}"><details open><summary class="host-heading"><span>${host.name}</span><span class="host-status ${host.status === '就绪' ? 'done' : 'muted'}">${host.status}</span></summary>${shown.filter((p) => p.host === host.name).map(projectButton).join('')}</details></section>`).join('')
|
||||
: shown.map(projectButton).join('');
|
||||
// Keep hovered project nodes stable while the right-hand list changes.
|
||||
if ($('desktops').dataset.markup !== projectMarkup) {
|
||||
$('desktops').innerHTML = projectMarkup || '<div class="empty">无匹配项目</div>';
|
||||
$('desktops').dataset.markup = projectMarkup;
|
||||
}
|
||||
$('desktops').querySelectorAll('[data-scope]').forEach((button) => {
|
||||
button.setAttribute('aria-pressed', String(button.dataset.scope === scope));
|
||||
});
|
||||
const list = sessions.filter((s) => scope === 'all' || s.project === scope);
|
||||
$('filters').innerHTML = ['all', 'waiting', 'running', 'done'].map((state) => `<button data-filter="${state}" aria-pressed="${filter === state}">${state === 'all' ? '全部' : labels[state]} <small>${list.filter((s) => state === 'all' || s.state === state).length}</small></button>`).join('');
|
||||
const targetProject = projects.find((p) => p.id === (scope === 'all' ? sessions.find((s) => s.id === current).project : scope));
|
||||
$('menu-new-chat').title = `在 ${targetProject.name} 中新建会话`;
|
||||
$('menu-new-chat').setAttribute('aria-label', $('menu-new-chat').title);
|
||||
const groups = filter === 'all' ? ['waiting', 'running', 'recent'] : [filter];
|
||||
$('windows').innerHTML = groups.map((state) => {
|
||||
const sorted = list.filter((s) => state === 'recent' ? s.state === 'done' || s.state === 'idle' : s.state === state)
|
||||
.sort((a, b) => b.lastActivity - a.lastActivity);
|
||||
const group = state === 'recent' ? sorted.slice(0, 5) : sorted;
|
||||
if (!group.length) return '';
|
||||
return `<section class="window-group"><h2 class="group-title">${state === 'recent' ? '最近会话' : badge(state)} <span class="muted">${group.length}</span></h2>${group.map((s) => `<button class="session-row" data-open="${s.id}" title="${escapeHtml(s.detail)}"><span class="session-copy"><strong>${escapeHtml(s.title)}</strong>${scope === 'all' ? `<small>${escapeHtml(projectOf(s).name)}</small>` : ''}</span><span class="row-action ${s.state}">${s.state === 'done' ? '✓ 已完成' : s.state === 'waiting' ? statusLabel(s) : s.id === current ? '当前' : '↗'}</span></button>`).join('')}</section>`;
|
||||
}).join('') || '<div class="empty">暂无会话</div>';
|
||||
if (focusScope) overview.querySelector(`[data-scope="${focusScope}"]`)?.focus();
|
||||
if (focusFilter) overview.querySelector(`[data-filter="${focusFilter}"]`)?.focus();
|
||||
}
|
||||
function openSession(id) {
|
||||
const session = sessions.find((s) => s.id === id);
|
||||
drafts[current] = $('message').value;
|
||||
current = id;
|
||||
session.lastActivity = ++activityOrder;
|
||||
remembered[session.project] = id;
|
||||
if (session.state === 'done') { session.state = 'idle'; session.detail = '结果已查看,可以继续这项工作。'; }
|
||||
closeMenu();
|
||||
$('message').value = drafts[id] || '';
|
||||
render();
|
||||
$('conversation').scrollTop = 0;
|
||||
}
|
||||
function positionMenu() {
|
||||
const anchor = document.querySelector('.workspace-control').getBoundingClientRect();
|
||||
const top = Math.max(8, Math.min(anchor.bottom + 8, window.innerHeight - 240));
|
||||
overview.style.left = `${Math.max(8, Math.min(anchor.left, window.innerWidth - overview.offsetWidth - 8))}px`;
|
||||
overview.style.top = `${top}px`;
|
||||
overview.style.maxHeight = `${window.innerHeight - top - 8}px`;
|
||||
}
|
||||
function closeMenu() {
|
||||
if (overview.matches(':popover-open')) overview.hidePopover();
|
||||
}
|
||||
function showOverview(project = 'all', trigger = $('workspace-trigger')) {
|
||||
if (overview.matches(':popover-open')) { closeMenu(); return; }
|
||||
menuTrigger = trigger;
|
||||
scope = project;
|
||||
projectTab = projects.find((p) => p.id === (project === 'all' ? sessions.find((s) => s.id === current).project : project)).kind;
|
||||
$('project-search').value = '';
|
||||
filter = 'all';
|
||||
$('project-form').hidden = true;
|
||||
$('new-project').setAttribute('aria-expanded', 'false');
|
||||
renderOverview();
|
||||
overview.showPopover();
|
||||
positionMenu();
|
||||
overview.querySelector('[data-scope][aria-pressed="true"]').focus();
|
||||
}
|
||||
document.addEventListener('click', (event) => {
|
||||
const button = event.target.closest('button');
|
||||
if (!button) return;
|
||||
if (button.dataset.settings) {
|
||||
settingsProjectId = button.dataset.settings;
|
||||
const project = projects.find((p) => p.id === settingsProjectId);
|
||||
$('settings-name').value = project.name;
|
||||
$('settings-name').readOnly = project.kind === 'channel';
|
||||
$('settings-description').value = project.description || '';
|
||||
$('settings-space').value = project.location;
|
||||
$('settings-path').value = project.rootPath;
|
||||
$('settings-mode').value = project.defaultMode || 'ask';
|
||||
$('settings-runtime').value = project.runtime || 'OpenCode';
|
||||
$('settings-runtime').querySelector('option:last-child').disabled = project.kind === 'remote';
|
||||
closeMenu();
|
||||
$('project-settings').showModal();
|
||||
}
|
||||
if (button.dataset.tab) {
|
||||
projectTab = button.dataset.tab;
|
||||
renderOverview();
|
||||
$('desktops').scrollTop = 0;
|
||||
}
|
||||
if (button.dataset.open) openSession(Number(button.dataset.open));
|
||||
if (button.dataset.scope === 'all') { scope = 'all'; renderOverview(); }
|
||||
else if (button.dataset.scope) {
|
||||
const project = button.dataset.scope;
|
||||
const session = sessions.find((s) => s.id === remembered[project]) ||
|
||||
sessions.filter((s) => s.project === project).sort((a, b) => b.lastActivity - a.lastActivity)[0];
|
||||
if (session) openSession(session.id); else createSession(project);
|
||||
}
|
||||
if (button.dataset.filter) { filter = button.dataset.filter; renderOverview(); }
|
||||
if (button.dataset.resolve) {
|
||||
const session = sessions.find((s) => s.id === current);
|
||||
const declined = button.dataset.resolve === '暂不部署';
|
||||
session.state = declined ? 'idle' : 'running';
|
||||
session.lastActivity = ++activityOrder;
|
||||
session.detail = declined ? '已暂缓部署,等待后续安排。' : `已确认${button.dataset.resolve},正在继续执行。`;
|
||||
session.answer = session.detail;
|
||||
render();
|
||||
notify(declined ? '已暂缓本次部署' : '已处理,工作继续进行');
|
||||
}
|
||||
});
|
||||
$('desktops').addEventListener('pointerover', (event) => {
|
||||
if (event.pointerType === 'touch') return;
|
||||
const button = event.target.closest('[data-scope]');
|
||||
if (!button || button.dataset.scope === scope) return;
|
||||
scope = button.dataset.scope;
|
||||
renderOverview();
|
||||
});
|
||||
$('project-search').addEventListener('input', () => { renderOverview(); $('desktops').scrollTop = 0; });
|
||||
$('project-tabs').addEventListener('keydown', (event) => {
|
||||
const tabs = [...$('project-tabs').querySelectorAll('[data-tab]')];
|
||||
const index = tabs.indexOf(document.activeElement);
|
||||
if (index < 0 || !['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return;
|
||||
event.preventDefault();
|
||||
const next = event.key === 'Home' ? 0 : event.key === 'End' ? tabs.length - 1 : (index + (event.key === 'ArrowRight' ? 1 : -1) + tabs.length) % tabs.length;
|
||||
tabs[next].focus();
|
||||
tabs[next].click();
|
||||
});
|
||||
$('settings-form').onsubmit = (event) => {
|
||||
event.preventDefault();
|
||||
const project = projects.find((p) => p.id === settingsProjectId);
|
||||
const name = $('settings-name').value.trim();
|
||||
const path = $('settings-path').value.trim();
|
||||
if (!name || !path) return;
|
||||
if (project.kind !== 'channel') project.name = name;
|
||||
project.rootPath = path;
|
||||
project.description = $('settings-description').value;
|
||||
project.defaultMode = $('settings-mode').value;
|
||||
project.runtime = $('settings-runtime').value;
|
||||
render();
|
||||
$('project-settings').close();
|
||||
};
|
||||
$('close-settings').onclick = $('cancel-settings').onclick = () => $('project-settings').close();
|
||||
$('project-settings').addEventListener('close', () => {
|
||||
showOverview(settingsProjectId);
|
||||
overview.querySelector(`[data-settings="${settingsProjectId}"]`)?.focus();
|
||||
});
|
||||
$('workspace-trigger').onclick = () => showOverview(sessions.find((s) => s.id === current).project);
|
||||
$('activity-trigger').onclick = () => showOverview('all', $('activity-trigger'));
|
||||
$('close-overview').onclick = () => { closeMenu(); menuTrigger?.focus(); };
|
||||
$('simulate').onclick = () => {
|
||||
const session = sessions.find((s) => s.state === 'running' && s.id !== current) || sessions.find((s) => s.state === 'running');
|
||||
if (!session) return;
|
||||
const visible = session.id === current && !overview.matches(':popover-open') && !document.hidden;
|
||||
session.state = visible ? 'idle' : 'done';
|
||||
session.lastActivity = ++activityOrder;
|
||||
session.detail = visible ? '结果已查看,可以继续这项工作。' : '工作已完成,结果等待查看。';
|
||||
session.answer = '本轮工作已完成。\n\n示例结果:检查项已通过,相关说明已整理。你可以继续追问,或者切换到下一项工作。';
|
||||
render();
|
||||
notify(`${projectOf(session).name} / ${session.title} 已完成`);
|
||||
};
|
||||
overview.addEventListener('toggle', (event) => {
|
||||
const open = event.newState === 'open';
|
||||
$('workspace-trigger').setAttribute('aria-expanded', String(open));
|
||||
$('activity-trigger').setAttribute('aria-expanded', String(open));
|
||||
if (open) return;
|
||||
const session = sessions.find((s) => s.id === current);
|
||||
if (session.state === 'done') { session.state = 'idle'; session.detail = '结果已查看,可以继续这项工作。'; render(); }
|
||||
});
|
||||
$('theme').onclick = () => {
|
||||
const dark = document.documentElement.dataset.theme !== 'dark';
|
||||
document.documentElement.dataset.theme = dark ? 'dark' : 'light';
|
||||
$('theme').textContent = dark ? '切换浅色' : '切换深色';
|
||||
};
|
||||
function createSession(project) {
|
||||
const id = Math.max(...sessions.map((s) => s.id)) + 1;
|
||||
sessions.push({ id, project, title: '新的工作', state: 'idle', detail: '等待你的第一条消息。', prompt: '开始一项新的工作。', answer: '在下方输入你的想法。' });
|
||||
openSession(id);
|
||||
$('message').focus();
|
||||
}
|
||||
$('new-chat').onclick = () => createSession(sessions.find((s) => s.id === current).project);
|
||||
$('menu-new-chat').onclick = () => createSession(scope === 'all' ? sessions.find((s) => s.id === current).project : scope);
|
||||
$('new-project').onclick = () => {
|
||||
$('project-form').hidden = !$('project-form').hidden;
|
||||
$('new-project').setAttribute('aria-expanded', String(!$('project-form').hidden));
|
||||
if (!$('project-form').hidden) $('project-input').focus();
|
||||
};
|
||||
$('cancel-project').onclick = () => {
|
||||
$('project-form').hidden = true;
|
||||
$('new-project').setAttribute('aria-expanded', 'false');
|
||||
$('new-project').focus();
|
||||
};
|
||||
$('project-form').onsubmit = (event) => {
|
||||
event.preventDefault();
|
||||
const name = $('project-input').value.trim();
|
||||
if (!name) { $('project-input').focus(); return; }
|
||||
const id = `project-${projects.length + 1}`;
|
||||
projects.push({ id, name, kind: 'local', location: '本地工作空间', rootPath: `D:/workspace/${id}`, description: '', defaultMode: 'ask', runtime: 'OpenCode' });
|
||||
scope = id;
|
||||
$('project-form').reset();
|
||||
$('project-form').hidden = true;
|
||||
$('new-project').setAttribute('aria-expanded', 'false');
|
||||
createSession(id);
|
||||
};
|
||||
$('composer').onsubmit = (event) => {
|
||||
event.preventDefault();
|
||||
const text = $('message').value.trim();
|
||||
if (!text) return;
|
||||
const session = sessions.find((s) => s.id === current);
|
||||
if (session.state === 'running' || session.state === 'waiting') { notify('请先完成当前模拟执行或处理待办,再发送消息。'); return; }
|
||||
session.prompt = text;
|
||||
session.state = 'running';
|
||||
session.lastActivity = ++activityOrder;
|
||||
session.detail = '正在处理你的消息。';
|
||||
session.answer = '已开始模拟执行。你可以切换工作空间,然后点击页面上方“模拟后台完成”查看通知效果。';
|
||||
$('message').value = '';
|
||||
render();
|
||||
};
|
||||
function reset() {
|
||||
projects.splice(0, projects.length, ...structuredClone(initialProjects));
|
||||
scope = 'all';
|
||||
projectTab = 'local';
|
||||
$('project-search').value = '';
|
||||
filter = 'all';
|
||||
$('project-form').reset();
|
||||
sessions = structuredClone(initial);
|
||||
sessions.forEach((session, index) => { session.lastActivity = initial.length - index; });
|
||||
activityOrder = initial.length;
|
||||
current = 1;
|
||||
Object.keys(remembered).forEach((key) => delete remembered[key]);
|
||||
Object.keys(drafts).forEach((key) => delete drafts[key]);
|
||||
remembered.buddy = 1;
|
||||
closeMenu();
|
||||
$('message').value = '';
|
||||
render();
|
||||
}
|
||||
$('reset').onclick = reset;
|
||||
document.addEventListener('keydown', (event) => {
|
||||
if ($('project-settings').open) return;
|
||||
if ((event.ctrlKey || event.metaKey) && event.shiftKey && event.code === 'KeyK') {
|
||||
event.preventDefault();
|
||||
if (overview.matches(':popover-open')) { closeMenu(); menuTrigger?.focus(); } else showOverview();
|
||||
}
|
||||
if (event.key === 'Escape' && overview.matches(':popover-open')) {
|
||||
event.preventDefault(); closeMenu(); menuTrigger?.focus();
|
||||
}
|
||||
});
|
||||
window.addEventListener('resize', () => { if (overview.matches(':popover-open')) positionMenu(); });
|
||||
reset();
|
||||
@@ -76,6 +76,8 @@ WAL 增长与 SQL 全历史扫描次数的定向验证见 [BUILD.md](../../../BU
|
||||
`createdAt` 最大值;摘要的 `latestMessageAt` 在已有聚合查询中取 `MAX(created_at)`,
|
||||
与摘要上附带的实时消息取最大值。该字段仅用于桌面只读投影,不增加数据库列或持久化副本。
|
||||
`updatedAt`/`updated_at` 继续随流式事件更新,原有快照合并、终态优先与逐消息保存规则不变。
|
||||
`useConversationListOrder` 在内存中保留展示 ID 顺序,以单个 5 秒计时器应用自动排序,
|
||||
并按上述交互规则暂缓重排;行数据始终使用最新会话对象,不延迟保存或同步。
|
||||
- 本机和托管 SSH 的 Renderer/Main 读取、持久化与恢复投影共用以上实现。Agent 的
|
||||
transcript、原生 Session、权限及模型桥协议未改变;完整列表、摘要和详情都附带
|
||||
Main 当前恢复请求的问题投影。
|
||||
|
||||
@@ -393,7 +393,7 @@ Renderer 不接收任意系统 PID 控制能力。控制动作引用 Main 签发
|
||||
|
||||
当前项目目录的文件浏览与 Git 交互遵循以下规则:
|
||||
|
||||
- FR-W1:顶部同一行放置“文件 / Git 工作区”视图切换和右侧刷新;非 Git 目录仍保留刷新。
|
||||
- FR-W1:顶部同一行放置“文件 / Git 工作区”视图切换和右侧刷新;非 Git 目录仍显示选中的“文件”入口和刷新,仅隐藏“Git 工作区”。
|
||||
下一行只提供当前视图的工具:文件视图提供新建文件、新建目录,Git 视图提供分支、Fetch 和更改显示方式,
|
||||
不在 Git 视图重复目录导航或新建文件操作。操作使用带名称和提示的紧凑图标按钮。
|
||||
文件视图在根目录不显示孤立的文件夹图标或空路径行;进入子目录后提供“项目目录名 / 子目录”
|
||||
@@ -401,6 +401,7 @@ Renderer 不接收任意系统 PID 控制能力。控制动作引用 Main 签发
|
||||
长目录名单行省略,完整路径通过提示查看;多级路径在导航内部横向滚动,不撑宽工作栏。
|
||||
选中目录时在该目录创建,选中文件时在其父目录创建,未选择时在当前浏览目录创建。
|
||||
无可用目录时隐藏无关操作。
|
||||
文件列表独立纵向滚动;顶部视图切换、刷新、新建操作和子目录路径导航保持可见,不随列表滚走。
|
||||
- 使用共享 `SegmentedControl` 切换“文件”和“Git 工作区”,默认显示文件;
|
||||
选择只在当前面板挂载期间保存在内存中,不持久化。未提交更改包含当前项目的全部
|
||||
Git 更改,不按 AI、用户或其他工具的修改来源筛选。
|
||||
@@ -447,6 +448,7 @@ Renderer 不接收任意系统 PID 控制能力。控制动作引用 Main 签发
|
||||
- 文件预览提供复制项目相对路径、刷新,以及仅本机项目可用的“使用默认应用打开”。
|
||||
刷新失败保留此前内容并显示错误;Markdown 默认渲染预览,可切换源码。
|
||||
- Diff 提供刷新、复制项目相对路径和“查看当前文件”;删除项不显示查看当前文件入口。
|
||||
文件路径与操作按钮位于分隔线上方,空间不足时换行;只有未暂存差异时省略“工作树更改”标题,同时存在已暂存差异时保留分区标题。
|
||||
- 手动刷新或任务结束更新 Git 状态时,同步重读根目录、当前浏览目录和已展开目录并去重,保留目录展开状态,
|
||||
刷新失败保留已有目录列表;重新展开目录时重读。目录读取错误在对应目录旁显示,
|
||||
可就地重试。正在查看的单文件 Diff 同时重读,旧请求不得覆盖新结果。
|
||||
@@ -980,12 +982,14 @@ todo/plan,本次不为其增加工具。也不增加 plan 工作模式、计
|
||||
布局遵循 [UI-DESIGN](../../../UI-DESIGN.md#610-应用顶栏与全局操作)。
|
||||
|
||||
默认折叠为一行“执行清单 · 已完成 m/n”,有进行中条目时附简短摘要;展开后按原生顺序
|
||||
显示内容、状态及原生提供的优先级。行内状态使用图标和“待处理 / 进行中 / 已完成 / 已取消”文字,
|
||||
在摘要按钮旁的浮层显示内容、状态及原生提供的优先级,不推移或压缩下方消息流。
|
||||
行内状态使用图标和“待处理 / 进行中 / 已完成 / 已取消”文字,
|
||||
不用可勾选 Checkbox,也不根据位置推测优先级或进行中状态。请求结束后的摘要附真实
|
||||
请求结果,例如“已取消”,不改变条目状态。后台更新不强制展开、不抢焦点。
|
||||
|
||||
展开按钮提供 `aria-expanded` 和 `aria-controls`,支持 Enter/Space,折叠后焦点留在
|
||||
按钮;实时区域只播报摘要变化。复用全局颜色、间距和字体令牌,浅深主题均可读。窄窗口
|
||||
展开按钮提供 `aria-expanded` 和 `aria-controls`,支持 Enter/Space,展开后焦点进入清单。
|
||||
Escape 收起并返回按钮;点击外部或焦点移出也收起。实时区域只播报摘要变化。
|
||||
复用全局颜色、间距和字体令牌,浅深主题均可读。窄窗口
|
||||
摘要截短、展开内容换行并有界滚动,保留完成计数、请求结果与输入区;200% 缩放时仍可
|
||||
操作。没有清单或收到有效空清单时整行隐藏,历史内容仍按消息归属查看。
|
||||
|
||||
|
||||
@@ -1,5 +1,107 @@
|
||||
# 工作栏实现与验证进度
|
||||
|
||||
## 2026-10-01:自定义 MCP 失败中断回复
|
||||
|
||||
已修复共用网关将单个上游发现失败升级为整轮失败的问题,行为见
|
||||
[本地 MCP 初始化失败恢复](./runtime-process-reuse-technical-design.md#41-本地-mcp-初始化失败恢复)。
|
||||
正常服务的连接继续复用,失败服务不进入工具列表,动态刷新失败也按服务隔离;
|
||||
取消和授权撤销继续生效。初次失败的服务在下一请求重试,原有连接超时未改。
|
||||
|
||||
验证使用真实 HTTP 上游和网关,覆盖部分失败、无可用工具、非法分页和超量目录、
|
||||
动态刷新失败后恢复、取消及撤销。Continue 使用真实网关和模拟 Host;新增实际 OpenCode
|
||||
二进制用例在自定义 MCP 返回 HTTP 503 后完成 Execute 回复,单次本地合成模型调用,
|
||||
外部模型调用 0 次。网关与 Continue 定向测试 65 项通过;OpenCode 生命周期 4 项通过。
|
||||
`npm run typecheck`、四个修改源码文件的 ESLint 通过。
|
||||
|
||||
完整 `npm test`:5,565 通过、89 跳过、4 失败。失败为 Agent 大依赖清单安装超时、
|
||||
Magic Notes 采集的 `memo is not defined`,以及终态探针和手动压缩的界面等待超时。
|
||||
全仓 lint 在并行修改的 `App.tsx` 性能测量代码处报告渲染纯度及不可变性错误;
|
||||
本次未修改这些文件,不将完整验证记为通过。
|
||||
远程 Agent 的独立 MCP 实现及 Story Graph 绑定不经过改动方法,本次无需远程 Host 验证。
|
||||
尚未发布或替换已安装应用。
|
||||
|
||||
## 2026-10-01:本地 MCP 晚到初始化覆盖新连接
|
||||
|
||||
根因修复见[本地 MCP 初始化失败恢复](./runtime-process-reuse-technical-design.md#41-本地-mcp-初始化失败恢复)。
|
||||
已核对随包 OpenCode 1.18.29:`MCP.add` 的初始化不随客户端取消而中止,完成后写入同名状态,
|
||||
并关闭该名称下已有的连接;`disconnect` 只处理当时已登记的客户端。旧实现在 `mcp.add`
|
||||
超时或取消后仍复用同一名称,前一轮的晚到初始化(授权已撤销,结果为 `failed`)会覆盖下一轮
|
||||
刚建立的连接。现在响应不确定的名称作废,后续改用唯一替代名称。
|
||||
|
||||
验证:新增真实 OpenCode+实际网关用例,通过本地代理挂起第一次 MCP `initialize`,
|
||||
使第一轮在 3 秒控制超时后结束,第二轮建立连接后再放行挂起的初始化。修复前该用例
|
||||
复现为第二轮连接状态从 `connected` 变为 `failed`;修复后第二轮连接保持 `connected`
|
||||
且请求完成。`opencode-runtime`、`opencode-runtime-lifecycle`、`knowledge-mcp-gateway`
|
||||
三个测试文件 180 项通过;`npm run typecheck` 通过;相关文件 ESLint 通过。模型为本地合成
|
||||
服务,外部模型调用 0 次。9 月 30 日那次失败的原始时序没有底层日志,未确认就是这一竞态;
|
||||
本次修复的是已复现的同类失败路径。尚未发布或替换已安装应用。
|
||||
|
||||
|
||||
## 2026-09-30:本地 MCP 连接诊断与恢复
|
||||
|
||||
已实现[本地 MCP 初始化失败恢复](./runtime-process-reuse-technical-design.md#41-本地-mcp-初始化失败恢复)。
|
||||
常驻 HTTP 网关和请求级授权保留;内置及自定义 MCP 对明确的暂时性传输故障最多重试一次,
|
||||
必须先成功清理失败连接。SDK 返回错误和抛出错误均有覆盖,未知失败、HTTP/API 拒绝和
|
||||
鉴权错误不重试。界面保留脱敏错误详情,两端失败诊断通过令牌摘要关联。
|
||||
网关初始化异常也会回收未完成会话。
|
||||
|
||||
最终定向验证:`npx vitest run` 指定 `opencode-runtime`、`opencode-runtime-lifecycle`、
|
||||
`opencode-runtime-permissions`、`knowledge-mcp-gateway`、`knowledge-mcp-gateway.obsidian`、
|
||||
`create-runtime`、`desktop-diagnostics`、`feedback-service` 八个测试文件,222 项全部通过。
|
||||
`npm run typecheck` 和 `npm run lint` 通过。真实 OpenCode 与实际网关的恢复用例在连接成功后
|
||||
注入响应丢失,验证断开先于重连、授权不变、请求完成和网关继续监听;该用例每轮产生一次
|
||||
本地合成模型请求,外部模型调用为 0。另有真实 HTTP 初始化连续失败后的会话回收测试。
|
||||
|
||||
全量 `npm test` 在 600 秒后超时,未取得汇总;超时前报告 `SettingsPanel.test.tsx` 中
|
||||
中英文磨砂玻璃开关两个用例失败。这些文件存在并行修改,未判定失败归因,全量验证不记为通过。
|
||||
本次未发布或替换已安装应用。远程 Agent 的 ACP 和 Agent MCP 连接路径未修改,
|
||||
不将本地测试结果作为远程恢复验证。
|
||||
|
||||
## 2026-09-30:OpenCode 终端启动准备
|
||||
|
||||
原先每次“在终端中打开” OpenCode 都会把随包插件依赖目录(约 3,600 个文件、52 MB)
|
||||
复制到临时目录,Windows 本机每次约 2.2–2.4 秒。现在该目录只复制一次到用户数据下的
|
||||
`terminal/opencode-config`,按 `.goodbuddy-ready.json` 判断是否需要刷新;Skills、Ask
|
||||
插件和模型配置仍按每次启动单独生成,关闭时清理。
|
||||
|
||||
Windows 本机实测启动准备:首次 2.9 秒,之后 19–44 ms。OpenCode 自身 TUI 从进程启动
|
||||
到显示输入框约 2.9–3.3 秒,由 OpenCode 程序决定,本次未改变。验证:原生终端单元测试
|
||||
5 项通过;已安装 OpenCode 的 Ask/Execute 真实 PTY 测试 2 项通过(合成模型,真实模型
|
||||
调用 0 次)。远程 Agent 使用独立的 OpenCode 启动路径,不受本次修改影响。
|
||||
|
||||
## 2026-09-28:消息取消与失败展示
|
||||
|
||||
消息终态按[Runtime 交互边界](./runtime-interactions.md)保存和展示。取消使用结构化
|
||||
`terminalStatus`,保持已有回答;终止提示不再补入正文。“重新编辑”只回填并聚焦输入框,
|
||||
没有可回填文字时隐藏入口。旧快照只对精确重复的形态隐藏状态副本,保留原正文。
|
||||
深色主题已移除覆盖失败提示颜色的通用规则;检索和压缩记录不再屏蔽终态的无障碍角色。
|
||||
|
||||
Windows 本地验证:
|
||||
|
||||
- 本次修复完成时 `npm run typecheck`、`npm run lint`、`git diff --check` 通过。
|
||||
后续并行的应用入口修改新增 `App.test.tsx:14430` 的 `getByRole(..., { exact: true })`,
|
||||
最后一次类型检查报 TS2769;该选项不属于 `ByRoleOptions`。未修改这处他人新增代码,
|
||||
当前组合工作区不能记为类型检查通过。最后一次 lint 和 diff 检查通过。
|
||||
- `npm test`:5,367 通过、84 跳过、1 失败。失败项为
|
||||
`tests/magic-notes-layout.electron.test.ts` 的原生拖拽测试,90 秒超时;单独复跑仍超时。
|
||||
该文件未修改,本次检查不记为全量通过。
|
||||
- 相关五文件复跑为 447 通过、14 失败;14 项均在并行入口改名期间查找“应用”按钮失败。
|
||||
入口源码更新后,单独复跑 `src/renderer/src/App.test.tsx` 的 304 项全部通过。
|
||||
其余四文件(数据库、时间线、清单和终态 Electron 探针)的 157 项在上述复跑中通过。
|
||||
- `tests/terminal-status-probe.electron.test.ts` 通过真实 App、Preload、生产 IPC、
|
||||
直连 Runtime 和 SQLite 验证空回答取消、部分回答取消、HTTP 400 失败;检查提示唯一性、
|
||||
浅深主题 computed style、重新编辑回填与焦点,以及数据库重开和页面恢复。
|
||||
每轮使用 3 次本地确定性 HTTP 请求,外部模型调用 0 次。探针在结束时关闭 Runtime、
|
||||
HTTP 服务和窗口,并删除本轮独立临时目录。
|
||||
- 数据库回归覆盖恢复事件事务回滚、失败与取消重放、空正文和已有回答保留、重开后元数据。
|
||||
App 和时间线回归覆盖新事件结构化判定、旧消息精确去重、正常正文保留、无效按钮隐藏、
|
||||
活动及工具终态。
|
||||
|
||||
本次未连接远程主机,未创建远程资源。远端事件仍通过既有公共事件进入桌面的
|
||||
`reduceRecoveredAgentEvent`,本次仅修改其消息投影与元数据保存,不改变 Agent、SSH
|
||||
传输、取消执行、ACK 或重连控制;本地 SQLite 恢复测试覆盖该修改。未验证 Linux/macOS
|
||||
原生界面,不把本地探针记为远端取消生命周期验收。
|
||||
|
||||
## 2026-09-28:DS Web 资源符号链接与 macOS 发布恢复
|
||||
|
||||
Desktop `v0.15.0` 原生构建中,两个 macOS 目标均因包外符号链接未通过签名校验。
|
||||
|
||||
@@ -110,7 +110,9 @@ No SQLite schema migration or separate checklist store is needed.
|
||||
|
||||
`ConversationTaskStrip` 与 `RuntimeChecklistStrip` 共用顶部容器,任务在上、清单在下,
|
||||
各自展开和折叠。没有 Task 时清单仍可显示;远程会话也保留清单入口。总高度不超过
|
||||
45vh,各自展开内容不超过 20vh 并内部滚动,标题不收缩。清单内容区可用键盘聚焦和滚动。
|
||||
45vh,Task 展开内容不超过 20vh 并内部滚动,标题不收缩。清单详情复用 `AnchoredMenu`
|
||||
的 `group` 语义和 `FloatingPortal`,宽度跟随摘要按钮并受视口约束,高度按可用空间限制,
|
||||
内部可用键盘滚动。展开不改变顶部容器或消息流的高度;点击外部、焦点移出或 Escape 收起。
|
||||
|
||||
状态文字与条目首行按文字基线对齐,状态图标单独居中。CN 不推断进行中或优先级。
|
||||
新轮先选择本轮消息,再读取清单,不回溯寻找旧轮非空清单。后台更新保持展开状态和焦点。
|
||||
|
||||
@@ -100,6 +100,16 @@ DeepSeek Harness 按调用 ID 保留名称、输入和摘要,结束事件省
|
||||
为静态,不凭请求尚未结束暗示仍有进展;会话列表闪点和发送、停止按钮保持原有行为。
|
||||
持久化快照中的同一条消息仍为生成中时,也不能覆盖本地已收到的终态,即使快照的
|
||||
会话更新时间更晚;否则连续发送或刷新会让已结束的请求重新显示停止按钮。
|
||||
- 失败和取消的终止提示只进入消息状态,不补写正文或文本块,已有回答、推理与工具输出
|
||||
保留。取消使用中性色和礼貌级 `status`,真实失败使用危险色和 `alert`;运行记录的
|
||||
取消结果标题为“任务已取消”。工具详情已表达同一失败时,不再复制到消息底部。
|
||||
- 最新失败或取消消息只有在上一条用户消息含可回填文字时显示“重新编辑”。点击只回填
|
||||
输入框并聚焦,由用户修改后自行发送;附件单独发送且无文字时不显示此按钮。
|
||||
- 实时事件和 Main 历史恢复均将事件的 `failed / cancelled` 写入消息元数据
|
||||
`terminalStatus`;既有 `state: error` 继续表示未成功终态,不改 SQLite 枚举。
|
||||
新事件不按提示文案识别取消。旧快照无此字段时,仅兼容已发布的精确取消标签。
|
||||
旧记录若正文与状态完全相同,且无文本块或唯一文本块位于末尾并等于该状态,展示时
|
||||
保留正文、隐藏重复状态;不修改持久数据,不删除正文中的同名句子或部分匹配内容。
|
||||
- Continue、DeepSeek Harness 和远端 ACP 保留其已接入的处理与工具事件,不伪造未接入
|
||||
的原生重试状态。直连模型的重试等待与请求开始文案见
|
||||
[直连模型 UI 设计](../direct-model-agent/ui-design.md#6-错误与通知);本次不改变任何
|
||||
|
||||
@@ -18,7 +18,7 @@ Status: Local shortcut/coordinator paths and configured-model responses verified
|
||||
| --- | --- | --- | --- |
|
||||
| Continue | 在终端中打开 | 工作栏新终端页签,启动原生 CLI | 本地和托管 SSH 项目 |
|
||||
| OpenCode | 在终端中打开 | 工作栏新终端页签,启动原生 TUI | 本地和托管 SSH 项目 |
|
||||
| DeepSeek Harness | 在浏览器中打开 | 系统默认浏览器,访问官方 Web 应用 | 本地项目 |
|
||||
| DeepSeek Harness | 打开DSH Web | 系统默认浏览器,访问官方 Web 应用 | 本地项目 |
|
||||
| 内置模型 | 不显示此快捷入口 | 无 | 保持现状 |
|
||||
|
||||
DS 采用官方 `@deepseek-ai/dsh` Web 应用,不开发新的 DS 终端前端,也不在首轮嵌入
|
||||
@@ -105,7 +105,8 @@ Task 关联恢复或客户端历史导入。原生客户端产生的历史由对
|
||||
活动记录中显示为已经同步的聊天内容。
|
||||
|
||||
FR-8 的实施清单须区分原生工具、GoodBuddy 分配的 Skills、自定义 MCP、内置 MCP、知识工具
|
||||
以及需要 GoodBuddy 原生确认的配置工具。支持情况以真实调用为准;现有 DS 宿主插件能力不能
|
||||
以及遵循当前工作模式授权的配置工具。配置应用不再额外要求 GoodBuddy 原生确认,Ask 仍只读。
|
||||
支持情况以真实调用为准;现有 DS 宿主插件能力不能
|
||||
直接算作 DS Web 能力。发现现有分配无法承接时,在实施评审中明确差异和处理方式,不能默默忽略。
|
||||
|
||||
## 4. 场景与状态规则
|
||||
@@ -163,7 +164,7 @@ DS 后台的 `read` 和原生 `skill` 可在 Ask 执行;`write`、`edit`、`pw
|
||||
|
||||
## 5. 界面设计
|
||||
|
||||
输入区下方从左到右排列“在终端中打开/在浏览器中打开”“压缩上下文”和现有用量信息。
|
||||
输入区下方从左到右排列“在终端中打开/打开DSH Web”“压缩上下文”和现有用量信息。
|
||||
两个按钮独立判断可用性。采用同一轻量操作组,避免两组绝对定位相互覆盖;窄输入区允许
|
||||
操作组换行,保留模式说明、用量信息和对称边距。稳定预留元信息高度的实现应与
|
||||
[统一 UI 规范](../../../UI-DESIGN.md)一起更新并验证。
|
||||
@@ -172,8 +173,9 @@ DS 后台的 `read` 和原生 `skill` 可在 Ask 执行;`write`、`edit`、`pw
|
||||
进入新终端;外部浏览器由系统处理焦点。启动期间保留按钮宽度并显示“正在打开…”,完成后
|
||||
恢复原文案。缺少项目目录时在相关位置提供选择目录的操作;其他异步失败进入全局通知视口。
|
||||
|
||||
DS 服务运行后,在入口旁提供轻量菜单“重新打开”“停止此服务”;操作指向当前匹配服务。
|
||||
若正在生成,停止前说明会中断该服务中的请求。输入区不常驻显示独立会话和服务生命周期说明。不在每次启动时增加确认弹窗。
|
||||
DS 服务运行后,打开入口仍显示“打开DSH Web”,旁边显示“停止DSH服务”;操作指向当前匹配服务。
|
||||
点击停止后,在同一操作行追加紧凑的“确认”“取消”按钮,不再展开整行警告。窄窗口下确认和取消作为一组换行,两个按钮不拆行。
|
||||
确认按钮的悬停提示和无障碍描述说明网页会断开、进行中的请求会中断;执行时由确认按钮显示“正在停止…”。输入区不常驻显示独立会话和服务生命周期说明。不在每次启动时增加确认弹窗。
|
||||
|
||||
按钮快捷入口根据所选 Runtime 显示,不改变工作栏“+”中的普通终端和浏览器入口。已有服务
|
||||
的停止入口应在切换回来后仍可找到。
|
||||
|
||||
@@ -177,6 +177,38 @@ SelectedRuntimeManager
|
||||
健康检查也读取 owner 的总 activeRequests,不能从一个空闲 adapter 判断整个 Server
|
||||
无任务后重启。异常退出使该 owner 的全部绑定失效,明确结算受影响请求,不重放已执行工具。
|
||||
|
||||
### 4.1 本地 MCP 初始化失败恢复
|
||||
|
||||
桌面 HTTP 工具网关保持监听,MCP 注册和授权仍按请求创建、清理。本地 OpenCode 的内置
|
||||
工具和自定义 MCP 共用连接逻辑:仅对明确的连接拒绝、连接重置、断管或传输超时,在
|
||||
一秒内成功断开失败连接后重试一次。SDK 返回的无 HTTP response 网络错误和抛出的网络
|
||||
错误采用同一判定;HTTP/API 错误、鉴权或协议错误、未知失败不自动重试。
|
||||
取消立即终止后续尝试。Prompt 和工具调用不重放。
|
||||
|
||||
自定义 MCP 上游发现由 `KnowledgeMcpGateway.getCustomMcpBindings` 按服务隔离失败。
|
||||
某个服务连接、列举或校验工具失败时,记录脱敏诊断并跳过该服务,保留正常服务的连接;
|
||||
全部失败时返回空的自定义工具列表,Continue 和内置 OpenCode 仍可开始回复。
|
||||
首次发现的结果在当前授权内复用,初次失败的服务在下一次请求创建授权时重试。
|
||||
动态刷新失败会清空该服务的工具列表,下次发现时再刷新,不阻断其他服务。
|
||||
请求取消和授权撤销仍终止操作,工具总数和名称冲突检查保留。发现仍等待连接尝试结束,
|
||||
因此连接超时可能延迟首个回复;失败隔离不等于后台加载。
|
||||
|
||||
OpenCode 1.18.x 的 `mcp.add` 在服务端独立执行:客户端超时或取消不会中止初始化,
|
||||
`disconnect` 也只清理当时已登记的连接。晚到的初始化完成后会按名称写入状态,并关闭同名
|
||||
的现有连接。因此,`mcp.add` 未得到 HTTP 响应(抛错、超时、取消或无 response 的网络错误),
|
||||
或断开未确认成功时,该名称即作废;同一槽位之后改用本客户端内唯一的替代名称
|
||||
(前缀加 8 位 base-36 序号,仍为 12 个字符)。槽位随请求结束释放,不因断开失败而长期占用。
|
||||
已得到响应的正常失败继续复用原名称。
|
||||
|
||||
界面显示有界、脱敏的失败详情。`runtime.mcp.failed` 诊断记录连接或断开阶段、失败类别、
|
||||
耗时、尝试次数、内置或自定义类型及可用的 HTTP 状态;通过本轮令牌的 SHA-256 摘要与
|
||||
网关鉴权、初始化和工具发现失败记录关联。日志不保存令牌、地址、请求正文或原始错误。
|
||||
网关初始化抛错时也清理未完成会话,防止失败会话累积。
|
||||
|
||||
此恢复逻辑属于本地 `OpenCodeRuntime`。远程 Agent 使用 `RuntimeAcpBackend` 和
|
||||
`AgentImageToolMcp`,不经过本地 MCP 注册或网关 HTTP 处理;本节不代表远程恢复能力。
|
||||
远程 Story Graph 通过网关的独立绑定调用,不经过上述自定义 MCP 发现方法。
|
||||
|
||||
## 5. DeepSeek Harness
|
||||
|
||||
### 5.1 共享 Host 的最小改法
|
||||
|
||||
@@ -182,7 +182,7 @@ stdio fallback。首期远程终端随 SSH Channel 结束,不提供跨 SSH 或
|
||||
- 每个实例使用独立 `tab` 和 `tabpanel`。
|
||||
- “+”固定在 Tab 行末尾,位于 `tablist` 和页签滚动容器之外,占用独立宽度。
|
||||
- Tab 过多时实例区域单行横向滚动,不换行、不自动隐藏实例。“+”在 `300px` 工作栏内也保持可见和可点击。
|
||||
- 标签全部可见时隐藏左右滚动按钮;溢出时左右箭头透明浮动于页签区域两端,不占标签布局宽度,也不进入“+”的点击区域。鼠标移入标签栏时显示可滚动方向的箭头,移出后隐藏;键盘聚焦箭头时显示,无悬停能力的设备保持可滚动方向的箭头可见。悬停箭头时显示轻微底色。点击滚动约一屏,边界方向禁用并隐藏,且不拦截下方标签的指针操作。切换或新增激活标签时将其滚入可见区域。未激活标签使用低对比度灰蓝底色和细边框,激活标签保留亮底与强调色顶线。
|
||||
- 标签全部可见时移除左右滚动按钮及其占位;溢出时左右箭头在滚动视口两侧各占独立的 28px 宽度,不覆盖标签、关闭按钮或“+”。鼠标移入标签栏时显示可滚动方向的箭头,移出后隐藏;键盘聚焦箭头时显示,无悬停能力的设备保持可滚动方向的箭头可见。悬停箭头时显示轻微底色。点击滚动约一屏,边界方向禁用并隐藏;溢出期间隐藏和禁用均保留占位,避免布局跳动。切换或新增激活标签时将整个标签(含关闭按钮)滚入可见区域。未激活标签使用低对比度灰蓝底色和细边框,激活标签保留亮底与强调色顶线。
|
||||
- 终端 Tab 默认使用递增标题,例如“终端 1”“终端 2”;状态栏持续显示项目、Host、
|
||||
工作目录和 Shell。用户可以重命名终端。
|
||||
- 任务中心、工作区、成果及新建终端的默认标题随界面语言切换,恢复布局时也使用当前语言。
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
电脑控制包含 GoodBuddy 对浏览器和客户端桌面能力的产品实现与平台适配状态。
|
||||
|
||||
内置浏览器 MCP 默认开启,用户可在设置中关闭。已保存的开关选择在重启和升级后保留。
|
||||
|
||||
## 文档导航
|
||||
|
||||
| 文档 | 权威职责 |
|
||||
|
||||
@@ -2,11 +2,15 @@
|
||||
|
||||
监督者统一工作回顾、会话监督与故事线图谱,以智能心跳作为定时唤醒机制。现有智能心跳页面承载监督者工作回顾入口,手动回顾、定时结果投影、来源原文、实体/关系确认、继续讨论、知识库预览提交和侧栏反馈已经接入。历史回顾定位到对应结果图谱;侧栏按会话或任务查询,并支持固定目标。同 scope 的跨 run 实体复用依赖模型显式选择 Main 提供的候选引用,Main 处理 UUID 和旧文本主键的精确映射,具体规则和验证边界见[实施进度](./progress.md)。
|
||||
|
||||
监督者可在“设置 → 模型”选择已有文本模型或跟随应用默认,连接与凭据复用规则及执行期间的模型一致性见[监督者模型选择](./logic-design.md#监督者模型选择)。
|
||||
|
||||
## 文档导航
|
||||
|
||||
主页面包含工作回顾、故事线图谱、自动监督、活动记录和设置。工作回顾阅读统一监督结果并选择历史,图谱定位同一结果;自动监督管理计划、概览、自动监督报告、记忆与行动建议和心跳审计。活动记录展示统一执行、进度及详情,可按计划 ID 筛选;设置只保留算法参数。当前布局以 [UI 设计](./ui-design.md#应用入口)为准。活动读取实际手动回顾及心跳执行,合并展示同一次心跳的报告与监督回顾阶段;状态规则、历史缺失和保留期限见[活动记录](./logic-design.md#活动记录)。
|
||||
主页面包含工作回顾、故事线图谱、自动监督、活动记录和设置。工作回顾阅读统一监督结果并选择历史,图谱定位同一结果;自动监督管理计划、监督建议和只读的旧报告。活动记录展示统一执行、进度、建议阶段及详情,可按计划 ID 筛选;设置管理模型选择和算法参数。当前布局以 [UI 设计](./ui-design.md#应用入口)为准。活动读取实际手动回顾及心跳执行,合并展示同一次心跳触发的监督回顾与建议阶段;状态规则、历史缺失和保留期限见[活动记录](./logic-design.md#活动记录)。
|
||||
|
||||
自动心跳报告和监督回顾已接入按来源版本及正文位置的增量处理,无变化时跳过模型。手动历史回顾继续重新整理所选区间;进度、失败重试和删除边界见[自动增量与手动重看](./logic-design.md#自动增量与手动重看)。
|
||||
心跳的职责为“定时触发增量回顾+按配置介入(建议)”,不再先生成独立报告;手动“回顾”默认增量,历史重看改为显式“重新整理”。规则见[心跳职责与介入](./logic-design.md#心跳职责与介入目标设计),需求 FR-S12,验收 US-S32、US-S33,2026-09-30 已实现(主动介入除外),验证见[实施进度](./progress.md#2026-09-30-心跳职责收敛与监督建议)。
|
||||
|
||||
监督回顾按来源版本及正文位置增量处理,手动回顾与心跳共享进度,无变化时跳过模型;“重新整理”才重读所选区间。旧的进度、失败重试和删除边界见[自动增量与手动重看](./logic-design.md#自动增量与手动重看)。
|
||||
|
||||
| 文档 | 权威职责 |
|
||||
| --- | --- |
|
||||
@@ -18,6 +22,7 @@
|
||||
| [技术设计](./technical-design.md) | Main/Renderer 分层、数据模型、IPC、安全边界和实施顺序 |
|
||||
| [时态 Story Graph MCP 设计](./story-graph-mcp-design.md) | 只读工具、时间与版本语义、Runtime 接入,以及后续跨范围事实复用和记忆过渡合同 |
|
||||
| [回顾分块调度设计](./review-scheduling-design.md) | 第 0 节定义生产分页、批次持久化、续跑、当前设置及剩余边界;其余章节保留改造前基线、目标设计和原型证据 |
|
||||
| [故事线模型改造设计](./storyline-model-design.md) | 全局 › 项目 › 功能 › 子线索的故事层级、故事归属与经验抽取阶段、用户调整及实施顺序(目标设计) |
|
||||
| [实施进度](./progress.md) | 已验证实现、验证证据和当前边界 |
|
||||
| [故事线图谱设计](../story-graph/prd.md) | 图谱对象、环绕时间轴与实体交互 |
|
||||
|
||||
@@ -25,4 +30,4 @@
|
||||
|
||||
实施状态以代码和[实施进度](./progress.md)为准。逻辑待定项见[逻辑设计](./logic-design.md),架构边界见[技术设计](./technical-design.md)。现有心跳行为见[智能心跳](../smart-heartbeat/README.md),原型范围见[Story Graph 演示](../story-graph/README.md)。
|
||||
|
||||
2026-09-28:Agent 可按需通过三个只读 MCP 工具读取决定、演变和来源;监督者关闭后不暴露工具并拒绝旧会话调用。本地与远程接线及验证边界见[实施进度](./progress.md#2026-09-28-story-graph-只读工具)。当前 memory 读取、精确 scope 增量进度和候选身份保留;跨 scope 叶子复用、读取入口切换及 `as_of` 仍属后续能力。
|
||||
Agent 可按需通过三个只读 MCP 工具读取决定、演变和来源。会话设置中的“使用故事图谱”默认开启,仅在监督者应用启用时显示;关闭会话开关或应用后不暴露工具并拒绝旧会话调用。规则见 [MCP 接入合同](./story-graph-mcp-design.md#6-内置-mcp-与-runtime-接入),验证边界见[实施进度](./progress.md)。当前 memory 读取、精确 scope 增量进度和候选身份保留;跨 scope 叶子复用、读取入口切换及 `as_of` 仍属后续能力。
|
||||
|
||||
@@ -4,6 +4,12 @@
|
||||
|
||||
本文承接[监督者产品设计](./supervisor-prd.md)、[故事线图谱设计](../story-graph/prd.md)和[User Stories](./user-stories.md),定义触发、整理、展示与用户后续操作之间的关系。既有会话监督的证据和介入边界继续由[会话监督 PRD](./prd.md)约束。
|
||||
|
||||
## 监督者模型选择
|
||||
|
||||
对应 FR-S13。监督者可跟随应用默认文本模型,或固定使用一个已保存的文本模型连接;会话和项目的 Runtime 选择不影响它。提取、导航汇总、故事归属、经验整理及建议措辞均使用监督者的模型设置。
|
||||
|
||||
回顾获得执行位置后,读取一次连接配置;同次执行中的批次、输出重试、汇总、故事与经验阶段保持该模型。修改监督者选择、应用默认模型或连接配置,仅影响后续执行。继续暂停或失败的回顾、单独重试故事、生成或重试建议时重新读取当前选择;跨重启不保存模型或凭据快照。显式选择的连接被删除、改为图片协议或缺少所需 API Key 时,执行报错并提示修改设置,不自动换成默认模型。
|
||||
|
||||
## 整体逻辑架构
|
||||
|
||||
报告、监督叶子与导航整理共用监督专用 FIFO 并发池,普通聊天不入池;监督服务外层 run 仍串行。报告在排队前冻结超时,获槽后领取,实际模型请求前校验并刷新租约;报告提交或无变化后先释放槽位,再执行监督。监督超时在 run 创建时保存,节点获槽并解析 Runtime 后计时。阶段超时、租约、传输时限、后台检查与活动轮询各自独立,取值及生效规则见[当前设置合同](./review-scheduling-design.md#当前设置合同)。
|
||||
@@ -166,12 +172,57 @@ flowchart LR
|
||||
|
||||
工作回顾的手动操作重新整理所选区间,沿用有界历史收集,不读取或推进自动进度。自动监督计划列表中的“立即运行”重新生成心跳报告,其下游监督仍按自动进度处理。删除报告或计划不重置来源进度;重看历史、删除报告和重建图谱是不同操作。当前没有清空或重建图谱功能。
|
||||
|
||||
回顾默认增量,重看历史改为显式重新整理。
|
||||
|
||||
### 心跳职责与介入(目标设计)
|
||||
|
||||
对应 FR-S4、FR-S12,US-S27、US-S32、US-S33。本节为已确认的目标规则,尚未实现;当前代码仍先生成心跳报告再执行监督整理,实际进度以[实施进度](./progress.md)为准。“回顾”默认增量,重看历史改为显式“重新整理”。
|
||||
|
||||
### 心跳职责与介入(目标设计)
|
||||
|
||||
对应 FR-S4、FR-S12,US-S27、US-S32、US-S33。本节为已确认的目标规则,尚未实现;当前代码仍先生成心跳报告再执行监督整理,实际进度以[实施进度](./progress.md)为准。
|
||||
|
||||
回顾整理只有一套流程,手动和自动共用:确定范围 → 分页采集来源 → 分批整理 → 汇总 → 发布到 Story Graph。心跳不参与整理本身,只在其外增加两件事:
|
||||
|
||||
1. **定时触发**:计划到期时代替用户发起一次增量回顾。
|
||||
2. **介入**:回顾发布后,按计划配置的介入方式处理本次图谱变化。
|
||||
|
||||
```text
|
||||
触发(点击“回顾” / 计划到期)
|
||||
↓
|
||||
变化检查:比较来源版本与已处理位置,不调用模型
|
||||
↓ 无变化 → 记录“无新变化”,结束
|
||||
↓ 有变化
|
||||
增量整理:只处理新增、修订和未完成片段,关联已有实体(唯一读取原文的阶段)
|
||||
↓
|
||||
发布:写入 Story Graph,推进增量进度
|
||||
↓ 仅心跳触发
|
||||
介入:读取本次发布的图谱变化,按计划配置生成建议
|
||||
```
|
||||
|
||||
| 规则 | 内容 |
|
||||
| --- | --- |
|
||||
| H1 原文只读一次 | 同一来源版本在一次触发中只由整理阶段读取;介入阶段的输入是本次图谱变化及其来源引用,不重新读取原文 |
|
||||
| H2 不再生成独立报告 | 取消“心跳报告”阶段及其独立增量进度;旧报告只读保留,不再新建 |
|
||||
| H3 手动默认增量 | “回顾”按钮与心跳整理行为一致,跳过已处理且未变化的来源;手动触发不生成建议 |
|
||||
重新整理放在更多中,确认后重新分析所选时间区间;不推进也不重置自动进度,不覆盖用户确认内容 |“重新整理”放在“更多”中,确认后重新分析所选时间区间;不推进也不重置自动进度,不覆盖用户确认内容 |
|
||||
| H5 介入方式 | 计划字段:`仅更新记忆`、`生成建议`(新计划默认)、`主动介入`(会话级实时,暂不实现,不显示为可选项) |
|
||||
| H6 建议来源 | 候选仅来自本次图谱变化:批次中的未解决事项、无明确取代关系的分歧(contrasts 关系)、由至少三处不同原始来源支持的未确认实体(候选约定)、被修订的实体(revised 变化)。长期未推进的判定尚未实现 |
|
||||
| H7 建议成本 | 先按规则挑选候选,不调用模型;存在候选时才发起一次输入有界的措辞调用,输入仅为候选及其引用 |
|
||||
| H8 建议可信 | 每条建议显示依据并可打开来源;用户忽略后,只有出现新证据才再次提出;确认候选约定或偏好后才作为长期背景 |
|
||||
| H9 频率与成本 | 提高频率只增加变化检查次数;模型调用次数由实际变化决定:无变化 0 次,有变化无候选仅整理,有候选再加一次建议调用 |
|
||||
| H10 失败隔离 | 建议阶段失败不回滚已发布的图谱与增量进度;活动显示“已更新,建议生成失败”,可单独重试建议 |
|
||||
|
||||
介入产物替代旧心跳的报告、记忆建议与行动建议:未解决事项可转任务,候选约定确认后进入长期背景,旧的报告阅读由按时间查看图谱变化替代。已有计划的范围、频率和启停状态保留,迁移后介入方式默认为“生成建议”;已确认记忆与待处理建议保留并逐步迁入,不补造来源。
|
||||
|
||||
计划 Modal 的新建草稿不产生持久化或执行副作用,显式保存时创建并启用;编辑沿用已有 enabled。保存失败保留草稿,提交期间禁止关闭,未保存关闭须确认。活动筛选只改变读取范围,不触发执行;同名同范围计划仍按 configId 区分,筛选在分页前应用,结果跳转继续按 resultId 定位。
|
||||
|
||||
规则冲突时,先满足所选范围与既有观察边界,再保留用户修订和来源事实,最后应用自动归纳和展示策略。新增证据与已确认结论冲突时显示待核对差异,不能自动断言旧观点失效。
|
||||
|
||||
## 完整性与待定项
|
||||
|
||||
FR-S11、US-S30 的会话故事图谱开关规则见 [MCP 接入合同第 6 节](./story-graph-mcp-design.md#6-内置-mcp-与-runtime-接入)。应用启用、会话允许和 Runtime 能力分配须同时满足;回顾是否自动运行不由会话读取开关控制。
|
||||
|
||||
FR-S11、US-S28 至 US-S31 的规则由[时态 Story Graph MCP 设计](./story-graph-mcp-design.md)统一定义:时间/确认与有效性见第 3 节,读取范围与分页见第 4 节,应用关闭与 Runtime 接入见第 6 节,跨 scope 复用与记忆过渡见第 7、8 节。D1 读取已接入,后两项仍待实现;Global/项目独立 checkpoint 和同 scope 候选身份保留。MCP 不接受 `as_of`,较晚生成的结果也不自动使旧决定失效。
|
||||
|
||||
本设计覆盖触发、证据输入、静默检查、正常整理、取消与失败、跨视图共享结果、用户纠正、知识库写入及历史回放。它补充产品逻辑,不代表接口、持久化或迁移方案已经完成。
|
||||
|
||||
@@ -1,9 +1,304 @@
|
||||
# 监督者实施进度
|
||||
|
||||
日期:2026-09-28。
|
||||
日期:2026-09-30。
|
||||
|
||||
当前记录以已验证生产行为为准。监督者尚未覆盖全部 user stories。
|
||||
|
||||
## 2026-10-03 会话故事图谱开关
|
||||
|
||||
FR-S11、US-S30 的“使用故事图谱”已接通 Composer、生产 Preload/IPC、SQLite 和工具网关。会话默认开启,仅在监督者应用启用时显示,位置在知识库之前。存储和执行合同见 [MCP 接入](./story-graph-mcp-design.md#6-内置-mcp-与-runtime-接入),没有数据库版本升级或迁移。另保留本轮开始前已有的监督者独立模型选择及并行性能改动。
|
||||
|
||||
定向测试共 9 个文件、37 项通过,重复运行不重复计数:
|
||||
|
||||
- `npx vitest run src/main/agent/native-client-coordinator.test.ts src/renderer/src/conversation-persistence.test.ts src/renderer/src/Composer.test.tsx src/agent-daemon/story-graph-integration.test.ts`:25 项。覆盖默认值、按会话隔离、保存中及失败、原生客户端绑定、Main/Agent HTTP MCP 发现、旧调用拒绝与重新开启;图谱集成 5 项在最后修改后单独复跑通过,含在途结果交付前关闭。
|
||||
- `npx vitest run src/main/assistant/assistant-database.test.ts src/main/ipc.test.ts src/renderer/src/App.test.tsx src/main/agent/deepseek-harness-acp-e2e.test.ts -t "story graph|Story Graph|pins"`:11 项通过,630 项按名称未运行。覆盖真实 SQLite 重开、旧自动保存不能覆盖选择、IPC 校验、四种本地 Runtime 的签发与关闭,以及受控 Harness Ask/Execute。
|
||||
- `npx vitest run tests/overlay-layering.electron.test.ts -t "conversation story graph"`:1 项通过,3 项按名称未运行。真实 App、Preload、IPC 和 SQLite 在 1280px/390px、浅色/深色四种组合下检查共享 Switch、位置和可见矩形;原生鼠标打开菜单、Space 保存、页面重载、应用关闭时移除 DOM、重开后恢复选择及旧工具绑定拒绝均通过。切换没有模型请求,模型尝试计数为 0。
|
||||
|
||||
`npm run typecheck` 的 Main、Agent、Web 三组通过,`git diff --check` 通过。修改文件定向 ESLint 除 `conversation-selectors.ts:32,47` 外通过;这两处是同工作树并行标题缓存改动的 `react-hooks/immutability` 报错,本功能只改该文件的 Composer 字段投影,未修改缓存逻辑。没有执行全量测试、发布构建、提交或推送。中文 MCP 说明扫描阻断项为 0,复核项保留必要的数据和执行边界。
|
||||
|
||||
### 本轮 Linux Host 证据
|
||||
|
||||
[源码探针](../../../scripts/story-graph-host-probe.ts)临时打包后在共享 Linux x64 Host 运行当前 `AgentImageToolMcp`、`AgentOwnedAcpPrompt`、`ContinueHostAdapter`,桌面使用真实 `AssistantDatabase`、`KnowledgeMcpGateway` 和 `MainImageToolSession`。关闭操作改为写会话持久设置,验证其他会话仍开启,以及同一个远程 MCP 会话不再发现工具、旧工具调用被拒绝。
|
||||
|
||||
OpenCode 和 Continue 的 Ask/Execute 确定性探针通过,共 10 次测试推理请求(含 2 次标题生成)、10 次桌面图谱读取、0 次外部 Provider 请求。真实 DeepSeek 复测中,Linux OpenCode Ask/Execute 均完成图谱调用并返回完成标记,随后关闭检查通过;该轮 6 次 Provider 请求(含 2 次标题生成)、8 次桌面读取。首轮真实模型已完成 Ask 图谱读取,但探针未拼接流式文字便检查完成标记而失败,消耗 3 次 Provider 请求;修正探针后复测,整个任务外部请求合计 **9 次**。
|
||||
|
||||
工具消息通过 SSH 标准输入输出搬运,调用生产二进制工具分发;没有重装共享 Agent,也未把完整 Desktop attach/bootstrap 或远端 Continue 的真实模型请求计作已验证。远程协议与模型桥源码未变,会话开关由 Desktop 的现有可用性回调执行。测试只使用隔离 SQLite 和固定示例内容;临时 Host 探针及运行目录已清理。Electron 界面与 SSH Runtime 分开验收,没有宣称一条自动化场景覆盖整窗点击到远端模型。
|
||||
|
||||
## 2026-10-03 监督者独立模型选择
|
||||
|
||||
FR-S13 已接通监督者“设置 → 模型”、应用设置持久化和 Main 连接解析。字段只保存已有文本连接 UUID,旧配置跟随应用默认;执行中复用同一 Runtime,继续和重试采用当前设置。规则见[模型选择](./logic-design.md#监督者模型选择)。
|
||||
|
||||
定向验证覆盖中英文选择与保存失败草稿、失效连接提示、旧设置默认值及重载、提取到经验阶段的模型一致性、失败后的释放与继续、建议措辞连接及清理。`ipc.test.ts` 的监督/心跳相关 24 项通过,含真实直连 Runtime 到 loopback HTTP 服务的两次请求:固定连接、应用默认各一次,验证模型名、请求定制、无工具及失效连接不回退。外部真实模型请求为 0 次。
|
||||
|
||||
验证命令:`npx vitest run src/main/application-settings-store.test.ts src/main/assistant/supervision-production.test.ts src/renderer/src/HeartbeatCenter.test.tsx`;`npx vitest run src/main/ipc.test.ts -t "supervis|heartbeat|unchanged sources"`;`npm run typecheck`;修改的源文件定向 ESLint。未跑全量测试、打包、真实供应商或 Electron 原生界面测试。监督模型请求在桌面 Main 内完成,Agent 和远程模型桥未改动;未进行远程 Host 验证。
|
||||
|
||||
## 2026-10-02 长会话验收与 Agent 自主调用图谱
|
||||
|
||||
### 长会话验收
|
||||
|
||||
`npx jiti scripts/long-session-acceptance.mjs <live.sqlite> .env.deepseek.local <dir> <会话前缀> [failAfter]`:对本机数据库做在线备份,只在副本里归档其他会话、隐藏任务,使回顾只读取一条真实会话;首次运行在第 N 次提取时注入一次提供方失败(不消耗付费调用),再继续同一回顾;完成后用保存的批次片段逐条重建每条消息原文;最后再跑一次心跳和一次默认手动回顾,要求零模型调用。原库不修改。
|
||||
|
||||
第一次跑最长会话(389 条消息、约 21.7 万码点、16 天)时,继续回顾三次都失败:两次是模型返回无效 JSON,一次是引用了范围外的来源。原因是一个批次的模型输出不可用时整次回顾立即失败,只能靠反复手动“继续”碰运气。改动:
|
||||
|
||||
- 叶子批次输出不可用(无效 JSON、结构不符、引用不存在的来源或实体)时,在同一段待处理正文上缩小到 1/2、再到 1/4(不少于 500 字符)重试,最多 2 次;缩小只是先处理前一部分,其余正文留在待处理里,不跳过、不截断。
|
||||
- 导航合并输出不可用时,用相同输入再问 1 次。
|
||||
- 提供方错误、超时、取消和响应超出容量不重试,仍按原来的方式失败并保留已保存批次。
|
||||
|
||||
改后结果(DeepSeek `deepseek-flash`):
|
||||
|
||||
| 会话 | 首次运行 | 继续 | 覆盖 | 无变化重跑 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 97 条、6.0 万码点、4.9 天 | 第 4 次提取注入失败,已存 3 批 | 1 次完成,16 次调用 | 97/97 条精确重建,0 缺口、0 重叠 | 心跳、手动均 `no_change`,0 调用 |
|
||||
| 389 条、21.7 万码点、16.2 天 | 第 7 次提取注入失败,已存 5 批 | 1 次完成,61 次调用(30 提取、31 合并),约 32.5 分钟 | 389/389 条精确重建,0 缺口、0 重叠 | 心跳、手动均 `no_change`,0 调用 |
|
||||
|
||||
最长会话共 67 次调用、约 52.7 万输入和 54.8 万输出 Token,发布 237 个事件;失败前保存的批次在继续后全部保留未重算。改动前的同一会话运行(三次继续均失败)记录在 `sl9/long2`,改后中间版本(仅叶子重试,合并失败两次后第三次继续完成)在 `sl9/long3`。
|
||||
|
||||
边界:完整覆盖只说明每段原文都进入了成功批次,不代表模型没有遗漏或误读;验收只用了两条会话,没有测多项目全局回顾。
|
||||
|
||||
### Agent 自主调用
|
||||
|
||||
决定:普通对话不自动注入故事和经验,由 Agent 按需调用 Story Graph 工具。为此:
|
||||
|
||||
- 工具说明写明何时应主动调用(继续之前的工作、询问为什么这样决定、在可能有既有约束的领域开始新任务)、何时不调用(闲聊、与项目历史无关的通用问题),用 2~4 个关键词查询,查几次找不到就说明并按当前上下文回答。
|
||||
- 搜索改为按词匹配:空格和标点分词,中文按相邻两字切分;一条记录命中整句或至少一半的词即返回,按命中程度排序,故事和经验在同分时靠前。此前 Agent 用“监督者回顾调度”这类自然短语搜索,因为要求整句出现而 0 结果。
|
||||
- 搜索结果的预览先显示名称、标题、描述等可读字段,不再以 ID 开头。
|
||||
|
||||
验证(`scripts/story-graph-agent-deepseek.mjs`,DeepSeek 函数调用,只给生产工具定义,不提示要用工具):
|
||||
|
||||
| 轮次 | 判断正确 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| 改前 | 4/5 | 通用编程题也去搜索;继续工作类问题搜了 9~22 次仍找不到内容 |
|
||||
| 改后 | 5/5 | 三个项目历史问题都主动调用;数组去重和问候都不调用 |
|
||||
|
||||
改后“继续回顾调度,按最后定的方案”能找到并引用 2026-10-02 的故事线模型设计;“导出功能有什么约束”能列出经验和来源。“为什么取消 300 秒自动暂停”仍在搜了十几次后达到对话轮数上限:这次取消发生在数据副本最后一次回顾之后,图谱里确实还没有这条决定,Agent 没能及时停止并说明“没有记录”。
|
||||
|
||||
## 2026-10-02 故事与经验质量抽查
|
||||
|
||||
数据:同一份 437 个事件的数据副本上已有的故事归属运行(`sl3/off1`、`sl3/cross2`)和经验抽取运行(`sl4/run1`、`sl4/run2`),以及本次改提示词后的新运行(`sl8/off3`,DeepSeek `deepseek-flash`,12 次调用,约 6.2 分钟)。一致性用“两次运行中同属一个功能的事件对”的重合度(Jaccard)衡量。
|
||||
|
||||
| 检查 | 结果 |
|
||||
| --- | --- |
|
||||
| 故事归属一致性(改前,off1 对 cross2) | 0.51 |
|
||||
| 主要不一致来源 | “提交与发布”吸收了 87 个事件,其中只有 40% 在另一次运行里属于发布;其余是各功能自己的提交、推送和验证 |
|
||||
| 改动 | 提示词明确:某个领域的提交、推送、测试和验证属于该领域;发布功能只放版本号、发布说明、标签、打包和发布 |
|
||||
| 改后(off3) | 发布功能 42 个事件;与 cross2 的一致性 0.74;暂不归类 55 个;再次整理 0 次调用 |
|
||||
| 暂不归类的事件 | 抽查 cross2 的 49 个:几乎都是问候、知识截止时间、SSL/认证报错、寿命和睡眠问答、案例咨询、权限挂载等一次性问题,归为“暂不归类”是合理的 |
|
||||
| 经验跨运行一致性 | run2 的 38 条里 23 条能在 run1 中找到形成依据重合(Jaccard ≥ 0.3)的对应经验,表述基本一致 |
|
||||
| 经验依据抽查(10 条) | 形成依据基本都对得上经验内容;后续“应用”较弱,抽到的 5 条应用里约 2 条和经验关系不大(例如“需求有歧义先确认”被记到“询问微信附件导入能力”) |
|
||||
|
||||
结论:功能边界在明确“提交归领域”后明显稳定;仍有约四分之一的事件对在两次运行间归属不同,主要发生在界面、笔记、帮助提示等相邻功能之间,可以通过手动合并修正。经验的形成依据可信,应用记录原先过宽。
|
||||
|
||||
随后收紧经验应用:模型给出的应用事件必须与该经验的某个形成事件共享实体,否则丢弃(同批其他内容保留)。在 off3 的故事上重新抽取(6 次调用,约 3.8 分钟):45 条经验,应用记录从此前的 53 条降到 6 条,抽查 6 条全部与经验内容相符;依据无来源 0、应用早于形成 0、再次抽取 0 次调用。代价是跨故事的经验连线变少(3 条),时间螺旋和“可用经验”建议能显示的跨故事复用也随之减少。`supervision-experiences.test.ts` 新增“无关知识的事件不记为应用”。
|
||||
|
||||
## 2026-10-02 时间螺旋显示经验节点
|
||||
|
||||
对应 [3D 概念](../story-graph/3d-concept.md) 中的桶外经验节点。
|
||||
|
||||
实现:
|
||||
|
||||
- `experienceLinks()`(`story-graph-3d-model.ts`):经验在当前层中,从最早形成依据所在的木片,连到之后第一个在另一片木片上的应用。只在同一片木片里用过的、应用不晚于形成的、依据不在当前层的经验不画。每层最多 6 条,按应用到的不同木片数排序。
|
||||
- 时间螺旋:经验画成桶外的菱形,高度在形成与应用两个时间之间,角度在两片木片之间;两段向外弯的弧线分别连到形成木片和应用木片的对应高度。只有选中的经验在画布上显示名称。提供“跨木片的经验”键盘入口(现为工具栏选择框);点击菱形或选择经验打开详情。
|
||||
|
||||
验证:
|
||||
|
||||
- `story-graph-3d-model.test.ts` 新增 1 项(只连到跨木片的后续应用、只出现一次、层外依据与倒序应用排除、上限)。相关组件测试 3 个文件 32 项通过,Web 类型检查、相关 ESLint 通过。
|
||||
- Electron 实测(生产组件,第 5 步数据副本的 46 个故事、38 条经验;1440 浅色、390 深色):无报错、无横向溢出;根层 3 条、goodbuddy 层 6 条跨木片经验;从列表选中和在画布上点中菱形都能打开经验。
|
||||
- 38 条经验中,在当前数据里能形成跨木片连线的只是少数,其余只在同一故事中形成或应用,不在螺旋上显示,仍可在“经验”页签中查看。
|
||||
|
||||
## 2026-10-02 故事线模型第 5 步:工作回顾按故事组织
|
||||
|
||||
对应[故事线模型改造设计](./storyline-model-design.md)第 5 步“工作回顾页”。
|
||||
|
||||
实现:
|
||||
|
||||
- 工作回顾在所选回顾结果的摘要之上新增“这段时间的故事”,按该结果的范围和时间区间读取已保存的故事和经验,不调用模型(`supervision-story-digest.ts`、`SupervisionStoryDigest.tsx`)。
|
||||
- 分组:推进的故事(区间内有事件、区间前也有)、新出现的故事(第一个事件在区间内)、已结束的故事(结束依据事件在区间内)、形成或用上的经验(最早形成依据在区间内,或区间内有应用)、这段时间没有新进展(进行中、区间前有事件、区间内没有;跨项目故事和已结束的故事不算)。功能把子线索的事件计入自己,子线索不重复列出。
|
||||
- 每组按事件数排序,默认显示 6 条,可展开其余;每条显示项目、事件数和最近一个事件。点击任一故事或经验打开图谱,并选中该对象。原有的工作总结、本次变化、未解决事项保留在下方,作为阅读入口。
|
||||
|
||||
验证:
|
||||
|
||||
- `supervision-story-digest.test.ts` 2 项(分组规则、子线索计入功能、跨项目与已结束故事不算无进展、经验形成与应用区分);`SupervisorWorkspace.test.tsx` 新增 1 项(按所选结果范围读取故事、各分组内容、点击进入图谱)。相关组件测试 4 个文件 73 项通过,Web 类型检查、相关 ESLint 通过。
|
||||
- Electron 实测(生产组件,第 5 步数据副本导出的 46 个故事、38 条经验和一条 7 天的全局回顾结果;1440 浅色、1024 深色、390 深色):无报错、无横向溢出、条目无文本溢出;宽屏 3 列、窄屏 1 列;展开后从 13 条到 26 条;点击条目切到图谱。该副本的数据从 09-19 开始,这条结果的区间正好覆盖全部数据,因此只出现“新出现的故事 19”“已结束的故事 1”“形成或用上的经验 33”,“推进的故事”和“没有新进展”两组没有在真实数据中出现,只由单元测试覆盖。
|
||||
|
||||
## 2026-10-02 故事线模型第 5 步:心跳故事建议与 MCP 故事、经验查询
|
||||
|
||||
对应[故事线模型改造设计](./storyline-model-design.md)第 5 步。工作回顾页按故事组织见上一节。
|
||||
|
||||
实现:
|
||||
|
||||
- 心跳建议新增两类,候选只由规则选出,不调用模型:
|
||||
- **暂无进展**(`stalled`):本次范围内进行中的功能或子线索(至少 3 个事件),最近事件距今达到“故事多少天没有新进展时提醒”(设置,默认 14 天)。只提示查看,不改变故事状态,不推断结束。每次心跳最多 3 条,按事件数从多到少;已待处理或依据未变的不计入名额,其余留到后续心跳。接受后转为暂停的后续任务。
|
||||
- **可用经验**(`experience`):本次回顾归入某故事的事件,与某条经验的形成事件共享实体,而该经验尚未在这个故事中形成或应用。出现在 5 个以上故事中的实体(如产品名)不作为依据。每条经验只指向匹配事件最多的一个故事,每次心跳最多 3 条,按依据数排序。接受即标记已参考。
|
||||
- 两类候选排在未决事项之前,不会被每次 20 条的上限挤掉;与其他建议一起只用一次模型调用措辞,输入不含来源原文和定位。
|
||||
- 建议记录 `story_id`、`experience_id`;“在图谱中查看”直接打开对应故事或经验。Schema 56 重建 `supervision_suggestions` 以扩展类型约束,已有行原样复制。
|
||||
- Story Graph MCP:`story_graph_search` 支持 `story`、`experience` 两种对象;`story_graph_get_context` 对故事返回其事件(功能包含子线索的事件),对经验返回形成与应用事件,再通过事件读取来源。经验带 `automatic` 或 `user_edited` 标记,工具说明要求把经验作为参考而非规则。查询范围规则不变:项目范围只返回本项目的故事,以及在本项目形成或应用过的经验。
|
||||
|
||||
验证:
|
||||
|
||||
- 新增 `supervision-story-suggestions.test.ts` 5 项:暂无进展的阈值、不改故事状态、不重复、转任务;每次最多 3 条且按事件数排序、其余在后续心跳补上;经验跨故事建议只出现一次、不建议给形成它的故事;MCP 故事与经验的搜索、上下文及项目范围隔离;Schema 55 升级到 56 保留已有建议。新增 `SupervisionSuggestionsPanel.test.tsx`:新类型标签与按故事、经验打开图谱。监督相关主进程 11 个文件 73 项、IPC 定向 21 项、组件 4 个文件 72 项通过;Node/Web/Agent 类型检查、相关 ESLint 通过。未运行全量测试。
|
||||
- 真实数据(第 4 步修正后运行的数据副本,`npx jiti scripts/story-suggestions-deepseek.mjs <db> .env.deepseek.local <dir> 7`,DeepSeek `deepseek-flash`;数据只有约 13 天,阈值用 7 天,“现在”取最后事件时间):
|
||||
|
||||
| 运行 | 规则候选 | 调用 | 结果 |
|
||||
| --- | --- | --- | --- |
|
||||
| 第 1 轮(故事候选排在未决事项之后) | 20 条未决事项,0 条故事候选 | 2 | 故事候选被 20 条上限挤掉,因此改为故事候选优先 |
|
||||
| 第 2 轮(暂无进展不限量) | 20 条暂无进展 | 2 | 13 天的数据里大量故事“停滞”,一次挤满上限,因此加每次 3 条的限制 |
|
||||
| 第 3 轮(经验按每个实体匹配) | 3 条暂无进展、17 条可用经验 | 2 | 同一经验指向 5~8 个故事,大多经由“监督者”这类通用实体匹配,因此改为排除通用实体、每条经验只指向一个故事、每次最多 3 条 |
|
||||
| 第 4 轮(最终规则) | 3 条暂无进展、3 条可用经验、14 条未决事项 | 2 | 每次请求 6 条故事候选,都不含来源定位;建议都带故事,经验建议都带经验 |
|
||||
|
||||
- 最终规则下的可用经验例如“长任务超时或中断先查取数与分批根因,不以延长超时或调并发修复”指向“回顾算法与并发设置”、“提交只纳入本次相关改动”指向“自动监督开关与设置”。是否真正适用未人工逐条核对。
|
||||
- 再次生成时没有重复任何待处理建议;只补上了上一次被 20 条上限挤掉的未决事项(1 次调用)。
|
||||
- MCP:全局范围搜到 6 个相关故事、9 条相关经验;一个功能的上下文返回 96 个事件(含子线索),一条经验返回 5 个依据事件。
|
||||
|
||||
边界:是否在普通对话中主动查询故事与经验,取决于 Agent 是否调用工具,未做额外注入。
|
||||
|
||||
## 2026-10-02 故事线模型第 4 步:经验抽取
|
||||
|
||||
对应[故事线模型改造设计](./storyline-model-design.md) SL-4、SL-5 和第 4 步。
|
||||
|
||||
实现:
|
||||
|
||||
- `supervision-experiences.ts`:新表 `supervision_experiences`(经验、适用条件、边界、状态、用户调整标记)、`supervision_experience_events`(`formed` 形成依据、`applied` 后续应用及结果)、`supervision_experience_seen`。Schema 55,升级后表为空,由下一次回顾从已有故事中抽取,不重读来源。
|
||||
- 在故事归属之后、同一回顾名额内执行。候选由规则选出,不调用模型:有尚未处理的决定、变化或里程碑事件,且事件数达到“故事参与经验整理的最少事件数”(设置,默认 5)的功能或子线索。小故事和只有讨论的进展直接标记已处理,不调用模型。
|
||||
- 候选故事按文本量分块,每块一次模型调用,输入是故事名、事件标题与截断描述以及已有经验,不含原文。回答中的每条形成依据必须是本次输入、且有来源的事件,否则整块不保存;早于经验形成时间的“应用”被丢弃(真实模型会出现这种引用),其余保留。同一表述的经验合并到已有经验。
|
||||
- 经验生成后即可读取,标记“自动归纳”。用户可以编辑、合并、删除,与故事调整共用撤销;编辑过的标记“已手动调整”;删除的经验不会因同一表述或相同依据再次生成。
|
||||
- 经验抽取失败不影响故事和已发布的回顾:活动记录显示“故事已整理,经验整理失败”,可与故事一起重新整理,下一次回顾也会补上。
|
||||
- 界面:图谱左侧新增“经验”页签;选中经验在右侧查看适用条件、边界、形成依据和后续应用(含所属故事与结果),依据事件可直接打开;编辑、合并、删除、撤销。设置新增“故事参与经验整理的最少事件数”。
|
||||
|
||||
验证:
|
||||
|
||||
- 新增 `supervision-experiences.test.ts` 4 项:规则候选、应用记录、无新进展零调用、小故事与纯讨论零调用、虚构依据整块拒绝、早于形成的应用被丢弃、编辑保护、删除后不再生成、合并与撤销。工作区新增经验页签测试。监督相关主进程测试 7 个文件 174 项、工作区/活动/设置组件测试 48 项通过;Node/Web 类型检查、相关 ESLint 通过。未运行全量测试。
|
||||
- 真实数据(第 2 步跨项目开启运行后的数据副本,46 个故事,`npx jiti scripts/experiences-deepseek.mjs`,DeepSeek `deepseek-flash`):
|
||||
|
||||
| 运行 | 候选故事 | 调用 / 耗时 | 经验 | 应用记录 | 跨故事经验 | 依据无来源 | 应用早于形成 |
|
||||
| --- | --- | --- | --- | --- | --- | --- | --- |
|
||||
| 首轮(应用早于形成时整块拒绝之前的规则) | 31 | 6 / 约 3.9 分钟 | 40 | 71 | 15 | 0 | 5 |
|
||||
| 修正后 | 31 | 6 / 约 2.7 分钟 | 38 | 53 | 9 | 0 | 0 |
|
||||
|
||||
- 两次再次抽取均为 0 次调用;所有请求都不含来源原文。
|
||||
- 修正后 38 条经验中 8 条只有 1 个形成依据。内容基本是这段时间真实形成的工作经验,例如“长材料回顾须完整分块,按成功批次保存进度并支持续作,禁止截断”“能力开关关闭时须阻断后台任务领取与执行,不能只隐藏入口”“提交只纳入本次相关改动,其他未完成改动留在工作区”。
|
||||
- 两次运行的经验条目不完全相同,未比较多次运行的一致性;也未逐条人工核对每条经验与依据是否对得上。
|
||||
|
||||
边界:经验尚未接入 MCP、心跳建议和时间螺旋视图(桶外经验节点),这些属于第 5 步和视图后续工作。
|
||||
|
||||
## 2026-10-02 故事线模型第 3 步:时间螺旋视图
|
||||
|
||||
对应[故事线模型改造设计](./storyline-model-design.md)第 3 步与 [3D 概念](../story-graph/3d-concept.md)。
|
||||
|
||||
实现:
|
||||
|
||||
- 图谱画布标题右侧新增“平铺 / 时间螺旋”切换。时间螺旋视图(`StoryGraph3D.tsx`)读取故事列表与图谱返回的 `attention`:高度为时间,半径为每圈的对话轮次(一圈一个半径,相邻圈差值受限),木片为故事起止,事件按屏幕距离聚合。层级为 全部 › 项目 › 功能 › 子线索,跨项目故事在根层;点击木片进入下一层并在右侧显示故事详情,单个事件选中后显示事件详情。斜俯视/侧视/俯视/自由视角,拖动、滚轮、方向键、数字键、Esc。
|
||||
- 计算部分在 `story-graph-3d-model.ts`(树、角度、圈长、半径、聚合),渲染只负责显示。圈长从候选档位中选最接近 8 圈且不超过 14 圈的一档。
|
||||
- Three.js `0.186.1` 作为依赖,组件通过 `lazy` 加载,只在打开时间螺旋时下载(生产构建中只出现在 `StoryGraph3D` 分块)。颜色取现有 `--graph-node-*` 令牌。WebGL 不可用时提示改用平铺视图。
|
||||
- 2026-10-02 布局调整:画布下方的图例段落和木片、经验列表移除,中间区域只保留图。图例改为工具栏“图例”按钮,点击后浮在画布右上角,可用 X 关闭;圈长(如“一圈 1 天 · 共 13.1 圈”)常驻画布左下角。木片和经验的键盘与读屏入口改为工具栏中的两个选择菜单(共享 `AnchoredMenu` 样式,每项显示名称和事件数、起止或来源→去向)。按钮按当前层级命名,如“项目与跨项目故事 · 6”“功能 · 15”“子线索 · 9”“经验 · 3”,不再使用“木片”这一内部比喻。菜单内边距 4px,菜单项 8px×12px、行间 2px,两个按钮间距 8px;1440 与 390 宽度下文字无裁切,菜单在窗口内。
|
||||
- 2026-10-03 图谱中间列不再滚动:平铺与时间螺旋都把标题栏以外的高度全部给图。平铺视图的图例、阅读说明移入标题栏“图例”浮层,可见数量常驻画布左下角;“事件浏览”改为单行(上一个、滑块、下一个、位置、当前事件)。图谱行最低 660px,以免平铺图文字小于 11px;更矮的窗口改为页面滚动。Electron 布局测试新增断言:中间列 `overflow: hidden` 且无溢出,图区一直延伸到列底部;1440×1200 下整页无滚动、底部留 33px,图高 625px。真实数据副本下时间螺旋在 700px 行高中填满,无列内滚动。
|
||||
- 2026-10-03 图谱页顶部的“图谱范围 · 全局”和时间行并入画布标题下方一行(范围 · 时间区间 · 生成时间 · 事件/实体数),刷新改为标题栏图标按钮;标题由“工作的来路”改为“事件与知识”。图谱行因此多出约 40px:1440×900 下整页不再滚动,图高 552px。布局测试新增断言:范围行位于标题下方,图谱上方不再有独立的范围栏。
|
||||
- 2026-10-03 修复“时间螺旋没有加载成功”:每次重建场景都复用同一个画布,而上一次释放时已对它调用 `forceContextLoss()`,该画布再也拿不到可用的 WebGL 上下文(Three.js 报 `Cannot read properties of null (reading 'precision')`)。开发模式的 StrictMode 双挂载、父组件刷新时传入新的 `attention` 数组都会触发重建,所以打开就失败。现在每次重建创建独立的绘图画布并在释放时移除,原画布只负责焦点和指针事件;`attention` 按内容保持稳定,刷新不再重建场景。复现脚本(StrictMode + 20 次父组件重渲染)修复前首屏即报错,修复后无报错。真实数据副本的 Electron 检查(1440 浅色、390 深色):画布外无段落和列表,图例浮层位于画布内,选择框逐层打开、视角切换、Esc 返回正常,无报错和横向溢出。
|
||||
|
||||
验证:
|
||||
|
||||
- `story-graph-3d-model.test.ts` 4 项(树与跨度、圈长档位、每圈一个半径且变化有界、聚合);工作区、活动、设置组件测试通过;Node/Web 类型检查、相关 ESLint 通过;`electron-vite build` 确认 Three.js 只在懒加载分块中。
|
||||
- Electron 实测(第 2 步跨项目运行的真实故事数据副本,生产组件):1440 浅色与 390 深色均无控制台错误、无横向溢出;画布非背景像素约 26%;根层 6 片(5 个项目、2 个跨项目故事中的果蝇神经仿真等),goodbuddy 15 个功能,监督者 9 条子线索;视角切换、Esc 返回、选中故事回调正常;旋转一帧约 1 ms。数据跨 13 天,一圈 1 天共 13.1 圈。
|
||||
|
||||
边界:时间切面(第 7 项)、展开视图(第 8 项)、原位分裂动画和桶外经验节点尚未移植;经验(W)要到第 4 步。Three.js 懒加载分块约 1.15 MB。
|
||||
|
||||
## 2026-10-02 故事线模型第 2 步:故事归属与用户调整
|
||||
|
||||
对应[故事线模型改造设计](./storyline-model-design.md) SL-1~SL-3、SL-5、SL-8 和第 2 步。
|
||||
|
||||
实现:
|
||||
|
||||
- `supervision-stories.ts`:新表 `supervision_stories`(功能、子线索、跨项目故事,状态、用户调整标记)、`supervision_event_stories`(主故事唯一,另可关联)、`supervision_story_assigned`、`supervision_story_edits`。Schema 54;升级后表为空,已有事件由下一次回顾归属,不重读来源。
|
||||
- 回顾发布后在同一回顾名额内执行故事归属:只取本范围中尚未归属的当前事件,按项目分块,每块一次模型调用,输入是事件标题、描述和本项目已有故事摘要,不含原文。没有待归属事件时不调用模型。项目来自事件来源;功能、子线索由模型提出;允许“暂不归类”。
|
||||
- Main 校验每个事件恰好分配一次、主故事属于本项目、子线索有上级功能、结束标记引用本次归入该故事的事件;任一项不符则整块不保存。功能事件数低于“子线索最少事件数”(设置,默认 20)时,子线索并入功能。
|
||||
- 跨项目关联关闭时,提示词禁止跨项目故事,也不提供已有跨项目故事;开启时项目依次处理,跨项目故事只作关联,不影响主故事的长度和事件数。
|
||||
- 用户调整:重命名、合并(同项目同层级)、移除(事件回到上级功能或变为暂不归类,不删除事件)、把事件移到同项目其他故事或暂不归类、撤销上一次调整。调整过的故事不会被自动整理改名或标记结束,移除的故事不会因同名再生成。
|
||||
- 故事归属失败不影响已发布的回顾:活动记录显示“回顾已保存,故事整理失败”并可重新整理;下一次回顾(含无变化)也会补上。
|
||||
- 界面:图谱左侧新增“故事”页签,按项目 › 功能 › 子线索、跨项目故事列出,显示暂不归类的事件数;选中故事在右侧查看起止、状态、事件,并可重命名、合并、移除、撤销;事件详情可改所属故事。设置新增“功能拆分子线索的最少事件数”。
|
||||
|
||||
验证:
|
||||
|
||||
- 新增 `supervision-stories.test.ts` 6 项:归属与无变化零调用、长时间空白后接回原故事、不完整/重复/虚构/证据不符的回答整块拒绝、小功能子线索并入、跨项目开关、调整保护与撤销、回顾内执行及失败后重试。`src/main/assistant` 全目录、Story Graph MCP 集成、监督相关 IPC、监督者工作区/活动/设置组件测试均通过;Node/Web 类型检查与相关 ESLint 通过。未运行全量测试。
|
||||
- 真实数据(第 1 步升级后的本机数据副本,`scripts/stories-deepseek.mjs`,DeepSeek `deepseek-flash`):
|
||||
- 跨项目关闭:437 个当前事件,11 次调用约 4.3 分钟;39 个故事(35 个功能、4 个子线索),396 个事件归入,41 个暂不归类;无事件有两个主故事、无主故事跨项目;11 个“已结束”均引用本故事内的事件;再次归属 0 次调用。
|
||||
- 跨项目开启:11 次调用约 6.4 分钟;44 个故事,其中 1 个跨项目故事关联 dgx 与阿里云-国内的 7 个事件;其余检查同上。
|
||||
|
||||
观察到的问题(首轮):
|
||||
|
||||
- 功能粒度不稳定:goodbuddy 的发布被拆成 0.13.10、0.13.11、0.13.12、0.13.13 多个功能,两次运行切分不同。
|
||||
- 监督者相关 77~85 个事件的功能只拆出 0~1 个子线索,模型很少主动建子线索。
|
||||
- goodbuddy 的 fly-brain 仿真与微信 ClawBot 的果蝇开源项目在开启跨项目后仍未关联。
|
||||
- 故事起止跨度可以从事件计算,但单个事件的跨度仍多为同一时刻(见第 1 步边界)。
|
||||
|
||||
针对上述问题的调整:
|
||||
|
||||
- 提示词写明粒度:功能是持续数周的模块、子系统或研究主题;发布、版本、单个缺陷、单日任务不是功能,同一项目的发布与版本同步归入一个功能,版本可作子线索;功能名不含版本号和日期;每个项目通常 3~12 个功能;功能达到“子线索最少事件数”后按子线索放置事件。
|
||||
- 新功能或跨项目故事的名字去掉版本号、日期后与已有或同批故事相同时,并入已有故事(确定性规则);被并入那一方的“已结束”标记不生效,避免一个版本结束让整条发布线结束。
|
||||
- 子线索拆分:每个项目归属完成后,自身事件数跨过“子线索最少事件数”下一个整数倍的功能(每项目最多 3 个)各做一次拆分调用,输入只有该功能名称、已有子线索和模型放置的事件标题与截断描述。只把事件从功能移到子线索;新子线索至少需要 max(3, 阈值/5) 个事件,否则不建;用户移动过的事件和用户调整过的功能不动;被用户移除的同名子线索不重建。功能没有跨过下一个整数倍时不再调用。
|
||||
- 跨项目开启时,提示词附带其他项目的功能名称、描述和事件数(最多 60 个,不含事件内容),已有跨项目故事附带它已关联的功能名称;回答可用 `links` 把本项目功能和其他项目功能挂到同一个跨项目故事,关联该功能的全部当前事件(只作关联,不改主故事)。关闭时不提供,且 `links` 会被整块拒绝。
|
||||
- 新故事键接受 `new_1_t1` 这类写法(真实模型在一次回答中这样写子线索键,原先整块被拒)。
|
||||
|
||||
调整后的真实数据(同一数据副本,DeepSeek `deepseek-flash`,每次各自从空故事开始):
|
||||
|
||||
| 运行 | 调用 | 功能 / 子线索 / 跨项目 | 归入 / 暂不归类 | 拆分移动 | 版本号命名的功能 |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| 首轮 关闭 | 11 | 35 / 4 / 0 | 396 / 41 | — | 有(0.13.10~0.13.13) |
|
||||
| 首轮 开启 | 11 | 35 / 8 / 1 | 407 / 30 | — | 有 |
|
||||
| 调整后 关闭 | 12(含 1 次拆分) | 19 / 23 / 0 | 372 / 65 | 18 | 0 |
|
||||
| 调整后 开启 | 12(含 1 次拆分) | 21 / 23 / 2 | 388 / 49 | 20 | 0 |
|
||||
|
||||
- 改善:goodbuddy 发布收敛为一个“发布与版本”/“提交与发布”功能,版本工作成为其子线索;监督者功能拆出 9~11 个子线索(回顾整理与图谱、回顾算法与并发设置、故事线模型与 3D 图谱、活动记录页、自动监督开关与设置等),界面、笔记与画布也拆出子线索;开启跨项目后“果蝇神经仿真”关联了 goodbuddy“本地果蝇神经仿真”与微信 ClawBot“果蝇神经开源项目调研”(16 个事件),另有“Agent 实例生命周期与回收”关联 goodbuddy 与 dgx(24 个事件)。两次运行都无事件有两个主故事、无主故事跨项目、无小功能子线索,再次归属 0 次调用。
|
||||
- 未改善或变差:暂不归类增多(关闭 41→65,开启 30→49),模型在功能变宽后把更多零散事件留空,未逐条核对是否合理。首轮的 dgx+阿里云跨项目故事这次未出现。子线索大多在归属调用中直接建出(关闭 18、开启 20 个事件由拆分调用移入子线索),拆分调用每次运行只触发 1 次。关闭、开启各只成功跑了一次,多次运行的切分是否一致未比较。
|
||||
- 单元测试:`supervision-stories.test.ts` 新增 3 项(版本名功能合并且不误结束、拆分调用的触发上限与用户事件保护及整块拒绝、跨项目功能列表与 `links` 关联及关闭时拒绝),共 9 项;与 `supervision-timeline.test.ts` 合计 13 项通过;Node 类型检查与相关 ESLint 通过。
|
||||
- 失败:另有两次运行中途失败、未完成。一次是开启跨项目时模型把“已结束”标记引用到未归入该故事的事件,整块被拒,导致整次归属失败(已在提示词中强调证据事件须归入同一故事,之后的开启运行通过);一次是上述 `new_1_t1` 键格式,已放宽校验并加单元测试,但放宽后未再用真实模型重跑。严格校验下单块被拒会让整次归属失败,仍需重试才能补上。
|
||||
|
||||
## 2026-10-02 故事线模型第 1 步:共享时间线
|
||||
|
||||
对应[故事线模型改造设计](./storyline-model-design.md) SL-6、SL-7 和第 1 步。
|
||||
|
||||
实现:
|
||||
|
||||
- 处理进度改为一条共享时间线:`review_checkpoints` 的监督阶段只用 `scope = 'timeline'`,项目、全局和多项目组合共用。同一来源版本在任一范围成功发布后,其他范围不再提取;范围只决定读取和展示哪些来源。旧的按范围进度在升级时合并,不重读已处理内容。
|
||||
- 事件新增 `started_at`、`ended_at`、`project_id`、`superseded_by`,来源新增 `source_key`、`source_revision`(`supervision-timeline.ts`)。起止时间和所属项目按事件引用的来源记录计算,不由模型给出;来源跨多个项目时 `project_id` 为空。同一来源版本的重叠区间被后来的结果重新提取时,旧结果中的事件标记为已被替代,图谱和 MCP 只读当前事件,历史结果快照不变。
|
||||
- 图谱按范围读取共享时间线上的当前事件、实体、关系和来源,并返回 `attention`:按小时统计的消息轮次与正文量,只表示投入集中程度。
|
||||
- 新增设置“跨项目关联”(`supervisionReview.crossProject`,默认关闭)。关闭时,每批只把本项目已有的实体作为候选,不同项目的内容不会合并为同一实体;开启后候选来自全部项目。运行开始时冻结到本次配置。
|
||||
- Story Graph MCP:全局范围可以读到各项目的当前事件;来源可从运行范围、全局或包含该来源项目的项目范围读取。
|
||||
- Schema 53。
|
||||
|
||||
验证:
|
||||
|
||||
- 新增 `supervision-timeline.test.ts`:项目回顾后全局只提取其他项目的来源,相同项目再次回顾不调用模型;起止时间和项目来自来源;重新整理与项目回顾重叠后当前事件不重复;跨项目开关决定候选;Schema 52 升级合并进度和重复事件。`src/main/assistant` 全目录 28 个文件 324 项、Story Graph MCP 集成、监督相关 IPC 15 项、设置组件均通过。Node/Web 类型检查、相关 ESLint、`git diff --check` 通过。未运行全量测试。
|
||||
- 真实数据(`scripts/timeline-deepseek.mjs`,用 SQLite 在线备份复制本机 2.2 GB 数据库,原库只读):升级 2.0 秒,`integrity_check` 为 ok。原有 423 个事件全部得到起止时间与项目;13 个两次回顾重复提取的事件被标记替代,当前 410 个(goodbuddy 358、默认项目 28、dgx 17、微信 ClawBot 5、阿里云-国内 2);全局图谱返回 67 个小时段、790 轮对话的密度。
|
||||
- 真实模型(DeepSeek,`deepseek-flash`):对 goodbuddy 最近 2 小时的 37 个来源做项目回顾,4 个批次加 3 次导航汇总,共 7 次请求,新增 27 个事件,起止时间都在区间内,未关联到其他项目的实体;再次项目回顾和同区间全局回顾均为无变化,0 次请求,全局重新提取的项目来源为 0。
|
||||
|
||||
边界:
|
||||
|
||||
- 这个 2 小时区间没有其他项目的消息,所以“全局只补其他项目”只在合成测试中验证过。
|
||||
- 模型通常让一个事件只引用一条消息,所以真实数据里的事件起止时间多数是同一时刻。按故事计算跨度要等第 2 步的故事归属。
|
||||
- 测试脚本把单次输出上限设为 8,192 token 时,一个批次的输出被截断,导致 JSON 无效。上限调到 32,768 后通过。产品路径不设这个参数,具体由 Runtime 的提供商配置决定。
|
||||
|
||||
## 2026-09-30 心跳职责收敛与监督建议
|
||||
|
||||
对应 FR-S4、FR-S12、US-S32、US-S33,规则见[心跳职责与介入](./logic-design.md#心跳职责与介入目标设计)。
|
||||
|
||||
实现:
|
||||
|
||||
- `HeartbeatService` 不再读取来源或调用模型。领取计划后立即完成自身运行,再通过 `review` 触发共享的 `SupervisorService.run({ trigger: 'heartbeat' })`;审查结果 `no_change` 时标记心跳为无变化。计划领取、单次补跑、单回顾排队和失败记录沿用原有机制。
|
||||
- 手动“回顾”默认增量:`supervisionRunRequestSchema` 新增可选 `reanalyze`,`isIncrementalReview` 决定是否读取、推进 `review_checkpoints`。只有 `reanalyze: true` 重读区间,且不推进也不重置共享进度。自动检查不再续跑用户主动暂停的回顾,改为新建运行处理未提交来源。
|
||||
- 计划新增 `intervention`(`suggest` / `memory`,默认 `suggest`)。`supervision-suggestions.ts` 从本次发布结果按规则挑选候选:批次未决事项、`revised` 实体变化、`contrasts` 关系、由至少三处不同原始来源支持的未确认实体。没有候选时不调用模型;有候选时 `supervision-suggester.ts` 只把候选文本交给一次建议调用,不读原文。待处理建议不重复,已处理建议仅在依据变化后再次提出。
|
||||
- 建议失败写入 `heartbeat_runs.suggestion_status/error`,图谱和进度保留;活动页显示“回顾已更新,建议生成失败”并可单独重试。
|
||||
- 接受未决事项生成暂停任务,接受候选约定确认为长期背景(记忆)并确认对应实体;分歧和修订标记已核对。
|
||||
- Schema 50:`heartbeat_configs.intervention`、`heartbeat_runs.suggestion_status/suggestion_error`、`supervision_suggestions` 表。升级时把旧心跳报告中仍为 `proposed` 的记忆迁入为待确认约定,不确认、不删除;旧报告只读保留。
|
||||
- 界面:工作回顾主按钮改为“回顾”,“更多 → 重新整理…”经确认后执行;计划 Modal 增加“介入方式”;自动监督页用“监督建议”替代成功率、趋势、记忆/行动建议和运行审计,建议可查看依据、打开会话、在图谱中查看;设置移除心跳报告超时;导航角标统计待处理建议。
|
||||
|
||||
验证:
|
||||
|
||||
- 定向单元与集成测试:`src/main/assistant`、`src/shared`、`src/preload` 共 589 项;`src/main/ipc.test.ts` 心跳与监督相关 29 项;Renderer 监督者、自动监督、活动、设置相关组件测试及 `App.test.tsx` 全文件均通过。新增覆盖:手动增量与重新整理不动进度、不续跑已暂停回顾、候选规则与去重、忽略后仅新证据重提、建议失败隔离、IPC 启停门控、Schema 49 升级迁移。
|
||||
- Electron 布局:`GOODBUDDY_SUPERVISOR_RECAP`、`AUTOMATIC_OVERVIEW`、`ACTIVITY`、`PLAN_MODAL` 场景通过,覆盖 1440/390px 下监督建议、介入方式字段、重新整理确认和工具栏两行布局,无横向溢出。
|
||||
- 真实模型(DeepSeek,`deepseek-flash`,隔离 SQLite,`scripts/heartbeat-deepseek.mjs`):首次心跳 1 次回顾调用 + 1 次建议调用(建议输入约 1,150 字符,不含原文),生成分歧、修订、未决事项三条建议且依据均指向本次来源;无变化心跳与默认手动回顾均 0 次调用;追加一条消息后只处理 1 个来源,复用全部 5 个既有实体,再调用 1 次回顾、1 次建议。共 4 次付费请求。
|
||||
- 未运行全量测试。
|
||||
|
||||
边界:主动介入(会话级实时)未实现,界面不显示;项目与全局仍各自维护处理进度(US-S31),已由 2026-10-02 的共享时间线取代。
|
||||
|
||||
## 2026-09-28 Story Graph 只读工具
|
||||
|
||||
对应 FR-S11、US-S28 至 US-S30 的 D1 读取实现。输入、分页与版本语义由[专项设计](./story-graph-mcp-design.md)负责。US-S31 的跨范围事实复用和 memory 读取切换仍待实现。
|
||||
|
||||
@@ -40,6 +40,8 @@ schema 47 增加运行配置、来源清单、成功批次、导航节点四张
|
||||
| `supervisionReview.batchCharacters` | 8000 | 1000..16000 | 每批新正文 UTF-16 单元,不含提示、候选及 JSON |
|
||||
| `supervisionReview.batchMessages` | 20 | 1..50 | 每批不同消息数;任务等无消息 ID 的来源单独计数 |
|
||||
| `supervisionReview.executionSeconds` | 300 | 30..3600 | 旧设置兼容字段,执行时忽略,设置页不再展示;恢复旧 run 同样不按此值暂停 |
|
||||
| `supervisionReview.crossProject` | 关闭 | 开关 | 创建回顾时冻结;关闭时每批只以本项目已有实体为候选,也不生成跨项目故事;开启后候选来自全部项目,故事归属可建立跨项目故事 |
|
||||
| `supervisionReview.storyThreadEvents` | 20 | 4..500 | 创建回顾时冻结;功能的事件数低于此值时,模型提出的子线索并入功能 |
|
||||
| `supervisorOrganizeTimeoutSeconds` | 240 | 30..600 | 创建回顾时冻结,节点获槽并解析 Runtime 后计时 |
|
||||
| `heartbeatReportTimeoutSeconds` | 240 | 30..600 | 维持报告发起时冻结及既有租约规则 |
|
||||
| `supervisorModelConcurrency` | 1 | 1..4 | 回顾保存接纳并发;共享池上限仍随应用设置实时变化,实际执行取较小值 |
|
||||
@@ -50,7 +52,7 @@ schema 47 增加运行配置、来源清单、成功批次、导航节点四张
|
||||
|
||||
- 真实验证覆盖一个 27 条消息的完整会话;较早的 129 条消息实验在第二次输出 JSON 无效后停止。两实验合计 5 次付费请求,没有重复付费或回退。范围及缓存复核说明见[进度](./progress.md#2026-09-24-完整会话生产验证与收尾)。
|
||||
- 来源被修改或删除时保留旧批次并明确失败,标记需要新建回顾,活动页停止提供无效的继续按钮;下一次自动检查可创建新 run,不会永久重复该失败。尚未实现仅使受影响叶子和父节点失效后重算。清单记录冻结版本,不保存全库正文快照。
|
||||
- 分块配置、超时、接纳并发及算法版本已保存。实际默认 Runtime、模型及候选背景仍在执行时解析;未实现跨重启模型/提示词版本固定,也未实现共享池排空后切换冻结并发的目标规则。
|
||||
- 分块配置、超时、接纳并发及算法版本已保存。监督者模型连接在每次执行开始时解析,同次回顾复用;继续或重试读取当前选择,规则见[监督者模型选择](./logic-design.md#监督者模型选择)。候选背景仍在执行时读取;未实现跨重启模型/提示词版本固定,也未实现共享池排空后切换冻结并发的目标规则。
|
||||
- 元数据清单和最终发布仍是同步 SQLite 事务,缺少大库延迟验证及可中断来源读取。批次详情已分页,既有历史图谱查询仍返回整份结果;大图谱的响应大小尚未改造。
|
||||
- 没有 token/费用预算、提供商 RPM/TPM、Retry-After 冷却、执行硬期限、独立清理期限或逐层可配置超时。默认正文预算不保证任意模型上下文容量;超长输出、非法 JSON 和引用错误会失败,保留已成功批次。
|
||||
- 片段以码点边界切分,尚未增加句界优先和重叠背景。事实保留指保留模型已产出的结构化记录,不证明语义无遗漏;生产契约校验引用 token,不要求原型的逐字引文和 reviewedTokens 回执。提供过的 locator.source 仅在本节点唯一对应一个片段时可作为引用别名;歧义及未知来源拒绝。
|
||||
|
||||
@@ -112,12 +112,17 @@ D1 来源页默认 4,000、上限 8,000 码点。`current` 通过既有 locator
|
||||
| 配置或目录 | 当前状态 | 目标映射 |
|
||||
| --- | --- | --- |
|
||||
| `heartbeatEnabled` | 监督者应用开关,新设置默认关闭 | 为 Story Graph 暴露和每次调用的必要条件;关闭后不列出三个工具,旧会话直接调用也拒绝 |
|
||||
| 会话 `storyGraphEnabled` | 每个会话默认开启,缺省字段按开启处理 | 关闭后不向该会话签发图谱工具,并拒绝已有能力会话的发现、调用和在途结果交付;其他会话不受影响 |
|
||||
| 内置能力 `enabled` 与 `assignments` | [CapabilityService](../../../src/main/capabilities/capability-service.ts)管理现有 MCP 开关与 Runtime 分配 | `story-graph` 沿用同一管理方式;应用关闭时不得由内置能力开关绕过,保留用户原分配 |
|
||||
| 内置工具目录 | 已有 Story Graph 条目 | 映射 `story_graph_search`、`story_graph_get_context`、`story_graph_read_source`,全部 `access: read`、`readOnlyHint: true` |
|
||||
| 监督整理参数 | 超时、并发、来源页与批次设置已存在 | 控制后台整理;不让一次 MCP 查询启动整理,也不以正文批次预算截断检索结果 |
|
||||
| 计划 `enabled` | 决定自动回顾是否到期执行 | 不决定已有图谱能否读取;应用开启且能力可用时,无计划也能查已有结果 |
|
||||
|
||||
Main 在工具发现和调用分发时共同检查应用启用、能力启用、Runtime 分配和 scope;只隐藏菜单不足以阻止旧会话调用。关闭后拒绝新的读取请求,已在途响应交付前复核状态,避免旧绑定继续返回数据。启用工具本身不产生回顾或数据写入。Ask 与 Execute 均可读取,同样没有写工具,也不借此扩大 Execute 权限。
|
||||
Main 在工具发现和调用分发时共同检查应用启用、会话开关、能力启用、Runtime 分配和 scope;只隐藏菜单不足以阻止旧会话调用。关闭后拒绝新的读取请求,已在途响应交付前复核状态,避免旧绑定继续返回数据。启用工具本身不产生回顾或图谱写入。Ask 与 Execute 均可读取,同样没有写工具,也不借此扩大 Execute 权限。
|
||||
|
||||
会话开关只决定 Agent 能否按需使用这三个 MCP 工具,不自动查询或注入图谱上下文,也不触发回顾。监督者应用关闭时,输入区完全隐藏开关并保留会话选择;再次启用应用后恢复该选择。后台回顾和计划仍由各自设置控制。
|
||||
|
||||
开关复用已发布的 `conversations.context_state_json`,字段为可选布尔值,不增加数据库版本或迁移。Renderer 经串行会话保存队列与 `conversations:set-story-graph` IPC 保存,成功后更新显示,失败保留原值并通知。Main 按会话主键只读设置,不加载消息历史;显式设置写入后,旧的自动保存快照不能覆盖它。新会话及新建分支使用默认开启。
|
||||
|
||||
本地 OpenCode、Continue 和原生客户端复用 [KnowledgeMcpGateway](../../../src/main/agent/knowledge-mcp-gateway.ts) 的 MCP 注册和分发;直连 Model 与 DeepSeek Harness Main 代理调用同一服务。远程 OpenCode/Continue 复用现有图像工具的受管 HTTP MCP 和二进制通道,`storyGraph` 描述独立于图像生成描述,Ask 可只携带图谱读取。请求仍由桌面 Main 解析范围和读取 SQLite,不把数据库、正文库或凭据复制到 Host。
|
||||
|
||||
@@ -162,4 +167,4 @@ D1 可将现有已确认偏好作为明确标记的背景投影,原读取路
|
||||
| Runtime 一致性 | US-S30 | 本地及 GoodBuddy Agent 远程实际链路具有相同 scope、版本、分页、引用与关闭行为,不能以共享 schema 代替接线验收 |
|
||||
| 记忆及范围复用 | US-S31 | 确认偏好与已有引用保留;同版本成功叶子跨 scope 复用且能检索事实,不只返回摘要;历史缺口不补造 |
|
||||
|
||||
验证命令和证据集中记录在[实施进度](./progress.md#2026-09-28-story-graph-只读工具)。本次只运行定向测试、类型检查、限定文件 lint 和确定性 Host 探针,没有全量测试、付费模型请求或发布操作。真实模型如何根据冲突证据形成最终建议,仍需与工具读取正确性分开验收。
|
||||
2026-09-28 的 D1 验证命令和证据记录在[实施进度](./progress.md#2026-09-28-story-graph-只读工具),当时运行定向测试、类型检查、限定文件 lint 和确定性 Host 探针,没有付费模型请求。2026-10-03 会话开关的 Electron、Host 及真实模型调用证据见[本轮记录](./progress.md#2026-10-03-会话故事图谱开关)。真实模型如何根据冲突证据形成最终建议,仍需与工具读取正确性分开验收。
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
# 故事线模型改造设计
|
||||
|
||||
状态:2026-10-02 已确认的目标设计。第 1 步(共享时间线、事件起止与密度、跨项目开关)、第 2 步(故事归属与用户调整)、第 3 步(时间螺旋视图)、第 4 步(经验抽取)和第 5 步(心跳故事建议、MCP 故事与经验查询、工作回顾按故事组织)已实现,见[实施进度](./progress.md)。本文定义监督回顾如何产出下一版故事线(时间螺旋、分层故事木片、经验 W),是 [3D 故事线图谱概念](../story-graph/3d-concept.md) 的数据来源合同。现有回顾流程见[回顾分块调度设计](./review-scheduling-design.md),现有图谱工具见 [Story Graph MCP 设计](./story-graph-mcp-design.md)。
|
||||
|
||||
## 1. 结构总览
|
||||
|
||||
| 层 | 含义 | 来源 |
|
||||
| --- | --- | --- |
|
||||
| 全局(根) | 整条时间螺旋本身,即用户的全部经历;不是一片木片 | 所有来源 |
|
||||
| 项目木片 | 按会话所属项目划分;无项目归属的内容进入“未归属项目” | 会话归属,确定性,不调用模型 |
|
||||
| 跨项目故事 | 同时涉及多个项目或不属于任何项目的长期事情,例如长期偏好、学习方向 | 故事归属阶段 |
|
||||
| 功能木片 | 项目内的长期功能或主题 | 故事归属阶段 |
|
||||
| 子线索 | 事件量足够大的功能再拆分;小功能不拆 | 故事归属阶段 |
|
||||
| 经验 W | 从故事中形成、可复用到其他故事的经验,默认是全局对象 | 经验抽取阶段 |
|
||||
|
||||
全局不作为单独的“全局记忆”重复处理:它是根视图,读取同一套事件、故事和经验。查看某个项目时,只是以该项目为过滤条件。
|
||||
|
||||
## 2. 已确认的决定
|
||||
|
||||
| 编号 | 决定 |
|
||||
| --- | --- |
|
||||
| SL-1 | 层级为 全局 › 项目 › 功能 › 子线索;子线索仅在功能事件量达到阈值时出现,阈值可在设置中调整 |
|
||||
| SL-2 | 跨项目或无项目的长期事情,作为全局根下的“跨项目故事”,与项目木片并列 |
|
||||
| SL-3 | 每个事件只归属一个主故事,可另外关联其他故事;关联不影响木片的长度和密度统计 |
|
||||
| SL-4 | 经验自动生成即可供 Agent 读取,不设确认门槛;标记“自动归纳”,用户可以编辑、合并、删除 |
|
||||
| SL-5 | 用户对故事和经验的调整(改名、合并、拆分、移动事件、编辑、删除)受保护,后续回顾不覆盖;删除的对象不会因同一证据再次生成 |
|
||||
| SL-6 | 注意力密度按来源统计:时段内的消息轮次与正文量,不由模型判断;只表示投入集中程度 |
|
||||
| SL-7 | 每个来源版本只提取一次,项目与全局共用提取结果和增量进度 |
|
||||
| SL-8 | 设置提供“跨项目关联”,默认关闭。关闭时新内容只关联本项目已有的知识;开启后可关联其他项目中的同一件事,跨项目故事也只在开启时生成 |
|
||||
|
||||
## 3. 回顾流程改造
|
||||
|
||||
```text
|
||||
采集来源(按来源版本增量)
|
||||
↓
|
||||
批次提取:事件带开始/结束时间(取原始消息时间)、来源引用
|
||||
↓
|
||||
故事归属(新增,不读原文)
|
||||
· 项目:按会话归属确定
|
||||
· 功能/子线索/跨项目:召回候选故事 → 一次模型调用判断归入已有、新建或暂不归类
|
||||
↓
|
||||
经验抽取(新增,低频,不读原文)
|
||||
· 规则挑候选:有明确结果的决定、多个故事中重复出现的做法、说明原做法问题的修订
|
||||
· 有候选时一次模型调用:经验内容、形成依据、适用条件、边界
|
||||
· 记录后续应用及结果
|
||||
↓
|
||||
导航摘要 → 发布
|
||||
```
|
||||
|
||||
规则:
|
||||
|
||||
- 故事跨回顾保持同一身份,沿用实体候选编号机制;长时间无事件的故事能被接回,不新建。
|
||||
- 故事状态(进行中、已结束)必须有内容依据;长期无事件只标“暂无进展”,不推断结束。
|
||||
- 允许“暂不归类”,不为凑层级强行归组;暂不归类的事件在项目下以淡色木片显示。
|
||||
- 经验的每条依据必须对应到具体事件和来源,否则不保存;没有候选时不调用模型。
|
||||
- 新增阶段失败时,已发布的提取结果和处理进度保留,可单独重试该阶段。
|
||||
|
||||
## 4. 用户调整
|
||||
|
||||
图谱是可编辑的结果,不需要逐条审批:
|
||||
|
||||
- 故事:改名、合并、拆分、调整层级、把事件移到其他故事。
|
||||
- 经验:编辑内容与适用条件、合并重复、删除。
|
||||
- 入口位于故事线图谱的详情面板与 3D 视图选中对象的详情中,共用同一组操作。
|
||||
- 每次调整可撤销,并记录在活动记录的修改记录中。
|
||||
|
||||
## 5. 展示与使用
|
||||
|
||||
- 故事线图谱新增 3D 视图,与现有平铺视图并列,几何、时间尺度、聚合规则沿用 [3D 概念](../story-graph/3d-concept.md);Three.js 仅在打开 3D 视图时加载,WebGL 不可用时使用平铺视图。
|
||||
- MCP 增加按故事、按经验查询;经验返回时附“自动归纳 / 用户编辑”标记和依据。
|
||||
- 心跳建议增加“故事长期无进展”和“已有经验可能适用于当前故事”。两者都由规则选出:前者按设置的天数判断,只提示不改状态;后者要求共享不过于通用的实体。每次心跳各最多 3 条,排在未决事项之前。
|
||||
- 工作回顾按故事组织:推进了哪些故事、新出现哪些故事、形成或应用了哪些经验。
|
||||
|
||||
## 6. 存储
|
||||
|
||||
新增故事表(含层级与父故事)、事件与故事的归属/关联、经验表、经验依据与应用记录、用户调整记录;对应一次数据库升级。已有回顾结果在升级后按新流程重建,原始来源不变。
|
||||
|
||||
## 7. 实施顺序与验收
|
||||
|
||||
| 步骤 | 内容 | 验收(真实数据副本 + `.env.deepseek.local` 中的 DeepSeek) |
|
||||
| --- | --- | --- |
|
||||
| 1 | 统一时间线(SL-7)、事件时间跨度与密度(SL-6) | 重叠回顾不产生重复事件;项目与全局不重复处理同一来源 |
|
||||
| 2 | 故事归属与用户调整(SL-1~SL-3、SL-5) | 多次增量回顾后故事不碎片化;间断后的事件接回原故事;调整不被覆盖 |
|
||||
| 3 | 3D 视图接入真实数据 | 长期数据下时间尺度与聚合正常 |
|
||||
| 4 | 经验抽取(SL-4) | 每条经验依据可追溯到原文;无候选时零额外调用 |
|
||||
| 5 | MCP、心跳建议、工作回顾页 | Agent 能按故事接手工作、按经验给出建议 |
|
||||
@@ -11,7 +11,7 @@
|
||||
| 名称 | 职责 |
|
||||
| --- | --- |
|
||||
| 监督者 | 应用名称,负责观察、回顾和解释工作进展 |
|
||||
| 智能心跳 | 按计划唤醒监督者的机制,保留计划与运行管理 |
|
||||
| 智能心跳 | 按计划触发增量回顾,并按配置的介入方式生成建议;不单独读取原文或生成报告(FR-S12) |
|
||||
| 监督反馈 | 会话右侧的阶段回顾、问题提示与证据入口 |
|
||||
| 故事线图谱(Story Graph) | 呈现事件、知识实体及其随时间发展的关系 |
|
||||
|
||||
@@ -19,10 +19,12 @@ FR-S1:将现有智能心跳入口整合、升级为监督者,不增加另一
|
||||
|
||||
应用默认关闭,用户显式启用后才接受新的回顾工作;关闭时保留计划和历史。默认值、已保存选择、自动运行条件及在途运行边界由[应用启停与执行](./logic-design.md#应用启停与执行)统一定义。
|
||||
|
||||
FR-S2:应用包含“工作回顾 / 故事线图谱 / 自动监督 / 活动记录 / 设置”五个主视图。工作回顾提供统一监督结果阅读、历史选择和手动新回顾,结果显示自身范围与日期;自动监督集中展示计划状态、范围、周期及下次执行时间,以及运行概览、指标、趋势、自动监督报告、待处理记忆与行动建议和心跳审计,创建和编辑使用同一份 Modal 表单;活动记录展示统一实际执行、进度、详情及结果入口,支持按计划 ID 筛选;设置只管理超时、并发、分页、分批及单次响应容量,不嵌套次级菜单。计划保存失败保留草稿,关闭未保存修改须确认;编辑已暂停计划保持暂停,创建须显式保存并启用。原有运行记录及应用、存储标识保留。
|
||||
FR-S2:应用包含“工作回顾 / 故事线图谱 / 自动监督 / 活动记录 / 设置”五个主视图。工作回顾提供统一监督结果阅读、历史选择和手动新回顾,结果显示自身范围与日期;自动监督集中展示计划状态、范围、周期及下次执行时间,以及运行概览、指标、趋势、自动监督报告、待处理记忆与行动建议和心跳审计,创建和编辑使用同一份 Modal 表单;活动记录展示统一实际执行、进度、详情及结果入口,支持按计划 ID 筛选;设置管理模型选择、超时、并发、分页、分批及单次响应容量,分类页签见 UI 设计。计划保存失败保留草稿,关闭未保存修改须确认;编辑已暂停计划保持暂停,创建须显式保存并启用。原有运行记录及应用、存储标识保留。
|
||||
|
||||
当前设置还支持分别调整报告与监督整理的模型超时,以及两者共用的监督并发数。并发设置不限制普通聊天,也不保证避免上游限流;排队不消耗模型阶段时限,降低并发不打断在途请求。取值及生效规则由[当前控制清单](./review-scheduling-design.md#当前超时与调度控制清单已实现)定义。
|
||||
|
||||
FR-S13:监督者的“设置 → 模型”可选择跟随应用默认文本模型或已有文本模型连接,复用连接及其凭据。回顾执行中修改设置不切换模型;生效时点、继续和重试规则见[监督者模型选择](./logic-design.md#监督者模型选择)。
|
||||
|
||||
FR-S10:活动同时覆盖手动监督回顾、定时心跳及用户立即运行的心跳。每条记录显示触发方式、范围、开始与结束时间、运行状态、失败原因和已有结果入口。同一次心跳的报告和下游监督回顾合为一条活动,报告保存成功不能掩盖下游仍在运行、暂停或失败。活动读取不调用模型,关闭应用后不查询活动。新批次运行支持暂停及继续,保留成功批次;人工修改的时间序列审计尚未实现,历史缺失不得猜测补齐。
|
||||
|
||||
FR-S2:分页只控制数据库读取批量,不能限制最终处理范围。当前已开放来源页大小、每批正文/消息上限;这些配置在新建回顾时保存,继续沿用。单次响应容量按当前设置生效。回顾持续处理至完成,不按累计运行时长自动暂停。当前字段和未来高级参数的边界见[生产接线](./review-scheduling-design.md#0-生产接线与剩余边界),交互见 [UI 设计](./ui-design.md#算法配置设置目标)。完整覆盖、来源校验与事实保留不作为可关闭选项。
|
||||
@@ -33,6 +35,10 @@ FR-S3:首次启用时选择项目、会话及可用来源,生成前显示范
|
||||
|
||||
FR-S4:支持手动回顾,并复用已有定时检查机制。自动报告和监督回顾分别只处理所选范围、回顾窗口内新增、修订或尚未处理的来源片段;没有待处理输入时跳过模型及结果生成。失败输入保留重试资格,预算遗漏不得记为已处理。手动回顾重新整理所选历史区间。具体规则见[自动增量与手动重看](./logic-design.md#自动增量与手动重看)。事件触发和阶段自动判定仍待设计。
|
||||
|
||||
回顾默认增量“回顾”默认增量,与心跳整理一致;重看历史改为“更多”中的显式“重新整理”。心跳不再先生成独立报告,同一来源版本在一次触发中只读取一次。规则见[心跳职责与介入](./logic-design.md#心跳职责与介入目标设计)。
|
||||
|
||||
FR-S12(已实现,主动介入除外):智能心跳的职责限定为**定时触发增量回顾**和**按计划配置介入**。介入方式为“仅更新记忆”“生成建议”(默认);会话级“主动介入”后续交付,当前不显示。建议只根据本次图谱变化产生,附来源依据,替代旧心跳报告、记忆建议和行动建议;无变化不调用模型,有变化而无建议候选时不发起建议调用。验收见 US-S32、US-S33。
|
||||
|
||||
FR-S5:会话右侧在跟随模式下展示当前会话的监督反馈;固定到 Conversation、Task 或 Experiment 时保持所选目标并显示其名称,切换聊天不改变监督目标。跨会话总结在完整应用中展示。反馈包含结论、来源和“在图谱中查看”定位入口。用户主动选择“发到当前会话”才加入聊天消息,同一结果不重复触发侧栏和全局通知。
|
||||
|
||||
FR-S6:监督者可提出整理与修改建议;已有会话监督的观察边界继续生效。自动更新图谱不能静默覆盖用户修改的描述、已确认的关系或已有知识库正文。取消整理后保留上次结果;失败说明受影响范围并允许重试,部分结果不得显示为完整回顾。
|
||||
@@ -55,6 +61,8 @@ FR-S11(D1 已实现,D2/D3 待实现):监督者以时态 Story Graph
|
||||
|
||||
触发、整理与结果共享规则,以及整体架构和图谱对象关系见[逻辑设计](./logic-design.md)。
|
||||
|
||||
FR-S11 的会话控制:输入区对话设置在知识库上方提供“使用故事图谱”,每个会话默认开启并独立保存。仅在监督者应用启用时显示,应用关闭时完全隐藏。关闭会话开关后,本地及远程 Agent 不再获得图谱工具,已有能力会话调用也被拒绝;开启仅允许 Agent 自主决定是否调用 MCP,不注入图谱上下文或触发回顾。验收见 US-S30。
|
||||
|
||||
首个完整流程为:启用监督者、选择范围、生成回顾、进入图谱、核对来源、携带选定上下文继续讨论。知识库双向定位与内容补充随后接入同一流程。图谱关系辅助检索需单独验证效果,不作为首版默认检索策略。
|
||||
|
||||
当前工作回顾和图谱读取生产结果,活动展示实际执行与无变化检查、暂停和继续入口,自动监督设置复用已有计划。完整图谱回放及应用市场尚未实现,验收证据见[实施进度](./progress.md)。
|
||||
|
||||
@@ -9,11 +9,11 @@
|
||||
| 产品设计 | [监督者应用产品设计](./supervisor-prd.md) |
|
||||
| 行为规则 | [监督者逻辑设计](./logic-design.md) |
|
||||
| 界面设计 | [监督者 UI 设计](./ui-design.md) |
|
||||
| 实施进度 | SQLite schema 47;当前算法、设置和验收边界见[生产接线](./review-scheduling-design.md#0-生产接线与剩余边界) |
|
||||
| 实施进度 | SQLite schema 50(心跳介入与监督建议见[心跳触发与建议](#心跳触发与建议2026-09-30));当前算法、设置和验收边界见[生产接线](./review-scheduling-design.md#0-生产接线与剩余边界) |
|
||||
|
||||
本文回答如何在现有 GoodBuddy 桌面端中实现监督者。它不改变产品范围,也不把模拟 Demo 当作生产数据模型。
|
||||
|
||||
FR-S11 的读取架构见[时态 Story Graph 内置 MCP 设计](./story-graph-mcp-design.md)。三个只读工具通过 `readStoryGraph` 投影现有 SQLite,Main 在发现、调用和返回前检查监督者启用及 Runtime 分配。本地 MCP、Model、Harness Main 代理和远程受管工具通道共用此入口;无 schema 迁移。该文独立定义对象/版本合同、时间语义、配置映射及后续跨 scope 复用和记忆过渡。当前 memory 背景读取、精确 scope checkpoint 与候选身份保留;结果快照不支持 `as_of` 历史重建。
|
||||
FR-S11 的读取架构见[时态 Story Graph 内置 MCP 设计](./story-graph-mcp-design.md)。三个只读工具通过 `readStoryGraph` 投影现有 SQLite,Main 在发现、调用和返回前检查监督者启用、会话开关及 Runtime 分配。绑定携带会话 ID,按主键读取当前设置;原生客户端的复用键包含会话 ID 和开关值。本地 MCP、Model、Harness Main 代理和远程受管工具通道共用此入口;会话开关复用 `context_state_json`,无 schema 迁移。该文独立定义对象/版本合同、时间语义、配置映射及后续跨 scope 复用和记忆过渡。当前 memory 背景读取、精确 scope checkpoint 与候选身份保留;结果快照不支持 `as_of` 历史重建。
|
||||
|
||||
生产入口由 `supervision-production.ts` 组装服务、SQLite、共享池和 ask Runtime。`supervision-review-store.ts` 保存冻结来源清单,按有界页和片段供 `SupervisorService` 派发;叶子及连续位置先提交,导航和完整结果之后发布。消息已有知识引用保留本地或外部 locator,已确认记忆通过独立背景输入提供。不会检索外部全库。详细存储、调度及当前限制统一见[分块调度第 0 节](./review-scheduling-design.md#0-生产接线与剩余边界)。
|
||||
|
||||
@@ -41,6 +41,12 @@ schema 43 在结果上增加 `graph_snapshot_json`,保存当次实体名称、
|
||||
|
||||
已确认记忆作为当前背景,不参与新增正文覆盖。最多四条、每条 500 字符,时间来自真实更新时间;模型不能把背景引用为本批新增证据。旧结果继续保留原来源类型和 locator。监督正文上限现用于分批,余段继续处理;心跳报告的有界输入语义没有改变。
|
||||
|
||||
## 监督者模型连接
|
||||
|
||||
`ApplicationSettings.supervisorModelProfileId` 只保存已有连接 UUID,`null` 或旧设置缺少字段时跟随 `RuntimeSettings.defaultModelProfileId`。Main 的 `resolveSupervisorRuntime` 从 `getResolvedSettings()` 读取连接和已有凭据,校验文本协议及认证后,用 `createModelProfileRuntime` 创建无工具直连 Runtime。Renderer 仅接收公开连接信息,不新增凭据存储或 IPC 方法。
|
||||
|
||||
`SupervisorService.admit` 在执行位置内调用生产工厂的 `withExecution`,为提取、导航、故事及经验共享一个 Runtime;执行结束后释放。每次请求仍释放自身临时会话。建议措辞在自身调用开始时解析同一设置,结束后释放 Runtime。生命周期规则见[监督者模型选择](./logic-design.md#监督者模型选择)。这条路径在桌面 Main 内运行,即使证据来自远程项目,也不经过 GoodBuddy Agent 或远程模型桥。
|
||||
|
||||
## 模型阶段超时与调度
|
||||
|
||||
监督整理超时由 run 创建时冻结;共享模型池仍采用实时设置上限。`supervision:pause` 取消该 run 的排队和在途工作,`supervision:resume` 继续已保存批次;监督整理输出在流中按 `supervisionReview.responseKiB` 检查响应容量,默认 1024 KiB,可在设置页调整为 100 至 16384 KiB。该容量每次请求读取当前设置,继续旧运行也采用新值;超限保留成功批次并提示调高后继续。生成摘要及事实内容不设独立短字符限制,监督旧摘要读取保留全文,具体边界见[生产接线](./review-scheduling-design.md#0-生产接线与剩余边界)。以下报告领取和租约规则继续适用。
|
||||
@@ -65,8 +71,19 @@ schema 47 的四张批次相关表使用外键随监督运行清理,未完成
|
||||
|
||||
Preload 暴露类型化 `execution`、`cancel`,取消输入使用 UUID schema 并校验可信 sender。活动 UI 用实时执行状态覆盖持久记录的停止显示;工作回顾挂载期间每两秒读取执行状态,发起前再次查询,Main 仍作最终准入判断。`resume` IPC 直到运行结束才返回,Renderer 发出请求后释放提交锁并独立处理结果,确保继续中的回顾仍可暂停或取消。状态条 X 仅修改本地显示状态。
|
||||
|
||||
### 心跳触发与建议(2026-09-30)
|
||||
|
||||
对应 FR-S12,规则见[心跳职责与介入](./logic-design.md#心跳职责与介入目标设计)。`HeartbeatService` 只依赖 `HeartbeatActions { review, suggest? }`:领取后 `completeHeartbeatTrigger` 立即提交心跳自身运行(租约 5 分钟,仅覆盖交接),再调用 `review`,由 `ipc.ts` 转为 `supervisorService.run({ trigger: 'heartbeat' }, heartbeatRunId)`。审查失败写 `projection_status/error`,结果为 `no_change` 时标记心跳无变化。心跳不再占用模型池,也不读取 `heartbeatReportTimeoutSeconds`;该设置字段保留兼容,界面不显示。下文关于心跳报告阶段、报告租约与报告增量的描述均为历史行为。
|
||||
|
||||
`isIncrementalReview(request)` 统一判断:`trigger: heartbeat` 或未设 `reanalyze` 的手动请求读取并在发布事务中推进 `review_checkpoints`;`reanalyze: true` 从零读取且不写进度。`SupervisionReviewStore.unfinished` 只续跑 `failed/running` 的心跳运行,用户暂停的保持暂停。
|
||||
|
||||
计划 `intervention` 为 `suggest` 时,`deriveSuggestions` 调用 `SupervisionSuggestionStore.candidates(supervisionRunId)`:读取该运行叶子批次的 `openItems`(按原始来源映射到本结果 `supervision_sources`)、`revised` 实体变化、快照内 `contrasts` 关系,以及事件关联中不同原始来源数不少于 3 的 `automatic` 实体。指纹按类型与实体、关系或规范化文本生成;依据键使用原始来源类型、ID 与内容哈希,不用每次结果新生成的来源行 ID。每次最多 20 条。有候选时 `createProductionSuggestionPhraser` 发起一次无工具请求,共享监督模型池与整理超时;模型只返回 `ref/title/detail`,未知 ref 丢弃,缺失措辞回退规则文本。建议失败写 `suggestion_status = 'failed'` 与错误,`supervision:retry-suggestions` 只重跑建议。
|
||||
|
||||
IPC `supervision:suggestions`、`supervision:suggestion-action`、`supervision:retry-suggestions` 校验可信 sender 与 Zod 输入;监督者关闭时列表返回空、操作拒绝。接受未决事项创建暂停任务,接受候选约定确认迁入记忆或新建已确认记忆并确认实体。Schema 50 内容见[实施进度](./progress.md#2026-09-30-心跳职责收敛与监督建议)。
|
||||
|
||||
## 自动增量收集
|
||||
|
||||
|
||||
本节的 100 来源/2,000 码点批次查询现用于原心跳报告及旧服务测试;生产监督使用 schema 47 清单及批次位置,自动 checkpoint 只在完整发布时提交。已有 schema 46 指纹算法和 checkpoint 仍复用,不重置用户已处理位置。
|
||||
|
||||
对应 FR-S4、FR-S10 和 US-S27,行为定义见[自动增量与手动重看](./logic-design.md#自动增量与手动重看)。schema 46 在现有数据库增加 `review_checkpoints`,主键为阶段、规范化 scope 和来源标识,保存版本指纹、已处理位置和来源长度。来源标识区分消息 ID、任务 ID 和消息内知识引用位置,不使用消息时间戳作为身份。
|
||||
|
||||
@@ -4,13 +4,21 @@
|
||||
|
||||
## 应用入口
|
||||
|
||||
“设置 → 模型”还提供“监督者模型”单选框:默认“跟随应用默认”,其余选项为已保存的文本模型连接,以连接名称和模型名区分,不列图片模型。失效的已选连接保留“所选模型已不可用”,用户可改选;加载或保存期间禁用选择。沿用该分类的保存按钮、未保存草稿保护和失败重试,常显说明覆盖的阶段及[生效时点](./logic-design.md#监督者模型选择),不增加凭据输入。
|
||||
|
||||
应用中心中的智能心跳入口升级为“监督者”,沿用既有启用、常驻与排序能力。未来应用市场使用同一应用身份。应用已启用时,停用单条自动心跳计划仍可手动回顾;关闭应用后,已打开页面显示关闭状态并禁用工作区操作。默认值及运行边界见[应用启停与执行](./logic-design.md#应用启停与执行)。
|
||||
|
||||
当前应用页的顶层导航只使用一套共享 PageTabs:“工作回顾 / 故事线图谱 / 自动监督 / 活动记录 / 设置”(英文活动标签为 Activity records)。自动监督独立管理计划;设置只显示模型超时与并发、回顾算法,不含计划、报告、审计或内部页签,也不显示整页全局徽标或独立刷新行。计划范围仍在表单和每条计划内显示。整个页面只有一个一级标题;监督者 bridge 不可用时显示明确错误,设置仍可访问,不回退到旧心跳概览。共享页签保留方向键、Home/End、游标焦点和单一激活面板,窄窗口横向滚动。故事线图谱面板内另有分类页签,见[故事线图谱](#故事线图谱)。
|
||||
当前应用页的顶层导航只使用一套共享 PageTabs:“工作回顾 / 故事线图谱 / 智能心跳 / 活动记录 / 设置”(英文活动标签为 Activity records)。智能心跳独立管理心跳计划;设置不含计划、报告或审计,也不显示整页全局徽标或独立刷新行。设置内用一排分段页签按用途分为“模型 / 回顾整理 / 故事与经验 / 建议”,每次只显示一个表单:模型含整理超时与并发;回顾整理含读取条数、每批文本量与消息数、单次响应容量;故事与经验含子线索与经验阈值及跨项目关联开关;建议含暂无进展天数。各分类单独保存,只提交本分类字段并沿用其他字段的已存值;切换分类保留未保存的草稿。故事线图谱没有数据时,空状态占满可见高度,标题、说明与“工作回顾 / 刷新”按钮水平、垂直居中,不再在上方单独放刷新行。计划范围仍在表单和每条计划内显示。整个页面只有一个一级标题;监督者 bridge 不可用时显示明确错误,设置仍可访问,不回退到旧心跳概览。共享页签保留方向键、Home/End、游标焦点和单一激活面板,窄窗口横向滚动。故事线图谱面板内另有分类页签,见[故事线图谱](#故事线图谱)。
|
||||
|
||||
工作回顾只读取统一监督结果。顶部紧凑排列范围、时间、发起与刷新控件,不重复显示“新回顾”标题和来源说明,不新增 Modal。历史结果选择与图谱入口位于正文之前,即使只有一条结果也显示选择器。新回顾工具栏、状态、历史选择和结果面板铺满监督者页面壳层的可用内容宽度,与页签左右对齐,不另设阅读宽度上限;结果内部按面板内边距排版,不再单独居中窄正文。正文使用“监督结果”标题,常显生成日期、真实范围和覆盖区间,历史结果不标为最新。工作总结按原有空行分段并保留换行;本次变化和未解决事项直接使用已有字段,不补写结论或截断正文。390px 下新请求字段与操作各占两列,结果元数据可换行。
|
||||
|
||||
新回顾运行中保留已显示结果及其日期、范围,范围、时间、发起和刷新控件保持可用;重复点击发起只重新显示当前运行提示,不排入新回顾。状态条提供活动记录入口,清除计划筛选并聚焦活动页签;右侧 X 只关闭提示,不暂停或取消执行,同一次运行的轮询不重新打开已关闭提示。范围、时间和活动计划筛选保留可访问名称,不再重复显示字段标题。工作回顾不读取自动监督报告,也不显示自动报告空态、记忆与行动建议或第二行全局范围与刷新。无监督结果时,空态独占工具栏下方整行,标题与手动回顾入口水平居中;自动报告是否为空不影响此判断。运行中和暂停后使用独立状态条,提示当前状态及活动记录入口。
|
||||
新回顾运行中保留已显示结果及其日期、范围,范围、时间、发起和刷新控件保持可用;重复点击发起只重新显示当前运行提示,不排入新回顾。状态条提供活动记录入口,清除计划筛选并聚焦活动页签;右侧 X 只关闭提示,不暂停或取消执行,同一次运行的轮询不重新打开已关闭提示。范围、时间和活动计划筛选保留可访问名称,不再重复显示字段标题。工作回顾不读取历史心跳报告,也不显示自动报告空态、记忆与行动建议或第二行全局范围与刷新。无监督结果时,空态独占工具栏下方整行,标题与手动回顾入口水平居中;自动报告是否为空不影响此判断。运行中和暂停后使用独立状态条,提示当前状态及活动记录入口。
|
||||
|
||||
## 会话故事图谱开关
|
||||
|
||||
对应 FR-S11、US-S30。输入区“选项 → 对话设置”在知识库之前显示“使用故事图谱”(英文 `Use story graph`),复用共享 `toggle-row` 滑动 Switch,标签在左、开关在右,支持 Tab 和 Space。仅当监督者应用 `heartbeatEnabled` 为 true 时渲染;应用关闭或设置尚未加载时完全隐藏,不保留禁用控件或占位。
|
||||
|
||||
每个会话默认开启。保存期间保留已确认状态并禁用重复提交,成功后显示新值,失败时保留原值并通过应用通知说明错误。切换会话、重新打开或重启后恢复各自设置。开关的执行规则和持久化见 [MCP 接入合同](./story-graph-mcp-design.md#6-内置-mcp-与-runtime-接入)。
|
||||
|
||||
## 活动
|
||||
|
||||
@@ -18,9 +26,9 @@
|
||||
|
||||
对应 FR-S10、US-S26。活动按开始时间倒序,每页 50 条,提供较新/更早记录和刷新。列表保留触发方式、执行范围、开始/结束时间、实际状态、阶段状态、错误及已保存摘要。没有结果时不显示结果按钮;有监督结果时可分别进入该结果的工作回顾和故事线图谱,焦点移到目标页签。超过最近 20 条的结果仍按 ID 读取,不能回退最新结果。
|
||||
|
||||
活动页只展示统一监督活动、进度、详情及结果入口,不在列表下方重复显示自动监督概览或心跳审计。计划“执行记录”打开此页并按精确 ID 筛选,筛选器显示所选计划并提供清除;监督查询在分页前过滤。同名计划仍按 ID 区分;没有匹配记录时显示筛选无结果,不回退全部数据。自动报告正文和建议处理位于自动监督。
|
||||
活动页只展示统一监督活动、进度、详情及结果入口,不在列表下方重复显示心跳概览或心跳审计。计划“执行记录”打开此页并按精确 ID 筛选,筛选器显示所选计划并提供清除;监督查询在分页前过滤。同名计划仍按 ID 区分;没有匹配记录时显示筛选无结果,不回退全部数据。监督建议和历史心跳报告位于智能心跳。
|
||||
|
||||
自动监督页依次展示范围与“刷新自动监督”、当前状态及计划列表、运行指标、报告趋势、待确认记忆与行动建议、“自动监督报告”历史、心跳报告阶段审计。刷新统一更新此页数据。报告与运行历史每次追加 20 条,报告可展开全部摘要和洞察,切换页签保留展开与分页位置;不再单独复制最近报告。建议保留确认、忽略、带入对话、标记完成及长内容展开。活动的计划筛选不改变自动监督的统计范围。当前状态下复用计划列表的唯一创建按钮,无计划时只显示列表的未配置说明,不叠加第二个计划空态或创建入口;点击直接打开现有 Modal。报告为空时明确写“暂无自动监督报告”,与工作回顾的结果状态区分。
|
||||
“智能心跳”页依次展示范围与“刷新智能心跳”、“心跳计划”卡片、监督建议,以及只读的历史心跳报告。心跳计划卡片内不再嵌套第二层边框:标题行右侧显示运行状态点,下方一行说明文字与“创建心跳计划”按钮同行,其后直接是计划列表。说明文字按状态三选一:无计划显示“尚未配置心跳计划,目前仅支持手动回顾”,全部暂停显示暂停说明,否则显示频率帮助;不叠加第二个空态或创建入口,点击按钮打开现有 Modal。
|
||||
|
||||
加载、空记录和读取失败分开显示;读取失败保留已显示记录并提供就地重试。活动页面激活时,同轮读取活动和实时执行状态;有活动执行(包括停止清理)或运行中记录则在上次读取结束 2 秒后刷新,否则 10 秒后刷新,不重叠轮询。切换顶层页签、离开监督者或关闭应用后停止活动定时器,迟到响应不更新界面。错误后等待用户重试,不持续重发失败查询。
|
||||
|
||||
@@ -32,25 +40,27 @@
|
||||
|
||||
活动页运行中同时提供“暂停回顾”和“取消回顾”,继续后的运行也可操作。停止期间禁用两项操作,并在运行标题和当前阶段显示“正在暂停”或“正在取消”;即使持久记录已经为已取消,只要执行尚未停止就继续显示“正在取消”。重新进入活动页通过实时执行状态恢复该提示。停止后显示“已取消”,保留事实详情,不显示继续按钮或失败恢复提示;任一回顾执行或停止清理期间,其他记录的继续按钮禁用。
|
||||
|
||||
## 自动监督设置
|
||||
## 智能心跳设置
|
||||
|
||||
没有自动运行、报告或关联建议时,只展示计划配置和已有计划,不显示零值统计、空趋势、建议、报告或审计区。手动回顾不计入自动监督统计;产生自动运行或报告后展示对应结果区。
|
||||
没有自动运行、报告或关联建议时,只展示计划配置和已有计划,不显示零值统计、空趋势、建议、报告或审计区。手动回顾不计入心跳统计;产生自动运行或报告后展示对应结果区。
|
||||
|
||||
2026-09-23 当前已实现“模型超时与并发”表单:心跳报告与监督者整理分别设置,均为 30 至 600 的整数秒,默认 240;监督模型并发数为 1 至 4 的整数,默认 1。保存真实字段 `heartbeatReportTimeoutSeconds`、`supervisorOrganizeTimeoutSeconds`、`supervisorModelConcurrency`;无效输入不能提交,设置加载/保存中或锁定时禁用,失败保留草稿并提供重试。超时以阶段读取设置的时点为准,不改变已冻结的值;并发数影响后续派发,降低上限不打断在途请求。
|
||||
设置的四个分类共用一个设置面板,使用共享表面、边框和卡片圆角。面板内边距为 `--space-4`(16px),分段页签按内容宽度靠左,与下方表单间隔 `--space-6`(24px);页签和表单标题左对齐。表单不再另设边框、底色或内边距,说明段落不叠加浏览器默认外边距。页签及设置面板不参与 Flex 高度压缩,窄窗口页签在自身内部横向滚动,长表单随页面纵向滚动。
|
||||
|
||||
并发说明明确报告和监督整理共用上限,排队不计入模型超时;普通聊天不受此设置限制,但仍与监督竞争上游额度。表单不宣称提供商全局并发、RPM、TPM 或 Retry-After 限流已实现。
|
||||
模型分类的标题为“模型与运行限制 / Model and execution limits”,包含监督者模型选择、整理超时与并发。整理超时为 30 至 600 的整数秒,默认 240;监督模型并发数为 1 至 4 的整数,默认 1。保存字段为 `supervisorModelProfileId`、`supervisorOrganizeTimeoutSeconds`、`supervisorModelConcurrency`;心跳不再单独调用报告模型,此处不显示报告超时。无效输入不能提交,设置加载/保存中或锁定时禁用,失败保留草稿并提供重试。超时以阶段读取设置的时点为准,不改变已冻结的值;并发数影响后续派发,降低上限不打断在途请求。
|
||||
|
||||
并发说明明确监督模型调用共用上限,排队不计入模型超时;普通聊天不受此设置限制,但仍与监督竞争上游额度。表单不宣称提供商全局并发、RPM、TPM 或 Retry-After 限流已实现。
|
||||
|
||||
模型超时不包含数据收集与保存,直连模型另有独立 600 秒传输超时。“回顾算法”表单提供“每次读取来源条数”“每批文本量”“每批消息数”和“单次响应容量”,显示默认值及合法范围,使用“保存回顾算法”独立提交。文本量内部仍按 UTF-16 单元计算,界面不要求用户理解编码单位。保存经 App 的现有设置回调进入共享设置存储,加载/保存/锁定时禁用,失败保留草稿。取值以[当前设置合同](./review-scheduling-design.md#当前设置合同)为准。帮助说明回顾持续处理至完成,每批保存进度,可从活动记录主动暂停并继续;已保存配置用“同时处理任务数”解释并发,说明实际并发还受当前监督设置限制。
|
||||
|
||||
对应 US-S22 至 US-S24。“自动监督”先显示计划列表,点击创建或编辑才打开 Modal;复用原表单,不保留页面内副本。表单展示名称、项目范围、每日或每周频率、指定时间、回顾窗口(1 至 720 小时)和历史保留(1 至 365 天);每周显示星期选择。时区常显,新建使用本机时区,编辑保留计划原时区。列表上方帮助说明选择每日或每周时间、保存并启用后自动运行。
|
||||
对应 US-S22 至 US-S24。“智能心跳”先显示心跳计划列表,点击创建或编辑才打开 Modal;复用原表单,不保留页面内副本。表单展示名称、项目范围、每日或每周频率、指定时间、回顾窗口(1 至 720 小时)和历史保留(1 至 365 天);每周显示星期选择。时区常显,新建使用本机时区,编辑保留计划原时区。列表上方帮助说明选择每日或每周时间、保存并启用后自动运行。
|
||||
|
||||
无计划时显示“尚未配置自动监督,目前仅支持手动回顾”;全部暂停时另行说明。加载或首次读取失败不显示未配置状态。打开页面、Modal 或填写字段均不创建计划,只有点击“保存并启用计划”才提交创建。已有计划显示范围、周期、时区、回顾窗口、保留期限和启停状态,保留编辑、暂停、恢复、立即运行、执行记录和确认删除;暂停时不显示待执行的下次时间,编辑保存不改变暂停状态。
|
||||
无计划时显示“尚未配置心跳计划,目前仅支持手动回顾”;全部暂停时另行说明。加载或首次读取失败不显示未配置状态。打开页面、Modal 或填写字段均不创建计划,只有点击“保存并启用计划”才提交创建。已有计划显示范围、周期、时区、回顾窗口、保留期限和启停状态,保留编辑、暂停、恢复、立即运行、执行记录和确认删除;暂停时不显示待执行的下次时间,编辑保存不改变暂停状态。
|
||||
|
||||
Modal 按[共享表单 Modal 规则](../../../UI-DESIGN.md#615-表单-modal)复用 header、body、footer:标题左侧、带本地化名称和工具提示的 X 右侧;底部取消次按钮、保存主按钮整体右对齐。标题和操作区保持可见,仅表单内容滚动。表单使用独立字段容器,不嵌套设置表单;窄窗口表单单列,保留共享窗口安全边距。
|
||||
|
||||
沿用整窗 Portal、共享遮罩、背景 inert、焦点循环与关闭后焦点恢复;初始焦点进入名称。X、Escape、取消或点击遮罩关闭时,有未保存修改先确认;确认初始焦点为继续编辑,Escape 或 X 只返回编辑,点击遮罩不放弃修改。保存期间禁止关闭和重复提交,失败在表单内显示并保留草稿。
|
||||
|
||||
界面及可访问名称统一使用“自动监督 / 回顾”,保留内部 heartbeat 标识及已有计划名称、报告正文。表单继续使用共享控件和容器响应式布局,建议及审计无需先进入即可配置计划。
|
||||
界面及可访问名称统一使用“智能心跳 / 心跳计划 / 回顾”;心跳负责定时触发回顾和按计划介入,回顾本身由监督者完成,保留内部 heartbeat 标识及已有计划名称、报告正文。表单继续使用共享控件和容器响应式布局,建议及审计无需先进入即可配置计划。
|
||||
|
||||
### 算法配置设置目标
|
||||
|
||||
@@ -68,6 +78,8 @@ Modal 按[共享表单 Modal 规则](../../../UI-DESIGN.md#615-表单-modal)复
|
||||
|
||||
## 工作回顾
|
||||
|
||||
2026-09-30 已实现(规则见[心跳职责与介入](./logic-design.md#心跳职责与介入目标设计)):主按钮“回顾”执行增量整理,文案不再称“重新分析”;旁边“更多”菜单提供“重新整理…”,确认对话说明将重新分析所选区间并可能产生模型费用。计划 Modal 增加“介入方式”单选:“生成建议”“仅更新记忆”;主动介入上线前不显示。最近建议替代,每条建议显示依据、打开来源及转任务/确认/忽略。“最近建议”替代,每条建议显示依据、打开来源及转任务/确认/忽略。
|
||||
|
||||
当前页面提供全局或单项目范围,以及 1、7、30 天时间区间选择。没有自动计划也能手动回顾;成功结果显示自身冻结的范围、时间和未解决事项,可选择历史结果或进入图谱。来源筛选由本次回顾范围和时间限制;图谱详情只列出所选事件的关联来源,实体或关系列出相关事件的来源。独立的来源类型选择器尚未实现。
|
||||
|
||||
选择历史结果后,图谱读取该结果的成员和内容,标题显示其回顾区间和生成时间。切换时清空旧图谱和详情并显示加载状态,迟到请求不得替换新选择。确认、修订和移除反馈更新当前所选结果,后续自动回顾不改写旧结果。
|
||||
@@ -101,6 +113,8 @@ Modal 按[共享表单 Modal 规则](../../../UI-DESIGN.md#615-表单-modal)复
|
||||
|
||||
使用 `supervisor` 页面壳层,最大内容宽度为 `1440px`。当前生产图谱左侧为紧凑的事件、实体和关系列表,中间为 SVG 画布,右侧为详情与来源。桌面列宽为 `216px minmax(0, 1fr) 280px`,画布获得主要空间。外圈事件按发生时间排序后等距逆时针排列,不表示实际时间间隔;起止之间保留缺口和方向箭头。内部显示真实实体名称,实线仅连接实际事件与实体,虚线仅连接实际实体关系。
|
||||
|
||||
高度:容器宽度不小于 861px 时,图谱这一行的高度等于窗口可见高度减去图谱顶部位置,并在底部保留约 24px 边距(不小于页面底部内边距),最低 660px(再矮平铺图的文字会小于 11px,这时改为页面滚动)。三栏时整页不再出现纵向滚动;左侧列表和右侧详情在这一行内独立滚动,滚动只影响自身,不带动页面。中间画布列不出现滚动条:标题栏和底部单行“事件浏览”栏之外的高度全部给图,平铺与时间螺旋一致。画布标题为“事件与知识”,下方一行小字依次显示图谱范围、时间区间、生成时间和事件/实体数,刷新为标题栏右侧图标按钮;图谱上方不再单独占一行显示范围与时间。没有图谱数据时,刷新按钮保留在页签顶部。图例默认收起,从标题栏“图例”按钮打开,浮在画布右上角,可用 X 关闭;可见事件与实体数量常驻画布左下角。两栏(861~1190px)时第一行按同样方式填满,详情换到下一行,保持自然高度并随页面滚动。单栏保持原有页面滚动和列表 320px 上限。窗口大小或图谱上方内容(提示、工具栏)变化时重新计算。
|
||||
|
||||
实体及其影响连线复用现有 `--graph-node-*-border` 令牌;颜色用于辅助辨认实体,不代表故事分类或确认状态。选中项加粗并突出连线,其余节点降低图形强调度但保留可读文字。详情分为名称与说明、图谱关联、确认操作和来源,来源正文按需读取。图例常显,读图说明可展开。
|
||||
|
||||
画布每批最多显示 8 个事件、6 个实体;同时间事件分开排布。列表保留全部返回记录和完整名称,选择实体会定位到真实关联事件所在批次,选择关系会优先显示两端实体。滑块明确命名“事件浏览”,只切换当前事件,不还原实体历史状态,也不宣称完整历史回放。当前接口返回单条故事线,不复制 Demo 中的三条模拟故事。后续视图规划见[图谱设计](../story-graph/prd.md)。
|
||||
@@ -111,6 +125,8 @@ Modal 按[共享表单 Modal 规则](../../../UI-DESIGN.md#615-表单-modal)复
|
||||
|
||||
设置 `GOODBUDDY_SUPERVISOR_CONTENT_LAYOUT=1` 验证工作回顾、空态、活动、待处理及失败场景,在 2000、1440、1024、390px 和浅深主题下共 40 个组合,检查全宽、空态居中及位于工具栏下方、阶段连接线和长内容溢出。
|
||||
|
||||
设置 `GOODBUDDY_SUPERVISOR_SETTINGS=1` 只验证设置布局:四个分类分别覆盖浅深主题及 1440×1100、1024×768、390×480,共 24 个组合。检查五个主页签、四个设置页签、单一可见面板、16px 内边距、24px 内容间距、无嵌套表单边框及横向溢出,并保存截图与几何测量;使用生产组件和模拟设置,不调用模型。
|
||||
|
||||
设置 `GOODBUDDY_SUPERVISOR_PLAN_MODAL=1` 可只验证五页签 fixture 中的计划 Modal:创建和编辑各覆盖浅深主题、1440×1100、390×1100、390×480,检查关闭按钮、底部按钮对齐、正文滚动、鼠标命中和原生键盘操作。截图及测量写入同一产物目录。
|
||||
|
||||
设置 `GOODBUDDY_SUPERVISOR_RECAP=1` 可验证本次工作回顾分区:1440×1100 和 390×1100 下覆盖监督结果与自动报告均有内容、仅有监督结果、两者均空,另检查历史结果运行中仍可阅读以及活动入口。该分支检查每页一处刷新、自动报告和建议的归属、正文段落与宽度,并截取自动页建议、展开报告和审计区块。
|
||||
|
||||
@@ -546,13 +546,39 @@
|
||||
|
||||
| Given | When | Then |
|
||||
| --- | --- | --- |
|
||||
| 自动报告和监督已成功处理窗口内来源 | 同范围再次自动检查且来源未修改 | 两阶段均不调用模型,活动显示无变化,不新增报告和图谱事件 |
|
||||
| 监督已成功处理窗口内来源 | 同范围再次自动检查且来源未修改 | 不调用模型,活动显示无变化,不新增图谱事件或建议 |
|
||||
| 消息正文、知识引用或任务状态有修改 | 再次自动检查 | 只处理修改版本及未读片段,结合已有摘要和实体说明,允许复用已有实体 ID |
|
||||
| 来源多于单次预算或正文较长 | 一次结果成功保存后再次检查 | 继续处理窗口内遗漏或剩余片段,已送入且保存成功的部分不重复处理 |
|
||||
| 模型、校验或保存失败 | 再次检查相同输入 | 失败阶段仍可重试;已成功的另一个阶段不重复付费 |
|
||||
| 来源已经自动处理 | 用户手动回顾所选区间 | 重新整理有界历史,保留自动进度;删除报告不等同重看或重建 |
|
||||
| 来源已经自动处理 | 用户点击
|
||||
| 只有已确认记忆或背景摘要变化 | 自动检查 | 背景不单独触发模型调用 |
|
||||
|
||||
### US-S32 点击回顾只整理变化
|
||||
|
||||
**状态:已实现(2026-09-30)。** 对应 FR-S4、FR-S12,规则 H3、H4。
|
||||
|
||||
| Given | When | Then |
|
||||
| --- | --- | --- |
|
||||
| 所选范围已有成功回顾,来源未变化 | 点击“回顾” | 不调用模型,活动显示“无新变化”,保留原结果 |
|
||||
| 有新增或修订的消息 | 点击“回顾” | 只处理变化部分,关联已有实体后发布;不生成建议 |
|
||||
| 用户需要重看一段历史 | 在“更多”中选择“重新整理”并确认 | 重新分析所选区间,保留用户确认内容,不改变自动进度 |
|
||||
| 首次对某范围回顾 | 点击“回顾” | 完整分批处理所选时间区间 |
|
||||
|
||||
### US-S33 心跳按配置给出建议
|
||||
|
||||
**状态:已实现(2026-09-30),主动介入除外。** 对应 FR-S12,规则 H1、H5 至 H10。
|
||||
|
||||
| Given | When | Then |
|
||||
| --- | --- | --- |
|
||||
| 计划介入方式为“生成建议”,来源无变化 | 计划到期 | 不调用模型,不产生建议或通知 |
|
||||
| 有变化,但图谱变化中没有建议候选 | 计划到期 | 完成增量整理;不发起建议调用 |
|
||||
| 有变化,出现新的未解决事项或分歧 | 计划到期 | 整理后按候选生成建议,每条附依据并可打开来源;原文不重复读取 |
|
||||
| 介入方式为“仅更新记忆” | 计划到期 | 只更新图谱,不生成建议 |
|
||||
| 用户忽略一条建议 | 后续检查没有新证据 | 不再重复提出 |
|
||||
| 建议生成失败 | 查看活动 | 显示“已更新,建议生成失败”;图谱与进度保留,可单独重试建议 |
|
||||
| 旧计划升级 | 打开自动监督 | 范围、频率和启停状态不变,介入方式为“生成建议”;旧报告只读可查 |
|
||||
|
||||
|
||||
## 时态图谱与 Agent 读取
|
||||
|
||||
### US-S28 依据决定演变继续工作
|
||||
@@ -589,6 +615,11 @@
|
||||
| 用户进行普通聊天 | 没有继续工作或历史依据需求 | 不强制每轮查询或注入完整图谱;需要核对原话时才进一步读来源 |
|
||||
| 监督者开启且内置能力已分配,没有自动计划 | 查询已有结果 | 可以读取;查询不触发模型、回顾或业务写入 |
|
||||
| 监督者关闭,旧会话仍缓存工具 | 列出工具或直接调用 | 不暴露工具,调用被拒绝;已有数据保留 |
|
||||
| 监督者应用关闭 | 打开输入区对话设置 | 完全不显示“使用故事图谱”开关 |
|
||||
| 监督者应用开启,会话未保存过该选项 | 打开输入区对话设置 | 知识库上方显示默认开启的共享 Switch |
|
||||
| 当前会话关闭“使用故事图谱” | 本地或远程 Agent 列工具或使用旧能力会话调用 | 不暴露三个图谱工具并拒绝执行,其他会话仍按自己的设置运行 |
|
||||
| 开关已保存 | 切换会话、重启或关闭后重开监督者应用 | 恢复该会话的选择;开关不发起回顾或自动注入上下文 |
|
||||
| 保存失败 | 用户切换开关 | 保留最后确认值并显示错误,可重试 |
|
||||
| 相同范围与证据分别通过本地和远程 Runtime 查询 | 翻页、读取版本或关闭监督者 | 返回相同业务语义与引用,远程实际桥接须通过验收 |
|
||||
|
||||
### US-S31 保留偏好并复用跨范围事实
|
||||
@@ -603,6 +634,19 @@
|
||||
| 仅部分片段成功或原来源已改版 | 再次整理 | 识别未覆盖或变更部分,不把旧版本覆盖算作新版本完成 |
|
||||
| Global 结果含多个项目的归纳 | 从单项目查询 | 不把跨项目摘要伪装成项目结论,复用事实仍保留来源归属 |
|
||||
|
||||
### US-S34 独立选择监督者模型
|
||||
|
||||
对应 FR-S13,生效规则见[监督者模型选择](./logic-design.md#监督者模型选择)。
|
||||
|
||||
| Given | When | Then |
|
||||
| --- | --- | --- |
|
||||
| 已保存多个文本模型连接 | 在监督者“设置 → 模型”选择连接并保存 | 回顾和建议使用所选连接,无需重新输入凭据 |
|
||||
| 尚未配置监督者模型,或选择“跟随应用默认” | 开始执行 | 使用应用默认文本模型 |
|
||||
| 回顾正在提取或汇总 | 修改模型选择或原连接配置 | 此次回顾后续批次、故事和经验阶段保持原模型 |
|
||||
| 回顾已暂停或失败 | 修改模型后继续 | 使用当前选择继续已保存进度 |
|
||||
| 已选连接被删除或变成图片模型 | 打开设置并尝试执行 | 显示连接不可用,执行报错,用户可改选;不自动回退 |
|
||||
| 模型选择保存失败 | 留在设置页 | 保留选择草稿及重试入口 |
|
||||
|
||||
## 验收优先级与依赖
|
||||
|
||||
首个可用版本应先打通以下路径:
|
||||
|
||||
@@ -82,7 +82,7 @@ Agent 配置、Shell 执行和系统消息接口。
|
||||
和 Electron UI 测试失败;未取得全量通过结果,未修改这些并行工作的文件。
|
||||
|
||||
本次依赖升级回归的真实供应商模型调用为 0 次;后续真实模型及 Web 实测见上节。上述升级验证不包含官方 Web 应用,
|
||||
“在浏览器中打开”入口仍按[原生客户端方案](../assistant-workbar/runtime-native-client-proposal.md)实施。
|
||||
“打开DSH Web”入口仍按[原生客户端方案](../assistant-workbar/runtime-native-client-proposal.md)实施。
|
||||
远程启动契约和资源打包仅支持 Continue、OpenCode,`create-runtime.ts` 拒绝 SSH
|
||||
执行空间使用 DS,因此本次升级未改变远程 Runtime 路径,也未进行 Linux Host 验证。
|
||||
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
# 设备发现与共享
|
||||
|
||||
本功能是 GoodBuddy 桌面端管理附近设备、本机共享和 ShareServer 连接的唯一入口。默认连接
|
||||
方式是局域网;网关由用户额外添加。功能不包含 ShareServer 的组织管理 Web 界面。
|
||||
设备共享在测试阶段默认关闭,先在“管理应用 → 设备共享 → 应用设置”启用,再从“应用 →
|
||||
设备共享”或管理列表的“打开”进入。当前首期连接本地未认证
|
||||
ShareServer 元数据服务,默认地址为 `http://127.0.0.1:8787`,可注册本机、浏览设备与发布目录、
|
||||
发布能力或知识的元数据并撤销本机 ID 的条目。设备 ID 只用于识别,不是所有权认证。
|
||||
|
||||
当前实现不执行远程能力,不读取、搜索或下载知识正文;来源模式和权限均为元数据声明。
|
||||
局域网发现、配对、组织网关和远程调用仍是后续设计,不是首期交付内容。
|
||||
|
||||
## 文档导航
|
||||
|
||||
@@ -9,12 +14,13 @@
|
||||
| --- | --- |
|
||||
| [PRD](./prd.md) | 用户问题、范围、功能需求和产品验收 |
|
||||
| [功能逻辑](./logic-design.md) | 不变量、状态、授权优先级和失败结果 |
|
||||
| [UI 设计](./ui-design.md) | 设置入口、四个页签、配对与调用交互 |
|
||||
| [UI 设计](./ui-design.md) | 当前应用入口、单页表单与列表,以及后续发现、配对界面 |
|
||||
| [技术设计](./technical-design.md) | Desktop 进程边界、持久化、IPC、网络和验证 |
|
||||
| [实施进度](./progress.md) | 首期实现、检查命令、真实 Electron 联调证据及未测范围 |
|
||||
| [共享网络总体设计](../../architecture/share-network-architecture.md) | 跨端身份、能力、授权、任务、更新和协议权威 |
|
||||
|
||||
当前没有单独的 User Stories:正常、失败、撤销、权限和兼容场景已直接进入 PRD 验收与逻辑
|
||||
决策表。功能尚未实施,因此不创建 `progress.md`。
|
||||
当前没有单独的 User Stories:首期验收见 PRD 的 `FR-M1` 至 `FR-M5`;后续场景仍在原有
|
||||
需求与逻辑决策表中保留。下列 Peer、配对、网关术语仅用于后续设计。
|
||||
|
||||
## 术语
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
| 项目 | 内容 |
|
||||
| --- | --- |
|
||||
| 状态 | 设计中 |
|
||||
| 状态 | 本地元数据首期已接入;完整共享网络为后续设计 |
|
||||
| 版本 | 0.1 |
|
||||
| 日期 | 2026-09-03 |
|
||||
| 关联 PRD | [设备发现与共享 PRD](./prd.md) |
|
||||
@@ -12,6 +12,27 @@
|
||||
|
||||
本文负责桌面端状态、不变量和决策规则,不重复跨端协议定义。
|
||||
|
||||
## 当前首期规则
|
||||
|
||||
本节对应 [FR-M1 至 FR-M5](./prd.md#当前首期本地元数据)。后文发现、配对、授权和任务状态均为
|
||||
后续设计,不适用于本地未认证元数据服务。
|
||||
|
||||
应用启用状态默认关闭,缺失设置按关闭处理,明确保存的启用状态在重启后保留。启用只开放
|
||||
页面入口;打开页面才读取目录。关闭后菜单隐藏该项,当前页面返回对话;不清除设备设置、
|
||||
目录或已发布记录,不停止已有请求。该应用开关不代替后续发现、广播和发布的独立状态。
|
||||
|
||||
本机 ID 在首次读取设置时生成并原子保存;并发首次读取共用一次加载。名称与服务 URL 更新
|
||||
串行保存,不接受 Renderer 写入设备 ID。已存在设置无法读取或解析时直接报错,避免静默
|
||||
更换身份。切换服务保持本机 ID,但目标服务必须有本机注册记录才能发布。
|
||||
|
||||
页面初次加载读取真实目录,空列表、加载中和读取失败分别展示。刷新失败保留旧列表并标记
|
||||
可能过期,禁用发布和撤销;用户可修改配置或重试。每次请求固定使用开始时读取的服务地址。
|
||||
页面操作期间锁定重复提交。保存失败保留设置草稿,发布失败保留条目草稿。
|
||||
|
||||
发布状态只有 `published` 与 `revoked`。撤销入口匹配本机 ID,Main 再查询本机发布列表并确认
|
||||
条目 ID;这只是防止误操作,不是认证。撤销只改变元数据状态。能力类型发送三个 `false`
|
||||
权限;知识类型按独立选择发送。目录读取无需注册,注册记录不表示在线。
|
||||
|
||||
## 1. 核心不变量
|
||||
|
||||
### INV-1 连接方式独立
|
||||
|
||||
@@ -4,13 +4,39 @@
|
||||
|
||||
| 项目 | 内容 |
|
||||
| --- | --- |
|
||||
| 状态 | 设计中 |
|
||||
| 状态 | 本地元数据首期已接入;完整共享网络为后续设计 |
|
||||
| 版本 | 0.1 |
|
||||
| 日期 | 2026-09-03 |
|
||||
| 适用产品 | GoodBuddy Desktop |
|
||||
| 关联架构 | [共享网络总体设计](../../architecture/share-network-architecture.md) |
|
||||
| 关联文档 | [功能逻辑](./logic-design.md)、[UI 设计](./ui-design.md)、[技术设计](./technical-design.md) |
|
||||
|
||||
## 当前首期:本地元数据
|
||||
|
||||
2026-10-01 首期范围由 `FR-M1` 至 `FR-M5` 定义。后文 `FR-1` 至 `FR-14` 保留为后续共享网络
|
||||
设计,不能用来推断首期已有发现、认证、调用或内容传输能力。
|
||||
|
||||
- **FR-M1 入口与设置**:设备共享为可选应用,测试阶段默认关闭,应用中心提供启用开关;
|
||||
启用后才出现在应用菜单,关闭时退出当前设备共享页面。旧设置缺少开关时按关闭处理,已保存的
|
||||
明确启用值保留。关闭不清除目录、设备设置或撤销已发布记录。本机设备 ID 稳定保存,用户可
|
||||
修改服务 URL 和设备名称。设置保存后重启仍保留;新设备默认连接 `http://127.0.0.1:8787`。
|
||||
- **FR-M2 注册与浏览**:用户显式注册本机,随后查看服务实际返回的设备及发布列表。注册
|
||||
包含 ID、名称、平台、应用版本;时间来自服务记录,不从更新时间推断在线状态。
|
||||
- **FR-M3 元数据发布**:注册本机后可发布名称、描述、能力或知识类型、设备或服务器来源。
|
||||
知识的搜索、读取、下载三个标志独立选择。服务器来源不会上传、同步或托管内容;权限
|
||||
标志不提供内容访问实现。
|
||||
- **FR-M4 撤销与刷新**:只给匹配本机 ID 的已发布条目提供撤销入口,确认后更新服务记录并
|
||||
刷新目录,显示已撤销状态。该筛选不构成认证,服务本身没有所有权鉴权。
|
||||
- **FR-M5 失败恢复**:请求有超时,错误可见;失败保留用户输入,列表失败不伪装为空目录。
|
||||
修改服务 URL 后必须先保存,再向新服务注册或发布。既有应用设置、排序和常驻偏好保留。
|
||||
|
||||
验收顺序为启用应用、保存配置、注册本机、浏览返回列表、发布知识元数据、检查三个独立标志、撤销、
|
||||
刷新并重启读取设备 ID。网络请求由 Main 发出,Renderer 只调用显式 preload 方法。
|
||||
|
||||
## 后续设计
|
||||
|
||||
以下章节为完整共享网络计划,尚未实施。
|
||||
|
||||
## 1. 背景
|
||||
|
||||
GoodBuddy 的能力当前主要在本机执行,SSH 远程项目则要求用户预先配置具体 Host。个人和组织
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
# 设备共享实施进度
|
||||
|
||||
日期:2026-10-01。当前实现范围见 [README](./README.md)。
|
||||
|
||||
## 2026-10-01:测试阶段默认关闭
|
||||
|
||||
对应 FR-M1,新增持久化 `deviceSharingEnabled`,默认 `false`,旧设置缺少字段时补为关闭,
|
||||
明确保存的 `true` 保留。应用中心提供启用 Switch,不增加常驻;菜单仅显示已启用项。
|
||||
从应用中心或设置变更事件关闭时,App 退出设备共享页面并返回对话;设备设置、目录与已发布
|
||||
记录保留。中英文文案、功能列表和导航文档已同步。
|
||||
|
||||
本轮验证:设置存储、配置工具契约及服务、应用中心、菜单和共享页面共 6 文件 112 项通过;
|
||||
完整 `App.test.tsx` 321 项通过;设置面板、更新设置及笔记工作区 3 文件 246 项通过。
|
||||
`npm run typecheck` 与 `npm run lint` 通过。首次 App 回归发现关闭逻辑误触其他设置导航,
|
||||
限定为当前设备共享页后复跑通过;新增测试的查询参数类型错误也已修正。
|
||||
|
||||
设置存储回归覆盖版本 11/12 的字段缺失、显式开启/关闭、无关更新、事件与重读;App 回归
|
||||
覆盖默认隐藏、启用、菜单打开、UI 关闭和外部事件关闭,重新启用不自动返回该页。
|
||||
真实 Electron/ShareServer 用例 1 项通过,新增默认禁用“打开”、仅一个启用 Switch 的检查,
|
||||
启用后继续注册、发布、撤销、刷新及浅深主题 480px 检查。该 Electron 用例的应用设置由组件
|
||||
fixture 更新,持久化由真实设置存储测试覆盖,不据此声明完整生产 App 或安装包联调通过。
|
||||
本轮未运行全仓测试,未改动 Agent/Runtime 路径,无模型调用。
|
||||
|
||||
## 首期接入记录
|
||||
|
||||
`a3b41bf` 已接入应用入口、Main 服务、可信 IPC、preload 与页面,
|
||||
支持配置 ShareServer、注册设备、读取目录、发布/撤销元数据及本机 ID 持久化。
|
||||
设备 ID 不是认证凭据,权限字段不代表已实现远程操作;正文传输、能力调用、
|
||||
发现和配对仍不属于本期实现。
|
||||
|
||||
当前组合源码类型检查和 lint 通过。仓库包含服务、IPC、页面测试和需指定
|
||||
`GOODBUDDY_SHARING_SERVER_ROOT` 的真实 ShareServer/Electron 联调用例。
|
||||
本轮发布准备中的定向测试被用户中断,并要求直接推送交 CI 验证;
|
||||
不据此声明测试或真实服务联调通过。发布结果以最终候选 CI 为准,
|
||||
条件跳过的真实服务用例不能计入已验证能力。
|
||||
|
||||
## 2026-10-01:桌面实现轮次的独立验证
|
||||
|
||||
以下记录来自设备共享实现会话,独立于上文发布准备轮次;已实际运行,不作为发布 CI 或安装包
|
||||
验收的替代。对应 [FR-M1 至 FR-M5](./prd.md#当前首期本地元数据)。
|
||||
|
||||
| 检查 | 结果与覆盖范围 |
|
||||
| --- | --- |
|
||||
| 定向 7 文件 | 114 项通过:Main HTTP 契约、稳定 ID、设置重读、可信 IPC、超时与错误、页面发布/撤销、输入保留、旧设置迁移与应用排序 |
|
||||
| 完整 `App.test.tsx` | 320 项通过,含新增设备共享入口与既有设置 Modal 回归 |
|
||||
| `goodbuddy-config-contracts.test.ts` | 8 项通过;修正旧四项写入样例,保留重复、未知及缺失项拒绝断言 |
|
||||
| 真实 Electron/ShareServer | 1 项通过,使用相邻后端源码、独立端口和临时数据目录,真实窗口运行生产页面、preload、可信 IPC 和 Main 服务 |
|
||||
| 类型与静态检查 | `npm run typecheck`、`npm run lint`、`git diff --check` 通过 |
|
||||
|
||||
Electron 联调从应用中心打开页面,注册本机,发布知识元数据,服务返回确认
|
||||
`sourceMode=server`、`search=true/read=false/download=true`,随后撤销并刷新至
|
||||
`status=revoked`。注册、发布、撤销确认和刷新使用原生鼠标输入;重读磁盘保持设备 ID。
|
||||
浅深主题的 480px 视口均无横向溢出。首次窄窗口检查因系统缩放与预期 CSS 宽度不一致失败,
|
||||
沿用现有 Electron 测试的固定设备缩放设置后通过。该测试是组件组合,未启动完整生产 App,
|
||||
也未验证安装包、截图视觉效果或 macOS/Linux 窗口。
|
||||
|
||||
全仓 `npm test` 用时 760.82 秒:468 文件通过、13 文件跳过、2 文件失败;5,549 项通过、88 项
|
||||
跳过、2 项失败。设备共享真实 Electron 联调在这次全仓运行中也通过。两项失败分别为:
|
||||
|
||||
- 配置工具样例仍提交四项顺序。运行期间修正后独立复跑 8 项通过;随后与服务测试合跑
|
||||
11 项也通过,服务测试包含对尚不存在设置文件的并发首次读取。
|
||||
- `inline-help.electron.test.ts` 的 `Hover Escape closed modal: dark 360x320` 断言失败。
|
||||
未修改该文件或帮助组件,独立复跑 1 项通过,原因未确定;不把复跑结果写成全仓一次全绿。
|
||||
|
||||
完整日志位于本次工作机临时目录 `opencode/goodbuddy-sharing-full-tests.log`。没有再次执行整套
|
||||
全仓测试。本功能测试没有模型调用。中文设备共享文档经 `deai-writing` 扫描无阻断项,复核项
|
||||
中的权限边界、真实字段及测试列表按事实保留。
|
||||
|
||||
### 复现命令
|
||||
|
||||
在 GoodBuddy 仓库执行:
|
||||
|
||||
```powershell
|
||||
npx vitest run src/main/device-sharing-service.test.ts src/main/device-sharing-ipc.test.ts src/main/application-settings-store.test.ts src/renderer/src/DeviceSharingPage.test.tsx src/renderer/src/ApplicationCenter.test.tsx src/renderer/src/ApplicationMenu.test.tsx src/preload/preload-sandbox.test.ts
|
||||
npx vitest run src/renderer/src/App.test.tsx
|
||||
npx vitest run src/shared/goodbuddy-config-contracts.test.ts
|
||||
$env:GOODBUDDY_SHARING_SERVER_ROOT = (Resolve-Path ../goodbuddy-shareserver).Path
|
||||
npx vitest run tests/device-sharing.electron.test.ts
|
||||
npx vitest run tests/inline-help.electron.test.ts
|
||||
npm run typecheck
|
||||
npm run lint
|
||||
npm test
|
||||
```
|
||||
|
||||
跨仓 Electron 测试需要后端已安装依赖;未设置 `GOODBUDDY_SHARING_SERVER_ROOT` 时明确跳过。
|
||||
测试会关闭自己启动的服务并清理临时数据,不写后端源码。
|
||||
|
||||
### 手工体验
|
||||
|
||||
在 ShareServer 仓库运行 `npm run dev:api`,在 GoodBuddy 仓库运行 `npm run dev`。先从“应用 →
|
||||
管理应用 → 设备共享 → 应用设置”启用,再从应用菜单打开“设备共享”,默认地址为
|
||||
`http://127.0.0.1:8787`。先保存名称或地址修改,再注册本机、
|
||||
发布条目、检查目录、撤销并刷新。知识类型提供三个独立权限框;重启后可比较同一设备 ID。
|
||||
服务关闭时刷新应显示错误并保留旧列表,不应显示为空目录或推断设备离线。
|
||||
@@ -4,13 +4,47 @@
|
||||
|
||||
| 项目 | 内容 |
|
||||
| --- | --- |
|
||||
| 状态 | 设计中 |
|
||||
| 状态 | 本地元数据首期已接入;完整共享网络为后续设计 |
|
||||
| 版本 | 0.1 |
|
||||
| 日期 | 2026-09-03 |
|
||||
| 关联 PRD | [设备发现与共享 PRD](./prd.md) |
|
||||
| 功能逻辑 | [功能逻辑设计](./logic-design.md) |
|
||||
| 共享协议 | [共享网络总体设计](../../architecture/share-network-architecture.md) |
|
||||
|
||||
## 当前首期实现
|
||||
|
||||
本节实现 [FR-M1 至 FR-M5](./prd.md#当前首期本地元数据)。后文模块树、SQLite 表、共享协议包、
|
||||
局域网与网关协议都是后续设计,当前不创建这些基础设施。
|
||||
|
||||
`src/shared/device-sharing-contracts.ts` 定义严格输入和返回类型;`DesktopApi.sharing` 暴露
|
||||
`getSettings`、`saveSettings`、`registerDevice`、`getCatalog`、`publish`、`revoke` 六个方法。
|
||||
生产 preload 对应六个固定 IPC 通道,`device-sharing-ipc.ts` 对每次调用检查可信主窗口主帧,
|
||||
对变更输入进行 Zod 校验,不暴露通用网络入口。
|
||||
|
||||
`DeviceSharingService` 在 Main 内使用 `fetch`,每个请求含响应体读取采用 10 秒超时,非成功
|
||||
响应保留 HTTP 状态;响应再经 Zod 校验。请求只在页面读取或用户动作时发出,不启动后台发现。
|
||||
URL 接受 HTTP/HTTPS,禁止内嵌凭据、查询和片段。没有令牌或 Agent/Runtime 改动。
|
||||
|
||||
| 方法 | HTTP 请求 |
|
||||
| --- | --- |
|
||||
| 注册 | `POST /api/v1/sharing/devices/register`,字段为 `id,name,platform,appVersion` |
|
||||
| 设备列表 | `GET /api/v1/sharing/devices`,读取 `{devices:[]}` |
|
||||
| 发布列表 | `GET /api/v1/sharing/publications`,读取 `{publications:[]}` |
|
||||
| 发布 | `POST /api/v1/sharing/publications`,Main 生成条目 ID 并填入本机设备 ID |
|
||||
| 撤销 | 先按 `?deviceId=` 获取本机条目,再 `POST /api/v1/sharing/publications/:id/revoke` |
|
||||
|
||||
设备 ID、名称与服务 URL 存在用户数据目录的 `device-sharing-settings.json`,版本为 1;
|
||||
复用 `writeJsonFileAtomically`,加载 Promise 去重、保存串行。损坏设置报错而不重置 ID。
|
||||
此文件不保存目录副本或凭据。导航沿用已有存储归一化,把 `device-sharing` 追加到旧顺序,
|
||||
保留既有应用相对顺序与设置;新入口可排序,不增加常驻字段。应用设置中的
|
||||
`deviceSharingEnabled` 使用共享 schema 的 `false` 默认值补齐缺失字段,显式 `true` 保留;
|
||||
沿用现有版本与原子持久化、变更事件。更新 schema 将该字段设为可选,避免无关更新重置选择。
|
||||
菜单复用启用过滤,App 收到关闭设置时返回对话并卸载页面。共享服务和目录数据不受此开关修改。
|
||||
|
||||
服务测试使用真实 loopback HTTP,验证契约、独立权限、识别范围、持久化、错误和超时;
|
||||
Electron 测试通过环境变量选择相邻真实 ShareServer 源码,使用临时服务数据与桌面设置。
|
||||
具体命令和实测边界见[实施进度](./progress.md)。
|
||||
|
||||
## 1. 进程边界
|
||||
|
||||
```text
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
| 项目 | 内容 |
|
||||
| --- | --- |
|
||||
| 状态 | 设计中 |
|
||||
| 状态 | 本地元数据首期已接入;完整共享网络为后续设计 |
|
||||
| 版本 | 0.1 |
|
||||
| 日期 | 2026-09-03 |
|
||||
| 关联 PRD | [设备发现与共享 PRD](./prd.md) |
|
||||
@@ -14,6 +14,26 @@
|
||||
本文只定义桌面端界面。所有颜色、间距、字体、通知、Switch、PageTabs、焦点和响应式行为以
|
||||
全局设计系统为准。
|
||||
|
||||
## 当前首期页面
|
||||
|
||||
本节对应 [FR-M1 至 FR-M5](./prd.md#当前首期本地元数据),后文四页签、配对及调用界面为后续设计。
|
||||
|
||||
应用中心显示“可选”及启用状态,测试阶段默认关闭,此时“打开”禁用。应用设置复用共享
|
||||
Switch,常显测试状态和关闭后果,不提供常驻开关。启用后应用菜单显示入口,关闭时退出
|
||||
设备共享页面并返回对话。主内容区使用 `standard` PageShell、PageHeader 与全局范围徽标。
|
||||
标题下常显未认证、ID 识别用途和无执行/内容访问的边界。内容依次为本机设置、已注册设备、
|
||||
发布表单和发布目录。服务 URL 与设备名称可编辑,设备 ID 只读;注册、保存与刷新为次操作,
|
||||
“发布元数据”为唯一主按钮。
|
||||
|
||||
类型与来源使用短标签原生选择框;知识的搜索、读取、下载使用三个独立 Checkbox。服务器来源
|
||||
不上传或同步内容的说明常显。发布按钮附近显示已保存的目标服务及设备名称,尚未注册或
|
||||
配置未保存时给出操作提示。列表展示服务返回的名称、标识、平台/类型、更新时间和发布状态,
|
||||
不展示在线灯或执行按钮。
|
||||
|
||||
撤销使用共享危险入口和就地确认,说明保留记录且不删除本机内容。成功与操作失败通过应用
|
||||
通知反馈;目录加载失败在页面显示并由刷新按钮恢复。宽度收窄时按钮换行,长 URL 和 ID 换行;
|
||||
表单复用 `.field`,颜色与间距使用共享令牌。页面文案提供中英文。
|
||||
|
||||
## 1. 信息架构
|
||||
|
||||
设置中心新增一级分类“设备发现与共享”,使用 `standard` PageShell:
|
||||
|
||||
@@ -261,6 +261,29 @@ DeepSeek Harness 和托管 SSH 路径不注入这些工具。既有功能的 Win
|
||||
MCP 代理复用 Provider 发现快照规则,托管 SSH Runtime 不经过该本机构造路径。本次未
|
||||
修改远端 Agent、桥接或启动器。
|
||||
|
||||
## 2026-09-28 本地直连手动压缩
|
||||
|
||||
- 本地直连文本会话复用“压缩上下文”按钮、摘要状态和会话保存路径,支持关闭自动压缩时
|
||||
手动执行。当前连接选择、图像排除、强制压缩和最新轮次保留规则见
|
||||
[技术设计](./technical-design.md#101-本地直连手动压缩)。未修改远端 Agent、桥接或启动器;
|
||||
model 入口排除远程会话与 SSH 项目,本地 IPC 仍拒绝 SSH 执行空间。
|
||||
- 三个目标文件 `App.test.tsx`、`ipc.test.ts`、`model-runtime.test.ts`:569 passed、2 skipped。
|
||||
覆盖入口点击、自动关闭和低于阈值、运行中禁用、图像排除、摘要保存与后续请求、当前及指定
|
||||
摘要连接选择。补入远程通道入口排除后,App 手动压缩专项复跑 7 passed;最终类型检查和
|
||||
本次文件 ESLint 再次通过。
|
||||
- `manual-compaction.electron.test.ts`:真实 App、preload、IPC、模型工厂和 SQLite 路径通过。
|
||||
2 次本机 HTTP fixture 请求验证较早历史被摘要替换、最新轮次保留、完整历史仍在数据库,
|
||||
页面重载后摘要可读,新建 Runtime 实际使用该摘要。
|
||||
- `manual-compaction-live.electron.test.ts`:真实配置文本模型最终通过,2 次请求完成摘要及
|
||||
新 Runtime 续问。三轮探针累计 6 次真实文本模型请求;前两轮失败来自探针用未转义摘要
|
||||
匹配 JSON payload,修正断言后通过。原设置文件未改变,未记录凭据。
|
||||
- `npm run typecheck` 和本次涉及文件的 ESLint 通过。全仓 `npm run lint` 有 10 个错误,
|
||||
位于已有侧栏探针 `sidebar-final-probe.ts`、`sidebar-final-run.mjs`,保留未修改。
|
||||
- 全量 `npm test`:5407 passed、85 skipped、2 failed。失败分别为已有侧栏探针的新建按钮
|
||||
溢出断言(`magic-notes-capture.electron.test.ts`)及魔法笔记布局 Electron 超时
|
||||
(`magic-notes-layout.electron.test.ts`);手动压缩 Electron 回归通过。未执行构建或跨平台
|
||||
真机测试,不将全仓验证记为全绿。
|
||||
|
||||
## 进度维护要求
|
||||
|
||||
- 只有真实生产路径完成并验证后才能勾选实施项。
|
||||
|
||||
@@ -451,6 +451,18 @@ type SubagentActor =
|
||||
处理。Continue 接收完整历史,已有有效摘要只替换其覆盖的前缀,剩余消息全部交给原生
|
||||
Runtime;GoodBuddy 不再额外保留最后 20 条或限制为 128,000 字符。
|
||||
|
||||
### 10.1 本地直连手动压缩
|
||||
|
||||
`agentCompactConversation` 接受 `model`,结果保留实际 provider,压缩策略为
|
||||
`goodbuddy-summary`。摘要来源为 `current` 时,IPC 使用 `applyRuntimeSelection`
|
||||
解析后的当前直连连接;来源为 `profile` 时,使用既有指定摘要连接配置。
|
||||
图像连接不进入文本摘要路径,SSH 执行空间仍由本地 IPC 拒绝。
|
||||
|
||||
`ModelAgentRuntime.compactConversation` 复用 `prepareCompressedRequest` 的强制压缩,
|
||||
跳过自动开关和触发阈值,保留最新轮次。返回的 `contextCompressionState` 沿用会话保存,
|
||||
不删除历史正文。后续请求校验摘要覆盖范围后,用摘要替换已覆盖前缀;即使关闭自动压缩,
|
||||
已保存的有效摘要仍会使用。
|
||||
|
||||
## 11. 性能
|
||||
|
||||
- Shell 探测按执行服务实例缓存,不在每轮模型调用重复探测。
|
||||
|
||||
@@ -21,6 +21,11 @@
|
||||
|
||||
Job、Subjob 和 Run 继续是内部对象,不创建树形 UI、独立路由或操作菜单。
|
||||
|
||||
本地直连文本会话复用输入区已有的“压缩上下文”按钮,不依赖原生 Runtime 快照,
|
||||
也不因自动压缩关闭或上下文低于触发阈值而隐藏。运行中或压缩中按钮禁用;图像连接、
|
||||
远程通道会话和 SSH 项目不显示直连模型压缩入口。完成后沿用应用通知及压缩后上下文估算,
|
||||
摘要随会话保存。压缩行为见[技术设计](./technical-design.md#101-本地直连手动压缩)。
|
||||
|
||||
## 2. 设置中的能力目录
|
||||
|
||||
直连模型工具目录使用两个本机开发分组:
|
||||
|
||||
@@ -64,6 +64,10 @@ Main 先保存资产,再保存草稿或接受队列;消息、分支建立独
|
||||
会话删除、项目删除及清空数据均核对引用,启动回收已无归属的记录和孤立 UUID 目录。
|
||||
清理失败保留无引用数据库记录供下次重试,不回滚已接受的草稿。
|
||||
|
||||
引用保存按输入顺序去重并查询资产。已有引用的顺序、会话归属均相同,且资产无需从临时目录
|
||||
转存时,直接返回,不重复开启写事务或删除、插入引用。空引用同样适用;顺序、归属或附件
|
||||
发生变化时仍按原事务更新,临时资产仍须转存。
|
||||
|
||||
助手库 schema 39 标记新附件元数据,阻止旧客户端将其当旧格式读取;没有批量改写历史附件。
|
||||
知识库 schema 13 新增 `metadata.parsedResultId` 表达式索引,结果按主键定位,不扫描全部文档。
|
||||
知识库新结果先写入独立 resultId 目录,数据库正文/chunks 发布成功后替换引用,再清理旧目录。
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
| 用户任务 | 主入口与路由 | 范围及目标行为 |
|
||||
| --- | --- | --- |
|
||||
| 配置解析与 OCR | 侧栏底部“设置” → “文档解析”(`document-parsing`) | 设备级共享配置,分别保留聊天与知识库工作流;HTTP 地址、凭据和高级选项只在此维护,不随当前项目或会话切换 |
|
||||
| 管理本地 OCR 文件 | 同一“文档解析”分类的本地模型区 | 下载、校验、ZIP、删除和目录操作;全局模型下载源仍在“平台功能 / 通用设置”,与 OCR 来源选择分开 |
|
||||
| 管理本地 OCR 文件 | 同一“文档解析”分类的本地模型区 | 下载、校验、ZIP、删除和目录操作;更换下载源入口直接打开“模型连接”页头的模型下载源弹窗,与 OCR 来源选择分开;弹窗交互见[模型下载源设计](../../architecture/model-download-source-design.md#7-模型下载源界面) |
|
||||
| 查看或释放本地 OCR 内存 | “应用中心” → “本机推理监控”(`local-inference` Modal),或其常驻入口 | 只管理本机执行服务;应用启用及常驻控制入口,不控制解析功能;HTTP 服务不出现在本机服务列表,也不提供远程启停 |
|
||||
| 添加文件或图片 | “当前对话” → “添加附件”,或输入框粘贴 | 先加入当前草稿,再按输入类型处理;上传按钮继续直接选文件,不改成先选 OCR 的菜单 |
|
||||
| 查看聊天解析结果 | 草稿或历史消息的附件卡 | 打开共享预览;来源消息与目标草稿分别显示,历史输入只读 |
|
||||
@@ -51,6 +51,10 @@
|
||||
菜单项包含附件名称以区分多个文件。普通图片的“提取图片文字”可作为就近次操作,不能和
|
||||
菜单形成两套状态或重复执行。菜单复用 `AnchoredMenu` 的定位、键盘和焦点恢复。
|
||||
|
||||
草稿与队列中的图片缩略图可点击进入共享图片查看器,优先显示已保存的完整图片,只有
|
||||
缩略图时使用缩略图预览;查看不触发 OCR。队列附件菜单只提供打开原文件,不读取 OCR
|
||||
来源或显示解析设置;修改发送方式须先恢复到草稿。解析结果入口仅在已有结果时显示。
|
||||
|
||||
草稿、历史消息和队列预览中的“查看解析结果”“更多附件操作”使用共享 `icon-button`
|
||||
视觉,图标分别为文档查阅和省略号,点击区域为 `32px × 32px`,在独立操作行内排列。
|
||||
按钮提供含文件名的可访问名称、悬停提示及键盘焦点提示;草稿移除和导入取消使用同尺寸
|
||||
|
||||
@@ -129,7 +129,10 @@ Main 进程职责、公共 API 对接、失败处理和验收方法。反馈平
|
||||
> 以及你主动添加的截图。默认不上传桌面诊断;只有勾选后才会把有界诊断摘要追加到描述。
|
||||
> 不会发送对话、Prompt、凭据、文件内容、路径、Provider 原始响应或远端 Agent 日志。
|
||||
|
||||
Checkbox 下方持续说明:诊断摘要只含时间、组件、阶段、稳定错误码、错误类型和固定短消息。
|
||||
Checkbox 下方持续说明:诊断摘要含时间、组件、阶段、稳定错误码、错误类型和固定短消息,
|
||||
以及工具连接失败的类别、耗时、HTTP 状态、尝试次数、工具类型和匿名关联标识。
|
||||
MCP 附加字段由 `normalizeDesktopDiagnosticRecord` 在写入、读取和反馈导出时按白名单
|
||||
校验;关联标识为请求令牌的 SHA-256 摘要,不含原始令牌、地址或错误正文。
|
||||
Renderer 不预览、不读取也不接收诊断内容或路径。勾选后详细描述上限为 3,398 字符,为附加
|
||||
摘要预留 1,600 字符以及分隔换行;超长草稿保留在表单中并显示字段错误,不静默截断。
|
||||
|
||||
|
||||
@@ -911,8 +911,8 @@ Endpoint、编码配置或服务端稳定模型摘要变化。
|
||||
|
||||
- 目录只包含元数据,不包含模型权重。
|
||||
- 每个来源的文件固定不可变 Revision、URL、字节数和 SHA-256。
|
||||
- 实际下载使用“平台功能 → 通用设置”中显式选择的 ModelScope 或 Hugging Face,完整
|
||||
来源契约以[平台功能页签与模型下载源设计](../../architecture/model-download-source-design.md)为准。
|
||||
- 实际下载使用“模型连接”页头的模型下载源弹窗中确认保存的 ModelScope 或 Hugging Face;
|
||||
向量模型的下载源入口直接打开该弹窗,完整来源契约以[平台功能页签与模型下载源设计](../../architecture/model-download-source-design.md)为准。
|
||||
- 下载写入随机暂存目录和 `.partial` 文件。
|
||||
- 禁止未声明重定向到其他主机。
|
||||
- 每个文件边下载边计算摘要。
|
||||
|
||||
@@ -6,9 +6,12 @@ metadata and indexes, and local files for entry bodies and binary assets.
|
||||
The open workbench subscribes to successful writes so users can see external
|
||||
changes without leaving the page or clicking a refresh button.
|
||||
|
||||
The workbench opens on a responsive note-card overview. Opening a note replaces
|
||||
the overview with a continuous record stream, a narrow left index and independent
|
||||
right AI comments. Both side panes can collapse. Index items show one real
|
||||
The desktop workbench keeps a compact all-notes list on the left and the selected
|
||||
note on the right. The list supports search, creation, pin/delete menus, resizing
|
||||
and collapse. Switching notes preserves list position and remembers each note's
|
||||
reading position while the workspace remains mounted. Narrow workspaces switch
|
||||
between list and detail. Note detail contains a continuous record stream, a narrow
|
||||
record index and independent AI comments. Both detail side panes can collapse. Index items show one real
|
||||
first-page canvas thumbnail with page count, or a text summary; clicking scrolls
|
||||
without replacing the editor or losing a draft. Opening a note shows a blank composer
|
||||
above the history, with text/canvas selection and Save. Creating a record resets
|
||||
@@ -19,7 +22,9 @@ comments. The divider can be dragged and remembers its width; each note group
|
||||
can independently collapse its tasks while retaining its title and count.
|
||||
Narrow containers use on-demand detail with an explicit return to the
|
||||
mounted list. One content-sized button switches to the other view in the page
|
||||
header immediately before New note; status filters sit beside search and wrap
|
||||
header when note detail is closed; New note is always the primary page-header
|
||||
action to the right of the view switch or Back to overview. The notes list
|
||||
heading keeps only Manage tags. Status filters sit beside search and wrap
|
||||
when needed. Note detail removes the surrounding frame and stream padding while
|
||||
keeping editor borders and the AI divider. Returning from a source note restores the task
|
||||
context and protects unsaved editor changes. A successful save followed by no
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
FR-2、FR-3:单条采集读取所选助手消息的非空正文;整段采集先加载该会话完整历史,再按消息顺序收录用户和助手的非空正文,保留角色分隔。预览建立后,切换会话或收到新消息不会替换正文及来源标识。生成中的消息不可采集,停止或失败后已有正文可保存,单条预览注明回答未完成。整个会话正在生成时禁用整段采集,提示等待完成或停止生成。
|
||||
|
||||
首版按文字保存,保留原始 Markdown 文本、换行和代码文本,不承诺还原聊天渲染效果。推理、工具调用日志、系统提示、附件二进制和图片不进入正文;预览持续说明保存范围。用户可编辑预览,来源仍指向本次采集的会话。读取完整会话失败时不以屏幕上已有消息代替。
|
||||
首版按文字保存,保留原始 Markdown 文本、换行和代码文本,不承诺还原聊天渲染效果。推理、工具调用日志、系统提示、附件二进制和图片不进入正文;预览来源旁提供保存范围帮助,按需展开。用户可编辑预览,来源仍指向本次采集的会话。读取完整会话失败时不以屏幕上已有消息代替。
|
||||
|
||||
FR-5:来源独立于可编辑正文。单条来源包含会话和消息标识;整段来源包含会话标识、此次收录的消息标识及采集时间。预览使用采集时的会话标题和项目名称;保存时主进程校验消息归属,并以当时数据库中的会话标题和项目名称写入来源,无项目时显示全局。若预览期间源会话或消息已删除,保存失败并保留预览。保存完成后,修改或删除源消息不回写笔记正文和来源。
|
||||
|
||||
|
||||
@@ -18,6 +18,12 @@
|
||||
| FR-4 | 通过右侧工作栏“+ → 笔记”打开面板并恢复当前应用会话中最近选择的笔记,首次使用显示列表,不隐式保存当前对话。左侧底部和聊天页头不再提供重复的“快速笔记”入口。 |
|
||||
| FR-5 | 从会话保存的记录保留结构化来源,显示会话名称、所属项目(如有)和采集时间,支持返回原会话及单条来源消息。源会话删除后保留笔记内容。 |
|
||||
| FR-6 | 面板、完整工作区和 Agent 写入使用同一份笔记数据,成功写入后更新已打开视图,同时保护未保存草稿。 |
|
||||
| FR-7 | 笔记支持手动标签。每篇最多 10 个,单个标签最长 24 字,不区分大小写,同名只保留首次写法。在笔记标题下添加或移除标签;添加时提示已有标签,也可以直接新建。 |
|
||||
| FR-8 | 总览可按标签筛选,多选时只显示同时带有全部所选标签的笔记;搜索框也能匹配标签名。打开笔记后,左侧列表沿用当前筛选,并提供清除入口。 |
|
||||
| FR-9 | 提供“管理标签”入口,列出全部标签及使用篇数。可以重命名标签;新名称与已有标签相同时,两个标签合并。删除标签时,它会从所有笔记中移除,笔记本身保留。 |
|
||||
| FR-10 | Agent 笔记工具可在创建、修改笔记时设置标签,并可按标签列出笔记。只在用户要求时修改标签,不自动打标签。 |
|
||||
|
||||
标签第一版不提供颜色和层级(如“工作/项目A”),“/”只作为普通字符保存。没有笔记使用的标签会自动删除。
|
||||
|
||||
首版不包含自动总结、自动分类、自动选择保存目标、选中文本采集、多条消息勾选、附件文件复制或跨设备同步。“总结后加入”和选区采集作为后续扩展,不预留不可用按钮。
|
||||
|
||||
@@ -28,5 +34,6 @@
|
||||
- 保存成功后显示目标笔记并定位新增记录;取消不写入,失败保留内容和目标供重试。
|
||||
- 从来源条可返回对应会话;会话已删除时显示“原会话已不存在”,已保存正文仍可阅读和编辑。
|
||||
- 面板追加后完整工作区可见,Agent 修改后面板可见;后台刷新和切换会话不覆盖未保存文字。
|
||||
- 给两篇笔记加上“Work”和“work”,只会得到一个标签,计数为 2。选中两个标签后,只显示同时带有这两个标签的笔记。把“Work”重命名为已有的“发布”后,两个标签合并,两篇笔记都不会丢失。删除标签不会删除任何笔记。
|
||||
|
||||
状态与内容范围见 [逻辑设计](./logic-design.md),入口和操作细节见 [界面设计](./ui-design.md),接口与存储见 [技术设计](./technical-design.md#conversation-integration)。
|
||||
|
||||
@@ -1,5 +1,140 @@
|
||||
# Magic Notes Progress
|
||||
|
||||
## 2026-10-03: Compact Notes List And Narrow Drawer
|
||||
|
||||
The notes list now defaults to 200px with a 160px minimum. At workspace widths
|
||||
of 600px or more it can remain beside detail; below 600px the toolbar toggle
|
||||
opens the mounted list as an overlay drawer. Escape, outside click, the close
|
||||
button and note selection dismiss it. Existing desktop width preferences remain
|
||||
intact. Scroll restoration now runs after read-only content effects populate the
|
||||
record, fixing a failure exposed by the Electron layout regression.
|
||||
|
||||
Validation: 143 workspace and list-window tests passed, along with the real
|
||||
Electron layout test covering native resizing, drawer geometry, Escape focus
|
||||
return and note switching. Typecheck and lint on changed code passed. Repository
|
||||
lint was blocked by concurrent App.tsx changes (max-lines error and a missing
|
||||
conversationStore hook dependency warning). No provider calls were made.
|
||||
|
||||
## 2026-10-01: Header New Note Action
|
||||
|
||||
New note is now one primary page-header button that always follows the view
|
||||
switch (overview, to-dos) or Back to overview (note detail). The `+` icon in the
|
||||
notes list heading was removed; the heading keeps only Manage tags. From note
|
||||
detail the button opens the list's title form (desktop reopens a collapsed list;
|
||||
narrow returns to the list first). Workspace tests (133) and the Electron layout
|
||||
test pass, with overview and detail screenshots checked at 1280px; the App note
|
||||
subset (11), typecheck, and lint on changed files pass.
|
||||
|
||||
## 2026-09-30: Navigate After Record Mount
|
||||
|
||||
Quick Notes navigation now scrolls after React commits the target record, rather
|
||||
than scheduling from the data request's completion. A regression that runs the
|
||||
animation-frame callback immediately fails before this fix and passes afterward.
|
||||
The complete App/workspace tests and Electron navigation/compaction checks pass:
|
||||
450 tests across four files, plus typecheck and lint. No provider calls were made.
|
||||
|
||||
## 2026-09-30: Manual Note Tags
|
||||
|
||||
Implemented FR-7 to FR-10 as described in
|
||||
[Note Tags](./technical-design.md#note-tags): schema 51 tag tables, tag
|
||||
replacement on note update, AND filtering, rename/merge/delete with note
|
||||
revision bumps, IPC and preload methods, the overview filter row, card chips,
|
||||
note-header tag editor, the "Manage tags" dialog, and tag support in
|
||||
`note_create`, `note_update` and `note_list`. No colours or hierarchy.
|
||||
|
||||
- New tests: database tag lifecycle (`assistant-database.test.ts`), MCP tag
|
||||
normalization and filtering (`knowledge-mcp-gateway.test.ts`), and four
|
||||
workspace tag tests (filtering, search, keyboard add/remove, manager
|
||||
rename/merge/delete).
|
||||
- `npx vitest run` over the workspace, panel, `src/main/agent`,
|
||||
`src/main/magic-notes`, `src/main/assistant`, storage startup, preload, shared
|
||||
and the five Magic Notes Electron tests: 142 files passed (4 skipped), 1706
|
||||
tests passed. `App.test.tsx -t note`: 4 passed.
|
||||
- The Electron layout test checks the overview filter row and card chips fit
|
||||
their containers; screenshots of the overview, tag manager and note header
|
||||
checked at 1280px.
|
||||
- `npm run typecheck` and eslint on the changed files passed. Full `npm test`
|
||||
was not rerun.
|
||||
|
||||
## 2026-09-30: Overview Page And Header Back Button
|
||||
|
||||
"Back to overview" returned to the page header's top-right as a labelled button.
|
||||
It now leads to a full-width all-notes card grid instead of a list beside an
|
||||
empty detail pane; the split list appears only while a note is open.
|
||||
`MagicNotesWorkspace.test.tsx` (129) and the Electron layout, analysis and
|
||||
navigation tests passed; screenshots checked at 1280 and 720px.
|
||||
|
||||
## 2026-09-30: Compact Note Detail
|
||||
|
||||
The note detail now prioritizes content: the notes list uses two-line rows
|
||||
(about 54px instead of 88px); note controls moved from text buttons in the page
|
||||
header to a sticky icon toolbar; the title appears once as the stream heading;
|
||||
the record index starts collapsed; AI opens only with comment activity or a pin;
|
||||
the idle composer is one line; entry actions are hover icons. At 1280x800 with
|
||||
the list open the stream is about 990px wide (content capped at 880px), versus
|
||||
525px before, and the first entry starts about 240px from the top of the stream.
|
||||
|
||||
- Electron screenshots checked at 1280 (light/dark), 1000 and 720px, with and
|
||||
without the index and AI panes.
|
||||
- `MagicNotesWorkspace.test.tsx` 129 tests, `App.test.tsx` 315 tests, and the
|
||||
Electron layout, analysis, navigation, footer and capture tests passed.
|
||||
The analysis and navigation fixtures now locate icon buttons by `aria-label`.
|
||||
- `npm run typecheck` and eslint on the changed files passed. Full `npm test`
|
||||
was not rerun.
|
||||
|
||||
## 2026-09-30: Persistent Notes List
|
||||
|
||||
Desktop notes now retain the all-notes list beside the current note. The list
|
||||
supports independent scrolling, selected state, creation, action menus, resizing
|
||||
and collapse. Narrow workspaces switch between the mounted list and detail.
|
||||
Switching notes restores reading positions within the mounted workspace and
|
||||
protects unsaved titles and bodies. Current-note deletion uses the same draft
|
||||
guard; failed deletion and deleting another note retain current drafts.
|
||||
|
||||
- Workspace, Electron layout, navigation and analysis regression tests passed:
|
||||
4 files, 132 tests. The analysis fixture no longer waits for Cancel after Save
|
||||
changes has already closed the editor.
|
||||
- The full-suite run also passed the production App/preload/IPC/SQLite note-capture
|
||||
scenario, including reopening the database and returning to conversation sources.
|
||||
- `npm run typecheck`, `npm run lint` and `git diff --check` passed.
|
||||
- Full `npm test` did not finish within 600 seconds. It reported two analysis
|
||||
failures subsequently fixed and passed above, and one supervisor-layout failure.
|
||||
There is no all-green full-suite result for this change.
|
||||
- Layout fix, same day: with the list occupying 289px, a 930-1100px window left
|
||||
the detail container under 800px, so AI stacked below a short stream. At about
|
||||
880px a CSS container query and the React width state disagreed, briefly
|
||||
rendering the list above the stacked detail. AI now stays beside the editor
|
||||
down to a 600px detail width; the record index uses a drawer from 800px; the
|
||||
list/detail switch uses only the measured workspace width. Electron screenshots
|
||||
were checked at 1280, 1100, 1000, 930, 880, 700 and 560px.
|
||||
`MagicNotesWorkspace.test.tsx`, the Electron layout test and the analysis
|
||||
regression passed: 3 files, 131 tests.
|
||||
|
||||
## 2026-09-28: Startup Body Read Reuse
|
||||
|
||||
Startup repair now reuses the asset references collected during revision
|
||||
inspection, as described in [file reconciliation](./technical-design.md#schema-38-migration).
|
||||
The persisted format and revision recovery rules are unchanged.
|
||||
|
||||
- Real temporary SQLite/filesystem fixture: 120 entries across 120 notes,
|
||||
half rich text and half canvas, with 4,039,560 bytes of body JSON.
|
||||
Five initializations each read 240 bodies before and 120 after the change.
|
||||
Logical bytes read fell from 8,079,120 to 4,039,560 per initialization.
|
||||
- Baseline durations were 148.23, 134.42, 131.06, 140.66 and 172.90 ms;
|
||||
after durations were 136.89, 119.11, 116.41, 115.14 and 116.26 ms.
|
||||
Medians were 140.66 and 116.41 ms. These Windows runs used warm filesystem
|
||||
caches on a shared machine; they do not measure cold application startup.
|
||||
- `npx vitest run src/main/magic-notes/magic-note-storage.test.ts src/main/assistant/assistant-database.test.ts src/main/assistant/assistant-storage-upgrade.test.ts src/main/assistant-storage-startup.test.ts tests/assistant-storage-worker.test.ts`
|
||||
passed 154 tests. Coverage includes failed media inspection, missing/invalid
|
||||
bodies, retained shared assets, revision mismatch recovery and migration.
|
||||
- Full `npm test`: 5382 passed, 84 skipped, 3 failed. The failures were the
|
||||
App question-round request wait and Electron note capture focus/layout waits.
|
||||
All three passed in targeted reruns; the full run was not all green.
|
||||
The capture rerun exercised production App/preload/IPC/SQLite and reopening.
|
||||
- `npm run typecheck` passed. Full lint still reports 10 errors in the existing
|
||||
untracked `sidebar-final-probe.ts` and `sidebar-final-run.mjs`; lint excluding
|
||||
those two files passed. No provider calls were made for these storage benchmarks.
|
||||
|
||||
## 2026-09-27: Shared Destructive Button Styles
|
||||
|
||||
The draft discard Portal now receives the global `danger-solid` styles. The
|
||||
|
||||
@@ -20,6 +20,22 @@ stream events, error causes and nested release/dispose cleanup.
|
||||
|
||||
## Continuous Record Workspace
|
||||
|
||||
The outer workspace separates the persistent notes list from the detail container.
|
||||
The list defaults to 200px, resizes within 160-420px and stores `notesPaneWidth`
|
||||
and `notesPaneOpen` in `goodbuddy.magic-notes-layout.v1`. Existing saved widths are
|
||||
preserved. Below 600px, selection replaces the visible list with detail while
|
||||
retaining the mounted list. The detail toolbar keeps its list toggle, opening an
|
||||
overlay drawer without shrinking detail. Selecting a note, clicking outside,
|
||||
using the drawer close button or pressing Escape closes the drawer; dismissal
|
||||
returns focus to the toggle. Drawer state is transient and separate from the
|
||||
desktop open preference. The right-hand `magic-notes-detail` query container and its ResizeObserver
|
||||
measure the detail width for two breakpoints: at 800px or less the record index
|
||||
becomes a drawer while AI stays beside the editor; at 600px or less AI stacks
|
||||
below the stream. The 600px list/detail switch is driven by the same measured
|
||||
workspace width in both CSS class and React state, so the two cannot disagree.
|
||||
Per-note stream scroll positions live in a workspace-local map; explicit entry
|
||||
navigation takes precedence over reading-position restoration.
|
||||
|
||||
The detail mounts every entry in a continuous newest-first stream. The left index
|
||||
and AI source buttons scroll to an entry ID without changing the active editor,
|
||||
clearing a composer, flushing content or invoking the draft-switch guard. Opening
|
||||
@@ -67,22 +83,38 @@ content and streamed draft comments through the shared draft-analysis cleanup.
|
||||
The context counter rejects late results and prevents dispatch after abandoned
|
||||
settings preparation. This cleanup does not clear the new-entry composer's content.
|
||||
|
||||
The left index defaults to 168px and resizes from its right edge between 140px
|
||||
The left index is collapsed by default (`recordIndexOpen`, default `false`); when
|
||||
open it defaults to 168px and resizes from its right edge between 140px
|
||||
and 320px. It shares the AI pane's pointer-capture handlers, separator styling
|
||||
and keyboard semantics (16px arrow steps, Home/End limits). Both widths are
|
||||
stored in `goodbuddy.magic-notes-layout.v1`; missing index width defaults to
|
||||
168px. Displayed widths clamp together to reserve at least 300px for the stream,
|
||||
without overwriting saved widths when the window shrinks. The page header places
|
||||
the index toggle immediately after the back button, followed by the AI toggle.
|
||||
All three use icons and visible localized text; pane toggle labels reflect their
|
||||
current show/hide action. Collapsing hides the entire index and removes its grid column,
|
||||
border, resize separator and occupied width. AI has a separate
|
||||
right pane with its existing resize separator and header toggle. Both panes scroll
|
||||
independently and persist independent desktop visibility. At container widths of
|
||||
800px or less, the index defaults to hidden and the same header toggle opens or
|
||||
closes a fixed 168px drawer below the header, without reserving a rail or showing
|
||||
without overwriting saved widths when the window shrinks. The legacy
|
||||
`indexPaneOpen`/`aiPaneOpen` flags are ignored so existing users get the compact
|
||||
defaults once. With no note open the workspace renders only the list, full
|
||||
width, as a card grid (`magic-notes-workspace--overview`); the split layout
|
||||
(`--notes`) exists only while a note is open on a wide workspace. The page
|
||||
header shows the library switch in the overview and a labelled "Back to
|
||||
overview" button while a note is open. Note controls live in a sticky icon
|
||||
toolbar at the top of the stream (notes list, record index, entry count/updated
|
||||
time, AI). Collapsing hides the entire
|
||||
index and removes its grid column, border, resize separator and occupied width.
|
||||
|
||||
AI visibility is derived, not stored directly: `aiPaneOpen = aiPanePinned ||
|
||||
(hasAiActivity && dismissedNoteId !== detail.id)`, where activity means saved
|
||||
comments, draft analyses, or a running/streaming analysis. Hiding records the
|
||||
current note as dismissed and clears the pin; starting a new analysis clears the
|
||||
dismissal. Opening AI with no activity sets `aiPanePinned`, which is persisted.
|
||||
|
||||
The new-entry composer stays mounted. It collapses with CSS
|
||||
(`:not(.magic-note-composer--active):not(:focus-within)`) so drafts, focus
|
||||
management and the unsaved-draft guard are unchanged; `composerHasDraft` tracks
|
||||
non-empty text so a draft never collapses after blur. At container widths of
|
||||
800px or less, the index defaults to hidden and the same toolbar toggle opens or
|
||||
closes a fixed 168px drawer over the stream, without reserving a rail or showing
|
||||
an index resize separator; choosing
|
||||
an item or pressing Escape closes it. AI moves below the stream, limited to 40%
|
||||
an item or pressing Escape closes it. AI remains a resizable right column until the
|
||||
detail container is 600px or less, then moves below the stream, limited to 40%
|
||||
of layout height and 280px. Overview search, scroll, task selection and return
|
||||
focus stay mounted.
|
||||
|
||||
@@ -173,7 +205,12 @@ empty content. Failed creates leave no accepted database membership; their
|
||||
unaccepted files are removed on the next access or reopen.
|
||||
|
||||
After commit, reconciliation derives `note.json` from database membership and
|
||||
scans all retained entry files before removing unreferenced assets. Manifest or
|
||||
inspects all retained entry files before removing unreferenced assets. Startup
|
||||
repair reads each current body once to inspect its revision and collect asset
|
||||
references, then passes the complete per-note reference set to reconciliation.
|
||||
If media inspection fails, cleanup uses its existing deferred retry path rather
|
||||
than trusting a partial set. Revision mismatch recovery still hydrates the body;
|
||||
legacy conversion still verifies its written content. Manifest or
|
||||
GC failures log `console.warn` with the note ID and error and remain in an
|
||||
in-memory retry set. Later database access retries; reopening derives cleanup
|
||||
again from membership. These cleanup failures do not turn a committed save,
|
||||
@@ -370,8 +407,11 @@ objects and returns a function removing that listener. The existing
|
||||
|
||||
The overview's `libraryView` and the optional note-only `detailView` are independent:
|
||||
opening a todo's source note does not lose the originating task view.
|
||||
The overview remains mounted but hidden while detail is open, retaining scroll
|
||||
position and filters. Returning restores the initiating control's focus and
|
||||
The notes list remains visible beside detail on desktop unless collapsed. On
|
||||
narrow screens, or when opening a source note from To-dos, the originating list
|
||||
remains mounted but hidden, retaining scroll position and filters. The narrow
|
||||
notes view can reopen that list through the detail toolbar drawer toggle.
|
||||
Returning restores the initiating control's focus and
|
||||
invalidates outstanding detail requests. Initial loading fetches summaries and
|
||||
todos without selecting the first note or task. Navigation away from unsaved content or
|
||||
an unsaved title uses the existing draft confirmation; active writes finish
|
||||
@@ -431,7 +471,7 @@ Groups start expanded and can independently hide their mounted rows without
|
||||
clearing the selected detail. Collapse state lasts while the group is mounted.
|
||||
Note detail retains its existing AI pane preferences.
|
||||
|
||||
A single secondary button lives in `PageHeader.actions` before New note. Its
|
||||
A single secondary button lives in `PageHeader.actions` while note detail is closed. Its
|
||||
label and icon describe the destination, and `requestDraftSwitch` retains the
|
||||
existing draft guard. Focus returns to the same `magic-library-switch` button
|
||||
after switching. The overview section keeps its localized list `aria-label`;
|
||||
@@ -441,12 +481,15 @@ search in one wrapping toolbar.
|
||||
|
||||
Only note detail applies `magic-notes-layout--detail` to remove the layout frame
|
||||
and corner radius. Its stream pane has no extra padding; editor borders and the
|
||||
AI divider remain. Overview cards and the independent todo layout keep their
|
||||
existing frame and spacing. These changes do not affect Main, preload or storage.
|
||||
AI divider remain. The compact notes list and independent todo layout retain their
|
||||
own spacing. These changes do not affect Main, preload or storage.
|
||||
|
||||
## Note List Actions
|
||||
|
||||
Each overview card has an open-detail button and a sibling action-menu button. The menu
|
||||
Each notes-list row has an open-detail button and a sibling action-menu button,
|
||||
available while detail is open. Deleting the selected note passes through the
|
||||
existing draft guard; drafts clear only after deletion succeeds. Deleting another
|
||||
note retains current selection and drafts. The menu
|
||||
uses the conversation action styles and shared `DestructiveConfirmActions`, and
|
||||
is portalled to `document.body`. Its position follows the trigger on scrolling,
|
||||
window resizing and confirmation-size changes. Entering detail, filtering out the
|
||||
@@ -459,6 +502,58 @@ target ID and refreshes the overview after success, without opening another note
|
||||
These actions are available only in the overview. Database and Agent contracts
|
||||
are unchanged.
|
||||
|
||||
## Note Tags
|
||||
|
||||
Implements [FR-7 to FR-10](./prd.md#功能要求). Schema 51 adds two tables:
|
||||
|
||||
- `magic_note_tags(id, name, name_key UNIQUE, created_at, updated_at)`. `name_key`
|
||||
is the NFC-normalized, trimmed, lower-cased name, so `Work` and `work` share one
|
||||
row and the first spelling is kept.
|
||||
- `magic_note_tag_links(note_id, tag_id, position, created_at)` with a composite
|
||||
primary key and `ON DELETE CASCADE` on both sides. `position` keeps the order
|
||||
the user added tags in.
|
||||
|
||||
`magicNoteTagNameSchema` and `magicNoteTagListSchema` in
|
||||
`src/shared/magic-notes-contracts.ts` hold all name rules: collapse whitespace,
|
||||
1–24 characters, no commas, `#` or control characters; case-insensitive dedup;
|
||||
at most 10 per note. IPC and MCP inputs go through these schemas before reaching
|
||||
the database. The MCP tool schemas stay plain string arrays so the published
|
||||
JSON schema remains simple; the gateway normalizes them.
|
||||
|
||||
Database behavior (`AssistantDatabase`):
|
||||
|
||||
- Summaries carry `tags: string[]` via one `json_group_array` subquery shared by
|
||||
list, get and summary search. The summary search also matches tag names.
|
||||
- `updateMagicNote({ tags })` replaces the full list inside the same
|
||||
`BEGIN IMMEDIATE` transaction as the revision check, so a stale revision
|
||||
changes nothing. `createMagicNote` accepts initial tags.
|
||||
- Tags with no remaining links are deleted by the write that orphaned them
|
||||
(tag replacement and note deletion). `listMagicNoteTags()` returns only
|
||||
linked tags with `noteCount`, ordered by count then name.
|
||||
- `listMagicNotes({ tags })` is an AND filter on `name_key`.
|
||||
- `renameMagicNoteTag` updates the name, or merges when the new key belongs to
|
||||
another tag: links move with `INSERT OR IGNORE`, then the source tag is
|
||||
deleted. `deleteMagicNoteTag` removes the tag and its links. Both bump the
|
||||
revision and `updated_at` of every affected note so open editors do not save
|
||||
over the change, and both emit one `onMagicNotesChanged`.
|
||||
|
||||
IPC: `magic-notes:list` now returns `{ notes, tags }`; new channels
|
||||
`magic-notes:rename-tag` and `magic-notes:delete-tag`; `magic-notes:update`
|
||||
accepts `tags`. Preload exposes `renameTag` and `removeTag`.
|
||||
|
||||
Renderer: tag UI lives in `MagicNoteTags.tsx`. The workspace filters the loaded
|
||||
summaries on the client (AND by tag key, plus tag names in the search box), so
|
||||
filter changes do not refetch. Filter keys that no longer exist after a refresh
|
||||
are ignored; renaming a filtered tag carries the filter to the new key. Tag edits
|
||||
from the note header use the detail revision and `applyDetail`, so title and
|
||||
composer drafts are kept.
|
||||
|
||||
Agent tools: `note_create` and `note_update` accept `tags` (update replaces the
|
||||
list; `[]` clears it), `note_list` accepts a `tags` AND filter, and note
|
||||
summaries returned to tools include `tags`. Descriptions tell the model to change
|
||||
tags only when the user asks. The conversation capture panel does not edit tags
|
||||
in this version.
|
||||
|
||||
## Agent Search Contract
|
||||
|
||||
`note_search` accepts an integer `limit` from 1 to 100, defaulting to 8.
|
||||
@@ -469,6 +564,13 @@ The built-in MCP gateway returns invalid scoped-tool arguments as an
|
||||
the model to correct its arguments without an MCP internal-error response.
|
||||
Validation failures do not execute the tool.
|
||||
|
||||
Note tools can be authorized with the knowledge-base switch off. The shared
|
||||
desktop gateway binds authorization to each request, without a fixed elapsed-time
|
||||
expiry, so long-running requests retain tool access. Request completion,
|
||||
cancellation, failure cleanup, or gateway disposal revokes the token. Invalid or
|
||||
revoked tokens report unavailable tool authorization rather than a knowledge-base
|
||||
expiry error.
|
||||
|
||||
These note tools run in the desktop gateway. Remote gbagent ACP sessions
|
||||
currently inject only the image MCP server, so this change requires no
|
||||
daemon implementation update.
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
|
||||
## 紧凑面板
|
||||
|
||||
面板按单列显示列表或笔记详情,不嵌入完整工作区的三栏布局。搜索位于列表顶部,“新建笔记”在列表之后;笔记行显示标题、置顶状态、两行摘要和更新时间。无笔记时提供新建动作;搜索无结果时保留搜索词并允许创建。
|
||||
面板按单列显示列表或笔记详情,不嵌入完整工作区的多栏布局。搜索位于列表顶部,“新建笔记”在列表之后;笔记行显示标题、置顶状态、两行摘要和更新时间。无笔记时提供新建动作;搜索无结果时保留搜索词并允许创建。
|
||||
|
||||
详情顶部提供返回列表、笔记标题和打开完整工作区,不显示“全局”标识。中间以可滚动记录摘要供浏览,每条文字摘要最多显示前 600 个字符,画布显示首页缩略图及页数;记录按钮可在完整工作区定位该记录。底部提供快速追加文字输入区与“加入笔记”按钮,画布编辑、待办管理和 AI 评论阅读仍在完整工作区。
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
```text
|
||||
加入笔记
|
||||
来自:项目 / 会话标题 · 单条回答或当前对话
|
||||
保存范围:用户与助手正文,不含推理、工具日志和附件文件
|
||||
来源旁帮助按钮:按需查看保存范围
|
||||
|
||||
[可编辑文字预览]
|
||||
|
||||
@@ -35,7 +35,9 @@
|
||||
[取消] [加入笔记]
|
||||
```
|
||||
|
||||
范围说明随单条或整段来源调整。预览初始包含完整待保存文字,可滚动编辑。新建目标标题默认取会话标题前 100 个字符,可修改;已有目标与新建目标使用共享 `SegmentedControl` 切换。采集内容为空或超限、缺少目标、新建标题为空或超限时就地提示,并禁用保存;普通新建允许创建空笔记。
|
||||
范围说明随单条或整段来源调整,通过来源旁的 `InlineHelp` 按需查看;回答未完成的警告仍直接显示。预览初始包含完整待保存文字,可滚动编辑。新建目标标题默认取会话标题前 100 个字符,可修改;已有目标与新建目标使用共享 `SegmentedControl` 切换。采集内容为空或超限、缺少目标、新建标题为空或超限时禁用保存;初始不显示通用校验说明,正文和标题超限时在对应字段旁提示。普通新建允许创建空笔记。
|
||||
|
||||
底栏默认仅显示右对齐的操作按钮。保存失败时保留草稿,错误与按钮在宽面板中同行,窄面板中错误换行、按钮仍靠右。完整工作区的文字保存按钮在窄窗口中也保持内容宽度并右对齐。
|
||||
|
||||
预览和快速追加输入框通过无障碍名称“待加入内容”标识,不再显示重复标签。提交区仅显示校验或保存错误,不显示“全局”。提交与来源跳转按钮组均居右,可在窄面板换行;来源没有项目名时省略项目及分隔符,不补写“全局”。
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ Provider 要求的附加元数据;GoodBuddy 只在直连链路或 Runtime 明
|
||||
| --- | --- |
|
||||
| [UI 设计](./ui-design.md) | 编辑入口、校验反馈、兼容性说明和无障碍行为 |
|
||||
| [技术设计](./technical-design.md) | 契约、持久化、合并优先级、Runtime 支持矩阵和验证 |
|
||||
| [执行方式与模型选择](./runtime-selection-design.md) | 会话/项目/全局分层解析、选择器行为、存储迁移、全局 `provider` 移除计划和用途模型规划 |
|
||||
| [功能进度](./progress.md) | 已验证实现、剩余工作和验证证据 |
|
||||
|
||||
模型连接选择准确性也在本目录维护:技术设计第 8 节定义通道直连回退修复,
|
||||
|
||||
@@ -1,5 +1,32 @@
|
||||
# 模型连接请求定制进度
|
||||
|
||||
## 2026-09-29:执行方式与模型分层选择
|
||||
|
||||
设计见[执行方式与模型选择设计](./runtime-selection-design.md)。
|
||||
|
||||
### 已完成(未提交)
|
||||
|
||||
- 共享选择层契约、`resolveRuntimeChoice` 分层解析及失效回退;移除 `auto`。
|
||||
- 数据库版本 49 迁移旧选择形状;连接删除后清理所有项目和会话的失效引用。
|
||||
- Main 每次请求按会话层、项目层重新解析,不回写会话;排队输入在派发时重新解析。
|
||||
- 项目设置 `ProjectRuntimeSelector`(本地、远程、通道共用),输入框两栏
|
||||
`RuntimeModelPicker`;系统设置恢复各 Runtime 页签的默认模型选择。
|
||||
- 选择器修复:模型列不再与底栏重叠,长名称单行截断;“管理模型连接…”直接打开模型连接分类。
|
||||
|
||||
### 已验证
|
||||
|
||||
- `npm run typecheck` 与相关文件 eslint 通过。
|
||||
- Main、Shared、Daemon 测试集通过;`App.test.tsx` 与 `WorkspacePrimitives.test.tsx`
|
||||
350 项中 349 项通过,唯一失败为既有不稳定用例“renders, saves and reloads OpenCode
|
||||
answers across question rounds”,单独运行通过。
|
||||
|
||||
### 待办
|
||||
|
||||
- 在运行中的应用里目测两栏选择器(窄窗口、深色主题、长名称)。
|
||||
- 移除全局 `provider`,见[设计第 6 节](./runtime-selection-design.md#6-全局-provider历史遗留待移除)。
|
||||
- 用途模型,见[设计第 7 节](./runtime-selection-design.md#7-用途模型规划未实现)。
|
||||
|
||||
|
||||
## 2026-09-27:窄容器协议显示
|
||||
|
||||
连接管理布局改用命名容器查询,不超过 `640px` 时列表和表单上下排列;移除原先仅在
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
# 执行方式与模型选择设计
|
||||
|
||||
本文件是执行方式(Runtime)与模型选择的唯一权威说明,覆盖分层解析、各入口的界面
|
||||
行为、存储迁移,以及尚未实现的“用途模型”规划。连接本身的请求定制见
|
||||
[技术设计](./technical-design.md),DeepSeek Harness 的平台来源解析见
|
||||
[Harness 技术设计](../deepseek-harness/technical-design.md#121-配置来源)。
|
||||
|
||||
## 1. 术语
|
||||
|
||||
| 术语 | 定义 |
|
||||
| --- | --- |
|
||||
| 执行方式 | `provider`:`model`(直连模型)、`opencode`、`continue`、`deepseek-harness` |
|
||||
| 模型选择 | `{ kind: 'profile', profileId }` 或 `{ kind: 'runtime-config' }`(Runtime 自有配置) |
|
||||
| 选择层 | `RuntimeSelectionLayer = { provider?, model? }`,两个字段都可缺省 |
|
||||
| 全局默认模型 | 每个执行方式在“系统设置”中的默认模型:直连用 `defaultModelProfileId`,其余用 `opencodeModelSource` / `continueModelSource` / `deepseekHarnessModelSource` |
|
||||
| 具体选择 | 解析结果 `AgentRuntimeSelection`,包含确定的 `provider`,以及 `profileId` 或 `runtimeConfig: true` 之一 |
|
||||
|
||||
## 2. 分层与优先级(已实现)
|
||||
|
||||
选择按“会话 → 项目 → 全局”逐字段解析,入口为共享函数 `resolveRuntimeChoice`
|
||||
(`src/shared/runtime-selection-contracts.ts`):
|
||||
|
||||
1. **执行方式**:会话层 `provider` → 项目层 `provider` → 全局兜底(见第 6 节)。
|
||||
2. **模型**:只继承与最终执行方式一致的层。会话单独切换执行方式时,丢弃会话层模型,
|
||||
沿用项目层(若项目执行方式相同)或该执行方式的全局默认模型。
|
||||
3. 结果附带 `providerSource` / `modelSource`(`global`、`project`、`conversation`),
|
||||
界面据此显示来源标记。
|
||||
|
||||
失效处理:
|
||||
|
||||
| 所选模型状态 | 行为 |
|
||||
| --- | --- |
|
||||
| 可用 | 使用 |
|
||||
| `unavailable`(缺少 API Key) | 保留选择;运行时明确报告缺少凭据,不静默换模型 |
|
||||
| `missing`(连接已删除) | 回退到下一层,并在选择器中提示回退原因 |
|
||||
| `incompatible`(协议或能力不匹配) | 同上 |
|
||||
|
||||
- 图像生成连接只对直连模型有效,且永远不作为回退默认值。
|
||||
- 远程项目只允许 OpenCode 与 Continue;其他执行方式按 `provider` 原因回退。
|
||||
- 全局 `defaultModelProfileId` 为空时解析为 `{ provider: 'model' }`,不生成空 `profileId`。
|
||||
- 不存在 `auto`。旧 `{ provider: 'auto' }` 在读取和 IPC 入口处去除,按上述顺序解析。
|
||||
|
||||
## 3. 各入口行为(已实现)
|
||||
|
||||
### 3.1 项目设置
|
||||
|
||||
本地、远程和通道项目共用 `ProjectRuntimeSelector`:
|
||||
|
||||
- “执行方式”必选,没有“继承”选项;保存时总是写入具体 `provider`
|
||||
(`ProjectSwitcher` 的 `withProjectProvider`、`ChannelProjectSettingsFields` 的
|
||||
`channelProjectDraft`)。
|
||||
- “模型”默认项为“跟随 xx 默认(…)”,其余为该执行方式可用的连接及“使用 xx 自有配置”。
|
||||
- 新建会话跟随项目,不再提供“默认执行方式”四选一。
|
||||
|
||||
### 3.2 输入框选择器
|
||||
|
||||
`RuntimeModelPicker` 为两栏菜单:左栏执行方式,右栏当前高亮执行方式的模型。
|
||||
|
||||
- 选择只写入会话层;左栏选择与项目相同的执行方式时写入 `undefined`,保持继承。
|
||||
- 右栏首项为“默认(…)”,带来源标记(全局 / 项目);已删除或不兼容的连接禁用,缺少
|
||||
API Key 的连接可选并标注。
|
||||
- 会话存在单独选择时,底栏显示“本会话单独选择”和“恢复为项目默认”;输入区按钮不在图标与 Runtime 名称之间添加圆点。
|
||||
- 模型列按最长列表预留高度,悬停切换执行方式时菜单不跳动;列内超出部分滚动,底栏
|
||||
固定在两栏下方。长名称单行截断,完整名称和模型 ID 通过 `title` 与可访问名称提供。
|
||||
- “管理模型连接…”打开“设置 → 模型连接”。
|
||||
- 键盘:左右键在两栏间切换,上下 / Home / End 在栏内移动,Esc 关闭并还焦点。
|
||||
|
||||
### 3.3 系统设置
|
||||
|
||||
“设置 → Agent Runtime”按 OpenCode / Continue / DeepSeek Harness 页签分别选择各自的
|
||||
全局默认模型;直连模型的默认值在“模型连接”中设置。设置页没有“全局执行方式”控件。
|
||||
|
||||
## 4. 执行时解析(已实现)
|
||||
|
||||
- Main 的 `resolveStoredRuntimeSelection` / `resolveLayeredRuntimeSelection` 在每次请求时
|
||||
读取会话层与项目层重新解析,不把解析结果回写到会话记录。
|
||||
- 排队的输入在真正派发时重新解析,不使用入队时的快照。
|
||||
- 定时任务的选择在创建时冻结在任务上;运行时优先任务选择,再到项目层。
|
||||
- 远程事件的 context metrics 按本次请求实际使用的具体选择记录
|
||||
(`remoteEventSelection`);未传入时数据库按会话层、项目层解析,缺省执行方式为
|
||||
OpenCode。
|
||||
|
||||
## 5. 存储与迁移(已实现)
|
||||
|
||||
- `projects.runtime_selection_json` 与 `conversations.runtime_selection_json` 保存紧凑
|
||||
的选择层;空层保存为 `NULL`。
|
||||
- 数据库版本 49(`migrateRuntimeSelectionLayers`)把旧的 `{ provider, profileId }` 和
|
||||
`{ provider: 'auto' }` 改写为选择层;读取时 schema 预处理也会升级旧形状。
|
||||
- `repairConversationRuntimeSelections` 在连接被删除后清除所有项目与会话中的失效
|
||||
`profileId`。
|
||||
- 通道项目创建为 `{ provider: 'model' }`;SSH 项目允许不保存选择。
|
||||
|
||||
## 6. 全局 `provider`(历史遗留,待移除)
|
||||
|
||||
`RuntimeSettings.provider` 来自项目层出现之前的“全局执行方式”。它已没有设置界面,
|
||||
但仍在以下位置被读取:
|
||||
|
||||
| 位置 | 用途 |
|
||||
| --- | --- |
|
||||
| `resolveRuntimeChoice` 的 `normalizedGlobalProvider` | 项目和会话都没有 `provider` 时的兜底(旧数据、无项目的状态查询) |
|
||||
| `getConfiguredRuntimeTarget` → `createConfiguredRuntime`(`src/main/index.ts`) | 启动和设置变更时创建默认 Runtime;监督者回顾、无唯一项目的心跳等不带项目的请求使用它 |
|
||||
| `runtime-settings-store.ts` | 归一化、`auto` → `model` 迁移和写回 |
|
||||
| `SettingsPanel` | 无界面地读入并原样保存 |
|
||||
|
||||
结论:该字段不应继续作为用户概念存在。移除方案与第 7 节一起实施:
|
||||
|
||||
1. 启动修复为所有 `provider` 为空的项目写入当时解析出的具体执行方式。
|
||||
2. 兜底固定为 `model`;默认 Runtime 固定为直连模型。
|
||||
3. 设置输入和公开设置不再包含 `provider`;存储保留该字段只用于读取旧文件,不再写入。
|
||||
|
||||
## 7. 用途模型(规划,未实现)
|
||||
|
||||
### 7.1 问题
|
||||
|
||||
后台功能各自隐式决定模型,用户无法控制,也无法从界面得知:
|
||||
|
||||
| 用途 | 当前行为 | 是否需要工具 |
|
||||
| --- | --- | --- |
|
||||
| 监督者回顾与整理 | 全局 `provider` 对应的默认 Runtime 与模型 | 否,工具调用被拒绝 |
|
||||
| 心跳报告 | 单一项目时跟随项目,否则同上 | 否 |
|
||||
| Magic Notes 分析 | 直连默认模型连接 | 否 |
|
||||
| 知识图谱抽取 | 直连默认模型连接(HTTP) | 否 |
|
||||
| 上下文压缩 | 已有 `contextCompression.modelSource`(当前模型 / 指定连接) | 否 |
|
||||
| 子代理 | 直连模型,可按专家覆盖 | 是,保持现状 |
|
||||
| 定时任务 | 任务或项目选择 | 是,保持现状 |
|
||||
|
||||
### 7.2 决策
|
||||
|
||||
- 用途只选择**模型连接**,统一走直连模型,不提供执行方式选择。上述用途都是无工具的
|
||||
文本请求,为其启动 OpenCode / Continue / DeepSeek Harness 只增加延迟、成本和故障面。
|
||||
- 每个用途的取值为“跟随默认模型”或一个文本模型连接;图像连接不可选。
|
||||
- 需要工具的子代理和定时任务不纳入本设置。
|
||||
|
||||
### 7.3 范围
|
||||
|
||||
“设置 → 模型连接”新增“用途模型”小节,包含:
|
||||
|
||||
- 监督者回顾与整理
|
||||
- 心跳报告(无唯一项目时)
|
||||
- Magic Notes 分析
|
||||
- 知识图谱抽取
|
||||
- 上下文压缩(迁入现有 `contextCompression.modelSource`,语义不变)
|
||||
|
||||
### 7.4 解析与失效
|
||||
|
||||
- 解析:用途设置的连接 → 全局 `defaultModelProfileId`。
|
||||
- 连接被删除:设置修复为“跟随默认模型”,与第 5 节的失效清理一致。
|
||||
- 连接缺少 API Key:保留选择,运行时明确失败,不静默改用默认模型。
|
||||
- 心跳在有唯一项目时仍跟随项目选择;用途设置只替代原先的全局兜底。
|
||||
|
||||
### 7.5 待定
|
||||
|
||||
- 设置字段形状(建议 `purposeModels: Record<Purpose, { kind: 'default' } | { kind: 'profile', profileId }>`)
|
||||
与设置文件版本号。
|
||||
- `contextCompression.modelSource` 的 `current`(跟随当前会话模型)是否保留为压缩用途
|
||||
专有的第三个选项。
|
||||
- 监督者模型并发(`supervisorModelConcurrency`)是否与所选连接绑定显示。
|
||||
@@ -52,10 +52,11 @@ OpenCode 与 DeepSeek Harness 不使用该值。
|
||||
|
||||
## 会话与项目 Runtime 选择
|
||||
|
||||
会话与项目选择器只提供直连模型和各 Runtime 入口,不再枚举每个 Runtime 与每个模型的
|
||||
组合;Runtime 模型在系统设置中配置。已保存的固定 Runtime 模型选择继续保留,项目
|
||||
选择器的选项与帮助文字均显示其固定模型,不能误报为跟随全局设置;用户可选择普通
|
||||
Runtime 入口恢复跟随。该展示规则不改变模型解析、远程 Agent 协议或执行权限。
|
||||
监督者在自身“设置 → 模型”中引用已保存的文本模型连接,不复制连接或凭据;默认跟随与执行生效规则见[监督者模型选择](../conversation-supervision/logic-design.md#监督者模型选择)。
|
||||
|
||||
项目设置分“执行方式”和“模型”两个字段,输入框使用两栏选择器,全局默认模型仍在系统
|
||||
设置中按 Runtime 页签配置。分层规则、选择器状态、键盘行为和失效提示由
|
||||
[执行方式与模型选择设计](./runtime-selection-design.md#3-各入口行为已实现)唯一维护。
|
||||
|
||||
## Harness 模型来源展示
|
||||
|
||||
|
||||
@@ -5,10 +5,10 @@
|
||||
| 项目 | 内容 |
|
||||
| --- | --- |
|
||||
| 状态 | 设计中 |
|
||||
| 版本 | 0.1 |
|
||||
| 日期 | 2026-09-09 |
|
||||
| 版本 | 0.2 |
|
||||
| 日期 | 2026-09-28 |
|
||||
| 适用产品 | GoodBuddy Desktop、ShareServer |
|
||||
| 推荐编辑引擎 | ONLYOFFICE Docs Developer Edition,最终采用前完成许可证与分发审核 |
|
||||
| 推荐编辑引擎 | 首选 ONLYOFFICE Docs Developer Edition;正式采用前验证实时编辑接口并确认授权与报价 |
|
||||
|
||||
## 1. 背景
|
||||
|
||||
@@ -21,7 +21,8 @@ XLSX 和 PPTX,也无法把当前选区交给 Agent 后在原位置应用修改
|
||||
- Windows、macOS 和 Linux Desktop 使用同一套浏览器编辑能力。
|
||||
- DOCX、XLSX、PPTX 在助手工作栏中以多个独立文档页签打开。
|
||||
- 用户可以手动编辑、保存、撤销和重做。
|
||||
- Agent 可以读取用户明确选择的内容,并将一次 AI 修改作为一个可撤销编辑事务应用。
|
||||
- 用户在左侧与 Agent 对话,在右侧助手工作栏看到当前文档的修改状态和结果,无需重新打开文件。
|
||||
- Agent 可以读取用户明确选择的最新内容,并将一次 AI 修改作为一个可撤销编辑事务应用。
|
||||
- 本地源文件只在用户明确打开后上传工作副本,保存时通过 Desktop 原子回写。
|
||||
- ShareServer 统一承载编辑器接入、短期会话、工作副本、回调、保留和服务端审计。
|
||||
|
||||
@@ -66,10 +67,18 @@ ONLYOFFICE 保存先形成 ShareServer 保存检查点。Desktop 下载并验证
|
||||
|
||||
### FR-6 AI 选区编辑
|
||||
|
||||
用户在编辑器中明确选择文字、单元格、对象、页面或幻灯片后,可以发起 AI 编辑。编辑器只提供
|
||||
本次选区的有界结构和必要上下文。Agent 返回编辑器支持的结构化操作,由编辑器插件应用;一次
|
||||
请求形成一个撤销事务,并在完成后标出修改位置。只有 Execute 模式可以应用修改;Ask 模式
|
||||
可以生成建议,但不得调用编辑器写入。
|
||||
用户在编辑器中明确选择文字、单元格、对象、页面或幻灯片后,可以发起 AI 编辑。编辑器提供
|
||||
当前会话内本次选区的最新内容、有界结构和必要上下文,包括尚未保存到源文件的人工修改。
|
||||
Agent 通过当前编辑会话应用受支持的结构化操作;一次请求形成一个撤销事务。只有 Execute
|
||||
模式可以应用修改;Ask 模式可以生成建议,但不得调用编辑器写入。
|
||||
|
||||
首期有界操作至少覆盖选区读取、指定文本替换、单元格区域公式与数字格式设置,以及指定幻灯片
|
||||
文本和对象属性修改。操作前明确目标文件与范围,完成后返回实际修改结果;不支持的操作说明
|
||||
限制,不以模型生成了建议或工具请求已发出作为成功依据。
|
||||
|
||||
Agent 思考期间用户可以继续编辑。应用前检查当前文档版本与目标是否仍符合本次请求;内容变化
|
||||
或目标失效时停止应用旧结果,保留人工修改并提示基于最新内容重试。切换页签或选区不得将已
|
||||
提交请求改发到其他目标。已打开文档的交互修改不得通过直接重写源文件并重载编辑器实现。
|
||||
|
||||
### FR-7 权限与数据去向
|
||||
|
||||
@@ -102,6 +111,34 @@ Desktop 记录本地打开、保存、另存为、冲突、AI 编辑请求和终
|
||||
Desktop 只连接用户已明确添加并认证的 ShareServer。ONLYOFFICE 地址、JWT 密钥、对象存储和
|
||||
回调配置由 ShareServer 管理,不进入 Desktop Renderer 或普通设置导出。
|
||||
|
||||
推荐由部署方在服务器 Docker 中独立运行 ONLYOFFICE Document Server,客户端加载编辑器;
|
||||
普通用户无需安装 Docker。启用前验证文件下载、保存回调和中文字体,并说明工作副本会进入
|
||||
所选服务的处理范围。完全离线编辑不属于此部署方案。
|
||||
|
||||
### FR-13 Agent 修改可见性
|
||||
|
||||
右侧文档页签与 Agent 使用同一个编辑会话。请求处理中显示当前目标与执行状态;编辑器确认
|
||||
应用后,内容在当前页签直接更新,用户可定位并看到修改范围的临时高亮。状态提示和高亮不写入
|
||||
文档正文,执行完成不等于源文件已保存。用户可以继续手动编辑,并对该次 AI 修改撤销或重做。
|
||||
|
||||
实时可见以一次已确认的编辑事务为单位,不要求模拟逐字输入。失败、取消或结果未知应明确
|
||||
显示实际状态;提交前取消不修改文档,已应用的事务通过撤销恢复,结果未知时先核实再重试。
|
||||
|
||||
### FR-14 选型验证与授权
|
||||
|
||||
ONLYOFFICE 是统一承载 Word、Excel、PPT 的首选方案。正式采用前,必须在同一嵌入编辑会话中
|
||||
验证选区读取、人工修改反馈、Agent 写入、修改定位与高亮、撤销重做,以及保存后重新打开。
|
||||
验证目标见第 6 节,不能以编辑器可打开或转换成功代替 Agent 编辑验证。
|
||||
|
||||
优先验证 Automation API 与 GoodBuddy 编辑器插件的配合方式。根据
|
||||
[官方 Automation API 文档](https://api.onlyoffice.com/docs/docs-api/usage-api/automation-api/)
|
||||
(2026-09-28 核对),该接口仅适用于 Docs Developer,且需额外付费。选型需取得覆盖桌面嵌入、
|
||||
私有部署、并发连接、分发和所需接口的授权条件及报价;社区版容器部署成功不代表取得这些权益。
|
||||
插件独立接入可作为备选验证,不预先认定与 Automation API 等价或免除商业授权要求。
|
||||
|
||||
三类文件的实际接口覆盖、单次撤销、高亮方式、中文字体与复杂格式兼容性,以及授权成本均为
|
||||
待验证项。未通过的操作记录具体限制,再决定补充实现或调整范围;本 PRD 不表示已有可用实现。
|
||||
|
||||
## 5. 文件范围
|
||||
|
||||
首期可编辑格式:
|
||||
@@ -121,7 +158,22 @@ PDF 保持预览或文档处理用途,不进入首期 Office 编辑。宏格
|
||||
- 同一工作栏至少可以同时保留两个独立文档页签,切换不会串文件、选区或保存状态。
|
||||
- 同名文件的页签可辨认,页签标题和完整来源一致。
|
||||
- 保存失败、回调失败和源文件冲突均不会破坏原文件。
|
||||
- 每次 AI 修改可以单次撤销,拒绝或取消不会产生部分修改。
|
||||
- 每次已完成 AI 修改可以单次撤销和重做;拒绝或提交前取消不修改文档,应用后取消按 FR-13 处理。
|
||||
- ShareServer 不可用时已有本地文件仍可由默认应用打开,但不能伪装成内置编辑可用。
|
||||
- Renderer、编辑器页面和日志中均不存在本机绝对路径、ShareServer 管理密钥或长期文件令牌。
|
||||
- 文档会话过期后,旧启动 URL、文件 URL 和回调令牌均不可重用。
|
||||
|
||||
以下场景按 [用户故事](./user-stories.md) 的 `US-C1` 至 `US-C7`、`US-F1` 和 `US-F2` 验证;
|
||||
记录编辑器版本、授权版本、样本和实际结果,不把待测能力标记为已通过。
|
||||
|
||||
| 样本/场景 | 操作 | 通过标准 |
|
||||
| --- | --- | --- |
|
||||
| Word | 改写选中段落 | 右侧原位更新,保留约定段落与文字格式,范围外内容不变 |
|
||||
| Excel | 修改指定区域公式并设为两位小数 | 公式重算与预期一致,显示格式正确,区域外内容不变 |
|
||||
| PPT | 修改指定页文本及一个对象属性 | 对象定位准确,非目标对象与布局不变 |
|
||||
| 人工修改反馈 | 人工修改后不保存,立即发起 AI 请求 | Agent 读取到会话内最新内容 |
|
||||
| 并行编辑 | Agent 思考期间用户修改文档 | 旧结果不覆盖新修改,支持基于最新内容重试 |
|
||||
| 共同编辑流程 | 应用、定位、高亮、撤销、重做、保存并重新打开 | 当前页签无需重载即可显示结果;保存后的文件内容与预期一致 |
|
||||
|
||||
文件兼容性使用包含中文字体、分页、表格、图表和公式的实际样本检查。目标平台的嵌入加载、
|
||||
焦点、快捷键与复制粘贴一并验收;接口验证和授权确认完成后再进入完整工作栏与生产部署实施。
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# Office 协同编辑用户故事
|
||||
|
||||
日期:2026-09-28。状态:需求设计,尚未完成实现与真实编辑器验证。
|
||||
|
||||
需求范围见 [PRD](./prd.md)。本轮补充 ONLYOFFICE 同会话编辑、右侧修改展示和选型验证用例。
|
||||
|
||||
## US-A1 打开本地文档
|
||||
|
||||
**Given** 用户已连接支持 Office 编辑的 ShareServer,并在工作区选择一个受支持文件
|
||||
@@ -45,11 +49,15 @@
|
||||
|
||||
## US-C1 使用 AI 修改选区
|
||||
|
||||
关联:`FR-6`、`FR-13`。
|
||||
|
||||
**Given** 当前 Conversation 使用 Execute,用户在编辑器中选择受支持内容
|
||||
|
||||
**When** 用户提交 AI 修改要求
|
||||
|
||||
**Then** Agent 只接收有界选区和必要上下文,编辑器将完整结果作为一个事务应用并允许单次撤销。
|
||||
**Then** Agent 只接收当前会话的最新有界选区和必要上下文,右侧显示目标与处理状态;编辑器
|
||||
确认后原位更新、临时高亮并支持定位修改范围,无需重载。完整结果作为一个事务应用,可以
|
||||
单次撤销和重做;高亮不进入正文,完成状态不表示已写回源文件。
|
||||
|
||||
## US-C2 Ask 模式请求修改
|
||||
|
||||
@@ -60,6 +68,65 @@
|
||||
**Then** GoodBuddy 可以生成修改建议,但不会调用编辑器写入;界面提供切换 Execute 后重新应用
|
||||
的明确入口。
|
||||
|
||||
## US-C3 读取尚未保存的人工修改
|
||||
|
||||
关联:`FR-6`。
|
||||
|
||||
**Given** 用户已在右侧修改文档,但尚未保存到源文件
|
||||
|
||||
**When** 用户选择刚修改的内容并要求 Agent 继续编辑
|
||||
|
||||
**Then** Agent 基于当前编辑会话中的最新内容执行;磁盘旧文件和旧检查点不能替代本次上下文。
|
||||
|
||||
## US-C4 Agent 思考期间继续编辑
|
||||
|
||||
关联:`FR-6`、`FR-8`。
|
||||
|
||||
**Given** Agent 已读取选区,正在生成修改
|
||||
|
||||
**When** 用户继续修改文档,或切换到另一个文档页签与选区
|
||||
|
||||
**Then** 切换页签不改变原请求目标;原文档版本变化或目标失效时不应用旧结果,保留人工修改并
|
||||
提示基于最新内容重试。同一文档的其他页签也不能接收本次操作。源文件冲突仍按 `US-B2` 处理。
|
||||
|
||||
## US-C5 编辑 Word、Excel 和 PPT
|
||||
|
||||
关联:`FR-3`、`FR-6`、`FR-13`。
|
||||
|
||||
**Given** 已分别准备 DOCX、XLSX 和 PPTX 样本,当前 Conversation 使用 Execute
|
||||
|
||||
**When** 用户分别请求改写选中段落、修改选区公式及两位小数格式、修改指定幻灯片文本和对象属性
|
||||
|
||||
**Then** 按下表验证实际结果;每个样本均完成定位、高亮、单次撤销、重做、保存及重新打开检查。
|
||||
|
||||
| 样本 | 验收结果 |
|
||||
| --- | --- |
|
||||
| DOCX | 目标段落内容符合要求,约定文字与段落格式保留,范围外内容不变 |
|
||||
| XLSX | 目标区域公式及重算值符合预期,数字显示为两位小数,区域外内容不变 |
|
||||
| PPTX | 指定页面和对象定位正确,文本与属性变更符合要求,非目标对象及布局不变 |
|
||||
|
||||
## US-C6 不支持、取消和执行失败
|
||||
|
||||
关联:`FR-6`、`FR-13`。
|
||||
|
||||
**Given** 用户发起一次 AI 编辑请求
|
||||
|
||||
**When** 操作不受支持、用户取消或编辑器未能确认结果
|
||||
|
||||
**Then** 不支持时返回具体限制;提交前取消不修改文档,已应用事务提供撤销。未知结果明确显示
|
||||
待核实,不自动重复写入,不以建议生成成功代替编辑成功;执行失败保留原因和实际已应用状态。
|
||||
|
||||
## US-C7 编辑状态与源文件保存
|
||||
|
||||
关联:`FR-4`、`FR-13`。
|
||||
|
||||
**Given** 右侧已显示 Agent 修改完成,用户又进行了一次人工编辑
|
||||
|
||||
**When** 用户保存文档并重新打开源文件
|
||||
|
||||
**Then** 保存包含已确认的 AI 修改与后续人工修改;只有源文件回写成功才显示“已写回源文件”。
|
||||
回调或回写失败时保留失败状态及可用检查点,不因右侧已经更新而宣称保存完成。
|
||||
|
||||
## US-D1 网络中断后恢复
|
||||
|
||||
**Given** 编辑会话已经建立
|
||||
@@ -83,3 +150,26 @@
|
||||
**When** 用户继续访问旧页签
|
||||
|
||||
**Then** 新读取、保存和 AI 操作被拒绝,旧令牌不能重用,本地源文件不受服务端清理影响。
|
||||
|
||||
## US-F1 启用集中部署的 Office 服务
|
||||
|
||||
关联:`FR-7`、`FR-12`、`FR-14`。
|
||||
|
||||
**Given** 部署方已准备独立的 ONLYOFFICE 服务及所需授权,用户已连接 ShareServer
|
||||
|
||||
**When** 用户首次在 Desktop 打开 Office 文档
|
||||
|
||||
**Then** 无需在用户电脑安装 Docker;打开前能看到工作副本进入哪个服务及保留期限。实际验证
|
||||
文件下载、保存回调和中文字体;在 Windows、macOS 和 Linux 检查加载、焦点、快捷键与复制粘贴。
|
||||
|
||||
## US-F2 决定正式采用 ONLYOFFICE
|
||||
|
||||
关联:`FR-14`。
|
||||
|
||||
**Given** 产品团队以 ONLYOFFICE Docs Developer 为首选,正在进行接口与授权验证
|
||||
|
||||
**When** 评审是否进入完整功能实施
|
||||
|
||||
**Then** 提供 `US-C1` 至 `US-C7` 的真实会话验证结果、三类文件样本兼容性记录,以及覆盖实际
|
||||
部署与嵌入方式的授权条件和报价。Automation API 单独确认额外费用;插件替代方案单独验证。
|
||||
未通过项明确列出限制与范围决定,仅安装容器、打开文档或完成转换不能视为实时编辑验证通过。
|
||||
|
||||
@@ -27,9 +27,9 @@ Agent `0.11.23` 提供,显式请求期限与取消仍有效。当前问答源
|
||||
|
||||
## 跨会话进程复用(未发布)
|
||||
|
||||
2026-09-28 Story Graph 补充:远程 OpenCode/Continue 的 Ask 和 Execute
|
||||
通过现有受管 HTTP MCP、工具二进制通道读取桌面图谱。Main 固定项目范围并复核监督者
|
||||
开关及 Runtime 分配;图谱读取不复制数据库到 Host,也不调用模型。三个只读工具、
|
||||
2026-09-28 Story Graph 接线、2026-10-03 会话开关补充:远程 OpenCode/Continue 的 Ask 和 Execute
|
||||
通过现有受管 HTTP MCP、工具二进制通道读取桌面图谱。Main 固定项目和会话,并复核监督者
|
||||
应用开关、会话“使用故事图谱”开关及 Runtime 分配;关闭会话开关后已有远程绑定也拒绝读取。图谱读取不复制数据库到 Host,也不调用模型。三个只读工具、
|
||||
分页和版本限制由[专项设计](../conversation-supervision/story-graph-mcp-design.md)定义,
|
||||
源码 Host 探针及协议测试见[验证记录](../conversation-supervision/progress.md#2026-09-28-story-graph-只读工具)。
|
||||
|
||||
@@ -485,8 +485,10 @@ Runtime 包。2026-09-19 完整桌面验证覆盖联合包安装、CN 项目创
|
||||
重连时,工具及原生子代理的状态也以已提交的远端活动事件为准,覆盖消息终态处理派生的
|
||||
失败状态,避免将未完成活动误判为可恢复错误,保证终态重放内容一致。
|
||||
- 远程 context metrics 与对话级压缩后的估算值按本次请求实际使用的 Runtime 选择写入
|
||||
`context_state_json`。实时接收与恢复入口都传入请求的选择;数据库在未传入时按对话、
|
||||
项目、auto 顺序解析。继承项目设置的对话保持 `runtime_selection_json = NULL`,
|
||||
`context_state_json`。实时接收与恢复入口都传入请求的选择;数据库在未传入时按对话层、
|
||||
项目层解析,缺省执行方式为 OpenCode(规则见
|
||||
[执行方式与模型选择设计](../model-connections/runtime-selection-design.md#4-执行时解析已实现))。
|
||||
继承项目设置的对话保持 `runtime_selection_json = NULL`,
|
||||
不为接收 usage 固定对话 Runtime;去重和消息/指标写入仍在同一 SQLite 事务中完成。
|
||||
- ACK 后 Agent 删除已确认事件,只保留小型操作终态与游标用于幂等核对;未 ACK 输出继续
|
||||
保留。Main 按 transcript page 的最高连续 sequence ACK,终态仍立即 ACK。单事件、单页、
|
||||
|
||||
@@ -4,6 +4,8 @@ Smart Heartbeat 已提供按计划或手动执行的有界回顾,生成报告
|
||||
|
||||
现有入口已升级为“监督者”,智能心跳作为其定期唤醒机制,驱动检查与整理;应用提供工作回顾、故事线图谱、自动监督、活动记录和设置五个页签。计划在自动监督管理,创建与编辑使用 Modal;设置只保留算法参数。定义见[监督者应用产品设计](../conversation-supervision/supervisor-prd.md),实施方案见[监督者技术设计](../conversation-supervision/technical-design.md)。现有计划和范围保留,监督调用继续禁止工具执行。
|
||||
|
||||
职责(2026-09-30 已实现):心跳只负责定时触发增量回顾,并按计划配置的介入方式(生成建议/仅更新记忆)处理本次图谱变化;不再单独读取原文或生成报告。见[心跳职责与介入](../conversation-supervision/logic-design.md#心跳职责与介入目标设计)。下文关于心跳报告、报告超时和报告增量的描述为历史行为,仅旧报告保持只读。
|
||||
|
||||
自动报告及监督回顾分别按来源版本和已读位置处理增量;没有待处理输入时记录无变化,跳过模型及新报告。手动历史重看和删除报告的边界见[自动增量规则](../conversation-supervision/logic-design.md#自动增量与手动重看)。
|
||||
|
||||
## 文档导航
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
# 下一版 3D 故事线图谱概念
|
||||
|
||||
状态:已讨论的视觉方向,尚未实现。本文记录空间表达及概念图引用,不替代当前产品需求或技术设计。
|
||||
|
||||
## 时间、事件与故事
|
||||
|
||||
内部只有一条共同的时间螺旋。时间均匀推进,同一圈中交错发生不同事情;同一件事也可以在多个圈中间隔出现。
|
||||
|
||||
每圈的时长随查看范围变化,使螺旋保持约 8 圈、最多 14 圈:一天约 3 小时一圈,一周 1 天一圈,一个季度 1 个月一圈,几年 1 季度或 1 年一圈。用户可以手动指定,指定值会超过 14 圈时改用更粗的粒度。注意力密度按当前粒度重新统计,每圈 8 个时段。
|
||||
|
||||
同一故事中屏幕上过于接近的事件合并为一段,显示事件数量。点击聚合段,查看范围缩小到这段时间,事件随之拆开;小于约 6 小时仍无法拆开的事件(同一时刻多次提取),改为在右侧列出。
|
||||
|
||||
[3D 网页 Demo](3d-demo.html) 已按上述几何规则和时间尺度实现,数据为本机故事线图谱的只读导出。
|
||||
|
||||
螺旋半径可以随时间、随事件片段变化,表达注意力处理密度。时间轴不能随半径拉伸。密度的统计口径、归一化和缺失数据表现尚待定义,半径不代表效率、价值或成果。
|
||||
|
||||
外层以木桶木片般的曲面连接同一故事的事件。木片起止时间、长度各不相同,可以顺着事件位置弯曲;不能为保持规则桶形而强行等长、等径。多年持续的故事可能只有少数活跃阶段,事件之间保留空白,连接只表达同一故事的延续。未结束的故事使用开放末端,不虚构未来事件。
|
||||
|
||||
数据来源与层级(全局 › 项目 › 功能 › 子线索、跨项目故事、经验)由[故事线模型改造设计](../conversation-supervision/storyline-model-design.md)定义。
|
||||
|
||||
Demo 底部的“跨项目故事”开关默认关闭。打开后,根层在项目木片旁增加跨项目故事木片(示例:果蝇神经仿真、Agent 运行环境、国内服务器部署),由多个项目中的功能组成;事件仍归属原项目,跨项目木片只做关联。关闭时只显示同一项目内的经验连接。示例中的跨项目分组按功能名称手工对应,产品中应由故事归属阶段提出,并允许用户调整。
|
||||
|
||||
## DIKW 与阅读路径
|
||||
|
||||
| 层次 | 表达 |
|
||||
| --- | --- |
|
||||
| D | 可追溯的原始记录 |
|
||||
| I | 时间螺旋上的事件片段 |
|
||||
| K | 木片串联的故事及其形成的认识 |
|
||||
| W | 从早期故事形成经验及适用条件,关联到后来其他故事中的应用 |
|
||||
|
||||
沿螺旋查看某阶段发生的事情;沿木片查看一件事如何跨阶段发展;跨木片查看经验如何被使用、验证或修正。普通相关性不能直接标为智慧。
|
||||
|
||||
## 概念图记录
|
||||
|
||||
- 基础图片引用:`0d644776-5ed2-42cc-86f9-67f8b0195c86`。
|
||||
- 本次编辑任务:`b4e13361-acd6-49ba-9aaa-f37be837e257`。
|
||||
- 会话:`95a52db9-fe02-4559-95de-4488f2163004`。
|
||||
- 编辑要求:均匀时间轴、变半径单螺旋、不同起止与长度的故事木片、跨故事智慧复用。
|
||||
|
||||
提交绘图时任务仍在生成,尚未取得新图片文件。生成结果由会话图片卡片保存;本文只登记任务引用,不表示图片已下载到 Git 仓库。完成后应核验上述空间关系,再将导出的图片纳入设计资产。
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,608 @@
|
||||
/* 3D Story Graph prototype on a real export, rendered with WebGL (Three.js r160, vendor/three.min.js).
|
||||
* Same model as 3d-demo.js: height = time (uniform), helix radius = attention density, staves on a fixed outer
|
||||
* cylinder at stable angles, events joined to their stave at the same height. The GPU depth buffer handles
|
||||
* occlusion; names, counts and experience cards are drawn on a 2D overlay. Falls back to 3d-demo.js without WebGL. */
|
||||
(() => {
|
||||
const canvas = document.querySelector('#scene');
|
||||
let renderer;
|
||||
try {
|
||||
if (!window.THREE) throw new Error('three.js missing');
|
||||
renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: false });
|
||||
} catch {
|
||||
const fallback = document.createElement('script'); fallback.src = '3d-demo.js'; document.body.append(fallback);
|
||||
document.querySelector('#renderer-note')?.replaceChildren('Canvas 渲染');
|
||||
return;
|
||||
}
|
||||
const data = window.STORY_DATA;
|
||||
const overlay = document.querySelector('#overlay');
|
||||
const ctx = overlay.getContext('2d');
|
||||
const tau = Math.PI * 2;
|
||||
const palette = ['#367e70', '#bb8743', '#8675a6', '#b5604f', '#4f7fa8', '#7a8f3b', '#a35d86', '#3f8f97', '#9a7a55', '#5f6fb0', '#c07a3a', '#6b8d6f'];
|
||||
const range = data.range.map(d => Date.parse(d + 'T00:00:00+08:00'));
|
||||
const totalHours = (range[1] - range[0]) / 3600000;
|
||||
const zoom = document.querySelector('#zoom');
|
||||
const showWisdom = document.querySelector('#wisdom');
|
||||
const reducedMotion = matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
const BG = 0xfcfcf8;
|
||||
|
||||
// ---- model ---------------------------------------------------------------
|
||||
const canonical = new Map();
|
||||
data.projects.forEach(p => p.features.forEach(f => {
|
||||
const seen = new Map();
|
||||
f.events = f.events.filter(e => { const k = e.at + e.type; if (seen.has(k)) { canonical.set(e.id, seen.get(k)); return false; } seen.set(k, e.id); canonical.set(e.id, e.id); return true; });
|
||||
}));
|
||||
const canon = id => canonical.get(id) ?? id;
|
||||
data.wisdom.forEach(w => { w.formedIn = [...new Set(w.formedIn.map(canon))]; w.appliedIn = [...new Set(w.appliedIn.map(canon))]; });
|
||||
const root = { name: '全部工作', children: data.projects.map(p => ({
|
||||
name: p.name, detailed: p.detailed, scattered: p.scattered,
|
||||
children: p.features.map(f => ({ ...f, children: f.subThreads.map(s => ({ ...s,
|
||||
events: [...new Set(s.eventIds.map(canon))].map(id => f.events.find(e => e.id === id)).filter(Boolean) })) }))
|
||||
})) };
|
||||
const eventIndex = new Map();
|
||||
function finish(node, parent, depth) {
|
||||
node.parent = parent; node.depth = depth;
|
||||
(node.children ?? []).forEach(c => finish(c, node, depth + 1));
|
||||
if (!node.events) node.events = [...new Map(node.children.flatMap(c => c.events).map(e => [e.id, e])).values()].sort((a, b) => a.t - b.t);
|
||||
node.start = node.events[0]?.t ?? 0; node.end = node.events.at(-1)?.t ?? 0;
|
||||
if (depth === 2 && !node.ref) node.events.forEach(e => eventIndex.set(e.id, { event: e, feature: node }));
|
||||
}
|
||||
finish(root, undefined, 0);
|
||||
const projects = root.children;
|
||||
projects.forEach((p, i) => { p.color = palette[i % palette.length]; });
|
||||
// Cross-project stories (off by default): grouped from real feature names across projects. Their events still
|
||||
// belong to the original project features; a cross-project stave only associates them.
|
||||
const CROSS = [
|
||||
['果蝇神经仿真', [['goodbuddy', '果蝇脑仿真'], ['微信 ClawBot', '果蝇开源调研'], ['微信 ClawBot', '脉冲脑模型'], ['微信 ClawBot', '视觉模型'], ['微信 ClawBot', '运动仿真']]],
|
||||
['Agent 运行环境', [['goodbuddy', '远程Agent运行时'], ['goodbuddy', '模型与运行时'], ['dgx', 'Ollama 配置'], ['dgx', 'OCR 显存预留'], ['dgx', 'GBAgent 清理'], ['dgx', 'Agent 运行错误']]],
|
||||
['国内服务器部署', [['默认项目', '国内节点隐藏IP'], ['阿里云-国内', '阿里云-国内']]],
|
||||
];
|
||||
const crossStories = CROSS.map(([name, members], i) => {
|
||||
const children = members.map(([p, f]) => projects.find(x => x.name === p)?.children.find(x => x.name === f)).filter(Boolean)
|
||||
.map(ref => ({ name: `${ref.parent.name} · ${ref.name}`, ref, entities: ref.entities, events: ref.events, children: [] }));
|
||||
return { name, cross: true, color: palette[(projects.length + i * 2) % palette.length], children };
|
||||
});
|
||||
const crossToggle = document.querySelector('#cross');
|
||||
const projectOfEvent = id => eventIndex.get(id)?.feature.parent;
|
||||
const visibleWisdom = () => data.wisdom.filter(w => crossToggle.checked || projectOfEvent(w.formedIn[0]) === projectOfEvent(w.appliedIn.at(-1)));
|
||||
function setCrossProject(on) {
|
||||
root.children = on ? [...projects, ...crossStories] : projects;
|
||||
root.events = undefined;
|
||||
finish(root, undefined, 0);
|
||||
}
|
||||
let focus = root, selectedEvent, selectedWisdom, selectedCluster, hovered;
|
||||
function layout(node, from, to) {
|
||||
node.a0 = from; node.a1 = to;
|
||||
const kids = (node.children ?? []).slice().sort((a, b) => a.start - b.start);
|
||||
const weight = kids.map(k => Math.max(1.2, Math.sqrt(k.events.length)));
|
||||
const total = weight.reduce((a, b) => a + b, 0);
|
||||
let at = from;
|
||||
kids.forEach((k, i) => { const w = (to - from) * weight[i] / total; layout(k, at, at + w); at += w; });
|
||||
}
|
||||
layout(root, 0, tau);
|
||||
|
||||
// ---- geometry ----------------------------------------------------------------
|
||||
const HEIGHT = 430, STAVE_R = 188, WISDOM_R = 262, FLOOR = -HEIGHT / 2 - 34;
|
||||
const TURN_STEPS = [3, 6, 12, 24, 168, 720, 2160, 8760];
|
||||
const TARGET_TURNS = 8, MAX_TURNS = 14;
|
||||
const win = { t0: 0, t1: 1, turnHours: 24 };
|
||||
let turns = 8, bins = [], binMax = 1, smoothR = [];
|
||||
const hoursOf = t => t * totalHours;
|
||||
const clamp01 = t => Math.max(0, Math.min(1, t));
|
||||
const inWindow = t => t >= win.t0 - 1e-9 && t <= win.t1 + 1e-9;
|
||||
const uOf = t => (t - win.t0) / (win.t1 - win.t0);
|
||||
const tAt = u => win.t0 + u * (win.t1 - win.t0);
|
||||
function autoTurnHours(spanHours) { return TURN_STEPS.find(h => spanHours / h <= TARGET_TURNS) ?? TURN_STEPS.at(-1); }
|
||||
const slotTimes = data.density.map((n, i) => ({ t: (i + .5) / data.density.length, n }));
|
||||
function setWindow(t0, t1, turnHours) {
|
||||
win.t0 = clamp01(t0); win.t1 = clamp01(t1);
|
||||
const span = hoursOf(win.t1 - win.t0);
|
||||
win.turnHours = turnHours ?? autoTurnHours(span);
|
||||
turns = Math.max(.5, span / win.turnHours);
|
||||
if (turns > MAX_TURNS) { win.turnHours = TURN_STEPS.find(h => span / h <= MAX_TURNS) ?? TURN_STEPS.at(-1); turns = span / win.turnHours; }
|
||||
const count = Math.max(4, Math.round(turns * 8));
|
||||
bins = new Array(count).fill(0);
|
||||
slotTimes.forEach(s => { if (inWindow(s.t)) bins[Math.min(count - 1, Math.floor(uOf(s.t) * count))] += s.n; });
|
||||
binMax = Math.max(1, ...bins);
|
||||
// One radius per turn, from that turn's attention density, eased between turn centres with a cosine. Each
|
||||
// turn reads as a circle; the helix swells or narrows only from turn to turn.
|
||||
const n = Math.max(1, Math.ceil(turns - 1e-6)), perTurn = new Array(n).fill(0);
|
||||
bins.forEach((v, i) => { perTurn[Math.min(n - 1, Math.floor((i + .5) / bins.length * turns))] += v; });
|
||||
const top = Math.max(1e-6, ...perTurn), level = perTurn.map(v => Math.sqrt(v / top)); // sqrt keeps quiet turns visible
|
||||
// Neighbouring turns differ by at most MAX_STEP of the radius range, so the coil never jumps between turns.
|
||||
for (let pass = 0; pass < n; pass++) for (let d = 1; d < n; d++) {
|
||||
if (level[d] - level[d - 1] > MAX_STEP) level[d - 1] = level[d] - MAX_STEP;
|
||||
if (level[d - 1] - level[d] > MAX_STEP) level[d] = level[d - 1] - MAX_STEP;
|
||||
}
|
||||
// Each turn holds its radius (a circle) and changes only in a short eased zone around the turn boundary.
|
||||
smoothR = Array.from({ length: R_SAMPLES + 1 }, (_, i) => {
|
||||
const x = i / R_SAMPLES * turns, d = Math.min(n - 1, Math.floor(x)), f = x - d;
|
||||
const half = TRANSITION / 2;
|
||||
if (f < half && d > 0) { const k = (f + half) / TRANSITION; return level[d - 1] + (level[d] - level[d - 1]) * (1 - Math.cos(k * Math.PI)) / 2; }
|
||||
if (f > 1 - half && d < n - 1) { const k = (f - 1 + half) / TRANSITION; return level[d] + (level[d + 1] - level[d]) * (1 - Math.cos(k * Math.PI)) / 2; }
|
||||
return level[d];
|
||||
});
|
||||
}
|
||||
const MAX_STEP = .22, TRANSITION = .3;
|
||||
const R_SAMPLES = 600;
|
||||
function density(u) {
|
||||
const x = u * bins.length - .5, i = Math.floor(x), f = x - i, at = j => bins[Math.max(0, Math.min(bins.length - 1, j))] / binMax;
|
||||
return at(i) * (1 - f) + at(i + 1) * f;
|
||||
}
|
||||
// Interpolated, never stepped, so the tube stays a smooth curve.
|
||||
const radius = t => { const x = clamp01(uOf(t)) * R_SAMPLES, i = Math.floor(x), f = x - i; return 78 + 74 * (smoothR[i] * (1 - f) + smoothR[Math.min(R_SAMPLES, i + 1)] * f); };
|
||||
const yOf = t => (uOf(t) - .5) * HEIGHT;
|
||||
const helixAngle = t => uOf(t) * turns * tau;
|
||||
const V = (x, y, z) => new THREE.Vector3(x, y, z);
|
||||
const helixPoint = t => { const r = radius(t), a = helixAngle(t); return V(r * Math.cos(a), yOf(t), r * Math.sin(a)); };
|
||||
const cylinder = (t, a, r = STAVE_R) => V(r * Math.cos(a), yOf(t), r * Math.sin(a));
|
||||
const centerAngle = node => (node.a0 + node.a1) / 2;
|
||||
|
||||
// ---- scene -------------------------------------------------------------------
|
||||
const scene = new THREE.Scene();
|
||||
scene.background = new THREE.Color(BG);
|
||||
scene.fog = new THREE.Fog(BG, 700, 1250); // far geometry fades toward the paper colour instead of gaining outlines
|
||||
scene.add(new THREE.HemisphereLight(0xffffff, 0xc9cfc4, 2.2));
|
||||
const sun = new THREE.DirectionalLight(0xffffff, 1.2); sun.position.set(-300, 500, 400); scene.add(sun);
|
||||
const camera = new THREE.PerspectiveCamera(30, 1, 10, 4000);
|
||||
let world = new THREE.Group(); scene.add(world);
|
||||
renderer.setPixelRatio(Math.min(devicePixelRatio || 1, 2));
|
||||
const raycaster = new THREE.Raycaster();
|
||||
|
||||
// ---- camera ------------------------------------------------------------------
|
||||
const VIEWS = { oblique: { yaw: -.4, tilt: .38 }, side: { tilt: 0 }, top: { tilt: Math.PI / 2 - 1e-3 } };
|
||||
const cam = { yaw: -.4, tilt: .38, zoom: 1, pan: 0 };
|
||||
let mode = 'oblique', tween, width = 0, height = 0;
|
||||
const DIST = 900;
|
||||
function placeCamera() {
|
||||
const { yaw, tilt } = cam, ct = Math.cos(tilt), st = Math.sin(tilt);
|
||||
const target = V(0, cam.pan, 0);
|
||||
camera.position.copy(target).add(V(-Math.sin(yaw) * ct, st, Math.cos(yaw) * ct).multiplyScalar(DIST));
|
||||
camera.up.set(Math.sin(yaw) * st, ct, -Math.cos(yaw) * st);
|
||||
camera.lookAt(target);
|
||||
// Same framing as the Canvas version: at zoom 1 the 500 × 560 box fits the viewport.
|
||||
const visibleHeight = height / Math.min(width / 500, height / 560);
|
||||
camera.fov = 2 * Math.atan(visibleHeight / 2 / DIST) * 180 / Math.PI;
|
||||
camera.aspect = width / height; camera.zoom = cam.zoom; camera.updateProjectionMatrix();
|
||||
}
|
||||
function project(p) {
|
||||
const v = p.clone().project(camera);
|
||||
return { x: (v.x + 1) / 2 * width, y: (1 - v.y) / 2 * height, z: -v.z, behind: v.z > 1 };
|
||||
}
|
||||
const viewDir = () => camera.position.clone().sub(V(0, cam.pan, 0)).normalize();
|
||||
const shortest = (from, to) => from + ((((to - from) % tau) + tau * 1.5) % tau - Math.PI);
|
||||
function animateTo(target, nextMode = mode) {
|
||||
mode = nextMode; syncViewButtons();
|
||||
const goal = { ...cam, ...target };
|
||||
goal.yaw = shortest(cam.yaw, goal.yaw);
|
||||
if (reducedMotion) { Object.assign(cam, goal); syncZoom(); draw(); return; }
|
||||
const from = { ...cam }, started = performance.now();
|
||||
tween = { started };
|
||||
const step = now => {
|
||||
if (tween?.started !== started) return;
|
||||
const k = Math.min(1, (now - started) / 480), e = k < .5 ? 2 * k * k : 1 - (-2 * k + 2) ** 2 / 2;
|
||||
for (const key of ['yaw', 'tilt', 'zoom', 'pan']) cam[key] = from[key] + (goal[key] - from[key]) * e;
|
||||
syncZoom(); draw();
|
||||
if (k < 1) requestAnimationFrame(step); else tween = undefined;
|
||||
};
|
||||
requestAnimationFrame(step);
|
||||
}
|
||||
function face(node) { if (node && node.depth > 0) animateTo({ yaw: centerAngle(node) - Math.PI / 2, pan: (yOf(Math.max(win.t0, node.start)) + yOf(Math.min(win.t1, node.end))) / 2 * .35 }); }
|
||||
function freeView() { tween = undefined; if (mode !== 'free') { mode = 'free'; syncViewButtons(); } }
|
||||
|
||||
// ---- colour ------------------------------------------------------------------
|
||||
const projectOf = n => { while (n.depth > 1) n = n.parent; return n; };
|
||||
function colorOf(node) {
|
||||
if (node.depth <= 1) return node.color ?? '#6f807b';
|
||||
const p = projectOf(node);
|
||||
return palette[(node.parent.children.indexOf(node) + root.children.indexOf(p) * 3) % palette.length];
|
||||
}
|
||||
function shown(node) { const set = visibleStaves(); for (let n = node; n; n = n.parent) if (set.includes(n)) return n; }
|
||||
function level() { return focus.children?.length ? focus : focus.parent; }
|
||||
function visibleStaves() { return level().children; }
|
||||
function emphasisOf(s) { return focus === level() ? (s === hovered ? 'focus' : 'normal') : s === focus ? 'focus' : 'dim'; }
|
||||
function connected() { return visibleStaves().filter(s => (focus !== level() && s === focus) || s === hovered); }
|
||||
|
||||
// ---- clustering --------------------------------------------------------------
|
||||
const CLUSTER_PX = 9;
|
||||
function scaleNow() { return Math.min(width / 500, height / 560) * cam.zoom; }
|
||||
// Quantised to quarter-octaves so zooming re-clusters in steps rather than rebuilding geometry every frame.
|
||||
function clusterSpan() { const raw = CLUSTER_PX / (HEIGHT * scaleNow()) * (win.t1 - win.t0); return 2 ** (Math.round(Math.log2(raw) * 4) / 4); }
|
||||
function clusters(events) {
|
||||
const span = clusterSpan(), out = [];
|
||||
events.filter(e => inWindow(e.t)).forEach(e => {
|
||||
const last = out.at(-1);
|
||||
if (last && e.t - last.t0 <= span) { last.events.push(e); last.t1 = e.t; } else out.push({ t0: e.t, t1: e.t, events: [e] });
|
||||
});
|
||||
out.forEach(c => { c.t = (c.t0 + c.t1) / 2; c.n = c.events.length; });
|
||||
return out;
|
||||
}
|
||||
const fmtSpan = c => c.n === 1 ? fmtTime(c.t) : `${fmtTime(c.t0)} – ${fmtTime(c.t1)}`;
|
||||
|
||||
// ---- building geometry -----------------------------------------------------------
|
||||
// The scene is rebuilt only when what it shows changes; camera moves just re-render and redraw the overlay.
|
||||
const mats = new Map();
|
||||
function mat(key, make) { if (!mats.has(key)) mats.set(key, make()); return mats.get(key); }
|
||||
const solid = (color, opacity = 1) => mat(`s${color}${opacity}`, () => new THREE.MeshStandardMaterial({ color, roughness: .65, metalness: 0, transparent: opacity < 1, opacity }));
|
||||
const flat = (color, opacity) => mat(`f${color}${opacity}`, () => new THREE.MeshBasicMaterial({ color, transparent: true, opacity, side: THREE.DoubleSide, depthWrite: false }));
|
||||
const lineMat = (color, opacity) => mat(`l${color}${opacity}`, () => new THREE.LineBasicMaterial({ color, transparent: opacity < 1, opacity }));
|
||||
function tube(points, r, material, segments) {
|
||||
const curve = new THREE.CatmullRomCurve3(points);
|
||||
return new THREE.Mesh(new THREE.TubeGeometry(curve, segments ?? Math.max(8, points.length * 2), r, 8, false), material);
|
||||
}
|
||||
function polyline(points, material) { return new THREE.Line(new THREE.BufferGeometry().setFromPoints(points), material); }
|
||||
let built = '', baseBuilt = '', staveMeshes = [], baseTicks = [], overlayItems = { labels: [], clusters: [], wisdom: [] };
|
||||
let base = new THREE.Group(); scene.add(base);
|
||||
let split; // in-place split / merge animation of staves between levels
|
||||
function signature() {
|
||||
return JSON.stringify([focus.name, focus.depth, hovered?.name, selectedEvent?.id, selectedWisdom?.lesson, win.t0, win.t1, win.turnHours, showWisdom.checked, clusterSpan(), split ? split.k.toFixed(3) : '']);
|
||||
}
|
||||
const dispose = group => group.traverse(o => o.geometry?.dispose());
|
||||
// Geometry that depends only on the time window: floor, the grey helix and its turn ticks.
|
||||
function rebuildBase() {
|
||||
scene.remove(base); dispose(base); base = new THREE.Group(); scene.add(base); baseTicks = [];
|
||||
const ring = []; for (let i = 0; i <= 96; i++) { const a = i / 96 * tau; ring.push(V(STAVE_R * Math.cos(a), FLOOR, STAVE_R * Math.sin(a))); }
|
||||
base.add(polyline(ring, lineMat('#9aa69c', .35)));
|
||||
const shadow = []; for (let i = 0; i <= 900; i++) { const p = helixPoint(tAt(i / 900)); p.y = FLOOR; shadow.push(p); }
|
||||
base.add(tube(shadow, 1.4, flat('#8e9a90', .14), 900));
|
||||
const helix = []; for (let i = 0; i <= 1400; i++) helix.push(helixPoint(tAt(i / 1400)));
|
||||
base.add(tube(helix, 1.1, solid('#7d8a84'), 2800));
|
||||
const every = Math.max(1, Math.ceil(turns / 10));
|
||||
for (let d = 0; d <= Math.floor(turns + 1e-6); d++) {
|
||||
const t = tAt(d / turns), p = helixPoint(t);
|
||||
const tick = new THREE.Mesh(new THREE.SphereGeometry(2.2, 10, 8), solid('#3f514a')); tick.position.copy(p); base.add(tick);
|
||||
if (d % every === 0 && d / turns < .999) baseTicks.push({ at: p, text: fmtTick(t) });
|
||||
}
|
||||
baseBuilt = JSON.stringify([win.t0, win.t1, win.turnHours]);
|
||||
}
|
||||
function rebuild() {
|
||||
if (baseBuilt !== JSON.stringify([win.t0, win.t1, win.turnHours])) rebuildBase();
|
||||
scene.remove(world); dispose(world);
|
||||
world = new THREE.Group(); scene.add(world);
|
||||
staveMeshes = []; overlayItems = { labels: [], clusters: [], wisdom: [] };
|
||||
const staves = visibleStaves().filter(s => s.events.some(e => inWindow(e.t)) && s.a1 - s.a0 > 1e-3), links = split ? [] : connected();
|
||||
const cl = new Map(staves.map(s => [s, clusters(s.events)]));
|
||||
// Spans of the helix owned by visible stories are overlaid in the story colour.
|
||||
const owner = new Array(600).fill(undefined);
|
||||
staves.filter(s => emphasisOf(s) !== 'dim').forEach(s => cl.get(s).forEach(c => {
|
||||
const i0 = Math.round(uOf(c.t0) * 599), i1 = Math.round(uOf(c.t1) * 599);
|
||||
for (let i = i0 - 1; i <= i1 + 1; i++) if (i >= 0 && i < 600 && owner[i] === undefined) owner[i] = colorOf(s);
|
||||
}));
|
||||
for (let i = 0; i < 600;) {
|
||||
const c = owner[i]; let j = i; while (j + 1 < 600 && owner[j + 1] === c) j++;
|
||||
if (c) { const pts = []; const steps = Math.max(2, (j - i + 1) * 3); for (let k = 0; k <= steps; k++) pts.push(helixPoint(tAt((i + (j + 1 - i) * k / steps) / 600))); world.add(tube(pts, 2.2, solid(c), steps * 2)); }
|
||||
i = j + 1;
|
||||
}
|
||||
|
||||
// Staves: curved strips of the outer cylinder, from the story's first to last event in the window.
|
||||
staves.forEach(s => {
|
||||
const em = emphasisOf(s), color = colorOf(s), gap = Math.min(.014, (s.a1 - s.a0) * .08);
|
||||
const inside = s.events.filter(e => inWindow(e.t)), pad = (win.t1 - win.t0) * .006;
|
||||
const a0 = s.a0 + gap, a1 = Math.max(s.a0 + gap + 1e-3, s.a1 - gap);
|
||||
const t0 = Math.max(win.t0, inside[0].t - pad), t1 = Math.min(win.t1, inside.at(-1).t + pad);
|
||||
const segs = Math.max(2, Math.ceil((a1 - a0) / .05));
|
||||
const geo = new THREE.BufferGeometry(), pos = [], idx = [];
|
||||
for (let j = 0; j <= segs; j++) { const a = a0 + (a1 - a0) * j / segs; const lo = cylinder(t0, a), hi = cylinder(t1, a); pos.push(lo.x, lo.y, lo.z, hi.x, hi.y, hi.z); }
|
||||
for (let j = 0; j < segs; j++) { const k = j * 2; idx.push(k, k + 2, k + 1, k + 1, k + 2, k + 3); }
|
||||
geo.setAttribute('position', new THREE.Float32BufferAttribute(pos, 3)); geo.setIndex(idx);
|
||||
const opacity = em === 'focus' ? .3 : em === 'dim' ? .05 : .15;
|
||||
const mesh = new THREE.Mesh(geo, flat(color, opacity)); mesh.userData.node = s; mesh.renderOrder = 1;
|
||||
world.add(mesh); staveMeshes.push(mesh);
|
||||
if (em !== 'dim') [a0, a1].forEach(a => world.add(polyline([cylinder(t0, a), cylinder(t1, a)], lineMat(color, .7))));
|
||||
// One band per cluster, thicker for more events; quiet periods stay as gaps.
|
||||
cl.get(s).forEach(c => {
|
||||
const arc = []; for (let i = 0; i <= 10; i++) arc.push(cylinder(c.t, a0 + (a1 - a0) * i / 10, STAVE_R + .6));
|
||||
const active = c.events.includes(selectedEvent);
|
||||
const r = (Math.min(6, 1.3 + Math.log2(c.n) * 1.1) + (active ? 2 : 0)) * .45;
|
||||
world.add(tube(arc, r, solid(color, em === 'dim' ? .3 : .9), 20));
|
||||
});
|
||||
overlayItems.labels.push({ node: s, at: cylinder(t1, centerAngle(s), STAVE_R + 2), angle: centerAngle(s) });
|
||||
});
|
||||
|
||||
// Connectors: helix -> stave at the same height, one per cluster.
|
||||
links.filter(s => cl.has(s)).forEach(s => {
|
||||
const color = colorOf(s), a = centerAngle(s);
|
||||
cl.get(s).forEach(c => {
|
||||
const p = helixPoint(c.t), q = cylinder(c.t, a), active = c.events.includes(selectedEvent);
|
||||
world.add(tube([p, q], active ? .9 : .5, solid(color, active ? 1 : .6), 2));
|
||||
const r = c.n === 1 ? (active ? 5.5 : 3.6) : Math.min(10, 5.5 + Math.log2(c.n) * 1.3);
|
||||
const dot = new THREE.Mesh(new THREE.SphereGeometry(r * .6, 16, 12), solid(color)); dot.position.copy(p); world.add(dot);
|
||||
if (active) { const halo = new THREE.Mesh(new THREE.TorusGeometry(r * .6 + 2, .5, 6, 32), solid('#273b36')); halo.position.copy(p); halo.lookAt(camera.position); world.add(halo); }
|
||||
overlayItems.clusters.push({ cluster: c, node: s, at: p, r, clickable: focus.depth >= 2 || s === focus });
|
||||
});
|
||||
});
|
||||
|
||||
// Experience: a node on a ring outside the barrel. The forming stave reaches out to it, and an arrow
|
||||
// runs from it to the applying stave, so the arc never crosses the helix inside.
|
||||
if (showWisdom.checked && !split) visibleWisdom().forEach((w, i) => {
|
||||
const a = staveFor(w.formedIn[0]), b = staveFor(w.appliedIn.at(-1)); if (!a || !b) return;
|
||||
const sa = shown(a.node), sb = shown(b.node); if (!sa || !sb || sa === sb) return;
|
||||
if (!inWindow(a.event.t) || !inWindow(b.event.t)) return;
|
||||
const aa = centerAngle(sa), ab = centerAngle(sb), pa = cylinder(a.event.t, aa), pb = cylinder(b.event.t, ab);
|
||||
const mean = Math.atan2(Math.sin(aa) + Math.sin(ab), Math.cos(aa) + Math.cos(ab));
|
||||
const node = cylinder((a.event.t + b.event.t) / 2, mean, WISDOM_R), on = selectedWisdom === w;
|
||||
const leg = (from, to, k) => { const c = from.clone().add(to).multiplyScalar(.5); const out = V(c.x, 0, c.z); if (out.length() > 1) c.add(out.normalize().multiplyScalar(k)); return new THREE.QuadraticBezierCurve3(from, c, to); };
|
||||
const formed = leg(pa, node, 34), applied = leg(node, pb, 34);
|
||||
[formed, applied].forEach(curve => {
|
||||
const dashed = new THREE.Line(new THREE.BufferGeometry().setFromPoints(curve.getPoints(40)), mat('wd', () => new THREE.LineDashedMaterial({ color: 0xa47837, dashSize: 7, gapSize: 5 })));
|
||||
dashed.computeLineDistances(); world.add(dashed);
|
||||
if (on) world.add(tube(curve.getPoints(40), .8, solid('#a47837', .5), 40));
|
||||
});
|
||||
const tip = applied.getPoint(1), dir = tip.clone().sub(applied.getPoint(.94)).normalize();
|
||||
const cone = new THREE.Mesh(new THREE.ConeGeometry(3.5, 10, 12), solid('#a47837')); cone.position.copy(tip).sub(dir.clone().multiplyScalar(5));
|
||||
cone.quaternion.setFromUnitVectors(V(0, 1, 0), dir); world.add(cone);
|
||||
const gem = new THREE.Mesh(new THREE.OctahedronGeometry(on ? 9 : 7), solid(on ? '#c08a3e' : '#d2a35e')); gem.position.copy(node); world.add(gem);
|
||||
overlayItems.wisdom.push({ w, i, at: node });
|
||||
});
|
||||
built = signature();
|
||||
}
|
||||
function staveFor(eventId) { const hit = eventIndex.get(eventId); if (!hit) return; const sub = hit.feature.children.find(s => s.events.some(e => e.id === eventId)); return { event: hit.event, node: sub ?? hit.feature }; }
|
||||
|
||||
// ---- frame -----------------------------------------------------------------------
|
||||
let hits = [];
|
||||
function draw() {
|
||||
if (!width) return;
|
||||
placeCamera();
|
||||
if (signature() !== built) rebuild();
|
||||
renderer.render(scene, camera);
|
||||
ctx.clearRect(0, 0, width, height); hits = [];
|
||||
const v = viewDir();
|
||||
ctx.font = '10px system-ui'; ctx.fillStyle = '#3f514a';
|
||||
baseTicks.forEach(t => { const p = project(t.at); if (!p.behind) ctx.fillText(t.text, p.x + 6, p.y + 3); });
|
||||
overlayItems.clusters.forEach(c => {
|
||||
const p = project(c.at); if (p.behind) return;
|
||||
if (c.cluster.n > 1) { ctx.fillStyle = '#fcfcf8'; ctx.font = '600 9px system-ui'; const label = String(c.cluster.n); ctx.fillText(label, p.x - ctx.measureText(label).width / 2, p.y + 3); }
|
||||
if (c.clickable) hits.push({ kind: 'cluster', cluster: c.cluster, node: c.node, x: p.x, y: p.y, r: Math.max(9, c.r + 2), z: p.z });
|
||||
});
|
||||
drawLabels(v);
|
||||
overlayItems.wisdom.forEach(w => drawWisdomCard(w));
|
||||
}
|
||||
// Labels: the focused or hovered stave always; otherwise only staves facing the viewer, without overlaps.
|
||||
function drawLabels(v) {
|
||||
const placed = [];
|
||||
overlayItems.labels.map(l => ({ ...l, facing: Math.cos(l.angle) * v.x + Math.sin(l.angle) * v.z, em: emphasisOf(l.node) }))
|
||||
.sort((a, b) => (b.em === 'focus') - (a.em === 'focus') || b.facing - a.facing)
|
||||
.forEach(({ node: s, at, facing, em }) => {
|
||||
if (em === 'dim' || (em !== 'focus' && facing < -.05)) return;
|
||||
const p = project(at); if (p.behind) return;
|
||||
ctx.font = (em === 'focus' ? '600 ' : '') + '11px system-ui';
|
||||
const w = ctx.measureText(s.name).width, x = p.x - w / 2, y = p.y - 8, box = [x - 4, y - 12, w + 8, 16];
|
||||
if (placed.some(b => box[0] < b[0] + b[2] && b[0] < box[0] + box[2] && box[1] < b[1] + b[3] && b[1] < box[1] + box[3])) return;
|
||||
placed.push(box);
|
||||
ctx.fillStyle = 'rgba(252,252,248,.85)'; ctx.fillRect(...box);
|
||||
ctx.fillStyle = colorOf(s); ctx.globalAlpha = em === 'focus' ? 1 : .55 + .45 * Math.max(0, facing); ctx.fillText(s.name, x, y); ctx.globalAlpha = 1;
|
||||
hits.push({ kind: 'label', node: s, rect: box });
|
||||
});
|
||||
}
|
||||
function drawWisdomCard({ w, i, at }) {
|
||||
const anchor = project(at), on = selectedWisdom === w, cw = Math.min(176, width - 24), ch = 44;
|
||||
const x = Math.max(12, Math.min(width - cw - 12, anchor.x - cw / 2)), y = Math.max(12, Math.min(height - ch - 30, anchor.y - ch / 2 + i * 50));
|
||||
ctx.fillStyle = on ? '#e9d7b6' : '#f8efdfe8'; ctx.strokeStyle = '#ad854b'; ctx.lineWidth = on ? 2 : 1;
|
||||
ctx.beginPath(); ctx.roundRect(x, y, cw, ch, 8); ctx.fill(); ctx.stroke();
|
||||
ctx.fillStyle = '#936825'; ctx.font = '10px system-ui'; ctx.fillText(`W${i + 1} · 跨故事经验 · 待确认`, x + 10, y + 16);
|
||||
ctx.fillStyle = '#574522'; ctx.font = '12px system-ui'; ctx.fillText(w.lesson, x + 10, y + 33);
|
||||
hits.push({ kind: 'wisdom', w, rect: [x, y, cw, ch] });
|
||||
}
|
||||
|
||||
// ---- side panel ------------------------------------------------------------
|
||||
const dateOf = t => new Date(range[0] + t * (range[1] - range[0]));
|
||||
const pad2 = n => String(n).padStart(2, '0');
|
||||
const fmt = t => { const d = dateOf(t); return `${d.getMonth() + 1}.${pad2(d.getDate())}`; };
|
||||
const fmtTime = t => { const d = dateOf(t); return `${fmt(t)} ${pad2(d.getHours())}:${pad2(d.getMinutes())}`; };
|
||||
const fmtTick = t => win.turnHours < 24 ? fmtTime(t) : win.turnHours >= 720 ? `${dateOf(t).getFullYear()}.${dateOf(t).getMonth() + 1}` : fmt(t);
|
||||
const turnName = h => ({ 3: '3 小时', 6: '6 小时', 12: '12 小时', 24: '1 天', 168: '1 周', 720: '1 个月', 2160: '1 个季度', 8760: '1 年' })[h] ?? `${h} 小时`;
|
||||
const typeName = { decision: '决定', change: '变更', milestone: '里程碑', discussion: '讨论' };
|
||||
function el(tag, props = {}, ...kids) { const n = document.createElement(tag); Object.assign(n, props); n.append(...kids); return n; }
|
||||
// Staves split in place when opening a level and merge back when returning, instead of jumping to a new layout.
|
||||
function planSplit(oldLevel, newLevel) {
|
||||
const kids = newLevel.children ?? [], to = new Map(kids.map(k => [k, [k.a0, k.a1]]));
|
||||
let from;
|
||||
if (newLevel.parent === oldLevel) { // opening: children start inside the opened stave's old sector
|
||||
const [c0, c1] = oldAngles.get(newLevel) ?? [0, tau], f = (c1 - c0) / tau;
|
||||
from = new Map(kids.map(k => [k, [c0 + k.a0 * f, c0 + k.a1 * f]]));
|
||||
} else if (oldLevel?.parent === newLevel) { // returning: the stave we came from starts as the whole circle
|
||||
const [x0, x1] = to.get(oldLevel);
|
||||
from = new Map(kids.map(k => [k, k === oldLevel ? [0, tau] : k.a0 < x0 ? [0, 0] : [tau, tau]]));
|
||||
}
|
||||
return from && { from, to };
|
||||
}
|
||||
let oldAngles = new Map();
|
||||
function runSplit(plan) {
|
||||
if (!plan || reducedMotion) return;
|
||||
const started = performance.now(), apply = k => plan.to.forEach(([b0, b1], node) => { const [a0, a1] = plan.from.get(node); node.a0 = a0 + (b0 - a0) * k; node.a1 = a1 + (b1 - a1) * k; });
|
||||
split = { k: 0, started }; apply(0);
|
||||
const step = now => {
|
||||
if (split?.started !== started) return;
|
||||
const t = Math.min(1, (now - started) / 520), k = t < .5 ? 4 * t ** 3 : 1 - (-2 * t + 2) ** 3 / 2;
|
||||
split.k = k; apply(k);
|
||||
if (t < 1) { draw(); requestAnimationFrame(step); } else { split = undefined; draw(); }
|
||||
};
|
||||
requestAnimationFrame(step);
|
||||
}
|
||||
function setFocus(node) {
|
||||
const oldLevel = level();
|
||||
oldAngles = new Map((oldLevel.children ?? []).map(k => [k, [k.a0, k.a1]]));
|
||||
focus = node; hovered = undefined; layout(level(), 0, tau); selectedEvent = undefined; selectedWisdom = undefined; selectedCluster = undefined;
|
||||
const plan = level() !== oldLevel ? planSplit(oldLevel, level()) : undefined;
|
||||
runSplit(plan);
|
||||
render();
|
||||
if (focus !== level()) face(focus); else if (mode === 'free') draw(); else animateTo({ ...VIEWS[mode], pan: 0 });
|
||||
}
|
||||
function render() {
|
||||
const crumbs = document.querySelector('#crumbs'); crumbs.replaceChildren();
|
||||
const trail = []; for (let n = focus; n; n = n.parent) trail.unshift(n);
|
||||
trail.forEach((n, i) => { if (i) crumbs.append(' › '); crumbs.append(i === trail.length - 1 ? el('strong', { textContent: n.name }) : el('button', { textContent: n.name, onclick: () => setFocus(n), className: 'crumb' })); });
|
||||
const levelName = focus.cross ? '跨项目故事' : focus.ref ? '关联的项目功能' : ['全部项目', '项目木片', '功能木片', '子线索'][focus.depth];
|
||||
document.querySelector('#period').textContent = selectedWisdom ? '跨故事经验 · 模型归纳 · 待确认' : `${levelName} · ${fmt(focus.start)} – ${fmt(focus.end)}`;
|
||||
document.querySelector('#story-title').textContent = selectedWisdom ? selectedWisdom.lesson : focus.name;
|
||||
const desc = selectedWisdom ? '由真实图谱事件归纳,形成与应用都对应具体事件;需要用户确认后才作为经验使用。'
|
||||
: focus === root ? `${projects.length} 个项目,${root.events.length} 个事件${crossToggle.checked ? `,${crossStories.length} 条跨项目故事` : ''}。木片越宽,事件越多;选择项目后拆分为功能。`
|
||||
: focus.cross ? `关联 ${focus.children.length} 个项目功能、${focus.events.length} 个事件,跨越 ${new Set(focus.children.map(c => c.ref.parent.name)).size} 个项目。事件仍归属原项目,这里只做关联。`
|
||||
: `${focus.events.length} 个事件 · ${focus.entities ?? focus.children.reduce((n, c) => n + (c.entities ?? 0), 0)} 个知识实体${focus.scattered ? ` · ${focus.scattered} 个零散实体未归入功能` : ''}${focus.unthreaded ? ` · ${focus.unthreaded} 个实体留在功能层` : ''}`;
|
||||
document.querySelector('#story-description').textContent = desc;
|
||||
const list = document.querySelector('#stories'); list.replaceChildren();
|
||||
if (!selectedWisdom) (focus.children ?? []).forEach(c => {
|
||||
const b = el('button', { onclick: () => setFocus(c), onmouseenter: () => { hovered = c; draw(); }, onmouseleave: () => { hovered = undefined; draw(); } },
|
||||
el('i'), el('span', { textContent: c.name }), el('small', { textContent: `${c.events.length} 事件 · ${fmt(c.start)}–${fmt(c.end)}${c.children?.length ? ` · ${c.children.length} 片` : ''}` }));
|
||||
b.style.setProperty('--story-color', colorOf(c)); list.append(b);
|
||||
});
|
||||
const events = document.querySelector('#events'); events.replaceChildren();
|
||||
const detail = document.querySelector('#detail'); detail.replaceChildren();
|
||||
if (selectedWisdom) {
|
||||
[['适用条件', selectedWisdom.conditions], ['边界', selectedWisdom.boundary], ['形成与应用依据', selectedWisdom.why]].forEach(([h, p]) => detail.append(el('section', {}, el('strong', { textContent: h }), el('p', { textContent: p }))));
|
||||
[['形成', selectedWisdom.formedIn], ['应用', selectedWisdom.appliedIn]].forEach(([label, ids]) => ids.forEach(id => {
|
||||
const s = staveFor(id); if (!s) return;
|
||||
detail.append(el('button', { className: 'jump', textContent: `${label} · ${fmt(s.event.t)} · ${s.node.name}`, onclick: () => { setFocus(s.node); selectEvent(s.event); } }));
|
||||
}));
|
||||
} else if (focus.depth >= 2) {
|
||||
const detailed = (focus.ref?.parent ?? projectOf(focus)).detailed;
|
||||
const listed = (selectedCluster ? selectedCluster.events : focus.events.filter(e => inWindow(e.t))).slice().reverse();
|
||||
if (selectedCluster) events.append(el('div', { className: 'cluster-head' }, el('span', { textContent: `${fmtSpan(selectedCluster)} · ${selectedCluster.n} 个事件` }), el('button', { className: 'crumb', textContent: '显示全部', onclick: () => { selectedCluster = undefined; render(); } })));
|
||||
listed.slice(0, 60).forEach(e => events.append(el('button', { 'aria-pressed': String(e === selectedEvent), onclick: () => selectEvent(e) },
|
||||
el('span', { textContent: detailed ? e.title : `${typeName[e.type]}事件` }), el('small', { textContent: `${fmtTime(e.t)} · ${typeName[e.type]}` }))));
|
||||
if (listed.length > 60) events.append(el('small', { textContent: `另有 ${listed.length - 60} 个更早的事件` }));
|
||||
if (!listed.length) events.append(el('small', { textContent: '这段时间没有该故事的事件' }));
|
||||
if (selectedEvent) detail.append(el('strong', { textContent: detailed ? selectedEvent.title : '该项目只显示时间与类型' }), el('p', { textContent: detailed ? selectedEvent.text : '' }), el('small', { textContent: new Date(selectedEvent.at).toLocaleString('zh-CN') }));
|
||||
}
|
||||
document.querySelector('#wisdom-list').replaceChildren(...visibleWisdom().map((w, i) => el('button', { className: 'wisdom-card', 'aria-pressed': String(selectedWisdom === w), onclick: () => { selectedWisdom = w; showWisdom.checked = true; render(); } },
|
||||
el('span', { textContent: `W${i + 1} · 跨故事经验 · 待确认` }), el('strong', { textContent: w.lesson }))));
|
||||
draw();
|
||||
}
|
||||
function selectEvent(e) {
|
||||
if (!inWindow(e.t)) { selectedCluster = undefined; applyRange('all'); }
|
||||
selectedEvent = e; render(); if (mode !== 'free') animateTo({ pan: yOf(e.t) * .5 });
|
||||
}
|
||||
function openCluster(c) {
|
||||
if (c.n === 1) { selectedCluster = undefined; selectEvent(c.events[0]); return; }
|
||||
const span = c.t1 - c.t0, minSpan = 6 / totalHours;
|
||||
if (win.t1 - win.t0 > minSpan * 1.01 && span > 0) {
|
||||
const pad = Math.max(span * .6, minSpan / 2), mid = (c.t0 + c.t1) / 2, half = Math.max(span / 2 + pad, minSpan / 2);
|
||||
selectedCluster = undefined; zoomWindow(mid - half, mid + half);
|
||||
} else { selectedCluster = c; selectedEvent = undefined; render(); }
|
||||
}
|
||||
|
||||
// ---- time window and grain ---------------------------------------------------------
|
||||
const rangeSelect = document.querySelector('#range'), turnSelect = document.querySelector('#turn'), rangeReset = document.querySelector('#range-reset');
|
||||
function applyWindow(t0, t1) {
|
||||
const fixed = turnSelect.value === 'auto' ? undefined : Number(turnSelect.value);
|
||||
setWindow(t0, t1, fixed);
|
||||
if (fixed && win.turnHours !== fixed) turnSelect.value = 'auto';
|
||||
document.querySelector('#scale-note').textContent = `高度为时间,一圈 ${turnName(win.turnHours)}(共 ${Math.round(turns * 10) / 10} 圈)· 半径为当时的对话密度`;
|
||||
document.querySelector('#axis-start').textContent = win.turnHours < 24 ? fmtTime(win.t0) : fmt(win.t0);
|
||||
document.querySelector('#axis-end').textContent = win.turnHours < 24 ? fmtTime(win.t1) : fmt(win.t1);
|
||||
rangeReset.hidden = win.t0 <= 0 && win.t1 >= 1;
|
||||
}
|
||||
function zoomWindow(t0, t1) {
|
||||
const len = t1 - t0;
|
||||
if (t0 < 0) { t0 = 0; t1 = Math.min(1, len); } if (t1 > 1) { t1 = 1; t0 = Math.max(0, 1 - len); }
|
||||
rangeSelect.value = 'custom';
|
||||
applyWindow(t0, t1); render(); if (mode !== 'free') animateTo({ pan: 0 });
|
||||
}
|
||||
function applyRange(value) {
|
||||
rangeSelect.value = value;
|
||||
if (value === 'all') applyWindow(0, 1); else if (value !== 'custom') { const h = Number(value); applyWindow(Math.max(0, 1 - h / totalHours), 1); }
|
||||
}
|
||||
rangeSelect.addEventListener('change', () => { selectedCluster = undefined; applyRange(rangeSelect.value); render(); });
|
||||
turnSelect.addEventListener('change', () => { applyWindow(win.t0, win.t1); render(); });
|
||||
rangeReset.addEventListener('click', () => { selectedCluster = undefined; applyRange('all'); render(); });
|
||||
applyRange('all');
|
||||
|
||||
// ---- input -----------------------------------------------------------------
|
||||
const viewButtons = [...document.querySelectorAll('[data-view]')];
|
||||
function syncViewButtons() { viewButtons.forEach(b => b.setAttribute('aria-pressed', String(b.dataset.view === mode))); }
|
||||
function syncZoom() { zoom.value = String(Math.round(cam.zoom * 100)); }
|
||||
viewButtons.forEach(b => b.addEventListener('click', () => {
|
||||
if (b.dataset.view === 'free') { freeView(); return; }
|
||||
animateTo({ ...VIEWS[b.dataset.view], zoom: 1, pan: 0 }, b.dataset.view);
|
||||
}));
|
||||
zoom.addEventListener('input', () => { tween = undefined; cam.zoom = Number(zoom.value) / 100; draw(); });
|
||||
showWisdom.addEventListener('input', draw);
|
||||
crossToggle.addEventListener('change', () => { setCrossProject(crossToggle.checked); focus = root; hovered = undefined; layout(root, 0, tau); selectedEvent = selectedWisdom = selectedCluster = undefined; render(); });
|
||||
document.querySelector('#reset').onclick = () => animateTo({ ...VIEWS.oblique, zoom: 1, pan: 0 }, 'oblique');
|
||||
const clampTilt = t => Math.max(-Math.PI / 2 + 1e-3, Math.min(Math.PI / 2 - 1e-3, t));
|
||||
const clampPan = p => Math.max(-HEIGHT / 2, Math.min(HEIGHT / 2, p));
|
||||
const clampZoom = z => Math.max(.5, Math.min(2.5, z));
|
||||
const inRect = (x, y, r) => x >= r[0] && x <= r[0] + r[2] && y >= r[1] && y <= r[1] + r[3];
|
||||
function staveAt(x, y) {
|
||||
raycaster.setFromCamera(new THREE.Vector2(x / width * 2 - 1, 1 - y / height * 2), camera);
|
||||
const hit = raycaster.intersectObjects(staveMeshes, false)[0];
|
||||
return hit && { kind: 'stave', node: hit.object.userData.node };
|
||||
}
|
||||
function hitAt(x, y) {
|
||||
return hits.find(h => h.kind === 'wisdom' && inRect(x, y, h.rect))
|
||||
?? hits.filter(h => h.kind === 'cluster' && Math.hypot(h.x - x, h.y - y) < h.r).sort((a, b) => b.z - a.z)[0]
|
||||
?? hits.find(h => h.kind === 'label' && inRect(x, y, h.rect))
|
||||
?? staveAt(x, y);
|
||||
}
|
||||
let drag;
|
||||
canvas.addEventListener('contextmenu', e => e.preventDefault());
|
||||
canvas.addEventListener('pointerdown', e => {
|
||||
drag = { id: e.pointerId, x: e.clientX, y: e.clientY, sx: e.clientX, sy: e.clientY, pan: e.button === 2 || e.shiftKey };
|
||||
canvas.setPointerCapture(e.pointerId);
|
||||
});
|
||||
canvas.addEventListener('pointermove', e => {
|
||||
const r = canvas.getBoundingClientRect();
|
||||
if (!drag) {
|
||||
const h = hitAt(e.clientX - r.left, e.clientY - r.top), node = h && (h.kind === 'stave' || h.kind === 'label') ? h.node : undefined;
|
||||
if (node !== hovered) { hovered = node; draw(); }
|
||||
canvas.style.cursor = h ? 'pointer' : '';
|
||||
return;
|
||||
}
|
||||
if (drag.id !== e.pointerId) return;
|
||||
const dx = e.clientX - drag.x, dy = e.clientY - drag.y;
|
||||
if (Math.hypot(e.clientX - drag.sx, e.clientY - drag.sy) >= 5) freeView();
|
||||
if (mode === 'free') {
|
||||
if (drag.pan) cam.pan = clampPan(cam.pan + dy / scaleNow());
|
||||
else { cam.yaw += dx * .008; cam.tilt = clampTilt(cam.tilt + dy * .006); }
|
||||
draw();
|
||||
}
|
||||
drag.x = e.clientX; drag.y = e.clientY;
|
||||
});
|
||||
canvas.addEventListener('pointerup', e => {
|
||||
if (drag && Math.hypot(e.clientX - drag.sx, e.clientY - drag.sy) < 5) {
|
||||
const r = canvas.getBoundingClientRect(), h = hitAt(e.clientX - r.left, e.clientY - r.top);
|
||||
if (h?.kind === 'wisdom') { selectedWisdom = h.w; render(); }
|
||||
else if (h?.kind === 'cluster') openCluster(h.cluster);
|
||||
else if (h) setFocus(h.node);
|
||||
}
|
||||
drag = undefined;
|
||||
});
|
||||
canvas.addEventListener('pointercancel', () => { drag = undefined; });
|
||||
canvas.addEventListener('pointerleave', () => { if (!drag && hovered) { hovered = undefined; draw(); } });
|
||||
canvas.addEventListener('wheel', e => {
|
||||
e.preventDefault(); freeView();
|
||||
if (e.shiftKey) cam.pan = clampPan(cam.pan - e.deltaY * .3);
|
||||
else cam.zoom = clampZoom(cam.zoom * Math.exp(-e.deltaY * .0012));
|
||||
syncZoom(); draw();
|
||||
}, { passive: false });
|
||||
canvas.addEventListener('keydown', e => {
|
||||
if (e.key === 'Escape' && focus.parent) { setFocus(focus.parent); return; }
|
||||
const preset = { 1: 'oblique', 2: 'side', 3: 'top' }[e.key];
|
||||
if (preset) { animateTo({ ...VIEWS[preset], zoom: 1, pan: 0 }, preset); return; }
|
||||
const moves = { ArrowLeft: () => { cam.yaw -= .12; }, ArrowRight: () => { cam.yaw += .12; }, ArrowUp: () => { cam.tilt = clampTilt(cam.tilt + .06); }, ArrowDown: () => { cam.tilt = clampTilt(cam.tilt - .06); },
|
||||
PageUp: () => { cam.pan = clampPan(cam.pan + 20); }, PageDown: () => { cam.pan = clampPan(cam.pan - 20); }, '+': () => { cam.zoom = clampZoom(cam.zoom * 1.1); }, '=': () => { cam.zoom = clampZoom(cam.zoom * 1.1); }, '-': () => { cam.zoom = clampZoom(cam.zoom / 1.1); } };
|
||||
if (!moves[e.key]) return;
|
||||
e.preventDefault(); freeView(); moves[e.key](); syncZoom(); draw();
|
||||
});
|
||||
new ResizeObserver(() => {
|
||||
const r = canvas.getBoundingClientRect(); width = r.width; height = r.height;
|
||||
if (!width || !height) return;
|
||||
renderer.setSize(width, height, false);
|
||||
const dpr = Math.min(devicePixelRatio || 1, 2); overlay.width = Math.round(width * dpr); overlay.height = Math.round(height * dpr); ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
draw();
|
||||
}).observe(canvas);
|
||||
// Read-only hook for layout checks.
|
||||
window.storyDemo = { renderer: 'webgl', cam, win, get turns() { return turns; }, get mode() { return mode; }, get focus() { return focus.name; },
|
||||
get splitting() { return !!split; },
|
||||
// Worst ratio of largest to smallest radius within any single turn: 1 means perfectly round.
|
||||
roundness: () => { let worst = 1; for (let d = 0; d < Math.floor(turns); d++) { const r = Array.from({ length: 64 }, (_, i) => radius(tAt((d + i / 64) / turns))); worst = Math.max(worst, Math.max(...r) / Math.min(...r)); } return +worst.toFixed(3); },
|
||||
maxStep: () => { let m = 0, prev = radius(tAt(0)); for (let i = 1; i <= 4000; i++) { const r = radius(tAt(i / 4000)); m = Math.max(m, Math.abs(r - prev)); prev = r; } return +m.toFixed(3); },
|
||||
wisdomNodes: () => overlayItems.wisdom.map(w => +Math.hypot(w.at.x, w.at.z).toFixed(1)),
|
||||
angles: () => visibleStaves().map(s => [+s.a0.toFixed(3), +s.a1.toFixed(3)]),
|
||||
get meshes() { let n = 0; world.traverse(o => { if (o.isMesh || o.isLine) n++; }); return n; },
|
||||
clustersOf: name => { const find = n => n.name === name ? n : (n.children ?? []).map(find).find(Boolean); const n = find(root); return n ? clusters(n.events).map(c => c.n) : []; },
|
||||
clusterHits: () => hits.filter(h => h.kind === 'cluster').map(h => ({ n: h.cluster.n, x: Math.round(h.x), y: Math.round(h.y) })),
|
||||
staveAt: (x, y) => staveAt(x, y)?.node.name, labels: () => hits.filter(h => h.kind === 'label').map(h => h.node.name),
|
||||
setWindow: (t0, t1) => zoomWindow(t0, t1), setFocusByName: name => { const find = n => n.name === name ? n : (n.children ?? []).map(find).find(Boolean); const n = find(root); if (n) setFocus(n); return !!n; } };
|
||||
syncViewButtons(); syncZoom();
|
||||
render();
|
||||
})();
|
||||
@@ -0,0 +1,8 @@
|
||||
:root{color-scheme:light;--paper:#f5f4ef;--ink:#273b36;--muted:#748078;--border:#dde1d7;--green:#357e6d}
|
||||
*{box-sizing:border-box}body{margin:0;background:var(--paper);color:var(--ink);font:14px/1.6 system-ui,-apple-system,"Segoe UI",sans-serif}button,input{font:inherit}button{cursor:pointer}button:focus-visible,input:focus-visible,canvas:focus-visible,a:focus-visible{outline:3px solid #ac7638;outline-offset:3px}.page-header{max-width:1540px;margin:auto;padding:36px 38px 25px;display:flex;align-items:flex-start;justify-content:space-between;gap:20px}.eyebrow{color:var(--green);font-size:11px;letter-spacing:2px;text-decoration:none}h1{font-size:clamp(23px,2.6vw,35px);letter-spacing:-1px;font-weight:550;margin:9px 0}p{color:var(--muted);margin:0}.badge{white-space:nowrap;border:1px solid var(--border);border-radius:30px;padding:7px 12px;font-size:11px;color:var(--muted);margin-top:8px}main{max-width:1540px;margin:auto;padding:0 38px;display:grid;grid-template-columns:minmax(0,1fr) 306px;gap:22px}.stage{min-width:0;background:#fcfcf8;border:1px solid var(--border);border-radius:15px;overflow:hidden}.stage-header{display:flex;justify-content:space-between;align-items:center;padding:15px 22px;border-bottom:1px solid #e9ebe3;font-size:12px}.stage-header i{display:inline-block;background:var(--green);width:6px;height:6px;border-radius:50%;margin-right:8px}button{border:1px solid var(--border);background:transparent;color:inherit;border-radius:7px;padding:7px 12px}.viewport{position:relative;height:560px;background:radial-gradient(ellipse at 50% 50%,#eef1e8 0,transparent 70%)}canvas{display:block;width:100%;height:100%;touch-action:none;cursor:grab}canvas:active{cursor:grabbing}.axis-note{position:absolute;left:19px;top:35%;text-align:center;font-size:11px;pointer-events:none;color:#879188}.axis-note span{font-size:10px;line-height:2.5}.canvas-hint{position:absolute;bottom:13px;left:0;right:0;text-align:center;font-size:11px;color:#8a948a;pointer-events:none}.controls{border-top:1px solid #e9ebe3;display:flex;align-items:center;gap:18px;flex-wrap:wrap;padding:15px 22px;font-size:12px}.controls label{display:flex;align-items:center;gap:6px}.controls input{accent-color:var(--green)}.range input{width:84px}.legend{display:flex;flex-wrap:wrap;gap:15px;padding:0 22px 17px;font-size:10px;color:var(--muted)}.legend span{display:flex;align-items:center;gap:5px}.legend b{display:inline-block;width:18px;height:0;border-top:2px solid #687b71}.legend .surface{height:9px;background:#65a08a44;border:1px solid #65a08a66}.legend .dashed{border-top:2px dashed #ad7939}aside{padding-top:6px;min-width:0}.section-label{font-size:11px;letter-spacing:1px;color:var(--muted)}.stories{display:grid;gap:8px;margin:13px 0 24px}.stories button{display:flex;text-align:left;align-items:center;gap:12px;padding:13px;background:#fafaf6}.stories button[aria-pressed=true]{background:white;border-color:var(--story-color);box-shadow:0 3px 15px #30433208}.stories i{width:5px;height:29px;border-radius:3px;background:var(--story-color)}.stories span{flex:1}.stories small{color:var(--muted);font-size:10px}.inspector h2{font-size:22px;font-weight:550;margin:6px 0 9px}.inspector p{font-size:12px}.events{display:grid;margin-top:18px;gap:6px}.events button{text-align:left;border-color:transparent;border-left:2px solid #d9e0d5;border-radius:0;padding:8px 12px;font-size:12px}.events button[aria-pressed=true]{border-left-color:var(--green);background:#e7ede3}.events small{color:var(--muted);display:block;font-size:10px}.detail{font-size:12px;padding:14px 0;color:#53665b;min-height:80px}.detail strong{display:block;color:var(--ink);margin-bottom:5px}.wisdom-card{display:grid;gap:7px;text-align:left;width:100%;padding:17px;background:#f0e9db;border-color:#e0d3bc;margin-top:12px}.wisdom-card span{font-size:10px;letter-spacing:1px;color:#996b2c}.wisdom-card strong{font-size:13px;font-weight:550}.wisdom-card small{font-size:11px;color:#817259}footer{max-width:1540px;padding:24px 38px 30px;margin:auto;font-size:11px;color:var(--muted);line-height:1.9}footer strong{font-weight:500;color:#4a6051}
|
||||
@media(min-width:1500px){.viewport{height:640px}}@media(max-width:900px){main{grid-template-columns:minmax(0,1fr) 250px;gap:15px;padding:0 20px}.page-header{padding:25px 20px}.badge{display:none}.viewport{height:520px}.controls{gap:10px;padding:14px}.legend{padding:0 14px 15px}.axis-note{left:8px}}@media(max-width:680px){main{grid-template-columns:1fr}.viewport{height:460px}.page-header h1{font-size:24px}.page-header p{font-size:12px}aside{padding:14px 0}.stories{grid-template-columns:repeat(3,minmax(0,1fr));gap:5px}.stories button{padding:9px 7px;gap:5px;font-size:11px}.stories small{display:none}.controls{font-size:11px}.range input{width:70px}footer{padding:15px 20px}.axis-note{font-size:10px}.canvas-hint{font-size:10px}}
|
||||
.crumbs{font-size:12px;color:var(--muted);margin-bottom:14px;line-height:2}.crumb{border:0;padding:0 2px;color:var(--green);text-decoration:underline;text-underline-offset:3px}.crumbs strong{color:var(--ink);font-weight:550}.stories{margin:12px 0 10px;max-height:340px;overflow:auto}.stories button{padding:10px 12px}.stories small{white-space:nowrap}.events{max-height:300px;overflow:auto;margin-top:6px}.detail section{margin-bottom:12px}.detail p{margin:0}.detail .jump{display:block;width:100%;text-align:left;margin-top:6px;font-size:12px}.wisdom-list{display:grid;gap:8px}.wisdom-card{margin-top:0;padding:12px 14px}.wisdom-card[aria-pressed=true]{border-color:#ad854b;background:#e9d7b6}
|
||||
.views{display:flex;gap:4px;flex-wrap:wrap;justify-content:flex-end}.views button{padding:5px 10px;font-size:12px}.views button[aria-pressed=true]{background:var(--ink);border-color:var(--ink);color:#fcfcf8}.views #reset{margin-left:6px}.legend .link{width:12px;border-top:1px solid #687b71}@media(max-width:680px){.stage-header{flex-direction:column;align-items:flex-start;gap:8px}.views{justify-content:flex-start}}
|
||||
.controls select{font:inherit;font-size:12px;border:1px solid var(--border);border-radius:6px;background:transparent;color:inherit;padding:3px 6px}#range-reset{padding:4px 10px;font-size:12px}.cluster-head{display:flex;justify-content:space-between;align-items:center;gap:8px;font-size:12px;color:var(--muted);margin-bottom:6px}
|
||||
|
||||
.overlay{position:absolute;inset:0;width:100%;height:100%;pointer-events:none}
|
||||
@@ -0,0 +1,52 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>时间与故事 · 3D Story Graph</title>
|
||||
<link rel="stylesheet" href="3d-demo.css">
|
||||
</head>
|
||||
<body>
|
||||
<header class="page-header">
|
||||
<div><a href="README.md" class="eyebrow">GOODBUDDY / STORY GRAPH LAB</a><h1>时间只有一条,故事交错生长。</h1><p>沿螺旋看一周的经历;木片可以层层拆开:项目 › 功能 › 子线索;跨木片的经验来自真实事件。</p></div>
|
||||
<span class="badge">真实图谱 · 09.19 – 09.26 · 归组由模型提出,待确认</span>
|
||||
</header>
|
||||
<main>
|
||||
<section class="stage" aria-label="三维故事图谱">
|
||||
<div class="stage-header"><span><i></i> <span id="scale-note">高度为时间,一圈一天 · 半径为当时的对话密度</span> · <span id="renderer-note">WebGL</span></span>
|
||||
<div class="views" role="group" aria-label="视角">
|
||||
<button data-view="oblique" title="看整体结构(1)">斜俯视</button><button data-view="side" title="看起止与间隔(2)">侧视</button><button data-view="top" title="看注意力密度(3)">俯视</button><button data-view="free" title="拖动、滚轮自由查看">自由</button>
|
||||
<button id="reset">重置</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="viewport">
|
||||
<canvas id="scene" tabindex="0" aria-label="三维故事图谱。数字键 1、2、3 切换视角,方向键旋转,PageUp/PageDown 沿时间移动,加减号缩放,Esc 返回上一层。木片也可通过右侧列表选择。"></canvas>
|
||||
<div class="axis-note"><span id="axis-end">09.26</span><br><span>↑<br>时间等速推进<br>↑</span><br><span id="axis-start">09.19</span></div>
|
||||
<canvas id="overlay" class="overlay" aria-hidden="true"></canvas>
|
||||
<div class="canvas-hint">拖动旋转 · 右键或 Shift 拖动沿时间移动 · 滚轮缩放 · 点击木片拆开 · Esc 返回</div>
|
||||
</div>
|
||||
<div class="controls">
|
||||
<label><input id="wisdom" type="checkbox" checked> 跨故事经验</label>
|
||||
<label title="把多个项目里属于同一件事的功能串成一片全局木片;事件仍归属原项目"><input id="cross" type="checkbox"> 跨项目故事</label>
|
||||
<label>范围 <select id="range"><option value="all">全部</option><option value="72">最近 3 天</option><option value="24">最近 1 天</option><option value="custom" hidden>所选时段</option></select></label>
|
||||
<label>一圈 <select id="turn"><option value="auto">自动</option><option value="3">3 小时</option><option value="6">6 小时</option><option value="12">12 小时</option><option value="24">1 天</option><option value="168">1 周</option><option value="720">1 个月</option></select></label>
|
||||
<button id="range-reset" hidden>返回全部时间</button>
|
||||
<label class="range">缩放 <input id="zoom" type="range" min="50" max="250" value="100"></label>
|
||||
</div>
|
||||
<div class="legend"><span><b class="line"></b>时间:唯一的连续螺旋</span><span><b class="surface"></b>故事:木片,上下跨度为起止时间,短横为同一时段的事件,越粗越多</span><span><b class="link"></b>事件:同一高度连到所属木片,数字为聚合数量,点击放大拆开</span><span><b class="dashed"></b>经验:从形成到应用</span></div>
|
||||
</section>
|
||||
<aside>
|
||||
<nav id="crumbs" class="crumbs" aria-label="木片层级"></nav>
|
||||
<section class="inspector" aria-live="polite"><span id="period" class="section-label"></span><h2 id="story-title"></h2><p id="story-description"></p></section>
|
||||
<div id="stories" class="stories" role="group" aria-label="下一层木片"></div>
|
||||
<div id="events" class="events" aria-label="事件"></div>
|
||||
<div id="detail" class="detail"></div>
|
||||
<div id="wisdom-list" class="wisdom-list"></div>
|
||||
</aside>
|
||||
</main>
|
||||
<footer><strong>数据来源:</strong>本机已生成的故事线图谱(2 次回顾,268 个实体;两次回顾重复提取的事件已按时间与类型合并,剩 358 个)的只读导出。项目由事件引用的会话确定;功能与子线索、跨故事经验由 deepseek-flash 根据实体与事件提出,未经确认。半径为每 3 小时的对话轮次,只表示投入集中程度,不代表效率。goodbuddy 以外的项目只显示时间与类型。“跨项目故事”默认关闭;打开后,按功能名称把多个项目中的同一件事(如果蝇神经仿真、Agent 运行环境)串成全局木片,与项目木片并列;事件仍归属原项目。</footer>
|
||||
<script src="3d-demo-data.js"></script>
|
||||
<script src="vendor/three.min.js"></script>
|
||||
<script src="3d-demo-webgl.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,472 @@
|
||||
/* 3D Story Graph prototype on a real export: one time helix, nested story staves (project > feature > sub-thread).
|
||||
* Geometry: height = time (uniform), helix radius = attention density, staves sit on a fixed outer cylinder at a
|
||||
* stable angle, and each event joins its stave by a horizontal connector at the same height. */
|
||||
const data = window.STORY_DATA;
|
||||
const canvas = document.querySelector('#scene');
|
||||
const ctx = canvas.getContext('2d');
|
||||
const tau = Math.PI * 2;
|
||||
const palette = ['#367e70', '#bb8743', '#8675a6', '#b5604f', '#4f7fa8', '#7a8f3b', '#a35d86', '#3f8f97', '#9a7a55', '#5f6fb0', '#c07a3a', '#6b8d6f'];
|
||||
const range = data.range.map(d => Date.parse(d + 'T00:00:00+08:00'));
|
||||
const totalHours = (range[1] - range[0]) / 3600000;
|
||||
const zoom = document.querySelector('#zoom');
|
||||
const showWisdom = document.querySelector('#wisdom');
|
||||
const reducedMotion = matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
|
||||
// ---- model ---------------------------------------------------------------
|
||||
// The two saved reviews overlap and re-extracted the same moments; show each (time, type) once.
|
||||
const canonical = new Map();
|
||||
data.projects.forEach(p => p.features.forEach(f => {
|
||||
const seen = new Map();
|
||||
f.events = f.events.filter(e => { const k = e.at + e.type; if (seen.has(k)) { canonical.set(e.id, seen.get(k)); return false; } seen.set(k, e.id); canonical.set(e.id, e.id); return true; });
|
||||
}));
|
||||
const canon = id => canonical.get(id) ?? id;
|
||||
data.wisdom.forEach(w => { w.formedIn = [...new Set(w.formedIn.map(canon))]; w.appliedIn = [...new Set(w.appliedIn.map(canon))]; });
|
||||
const root = { name: '全部工作', children: data.projects.map(p => ({
|
||||
name: p.name, detailed: p.detailed, scattered: p.scattered,
|
||||
children: p.features.map(f => ({ ...f, children: f.subThreads.map(s => ({ ...s,
|
||||
events: [...new Set(s.eventIds.map(canon))].map(id => f.events.find(e => e.id === id)).filter(Boolean) })) }))
|
||||
})) };
|
||||
const eventIndex = new Map();
|
||||
function finish(node, parent, depth) {
|
||||
node.parent = parent; node.depth = depth;
|
||||
(node.children ?? []).forEach(c => finish(c, node, depth + 1));
|
||||
if (!node.events) node.events = [...new Map(node.children.flatMap(c => c.events).map(e => [e.id, e])).values()].sort((a, b) => a.t - b.t);
|
||||
node.start = node.events[0]?.t ?? 0; node.end = node.events.at(-1)?.t ?? 0;
|
||||
if (depth === 2) node.events.forEach(e => eventIndex.set(e.id, { event: e, feature: node }));
|
||||
}
|
||||
finish(root, undefined, 0);
|
||||
root.children.forEach((p, i) => { p.color = palette[i % palette.length]; });
|
||||
let focus = root, selectedEvent, selectedWisdom, hovered, hits = [];
|
||||
|
||||
// Stable angular layout: siblings keep the order of their first event, each takes a sector by sqrt(event count).
|
||||
function layout(node, from, to) {
|
||||
node.a0 = from; node.a1 = to;
|
||||
const kids = (node.children ?? []).slice().sort((a, b) => a.start - b.start);
|
||||
const weight = kids.map(k => Math.max(1.2, Math.sqrt(k.events.length)));
|
||||
const total = weight.reduce((a, b) => a + b, 0);
|
||||
let at = from;
|
||||
kids.forEach((k, i) => { const w = (to - from) * weight[i] / total; layout(k, at, at + w); at += w; });
|
||||
}
|
||||
layout(root, 0, tau);
|
||||
|
||||
// ---- geometry ----------------------------------------------------------------
|
||||
// Event times `t` are normalised over the whole export. The viewed window [win.t0, win.t1] maps to the full height,
|
||||
// and one turn covers `turnHours`, chosen so the helix keeps a readable number of turns at any range.
|
||||
const HEIGHT = 430, STAVE_R = 188, FLOOR = -HEIGHT / 2 - 34;
|
||||
const TURN_STEPS = [3, 6, 12, 24, 168, 720, 2160, 8760]; // hours: 3h … 1 year
|
||||
const TARGET_TURNS = 8, MAX_TURNS = 14;
|
||||
const win = { t0: 0, t1: 1, turnHours: 24, auto: true };
|
||||
let turns = 8, bins = [], binMax = 1;
|
||||
const hoursOf = t => t * totalHours;
|
||||
const clamp01 = t => Math.max(0, Math.min(1, t));
|
||||
const inWindow = t => t >= win.t0 - 1e-9 && t <= win.t1 + 1e-9;
|
||||
const uOf = t => (t - win.t0) / (win.t1 - win.t0);
|
||||
function autoTurnHours(spanHours) { return TURN_STEPS.find(h => spanHours / h <= TARGET_TURNS) ?? TURN_STEPS.at(-1); }
|
||||
// Attention density is re-counted at the current grain: one bin is an eighth of a turn.
|
||||
const slotTimes = data.density.map((n, i) => ({ t: (i + .5) / data.density.length, n }));
|
||||
function setWindow(t0, t1, turnHours) {
|
||||
win.t0 = clamp01(t0); win.t1 = clamp01(t1);
|
||||
const span = hoursOf(win.t1 - win.t0);
|
||||
win.turnHours = turnHours ?? autoTurnHours(span);
|
||||
turns = Math.max(.5, span / win.turnHours);
|
||||
if (turns > MAX_TURNS) { win.turnHours = TURN_STEPS.find(h => span / h <= MAX_TURNS) ?? TURN_STEPS.at(-1); turns = span / win.turnHours; }
|
||||
const count = Math.max(4, Math.round(turns * 8));
|
||||
bins = new Array(count).fill(0);
|
||||
slotTimes.forEach(s => { if (inWindow(s.t)) bins[Math.min(count - 1, Math.floor(uOf(s.t) * count))] += s.n; });
|
||||
binMax = Math.max(1, ...bins);
|
||||
smoothR = Array.from({ length: 401 }, (_, i) => { let s = 0; for (let k = -3; k <= 3; k++) s += density(clamp01((i + k) / 400)); return s / 7; });
|
||||
}
|
||||
function density(u) {
|
||||
const x = u * bins.length - .5, i = Math.floor(x), f = x - i, at = j => bins[Math.max(0, Math.min(bins.length - 1, j))] / binMax;
|
||||
return at(i) * (1 - f) + at(i + 1) * f;
|
||||
}
|
||||
let smoothR = [];
|
||||
const radius = t => 52 + 100 * smoothR[Math.round(clamp01(uOf(t)) * 400)];
|
||||
const yOf = t => (uOf(t) - .5) * HEIGHT;
|
||||
const helixAngle = t => uOf(t) * turns * tau;
|
||||
const helixPoint = t => { const r = radius(t), a = helixAngle(t); return [r * Math.cos(a), yOf(t), r * Math.sin(a)]; };
|
||||
const cylinder = (t, a, r = STAVE_R) => [r * Math.cos(a), yOf(t), r * Math.sin(a)];
|
||||
const centerAngle = node => (node.a0 + node.a1) / 2;
|
||||
|
||||
// ---- camera ------------------------------------------------------------------
|
||||
const VIEWS = { oblique: { yaw: -.4, tilt: .38 }, side: { tilt: 0 }, top: { tilt: Math.PI / 2 } };
|
||||
const cam = { yaw: -.4, tilt: .38, zoom: 1, pan: 0 };
|
||||
let mode = 'oblique', tween;
|
||||
let width = 0, height = 0;
|
||||
function project(p) {
|
||||
const y0 = p[1] - cam.pan;
|
||||
const x = p[0] * Math.cos(cam.yaw) + p[2] * Math.sin(cam.yaw);
|
||||
const z = -p[0] * Math.sin(cam.yaw) + p[2] * Math.cos(cam.yaw);
|
||||
const y = y0 * Math.cos(cam.tilt) - z * Math.sin(cam.tilt);
|
||||
const depth = y0 * Math.sin(cam.tilt) + z * Math.cos(cam.tilt);
|
||||
const scale = Math.min(width / 500, height / 560) * cam.zoom * 900 / (900 - depth);
|
||||
return { x: width / 2 + x * scale, y: height / 2 - y * scale, z: depth };
|
||||
}
|
||||
const shortest = (from, to) => from + ((((to - from) % tau) + tau * 1.5) % tau - Math.PI);
|
||||
function animateTo(target, nextMode = mode) {
|
||||
mode = nextMode; syncViewButtons();
|
||||
const goal = { ...cam, ...target };
|
||||
goal.yaw = shortest(cam.yaw, goal.yaw);
|
||||
if (reducedMotion) { Object.assign(cam, goal); syncZoom(); draw(); return; }
|
||||
const from = { ...cam }, started = performance.now();
|
||||
tween = { from, goal, started };
|
||||
const step = now => {
|
||||
if (tween?.started !== started) return;
|
||||
const k = Math.min(1, (now - started) / 480), e = k < .5 ? 2 * k * k : 1 - (-2 * k + 2) ** 2 / 2;
|
||||
for (const key of ['yaw', 'tilt', 'zoom', 'pan']) cam[key] = from[key] + (goal[key] - from[key]) * e;
|
||||
syncZoom(); draw();
|
||||
if (k < 1) requestAnimationFrame(step); else tween = undefined;
|
||||
};
|
||||
requestAnimationFrame(step);
|
||||
}
|
||||
// Turn the camera so a stave faces the viewer.
|
||||
function face(node) { if (node && node.depth > 0) animateTo({ yaw: centerAngle(node) - Math.PI / 2, pan: (yOf(Math.max(win.t0, node.start)) + yOf(Math.min(win.t1, node.end))) / 2 * .35 }); }
|
||||
function freeView() { tween = undefined; if (mode !== 'free') { mode = 'free'; syncViewButtons(); } }
|
||||
|
||||
// ---- colour and depth cue -------------------------------------------------------
|
||||
const hexRgb = h => [1, 3, 5].map(i => parseInt(h.slice(i, i + 2), 16));
|
||||
const GREY = [150, 158, 154];
|
||||
// k: 0 = farthest, 1 = nearest. Far geometry loses saturation and opacity instead of gaining an outline.
|
||||
function shade(hex, alpha, k) {
|
||||
const [r, g, b] = hexRgb(hex), d = (1 - k) * .65;
|
||||
const mix = (c, i) => Math.round(c * (1 - d) + GREY[i] * d);
|
||||
return `rgba(${mix(r, 0)},${mix(g, 1)},${mix(b, 2)},${(alpha * (.22 + .78 * k)).toFixed(3)})`;
|
||||
}
|
||||
const projectOf = n => { while (n.depth > 1) n = n.parent; return n; };
|
||||
function colorOf(node) {
|
||||
if (node.depth <= 1) return node.color ?? '#6f807b';
|
||||
const p = projectOf(node);
|
||||
return palette[(node.parent.children.indexOf(node) + root.children.indexOf(p) * 3) % palette.length];
|
||||
}
|
||||
function shown(node) { const set = visibleStaves(); for (let n = node; n; n = n.parent) if (set.includes(n)) return n; }
|
||||
function level() { return focus.children?.length ? focus : focus.parent; }
|
||||
function visibleStaves() { return level().children; }
|
||||
function emphasisOf(s) { return focus === level() ? (s === hovered ? 'focus' : 'normal') : s === focus ? 'focus' : 'dim'; }
|
||||
// Staves whose events are joined to the helix: the focused leaf, or the stave under the pointer.
|
||||
function connected() { return visibleStaves().filter(s => (focus !== level() && s === focus) || s === hovered); }
|
||||
|
||||
// ---- rendering ---------------------------------------------------------------
|
||||
const tAt = u => win.t0 + u * (win.t1 - win.t0);
|
||||
function drawFloor() {
|
||||
// A faint footprint on the floor helps read front and back.
|
||||
const ring = [];
|
||||
for (let i = 0; i <= 96; i++) ring.push(project(cylinder(win.t0, i / 96 * tau, STAVE_R).map((v, j) => j === 1 ? FLOOR : v)));
|
||||
line(ring, 'rgba(120,132,124,.16)', 1);
|
||||
const shadow = [];
|
||||
for (let i = 0; i <= 900; i++) { const p = helixPoint(tAt(i / 900)); p[1] = FLOOR; shadow.push(project(p)); }
|
||||
line(shadow, 'rgba(110,122,114,.10)', 4);
|
||||
}
|
||||
function line(points, color, size = 1, dash = []) {
|
||||
ctx.beginPath(); points.forEach((p, i) => i ? ctx.lineTo(p.x, p.y) : ctx.moveTo(p.x, p.y));
|
||||
ctx.setLineDash(dash); ctx.lineWidth = size; ctx.strokeStyle = color; ctx.lineCap = 'round'; ctx.stroke(); ctx.setLineDash([]);
|
||||
}
|
||||
function polygon(points, color) { ctx.beginPath(); points.forEach((p, i) => i ? ctx.lineTo(p.x, p.y) : ctx.moveTo(p.x, p.y)); ctx.closePath(); ctx.fillStyle = color; ctx.fill(); }
|
||||
// Events closer than this on screen are merged into one cluster (≈ 9 px of height).
|
||||
const CLUSTER_PX = 9;
|
||||
function scaleNow() { return Math.min(width / 500, height / 560) * cam.zoom; }
|
||||
function clusterSpan() { return CLUSTER_PX / (HEIGHT * scaleNow()) * (win.t1 - win.t0); }
|
||||
// Groups a story's in-window events into clusters on the time axis, at the current grain and zoom.
|
||||
function clusters(events) {
|
||||
const span = clusterSpan(), out = [];
|
||||
events.filter(e => inWindow(e.t)).forEach(e => {
|
||||
const last = out.at(-1);
|
||||
if (last && e.t - last.t0 <= span) { last.events.push(e); last.t1 = e.t; } else out.push({ t0: e.t, t1: e.t, events: [e] });
|
||||
});
|
||||
out.forEach(c => { c.t = (c.t0 + c.t1) / 2; c.n = c.events.length; });
|
||||
return out;
|
||||
}
|
||||
const fmtSpan = c => c.n === 1 ? fmtTime(c.t) : `${fmtTime(c.t0)} – ${fmtTime(c.t1)}`;
|
||||
function draw() {
|
||||
if (!width) return;
|
||||
ctx.clearRect(0, 0, width, height); hits = [];
|
||||
drawFloor();
|
||||
const prims = [], staves = visibleStaves().filter(s => s.events.some(e => inWindow(e.t))), links = connected();
|
||||
const cl = new Map(staves.map(s => [s, clusters(s.events)]));
|
||||
// Helix: one continuous line split into short segments so it can interleave with staves by depth.
|
||||
const owner = new Array(600).fill(undefined);
|
||||
staves.filter(s => emphasisOf(s) !== 'dim').forEach(s => cl.get(s).forEach(c => {
|
||||
const i0 = Math.round(uOf(c.t0) * 599), i1 = Math.round(uOf(c.t1) * 599);
|
||||
for (let i = i0 - 1; i <= i1 + 1; i++) if (i >= 0 && i < 600 && owner[i] === undefined) owner[i] = colorOf(s);
|
||||
}));
|
||||
const N = 1800;
|
||||
let previous = project(helixPoint(tAt(0)));
|
||||
for (let i = 1; i <= N; i++) {
|
||||
const u = i / N, next = project(helixPoint(tAt(u))), c = owner[Math.round(u * 599)];
|
||||
const a = previous, b = next;
|
||||
prims.push({ z: (a.z + b.z) / 2, draw: k => line([a, b], shade(c ?? '#6d7c76', c ? 1 : .8, k), c ? 2.4 : 1.25) });
|
||||
previous = next;
|
||||
}
|
||||
// Staves: vertical strips of a fixed outer cylinder, spanning the story's first to last event in the window.
|
||||
staves.forEach(s => {
|
||||
const em = emphasisOf(s), color = colorOf(s), gap = Math.min(.014, (s.a1 - s.a0) * .08);
|
||||
const inside = s.events.filter(e => inWindow(e.t)), pad = (win.t1 - win.t0) * .006;
|
||||
const a0 = s.a0 + gap, a1 = s.a1 - gap;
|
||||
const t0 = Math.max(win.t0, inside[0].t - pad), t1 = Math.min(win.t1, inside.at(-1).t + pad);
|
||||
const fill = em === 'focus' ? .26 : em === 'dim' ? .05 : .13, edge = em === 'dim' ? .28 : .75;
|
||||
const strips = Math.max(2, Math.ceil((a1 - a0) / .07));
|
||||
for (let j = 0; j < strips; j++) {
|
||||
const b0 = a0 + (a1 - a0) * j / strips, b1 = a0 + (a1 - a0) * (j + 1) / strips;
|
||||
const quad = [cylinder(t0, b0), cylinder(t0, b1), cylinder(t1, b1), cylinder(t1, b0)].map(project);
|
||||
const z = quad.reduce((n, p) => n + p.z, 0) / 4;
|
||||
prims.push({ z, draw: k => polygon(quad, shade(color, fill, k)) });
|
||||
if (em !== 'dim' && (j === 0 || j === strips - 1)) {
|
||||
const edgeAngle = j === 0 ? b0 : b1;
|
||||
prims.push({ z: z + .1, draw: k => line([project(cylinder(t0, edgeAngle)), project(cylinder(t1, edgeAngle))], shade(color, edge, k), 1) });
|
||||
}
|
||||
hits.push({ kind: 'stave', node: s, poly: quad, z });
|
||||
}
|
||||
// One band per cluster: thickness grows with the number of events; quiet periods stay as gaps.
|
||||
cl.get(s).forEach(c => {
|
||||
const band = []; for (let i = 0; i <= 8; i++) band.push(project(cylinder(c.t, a0 + (a1 - a0) * i / 8, STAVE_R + .5)));
|
||||
const z = band[4].z + .2, active = c.events.includes(selectedEvent);
|
||||
const size = Math.min(6, 1.3 + Math.log2(c.n) * 1.1) + (active ? 2 : 0);
|
||||
prims.push({ z, draw: k => line(band, shade(color, em === 'dim' ? .3 : .85, k), size) });
|
||||
});
|
||||
s.labelAt = project(cylinder(t1, centerAngle(s), STAVE_R + 2));
|
||||
s.labelZ = s.labelAt.z;
|
||||
});
|
||||
// Connectors: helix -> stave at the same height, one per cluster; clusters show their size and open on click.
|
||||
links.filter(s => cl.has(s)).forEach(s => {
|
||||
const color = colorOf(s), a = centerAngle(s);
|
||||
cl.get(s).forEach(c => {
|
||||
const p = project(helixPoint(c.t)), q = project(cylinder(c.t, a)), active = c.events.includes(selectedEvent);
|
||||
const r = c.n === 1 ? (active ? 6 : 3.4) : Math.min(11, 6 + Math.log2(c.n) * 1.4);
|
||||
prims.push({ z: (p.z + q.z) / 2, draw: k => line([p, q], shade(color, active ? .95 : .45, k), active ? 2 : 1) });
|
||||
prims.push({ z: p.z + .3, draw: k => {
|
||||
ctx.beginPath(); ctx.arc(p.x, p.y, r, 0, tau); ctx.fillStyle = shade(color, 1, k); ctx.fill();
|
||||
ctx.strokeStyle = active ? '#273b36' : 'rgba(252,252,248,.9)'; ctx.lineWidth = 1.4; ctx.stroke();
|
||||
if (c.n > 1) { ctx.fillStyle = '#fcfcf8'; ctx.font = '600 9px system-ui'; const label = String(c.n); ctx.fillText(label, p.x - ctx.measureText(label).width / 2, p.y + 3); }
|
||||
} });
|
||||
if (focus.depth >= 2 || s === focus) hits.push({ kind: 'cluster', cluster: c, node: s, x: p.x, y: p.y, r: Math.max(9, r + 2), z: p.z });
|
||||
});
|
||||
});
|
||||
// Turn ticks on the helix: equal time per turn, labelled at the current grain.
|
||||
const every = Math.max(1, Math.ceil(turns / 10));
|
||||
for (let d = 0; d <= Math.floor(turns + 1e-6); d++) {
|
||||
const t = tAt(d / turns), p = project(helixPoint(t));
|
||||
prims.push({ z: p.z + .4, draw: k => {
|
||||
ctx.fillStyle = shade('#3f514a', 1, k); ctx.beginPath(); ctx.arc(p.x, p.y, 2.3, 0, tau); ctx.fill();
|
||||
if (d % every === 0 && d / turns < .999) { ctx.font = '10px system-ui'; ctx.fillText(fmtTick(t), p.x + 6, p.y + 3); }
|
||||
} });
|
||||
}
|
||||
let zmin = Infinity, zmax = -Infinity;
|
||||
prims.forEach(p => { zmin = Math.min(zmin, p.z); zmax = Math.max(zmax, p.z); });
|
||||
const norm = z => zmax > zmin ? (z - zmin) / (zmax - zmin) : 1;
|
||||
prims.sort((a, b) => a.z - b.z).forEach(p => p.draw(norm(p.z)));
|
||||
drawLabels(staves, norm);
|
||||
if (showWisdom.checked) data.wisdom.forEach((w, i) => drawWisdom(w, i));
|
||||
}
|
||||
// Labels: the focused or hovered stave always; otherwise only staves facing the viewer, without overlaps.
|
||||
function drawLabels(staves, norm) {
|
||||
const placed = [];
|
||||
staves.map(s => ({ s, k: norm(s.labelZ), em: emphasisOf(s) }))
|
||||
.sort((a, b) => (b.em === 'focus') - (a.em === 'focus') || b.k - a.k)
|
||||
.forEach(({ s, k, em }) => {
|
||||
if (em === 'dim' || (em !== 'focus' && k < .45)) return;
|
||||
ctx.font = (em === 'focus' ? '600 ' : '') + '11px system-ui';
|
||||
const w = ctx.measureText(s.name).width, x = s.labelAt.x - w / 2, y = s.labelAt.y - 8;
|
||||
const box = [x - 4, y - 12, w + 8, 16];
|
||||
if (placed.some(b => box[0] < b[0] + b[2] && b[0] < box[0] + box[2] && box[1] < b[1] + b[3] && b[1] < box[1] + box[3])) return;
|
||||
placed.push(box);
|
||||
ctx.fillStyle = 'rgba(252,252,248,.82)'; ctx.fillRect(...box);
|
||||
ctx.fillStyle = shade(colorOf(s), 1, Math.max(k, em === 'focus' ? 1 : 0)); ctx.fillText(s.name, x, y);
|
||||
hits.push({ kind: 'label', node: s, rect: box });
|
||||
});
|
||||
}
|
||||
function staveFor(eventId) { const hit = eventIndex.get(eventId); if (!hit) return; const sub = hit.feature.children.find(s => s.events.some(e => e.id === eventId)); return { event: hit.event, node: sub ?? hit.feature }; }
|
||||
function drawWisdom(w, i) {
|
||||
const a = staveFor(w.formedIn[0]), b = staveFor(w.appliedIn.at(-1)); if (!a || !b) return;
|
||||
const sa = shown(a.node), sb = shown(b.node); if (!sa || !sb || sa === sb) return;
|
||||
if (!inWindow(a.event.t) || !inWindow(b.event.t)) return; // either end lies outside the viewed time window
|
||||
const pa = cylinder(a.event.t, centerAngle(sa)), pb = cylinder(b.event.t, centerAngle(sb));
|
||||
const mid = [(pa[0] + pb[0]) * .9, (pa[1] + pb[1]) / 2, (pa[2] + pb[2]) * .9 + 120];
|
||||
const curve = []; for (let k = 0; k <= 50; k++) { const t = k / 50; curve.push(project(pa.map((v, j) => (1 - t) ** 2 * v + 2 * (1 - t) * t * mid[j] + t * t * pb[j]))); }
|
||||
const on = selectedWisdom === w;
|
||||
line(curve, '#a47837', on ? 2.6 : 1.3, [5, 5]);
|
||||
const end = curve[50], prev = curve[47], ang = Math.atan2(end.y - prev.y, end.x - prev.x);
|
||||
line([{ x: end.x - 8 * Math.cos(ang - .45), y: end.y - 8 * Math.sin(ang - .45) }, end, { x: end.x - 8 * Math.cos(ang + .45), y: end.y - 8 * Math.sin(ang + .45) }], '#a47837', 1.8);
|
||||
const anchor = curve[25], cw = Math.min(176, width - 24), ch = 44;
|
||||
const x = Math.max(12, Math.min(width - cw - 12, anchor.x - cw / 2)), y = Math.max(12, Math.min(height - ch - 30, anchor.y - ch / 2 + i * 50));
|
||||
ctx.fillStyle = on ? '#e9d7b6' : '#f8efdfe8'; ctx.strokeStyle = '#ad854b'; ctx.lineWidth = on ? 2 : 1;
|
||||
ctx.beginPath(); ctx.roundRect(x, y, cw, ch, 8); ctx.fill(); ctx.stroke();
|
||||
ctx.fillStyle = '#936825'; ctx.font = '10px system-ui'; ctx.fillText(`W${i + 1} · 跨故事经验 · 待确认`, x + 10, y + 16);
|
||||
ctx.fillStyle = '#574522'; ctx.font = '12px system-ui'; ctx.fillText(w.lesson, x + 10, y + 33);
|
||||
hits.push({ kind: 'wisdom', w, rect: [x, y, cw, ch] });
|
||||
}
|
||||
|
||||
// ---- side panel ------------------------------------------------------------
|
||||
const dateOf = t => new Date(range[0] + t * (range[1] - range[0]));
|
||||
const pad2 = n => String(n).padStart(2, '0');
|
||||
const fmt = t => { const d = dateOf(t); return `${d.getMonth() + 1}.${pad2(d.getDate())}`; };
|
||||
const fmtTime = t => { const d = dateOf(t); return `${fmt(t)} ${pad2(d.getHours())}:${pad2(d.getMinutes())}`; };
|
||||
// Tick labels follow the grain: hours for sub-day turns, dates for daily turns, months beyond.
|
||||
const fmtTick = t => win.turnHours < 24 ? fmtTime(t) : win.turnHours >= 720 ? `${dateOf(t).getFullYear()}.${dateOf(t).getMonth() + 1}` : fmt(t);
|
||||
const turnName = h => ({ 3: '3 小时', 6: '6 小时', 12: '12 小时', 24: '1 天', 168: '1 周', 720: '1 个月', 2160: '1 个季度', 8760: '1 年' })[h] ?? `${h} 小时`;
|
||||
const typeName = { decision: '决定', change: '变更', milestone: '里程碑', discussion: '讨论' };
|
||||
function el(tag, props = {}, ...kids) { const n = document.createElement(tag); Object.assign(n, props); n.append(...kids); return n; }
|
||||
function setFocus(node) {
|
||||
focus = node; hovered = undefined; layout(level(), 0, tau); selectedEvent = undefined; selectedWisdom = undefined; selectedCluster = undefined; render();
|
||||
if (focus !== level()) face(focus); else if (mode === 'free') draw(); else animateTo({ ...VIEWS[mode], pan: 0 });
|
||||
}
|
||||
function render() {
|
||||
const crumbs = document.querySelector('#crumbs'); crumbs.replaceChildren();
|
||||
const trail = []; for (let n = focus; n; n = n.parent) trail.unshift(n);
|
||||
trail.forEach((n, i) => { if (i) crumbs.append(' › '); crumbs.append(i === trail.length - 1 ? el('strong', { textContent: n.name }) : el('button', { textContent: n.name, onclick: () => setFocus(n), className: 'crumb' })); });
|
||||
const levelName = ['全部项目', '项目木片', '功能木片', '子线索'][focus.depth];
|
||||
document.querySelector('#period').textContent = selectedWisdom ? '跨故事经验 · 模型归纳 · 待确认' : `${levelName} · ${fmt(focus.start)} – ${fmt(focus.end)}`;
|
||||
document.querySelector('#story-title').textContent = selectedWisdom ? selectedWisdom.lesson : focus.name;
|
||||
const desc = selectedWisdom ? '由真实图谱事件归纳,形成与应用都对应具体事件;需要用户确认后才作为经验使用。'
|
||||
: focus === root ? `${root.children.length} 个项目,${root.events.length} 个事件。木片越宽,事件越多;选择项目后拆分为功能。`
|
||||
: `${focus.events.length} 个事件 · ${focus.entities ?? focus.children.reduce((n, c) => n + (c.entities ?? 0), 0)} 个知识实体${focus.scattered ? ` · ${focus.scattered} 个零散实体未归入功能` : ''}${focus.unthreaded ? ` · ${focus.unthreaded} 个实体留在功能层` : ''}`;
|
||||
document.querySelector('#story-description').textContent = desc;
|
||||
const list = document.querySelector('#stories'); list.replaceChildren();
|
||||
if (!selectedWisdom) (focus.children ?? []).forEach(c => {
|
||||
const b = el('button', { onclick: () => setFocus(c), onmouseenter: () => { hovered = c; draw(); }, onmouseleave: () => { hovered = undefined; draw(); } },
|
||||
el('i'), el('span', { textContent: c.name }), el('small', { textContent: `${c.events.length} 事件 · ${fmt(c.start)}–${fmt(c.end)}${c.children?.length ? ` · ${c.children.length} 片` : ''}` }));
|
||||
b.style.setProperty('--story-color', colorOf(c)); list.append(b);
|
||||
});
|
||||
const events = document.querySelector('#events'); events.replaceChildren();
|
||||
const detail = document.querySelector('#detail'); detail.replaceChildren();
|
||||
if (selectedWisdom) {
|
||||
[['适用条件', selectedWisdom.conditions], ['边界', selectedWisdom.boundary], ['形成与应用依据', selectedWisdom.why]].forEach(([h, p]) => detail.append(el('section', {}, el('strong', { textContent: h }), el('p', { textContent: p }))));
|
||||
[['形成', selectedWisdom.formedIn], ['应用', selectedWisdom.appliedIn]].forEach(([label, ids]) => ids.forEach(id => {
|
||||
const s = staveFor(id); if (!s) return;
|
||||
detail.append(el('button', { className: 'jump', textContent: `${label} · ${fmt(s.event.t)} · ${s.node.name}`, onclick: () => { setFocus(s.node); selectEvent(s.event); } }));
|
||||
}));
|
||||
} else if (focus.depth >= 2) {
|
||||
const detailed = projectOf(focus).detailed;
|
||||
// The list follows the viewed window; an opened cluster narrows it to that cluster's events.
|
||||
const listed = (selectedCluster ? selectedCluster.events : focus.events.filter(e => inWindow(e.t))).slice().reverse();
|
||||
if (selectedCluster) events.append(el('div', { className: 'cluster-head' }, el('span', { textContent: `${fmtSpan(selectedCluster)} · ${selectedCluster.n} 个事件` }), el('button', { className: 'crumb', textContent: '显示全部', onclick: () => { selectedCluster = undefined; render(); } })));
|
||||
listed.slice(0, 60).forEach(e => events.append(el('button', { 'aria-pressed': String(e === selectedEvent), onclick: () => selectEvent(e) },
|
||||
el('span', { textContent: detailed ? e.title : `${typeName[e.type]}事件` }), el('small', { textContent: `${fmtTime(e.t)} · ${typeName[e.type]}` }))));
|
||||
if (listed.length > 60) events.append(el('small', { textContent: `另有 ${listed.length - 60} 个更早的事件` }));
|
||||
if (!listed.length) events.append(el('small', { textContent: '这段时间没有该故事的事件' }));
|
||||
if (selectedEvent) detail.append(el('strong', { textContent: detailed ? selectedEvent.title : '该项目只显示时间与类型' }), el('p', { textContent: detailed ? selectedEvent.text : '' }), el('small', { textContent: new Date(selectedEvent.at).toLocaleString('zh-CN') }));
|
||||
}
|
||||
document.querySelector('#wisdom-list').replaceChildren(...data.wisdom.map((w, i) => el('button', { className: 'wisdom-card', 'aria-pressed': String(selectedWisdom === w), onclick: () => { selectedWisdom = w; showWisdom.checked = true; render(); } },
|
||||
el('span', { textContent: `W${i + 1} · 跨故事经验 · 待确认` }), el('strong', { textContent: w.lesson }))));
|
||||
draw();
|
||||
}
|
||||
function selectEvent(e) {
|
||||
if (!inWindow(e.t)) { selectedCluster = undefined; applyRange('all'); }
|
||||
selectedEvent = e; render(); if (mode !== 'free') animateTo({ pan: yOf(e.t) * .5 });
|
||||
}
|
||||
// A single event opens directly. A cluster zooms the window into its time span so it splits apart;
|
||||
// when it cannot split further (same moment) the list narrows to its events instead.
|
||||
function openCluster(c) {
|
||||
if (c.n === 1) { selectedCluster = undefined; selectEvent(c.events[0]); return; }
|
||||
const span = c.t1 - c.t0, minSpan = 6 / totalHours; // never zoom below ~6 hours
|
||||
if (win.t1 - win.t0 > minSpan * 1.01 && span > 0) {
|
||||
const pad = Math.max(span * .6, minSpan / 2), mid = (c.t0 + c.t1) / 2, half = Math.max(span / 2 + pad, minSpan / 2);
|
||||
selectedCluster = undefined; zoomWindow(mid - half, mid + half);
|
||||
} else { selectedCluster = c; selectedEvent = undefined; render(); }
|
||||
}
|
||||
|
||||
// ---- time window and grain ---------------------------------------------------------
|
||||
const rangeSelect = document.querySelector('#range'), turnSelect = document.querySelector('#turn'), rangeReset = document.querySelector('#range-reset');
|
||||
let selectedCluster;
|
||||
function applyWindow(t0, t1) {
|
||||
const fixed = turnSelect.value === 'auto' ? undefined : Number(turnSelect.value);
|
||||
setWindow(t0, t1, fixed);
|
||||
if (fixed && win.turnHours !== fixed) turnSelect.value = 'auto'; // the chosen turn would make the helix unreadable
|
||||
document.querySelector('#scale-note').textContent = `高度为时间,一圈 ${turnName(win.turnHours)}(共 ${Math.round(turns * 10) / 10} 圈)· 半径为当时的对话密度`;
|
||||
document.querySelector('#axis-start').textContent = win.turnHours < 24 ? fmtTime(win.t0) : fmt(win.t0);
|
||||
document.querySelector('#axis-end').textContent = win.turnHours < 24 ? fmtTime(win.t1) : fmt(win.t1);
|
||||
rangeReset.hidden = win.t0 <= 0 && win.t1 >= 1;
|
||||
}
|
||||
function zoomWindow(t0, t1) {
|
||||
const len = t1 - t0;
|
||||
if (t0 < 0) { t0 = 0; t1 = Math.min(1, len); } if (t1 > 1) { t1 = 1; t0 = Math.max(0, 1 - len); }
|
||||
rangeSelect.value = 'custom';
|
||||
applyWindow(t0, t1); render(); if (mode !== 'free') animateTo({ pan: 0 });
|
||||
}
|
||||
function applyRange(value) {
|
||||
rangeSelect.value = value;
|
||||
if (value === 'all') applyWindow(0, 1); else if (value !== 'custom') { const h = Number(value); applyWindow(Math.max(0, 1 - h / totalHours), 1); }
|
||||
}
|
||||
rangeSelect.addEventListener('change', () => { selectedCluster = undefined; applyRange(rangeSelect.value); render(); });
|
||||
turnSelect.addEventListener('change', () => { applyWindow(win.t0, win.t1); render(); });
|
||||
rangeReset.addEventListener('click', () => { selectedCluster = undefined; applyRange('all'); render(); });
|
||||
applyRange('all');
|
||||
|
||||
// ---- input -----------------------------------------------------------------
|
||||
const viewButtons = [...document.querySelectorAll('[data-view]')];
|
||||
function syncViewButtons() { viewButtons.forEach(b => b.setAttribute('aria-pressed', String(b.dataset.view === mode))); }
|
||||
function syncZoom() { zoom.value = String(Math.round(cam.zoom * 100)); }
|
||||
viewButtons.forEach(b => b.addEventListener('click', () => {
|
||||
if (b.dataset.view === 'free') { freeView(); return; }
|
||||
animateTo({ ...VIEWS[b.dataset.view], zoom: 1, pan: 0 }, b.dataset.view);
|
||||
}));
|
||||
zoom.addEventListener('input', () => { tween = undefined; cam.zoom = Number(zoom.value) / 100; draw(); });
|
||||
showWisdom.addEventListener('input', draw);
|
||||
document.querySelector('#reset').onclick = () => animateTo({ ...VIEWS.oblique, zoom: 1, pan: 0 }, 'oblique');
|
||||
const clampTilt = t => Math.max(-Math.PI / 2, Math.min(Math.PI / 2, t));
|
||||
const clampPan = p => Math.max(-HEIGHT / 2, Math.min(HEIGHT / 2, p));
|
||||
const clampZoom = z => Math.max(.5, Math.min(2.5, z));
|
||||
function inPoly(x, y, poly) { let inside = false; for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) { const a = poly[i], b = poly[j]; if ((a.y > y) !== (b.y > y) && x < (b.x - a.x) * (y - a.y) / (b.y - a.y) + a.x) inside = !inside; } return inside; }
|
||||
const inRect = (x, y, r) => x >= r[0] && x <= r[0] + r[2] && y >= r[1] && y <= r[1] + r[3];
|
||||
function hitAt(x, y) {
|
||||
return hits.find(h => h.kind === 'wisdom' && inRect(x, y, h.rect))
|
||||
?? hits.filter(h => h.kind === 'cluster' && Math.hypot(h.x - x, h.y - y) < h.r).sort((a, b) => b.z - a.z)[0]
|
||||
?? hits.find(h => h.kind === 'label' && inRect(x, y, h.rect))
|
||||
?? hits.filter(h => h.kind === 'stave' && inPoly(x, y, h.poly)).sort((a, b) => b.z - a.z)[0];
|
||||
}
|
||||
let drag;
|
||||
canvas.addEventListener('contextmenu', e => e.preventDefault());
|
||||
canvas.addEventListener('pointerdown', e => {
|
||||
drag = { id: e.pointerId, x: e.clientX, y: e.clientY, sx: e.clientX, sy: e.clientY, pan: e.button === 2 || e.shiftKey };
|
||||
canvas.setPointerCapture(e.pointerId);
|
||||
});
|
||||
canvas.addEventListener('pointermove', e => {
|
||||
const r = canvas.getBoundingClientRect();
|
||||
if (!drag) {
|
||||
const h = hitAt(e.clientX - r.left, e.clientY - r.top), node = h && (h.kind === 'stave' || h.kind === 'label') ? h.node : undefined;
|
||||
if (node !== hovered) { hovered = node; draw(); }
|
||||
canvas.style.cursor = h ? 'pointer' : '';
|
||||
return;
|
||||
}
|
||||
if (drag.id !== e.pointerId) return;
|
||||
const dx = e.clientX - drag.x, dy = e.clientY - drag.y;
|
||||
if (Math.hypot(e.clientX - drag.sx, e.clientY - drag.sy) >= 5) freeView();
|
||||
if (mode === 'free') {
|
||||
if (drag.pan) cam.pan = clampPan(cam.pan + dy / (Math.min(width / 500, height / 560) * cam.zoom));
|
||||
else { cam.yaw += dx * .008; cam.tilt = clampTilt(cam.tilt + dy * .006); }
|
||||
draw();
|
||||
}
|
||||
drag.x = e.clientX; drag.y = e.clientY;
|
||||
});
|
||||
canvas.addEventListener('pointerup', e => {
|
||||
if (drag && Math.hypot(e.clientX - drag.sx, e.clientY - drag.sy) < 5) {
|
||||
const r = canvas.getBoundingClientRect(), h = hitAt(e.clientX - r.left, e.clientY - r.top);
|
||||
if (h?.kind === 'wisdom') { selectedWisdom = h.w; render(); }
|
||||
else if (h?.kind === 'cluster') openCluster(h.cluster);
|
||||
else if (h) setFocus(h.node);
|
||||
}
|
||||
drag = undefined;
|
||||
});
|
||||
canvas.addEventListener('pointercancel', () => { drag = undefined; });
|
||||
canvas.addEventListener('pointerleave', () => { if (!drag && hovered) { hovered = undefined; draw(); } });
|
||||
canvas.addEventListener('wheel', e => {
|
||||
e.preventDefault(); freeView();
|
||||
if (e.shiftKey) cam.pan = clampPan(cam.pan - e.deltaY * .3);
|
||||
else cam.zoom = clampZoom(cam.zoom * Math.exp(-e.deltaY * .0012));
|
||||
syncZoom(); draw();
|
||||
}, { passive: false });
|
||||
canvas.addEventListener('keydown', e => {
|
||||
if (e.key === 'Escape' && focus.parent) { setFocus(focus.parent); return; }
|
||||
const preset = { 1: 'oblique', 2: 'side', 3: 'top' }[e.key];
|
||||
if (preset) { animateTo({ ...VIEWS[preset], zoom: 1, pan: 0 }, preset); return; }
|
||||
const moves = { ArrowLeft: () => { cam.yaw -= .12; }, ArrowRight: () => { cam.yaw += .12; }, ArrowUp: () => { cam.tilt = clampTilt(cam.tilt + .06); }, ArrowDown: () => { cam.tilt = clampTilt(cam.tilt - .06); },
|
||||
PageUp: () => { cam.pan = clampPan(cam.pan + 20); }, PageDown: () => { cam.pan = clampPan(cam.pan - 20); }, '+': () => { cam.zoom = clampZoom(cam.zoom * 1.1); }, '=': () => { cam.zoom = clampZoom(cam.zoom * 1.1); }, '-': () => { cam.zoom = clampZoom(cam.zoom / 1.1); } };
|
||||
if (!moves[e.key]) return;
|
||||
e.preventDefault(); freeView(); moves[e.key](); syncZoom(); draw();
|
||||
});
|
||||
new ResizeObserver(() => { const r = canvas.getBoundingClientRect(); width = r.width; height = r.height; const dpr = Math.min(devicePixelRatio || 1, 2); canvas.width = Math.round(width * dpr); canvas.height = Math.round(height * dpr); ctx.setTransform(dpr, 0, 0, dpr, 0, 0); draw(); }).observe(canvas);
|
||||
// Read-only hook for layout checks.
|
||||
window.storyDemo = { cam, win, get turns() { return turns; }, get mode() { return mode; }, get focus() { return focus.name; },
|
||||
clustersOf: name => { const find = n => n.name === name ? n : (n.children ?? []).map(find).find(Boolean); const n = find(root); return n ? clusters(n.events).map(c => c.n) : []; },
|
||||
clusterHits: () => hits.filter(h => h.kind === 'cluster').map(h => ({ n: h.cluster.n, x: Math.round(h.x), y: Math.round(h.y) })),
|
||||
setWindow: (t0, t1) => zoomWindow(t0, t1), simulateSpan: hours => { const t = totalHours; return { hours, turnHours: autoTurnHours(hours), turns: hours / autoTurnHours(hours), total: t }; }, setFocusByName: name => { const find = n => n.name === name ? n : (n.children ?? []).map(find).find(Boolean); const n = find(root); if (n) setFocus(n); return !!n; } };
|
||||
syncViewButtons(); syncZoom();
|
||||
render();
|
||||
@@ -1,5 +1,9 @@
|
||||
# 故事线图谱(Story Graph)
|
||||
|
||||
下一版视觉方向见 [3D 故事线图谱概念](./3d-concept.md):记录变半径时间螺旋、长短不同的故事木片与跨故事智慧复用,以及概念图生成引用。该方案尚未实现,不替代下述现有演示。
|
||||
|
||||
[打开 3D 网页 Demo](./3d-demo.html):基于本机故事线图谱的只读导出(`3d-demo-data.js`),木片按项目 › 功能 › 子线索分层。默认用 WebGL 渲染(`3d-demo-webgl.js`,依赖仓库内的 `vendor/three.min.js`,Three.js r160,MIT 许可见 `vendor/three.LICENSE`),由 GPU 深度缓冲处理遮挡;浏览器不支持 WebGL 时自动改用 Canvas 投影版本(`3d-demo.js`)。直接打开 HTML 即可,不接入生产图谱,不是已上线的产品界面。
|
||||
|
||||
这是[监督者应用](../conversation-supervision/supervisor-prd.md)的核心视图。目标对象与交互由[产品设计](./prd.md)定义,用户验收见[User Stories](../conversation-supervision/user-stories.md)。时间事件、知识实体与阶段总结在目标设计中分别建模;下述演示仍使用阶段节点。
|
||||
|
||||
[打开交互演示](./demo.html)。这是监督者故事图谱的独立设计原型,使用模拟数据,尚未接入产品或笔记存储。
|
||||
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
The MIT License
|
||||
|
||||
Copyright © 2010-2023 three.js authors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
File diff suppressed because one or more lines are too long
@@ -178,7 +178,7 @@ GoodBuddy Desktop · 项目工作目录
|
||||
### 6.3 审批
|
||||
|
||||
- 使用普通会话的授权和提问流程。Agent Runtime 的 Execute 工具调用无需通用 GoodBuddy 审批,工具活动仍显示在会话中。
|
||||
- GoodBuddy 配置修改等原生确认保持各自的确认流程。需要用户回答的问题显示在关联会话中,不因定时触发直接判为失败。
|
||||
- GoodBuddy 配置修改遵循工作模式和通用工具授权,不另设原生确认;定时执行当前不授予配置工具访问权限。需要用户回答的问题显示在关联会话中,不因定时触发直接判为失败。
|
||||
- Ask 在 Runtime 边界保持只读;计划不增加另一层工具限制。
|
||||
- `outcome_unknown` 等高级执行治理尚未实现,见第 2 节的范围说明。
|
||||
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
# GoodBuddy 更新:故事图谱、Obsidian接入、Runtime原生终端
|
||||
|
||||
> **编辑标注说明(对外发布前删除):** 本稿汇总桌面版 0.13.8 至 0.15.1,以及此后截至 `66f77b8` 的已提交变化,更新于 2026-09-28。所述功能与修复均已开发完成并提交;0.15.1 之后的提交归属版本待确认。“待补图”表示缺少可发布素材,“待替换”表示发布信息未核验,“待核对”表示发布前需要确认的行为。本文是宣传草稿,尚未对外发布。
|
||||
> **编辑标注说明(对外发布前删除):** 本稿汇总桌面版 0.13.8 至 0.15.9,截至 `9527402` 的已提交变化,更新于 2026-10-03。所述功能与修复均已开发完成并提交;原故事线概念图仅作示意,待换为生产界面截图。“待补图”表示缺少可发布素材,“待替换”表示发布信息未核验,“待核对”表示发布前需要确认的行为。本文是宣传草稿,尚未对外发布。
|
||||
|
||||
GoodBuddy 这一轮更新围绕工作记录的整理与使用展开:监督者按项目回顾进展,并通过图谱保留结论依据;新加入的笔记连接支持保存聊天正文、返回原会话,以及在聊天中读写本机 Obsidian 笔记。本地项目还可以从输入区打开 Continue、OpenCode 原生终端或 DS Web 客户端,沿用当前项目与模型配置。
|
||||
GoodBuddy 这一轮更新围绕工作记录的整理与使用展开:监督者把分散的事件归入持续发展的故事,从中整理带有来源和适用条件的经验,供人和助手在后续工作中查阅。笔记连接支持保存聊天正文、返回原会话,以及在聊天中读写本机 Obsidian 笔记。本地项目还可以从输入区打开 Continue、OpenCode 原生终端或 DS Web 客户端,沿用当前项目与模型配置。
|
||||
|
||||
本文接续上一份画布笔记与文档处理更新稿,覆盖 0.13.8 起的实际增量。远程 Runtime 配置修复需要先升级至 Desktop 0.15.1,再将 Host 环境更新至 Agent 0.15.0。下文新增的远程故事图谱读取还需要包含对应改动的 Desktop 与 Agent,配套版本见发布前核验项。
|
||||
本文接续上一份画布笔记与文档处理更新稿,覆盖 0.13.8 起的实际增量。这次还加入了智能心跳的增量回顾与建议、笔记标签、会话图片保存,以及按会话、项目和全局分别选择 Runtime 与模型的方式,并继续改善长会话的浏览与输入。远程使用建议配套 Desktop 0.15.9 和 Agent 0.15.3,请先升级桌面端,再更新 Host 环境。
|
||||
|
||||
> **【待替换 R01|版本与体验入口】** 最新已核查桌面 tag 为 `v0.15.1`,对应 `4e52392`,承接未公开发布的 0.15.0;本轮增量为 `65a2139..66f77b8`。故事图谱读取、完整讨论正文编辑及笔记面板简化位于该 tag 之后,发布归属待确认;远程图谱读取需要同步包含 `66f77b8` 改动的 Desktop 与 Agent,不能标为 Agent 0.15.0 已有。对外发布前核验最终版本、公开安装包、发布日期、下载地址和完整更新日志链接,并在此补入。桌面 0.13.14 的发布失败后由 0.13.15 承接。
|
||||
> **【待替换 R01|版本与体验入口】** 最新已核查桌面 tag `v0.15.9` 与 Agent tag `agent-v0.15.3` 均指向 `9527402`。本轮检查 `b02ce31..9527402`,共 45 个提交;图片保存、笔记取消及默认开关调整已包含在桌面 0.15.8 中。对外发布前核验公开安装包、发布日期、下载地址和完整更新日志链接,并在此补入。桌面 0.13.14 的发布失败后由 0.13.15 承接。
|
||||
|
||||
**升级前,请先关闭应用并备份完整用户数据。** 数据库和相邻的 `notes/` 目录需要成套保留。本稿中的聊天采集功能将数据库格式升至 48,用于保存笔记来源;升级后旧客户端不能直接打开该数据库,回退需要升级前的备份。
|
||||
**升级前,请先关闭应用并备份完整用户数据。** 数据库和相邻的 `notes/` 目录需要成套保留;如有 WAL 文件,也应一并备份。当前助理数据库格式为 56,包含共享时间线、故事归属、经验及建议等更新;升级后旧客户端不能直接打开该数据库,回退需要升级前的备份。
|
||||
|
||||
## 新增功能
|
||||
|
||||
### 监督者:从工作记录中整理进展,查看每条结论的依据
|
||||
|
||||
项目讨论持续一段时间后,可以打开“监督者”的“工作回顾”,选择全局或一个项目,以及最近 1 天、7 天或 30 天的范围。回顾会整理工作总结、本次变化和未解决事项;历史结果保留各自的范围和生成时间,方便回看。
|
||||
项目讨论持续一段时间后,可以打开“监督者”的“工作回顾”,选择全局或一个项目,以及最近 1 天、7 天或 30 天的范围。回顾会整理工作总结、本次变化和未解决事项,并按故事列出这段时间的推进、新出现与结束的事项,以及形成或用上的经验。点击故事或经验,可以进入图谱查看依据;历史结果保留各自的范围和生成时间,方便回看。
|
||||
|
||||
监督者默认关闭,在应用中心启用后即可手动回顾,无需先建立自动计划。当前分析来源包括范围内的用户与助手消息、任务标题与当前状态,以及消息中已有的知识引用片段。它不会自动遍历全部笔记、整个知识库或附件原件。
|
||||
|
||||
@@ -24,19 +24,53 @@ GoodBuddy 这一轮更新围绕工作记录的整理与使用展开:监督者
|
||||
|
||||
回顾中的事件、实体与关系可以在“故事线图谱”中查看。选中一个对象,就能检查它的说明、关联和保存的来源正文,再判断模型的归纳是否准确。实体支持确认、改名或撤销,关系可以确认或移除。
|
||||
|
||||
这适合检查“某个结论来自哪段讨论”“一个事项与哪些事件有关”。图谱按事件顺序提供浏览,但还不能逐时刻还原当时的全部实体状态;图上的间距也不表示实际时间间隔。
|
||||
这适合检查“某个结论来自哪段讨论”“一个事项与哪些事件有关”。图谱提供平铺和时间螺旋两种浏览方式,时间螺旋按事件时间排列;平铺视图中的节点间距不表示实际时长。历史回顾中的故事分组使用当前保存的故事结构,尚不提供任意时刻的完整状态还原。
|
||||
|
||||
> **【待补图 02|必补|图谱与来源】** 使用图 01 同一份真实回顾结果,选中事件或实体,展示对应的来源正文。画面应能辨认对象与依据的关联;不使用设计原型代替生产结果。计划文件:`assets/02-story-sources.png`。
|
||||
>
|
||||
> 建议图注:从回顾图谱查看关联对象,并打开保存的讨论依据。
|
||||
|
||||
### 故事与时间螺旋:追踪一件事如何持续发展
|
||||
|
||||
一项功能往往经过多次讨论、试验和修订。回顾后,相关事件可以归入同一个故事,按项目、功能和子线索逐层浏览;后续有新进展时继续积累,不必每次从零查找。用户可以调整名称、合并重复故事、移动归错的事件,也能撤销调整;移除故事会保留其中的事件。
|
||||
|
||||
时间螺旋把这些故事放到连续时间上,便于查看不同工作如何交错推进。同一件事可以隔一段时间再继续,中间的停顿保留下来。螺旋高度对应时间,半径随消息活动量变化,不代表实际工时、效率或成果价值。
|
||||
|
||||
视图支持斜俯视、侧视、俯视和自由视角,可以逐层进入项目、功能与子线索,选中故事或事件查看详情。当前层可显示部分经验及其跨故事应用连线,完整经验列表仍在“经验”页签中查阅。设备无法使用 3D 时,可以切回平铺视图。
|
||||
|
||||

|
||||
|
||||
*早期概念示意,使用示例数据;不是当前生产界面的截图,实际布局与操作以应用为准。*
|
||||
|
||||
> **【待补图 09|必补|故事与经验的生产视图】** 原图保留作概念参考。发布前用目标版本的真实回顾结果替换,展示同一故事的事件、经验形成依据与后续应用,并核对平铺和时间螺旋中的选择联动。计划文件:`assets/09-story-experience.png`。
|
||||
>
|
||||
> 建议图注:沿故事查看工作进展,从经验节点核对形成依据与后续应用记录。
|
||||
|
||||
### 从故事中整理经验,保留适用条件和依据
|
||||
|
||||
故事积累到一定规模,并出现新的决定、变化或里程碑后,监督者可以从事件摘要中归纳经验,保存适用条件、边界和形成依据。自动归纳的内容有明确标记,用户可以编辑、合并或删除,再回到原讨论核对。
|
||||
|
||||
后续回顾还可以记录某条经验在哪些事件中得到应用。应用记录会核对事件是否晚于最早形成事件的起始时间,并与至少一个形成事件涉及共同实体;这些记录仍是模型对历史的归纳,需要结合来源判断,不能据此断定经验已经产生效果。助手可以查阅这些经验,为后续工作提供参考。
|
||||
|
||||
### 助手可以查阅故事图谱,接着已有决定继续工作
|
||||
|
||||
继续一个项目时,可以请助手“查一下此前选了哪个方案,依据是什么”,或“找出这项决定后来的修订”。助手可搜索监督者已经保存的事件、实体、关系和回顾结果,读取相关上下文,再查看来源片段,作为后续讨论的依据。
|
||||
继续一个项目时,可以请助手“查一下此前选了哪个方案,依据是什么”,或“找出这项决定后来的修订”。助手可搜索监督者已经保存的故事、经验、事件、实体、关系和回顾结果,读取相关上下文,再查看来源片段,作为后续讨论的依据。故事上下文带出相关事件,经验上下文带出形成和应用记录。
|
||||
|
||||
工具说明也会引导助手在继续旧工作、追问决定原因或遇到可能已有约定的任务时主动查阅,优先找故事与经验。是否调用仍由当前模型判断;普通闲聊和独立的通用问题不要求查询,也不会在每次对话中自动塞入整张图谱。
|
||||
|
||||
在应用中心启用监督者,并在 MCP 设置中保持 Story Graph 开启、分配给所用 Runtime 后即可使用。支持直连模型、DeepSeek Harness,以及本地和远程 OpenCode、Continue;Ask 与 Execute 均可只读查询。本地 Continue、OpenCode 原生终端也可读取。
|
||||
|
||||
查询默认使用当前项目,也可明确指定全局或项目集合;各范围读取各自保存的结果。工具只读取已有记录,不会为一次查询重新运行回顾或修改图谱;助手根据结果生成回答仍使用当前模型。保存的来源片段可以分页读取,但完整历史状态重建和跨范围事实自动复用不在本次功能范围内。
|
||||
查询默认使用当前项目,也可明确指定全局或项目集合。事件可从共享时间线中按项目查阅,回顾结果仍保留各自的范围。工具只读取已有记录,不会为一次查询重新运行回顾或修改图谱;助手根据结果生成回答仍使用当前模型。保存的来源片段可以分页读取,尚不支持任意时刻的完整历史状态重建。
|
||||
|
||||
### 智能心跳:按新增工作回顾,并提出可处理的建议
|
||||
|
||||
为项目设置每日或每周计划后,智能心跳会整理回顾窗口内新增、修改或尚待处理的记录,把变化保存到故事图谱。计划默认生成建议,也可以选择“仅更新记忆”;没有新来源时跳过事实提取,仍可补做尚未完成的故事与经验整理。没有待处理工作时,不调用模型。
|
||||
|
||||
建议围绕本次回顾发现的未决事项、分歧、修订和候选约定展开,并保留来源供核对。接受未决事项后,会创建一项暂停任务,方便先检查再开始;候选约定经确认后才进入长期记忆。建议生成失败可以单独重试,已经保存的图谱和回顾进度仍然保留。
|
||||
|
||||
心跳完成回顾后,还可以提示达到设定天数仍没有新进展的故事,或指出某条已有经验可能适用于当前故事。建议可以直接打开图谱;接受暂无进展建议会建立暂停任务,接受经验建议只标记已参考,不会据此写入经验应用记录。暂无进展默认按 14 天筛选;仅时间流逝到期、但本次回顾没有变化时,不会单独触发提醒。
|
||||
|
||||
计划需要应用保持运行,并按所选全局或项目范围处理。心跳不会在每条聊天消息后实时介入;有候选建议时会额外调用模型整理措辞,回顾本身也可能分批调用模型。
|
||||
|
||||
### 在任务中心跟踪当前会话或任务
|
||||
|
||||
@@ -56,7 +90,7 @@ GoodBuddy 这一轮更新围绕工作记录的整理与使用展开:监督者
|
||||
|
||||
工作栏“+ → 笔记”可以打开紧凑面板,在聊天旁搜索笔记标题与正文、浏览摘要、追加文字。切换项目或会话时草稿保留,关闭有未保存内容的面板前会提示;草稿仅在本次应用运行期间保留。画布、待办和完整编辑继续在魔法笔记工作区中使用。
|
||||
|
||||
快速笔记入口集中在工作栏和聊天采集操作中,移除了侧栏与顶栏的重复快捷按钮。面板精简重复标签,保存和来源操作统一靠右排列,输入校验与错误提示继续保留。
|
||||
快速笔记入口集中在工作栏和聊天采集操作中,移除了侧栏与顶栏的重复快捷按钮。面板精简重复标签,采集范围说明按需展开,标题或正文超限时在对应输入位置提示。保存和来源操作统一靠右排列,窄面板中的保存按钮也保持紧凑。
|
||||
|
||||
采集保存文本与 Markdown,不复制推理过程、工具日志或附件图片。生成中的回答暂不可采集,单条记录上限为 500 KiB,超限会提示缩减。采集本身无需调用模型;保存后自动评论仍按已有设置执行。笔记继续属于全局,来源项目只用于帮助回看。
|
||||
|
||||
@@ -64,6 +98,16 @@ GoodBuddy 这一轮更新围绕工作记录的整理与使用展开:监督者
|
||||
>
|
||||
> 建议图注:把有用的回答存入笔记,需要上下文时返回原会话。
|
||||
|
||||
### 笔记支持标签筛选,列表与正文并排浏览
|
||||
|
||||
给笔记加上“项目资料”“待整理”等标签后,可以按标签查找,也可以同时选择多个标签,筛出同时带有这些标签的笔记。每篇最多添加 10 个标签;标签支持改名、同名合并和删除,删除标签会保留笔记正文。
|
||||
|
||||
宽窗口打开笔记时,左侧保留列表,右侧阅读和编辑正文,两栏宽度可以拖动调整。切换笔记时保留当前工作区内的筛选与阅读位置,有未保存内容时会提示。窄窗口改为列表、详情切换;记录索引默认收起,“新建笔记”固定在页头。
|
||||
|
||||
正文使用当前栏的可用宽度,宽窗口下阅读画布和长内容有更多空间。新建文字或画布记录都可以取消,有草稿时先确认是否放弃;取消编辑、切换记录类型或返回聊天后,焦点回到对应的按钮或输入位置。
|
||||
|
||||
标签也可由支持魔法笔记工具的助手按你的要求添加或修改。快速采集面板继续用于保存聊天内容,标签管理在完整笔记工作区中进行。
|
||||
|
||||
### 在聊天中查找和整理 Obsidian 笔记
|
||||
|
||||
已有 Obsidian 笔记可以直接接入 GoodBuddy。在“设置 → 能力与工具 → MCP”的 Obsidian 卡片中,选择本机已注册的仓库,或限定一个文件夹,测试连接并保存,然后启用并分配给所用 Runtime。无需启动 Obsidian,也无需额外安装 Obsidian 插件或配置 REST 服务。
|
||||
@@ -88,6 +132,14 @@ GoodBuddy 这一轮更新围绕工作记录的整理与使用展开:监督者
|
||||
>
|
||||
> 建议图注:在工作栏打开原生终端,或在系统浏览器中使用 DS Web 的独立会话。
|
||||
|
||||
### Runtime 与模型可以分别跟随项目或临时切换
|
||||
|
||||
项目可以保留常用的执行方式与模型,某次会话需要换模型时,在输入区的两栏选择器中临时选择即可。菜单标明当前选择来自会话、项目还是全局,也可以恢复项目默认,减少每次新建会话重复配置。
|
||||
|
||||
执行方式与模型分别按“会话 → 项目 → 全局”解析,并检查模型是否适用于当前 Runtime。连接被删除或不兼容时按规则回退;缺少 API Key 时保留选择并提示配置,不会悄悄换用另一个模型。排队消息在实际发送时重新读取有效选择,定时任务则保留创建时确定的配置。
|
||||
|
||||
托管 SSH 项目支持 OpenCode、Continue;外部 OpenCode Server 使用服务端自身配置。
|
||||
|
||||
### 复制模型连接,保留已有配置
|
||||
|
||||
在“设置 → 模型连接”中打开一条 LLM 连接,点击“复制”,即可在副本中修改模型名称、地址或其他选项,再保存为新连接。协议、请求定制和认证配置随副本带入,已保存的 API Key 无需重新输入。
|
||||
@@ -104,12 +156,32 @@ GoodBuddy 这一轮更新围绕工作记录的整理与使用展开:监督者
|
||||
>
|
||||
> 建议图注:从 Mermaid 大图窗口导出完整 PNG,保留图中的边缘节点。
|
||||
|
||||
### 让助手把会话图片保存到指定文件
|
||||
|
||||
在 Execute 模式下,可以让助手把当前会话中已经上传或生成的图片保存到指定绝对路径,缺少的父目录会自动创建。远程项目保存到对应 Host,方便后续在项目文件中使用;已有同名文件默认保留,明确允许覆盖后才替换。
|
||||
|
||||
保存支持 PNG、JPEG 和原格式 WebP,可按扩展名转换为 PNG 或 JPEG,不提供转为 WebP。工具使用当前会话已有图片,不负责下载任意图片网址;Ask 模式不提供写入操作。远程保存自 Agent 0.15.2 起提供,本轮建议使用上述最新配套版本。
|
||||
|
||||
### 桌面通知可以单独关闭
|
||||
|
||||
“设置 → 平台功能 → 通用设置”新增“桌面通知”开关,默认开启。关闭后,应用处于后台时的任务完成、失败或取消等系统提醒停止弹出,应用内通知仍然保留。设置保存后立即生效,重启后也会记住选择。
|
||||
|
||||
### 设备共享目录:登记设备,发布能力与知识说明
|
||||
|
||||
在应用中心启用“设备共享”,连接另行运行的 ShareServer 后,可以登记本机、浏览设备和发布目录,并发布或撤销能力、知识的元数据,供使用同一服务的人查看有哪些资源。
|
||||
|
||||
此功能默认关闭,当前提供目录管理。发布“知识”记录不会上传或同步正文,搜索、读取和下载标志仅表示声明;尚不提供远程执行或知识正文访问。设备登记也不表示设备在线,当前服务没有身份认证,应在可信环境中使用。
|
||||
|
||||
## 功能改进
|
||||
|
||||
### 会话搜索按需展开,快捷键提示集中在输入框
|
||||
|
||||
侧栏的搜索入口与“新建对话”放在同一行,点击后展开并自动聚焦,可查找会话标题或消息正文。切换会话保留关键词;点击关闭按钮,或在搜索控件行内按 Escape,可清空并收起搜索。展开和收起时,会话列表位置保持不变。
|
||||
|
||||
输入框的空白提示集中显示发送、换行、粘贴、新建对话及可用的快捷唤起按键,并按系统显示 Ctrl 或 Command。修改快捷键设置后提示随之更新;快捷唤起已关闭或注册失败时,不再显示不可用的按键。
|
||||
|
||||
侧栏底部的“应用”和“设置”保留文字并分开显示,应用菜单箭头与向上展开方向一致。会话列表独立滚动,短窗口中的入口仍可到达,滚动条和分隔线样式也作了统一。
|
||||
|
||||
### 知识库文档与来源集中浏览
|
||||
|
||||
知识库将内容来源与文档索引合并为统一列表。单文档来源合成一行,目录继续按组展示,导入失败、尚未生成文档的来源也保留恢复入口。搜索可以同时查找来源与文档,网址来源保留原网址,便于辨认同名资料。
|
||||
@@ -118,19 +190,19 @@ GoodBuddy 这一轮更新围绕工作记录的整理与使用展开:监督者
|
||||
|
||||
任务历史改为按批次展示,可以展开处理步骤,查看进行中、失败与历史记录。筛选与计数跟随当前文档或来源,便于检查一次导入的处理情况。
|
||||
|
||||
### 图谱按事件、实体和关系切换
|
||||
### 图谱列表与画布各自浏览
|
||||
|
||||
故事线图谱侧栏将原来上下堆叠的列表改为“事件、实体、关系”页签,各自显示数量并独立滚动。切换页签保留已有选中对象,在画布选中对象时,侧栏会切到对应类别,查看关联信息时少一些来回滚动。
|
||||
故事线图谱侧栏可以按故事、经验、事件、实体和关系查看,列表与详情各自滚动。切换页签保留已有选中对象,在画布选中对象时,侧栏会切到对应类别。平铺与时间螺旋都使用中间画布的可用空间,视角等控件收进紧凑操作区,减少窗口较矮时内容被挤出屏幕的问题。
|
||||
|
||||
### 自动监督与手动回顾分开管理
|
||||
### 手动回顾与智能心跳共用增量进度
|
||||
|
||||
原有的每日、每周计划现在集中到“自动监督”。新建或编辑计划时,可以选择全局或多个项目,设置时间、时区、回顾窗口和历史保留期限;暂停的计划在编辑保存后仍保持暂停。
|
||||
|
||||
自动报告与监督回顾分别记录处理进度。对于对应阶段没有新增、修改或待处理内容的情况,会跳过模型调用;手动回顾则重新分析选择的时间范围。删除报告或计划不会重置已经记录的来源处理进度。
|
||||
手动“回顾”和心跳计划现在共用增量处理方式,默认只分析所选范围内尚待处理的变化。项目与全局共用来源处理进度:同一版本的来源已成功发布后,换一个范围回顾可以复用,减少重复提取。没有新来源时仍可补做积压的故事与经验整理,没有待处理工作才跳过模型调用。需要重看完整区间时,可从“更多 → 重新整理”确认发起;重新整理不会推进或重置增量进度。
|
||||
|
||||
启用监督者不会自动创建计划。定时工作需要桌面应用保持运行,关闭监督者会停止发起新的回顾,并保留计划和历史;从导航取消常驻只改变入口显示。尚无活动时,自动监督页会隐藏空的统计、建议和历史区域,先展示计划入口。
|
||||
|
||||
监督分析会将入选内容交给配置的模型服务,可能产生费用。它以只读、无工具的方式分析记录;自动报告与回顾可能分别调用模型,长时间范围也可能分成多批。并发数和超时设置不等于总费用上限。
|
||||
监督分析会将入选内容交给配置的模型服务,可能产生费用。它以只读、无工具的方式分析记录,长时间范围可能分成多批;心跳不再额外生成一份独立报告。并发数和超时设置不等于总费用上限。
|
||||
|
||||
### 长回顾可以看进度、保留批次并继续处理
|
||||
|
||||
@@ -152,25 +224,37 @@ GoodBuddy 这一轮更新围绕工作记录的整理与使用展开:监督者
|
||||
|
||||
魔法笔记的待办页可以拖动列表与详情之间的分隔条,调整后的宽度会保存。来源笔记标题支持独立折叠,收起一个分组不会清空正在查看的任务详情。分组折叠状态不跨重启保存,窄窗口下不提供横向拖宽。
|
||||
|
||||
笔记详情中的记录索引开关移到返回按钮旁,索引和 AI 评论开关都补上了文字标签。任务中心展开列表后,可以直接选择“全部、进行中、暂停、已结束”;默认显示进行中的任务,历史任务仍可通过切换状态查看。
|
||||
笔记详情的记录索引与 AI 区开关集中在正文顶部的固定工具栏,图标带有悬停提示。任务中心展开列表后,可以直接选择“全部、进行中、暂停、已结束”;默认显示进行中的任务,历史任务仍可通过切换状态查看。
|
||||
|
||||
> **【待补图 05|建议|待办布局】** 用虚构笔记创建不同来源的待办,拖动分隔条并折叠一个来源组,保持另一项详情可见。计划文件:`assets/05-note-todos.png`。
|
||||
>
|
||||
> 建议图注:调整待办列表宽度,按来源笔记收起暂时不看的任务。
|
||||
|
||||
运行记录中的 Token 用量先显示项目或会话总计,展开后再看 Runtime 和模型明细,按模型分组的视图继续保留。这里汇总的是应用已有的用量记录,并非服务商计费账单。
|
||||
运行记录中的 Token 用量先显示项目或会话总计,展开后再看 Runtime 和模型明细,按模型分组的视图继续保留。“系统任务”页签单列监督回顾、笔记、知识库等后台用量,并补齐回顾、图片重新生成、知识图谱提取和远程嵌入等调用的用量记录;过去未记录的用量无法补算。页面打开时加载,可见期间每 15 秒刷新,也可点击页头手动刷新。这里汇总的是应用记录,并非服务商计费账单。
|
||||
|
||||
### 回复期间减少重复读写和界面等待
|
||||
### 长对话输入、切换与流式回复减少重复处理
|
||||
|
||||
任务统计转到后台查询,隐藏的任务面板保留操作状态并减少不必要的更新。较长任务列表也减少了屏幕外卡片的绘制工作。
|
||||
|
||||
流式活动更新时,只保存变化的记录,并减少连续保存过程中反复读取整份历史的次数。改动针对工具活动较多、运行记录较长的对话,保留原有内容与顺序;实际响应改善取决于历史规模和设备,不以测试中的数据库指标代替整机提速幅度。
|
||||
|
||||
聊天输入和流式回复减少了无关活动面板的刷新;置顶或取消置顶会话时,也不再附带扫描附件引用。笔记启动检查复用已读取的正文信息,减少重复读取,原有内容恢复与清理流程保留。
|
||||
|
||||
长回答逐步生成时,已完成的 Markdown 区块可以复用,减少每次新增文字都重新处理整篇内容。切回近期看过的会话时也会复用缓存结果;会话侧栏与运行记录减少无关刷新,降低回复期间继续输入和切换会话的开销。
|
||||
|
||||
较长会话和大量会话的侧栏主要渲染屏幕附近的内容,历史记录仍然保留,可以继续加载和定位。切回缓存会话时恢复阅读位置;Markdown 解析结果也可复用,包含公式的长回复减少重复解析。
|
||||
|
||||
知识检索、故事图谱查询、会话搜索和会话列表等常用读取移到后台线程,减少查询期间对桌面主线程的占用。远程运行的连续事件按批保存,减少逐条写入的开销。改善幅度取决于设备、数据规模和内容复杂度,不作统一提速比例承诺。
|
||||
|
||||
### 设置页减少来回查找
|
||||
|
||||
长设置表单的分类标题与相关页签固定在顶部,请求 Header、Body 等低频选项收进高级设置。默认工作目录移到“平台功能 → 通用设置”,Skills 和 MCP 页面移除重复标题,保留导入与添加操作。
|
||||
|
||||
通用设置进一步按用途整理为快捷键、会话与通知、工作区与下载等区域,桌面通知、默认工作目录和模型下载源更容易找到。
|
||||
设置导航按用途分组,开关统一放在右侧。通用设置集中放置会话与通知、工作目录等选项;本地语音和嵌入模型页面提供“模型下载源”快捷入口,可打开统一弹窗,在 ModelScope 与 Hugging Face 之间切换。选择影响后续托管模型下载,已安装模型、ZIP 导入、Ollama 和应用更新不受影响。
|
||||
|
||||
外观设置新增透明磨砂效果,当前默认开启;标题栏和相关卡片使用半透明背景,可以看到其后的应用内容。可以关闭恢复实色显示,已有的外观选择继续保留。
|
||||
|
||||
内置浏览器 MCP 也改为默认启用,仍可在设置中关闭;之前明确保存的关闭状态会保留,不会修改系统默认浏览器。在 Execute 模式中,请助手通过配置工具应用本次请求的变更计划时,取消了额外一次原生确认;Ask 仍保持只读。
|
||||
|
||||
HTTP OCR 高级设置可以直接选择是否保留页眉、页脚、页码等内容,并恢复默认过滤方式。版面与生成参数的分组和间距也重新整理;具体选项效果仍取决于所连接的 OCR 服务。
|
||||
|
||||
@@ -178,35 +262,61 @@ HTTP OCR 高级设置可以直接选择是否保留页眉、页脚、页码等
|
||||
|
||||
## 问题修复
|
||||
|
||||
### 直连文本模型恢复手动压缩上下文
|
||||
|
||||
本地直连文本会话可以从输入区点击“压缩上下文”,即使关闭自动压缩或尚未达到触发阈值,也能手动执行。压缩使用配置的摘要连接,摘要随会话保存并用于后续对话,同时保留最近轮次和完整聊天历史;关闭自动压缩后,已有有效摘要仍可使用。
|
||||
|
||||
会话运行或正在压缩时需等待完成;没有可压缩的较早历史时会提示。这项修复适用于本地直连文本会话,不包含图像生成连接、远程通道会话或 SSH 项目。
|
||||
|
||||
### 长请求持续保留已授权的工具访问
|
||||
|
||||
修复桌面 Agent 请求持续超过十分钟后,工具授权提前失效的问题。请求仍在执行时,已启用并分配的知识库、笔记、浏览器等工具可继续使用;完成、取消或失败清理时仍会撤销该次授权。工具自身的调用超时、读写权限和开关设置保持不变。
|
||||
|
||||
### 取消与失败提示更清楚
|
||||
|
||||
取消和失败信息集中显示在消息状态中,减少与回答正文或工具详情的重复。主动取消使用中性提示,真实失败继续显示错误状态,已经生成的回答保留。
|
||||
|
||||
最后一条失败或取消消息有可恢复的原问题文字时,可以点击“重新编辑”将文字回填输入框,修改后自行发送;不会自动重发,也不承诺恢复附件。
|
||||
|
||||
取消或失败的轮次在后续上下文中明确标为中断,并保留已有回答,避免上一条未完成请求被并入新问题继续执行。OpenCode 复用会话在取消后的下一轮也会收到中断说明。
|
||||
|
||||
### 远程回答完成后,可以继续对话
|
||||
|
||||
远程请求现在等待模型交付确认后再结束。回答已经完成时,随后回收空闲 Runtime 失败不会再把结果改成“未知”。确实遇到结果未知的请求,用户显式发送新消息后会建立新的运行绑定并带入已有历史;自动恢复仍只重新连接原请求,不会擅自重复执行结果未知的任务。
|
||||
|
||||
最后一个活动任务结束后,远程空闲 Runtime 会被释放,下一轮对话再恢复历史。仍有任务或待答问题的共享进程会保留;更新后被替换的旧 Agent 会等待已有工作结束再退出,当前 Agent 保持常驻。
|
||||
|
||||
Host 更新还会清理已确认停用的旧 Agent 程序和归属明确的孤儿进程,保留会话历史、活动安装和状态不明的安装。这些完成状态与进程清理修复自 Agent 0.13.4 起提供;本轮建议配套 Agent 0.15.0,仅升级桌面不会直接替换 Host 上已经运行的旧进程。
|
||||
Host 更新还会清理已确认停用的旧 Agent 程序和归属明确的孤儿进程,保留会话历史、活动安装和状态不明的安装。这些完成状态与进程清理修复自 Agent 0.13.4 起提供;本轮建议配套 Agent 0.15.3,仅升级桌面不会直接替换 Host 上已经运行的旧进程。
|
||||
|
||||
### 启动、搜索与回顾结果
|
||||
|
||||
- 修复正常聊天或保存笔记后,重启反复显示历史迁移的问题。普通数据库结构升级也不再因正常空闲页额外整理整库;确需转换的旧数据仍会处理,启动页区分实际操作。
|
||||
- 修复本机直连模型文件搜索参数受限和大结果截断的问题。模型可使用 ripgrep 原生参数,保留原生结果与退出码,并分页续读长输出。Ask 保持工作区只读,Execute 使用当前账号权限;这项变更不属于远程 Agent 或其他 Runtime 的新增工具。
|
||||
- 修复早期监督回顾只取部分会话、消息和正文的问题,改为对入选来源分页、分批处理。事实与来源独立保存,只有必要阶段完成并成功保存结果后才标记完成。
|
||||
- 修复监督回顾中的旧实体引用、摘要合并校验及长摘要报错。非法格式、未知引用和超出响应容量的结果仍会报错,成功保存的批次可以保留供后续处理。
|
||||
- 修复监督回顾中的旧实体引用、摘要合并校验及长摘要报错。遇到无效 JSON、结构或引用时,会尝试缩小当前批次后重试,剩余正文留待后续处理;成功保存的批次保留。网络错误、超时、取消和超出响应容量不走这套重试。
|
||||
- 故事或经验整理失败时,已保存的回顾结果继续可用,可以单独重试整理。故事归类减少把各功能的提交与测试统归到“发布”的情况;经验应用记录也增加时间顺序与共同实体核对,归纳内容仍需结合来源判断。
|
||||
- 时间螺旋退出时释放图形资源;3D 初始化失败或图形上下文丢失时,在视图内提示并提供重试,也可切回平铺,避免整个监督页面无法使用。
|
||||
- 修复历史回顾入口跳到其他结果、切换后旧响应覆盖当前选择的问题。侧栏、活动记录和图谱按对应结果定位,人工确认与修订的内容保留。
|
||||
|
||||
### 日常界面操作
|
||||
|
||||
- 多个会话同时回复时,侧栏列表不再随流式文本或工具进度反复换位。会话按最新消息的创建时间排序,新消息仍会把会话向前移动,置顶会话继续优先显示。
|
||||
- 多个会话同时回复时,侧栏列表不再随流式文本或工具进度反复换位。会话按最新消息的创建时间排序,每五秒检查一次;悬停、滚动后的短暂停留、菜单展开或键盘焦点位于列表时暂缓重排,方便点选。置顶会话继续优先显示。
|
||||
- 输入框的附件、语音与选项按钮统一尺寸、间距和悬停反馈,录音中和选项展开时仍保留对应状态提示。
|
||||
- 草稿和排队消息中的图片可以直接打开预览,发送前更容易核对附件。文本输入框移除重复问候,只保留快捷键提示。
|
||||
- 展开 Runtime 检查清单不再挤动聊天内容;文件列表和差异页的操作栏在滚动时保持可见。
|
||||
- 笔记保存后退出编辑;从快速笔记跳到指定记录时,等待正文显示后再定位,减少落在错误位置的情况。
|
||||
- 切回已查看的会话时,立即显示缓存的累计回复时长,再后台刷新;缓存限本次应用运行期间。
|
||||
- Mermaid 大图放大后,顶部和左侧等边缘仍可通过滚动、拖动到达,窄窗口中的控件可以换行。
|
||||
- Git 分支选择器悬浮在工作区上方,不再把文件列表和提交历史向下挤动;项目选择菜单可以浮出侧栏,长名称与路径有更多显示空间。
|
||||
- 文件差异页将查看文件、刷新和复制路径集中到路径旁,窄面板中可以换行;只有未暂存改动时,省去重复的分区标题。浏览器“前往”按钮改为紧凑图标,保留悬停提示。
|
||||
- 聊天与浏览器、终端工作栏之间的分隔条恢复完整拖动区域;笔记缩略图与内容边缘对齐,键盘焦点轮廓保持可见。
|
||||
- 纯文本排队消息不再显示无效的“查看附件”按钮;浏览器页签关闭失败后重试成功,会清除残留错误。
|
||||
- 监督反馈显示会话或任务名称,固定与刷新按钮在窄侧栏中排列更紧凑。工作回顾和活动页使用可用宽度,运行阶段与已保存批次更醒目。
|
||||
|
||||
### 运行配置与界面修复
|
||||
|
||||
- OpenCode 原生终端减少重复准备步骤,缩短打开时的等待;临时 MCP 工具名缩短并在同一会话中尽量复用,减少工具名称过长导致的调用失败。
|
||||
- 本地 OpenCode 初始化工具连接时,遇到明确的暂时性传输故障,清理成功后最多重试一次,不重放问题或工具调用。迟到的旧连接结果不再替换当前连接;自定义 MCP 中一台服务加载工具失败时,其他正常服务仍可使用。
|
||||
- 修复 Continue 禁用自动更新后仍显示“Checking for updates”的问题,避免空闲终端显示误导性的更新状态。
|
||||
- 补齐 DS Web、远程 Runtime 和模型桥辅助进程启动时的遥测关闭配置;模型、MCP 和用户主动发起的联网请求保持可用。远程遥测配置与 Continue 更新检查修复由 Agent 0.15.0 配套提供,对更新 Host 后新启动的 Runtime 生效,已有进程不会热更新。
|
||||
- 修复知识库不同来源的操作相互阻塞,以及多文档来源中任务范围、筛选计数不一致的问题。
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.1 MiB |
@@ -1,8 +1,105 @@
|
||||
# 0.13.8 起综合宣传稿核查记录
|
||||
|
||||
## 2026-10-03 截至 9527402 的增量核查
|
||||
|
||||
本轮固定范围 `b02ce31..9527402`,共 45 个提交;全文范围为 `v0.13.7..9527402`。桌面 `v0.15.9` 与 Agent `agent-v0.15.3` 同指向 `9527402`。沿用用户确认的标题和综合稿结构,直接更新已授权草稿;未在线核验公开安装包和下载信息。以下记录取代旧节中的当前状态描述,旧节保留为历史依据。
|
||||
|
||||
| 提交 | 入文内容 | 核查依据与边界 |
|
||||
| --- | --- | --- |
|
||||
| `5dc6545`、`c3fa262` | 故事归类、共享时间线与时间螺旋,替换原未来方向节 | `supervision-stories.ts`、`supervision-timeline.ts`、`supervision-review-store.ts`、`story-graph-3d-model.ts`、`StoryGraph3D.tsx`;项目/功能/子线索,命名、合并、移动事件、移除和撤销。共享已成功发布来源版本的增量进度,时间与项目取自来源;螺旋半径为消息量,不是工时或成果。未归类事件不进入当前 3D 树;不宣传手动拆分、任意层级调整或完整时点回放 |
|
||||
| `983cdd4`、`4d1ce38`、`ee4eb73` | 经验归纳、形成与应用依据、螺旋外侧经验节点 | `supervision-experiences.ts`、`SupervisionStories.tsx`、3D 数据模型;自动归纳可读且有标记,可编辑、合并、删除。应用事件必须晚于最早形成事件的起始时间,并与至少一个形成事件共享实体,仍是回顾归纳,不是实时执行遥测。3D 每层最多展示 6 条满足跨故事应用条件的经验,完整内容在经验页签 |
|
||||
| `1f420cd` | 工作回顾按故事展示进展与经验,可点击查看 | `supervision-story-digest.ts`、`SupervisionStoryDigest.tsx`、`SupervisorWorkspace.tsx`;按所选结果的范围和时间筛选当前故事结构,不保存当时分类快照,不额外调用模型 |
|
||||
| `43be465`、`e97263c` | 心跳新增暂无进展与可用经验建议,助手按需搜索故事和经验 | `supervision-suggestions.ts`、`heartbeat-service.ts`、`story-graph-tools.ts`、`story-graph-reader.ts`;停滞默认 14 天,但 no_change 不发建议。接受停滞建议建暂停任务,经验建议仅标记参考。主动检索由模型判断,搜索为分词匹配,不能写成语义检索或每轮自动注入;无答案时模型仍可能多次检索 |
|
||||
| `0fd2f7e` | 无效模型输出缩小批次后重试,保留成功结果 | `supervisor-service.ts`;无效 JSON/结构/引用的叶子批次最多额外重试两次,减小文本预算,余文后续继续;导航合并同输入再问一次。网络/提供方错误、超时、取消、响应超容量不纳入。故事/经验整理在回顾发布后执行,失败不撤回回顾 |
|
||||
| `9d8477b`、`a1a1d24`、`50481de` | 图谱占满画布列、独立滚动、紧凑控件、3D 局部恢复 | `SupervisorWorkspace.tsx`、`StoryGraph3D.tsx`、监督样式;退出释放 WebGL,失败不扩大到整个页面,保留平铺选择 |
|
||||
| `dd29f30` | 保存当前会话上传或生成的图片到本机/Host 文件 | `image-generation-service.ts`、图片契约、`image-tool-mcp.ts`、MCP 设置;Execute、绝对路径、默认不覆盖;PNG/JPEG 转换,WebP 仅原格式保存。远程从 Agent 0.15.2 提供;不是任意 URL 下载,不宣传所有格式无损转换 |
|
||||
| `f4d8fe7` | 笔记新记录取消、焦点恢复、正文铺满栏宽 | 提交 diff 及已提交 `MagicNotesWorkspace.tsx`、App 与样式;不包含工作区未提交的笔记列表窗口化 |
|
||||
| `2e5c348`、`b1129ce`、`02a6f5c` | 浏览器与磨砂默认开启,Execute 配置变更减少重复确认 | capability/application settings、`goodbuddy-config-service.ts` 与网关;保留已有关闭选择,不改系统默认浏览器,不透出系统桌面。仅配置计划应用移除重复原生确认,Ask、请求绑定、有效期、单次应用与失败报告仍保留 |
|
||||
| `3d4e957`、`90ea33e`、`bf6c4a9`、`fdf7d83`、`663aa85`、`b6c7de5`、`775763e`,以及 `43be465`、`ee4eb73` 的聊天改动 | 长会话与侧栏可见区域渲染、Markdown 缓存、草稿/流式/会话更新分离、阅读位置恢复、交互时暂缓排序 | 最终 `ChatHistoryPane.tsx`、`ConversationSidebar.tsx`、会话/草稿/live store 与窗口化组件;单条超长消息仍整行处理,历史未删除。不把缓存与窗口化宣传成全程无卡顿。排序每五秒检查并在交互时暂停 |
|
||||
| `f571301`、`3c31f0b`、`d859cbf` | 常用数据库读取后台化、活动记录差异处理、远程事件批量保存 | 只读 worker、活动记录差异计划、remote event batcher 与 IPC;向量查询仍可能全表扫描,worker 失败/繁忙可同步回退。Renderer 活动记录仍发送完整快照,不宣称全链路增量 IPC |
|
||||
| `09c415b`、`855b941` | 过渡性能实现,不叠加宣传 | 新打开会话先显示少量消息再补齐的方案已被最终窗口化替代 |
|
||||
| `80579ee`、`1e10935`、`dc635d1`、`7aad787`、`169941c`、`82107ad`、`baa6def`、`53b9ecb`、`6ced760`、`0ce9606` | 性能基准、归因更正、架构/测试维护,仅内部依据 | 受控设备与假模型测量不能推为普遍收益;部分测量含同期改动,收尾波动也不作为显著提速。不把本地阈值检查写成性能 CI 已全面接通 |
|
||||
| `5f1ff24`、`d773f5e`、`5368e0e`、`8280158`、`20317c2`、`9527402` | 小样式、运行环境、测试与发布维护 | 滚动按钮边框不单列卖点;Electron 44.5.1 与剪贴板适配不写成新增功能。Desktop 0.15.8/Agent 0.15.2 与本轮版本分开,助理 schema 52→56,知识库 schema 仍为 13 |
|
||||
|
||||
新增主线按使用过程展开:事件归为故事,故事形成经验,人通过回顾和图谱查看,助手按需查阅,心跳提出待处理建议。原稿“下一版”改为已实现能力,但原 `assets/09-next-storyline-wisdom.png` 仍是早期模拟概念图,已改图注并增加生产替换标注 09,计划文件 `assets/09-story-experience.png`。不将概念稿中的切面、展开、动画等全部写成生产功能。旧图 01、02、04 也需采用最新故事/经验布局;本轮没有新增截图或空图片链接。
|
||||
|
||||
同步修正旧稿中的按范围独立进度、无新来源必然零模型调用、磨砂默认关闭、即时会话前移及 Agent 0.15.1 配套等描述。升级提示更新为助理数据库 56,备份数据库、WAL 与相邻 `notes/`;旧客户端不能直接打开升级数据库。
|
||||
|
||||
只读核查发现 `supervision-production.ts` 的 `existingGraph` 回调未转发 `batch`,因此不能承诺关闭跨项目关联后,全局回顾的实体候选已按批次项目隔离;正文未作该承诺,本轮不修改产品代码。故事归属阶段自身的跨项目开关与此不同。
|
||||
|
||||
工作区另有文档解析 worker、终端输出合批、笔记/文件/知识列表窗口化、启动测量及架构文档改动,均未纳入已提交增量,也未编辑。故事图谱历史检索未找到相关宣传记录,本轮依据当前文档与固定 Git 区间核查。
|
||||
|
||||
本轮只执行静态核查和文档检查,产品测试、构建、真实模型请求与新截图均为 0。历史性能和 Host 验证仅作参考,不宣称本轮完成跨平台实机或正式包验收;公开下载信息继续待核验。打包、构建及测试维护未写入宣传正文。
|
||||
|
||||
独立事实复核修正两处:移除旧稿笼统的“跨范围事实自动复用不支持”,区分共享事件时间线与按范围保存的回顾结果;经验应用的时间条件明确为晚于最早形成事件的起始时间,不扩大为晚于全部形成依据。文字扫描无阻断项,人工复核保留版本、素材、升级和能力范围的必要限定;唯一现有图片相对链接目标存在,待补图 09 只登记计划路径。文档差异检查通过,未提交、推送或对外发布。
|
||||
|
||||
## 2026-10-02 截至 b02ce31 的增量核查
|
||||
|
||||
当前依据为 `86347af..b02ce31`,共 36 个提交;全文范围为 `v0.13.7..b02ce31`。桌面 `v0.15.7` 指向 `b02ce31`,Agent `agent-v0.15.1` 指向 `a662b16`。本轮未在线核验公开安装包。沿用用户确认的标题和综合稿结构,继续更新已授权的草稿。
|
||||
|
||||
| 提交 | 入文内容 | 核查依据与边界 |
|
||||
| --- | --- | --- |
|
||||
| `6293ef1` | 新增智能心跳建议,替换旧自动报告与手动全量回顾说明 | 心跳服务、建议规则、监督进度文档;手动与计划共用增量机制,全局与项目各自记录进度,无变化不调模型。候选建议另调模型整理措辞,接受未决事项创建暂停任务,候选约定需确认;重新整理不改增量进度 |
|
||||
| `9c88ca2`、`a662b16` | Runtime 与模型分层选择 | 实际解析器、RuntimeModelPicker 与分层设计;会话、项目、全局分别解析并检查兼容性。排队时重新解析、定时任务冻结选择;缺少 Key 不静默换模型。不采用设计文档中与最终代码不一致的“只继承相同执行方式层”表述 |
|
||||
| `6edfc00`、`0a02a17`、`bf64181`、`3be5534`、`2d8e58e` | 笔记标签、多标签筛选、双栏及操作修复 | 标签契约与 MagicNotesWorkspace;多标签 AND、每篇最多 10 个、删除标签保留正文,宽屏双栏可调,窄屏切换。快速面板不管理标签;保存退出编辑、等待内容挂载后定位、页头保留新建入口 |
|
||||
| `a3b41bf`、`c8f327f` | 设备共享目录短节 | 设备共享服务、页面、应用设置;最终默认关闭,需另行运行 ShareServer,仅元数据管理。未认证、无正文上传/读取/下载或远程执行,登记不代表在线,不把声明权限当作已实现能力 |
|
||||
| `9a91704`、`c8f327f` | 系统任务用量、手动与定时刷新 | ActivityPanel、数据库与用量接线;系统任务单列,补记原有遗漏。最终为可见期间 15 秒刷新,不沿用早期按用量事件即时刷新的描述 |
|
||||
| `c23dca6`、`c8f327f` | 长对话输入、缓存切换与流式性能 | App、MarkdownRenderer、会话派生值缓存;减少重复解析和无关更新,不宣传固定倍数或全设备收益 |
|
||||
| `f7f501a`、`e4f7b8d`、`c8f327f` | 可选磨砂、设置分组、下载源快捷入口 | 外观设置、ModelDownloadSourceSettings、语音/嵌入设置及文案;默认关闭,磨砂为应用内内容。下载源入口切换至模型页并打开统一弹窗,影响后续托管下载,既有模型和应用更新不受影响 |
|
||||
| `608a9a5` | 中断轮次与下一请求区分 | runtime-history、OpenCode 与手动压缩共用历史;保留部分回答及中断标记,避免未完成请求并入下一轮,不扩写为远程取消执行机制修复 |
|
||||
| `7fb23d9`、`80edd15`、`f7f501a`、`9cc5dc1`、`c8f327f` | 原生终端启动、MCP 工具连接修复 | native terminal、OpenCode 生命周期与工具网关提交 diff;缩短并尽量复用工具名,本地 OpenCode 明确暂时性传输故障清理成功后最多重试一次,隔离迟到连接,故障自定义服务不阻塞正常服务。不宣称远端 Agent 同步获得所有本地恢复修复 |
|
||||
| `b6d24ad`、`ab85e72`、`d30f794`、`5e77118` | 图片预览、固定工具栏、清单不挤动正文、输入提示精简 | 草稿/队列图片直接预览,工作区操作栏滚动可见,最终文本输入框保留快捷键提示 |
|
||||
| `3c9229a`、`ed47e2c`、`925d410`、`89fc100`、`cc451f6`、`2c5300d`、`227507d` | 版本依据与发布维护 | 不把说明文件新增当作新功能;数据库按最终代码核对为 52,迁移 49/50/51/52 分别涉及选择层、心跳建议、笔记标签与统计索引 |
|
||||
| `d1b92de`、`911eed5`、`8201c9c`、`d2ceff8`、`016f987`、`7cb74cc`、`b02ce31` | 文档与测试维护 | 仅留核查记录,不进入宣传正文 |
|
||||
|
||||
重点按新的使用方式排序:心跳建议、笔记整理和 Runtime 选择展开介绍;设备共享因范围有限仅作短节。外观、用量和性能归入改进,终止状态与连接故障归入修复。打包、测试与发布维护不作为宣传内容。
|
||||
|
||||
保留工作区已有“下一版故事线”预览及素材引用,不将 3D Demo 当作生产能力。本轮未纳入未提交生图等源码变化,也未改动这些文件。已有预览素材的拍摄说明沿用上一节记录,不声称本轮重新拍摄或核验图片。
|
||||
|
||||
本轮验证为提交 diff、终点源码与文档的静态核对;没有运行产品测试、构建、模型请求或截图。原生产配图缺口继续保留。新增内容可优先补拍心跳建议、笔记标签与双栏、Runtime 两栏选择器,尚未取得素材,不新增空图片链接。
|
||||
|
||||
独立事实复核后修正下载源跳转、MCP 重试范围与工具名复用、旧 Agent 建议、笔记工具栏位置和用量无法补算历史的说明。deai-writing 扫描无阻断项,人工复核保留必要的版本、升级、素材与支持范围限定。未提交、推送或对外发布。
|
||||
|
||||
## 2026-10-01 下一版故事线配图
|
||||
|
||||
按用户要求在最新未发布稿中增加“下一版故事线”预览,不改变原已实现功能的核查基线。此节为未来版本方向,不能纳入开头“均已开发完成并提交”的范围。
|
||||
|
||||
- 图片:`assets/09-next-storyline-wisdom.png`,2804 × 1999 像素。
|
||||
- 来源:`docs/features/story-graph/3d-demo.html` 的实际 Electron 渲染;选择页面中的 W 卡片,显示归纳经验、依据、适用条件、边界、应用和验证状态。
|
||||
- 环境:Windows、中文、浅色、页面缩放 80%;实际 CSS 视口 2002 × 1427。独立临时 Electron profile,不读取产品数据库,不调用模型。
|
||||
- 驱动:临时目录 `opencode/capture-story-next.cjs`;截图没有修改 DOM、拼接或裁切。已打开图片检查,完整内容与操作按钮可见,无私人会话或凭据。
|
||||
- 真实性:独立网页使用模拟故事与经验,不代表生产图谱已经接入 3D 或自动归纳 W。正文以“下一版”“将”说明时态,图注保留示例数据说明,不使用概念原型作为宣传标题。
|
||||
- 原图 01、02、04 等生产功能配图缺口仍保留,本图不替代生产结果验收。本轮没有重新核查全部版本变更,未提交或发布。
|
||||
|
||||
## 2026-09-28 截至 86347af 的增量核查
|
||||
|
||||
本节保留上一轮依据,当前范围以文首最新记录为准。上次内容截止 `66f77b8`,稿件提交为 `0cdf042`;本轮固定检查 `0cdf042..86347af0b1cc8726c1166b3f9fa3d7f12a0cc5e3`,共 17 个新增提交。全文范围为 `v0.13.7..86347af`,开始检查时工作区干净。沿用用户确认的标题及综合稿结构,打包类维护不进入正文。
|
||||
|
||||
桌面 `v0.15.2` 和 `agent-v0.15.0` 同指向 `47ba54f`。已提交双语说明确认远程故事图谱配套 Desktop 0.15.2/Agent 0.15.0,正文据此替换上一轮“Agent 0.15.0 尚未包含”的旧说明;其余本轮产品修复均在该 tag 之后,已完成但发布归属待确认。本轮未在线核验公开安装包或下载链接。
|
||||
|
||||
| 提交 | 内容与入文处理 | 实现依据与边界 |
|
||||
| --- | --- | --- |
|
||||
| `4baf4ec` | 修复节重点补入直连文本手动压缩 | `App.tsx`、`ipc.ts`、`model-runtime.ts`;手动跳过自动开关和阈值,保存摘要供后续使用,保留最近轮次和完整历史。仅本地直连文本,排除图像、远程通道和 SSH 项目;运行中禁用,无较早历史会提示 |
|
||||
| `1603871` | 修复节补入长请求工具授权 | `knowledge-mcp-gateway.ts`、`native-client-coordinator.ts`;移除桌面网关固定十分钟有效期,保留令牌校验和请求结束、取消、失败清理撤销。原生客户端独立授权随客户端生命周期释放;未移除工具调用超时或扩大权限,没有修改远端 gbagent 实现 |
|
||||
| `58e6808` | 修复节补入取消、失败和重新编辑 | `App.tsx`、`ChatTimeline.tsx`、`message-terminal-status.ts`、数据库元数据;保留正文,状态提示去重,取消中性显示,历史恢复保留终态。仅最后一条失败/取消且有文字可恢复时显示重新编辑;不自动重发,不扩大为远程取消执行机制修复 |
|
||||
| `13ff182` | 合入已有性能改进节 | 活动面板、消息更新、附件引用及 `MagicNoteStorage`;减少无关渲染、无变化保存、置顶附件扫描及启动正文重复读取。保留恢复和清理,不宣传整体渲染提速或冷启动倍数 |
|
||||
| `86347af`、`120076a` | 改进节介绍最终搜索交互及快捷键提示 | `App.tsx`、`styles.css`、中英文文案;以最终实现为准,搜索与新建同一行,按需展开,失焦或点会话保留词,关闭/Escape 清空,列表不跳动。输入框按系统显示快捷键,唤起键仅在可用时显示,保存设置即更新;不沿用中间提交的常驻搜索框描述 |
|
||||
| `393a07c` | 合入聊天采集章节 | `MagicNotesPanel.tsx` 与样式;范围帮助按需查看,标题/正文超限贴近对应字段,操作靠右,窄面板按钮不拉伸。没有更改保存范围或容量 |
|
||||
| `c11f204`、`8650907`、`10f3b4f`、`50e8fde` | 合并简述侧栏浏览与应用/设置入口 | 细滚动条、等宽带标签入口、菜单箭头、分隔线与间距;触摸和强制高对比保留系统滚动条,不将视觉调整拆成多个新功能 |
|
||||
| `1c6264d`、`d8f0ef1` | 日常操作简述 | `WorkspaceFilesPanel.tsx` 将操作与路径归组,只有未暂存时省略标题;浏览器前往改图标,保留提示与可访问名称 |
|
||||
| `594222a` | 按用户要求仅留内部记录 | 桌面包依赖和非运行文件精简;不进入宣传正文,也不写成运行速度提升 |
|
||||
| `be23c13` | 仅记录需求规划 | Office 同会话编辑及验收要求,无对应完整实现,不宣称已支持 Word、Excel、PowerPoint 实时编辑 |
|
||||
| `76e781a`、`47ba54f` | 更新版本依据,测试维护不单独宣传 | 桌面/Agent 双语说明、MCP 目录测试;发布资料中的 Host/cloud 方案仍为规划。`0cdf042` 本身为上一稿提交,不重复计入新功能 |
|
||||
|
||||
排序以用户能继续工作为依据:手动压缩、工具访问与终止状态在修复段展开;搜索与快捷键归入操作改进;笔记、性能及小控件合入原有内容。没有改变用户指定标题,也没有增加开发状态标签。
|
||||
|
||||
本轮只读核查 Git diff、生产实现及已提交文档。历史测试记录只作证据:手动压缩提交记录 13 项定向与 Electron 本地 HTTP 测试,授权提交记录 34 项定向,性能提交记录 5397 通过、84 跳过;均非本轮执行结果,不据此声称当前提交全平台验收通过。本轮产品测试、构建、截图与模型请求均为 0,原配图缺口保留。
|
||||
|
||||
交付检查:deai-writing 扫描阻断项 0、复核项 14、读取错误 0;逐项核对后保留版本、素材、升级和功能范围限定,以及实际设置分组名称。独立只读事实复核无发现,`git diff --check -- docs/pr-articles` 通过,仅有既有行尾提示;未新增图片或链接。结束检查时 HEAD 仍为 `86347af`。核查期间其他任务新增了功能矩阵、package 与发布说明改动,未修改或纳入本轮依据;本轮仅编辑正文、核查记录及索引,未提交、推送或对外发布。
|
||||
|
||||
## 2026-09-28 故事图谱读取与讨论编辑增量核查
|
||||
|
||||
本节为最新依据,下方记录保留各轮历史状态。本轮固定范围 `65a2139..66f77b83c2b6ebe0018a6c5c2bc72e1b4c1e43b1`,共 5 个提交;全文范围为 `v0.13.7..66f77b8`。保留工作区已有宣传稿修改,仅追加本轮变化,未纳入其他未提交功能文档。
|
||||
本节保留上一轮依据,当前版本归属以文首最新记录为准。本轮固定范围 `65a2139..66f77b83c2b6ebe0018a6c5c2bc72e1b4c1e43b1`,共 5 个提交;全文范围为 `v0.13.7..66f77b8`。保留工作区已有宣传稿修改,仅追加本轮变化,未纳入其他未提交功能文档。
|
||||
|
||||
| 提交 | 内容与入文处理 | 依据与边界 |
|
||||
| --- | --- | --- |
|
||||
|
||||
@@ -386,4 +386,4 @@ Agent 专题使用对应的 `agent-v…` tag。
|
||||
| 日期 | 目标 tag | 标题 | 状态 | 正文 | 发布链接 |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| 2026-09-19 | 未确定(unreleased) | GoodBuddy 更新预览:画布笔记、文档处理与应用管理 | 草稿,截图待补 | [综合更新稿](./2026-09-19-unreleased/article.md) | 未发布 |
|
||||
| 2026-09-26(09-28 更新) | v0.15.1(4e52392)及后续至 66f77b8;汇总 v0.13.8 起的变化,后续提交发布归属待确认 | GoodBuddy 更新:故事图谱、Obsidian接入、Runtime原生终端 | 草稿,已补故事图谱读取与完整讨论正文编辑;配图与公开发布信息待核验 | [综合更新稿](./2026-09-26-unreleased/article.md) | 未发布 |
|
||||
| 2026-09-26(10-03 更新) | v0.15.9(9527402);汇总 v0.13.8 起的变化 | GoodBuddy 更新:故事图谱、Obsidian接入、Runtime原生终端 | 草稿,已补故事归类、时间螺旋、经验与心跳建议、助手按需检索、图片保存和长会话性能;原概念图待换为生产截图,公开发布信息待核验 | [综合更新稿](./2026-09-26-unreleased/article.md) | 未发布 |
|
||||
|
||||
@@ -57,6 +57,18 @@ AI 列 280。测试同步原生输入与实际状态后独立复跑通过,保
|
||||
`activeElement.textContent` 包含消息文字判断导航完成,`body` 也会满足,随后真正的
|
||||
消息聚焦会关闭刚打开的菜单。应同时断言焦点元素是消息 `article`,不只匹配后代文本。
|
||||
原生 Runtime 控件用例须等当前项目的能力请求及启动渲染完成,再打开失焦关闭的选项菜单。
|
||||
2026-09-29 的 0.15.4 候选 CI 中,问答轮次用例仅等“发送”按钮存在便点击;
|
||||
Runtime 状态尚未匹配当前选择时按钮仍禁用,点击不会发出请求。发送前应以现有
|
||||
`waitFor` 等待按钮可用,而不是将按钮存在等同于 Runtime 就绪。保留问答保存、
|
||||
跳过与重载的全部断言,不延长全局超时。
|
||||
2026-09-30 发布验证中,连续新建会话用例只等 Runtime mock 收到请求,尚未等
|
||||
已用会话渲染完成就触发新建。沿用 `waitFor` 等待已发送用户消息可见,再保持
|
||||
两次同批新建及最终数量断言;mock 调用不代表界面状态已提交。
|
||||
同日 CI 再现快速笔记打开完整记录后未滚动:数据请求完成后的动画帧仍可能早于
|
||||
React 提交目标 DOM。定位操作应在目标记录提交后的布局 Effect 中执行,
|
||||
而不是在请求 Promise 完成时猜测渲染时机;立即执行动画帧的回归可稳定暴露旧问题。
|
||||
2026-10-01 磨砂外观 CI 在主题切换后读到窗口按钮颜色过渡的中间值。比较稳定样式前,
|
||||
等待当前 CSS Transition 的 `finished`,不要固定睡眠、删除颜色断言或等待无限循环动画。
|
||||
|
||||
## 整窗遮罩与原生拖动区域
|
||||
|
||||
@@ -283,6 +295,14 @@ Modal,兼顾 top layer 绘制、辅助技术的模态归属及焦点循环;
|
||||
**注意**:React 允许在渲染期用守卫调用 `setState` 来响应 props 变化,这是官方推荐做法,不要
|
||||
把它改写成 `useEffect`。仓库 lint 规则 `react-hooks/set-state-in-effect` 会拒绝后者。
|
||||
|
||||
2026-09-30 的 App 回归还确认两类关联开销:内联 JSX、回调和空数组使 memo 失效;
|
||||
`useRef(hasConversationMigrationStorage())` 在每次输入时执行存储读取。初始化读取应延迟到
|
||||
首次初始化,缓存面板的 props 应保持稳定。即时用户动作可在调用时读取当前会话;异步
|
||||
草稿恢复、语音和附件操作须保留发起会话,不能统一改成完成时读取活动会话。
|
||||
App 集成回归应计数 memo 边界内的真实渲染,覆盖输入、热切换和后续流式更新;仅测独立
|
||||
消息组件无法发现父组件传入的不稳定 props。测量结果见
|
||||
[PERF-10](../roadmap/product-performance-experience-improvement-plan.md#perf-10-keep-alive-轻量优化)。
|
||||
|
||||
## 解析警告被当作提取正文
|
||||
|
||||
**症状**:OCR 没有识别文字,但缺图警告让图片附件切成文字发送,或让仅图片结果被判为
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
# 桌面端性能改造(第一期,参照 VS Code 架构)结项报告
|
||||
|
||||
- **时间:** 2026-10-02 至 2026-10-04
|
||||
- **范围:** Desktop 的 Electron Main、Preload、Renderer 及其后台线程。远程主机(Agent、SSH、远程 Runtime)的稳定性和性能不在本期范围,下一期单独审查。
|
||||
- **结论:** 本期计划内的工作已完成。用户日常能感受到的卡顿(打字、流式输出、切换会话、滚动、启动、写库)都已测量并修复。剩下的工作是架构上的长期项(存储接口异步化、独立数据进程),风险高,已经写明理由,留到后续单独立项。
|
||||
- **相关文档:**
|
||||
- 性能原则和评审清单:`docs/architecture/performance-principles.md`
|
||||
- 逐项测量记录:`docs/roadmap/product-performance-experience-improvement-plan.md` 第 7 节(PERF-11 至 PERF-17)
|
||||
|
||||
## 1. 结果
|
||||
|
||||
### 1.1 模拟低端机(Renderer CPU 降速 4 倍)
|
||||
|
||||
| 场景 | 改造前 | 改造后 |
|
||||
| --- | --- | --- |
|
||||
| 有 300 个会话时打字,按键延迟 p95 | 最高 4 秒 | 约 10–35 ms |
|
||||
| 流式输出时打字,按键延迟 p95 | 最高 9.6 秒 | 约 30–60 ms |
|
||||
| 流式输出期间卡住界面的总时长 | 16–21 秒 | 约 0.3–3 秒 |
|
||||
| 切回刚看过的会话,点击延迟 p95 | 约 530 ms | 约 110–140 ms |
|
||||
| 打开 2,000 条消息的会话 | 约 1.1–1.8 秒 | 约 0.27 秒 |
|
||||
|
||||
### 1.2 标准机
|
||||
- `npm run perf:check` 的 60 项阈值全部通过。
|
||||
- 流式输出、流式输出时打字,超过 50 ms 的长任务均为 0。
|
||||
- 改造前,流式输出 40 KB 回答时约 47% 掉帧,帧间隔 p95 83 ms。
|
||||
|
||||
### 1.3 Main 进程
|
||||
Main 卡顿的数据来自真实数据,以及 5 万个文本块的知识库。
|
||||
|
||||
| 场景 | 改造前 | 改造后 |
|
||||
| --- | --- | --- |
|
||||
| 5 万个文本块的知识库混合检索 | 490 ms | 19 ms(移到只读 worker) |
|
||||
| 读取会话列表 | 25 ms | 0.8 ms |
|
||||
| 活动记录:启动读取 | 14.2 MB,198 ms | 114 KB,0.6 ms |
|
||||
| 活动记录:保存一条变化 | 114 ms | 6.9 ms |
|
||||
| 导入大 PDF | 0.5–0.7 s | 约 16 ms(移到解析 worker) |
|
||||
| 单次写库(真实数据) | 2.2–2.6 ms | 0.1–0.3 ms |
|
||||
| 写入期间偶发卡顿(真实数据) | 最长 346 ms,超过 50 ms 共 37 次 | 最长 34 ms,0 次 |
|
||||
|
||||
### 1.4 启动
|
||||
真实数据:2.4 GB 数据库,175 万条任务事件。
|
||||
|
||||
| 指标 | 改造前 | 改造后 |
|
||||
| --- | --- | --- |
|
||||
| 启动到输入框可用 | 3.70 s | 1.36 s |
|
||||
| 侧栏出现会话 | 4.08 s | 1.75 s |
|
||||
| 启动期间 Main 最长阻塞 | 2.5–2.8 s | 约 0.25 s |
|
||||
| 空配置启动 | 1,044 ms | 962 ms |
|
||||
|
||||
## 2. 做了什么
|
||||
|
||||
按性能原则 P1–P10 归类,提交号见计划文档。
|
||||
|
||||
| 原则 | 改动 |
|
||||
| --- | --- |
|
||||
| P1 Main 只做调度 | 知识检索、Story Graph、会话列表、会话搜索、活动记录分页和汇总放到只读 worker;文档解析、分块、哈希放到解析 worker;WAL checkpoint 放到后台线程;远程事件按检查点批量写库;高频本地写入使用 `synchronous = NORMAL`;启动恢复改为按索引读取(schema 58) |
|
||||
| P2 状态局部订阅 | 流式缓冲、输入草稿、会话、任务和产物、活动记录、弹出层都改为独立 store;拆出 Composer、侧栏和隐藏路由并加 memo;切换会话只渲染一遍;`App.tsx` 从 11,246 行降到约 7,900 行,lint 设了上限 |
|
||||
| P3 高频数据按帧合并 | 流式文本按帧合并;终端输出在 Main 端按 8 ms 合并,界面每帧最多写一次;自动滚动每帧最多一次 |
|
||||
| P4 只传变化部分 | 活动记录改为增量保存和分页,统计由 Main 汇总,覆盖全部记录;Markdown 只重新解析变化的段落,并缓存解析结果 |
|
||||
| P5 工作量和可见内容成正比 | 消息时间线、会话侧栏、笔记、文件树、知识库文档表格都只渲染可见部分 |
|
||||
| P6 启动只做首屏需要的工作 | 本地工具环境改为后台初始化;Canvas、终端、SSH、OpenCode SDK 等模块改为用到时再加载;启动恢复改为按索引读取 |
|
||||
| P7 先测量,再修改 | 新增 `perf:app`(支持模拟低端机、内存、CPU 剖析)、`perf:main`、`perf:knowledge`、`perf:document-parse`、`perf:startup`(含 `real` 真实数据变体)、`perf:write`(真实数据回放) |
|
||||
| P8 正确性优先于速度 | 分段渲染与整篇渲染对照测试,含随机语料;活动记录与改造前的整份列表逻辑逐步对照;数据库写入顺序和持久性边界有专门测试 |
|
||||
| P9 用工具守住规则 | lint 规则:主进程禁止同步文件 API、限制 `DatabaseSync` 的使用范围、组件不得直接订阅 IPC、`App.tsx` 行数上限;`perf:check` 阈值 |
|
||||
| P10 体验不倒退 | 修复了 8 处体验倒退:回到底部的平滑动画、滚出可见区后丢失状态、跨消息选择文本、侧栏插入时跳动、终端回显、远程流式输出一顿一顿、复制时缺内容、锚定冲突 |
|
||||
|
||||
## 3. 有意不做的部分
|
||||
|
||||
| 项目 | 理由 |
|
||||
| --- | --- |
|
||||
| 存储接口全部异步化,新增独立数据进程(PERF-15、16 的剩余部分) | 约 500 处同步调用,并且要保证“先写后读”的顺序,风险高。最重的读已经移出 Main,写入已降到每次 0.1–0.3 ms,收益已经不大 |
|
||||
| Markdown 解析放到后台线程 | 需要重写渲染流程。标准机上流式输出已经不卡,只有低端机上的长回答还有余量 |
|
||||
| 窗口提前加载,接口请求排队等待 | 可省 200–300 ms,但要改动所有接口的注册顺序和首次运行流程 |
|
||||
| 会话队列移出 App | 和发送、恢���、中断联系太紧,拆出来的风险大于收益 |
|
||||
| 性能阈值接入 CI | 仓库目前没有 CI 配置 |
|
||||
|
||||
## 4. 已知问题和下一步
|
||||
|
||||
### 4.1 本期遗留
|
||||
- **打开最大的会话要等约 170 ms。** 这个会话有 407 条消息、42 MB 附加数据。读取在 worker 上,不卡界面,但切换时要等。根因是附加数据冗余:
|
||||
- 子任务过程记录约 207 MB,主要是 `read`、`bash` 等工具的完整输出;
|
||||
- 旧的 `tools` 和新的 `blocks` 重复保存,约 65 MB;
|
||||
- 旧数据里有约 4 MB 内嵌图片。
|
||||
|
||||
建议去重,并把工具输出改为按需加载,单独立项。
|
||||
- **持久性的变化。** 高频本地写入改为 `synchronous = NORMAL`:断电时最多丢失最近一段运行事件和会话保存,应用崩溃时不丢。远程事件、通道投递、任务状态仍然每次落盘。
|
||||
- **没有在真实低配 Windows 机器上测过**,也没有用同一标准实测 VS Code 作为对照。
|
||||
- **全量测试中 2 个监督者相关测试失败**,在本期之前就已失败,属于另一项正在进行的工作。
|
||||
|
||||
### 4.2 远程主机(下一期)
|
||||
本期涉及远程路径的改动只有 3 处:
|
||||
- `d859cbf`:远程事件按检查点批量写库;
|
||||
- `805acc3`:远程流式合并间隔从 50 ms 改为 16 ms;
|
||||
- `0dd4d1d`:远程事件仍然每次落盘,未降级。
|
||||
|
||||
用户报告的 `Agent refused the stable controller generation resume` 和 `Runtime stopped before the prompt reached a terminal state` 两个错误,还需要在下一期确认和这些改动有没有关系。确认方式:在改动前后的版本上复现,对照远程事件写入和确认(ACK)的顺序。
|
||||
@@ -70,6 +70,7 @@
|
||||
| 2 | 通道、更新和真实 Runtime | `FUN-02`、`FUN-03`、`FUN-05`、`QA-01`、`QA-02` | 对应平台测试配置可用 | 文件办公闭环、更新下载闭环和真实 Runtime E2E 均通过 |
|
||||
| 3A | 性能基线 | `PERF-01`、`PERF-03`、`PERF-05`、`PERF-07`、`PERF-09` | 固定测试数据和设备 | 形成可重复基线、目标阈值和原始结果 |
|
||||
| 3B | 已测量性能优化 | `PERF-02`、`PERF-04`、`PERF-06`、`PERF-08`、`PERF-10` | 对应 3A 指标确认存在瓶颈 | 指标改善且功能、取消、恢复语义不回退 |
|
||||
| 3C | 架构级界面性能改造 | `PERF-11` 至 `PERF-17` | `PERF-11` 完整 App 基线已产生 | 第 7.1 节五条架构规则成立,并由基准和 lint 持续守护 |
|
||||
| 4 | 增强能力 | `FUN-06` 至 `FUN-09` | 核心闭环稳定;首个电脑控制平台已选定 | 每项形成独立、完整、可真实使用的工作流 |
|
||||
|
||||
## 5. 发布与交付
|
||||
@@ -280,6 +281,44 @@
|
||||
Renderer 堆内存。
|
||||
- **验收:** 测试数据、运行步骤和原始结果可重复执行。
|
||||
|
||||
#### 2026-09-28 Measurement Evidence (Partial)
|
||||
|
||||
`npx vitest run tests/streaming-markdown.electron.test.ts` runs the real
|
||||
MarkdownRenderer in Electron 43.2.0 / Chromium 150.0.7871.129. It compares the
|
||||
original normalizer with fast paths that skip unchanged text, retaining live
|
||||
Markdown, math, code and HTML behavior. It also measures a 102,400-byte answer
|
||||
at six growing prefixes, alternating original/current execution order in the
|
||||
same page, with one warm-up sweep and three measured sweeps (18 samples each).
|
||||
|
||||
| Measurement | Original | Current |
|
||||
| --- | --- | --- |
|
||||
| 100KiB growing-prefix render median, isolated run | 54.80 ms | 54.65 ms |
|
||||
| Same run p95 | 95.50 ms | 106.10 ms |
|
||||
| Growing-prefix render median, full-suite run | 53.50 ms | 53.10 ms |
|
||||
| Same run p95 | 93.70 ms | 83.40 ms |
|
||||
|
||||
These development-mode measurements include synchronous React rendering, DOM
|
||||
commit and style/layout, but exclude frame waiting and paint. With 18 samples,
|
||||
nearest-rank p95 is the maximum sample. The results do not establish an overall
|
||||
rendering improvement. Smaller 6.4-7.2K-character corpora do show lower normalizer
|
||||
cost: prose median 33-34 microseconds to below 1 microsecond, and mixed math/code
|
||||
median 32-35 to 16-20 microseconds. The production changes remain limited to this
|
||||
preprocessing and memoizing unchanged subagent-derived collections. In the
|
||||
four-subagent regression, initial render plus six updates builds the lookup map
|
||||
once instead of seven times; block-dependent scans still run when blocks change.
|
||||
|
||||
Validation: 550 prefix normalization comparisons, 1,100 short-prefix DOM
|
||||
comparisons and 24 long-prefix DOM comparisons passed. The real Electron
|
||||
terminal-status probe covers cancellation, failure, retry and persistence via
|
||||
three loopback model-stub requests; the Mermaid viewer covers nine scenarios
|
||||
and ten PNG downloads. No external model calls were used for these probes.
|
||||
Full `npm test` passed 459 files / 5,397 tests, with 11 files / 84 tests skipped.
|
||||
Type checking and scoped lint passed. Full lint remains blocked by the existing
|
||||
untracked sidebar-final probe files.
|
||||
|
||||
This is partial evidence for PERF-03, not completion: full-App input latency,
|
||||
frame rate, heap measurements and 500KB/1MB scenarios remain unmeasured.
|
||||
|
||||
### PERF-04 流式更新合并
|
||||
|
||||
- **优先级 / 状态:** P1 / 待开始
|
||||
@@ -293,11 +332,30 @@
|
||||
|
||||
### PERF-05 向量检索规模基准
|
||||
|
||||
- **优先级 / 状态:** P1 / 待开始
|
||||
- **优先级 / 状态:** P1 / 耗时基线已完成,取消响应与峰值内存待测
|
||||
- **场景:** 1k、10k、50k chunks 的全文、向量和混合检索。
|
||||
- **指标:** 总耗时、`vectorScannedCount`、Main event-loop lag、取消响应和峰值内存。
|
||||
- **验收:** 固定语料和查询集产生可比较结果,并保留检索质量指标。
|
||||
|
||||
#### 2026-10-02 耗时基线
|
||||
|
||||
`npm run perf:knowledge`(`build/run-knowledge-perf.cjs`)用 esbuild 打包生产
|
||||
`KnowledgeDatabase`,在临时 SQLite 中写入确定性随机语料(每文档 100 chunks,每 chunk 80 词,
|
||||
384 维向量,与内置嵌入模型一致),重开连接后对每个规模各测 15 次 `search`、`vectorSearch`
|
||||
和 `hybridSearchWithDiagnostics`(图谱关闭)。这些调用是同步的,在 App 中由 Main 线程直接
|
||||
执行,所以单次耗时就是 Main 被阻塞的时长。同一台设备(Node 24.18),p95:
|
||||
|
||||
| chunks | 全文 | 向量 | 混合 |
|
||||
| --- | --- | --- | --- |
|
||||
| 5,000 | 14 ms | 54 ms | 93 ms |
|
||||
| 20,000 | 59 ms | 173 ms | 219 ms |
|
||||
| 50,000 | 130 ms | 396 ms | 520 ms |
|
||||
|
||||
向量扫描与 chunk 数线性相关,FTS 也随规模增长。按 50 ms 预算,约 5k chunks 起混合检索就会
|
||||
使 Main 可感知地卡顿;5 万 chunks 时每次检索约冻结 Main 0.5 s,期间全部 IPC(包括流式输出、
|
||||
终端和界面操作)排队。结论:`PERF-06` 的实施条件已满足;检索应先移出 Main(`PERF-06`,
|
||||
随后并入 `PERF-16`)。取消响应和峰值内存尚未测量。
|
||||
|
||||
### PERF-06 向量计算移出 Main
|
||||
|
||||
- **优先级 / 状态:** P1 / 待开始
|
||||
@@ -327,21 +385,658 @@
|
||||
|
||||
### PERF-09 Keep-alive 压力测试
|
||||
|
||||
- **优先级 / 状态:** P2 / 待开始
|
||||
- **优先级 / 状态:** P2 / 文本会话部分已完成,图片与工具记录待补
|
||||
- **场景:** 连续打开 12 个会话,每个会话包含 80 条富 Markdown、图片和工具记录。
|
||||
- **指标:** DOM 节点、Renderer heap、GC pause 和流式 commit。
|
||||
- **验收:** 明确隐藏会话缓存是否构成实际内存或渲染问题。
|
||||
|
||||
#### 2026-10-02 文本会话测量
|
||||
|
||||
`npm run perf:app` 在流式场景之后连续打开 24 个未访问过的种子会话(每个 20 条富 Markdown),
|
||||
每 4 次记录挂载的面板数、DOM 节点数和强制 GC 后的 JS heap(CDP
|
||||
`HeapProfiler.collectGarbage` + `Runtime.getHeapUsage`)。
|
||||
|
||||
| 已访问 | 挂载面板 | DOM 节点 | GC 后 heap |
|
||||
| --- | --- | --- | --- |
|
||||
| 0(两次流式之后) | 12 | 39,657 | 54.6 MB |
|
||||
| 4 / 8 | 12 | 39,656 | 54.6–55.1 MB |
|
||||
| 12 | 12 | 15,884 | 27.4 MB |
|
||||
| 16 / 20 / 24 | 12 | 15,884 | 27.3–27.9 MB |
|
||||
|
||||
缓存上限 12 个面板有效:两条长流式回答被挤出缓存后,DOM 和 heap 回落并保持不变,后半段
|
||||
增长为 0。此前观察到的“4 万节点、210 MB”是两条 40 KB 流式回答仍在缓存中时的瞬时值
|
||||
(未 GC),不是持续泄漏。结论:文本会话场景下 keep-alive 不构成内存问题,`PERF-10` 不需要
|
||||
调整缓存数量或有效期;含图片和工具记录的会话尚未测。
|
||||
|
||||
### PERF-10 Keep-alive 轻量优化
|
||||
|
||||
- **优先级 / 状态:** P2 / 待开始
|
||||
- **实施条件:** `PERF-09` 确认当前缓存不达标。
|
||||
- **优先级 / 状态:** P2 / 已完成输入与热切换渲染隔离,压力测试待完成
|
||||
- **实施依据:** 2026-09-30 的真实 App 组件回归确认输入和热切换会重复渲染缓存内容;
|
||||
`PERF-09` 的 Electron 内存与 GC 压力测试仍待执行。
|
||||
- **范围:**
|
||||
- 优先 memoize 隐藏 `ChatHistoryPane` 并稳定 props。
|
||||
- `ChatHistoryPane`、任务条和 Runtime 清单使用 memo,稳定快捷输入、重试、图片恢复、
|
||||
定时任务回调及任务条 JSX。异步草稿操作仍绑定发起时的会话。
|
||||
- 仍不达标时才调整缓存数量或一小时有效期。
|
||||
- 暂不引入完整虚拟化框架。
|
||||
- **验收:** 内存和 commit 指标改善,同时保留会话切换位置和快速恢复体验。
|
||||
|
||||
`App.test.tsx` 修复前回归记录:运行请求期间连续输入三次,历史面板、任务条、清单各
|
||||
渲染三次,会话迁移存储读取三次;两个已缓存会话往返一次,Markdown 渲染 324 次。
|
||||
长会话已展开 160 条消息,另一会话含一条消息,计数包含展开的推理正文。修复后上述
|
||||
计数均为零。最终用例还覆盖可见且展开的运行中任务条、实时文本与清单更新、停止、
|
||||
会话草稿、图片素材选择期间切换会话,以及滚动位置和推理展开状态保留。
|
||||
这些是 jsdom 中真实 App 的渲染次数,使用 mock preload;尚未测量 Electron 输入延迟、
|
||||
绘制耗时或内存。流式事件未延后,缓存容量和有效期未改变。
|
||||
|
||||
### 7.1 架构级界面性能改造(批次 3C)
|
||||
|
||||
2026-10-02 的架构评审确认,界面卡顿的主要来源是架构组织方式,而不是 Electron 或 GPU:
|
||||
|
||||
- **Renderer 单组件状态:** `App.tsx` 约 11k 行,`App()` 内有 123 个 `useState`、69 个
|
||||
`useEffect`。会话、消息、草稿、任务和设置都在其中,输入和每次流式 flush 都会重新执行
|
||||
整个 App 函数体;多个大面板未 memo,隐藏的 keep-alive 视图也会参与渲染。
|
||||
- **Main 同步存储:** 全部 SQLite 访问使用 Main 线程的 `DatabaseSync`,包括全表向量扫描、
|
||||
会话列表与搜索、Story Graph 读取;PDF/Office 解析和附件同步文件 IO 也在 Main。
|
||||
- **全量回写:** 活动记录在任何变化后 250 ms 全量 `replace`;会话每 500 ms 定时保存;
|
||||
`*Changed` 通知不带内容,Renderer 再重新查询完整列表。
|
||||
|
||||
改造完成后须同时满足以下规则,并由工具守护,避免回退:
|
||||
|
||||
1. Main 只负责窗口、生命周期、权限和消息路由,不执行数据库查询、文档解析或批量计算。
|
||||
2. Renderer 状态按领域拆成外部 store,组件通过 selector 订阅;`App.tsx` 只保留布局壳。
|
||||
3. 流式文本和终端输出走专用通道,只更新正在显示的行或面板,按帧合并提交。
|
||||
4. 数据只有一个权威来源,跨进程只传增量;不再全量回写,也不再“通知后重新查全量”。
|
||||
5. 消息、会话、笔记、文件树等长列表虚拟化,DOM 数量不随数据量增长。
|
||||
|
||||
实施方式:先建立 `PERF-11` 基线,再沿 Renderer(`PERF-12`→`PERF-13`→`PERF-14`)和
|
||||
数据进程(`PERF-15`→`PERF-16`)两条线并行推进,最后由 `PERF-17` 固化守护。每一步完成后
|
||||
重新运行 `PERF-11` 场景并记录对比;不换技术栈,不一次性重写 `App.tsx`。
|
||||
|
||||
### PERF-11 完整 App 交互基准
|
||||
|
||||
- **优先级 / 状态:** P1 / 进行中
|
||||
- **目标:** 在当前生产构建、隔离 profile 和本地假模型下,可重复测量用户能感知的卡顿。
|
||||
- **运行:** `npm run build:bundle` 后执行 `npm run perf:app`;结果写入
|
||||
`GB_PERF_OUTPUT` 或系统临时目录,不含用户内容,不发出外部模型请求。
|
||||
- **场景:** 空闲基线;Composer 连续输入;切换会话;打开长会话;大量会话列表下的流式
|
||||
回答;流式回答期间同时输入。
|
||||
- **指标:** Renderer Long Tasks(>50 ms)数量与总时长、按键到下一帧延迟、帧间隔 p95 与
|
||||
掉帧比例、React commit 无法直接取得时以 DOM mutation 批次近似、Main event-loop 延迟
|
||||
(`monitorEventLoopDelay`)、Main 与 Renderer 进程 CPU 和内存、Renderer JS heap。
|
||||
- **验收:** 同一设备多次运行结果可比较;报告记录源码版本、环境、数据规模和原始样本摘要。
|
||||
|
||||
#### 实现
|
||||
|
||||
- `build/run-app-perf.cjs`:独立 supervisor。在 `127.0.0.1:11434`(新 profile 默认模型地址)
|
||||
启动 OpenAI 兼容假模型,清除 `ELECTRON_RUN_AS_NODE` 和模型环境变量,设置整轮期限(默认
|
||||
600 s),汇总报告并删除隔离 profile。端口被占用时直接失败。
|
||||
- `tests/support/app-perf-driver.mjs`:在导入 `out/main/index.js` 前设置 `appPath`、
|
||||
`userData`、`sessionData` 和日志目录,然后由生产 Main 创建窗口、注册 IPC 并加载真实
|
||||
Preload/Renderer。全部交互使用 `sendInputEvent` 原生输入;数据通过生产
|
||||
`conversations.saveLocal` IPC 写入,再 reload 走正常启动加载。
|
||||
- Renderer 探针只观察:`longtask`、`long-animation-frame`、Event Timing、rAF 帧间隔、
|
||||
keydown 到下一帧、MutationObserver 计数、DOM 节点数和精确 JS heap。Main 用
|
||||
`monitorEventLoopDelay`,进程用 `app.getAppMetrics()`。
|
||||
- 默认规模:300 个会话 × 20 条消息,另加一个 2,000 条消息的长会话;输入 120 个字符,
|
||||
按键间隔 60 ms;流式回答约 40 KB 混合 Markdown(标题、列表、代码、表格、行内/块级公式、
|
||||
引用),每个 SSE 事件 12 个字符,2,000 字符/秒。可用 `GB_PERF_*` 环境变量调整,
|
||||
见脚本开头。
|
||||
- `GB_PERF_PROFILE=1` 为有历史输入和流式场景采集 Renderer CPU profile 并汇总自身耗时
|
||||
热点;剖析有开销,只用于归因,不作为基线数据。
|
||||
- `GB_PERF_CPU_THROTTLE=<倍数>` 用 CDP 降低 Renderer CPU 速度模拟低端设备;
|
||||
`GB_PERF_MEMORY_VISITS`(默认 24,0 跳过)控制 `PERF-09` 内存场景。
|
||||
|
||||
#### 2026-10-02 基线
|
||||
|
||||
源码 `8280158`(工作区含其他未提交改动),Windows x64,Intel Core Ultra X7 358H ×16,
|
||||
32 GB,Electron 43.2.0 / Chromium 150,DPR 1,内容区 1280×800,GPU 合成与光栅化均启用。
|
||||
连续运行 3 次,假模型请求每次 2 次,外部模型请求 0 次。帧间隔基线为 8.3 ms(120 Hz)。
|
||||
|
||||
| 场景 | Long Tasks 次数 / 总时长 | 帧间隔 p95 | 掉帧比例 | 交互延迟 p50 / p95 | Main lag 最大 |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| 空闲(新 profile / 有历史) | 0 / 0 | 8.5 ms | 0% | — | 17–21 ms |
|
||||
| 输入 120 字(新 profile) | 0 / 0 | 8.5 ms | ≤0.2% | 按键→帧 4.8–5.1 / 8.3–8.5 ms | ≤17 ms |
|
||||
| 输入 120 字(有历史) | 0 / 0 | 8.5 ms | 0% | 按键→帧 6.5–6.7 / 8.4–8.7 ms | ≤18 ms |
|
||||
| 首次切换会话 ×15 | 1 / 77–81 ms | 8.6 ms | 3.7–4.5% | 点击→显示 49–50 / 102–105 ms | 29–32 ms |
|
||||
| 再次切换(缓存命中)×10 | 0 / 0 | 8.6 ms | 3.3–4.1% | 点击→显示 52–53 / 56–59 ms | 19–22 ms |
|
||||
| 打开 2,000 条消息会话 | 1 / 94–102 ms | 8.5 ms | 1.6% | 点击→显示 134–144 ms | ≤21 ms |
|
||||
| 滚动长会话 3 s | 0 / 0 | 8.5 ms | 0% | — | ≤17 ms |
|
||||
| **流式 40 KB 回答** | **126–137 / 8.6–9.3 s** | **83 ms** | **45–48%** | — | 30–36 ms |
|
||||
| **流式期间输入 100 字** | **128–182 / 8.8–11.9 s** | **83 ms** | **50–52%** | 按键→帧 12.5–13.5 / 35–36 ms | 29–144 ms |
|
||||
|
||||
其他观察:
|
||||
|
||||
- 启动:窗口创建约 0.58 s,`ready-to-show` 约 1.29 s,Composer 可用约 1.42 s;带 300 个
|
||||
会话 reload 到侧栏出现约 0.19 s。
|
||||
- 流式回答约 20 s,Long Tasks 占记录时间约 40%;按四等分统计为 `0 / 2.5 / 4.7 / 4.6 s`,
|
||||
随回答变长而增加,说明每次提交的成本与已生成长度成正比。
|
||||
- 有历史时侧栏一次渲染全部 302 行,DOM 约 4,400 节点;访问过若干会话后约 1.6 万;两次
|
||||
流式后约 4 万,JS heap 最高约 210 MB(keep-alive 缓存持续增长)。
|
||||
- 长会话只渲染最近 80 条,打开耗时主要是一次约 100 ms 的 Long Task。
|
||||
- KaTeX 的 `data:` 字体被 CSP 拒绝(缺少 `font-src`),每轮 8 条控制台错误,字体回退
|
||||
(已在 `PERF-12` 第一步修复)。
|
||||
- `app.getAppMetrics()` 的 CPU 百分比在 Windows 上按全部逻辑核归一,只作相对比较。
|
||||
|
||||
**归因(剖析运行):** 流式期间 Renderer 自身 CPU 约 74% 在应用 bundle 中,热点是
|
||||
`hast-util-from-dom` 的 `parseFragment`/`fromDom`(rehype-katex 把 KaTeX HTML 再解析成
|
||||
hast)、micromark 分词、hast→React 转换和 React reconcile。对照实验确认根因:默认语料的
|
||||
块级公式写在一行(`$$...$$`),命中 `MarkdownRenderer.tsx` 的 `unsafeSegmentLine`,
|
||||
`splitMarkdownSegments` 退回整篇单段,**每次 flush 都重新解析整篇回答并重新渲染全部公式**。
|
||||
仅把 `$$` 改成独占一行(`GB_PERF_CORPUS=multiline-math`)后,同一流式场景 Long Tasks
|
||||
从约 130 次 / 9 s 降到 1 次 / 0.15 s,帧间隔 p95 从 83 ms 回到 8.5 ms,流式期间按键→帧
|
||||
p95 从约 36 ms 降到 15.5 ms。
|
||||
|
||||
**结论与对后续批次的影响:**
|
||||
|
||||
1. 当前最严重、用户最易感知的卡顿是**流式长回答**,且由 Markdown 分段失效后的整篇重解析
|
||||
主导;输入、切换、滚动和空闲在本机均未出现明显问题。`PERF-12` 应优先处理:让分段在
|
||||
单行 `$$`、HTML 行、链接定义等情况下也能安全工作或局部回退,并对已完成段缓存渲染结果。
|
||||
2. 假模型 2,000 字符/秒高于常见真实模型输出速度,但成本随长度线性增长,真实速度下回答
|
||||
足够长时同样会出现。
|
||||
3. 会话首次切换 p95 约 100 ms、打开长会话约 140 ms,可在 `PERF-13`/`PERF-14` 中改善,
|
||||
优先级低于第 1 项。
|
||||
4. 本机为高性能设备;低端设备上的绝对值会更差,后续应补一台低配 Windows 设备基线。
|
||||
|
||||
### PERF-12 流式与输入快速止血
|
||||
|
||||
- **优先级 / 状态:** P1 / 进行中(Markdown 分段已完成)
|
||||
- **范围:** 流式 delta 在 Renderer 按 `requestAnimationFrame` 合并后提交,并以 transition
|
||||
降低优先级;缓存 Markdown 预处理结果;`RightAssistantSidebar`、`ProjectSwitcher` 和
|
||||
keep-alive 路由使用 memo 并稳定回调;滚动跟随只在需要时执行。
|
||||
- **验收:** `PERF-11` 流式与输入场景的 Long Tasks 和输入延迟下降;事件顺序、停止、重试、
|
||||
工具与 Subagent 块不回退。该项吸收 `PERF-04` 的范围。
|
||||
|
||||
#### 2026-10-02 第一步:Markdown 分段与 KaTeX 字体
|
||||
|
||||
- **分段(`MarkdownRenderer.tsx`):** `splitMarkdownSegments` 按 micromark-extension-math
|
||||
的实际规则识别块级公式:`$$` 等两个以上 `$` 开头、其后不含 `$` 的行才开启公式块,并需要
|
||||
不短于开启长度的 `$` 行关闭;`$$x^2$$` 这类单行公式按普通段落处理,不再使整篇退回单段。
|
||||
链接定义和脚注改为逐行检查,位于顶格代码围栏内的行视为字面内容。原始 HTML 行、`\r`、
|
||||
缩进围栏内的定义仍整篇退回。
|
||||
- **字体(`electron.vite.config.ts`):** Renderer 构建不再把字体内联为 `data:` URL。CSP
|
||||
没有 `font-src`,原先 4 个小于 4 KB 的 KaTeX 字体被拒绝并回退;现在 60 个 KaTeX 字体
|
||||
全部作为同源文件输出,CSP 不变。
|
||||
- **等价性验证:** 现有逐前缀对照语料新增单行公式、长 `$` 围栏、代码围栏内的定义等用例;
|
||||
新增 1,200 份确定性随机文档(半数取随机前缀)对照,要求分段渲染与整篇渲染的 DOM 完全
|
||||
一致。开发期另跑了约 4.8 万份随机文档与前缀,其中约 6,300 份走分段路径,全部一致。
|
||||
- **`npm run perf:app` 对比(同机,各 3 次,`80579ee` + 本改动):**
|
||||
|
||||
| 场景 | 改动前 | 改动后 |
|
||||
| --- | --- | --- |
|
||||
| 流式 40 KB:Long Tasks | 126–137 次 / 8.6–9.3 s | 1 次 / 0.16 s |
|
||||
| 流式 40 KB:帧间隔 p95 / 掉帧 | 83 ms / 45–48% | 8.6–8.9 ms / 3–5% |
|
||||
| 流式期间输入:按键→帧 p50 / p95 | 12.5–13.5 / 35–36 ms | 11.7–11.9 / 19–21 ms |
|
||||
| 流式期间输入:掉帧 | 50–52% | 7.7–8.5% |
|
||||
| KaTeX 字体 CSP 错误 | 每轮 8 条 | 0 |
|
||||
|
||||
输入、切换、长会话和滚动场景与基线持平。流式期间输入仍有约 8% 掉帧、按键 p95 约 20 ms,
|
||||
剩余开销属于 App 级重渲染,留给 `PERF-12` 后续项和 `PERF-13`。
|
||||
- **剩余:** 流式 delta 按帧合并、大面板 memo、滚动跟随优化仍待实施。
|
||||
|
||||
#### 2026-10-02 低端设备模拟(Renderer CPU 4× 降速)
|
||||
|
||||
`GB_PERF_CPU_THROTTLE=4 npm run perf:app` 通过 CDP `Emulation.setCPUThrottlingRate` 把
|
||||
Renderer 主线程降速 4 倍(Main 不降速,每次 reload 后重新应用),源码 `5dc6545`
|
||||
(含 `PERF-12` 第一步),同一设备。不降速一栏为同一轮构建的对照。
|
||||
|
||||
| 场景 | 不降速 | 4× 降速 |
|
||||
| --- | --- | --- |
|
||||
| 输入 120 字(新 profile)按键→帧 p95 | 8.7 ms | 42 ms,掉帧 21% |
|
||||
| **输入 120 字(有 300 个会话)** | 0 Long Task,p95 9 ms | **37 次 / 3.2 s,p95 355 ms,最大 483 ms** |
|
||||
| **首次切换会话 ×15** 点击→显示 p50 / p95 | 52 / 117 ms | **434 / 922 ms** |
|
||||
| 再次切换(缓存命中)p50 / p95 | 55 / 66 ms | 482 / 534 ms |
|
||||
| 打开 2,000 条消息会话 | 140 ms | 1.2 s |
|
||||
| 滚动长会话 | 帧 p95 8.5 ms | 帧 p95 17 ms,正常 |
|
||||
| **流式 40 KB** | 1 次 / 0.17 s,帧 p95 17 ms | **156 次 / 16.7 s,帧 p95 183 ms,掉帧 100%** |
|
||||
| **流式期间输入** 按键→帧 p50 / p95 | 11.5 / 17 ms | **575 / 1,513 ms** |
|
||||
|
||||
高性能机上不可见的问题,在降速后全部暴露:
|
||||
|
||||
- 新 profile 输入只到 42 ms,有 300 个会话后升到 355 ms,差异来自**每次按键重新渲染整个
|
||||
App 和完整侧栏**。降速剖析:`resizeComposerTextarea`(每次输入同步读 `scrollHeight` 强制
|
||||
布局)约 8%;i18next `formatLanguageCode`/`translate` 约 12%(`Intl.getCanonicalLocales`
|
||||
每次调用 `t()` 都执行,侧栏 302 行各自多次调用);其余是 App 与侧栏行的 React 渲染。
|
||||
- 流式期间约 18% 是 `ChatHistoryPane` 每次 flush 的滚动跟随 layout effect(读布局后
|
||||
`scrollTo`),i18next 约 8%,Markdown 解析与 React reconcile 其余。
|
||||
- 缓存命中切换也约 0.5 s,说明慢的不是挂载新面板,而是切换触发的 App 级整体重渲染。
|
||||
|
||||
**结论:** 低端设备上输入、切换和流式都会明显卡顿,主因是 App 级重渲染和侧栏全量渲染,
|
||||
证实 `PERF-13`(领域 store)和 `PERF-14`(侧栏虚拟化)有必要。`PERF-12` 剩余项应先做三处
|
||||
低成本热点:Composer 高度改为只在需要时测量、`t()` 结果或 i18next 语言码规范化做缓存、
|
||||
滚动跟随按帧合并且只在贴底时执行。
|
||||
|
||||
#### 2026-10-02 第二步:三处低成本热点
|
||||
|
||||
- **Composer 高度(`App.tsx` `resizeComposerTextarea`):** 记住上次的值与高度。值未变不测量;
|
||||
在原文本末尾追加时不再先设 `height: auto`(只会变高),已到最大高度直接跳过测量;删除、
|
||||
替换、清空仍按原方式重置并测量。
|
||||
- **i18next 语言码(`i18n/index.ts`):** i18next 25 每次 `t()` 都经
|
||||
`toResolveHierarchy`→`formatLanguageCode` 调用 `Intl.getCanonicalLocales`。初始化后对
|
||||
`languageUtils.formatLanguageCode` 加按输入缓存(结果只取决于代码与静态初始化选项,上限
|
||||
64 项),翻译结果不变。
|
||||
- **滚动跟随(`ChatHistoryPane.tsx`):** 激活面板时仍同步滚到底;之后内容更新时改为每帧最多
|
||||
一次 `requestAnimationFrame` 中读取 `scrollHeight` 并 `scrollTo`,回调里再次确认仍贴底;
|
||||
卸载时取消。读者离开底部时不滚动,恢复阅读位置逻辑不变。
|
||||
- **测试:** 新增“流式三次更新只滚动一帧”(旧实现 3 次,失败)、Composer 追加与删除的测量
|
||||
次数、`t()` 不再调用 `Intl.getCanonicalLocales`。全量测试 5,621 通过,2 失败与本改动
|
||||
无关(真实 OpenCode 集成测试因免费额度返回 403;Supervisor 超时设置用例此前已失败)。
|
||||
- **`npm run perf:app` A/B(同机,改动前为临时 worktree 中的 `983cdd4` 构建,交替运行):**
|
||||
|
||||
| 场景(4× 降速,3 轮) | 改动前 按键/点击 p50 / p95 | 改动后 |
|
||||
| --- | --- | --- |
|
||||
| 输入 120 字(新 profile) | 28–80 / 33–361 ms | 19–36 / 24–57 ms |
|
||||
| 输入 120 字(有 300 个会话) | 265–2,372 / 1,486–4,266 ms | 27–161 / 32–346 ms |
|
||||
| 首次切换会话 | 442–761 / 1,063–1,384 ms | 360–398 / 820–1,334 ms |
|
||||
| 再次切换(缓存命中) | 472–749 / 501–1,143 ms | 390–420 / 456–951 ms |
|
||||
| 流式 40 KB:Long Tasks 总时长 | 15.9–20.6 s | 15.1–17.4 s |
|
||||
| 流式期间输入 | 432–6,778 / 1,146–9,634 ms | 96–466 / 297–1,567 ms |
|
||||
|
||||
不降速时(2 轮)两版各场景持平,输入 p95 均约 9 ms,流式 1 个 Long Task。4× 降速下
|
||||
方差很大(同版本轮间可差数倍),应看量级:有历史时输入从秒级回到百毫秒内,切换略有改善,
|
||||
流式本身仍是每帧卡顿——剩余成本是 App 级重渲染与 Markdown/React 提交,需 `PERF-13`。
|
||||
- **剩余:** 流式 delta 按帧合并后以 transition 提交、大面板 memo 仍待实施;之后进入
|
||||
`PERF-13`。
|
||||
|
||||
### PERF-13 Renderer 领域 Store 迁移
|
||||
|
||||
- **优先级 / 状态:** P1 / 进行中(流式缓冲、Composer 草稿已迁移)
|
||||
- **顺序:** 流式缓冲 → Composer 草稿 → 会话与消息 → 任务与通知 → 设置及其余界面状态。
|
||||
- **每个领域的完成步骤:** 建立 store 并把 IPC 订阅移入 store;调用方改为 selector;删除
|
||||
App 中对应 state/ref 同步代码;运行测试与 `PERF-11`。
|
||||
- **验收:** 输入和流式更新不再重新渲染 App 根组件;`App.tsx` 最终降到布局壳规模。
|
||||
|
||||
#### 2026-10-02 第一步:流式缓冲与 Composer 草稿
|
||||
|
||||
- **流式缓冲(`live-message-store.ts`):** `text`/`reasoning` delta 不再逐条 `setConversations`,
|
||||
而是写入按消息分组的外部 store;`ChatMessageRow` 用 `useSyncExternalStore` 订阅自己的消息,
|
||||
只有这一行重新渲染。App 状态每 250 ms 吸收一次,或在任何其他会话更新时先吸收。
|
||||
- 每个 delta 带序号和应用后的正文/思考长度。store 生成的消息对象记录已包含的序号;持久化
|
||||
快照等外来��象按长度判断(delta 只追加),因此不会重复或丢失。
|
||||
- 一次更新只吸收派发时已到达的 delta,工具、审批等块与文本保持原有顺序。
|
||||
- 吸收后裁剪已提交的 delta,行内显示增量折叠,长回答期间缓冲区保持很短。
|
||||
- 吸收用普通更新而非 transition:transition 会被行的同步更新反复打断,导致缓冲区一直增长
|
||||
(首版实测出现过流式 Long Tasks 翻倍)。
|
||||
- **Composer 草稿(`composer-draft-store.ts`):** 按会话的草稿移出 App state;输入框和发送按钮
|
||||
分别订阅草稿文本和“是否有内容”,按键不再重渲染 App。高度调整改为草稿提交后的 layout effect。
|
||||
- **测试:** store 单测覆盖折叠等价、提交前显示、工具块顺序、派发时快照、替换快照不重复、
|
||||
React 重放幂等、无基线不隐藏、长流裁剪;App 测试改为断言流式文本不再渲染历史面板与任务条。
|
||||
- **`npm run perf:app` A/B(同机,改动前为临时 worktree 中的 `90ea33e` 构建,交替运行):**
|
||||
|
||||
| 场景(4× 降速,3 轮) | 改动前 p50 / p95 | 改动后 |
|
||||
| --- | --- | --- |
|
||||
| 输入 120 字(新 profile) | 18–21 / 24–26 ms,掉帧 7–13% | 9–10 / 11–16 ms,掉帧 0–1% |
|
||||
| 输入 120 字(有 300 个会话) | 27–81 / 35–227 ms | 10–19 / 12–36 ms |
|
||||
| 流式 40 KB:Long Tasks 总时长 | 6.5–20.8 s | 3.6–19.3 s(3 轮中 2 轮约 4–5 s) |
|
||||
| 流式期间输入 | 73–1,808 / 174–3,498 ms | 34–62 / 59–573 ms |
|
||||
| 打开 2,000 条消息会话(见下方更正) | 1.07–1.80 s,帧 p95 9–42 ms | 0.47–0.76 s,帧 p95 75–1,008 ms |
|
||||
| 首次 / 缓存命中切换会话 | 持平 | 持平 |
|
||||
|
||||
**更正(归因):** “改动后”构建包含了当时工作区中另一项未提交的改动——新会话面板分两步
|
||||
渲染(先最后 20 条,首帧后补齐 80 条,后提交为 `09c415b`)。打开长会话的耗时下降主要来自
|
||||
它,而不是流式缓冲;同时补齐那一帧变成长帧,4× 降速下该场景帧间隔 p95 从 9–42 ms 升到
|
||||
75–1,008 ms。输入与流式期间输入的改善来自本步骤。分两步渲染只是过渡,补齐帧的成本需由
|
||||
`PERF-14` 虚拟化解决。
|
||||
|
||||
不降速时(2 轮)各场景持平或略好,流式期间输入 p95 从 15–17 ms 降到 11–12 ms。流式本身在
|
||||
4× 降速下仍每帧掉帧:剩余成本是行内 Markdown 解析与 KaTeX 渲染,以及每 250 ms 一次的 App
|
||||
级吸收;切换会话的 App 级重渲染未改善,留给后续领域迁移。
|
||||
- **远程 Runtime:** 远程事件与本地事件走同一 `agent:event` 通道和同一 Renderer 处理函数,
|
||||
上述 Renderer 改进同样生效。远程事件在 Main 中逐条同步写 SQLite(每个 delta 约 2 个写事务,
|
||||
会话恢复路径还会重写整条消息)后再转发,这部分未改,归入 `PERF-15`/`PERF-16`。
|
||||
- **基准工具:** `GB_PERF_MODEL_PORT` 可把假模型移到其他端口(例如本机已运行 GoodBuddy 占用
|
||||
11434 时),隔离 profile 通过 `GOODBUDDY_MODEL_*` 环境变量指向它。
|
||||
|
||||
#### 2026-10-02 其他改动的架构核对
|
||||
|
||||
对同期其他功能改动按本批次 5 条规则核对(监督者功能除外):
|
||||
|
||||
- **一致:** 剪贴板改为异步读写(`d773f5e`);未保存修改通过 context 上报、记录不进 App state,
|
||||
有未保存修改的路由不被缓存淘汰(`43be465`);新会话面板分两步渲染(`09c415b`,过渡方案)。
|
||||
- **偏离规则 1:** Story Graph 检索(`e97263c`)仍在 Main 内经 `DatabaseSync` 读取后,在 JS 中
|
||||
逐条 `JSON.stringify` 并按多个关键词匹配,单次成本高于原整句匹配;工具描述又鼓励 Agent
|
||||
主动调用,调用次数会增加。纳入 `PERF-15`/`PERF-16`,在此之前补测检索耗时并限制扫描规模。
|
||||
- **偏离规则 2:** 侧栏 5 秒重排(`ee4eb73`,`use-conversation-list-order.ts`)由 App 持有状态,
|
||||
渲染期间 setState,定时器每 5 秒检查;顺序不变时跳过,当前成本低。交互上悬停、滚动、菜单
|
||||
打开时不重排。侧栏迁移和虚拟化(`PERF-13`/`PERF-14`)时移入侧栏组件。
|
||||
- **React 规则:** 缓存会话保持滚动位置(`bf6c4a9`)在渲染期间写 ref,即当前 lint 的 3 条
|
||||
`react-hooks/refs` 错误;启用 React Compiler 前需改为由状态派生顺序。
|
||||
|
||||
#### 2026-10-02 并行修复:补全分帧、远程批量写库、架构守护
|
||||
|
||||
- **补全分帧(`ChatHistoryPane.tsx`):** 新面板补全改为每帧 +20 条(20→40→60→80),每步后
|
||||
贴底或按高度差保持阅读位置;“加载更早”、批次变化或笔记跳转到达时立即渲染完整批次。
|
||||
- **渲染期 ref(`App.tsx`、`pane-order.ts`):** 会话面板顺序改为 state,用纯函数
|
||||
`reconcilePaneOrder` 在渲染期按需调整,顺序不变时返回原数组;消除 3 条 `react-hooks/refs`。
|
||||
- **远程批量写库(`remote-event-batcher.ts`、`assistant-database.ts`、`ipc.ts`):** 远程语义事件
|
||||
按 checkpoint 一个事务提交(Agent 在 checkpoint 之后才可能 ACK),可恢复会话每批只读写一次
|
||||
消息;非远程事件、`question`、`done`/`error`、显式 flush、256 条或 50 ms 计时器也会先提交。
|
||||
只转发新写入的事件,顺序不变;提交失败整批回滚且之后拒绝继续提交。交互、后台/定时/通道
|
||||
和重启恢复三条路径都已改用。当前 checkpoint 粒度是每条 transcript 记录而非每页,按页批量
|
||||
需要改 `acp-remote-runtime.ts`。
|
||||
- **架构守护(提前实施 `PERF-17` 的一部分):** `eslint.config.js` 新增只减不增的白名单规则:
|
||||
`DatabaseSync`(19 个文件)、`src/main` 同步 fs(8 个文件)、组件直接订阅
|
||||
`window.goodbuddy.*.on*`(6 个文件)、`App.tsx` `max-lines` 11700;文档演示和 vendor 脚本
|
||||
不再参与 lint,`npx eslint .` 现为 0 错误。`build/perf-thresholds.json` +
|
||||
`npm run perf:check`(或 `GB_PERF_CHECK=1 npm run perf:app`)检查不降速报告的阈值。
|
||||
- **`npm run perf:app` A/B(改动前为 `9d8477b`):** 不降速 2 轮两版均通过 60 项阈值;打开长会话
|
||||
从 1 个 Long Task / 56–61 ms、最大帧 50–58 ms 降到 0 个、最大帧 25–33 ms,其余持平。4× 降速
|
||||
3 轮:打开长会话最大帧从 475–567 ms 降到 225–283 ms(帧 p95 因多出几帧小提交升到
|
||||
175–258 ms);切换会话持平;流式场景两版方差都很大,未见一致变化。远程批量写库不在该基准
|
||||
覆盖范围内,由单元测试和 `ipc.test.ts` 验证事务次数、顺序与回放去重。
|
||||
|
||||
#### 2026-10-03 会话与消息领域(第一片)
|
||||
|
||||
- **会话 store(`conversation-store.ts`、`conversation-selectors.ts`):** 会话数组移出 App state,
|
||||
`getState()` 同步可读,取代 `conversationsRef` 及其 5 处直接写入;`set()` 先吸收流式 delta
|
||||
再同步更新。按会话 id 和列表两级通知,选择结果未变时保持同一对象。
|
||||
- **侧栏(`ConversationSidebar.tsx`):** 搜索匹配、5 秒重排、行级派生值移入侧栏自身订阅;
|
||||
切换会话时侧栏只重渲染状态翻转的两行。顺便修复 React 19.2 中 `memo` 组件内
|
||||
`useEffectEvent` 不刷新导致重排用旧列表的问题(有回归测试)。
|
||||
- **App 只订阅:** 活动会话、缓存面板的会话、数量,以及标题/审批/项目活动的按值比较
|
||||
选择器。`App.tsx` 从 11,636 行降到约 10,960 行。
|
||||
- **仍在 App:** 未读、展开、菜单、重命名、删除等界面 ID;持久化、历史加载与合并;
|
||||
活动会话的更新(含 250 ms 流式吸收)仍重渲染 App。
|
||||
|
||||
#### 2026-10-03 PERF-13 进展:Composer、路由边界、任务 store、切换会话
|
||||
|
||||
提交 `41be9f8`、`2e3d2e7`、`651475d`、`492f1d2`、`6b634c5`。
|
||||
|
||||
- **Composer(`Composer.tsx`,`memo`):** 输入区页脚(约 930 行 JSX、23 个内联回调)移出 App。
|
||||
草稿读 `composer-draft-store`,会话字段读 `useComposerConversationView`(不含消息数和结果 ID),
|
||||
弹出菜单状态放进 `composer-menu-store.ts`;动作经 `use-composer-actions.ts` 保持同一引用。
|
||||
其他会话更新、当前会话新增消息、别的会话输入都不重渲染 Composer(有测试)。
|
||||
- **路由边界:** `KeepAliveRoute` 改为 `memo`,隐藏路由在 App 重渲染时保持上次结果,再次显示时
|
||||
补一次渲染。知识库、设置、监督、笔记、右侧栏、项目切换器改为 `memo`,知识库 26 个回调移到
|
||||
`use-knowledge-workspace-actions.ts`,其余用 `useStableHandlers`;笔记面板元素 `useMemo`。
|
||||
- **任务与结果(`task-store.ts` / `task-selectors.ts` / `task-sync.ts`):** `assistantTasks`、
|
||||
`assistantArtifacts` 移出 App state,`assistantTasksRef` 删除。App 只选取顶层任务状态、活动摘要
|
||||
所需字段和当前会话是否忙碌,子代理进度不再重渲染 App;结果刷新时内容相同的对象保留引用,
|
||||
会话面板自己读结果表。`agent-event-handler.ts` 只改了任务/结果调用。队列状态未迁移:它和发送、
|
||||
恢复、中断流程交织,单独拆风险高于收益。
|
||||
- **切换会话:** 面板顺序改为渲染期派生(`usePaneOrder`),Composer 弹窗按上下文键关闭,
|
||||
不再在渲染中 setState,切换一次只渲染 App 一次(测试在旧代码上为 2 次)。标题表在标题不变时
|
||||
保持同一 Map。
|
||||
- **规模:** `App.tsx` 9,576 → 7,904 行(含期间合入的监督者改动);App 内 useState 120→114、
|
||||
useEffect 62→56、useRef 79→73、useCallback 89→83。lint 上限 9,600 → 8,000。
|
||||
- **`npm run perf:app` A/B**(改动前为 `b28ddd4` 临时 worktree 构建,与 `6b634c5` 交替各 3 轮,
|
||||
4× 降速,取中位数,括号为 3 轮范围):
|
||||
|
||||
| 场景 | 改动前 | 改动后 |
|
||||
| --- | --- | --- |
|
||||
| 首次切换会话 延迟 p95 | 525 ms(520–674) | 489 ms(470–607) |
|
||||
| 首次切换 Long Tasks 数 / 总时长 | 37 / 3.9 s | 24 / 2.0 s |
|
||||
| 缓存切换(warm)延迟 p95 | 254 ms(208–256) | 139 ms(135–149) |
|
||||
| 缓存切换 Long Tasks 数 / 总时长 | 21 / 1.7 s | 12 / 0.7 s |
|
||||
| 热切换(hot)延迟 p95 | 139 ms | 94 ms(77–154) |
|
||||
| 流式 40 KB:Long Tasks 数 / 总时长 / 帧 p95 | 90 / 7.0 s / 208 ms | 49 / 3.1 s / 108 ms |
|
||||
| 流式期间输入:延迟 p95 / Long Tasks 总时长 / 帧 p95 | 110 ms / 8.3 s / 233 ms | 53 ms / 3.5 s / 100 ms |
|
||||
| 输入(有 300 个会话)延迟 p95 | 11.7 ms | 14.1 ms(12.6–15.9,噪声范围内) |
|
||||
|
||||
流式两个场景的掉帧率仍接近 100%(4× 降速下每帧仍有 Markdown 解析和 250 ms 吸收),但 Long
|
||||
Tasks 减半。不降速一轮 `GB_PERF_CHECK=1`:60 项阈值全部通过。
|
||||
|
||||
### PERF-14 长列表虚拟化
|
||||
|
||||
#### 2026-10-03 消息时间线窗口化
|
||||
|
||||
- **实现(`chat-message-window.ts`、`ChatHistoryPane.tsx`、`ChatTimeline.tsx`):** 自研窗口化,
|
||||
未引入依赖。只挂载视口附近的行(上限 60 行,外加获得焦点的行),其余用按实测高度或
|
||||
平均高度估算的占位块代替;行高按会话缓存(最多 24 个会话)。以视口内的一行为锚点,
|
||||
行高变化或挂载/卸载时修正滚动位置,读者看到的内容不动。
|
||||
- **保留:** 96 px 贴底阈值和流式时每帧跟随;发送消息回到底部;“加载更早”仍按 80 条扩展
|
||||
可滚动范围;笔记跳转先挂载目标再滚动;隐藏/显示和重新挂载恢复位置;隐藏面板不测量。
|
||||
分帧补全(`09c415b`/`855b941`)已删除,打开面板一次提交有界行数。
|
||||
- **滚动滞后窗口:** 视口离开已挂载范围 160 px 内才重新取窗,取窗时挂载 1.5 屏余量,避免
|
||||
每个滚动事件都重挂行。
|
||||
- **毛玻璃锚点:** 锚点以 `.chat-content` 的上内边距为基准,切换毛玻璃时阅读位置不变
|
||||
(由 `tests/frosted-glass.electron.test.ts` 在真实 Electron 中验证,该测试改为按消息 id 跟踪
|
||||
锚点行)。
|
||||
- **已知限制:** 页内查找搜不到未挂载的消息;快速甩动时可能短暂看到空白占位;单条超长
|
||||
消息仍整行挂载;估算高度会让滚动条滑块在首次经过时轻微变化。
|
||||
|
||||
### PERF-15/16 第一片:热点读路径移出 Main
|
||||
|
||||
#### 2026-10-03 只读查询 worker
|
||||
|
||||
- **实现(`readonly-query-reader.ts`、`readonly-query-worker.ts`):** 每个数据库文件一个只读
|
||||
worker 线程,沿用 `execution-stats-reader` 的模式,复用 `KnowledgeDatabase` /
|
||||
`AssistantDatabase` 的生产方法,结果与同步路径同源。最多 32 个并发请求;worker 崩溃或
|
||||
启动失败时转为同步执行,30 秒后再重启;`:memory:` 始终同步。`AbortSignal` 通过共享标志
|
||||
取消(向量扫描每 256 行检查)。没有新增 `DatabaseSync` 白名单。
|
||||
- **已移出 Main:** 知识库检索(`retrieve`、`searchHybrid`);Story Graph 读取(Agent 通过 MCP
|
||||
调用,先在 SQL 中按范围预筛,超过 5 万个对象返回 `scan_limit_exceeded`);会话搜索(一条
|
||||
SQL + 注册函数,保持 `toLocaleLowerCase().includes()` 语义)。会话列表的 N+1 查询改为两次
|
||||
批量查询,IPC 结构不变。
|
||||
- **`npm run perf:knowledge`(改用 Electron 运行):**
|
||||
|
||||
| 文本块 | 混合检索时 Main 最大事件循环延迟(同步 → worker) | 单次耗时(同步 / worker) |
|
||||
| --- | --- | --- |
|
||||
| 5,000 | 54.9 → 19.5 ms | 47.7 / 53.3 ms |
|
||||
| 20,000 | 204.3 → 19.6 ms | 188.6 / 198.9 ms |
|
||||
| 50,000 | 489.9 → 18.6 ms | 466.8 / 478.6 ms |
|
||||
|
||||
- **剩余:** 向量检索仍是全表扫描(只是不在 Main 上);写入和其余约 530 处同步调用仍在
|
||||
Main;独立数据进程和 `DatabaseSync` 白名单缩减未开始。
|
||||
|
||||
#### 2026-10-03 三项合并后的 `npm run perf:app` A/B(改动前为 `53b9ecb`)
|
||||
|
||||
不降速:新版本通过全部 60 项阈值。4× 降速(改动前 3 轮,改动后 3 轮,滚动场景另取调整后
|
||||
2 轮):
|
||||
|
||||
| 场景 | 改动前 | 改动后 |
|
||||
| --- | --- | --- |
|
||||
| 首次切换会话 p50 / p95 | 335–377 / 677–730 ms | 198–312 / 518–634 ms |
|
||||
| 切回刚看过的会话 p95 | 379–386 ms | 179–240 ms |
|
||||
| 切回最近的会话 p95 | 161–164 ms | 89–140 ms |
|
||||
| 打开 2,000 条消息会话 | 448–484 ms,帧 p95 200–225 ms | 290–354 ms,帧 p95 8–9 ms |
|
||||
| 滚动长会话:帧 p95 / 掉帧 | 17 ms / 7% | 58–67 ms / 15–16% |
|
||||
| 流式输出、流式时打字 | 两版波动都很大,未见一致变化 | 同左 |
|
||||
| 打字、空闲 | 持平 | 持平 |
|
||||
|
||||
滚动是唯一变差的场景:改动前长会话一次性挂载所有已加载行,滚动只是移动画面;窗口化后
|
||||
每隔几屏要挂载新行(含 Markdown 渲染)并测量。初版每个滚动事件都重新取窗,掉帧约 70%;
|
||||
加入滞后窗口后降到 15–16%。剩余开销主要在新行渲染和 `getBoundingClientRect` 测量,后续
|
||||
可把锚点测量合并到每帧一次,或给 Markdown 结果加缓存。
|
||||
|
||||
#### 2026-10-03 第二轮并行:滚动、Markdown 缓存、侧栏窗口化、App 继续瘦身、活动记录
|
||||
|
||||
- **滚动(`ChatHistoryPane.tsx`、`chat-message-window.ts`):** 滚动事件只读 `scrollTop` 等
|
||||
滚动容器指标;是否重新取窗改为每帧最多判断一次,并用“锚点行 + 缓存行高”推算视口位置,
|
||||
稳定滚动时不读元素几何。提交后只有挂载集合或占位高度变化时才校正锚点;上内边距缓存,
|
||||
在 resize、激活和重新取锚点时刷新(真实 Electron 毛玻璃测试曾因缓存旧边距偏 105 px,已修)。
|
||||
- **Markdown 渲染缓存(`markdown-render-cache.ts`):** 按源文本和管线配置缓存解析后的 hast
|
||||
树(LRU),重新挂载的行跳过 micromark/remark/rehype/KaTeX,只做 hast → React。
|
||||
- **侧栏窗口化(`use-conversation-list-window.ts`):** 60 个会话以上只挂载视口附近的行,
|
||||
活动会话、菜单、重命名、获得焦点的行保持挂载;`aria-setsize`/`aria-posinset` 保留总数。
|
||||
- **App 继续瘦身:** 会话持久化与历史加载(`conversation-persistence.ts`)、刷新调度
|
||||
(`conversation-refresh.ts`)、Agent 事件处理(`agent-event-handler.ts`)、历史面板
|
||||
(`ConversationHistorySlot.tsx`)移出 App。`App.tsx` 从约 10,960 行降到 9,566 行,lint 上限
|
||||
改为 9,600。
|
||||
- **活动记录(`activity-history-plan.ts`):** Main 的整份替换只校验、序列化变化的记录;
|
||||
新增增量 IPC `activity-history:update`。Renderer 还没有改用增量接口。
|
||||
- **会话读取:** `conversations:list-summaries` 和 `conversations:get` 也改由只读 worker 执行。
|
||||
- **`npm run perf:app` 驱动:** 侧栏窗口化后不再一次挂载全部会话,驱动改为按 `aria-setsize`
|
||||
判断加载完成,点击会话前逐屏滚动侧栏直到该行挂载。
|
||||
|
||||
`npm run perf:main`(1,000 个会话 / 2 万条消息 / 5,000 条活动记录,Main 事件循环最大延迟):
|
||||
|
||||
| 路径 | 改动前 | 改动后 |
|
||||
| --- | --- | --- |
|
||||
| 会话摘要列表 | 25.4 ms | 0.8 ms(单次耗时 31 ms,在 worker 上) |
|
||||
| 活动记录整份替换,无变化 | 13.8 ms | 9.3 ms |
|
||||
| 活动记录增量更新 1 条 | — | 5.3 ms |
|
||||
|
||||
`npm run perf:app`,4× 降速,改动前(`169941c`)与改动后各 3 轮:
|
||||
|
||||
| 场景 | 改动前 | 改动后 |
|
||||
| --- | --- | --- |
|
||||
| 滚动长会话:帧 p95 / 掉帧 | 25–50 ms / 10–11% | 17–41 ms / 9–10% |
|
||||
| 打开 2,000 条消息会话:长任务 / 点击 p95 | 165–170 / 238–247 ms | 97–105 / 211–218 ms |
|
||||
| 首次切换会话:长任务 / 点击 p95 | 1,969–2,115 / 444–460 ms | 1,256–1,341 / 425–435 ms |
|
||||
| 切回刚看过的会话:长任务 / 点击 p95 | 1,084–1,183 / 151–158 ms | 152–205 / 110–146 ms |
|
||||
| 流式输出 40 KB:长任务总时长 / 掉帧 | 1.6–2.4 s / 90–99% | 0.33–0.41 s / 63–72% |
|
||||
| 流式时打字:长任务 / 按键 p95 | 3.0–3.6 s / 41–48 ms | 0.53–0.64 s / 29–43 ms |
|
||||
| 有历史时打字:DOM 节点 | 4,382 | 705–719 |
|
||||
|
||||
不降速:全部阈值通过;流式输出和流式时打字的长任务均为 0。上一轮滚动的退化已消除(本轮基线
|
||||
已含窗口化,故与 `53b9ecb` 的 17 ms / 7% 不完全可比,帧 p95 在两版中都波动较大)。
|
||||
|
||||
#### 2026-10-03 本期收尾:已做、暂缓与理由
|
||||
|
||||
本期收尾只做低风险改动:
|
||||
|
||||
- **侧栏 id 列表保持同一数组**(`ConversationSidebar.tsx`):流式更新时,会话对象每次都会变,
|
||||
原来每次都生成新的 id 数组,导致侧栏窗口化的布局 effect 重跑并读取 `scrollTop`。4× 降速剖析
|
||||
显示,这段在流式场景中占 562 ms self time。现在 id 不变时,数组也保持不变。
|
||||
- **会话标题跑马灯不在挂载时强制布局**(`OverflowMarquee.tsx`):挂载时不再同步读取
|
||||
`scrollWidth`,改由 ResizeObserver 在首次报告时测量,有回归测试。悬停时的测量方式不变。
|
||||
- 4× 降速下复测,这两项的变化都在波动范围内(同版本多次运行,差距可达 2 倍),没有出现退化;
|
||||
不降速时 60 项阈值全部通过。
|
||||
|
||||
**本期暂缓,理由如下:**
|
||||
|
||||
| 项目 | 理由 |
|
||||
| --- | --- |
|
||||
| Renderer 改用 `activity-history:update` | 需要在 App 中跟踪每次变化并处理顺序与失败重试;Main 端已经只处理变化的记录,Main 卡顿从 13.8 ms 降到 9.3 ms,剩余收益小,而出错会丢活动记录 |
|
||||
| 数据库写入异步化、独立数据进程(PERF-15/16 剩余部分) | 涉及约 530 处调用和写入顺序语义,属于高风险大改动;本期已把最重的读取路径(检索、会话列表、搜索)移出 Main,需要单独立项 |
|
||||
| 首次切换会话(4× 降速 p95 约 430 ms) | 剖析显示,开销分散在 React 渲染、首次 Markdown 解析和 i18n 上,没有单一热点;不降速时 p95 约 70 ms,已在阈值内 |
|
||||
| 流式输出 Markdown 放进 worker | 需要重写渲染管线,并处理流式中途结果和公式;不降速时流式场景已无长任务 |
|
||||
| 页内查找覆盖未挂载的消息 | 当前 App 没有页内查找功能(未调用 `findInPage`),属于新功能,不是回归 |
|
||||
| 性能阈值接入 CI | 仓库目前没有 CI 工作流;`npm run perf:check` 可以在本地运行 |
|
||||
|
||||
### 7.2 按性能原则补齐差距(2026-10-03,第三轮并行)
|
||||
|
||||
统一标准见 `docs/architecture/performance-principles.md`(P1–P10、性能预算和评审清单)。本轮按其中“当前差距”并行推进:
|
||||
|
||||
| 原则 | 改动 | 测量结果 |
|
||||
| --- | --- | --- |
|
||||
| P1 | 文档解析、PPTX 提取、分块、大文件(≥ 1 MiB)哈希移到 `document-parse-worker` 线程,FIFO 队列;worker 不可用或崩溃时退回原来的 Main 内联执行,30 秒后重试;中止正在执行的任务会终止 worker。数据库写入仍由调用方按原顺序执行 | `npm run perf:document-parse`,各 3 轮:Main 最长阻塞 large.pdf 498–679 → 16–17 ms,large.docx 313–335 → 17–20 ms;总耗时基本不变 |
|
||||
| P3 | 终端输出:Main 端按 8 ms 窗口合并(空闲时第一块立即发出,不增加回显延迟),单批不超过 64 KiB;控制事件(退出、错误、状态)发出前先清空缓冲,顺序不变;背压按“已发出 + 已缓冲”字节计算,暂停阈值留 1/4 余量。Renderer 端每帧最多写一次 xterm,写完后按最大序号累计 ACK;窗口不可见时 100 ms 定时兜底,保证 ACK 不中断 | 单元测试覆盖顺序、UTF-8 边界、背压、退出前清空;`perf:app` 未覆盖终端场景 |
|
||||
| P5 | 笔记列表(按卡片行窗口化,CSS subgrid)、工作区文件树(展开后拍平,按缩进显示层级)、知识库文档表格只渲染可见部分;抽出通用 `use-list-window` / `use-grid-columns`,侧栏会话列表改用同一实现。选中项、打开菜单的行、焦点行保持挂载 | 每个列表新增窗口化单元测试;真实 Electron 文件树布局测试改为验证窗口化后的停靠、滚动和预览返回 |
|
||||
| P6 | Main 启动打点(`startup-marks.ts`),新增 `npm run perf:startup`:首次运行 / 空配置 / 300 个会话三种配置,各 5 轮 | 见下表 |
|
||||
| P9 | `package.json` 新增 `perf:startup`、`perf:document-parse` | — |
|
||||
|
||||
冷启动基线(标准机,中位数 / p95,ms):
|
||||
|
||||
| 阶段 | 首次运行 | 空配置 | 300 个会话 |
|
||||
| --- | --- | --- | --- |
|
||||
| 启动到输入框可用 | 1284 / 1322 | 1024 / 1040 | 1028 / 1040 |
|
||||
| Main 模块加载(index.js 求值) | 414 / 420 | 411 / 420 | 413 / 423 |
|
||||
| 本地工具环境初始化 | 154 / 169 | 160 / 163 | 153 / 160 |
|
||||
| 知识库初始化 | 240 / 249 | 19 / 22 | 32 / 42 |
|
||||
| 助手数据库初始化(同步) | 205 / 206 | 4 / 4 | 14 / 15 |
|
||||
| Renderer 导航到输入框出现 | 228 / 233 | 226 / 244 | 224 / 228 |
|
||||
|
||||
启动的主要开销是 Main 模块加载(约 415 ms)和本地工具环境初始化(约 155 ms);首次运行另有约 450 ms 的建库开销。这些只做了测量,没有改动,后续按 P6 拆分延迟加载。
|
||||
|
||||
本轮回归:`perf:app` 60 项阈值全部通过(不降速,流式和流式打字长任务均为 0);typecheck、全量 lint 通过;全量测试中 2 个失败分别为 OpenCode 免费额度 403 的集成用例和 `magic-notes-*` 真实 Electron 测试在整套并行负载下的偶发失败,后者单独运行和 6 个 Electron 布局测试并行连跑 3 次均通过。
|
||||
|
||||
### 7.2.1 活动记录增量与分页(2026-10-04,P2/P4/P5)
|
||||
|
||||
之前启动时整份读取活动记录(本机约 2.4 万条、10 MB),任何变化 250 ms 后把整份列表 `replace` 回 Main;Main 每次在列表前面插入记录都要重写顺序 JSON。分三次提交改完:
|
||||
|
||||
1. **增量保存(`6aab78b`)。** 活动记录移出 App 状态,放进 `activity-store.ts`。领域动作(新增、更新请求、审批、按请求删除、按调用删除、结束请求、对账、清空)修改状态,同时排入对应的契约变更;`activity-sync.ts` 每 250 ms 用 `activityHistory.update` 发送(每次最多 2 万条),失败的变更按原顺序留在队列里,退避重试;退出和卸载前也会刷盘。清空改为新增的 `activityHistory.clear()`,Main 在一个事务里完成。
|
||||
2. **Main 分页、汇总、对账(`352da66`)。** 第 57 版 schema 重建 `activity_history_records`:`order_seq`(越新越大,移到最前就分配新的最大值)加上从记录 JSON 生成的存储列(状态、类型、请求、会话、项目、创建时间)和索引。顺序从 `record_order_json` 原样迁移;旧数据里的重复 ID 保留,仍然只显示第一条。`ActivityHistoryRepository` 负责全部 SQL:按索引倒序分页 `page`,`summary` 返回面板原来从整份列表算出的计数、会话标题和会话状态,`reconcile` 用 SQL 关联任务表(追加的说明行由界面传入本地化文本),以及 `clear`。分页和汇总走只读 worker。
|
||||
3. **界面只保留已加载的页(`bf9850a`)。** 启动顺序:发送排队的变更,Main 对账,再加载首页(200 条)。打开运行记录页时补足一批(500 条,和以前首屏一样多),“继续显示”先显示下一批,再在后台加载下一页;切换筛选会重新取首页;离开页面只留一批。计数、分组、标题、会话状态和清空数量都来自 Main 的汇总。没加载的记录也照样增量更新:`update` 新增按内容匹配的变更(`upsert-call`、`update-request`、`resolve-approval`、`settle-request`、`remove-request`、`remove-call`),由 Main 在 SQL 里执行,界面对已加载的页用同一份参考实现处理。任务刷新后的对账也改在 Main 里用 SQL 完成。旧 localStorage 记录改成增量变更迁移,界面不再调用 `replace`。
|
||||
|
||||
`npm run perf:main`,2.4 万条活动记录,两轮结果一致(括号内为 Main 事件循环最大延迟):
|
||||
|
||||
| 路径 | 之前 | 之后 |
|
||||
| --- | --- | --- |
|
||||
| 启动读取 | 整份 `get`:14.2 MB,178 ms(198 ms),在 Main 上执行 | 首页 + 汇总:114 KB,16 ms,在 worker 上执行(Main 0.6 ms) |
|
||||
| 首页 200 条(同步路径,参考) | — | 1.0 ms(1.3 ms) |
|
||||
| 汇总(同步路径,参考) | — | 14 ms(15.6 ms) |
|
||||
| 保存 1 条变化 | 整份 `replace`:98 ms(114 ms) | `update`:3.9 ms(6.9 ms) |
|
||||
| 每次保存的载荷 | 整份列表(约 14 MB) | 只含变化的记录,与总量无关(单元测试:3 条变化 < 1.5 KB) |
|
||||
|
||||
测试:随机动作序列(含翻页、截断、刷盘和注入的保存失败),在真实数据库上与改动前的整份列表逻辑逐步对比,最终数据库与整份 `replace` 的结果一致(含重复 ID 和移到最前);启动后界面不超过一页;保存载荷只取决于变化的记录(含未加载的);随机数据集(含重复 ID)下,分页加汇总渲染出的计数、分组、标题、状态和清空数量与整份列表渲染一致;重启后遗留的进行中记录被标为中断,说明文本相同;清空后数据库、界面和汇总都为空;SQL 变更语义与参考实现一致。`perf:app` 60 项阈值全部通过。
|
||||
|
||||
后续(2026-10-04):汇总覆盖全部已加载会话,超过 5,000 个时分多次请求再合并,不再截断(单元测试:5,003 个会话分 5,000 + 3 两次请求,全部有标题和状态)。分组里“会话活动数”统计的仍是已加载的记录,和以前统计已显示的记录一致。
|
||||
|
||||
### 7.2.2 Main 写库与启动:真实数据测量(2026-10-04,PERF-15)
|
||||
|
||||
用便携版 `dist/GoodBuddy-windows-x64/data` 的副本测量(源目录从不被打开):`assistant.sqlite` 2.4 GB,175.5 万条任务事件(其中 148 万条子任务进度、1.1 GB),4,812 条消息(metadata 约 400 MB,单条最大 4 MB),2.4 万条活动记录。新增 `npm run perf:write`(`build/run-write-perf.cjs`,按真实数据中最忙一秒的速率 270 条/秒回放真实事件),`perf:startup` 新增 `real` 变体(`GB_PERF_STARTUP_REAL_DATA`,每次启动用新副本,外网请求全部拦截)。
|
||||
|
||||
**测到的问题:**
|
||||
1. 每次写库都按 `synchronous = FULL` 同步落盘,单次约 2.4 ms,与写入内容无关:子任务进度、工具、文本批、会话保存、活动记录都一样。
|
||||
2. SQLite 自动 checkpoint 发生在越过 1,000 页的那次提交里:大库上每隔几百次写入就有一次卡住 Main 100–350 ms(3 轮里 18 次超过 50 ms)。
|
||||
3. 启动时两条恢复语句、一条图片操作恢复语句各扫描全部消息的 metadata:在 Main 上同步约 2.3 秒,启动到输入框可用 3.7 秒。
|
||||
|
||||
**改动:**
|
||||
- **频繁的本地写入改用 `synchronous = NORMAL`**:本地运行事件(生命周期事件 `status`/`done`/`error` 除外)、渲染进程 500 ms 一次的会话保存、活动记录增量。WAL 模式下仍是原子提交,应用崩溃不丢;断电最多丢最近一段这类写入,任何 FULL 提交及其之前的数据不受影响。远程事件(Agent ACK 依赖其持久性)、通道投递、任务状态等其余写入仍为 FULL。
|
||||
- **WAL checkpoint 移到 worker 线程**:复用只读查询 worker 的入口,每 250 ms 做一次 PASSIVE checkpoint;运行期间 Main 的自动 checkpoint 阈值提到 4,000 页作为兜底(持续写入时被动 checkpoint 追不上 WAL 重置点),worker 退出即恢复默认 1,000。
|
||||
- **启动恢复按索引读候选消息**:第 58 版 schema 新增部分索引 `messages_recovery_idx`(流式中,或 metadata 含未结束的工具、子任务、图片操作状态);没有用户队列项时跳过按 queueItemId 的全表删除。metadata 只由 `JSON.stringify` 写入,状态总以紧凑形式出现,精确判断仍在候选行上用原逻辑执行。一次性迁移在升级 worker 中执行,真实数据约 2 秒。
|
||||
|
||||
**结果(真实数据):**
|
||||
|
||||
| 指标 | 之前 | 之后 |
|
||||
| --- | --- | --- |
|
||||
| 单次写库 p50(子任务 / 工具 / 文本 / 会话保存 / 活动记录) | 2.2–2.6 ms | 0.1–0.3 ms |
|
||||
| 写入期间 Main 最长阻塞(270 条/秒,3 轮) | 8–346 ms,超过 50 ms 共 37 次 | 2–34 ms,超过 50 ms 0 次 |
|
||||
| 启动:Main 上的初始化(`assistant-db-init`) | 2,564 ms | 232 ms |
|
||||
| 启动:到输入框可用(中位数,4 次) | 3,702 ms | 1,359 ms |
|
||||
| 启动:侧栏出现会话 | 4,081 ms | 1,749 ms |
|
||||
| 启动期间 Main 最长阻塞 | 2.5–2.8 s | 236–257 ms |
|
||||
|
||||
部分索引的代价:metadata 更新时如果新旧值都命中候选条件,要多维护一次索引(4 MB metadata 的更新 36 → 59 ms,233 KB 的 0.7 → 1.6 ms);不命中时没有额外开销。启动时剩下的约 0.6 s 在本地工具环境探测(已在后台初始化,不阻塞 Main)。
|
||||
|
||||
未做:读取最大的会话(407 条消息、42 MB metadata)在 worker 上约 170 ms,不阻塞 Main,但切换到这个会话时要等这么久;需要按需加载 metadata,单独立项。
|
||||
|
||||
### 7.3 体验倒退复查(2026-10-03)
|
||||
|
||||
逐一复查了所有性能提交对交互体验的影响,并新增原则 P10“体验不倒退”。发现并修复:
|
||||
|
||||
| 问题 | 来源 | 修复 | 验证 |
|
||||
| --- | --- | --- | --- |
|
||||
| “到底部”按钮的平滑滚动动画消失,直接跳到底部 | `fdf7d83` 消息窗口化:贴底跟随、ResizeObserver、锚定修正都会瞬间写入 `scrollTop`,打断平滑滚动;点击时还立即切换窗口导致可见行被卸载 | 滑动期间暂停所有滚动写入,只随视口重新窗口化;到达底部、`scrollend` 或 1.5 秒兜底时再贴底;用户滚轮、按键或点击会接管滚动 | 真实 Electron 逐帧采样,要求有中间帧(旧代码中间帧为 0),连跑 3 次通过;新增单元测试,旧代码失败 |
|
||||
| 消息滚出可见区后丢失状态:待回答问题里已选的选项和输入的内容、展开的工具详情和推理、Mermaid 重新绘制并显示“加载中” | `fdf7d83` | 有待回答问题或待审批的行、用户交互过的最近 8 行、选区两端所在的行保持挂载;Mermaid 按“主题 + 源码”缓存渲染结果(最多 64 个) | 单元测试,旧代码失败 |
|
||||
| 跨多条消息选择文本时,选区起点被卸载;复制时只拿到已挂载的文字 | `fdf7d83` | 选区两端所在的行保持挂载;复制时,未挂载的消息用原文补全 | 单元测试 |
|
||||
| 侧栏在上方插入、删除、置顶或 5 秒重排时,可见的会话行跳动 | `663aa85`,窗口化关闭了原生滚动锚定 | 会话 id 变化时,在绘制前按第一条可见行恢复位置;列表在顶部时不锚定 | 单元测试,旧代码失败 |
|
||||
| 笔记、文件树、知识库文档表格的锚定修正可能和浏览器原生锚定冲突 | `b2eb86a` | 窗口化状态下关闭 `overflow-anchor` | 单元测试 |
|
||||
| 终端打字回显多等一帧;点击“清屏”前到达的输出在清屏之后才显示 | `f4458af` | 空闲时立即写入,只合并同一帧内后续到达的输出;清屏前先写完待写入的输出 | 单元测试 |
|
||||
| 远程 Runtime 流式输出最多 50 ms 一批,显示一顿一顿 | `d859cbf` | 合并间隔改为 16 ms;写库成功后立即转发,仍然是先写库再转发 | 单元测试 |
|
||||
| 流式输出时从侧栏复制或导出会话,可能缺最后不超过 250 ms 的文字 | PERF-13 流式缓冲 | 复制、导出前先把待同步的流式文字刷进状态 | 单元测试,旧代码失败 |
|
||||
|
||||
检查后没有问题:CSS 动画和过渡没有被删改;其余 `scrollIntoView` 的行为和原来一致;标题跑马灯、侧栏 5 秒重排的悬停冻结、重命名和菜单行保持挂载;输入框自动增高;Markdown 和 KaTeX 渲染结果。快速大跨度跳转是否出现空白帧也做了实测:在 `perf:app` 滚动场景中加了检查,CPU 降速 4 倍时 10 次跳转中空白帧为 0,所以没有改动。
|
||||
|
||||
已知限制(不是倒退,原来也没有):读屏软件的浏览模式访问不到未挂载的消息;应用本身没有页内查找功能。
|
||||
|
||||
修复记录:文件树窗口化最初以 `.assistant-sidebar__body` 为滚动容器,但真实布局中滚动的是停靠的 `.workspace-files__files-view`,真实 Electron 测试发现后改为优先使用后者。
|
||||
|
||||
### PERF-14 状态
|
||||
|
||||
- **优先级 / 状态:** P2 / 进行中(消息时间线和侧栏已窗口化;页内查找等限制未解决)
|
||||
- **实施条件:** `PERF-13` 完成会话与消息迁移,行组件 props 已稳定。
|
||||
- **范围:** 消息时间线和会话侧栏优先;保留滚动位置、跳转、展开状态和“加载更早消息”语义。
|
||||
替换新面板分两步渲染(`09c415b`);侧栏 5 秒重排状态随侧栏迁出 App。
|
||||
- **验收:** 5,000 条消息和 1,000 个会话场景下 DOM 节点数有界,滚动与切换指标改善。
|
||||
|
||||
### PERF-15 存储接口异步化
|
||||
|
||||
- **优先级 / 状态:** P1 / 进行中(热点读走 worker;高频写降为 NORMAL、checkpoint 移出 Main、启动恢复按索引,见 7.2.2;接口异步化未开始)
|
||||
- **范围:** 在原进程内把 `AssistantDatabase`、`KnowledgeDatabase` 等对外接口改为异步,
|
||||
调用方全部 `await`;会话列表只返回摘要,搜索下推到 SQL,活动记录改为增量追加。
|
||||
远程 Runtime 实时事件改用已有的 `appendRemoteTaskEventsBatch` 批量写入,不再逐条同步事务。
|
||||
Story Graph 检索先补测耗时,扫描规模设上限,匹配尽量下推到 SQL。
|
||||
- **验收:** 行为与测试不变;`PERF-11` Main event-loop 延迟不变差。
|
||||
|
||||
### PERF-16 数据进程
|
||||
|
||||
- **优先级 / 状态:** P1 / 待开始
|
||||
- **实施条件:** `PERF-15` 完成,调用方已全部异步。
|
||||
- **范围:** 新增独占 SQLite 的 `utilityProcess`,承接会话、知识库、向量检索、Story Graph
|
||||
(含 Agent 主动调用的检索)、远程 Runtime 事件持久化、活动记录及文档解析;Main 只转发;
|
||||
保证同一时刻只有一个写入方。吸收 `PERF-06` 的范围。
|
||||
- **验收:** 大知识库检索、会话列表和文档导入期间 Main event-loop 延迟达到批次目标;取消、
|
||||
崩溃恢复和迁移语义不回退。
|
||||
|
||||
### PERF-17 架构守护
|
||||
|
||||
- **优先级 / 状态:** P2 / 待开始
|
||||
- **范围:** lint 禁止 `src/main` 使用同步文件 API 和在数据进程外引用 `DatabaseSync`;禁止
|
||||
组件直接订阅 `window.goodbuddy.*.on*`;限制 `App.tsx` 规模;`PERF-11` 关键指标设置回归阈值。
|
||||
- **验收:** 违反规则的改动在 lint 或基准阶段失败。
|
||||
|
||||
## 8. 体验与可访问性
|
||||
|
||||
### UX-01 统一 Ask/Execute 文案
|
||||
|
||||
@@ -73,6 +73,15 @@ export function stableMainEntryFileName(chunk: {
|
||||
: '[name].js'
|
||||
}
|
||||
|
||||
// The renderer CSP has no font-src, so fonts fall back to default-src 'self'.
|
||||
// Small fonts (e.g. KaTeX) must stay files rather than data: URLs, or Chromium
|
||||
// refuses them. Other assets keep Vite's default inline threshold.
|
||||
export function rendererAssetInlineLimit(
|
||||
filePath: string
|
||||
): false | undefined {
|
||||
return /\.(?:woff2?|ttf|otf)(?:$|\?)/iu.test(filePath) ? false : undefined
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
main: {
|
||||
plugins: [
|
||||
@@ -124,6 +133,8 @@ export default defineConfig({
|
||||
index: resolve('src/main/index.ts'),
|
||||
'assistant-storage-worker': resolve('src/main/assistant-storage-worker.ts'),
|
||||
'execution-stats-worker': resolve('src/main/execution-stats-worker.ts'),
|
||||
'readonly-query-worker': resolve('src/main/readonly-query-worker.ts'),
|
||||
'document-parse-worker': resolve('src/main/document-parse-worker.ts'),
|
||||
'wechat-sidecar': resolve(
|
||||
'src/main/channels/wechat-sidecar.ts'
|
||||
),
|
||||
@@ -192,6 +203,9 @@ export default defineConfig({
|
||||
worker: {
|
||||
format: 'es'
|
||||
},
|
||||
build: {
|
||||
assetsInlineLimit: rendererAssetInlineLimit
|
||||
},
|
||||
plugins: [react()]
|
||||
}
|
||||
})
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user