diff --git a/.github/workflows/python-tests.yml b/.github/workflows/python-tests.yml index c50bc71fa..01b6cc882 100644 --- a/.github/workflows/python-tests.yml +++ b/.github/workflows/python-tests.yml @@ -57,6 +57,8 @@ jobs: run: python -m mypy - name: Qualify agent-facing CLI output + env: + LOOPX_CLI_OUTPUT_BASE_REF: origin/${{ github.event.pull_request.base.ref || 'main' }} run: python examples/control_plane/cli-output-budget-regression-smoke.py - name: Run fast tests diff --git a/AGENTS.md b/AGENTS.md index b49a73f99..a7a4aa05f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,9 +5,13 @@ ### Worktree And PR Gate For any tracked repository change beyond a trivial typo fix, create or use a -dedicated clean `git worktree` on a `codex/` branch from latest `origin/main` -before editing files. Do not implement changes directly in a dirty primary -worktree, even when the task starts by inspecting that dirty tree. +dedicated clean `git worktree` on a `codex/` branch. Use latest `origin/main` +unless the user explicitly names an integration or release branch; in that +case, fetch that branch and use its latest remote head as both the worktree +baseline and pull-request base. Before pushing, verify the merge base and PR +base so unrelated `main` history cannot leak into a stacked integration PR. +Do not implement changes directly in a dirty primary worktree, even when the +task starts by inspecting that dirty tree. When a dirty worktree contains potentially valuable changes, first classify it read-only, then copy or reapply the valuable subset into the dedicated clean diff --git a/README.md b/README.md index 51c9df15d..49b892b75 100644 --- a/README.md +++ b/README.md @@ -324,6 +324,7 @@ evidence → recovery; continuation → governance. | Quota and interaction contract | Decides whether a turn should deliver, ask, wait, self-repair, or stay quiet. | `loopx quota should-run`, [quota allocation](docs/quota-allocation.md) | | Agent runtime bridges | Keeps Codex App, Codex CLI, Claude Code, and generic workers aligned with the same guard. | `loopx heartbeat-prompt`, `loopx codex-cli-bootstrap-message`, `loopx worker-bridge` | | Operator surfaces | Renders compact status without making the browser the state authority. | `loopx serve-status`, [dashboard](apps/presentation/dashboard/README.md) | +| Session dash | Starts a live single-page panel that tracks fleet progress: sessions, their goals, and each goal's status/todo progress, with result statistics; auto-refreshes in place. | `loopx dash`, [session dash design](docs/product/surfaces/session-dash-panel-design.md) | | External projections | Projects todos and gates into collaboration surfaces while LoopX remains authoritative. | `loopx lark-kanban`, [Lark Kanban adapter](docs/integrations/lark-kanban-control-plane-adapter.md) | | Domain capabilities | Packages repeatable work lanes such as issue fixing, content operations, value connector planning, ML experiment advice, benchmark evidence, and Explore. | `loopx issue-fix`, `loopx content-ops`, `loopx value-connectors`, `loopx ml-experiment`, `loopx benchmark`, [Explore](loopx/capabilities/explore/README.md) | | Experimental context learning | Lets named registered agents trial provider-neutral Reward Memory through ignored, default-off project configuration. OpenViking is one provider option, not a global dependency. | `loopx reward-memory experiment-status`, [Reward Memory architecture](loopx/capabilities/reward_memory/README.md) | diff --git a/apps/presentation/dashboard/README.md b/apps/presentation/dashboard/README.md index 9f8d41581..af0a879aa 100644 --- a/apps/presentation/dashboard/README.md +++ b/apps/presentation/dashboard/README.md @@ -116,12 +116,36 @@ generated site artifact. ## Run +From any directory after installing LoopX: + +```bash +loopx dashboard +``` + +This command installs the dashboard's npm dependencies on first run, then +starts the Vite UI together with the loopback status and Chat services. Open +`http://127.0.0.1:5173/` after the readiness messages appear. + +The equivalent source-checkout command remains available for dashboard +development: + ```bash npm ci npm run build npm run dev ``` +`npm run dev` starts the Vite UI together with the loopback status and Chat +services on ports `5173`, `8766`, and `8767`. Use `npm run dev:web` when those +LoopX services are already running separately. Vite proxies the default +`/status.json` request to port `8766`, so an SSH user only needs to forward port +`5173` for the normal development page. + +The live `/status.json` route keeps repository-wide public-boundary scanning +out of the first-screen request. Its contract projection reports that scan as +deferred; run `loopx check` before publishing or pushing public surfaces to +perform the complete boundary audit. + The default screen is the Chinese-first control-plane home. It is meant to answer the operator's first questions before raw status drill-down: which project line is active, which user todo is truly blocking, which agent todo is @@ -281,6 +305,43 @@ local inspection file only. For public demos, use the sanitized You can also import a JSON file directly in the browser, or load a local API URL that returns the same `loopx --format json status` shape. +## Live Single-Page Session Dash + +The primary way to watch session task progress is a loopback single-page panel: + +```bash +loopx dash # serve at http://127.0.0.1:8767/ (auto-refresh every 10s) +loopx dash --goal-id # narrow the panel to one goal +``` + +Open the printed URL in any browser and keep it open while the agents work. +The page is a human-focused fleet view: an overview strip of sessions, goals, +active / needs-you / blocked / done buckets, open todos and run statistics, +followed by one card per session with the goals it owns and each goal's +status badge, todo progress bar, waiting reason, and latest run. It refreshes +itself in place every 10 seconds by re-fetching the `/panel` fragment. +Internal control machinery (decision frames, work-lane contracts, quota slot +math, source warnings) is intentionally not rendered. The panel is +read-only: no write controls, no browser write authority. The server binds +loopback only and exposes no write routes. + +A one-shot static snapshot is also available for demos or sharing: + +```bash +loopx dash generate [--goal-id ] --out dash.html +``` + +Open `dash.html` in any browser. The command runs the public/private +boundary scan before reporting success and withholds output on failure. + +```bash +# print the projection + html as JSON instead +loopx --format json dash generate --goal-id +``` + +See [the session dash panel design](../../../docs/product/surfaces/session-dash-panel-design.md) +for the layout, data boundary, and validation contract. + ## Browser Smokes Dashboard browser smokes are explicit because they start a temporary Vite diff --git a/apps/presentation/dashboard/chat/index.html b/apps/presentation/dashboard/chat/index.html new file mode 100644 index 000000000..810a41232 --- /dev/null +++ b/apps/presentation/dashboard/chat/index.html @@ -0,0 +1,22 @@ + + + + + + + + + + LoopX 个人 Agent 工作区 + + +
+ + + diff --git a/apps/presentation/dashboard/design.md b/apps/presentation/dashboard/design.md new file mode 100644 index 000000000..325190ee0 --- /dev/null +++ b/apps/presentation/dashboard/design.md @@ -0,0 +1,710 @@ +# LoopX Personal Agent Workspace — Final Design + +## Status + +- Product surface: personal Goal and Agent workspace +- Primary interaction: channel-style Chat +- Default Agent: Codex when healthy and compatible +- Audience: one owner managing personal Goals, Todos, Agent work, and recurring execution +- Implementation target: `apps/presentation/dashboard` +- Design reference: [final desktop mockup](public/showcase/loopx-personal-agent-control-plane-final.png) +- Review state: owner approved the first-screen direction on 2026-08-12 + +![LoopX personal Agent control plane final design](public/showcase/loopx-personal-agent-control-plane-final.png) + +## Product Decision + +LoopX uses one personal workspace with three layers: + +1. A single left sidebar for the Manager and Goal directory. +2. A central channel timeline for conversation, progress, decisions, and outputs. +3. A contextual right drawer for focused inspection and action. + +The main surface is optimized for reading and conversation. Rows do not repeat +`View`, `Correct`, `Open`, `Approve`, or `Pause` buttons. A row is the entry; +selecting it opens the right drawer, where the relevant controls and focused +Chat are available. + +Natural-language requests are a first-class control path. The owner can ask +LoopX to create a Goal, assign an Agent, add Todos, configure a Goal heartbeat, +or create a recurring monitor. A durable or high-impact request produces a +structured preview before LoopX writes state or starts work. + +## User Outcomes + +The product must answer five questions with little interpretation: + +1. What needs me now? +2. What are my Agents doing? +3. What changed or finished recently? +4. How do I correct the active work without losing context? +5. How do I describe a Goal or recurring job and let LoopX configure it safely? + +## Design Principles + +1. **Chat is the workspace.** Conversation and durable LoopX projections share + one chronological surface. +2. **Browse first, act after selection.** Lists stay quiet; controls appear in + the contextual drawer. +3. **One visible primary action.** A preview or gate exposes one emphasized + transition. Secondary paths live in a compact overflow menu. +4. **Natural language becomes typed intent.** LoopX classifies the request, + shows the target and impact, then applies a verified transition. +5. **Correction preserves continuity.** A message sent from a running detail + drawer continues the selected Goal × Agent Session whenever it is + recoverable. +6. **Agent identity and authority stay visible.** Every execution, proposal, + and output names its Agent and relevant permission boundary. +7. **Technical state is available on demand.** Raw ids, quota receipts, + registry findings, and transport diagnostics stay collapsed. +8. **Every visible claim has lineage.** Progress and completion derive from + Todo, run, gate, artifact, or public-safe event projections. + +## Information Architecture + +```text +LoopX Personal Workspace +├── LoopX Manager +│ ├── needs-you digest +│ ├── active Agent work +│ ├── recent outputs +│ ├── cross-Goal conversation +│ └── natural-language action proposals +├── Goals +│ └── Goal Channel +│ ├── Chat +│ ├── Tasks +│ └── Files +├── Context Drawer +│ ├── user decision +│ ├── Todo detail +│ ├── Run / Session detail and correction +│ ├── heartbeat or monitor detail +│ └── artifact preview +└── Settings + ├── Agent endpoints + ├── workspace mappings + ├── permission profiles + └── credential references +``` + +## Desktop Shell + +```text +┌────────────────────┬──────────────────────────────────┬──────────────────────┐ +│ LoopX │ LoopX Manager Codex ▾ │ Context drawer │ +│ │ │ │ +│ Manager 3 │ Channel timeline │ Selected object │ +│ │ │ context │ +│ Goals │ Messages │ │ +│ ● Goal A 2 │ Progress / decision cards │ Focused Chat or │ +│ ● Goal B 1 │ Action previews │ one primary action │ +│ ○ Goal C │ Outputs │ │ +│ │ │ Advanced diagnostics │ +│ Settings / owner │ [Agent ▾] Composer │ collapsed │ +└────────────────────┴──────────────────────────────────┴──────────────────────┘ +``` + +### Single sidebar + +The narrow icon rail is removed. The sidebar contains: + +- `LoopX Manager`, including the current attention count; +- the active Goal directory; +- one semantic state and optional count per Goal; +- Settings and owner identity at the bottom. + +The Goal row state vocabulary is intentionally small: + +- `Needs repair` +- `Needs you` +- `Waiting` +- `In progress` +- `Complete` +- `Quiet` + +Completed Goals can move behind a filter. Raw Goal ids never lead the visible +row. + +### Central channel + +The central surface remains conversational in both contexts: + +- Manager Channel: cross-Goal digest, questions, and action proposals. +- Goal Channel: Goal-scoped conversation, Todo progress, executions, gates, + and outputs. + +When a Goal is selected, compact `Chat`, `Tasks`, and `Files` tabs may appear +under the header. They are navigation, so they use text-tab treatment and do +not compete with actions. + +### Context drawer + +The drawer opens only after the owner selects a row or timeline object. Closing +it expands the central channel. It supports five content modes through one +stable container: + +1. Decision detail +2. Todo detail +3. Run / Session detail +4. Heartbeat or recurring-monitor detail +5. Artifact preview + +The drawer header states the selected object and its source Goal. `Advanced +diagnostics` remains collapsed at the bottom. + +## Manager Channel + +The Manager is the default route when no Goal is selected. Its initial digest +prioritizes: + +1. `Needs you` +2. `Agents working` +3. `Recent outputs` + +Each item is a full-row entry with a status, short reason, and chevron. Lists +contain no repeated action buttons. Selecting a row opens the matching drawer. + +After the owner sends a message, normal messages and structured proposal cards +join the same timeline. Useful quick prompts remain limited to two or three +examples, such as: + +- `What should I do now?` +- `Summarize today's progress.` +- `Create a recurring monitor.` + +Fast status questions may use the deterministic local projection. Planning, +judgment, and execution requests route to the selected Agent. + +## Goal Channel + +The Goal header shows one compact summary: + +```text +LoopX project development +Codex · In progress 2/3 · 1 item needs you +``` + +The timeline supports: + +1. owner messages; +2. Agent replies; +3. Agent Todo progress; +4. execution progress; +5. user decisions and gates; +6. artifacts and evidence; +7. action proposals and application receipts. + +Chat prose cannot mark a Todo complete. Completion appears only after a +durable LoopX transition or validated execution event. + +## Run And Session Presentation + +LoopX distinguishes four related concepts: + +| Concept | Purpose | Visible treatment | +| --- | --- | --- | +| Chat Session | Persistent Goal × Agent × channel conversation context | Hidden behind channel history | +| Run Session | One Agent execution attempt for a Todo or requested action | Human-readable execution row/card | +| Turn | One owner or Agent interaction within a Chat Session | Timeline message or streamed reply | +| Event | Ordered progress, state, or output signal | Compact meaningful update; raw events stay collapsed | + +The first screen uses a readable label such as `Codex · Review MR 3559`. +`session_id`, upstream thread identifiers, and transport details stay inside +advanced diagnostics. + +An active execution row shows: + +- Goal; +- Agent; +- task label; +- compact progress fraction or phase; +- latest meaningful activity; +- semantic status; +- one chevron. + +Selecting the row opens Run detail. + +## Same-Session Agent Correction + +The Run detail drawer contains a focused `Correct with ` conversation. +It includes the selected Goal, Todo, Run, and Agent scope automatically. + +Example: + +```text +Owner +Focus on permission and data-leak risks. Do not submit yet. + +Codex +Understood. I will stay read-only, finish the risk list, and wait for approval. + +[Add direction or adjust requirements…] [Send] +``` + +Correction rules: + +1. Send a new Turn to the current recoverable Chat Session. +2. Preserve the current `goal_id`, `agent_id`, `session_id`, and selected Run + context. +3. Never transfer a running execution because the global Agent selector + changed. +4. Treat permission, workspace, protected-scope, and Agent-transfer changes as + typed action proposals that require preview. +5. Show streamed visible output and compact tool phases; exclude thought text, + raw tool output, credentials, and private paths. +6. When upstream resume fails, preserve local history and offer `Retry resume` + or `Start a new Session with this context`. Do not replay the last message + automatically. + +The compact `More` menu contains secondary runtime actions: + +- interrupt; +- retry after terminal failure; +- start a new Session; +- close the current Session. + +## Natural-Language Control + +The Manager and Goal composers are command surfaces as well as Chat inputs. +LoopX classifies each message into one of these intent families: + +| Intent | Example | Handling | +| --- | --- | --- | +| Read | `Which Agents are stuck?` | Answer from deterministic projection or selected Agent | +| Correct | `Inspect permissions first and wait before submitting.` | Continue the scoped Session when authority is unchanged | +| Goal write | `Create a Goal for the Agent control plane.` | Generate a typed preview | +| Todo write | `Add a regression-test Todo and give it to Codex.` | Generate a typed preview | +| Agent binding | `Use Claude Code for research and Codex for implementation.` | Validate endpoints and preview bindings | +| Goal heartbeat | `Keep this Goal moving every morning.` | Preview host heartbeat binding and lifecycle policy | +| Recurring monitor | `Check MR status every 30 minutes.` | Preview a bounded `continuous_monitor` Todo and schedule | +| Protected transition | `Release this version.` | Produce an explicit operator gate | + +### Impact-aware execution + +- Read-only questions execute immediately. +- Scoped conversational correction executes immediately when it stays within + the existing authority envelope. +- Durable state changes produce a structured preview. +- Protected, destructive, credentialed, production, or externally visible + actions require an explicit gate. + +Every proposal records a Goal revision or equivalent state fingerprint. +Applying a stale proposal writes nothing and asks the owner to regenerate the +preview. + +## Goal Creation Flow + +The owner can say: + +```text +Create a Goal to improve the Agent control plane. Bind the current repository, +use Codex, check progress every morning, analyze before editing, and ask me +before submitting. +``` + +LoopX responds with a single structured card: + +```text +Goal creation preview + +Name Improve the Agent control plane +Agent Codex +Workspace Current repository +Permission Repository write · confirm before submit +Heartbeat Every day at 09:00 +Stop condition Goal complete + +Initial plan +1. Inspect the current control-plane implementation and permission boundary +2. Design the implementation slices and verification plan +3. Implement and validate one bounded slice at a time + +[Create and start] Modify settings +``` + +`Create and start` performs one idempotent transaction or a resumable saga: + +1. Validate the target project and Goal id. +2. Validate the Agent endpoint, health, capability, trust scope, and workspace + mapping. +3. Create or connect the Goal and its authority boundary. +4. Write ordered initial Todos. +5. Bind the selected Agent identity. +6. Generate and bind the Goal heartbeat when requested. +7. Refresh the public-safe projection. +8. Create or resume the Goal Chat Session. +9. Start the first eligible bounded Turn only after quota and gate checks. +10. Return an apply receipt and navigate to the new Goal Channel. + +A partial failure leaves a visible resumable result. Retrying with the same +proposal id cannot duplicate the Goal, Todos, schedule, or first Turn. + +## Heartbeat And Recurring Monitor + +The interface accepts friendly language while preserving two distinct LoopX +contracts. + +### Goal heartbeat + +A Goal heartbeat wakes the host Agent to reassess and advance the Goal under +LoopX quota, gate, boundary, and scheduler rules. It is suitable for requests +such as: + +- `Keep this Goal moving every morning.` +- `Continue this Goal while there is eligible work.` + +The preview shows: + +- Goal and Agent identity; +- host surface; +- initial cadence; +- permission boundary; +- quota behavior; +- notification policy; +- stop or pause condition. + +LoopX generates the lifecycle body from `heartbeat-prompt`; the UI never asks +the owner to edit raw prompt text or RRULE syntax. + +### Recurring monitor + +A recurring monitor watches a bounded target and materializes as an Agent Todo +with `task_class=continuous_monitor`. It is suitable for requests such as: + +- `Check MR 3559 every 30 minutes and notify me on failure.` +- `Review the daily data refresh until the migration completes.` + +The preview shows: + +- target and target key; +- cadence and timezone; +- next due time; +- Agent; +- notification rule; +- boundedness through expiry, completion, or another supported stop condition; +- expected permission and cost boundary. + +Each due execution creates or resumes a Run Session and posts meaningful +progress and output back to its Goal Channel. The `Tasks` tab includes a +`Scheduled and continuous` group. Selecting a monitor opens its drawer, where +the owner can run now, pause, edit, resume, or stop it. + +## Proposal And Gate State Machine + +```mermaid +stateDiagram-v2 + [*] --> Interpreting + Interpreting --> Answered: read-only request + Interpreting --> PreviewReady: durable write + Interpreting --> GateReady: protected action + PreviewReady --> Applying: owner confirms + PreviewReady --> Cancelled: owner cancels + Applying --> Applied: receipt verified + Applying --> Stale: source revision changed + Applying --> Failed: bounded apply failure + Stale --> PreviewReady: regenerate + Failed --> Applying: safe idempotent retry + GateReady --> Applying: approved + GateReady --> Rejected: rejected + GateReady --> Deferred: deferred +``` + +Proposal cards never count as durable Goal or Todo state. Only a verified apply +receipt and refreshed projection establish success. + +## Drawer Modes + +### Decision + +- exact question and decision scope; +- reason and evidence summary; +- one primary decision; +- defer, reject, or explain in `More`; +- related execution and output. + +### Todo + +- Goal, owner, task class, status, dependencies, and next transition; +- reassign, block, defer, complete, or create successor through previewed + transitions where required. + +### Run / Session + +- human-readable identity and progress; +- latest meaningful activity; +- same-Session correction composer; +- outputs; +- compact runtime actions; +- advanced diagnostics. + +### Heartbeat / monitor + +- target, Agent, cadence, timezone, next and previous run; +- notification and stop rules; +- run now, pause, edit, resume, or stop; +- execution history. + +### Artifact + +- safe inline preview when supported; +- producing Goal, Todo, Agent, and Run lineage; +- open or export controls inside the drawer. + +## Agent Selection And Binding + +The header and composer expose a compact Agent selector. Codex is selected for +new conversations when it is healthy and compatible. + +Each choice shows: + +- display name; +- provider or adapter kind; +- availability; +- short capability summary; +- trust scope; +- workspace compatibility. + +Codex and Claude Code use their native local runtimes. Direct OpenAI or +Anthropic API-key endpoints expose the same selector contract and a bounded +read-only project tool set (`list_files`, `search_text`, and `read_file`). Tool +paths remain project-relative, sensitive directories are denied, and raw tool +results are never persisted as visible Chat messages. Durable writes still go +through typed LoopX previews and verified apply receipts. + +Selection routes the next Chat message. Existing Todo ownership remains in +`claimed_by`, and active Sessions stay attached to their original Agent. + +Endpoint commands, remote addresses, credential references, and workspace +mappings remain owner-local. The browser consumes redacted health and +capability projections. Endpoint mutation belongs in Settings and may require +an explicit local CLI or trusted host action. + +## Business Object Mapping + +```text +Goal +├── objective +├── goal_boundary +├── Agent bindings +├── user_todos +├── agent_todos +│ └── continuous_monitor Todos +├── Chat Sessions +│ └── Turns and visible events +├── Run Sessions +│ └── evidence and artifacts +├── heartbeat host binding +└── interaction_contract +``` + +| LoopX source | Surface | +| --- | --- | +| Goal directory and objective | Sidebar and channel identity | +| `goal_boundary` | Proposal permission summary and diagnostics | +| `user_todos` | Needs-you rows and decision drawer | +| `agent_todos` | Agent work rows and Goal task progress | +| `claimed_by` | Agent attribution | +| `continuous_monitor` metadata | Scheduled-and-continuous tasks and drawer | +| Chat Session snapshot | Conversation history and resume state | +| active Turn and safe events | Streaming reply and current phase | +| run history and evidence | Execution progress, receipts, and outputs | +| interaction contract | Who acts next and which transition is available | +| quota and scheduler hint | Heartbeat eligibility and cadence detail | +| Agent-management projection | Selector, binding preview, and endpoint health | +| registry findings | Needs-repair state and diagnostics | + +Presentation code consumes stable public-safe projections. It does not parse +private planning files, provider payloads, raw logs, credential material, or +local absolute paths. + +## Control-Plane API Requirements + +Existing Chat Session, asynchronous Turn, SSE, interrupt, and resume APIs remain +the transport foundation. The final workspace adds a typed action layer with +equivalent contracts: + +```text +POST /api/actions/preview +POST /api/actions/{proposal_id}/apply +POST /api/actions/{proposal_id}/cancel +GET /api/actions/{proposal_id} +``` + +The action preview request carries: + +- natural-language input; +- context kind: Manager, Goal, Todo, Run, or Schedule; +- selected Goal, Agent, and visible object identifiers; +- client-generated idempotency key. + +The response carries: + +- typed action kind; +- human-readable summary; +- normalized parameters; +- expected Goal revision or state fingerprint; +- permission and gate classification; +- dry-run or validation evidence; +- available transitions. + +Initial action kinds: + +- `goal.create` +- `goal.update` +- `todo.create` +- `todo.update` +- `agent.bind` +- `heartbeat.bind` +- `monitor.create` +- `monitor.update` +- `gate.resolve` +- `run.correct` + +`run.correct` may route directly to the current Chat Turn contract when the +authority envelope is unchanged. All other durable types follow preview and +apply. + +## Visual System + +- Canvas: `#FBFAF7` +- Panel: `#FFFFFF` +- Primary ink: `#20232B` +- Muted text: `#747A86` +- LoopX blue: `#2F66E9` +- Success: `#2DAA72` +- Attention: `#D99028` +- Failure: red, reserved for terminal or unsafe states +- Border: subtle neutral 1 px +- Radius: 10–12 px +- Shadow: minimal and limited to overlays +- Body type: 14–16 px with comfortable line height + +Button policy: + +- one emphasized primary action in an active preview or gate; +- send buttons in active composers; +- secondary actions as quiet text or overflow items; +- zero repeated action-button columns in browse lists. + +## Responsive Behavior + +### Tablet + +- collapse the Goal directory behind a drawer; +- keep the central channel full width; +- open context detail as a right sheet; +- preserve the active composer. + +### Mobile + +- show one of Goal directory, channel, or context sheet at a time; +- use a back action to preserve navigation context; +- open Agent selection as a bottom sheet; +- keep preview confirmation and correction composer above the safe area; +- maintain 44 px minimum touch targets. + +## Accessibility + +- Support keyboard navigation across sidebar rows, timeline objects, and drawer + controls. +- Expose status through text and icon semantics in addition to color. +- Move focus into an opened drawer and restore it to the selected row on close. +- Announce streamed Agent messages and durable state transitions with a polite + live region. +- Label the correction composer with its Goal, Agent, and Run target. +- Respect reduced-motion preferences. + +## Implementation Plan + +### Phase 1 — Shell and browse interaction + +- remove the narrow global icon rail; +- build the single Manager/Goal sidebar; +- convert attention, Agent work, and output lists to full-row selection; +- implement the polymorphic context drawer; +- remove repeated row action buttons. + +### Phase 2 — Same-Session correction + +- add Run-detail conversation history; +- bind correction messages to the selected Goal × Agent Session; +- stream the new Turn through existing SSE; +- expose resume failure, interrupt, retry, and new-Session recovery. + +### Phase 3 — Natural-language action proposals + +- add typed preview and idempotent apply contracts; +- implement Goal creation, ordered Todo creation, and Agent binding; +- render proposal, stale, failure, and receipt states in the channel timeline. + +### Phase 4 — Heartbeat and recurring monitors + +- classify Goal heartbeat and bounded monitor intents separately; +- generate Goal heartbeat lifecycle configuration through LoopX policy; +- create and edit `continuous_monitor` Todos; +- add schedule detail, run history, pause/resume, and stop interactions. + +### Phase 5 — Tasks, files, and Agent settings + +- complete Goal `Tasks` and `Files` views using the same row/drawer pattern; +- expose redacted endpoint health and capability choices; +- preserve local-only endpoint mutation and credential boundaries. + +### Phase 6 — Verification and rollout + +- browser E2E for every visible entry and drawer transition; +- real Chat, stream, refresh, resume, correction, interrupt, and retry tests; +- idempotent Goal/heartbeat apply tests; +- public/private boundary checks; +- first-screen screenshot comparison and owner review before finalization. + +## Acceptance Criteria + +1. The first screen contains one sidebar and no unexplained icon rail. +2. Browse lists contain no repeated action-button column. +3. Selecting any needs-you, Agent-work, schedule, or output row opens the + correct drawer. +4. The owner can identify required attention, active Agent work, and recent + output within five seconds. +5. A correction sent from Run detail reaches the same recoverable Goal × Agent + Chat Session and preserves context. +6. Refreshing the page restores visible history and reconnects an active Turn. +7. A natural-language Goal request creates a structured preview with Goal, + Agent, workspace, permissions, Todos, heartbeat, and stop condition. +8. No Goal, Todo, Agent binding, heartbeat, or recurring monitor is written + before the required confirmation. +9. Applying the same proposal twice cannot duplicate durable state or launch a + duplicate first Turn. +10. A request for Goal continuation maps to the heartbeat contract; a request + to watch a bounded target maps to a `continuous_monitor` Todo. +11. Protected operations remain explicit operator gates. +12. Raw ids, logs, tool output, private paths, credentials, and provider + payloads stay outside the default visible surface. +13. Every progress or output statement remains attributable to a public-safe + Todo, Run, event, gate, or artifact projection. +14. Codex remains the default only when healthy and compatible; unavailable + Agents are explained before selection. +15. The final implementation matches the approved first-screen design at + desktop width before commit or PR finalization. + +## Out Of Scope For The First Delivery + +- collaborative or multi-owner Goal editing; +- visual workflow builders; +- analytics dashboards and KPI charts; +- raw terminal or tool-log rendering; +- automatic mid-run Agent transfer; +- browser-side storage of endpoint commands or credentials; +- unreviewed execution of protected external actions. + +## Chosen Defaults + +- LoopX Manager is the initial route. +- Codex is the default healthy Agent. +- Goal Chat is the default Goal tab. +- Lists browse; drawers act. +- Corrections continue the scoped Session. +- Durable natural-language operations use preview and apply. +- Goal heartbeat and recurring monitor remain separate typed contracts. +- Advanced diagnostics stay collapsed. diff --git a/apps/presentation/dashboard/index.html b/apps/presentation/dashboard/index.html index 5c0c40cca..a4afc7481 100644 --- a/apps/presentation/dashboard/index.html +++ b/apps/presentation/dashboard/index.html @@ -3,6 +3,7 @@ + LoopX 看板 diff --git a/apps/presentation/dashboard/package.json b/apps/presentation/dashboard/package.json index 05628b9e6..394edd667 100644 --- a/apps/presentation/dashboard/package.json +++ b/apps/presentation/dashboard/package.json @@ -5,18 +5,23 @@ "license": "Apache-2.0", "type": "module", "scripts": { - "build": "tsc --noEmit && vite build", - "dev": "vite --host 127.0.0.1", + "build": "tsc --noEmit && vite build && vite build --config vite.chat.config.ts", + "build:chat": "tsc --noEmit && vite build --config vite.chat.config.ts", + "dev": "bash ../../../scripts/dashboard-dev.sh", + "dev:web": "vite --host 127.0.0.1", "export:frontstage-share": "node ../../../examples/export-frontstage-share-bundle.mjs", "preview": "vite preview --host 127.0.0.1", "smoke:action-packet": "rm -rf /tmp/loopx-action-packet-smoke && tsc --ignoreConfig --target ES2022 --module NodeNext --moduleResolution NodeNext --skipLibCheck --strict --outDir /tmp/loopx-action-packet-smoke smoke/action-packet-smoke.ts src/data/action-packet.ts && node /tmp/loopx-action-packet-smoke/smoke/action-packet-smoke.js", + "smoke:chat-route": "tsc --ignoreConfig --target ES2022 --module CommonJS --moduleResolution Node --ignoreDeprecations 6.0 --skipLibCheck --strict --outDir node_modules/.cache/loopx-chat-route-smoke smoke/chat-route-smoke.ts src/data/chat-model.ts && node node_modules/.cache/loopx-chat-route-smoke/smoke/chat-route-smoke.js", "smoke:demo-readiness": "python3 ../../../examples/dashboard-demo-readiness-smoke.py", "smoke:frontstage-browser": "node ../../../examples/dashboard-frontstage-browser-smoke.mjs", "smoke:frontstage-design-baseline": "node ../../../examples/dashboard-frontstage-design-baseline-smoke.mjs", "smoke:frontstage-route": "rm -rf /tmp/loopx-frontstage-route-smoke && tsc --ignoreConfig --target ES2022 --module NodeNext --moduleResolution NodeNext --skipLibCheck --strict --outDir /tmp/loopx-frontstage-route-smoke smoke/frontstage-route-smoke.ts && node /tmp/loopx-frontstage-route-smoke/frontstage-route-smoke.js", "smoke:frontstage-share-bundle": "node ../../../examples/frontstage-share-bundle-smoke.mjs", "smoke:home-browser": "node ../../../examples/dashboard-home-browser-smoke.mjs", - "smoke:home-route": "rm -rf /tmp/loopx-home-route-smoke && tsc --ignoreConfig --target ES2022 --module NodeNext --moduleResolution NodeNext --skipLibCheck --strict --outDir /tmp/loopx-home-route-smoke smoke/home-route-smoke.ts && node /tmp/loopx-home-route-smoke/home-route-smoke.js", + "smoke:personal-workspace": "node ../../../examples/personal-workspace-browser-smoke.mjs", + "smoke:personal-workspace-router": "rm -rf /tmp/loopx-personal-workspace-router-smoke && tsc --ignoreConfig --target ES2022 --module commonjs --moduleResolution node --ignoreDeprecations 6.0 --skipLibCheck --strict --outDir /tmp/loopx-personal-workspace-router-smoke smoke/personal-workspace-router-smoke.ts src/features/personal-workspace/personal-workspace-router.ts && node /tmp/loopx-personal-workspace-router-smoke/smoke/personal-workspace-router-smoke.js", + "smoke:home-route": "rm -rf /tmp/loopx-home-route-smoke && tsc --ignoreConfig --target ES2022 --module CommonJS --moduleResolution Node --ignoreDeprecations 6.0 --skipLibCheck --strict --outDir /tmp/loopx-home-route-smoke smoke/home-route-smoke.ts && node /tmp/loopx-home-route-smoke/home-route-smoke.js", "smoke:ops-decision-freshness": "node ../../../examples/dashboard-ops-decision-freshness-smoke.mjs", "smoke:promotion-readiness": "node ../../../examples/dashboard-promotion-readiness-browser-smoke.mjs", "smoke:presentation-surface-schema": "rm -rf /tmp/loopx-presentation-surface-schema-smoke && tsc --ignoreConfig --target ES2022 --module CommonJS --moduleResolution Node --ignoreDeprecations 6.0 --skipLibCheck --strict --resolveJsonModule --esModuleInterop --outDir /tmp/loopx-presentation-surface-schema-smoke smoke/presentation-surface-schema-smoke.ts src/data/status.ts src/data/decision-research.ts src/data/goal-channel-frontstage.ts && NODE_PATH=\"$PWD/node_modules\" node /tmp/loopx-presentation-surface-schema-smoke/apps/presentation/dashboard/smoke/presentation-surface-schema-smoke.js", diff --git a/apps/presentation/dashboard/public/showcase/loopx-personal-agent-control-plane-final.png b/apps/presentation/dashboard/public/showcase/loopx-personal-agent-control-plane-final.png new file mode 100644 index 000000000..6e9a5743b Binary files /dev/null and b/apps/presentation/dashboard/public/showcase/loopx-personal-agent-control-plane-final.png differ diff --git a/apps/presentation/dashboard/smoke/chat-route-smoke.ts b/apps/presentation/dashboard/smoke/chat-route-smoke.ts new file mode 100644 index 000000000..a9503c31c --- /dev/null +++ b/apps/presentation/dashboard/smoke/chat-route-smoke.ts @@ -0,0 +1,660 @@ +import { + closeChatSession, + fetchChatHistory, + mergeChatSessionMessages, + parseCompletedDecisionHistory, + resumeChatTurnStreaming, + serializeCompletedDecisionHistory, + type ChatSessionSnapshot, + type StoredDecisionHistoryItem, +} from "../src/data/chat.js"; +import { + agentBackendLabel, + answerLocalStatusQuestion, + buildGoalStudioNodes, + chatFailureMessage, + completedGoalReviews, + pendingGoalReviews, + proposalReviewState, + sessionInvalidatedByPayload, + selectChatGoal, + stewardPrompts, + turnReplaySafeByPayload, + todoApplyResultMatchesRequest, + todoPreviewMatchesRequest, + todoNoWriteReceiptFromPayload, + todoNoWriteReceiptLabel, + todoReceiptLabel, + todoReceiptOutcomeLabel, + todoReceiptProjected, + type ChatStatus, + type TodoNoWriteReceipt, + type TodoProposal, + type TodoWriteReceipt, +} from "../src/data/chat-model.js"; +import * as chatModel from "../src/data/chat-model.js"; + +function check(condition: boolean, message: string) { + if (!condition) { + throw new Error(message); + } +} + +type ChatRouteCandidate = { + agentId: string; + available: boolean; +}; + +const capabilityAwareRoute = chatModel as unknown as { + selectAvailableChatAgent?: ( + options: ChatRouteCandidate[], + preferredAgentId: string | undefined, + defaultAgentId: string, + ) => ChatRouteCandidate; +}; + +check( + typeof capabilityAwareRoute.selectAvailableChatAgent === "function", + "Chat routing should expose stale-selection fallback", +); + +const routeOptions: ChatRouteCandidate[] = [ + { agentId: "codex", available: true }, + { agentId: "registered-fixer", available: false }, + { agentId: "status-only", available: true }, +]; +check( + capabilityAwareRoute.selectAvailableChatAgent!(routeOptions, "registered-fixer", "codex").agentId === "codex", + "a persisted unavailable Agent should fall back to Codex", +); +check( + capabilityAwareRoute.selectAvailableChatAgent!( + routeOptions.map((option) => option.agentId === "codex" ? { ...option, available: false } : option), + "registered-fixer", + "status-only", + ).agentId === "status-only", + "Chat should fall back to status-only when no model adapter is available", +); + +const fixture: ChatStatus = { + ok: true, + schema_version: "loopx_chat_status_v0", + selected_goal_id: "goal-studio", + goal_count: 1, + goals: [ + { + goal_id: "goal-studio", + title: "LoopX Chat", + objective: "Make the next decision visible and reviewable.", + status: "active", + waiting_on: "codex", + severity: "action", + gate: "review_required", + next_action: "Review the proposed Todo.", + top_todo: { + todo_id: "todo-1", + role: "agent", + status: "open", + priority: "P0", + text: "Implement the Goal Studio review card.", + action_kind: "loopx_chat_proposal", + task_class: "advancement_task", + claimed_by: null, + evidence: null, + }, + todos: [ + { + todo_id: "gate-1", + role: "user", + status: "open", + priority: null, + text: "Approve the first-screen presentation.", + action_kind: null, + task_class: "user_gate", + claimed_by: null, + evidence: null, + }, + { + todo_id: "action-1", + role: "user", + status: "done", + priority: null, + text: "Review the original Goal Studio direction.", + action_kind: null, + task_class: "user_action", + claimed_by: null, + evidence: "Owner approved the direction.", + }, + { + todo_id: "deferred-1", + role: "user", + status: "deferred", + priority: null, + text: "Review a later visual refinement.", + action_kind: null, + task_class: "user_action", + claimed_by: null, + evidence: null, + }, + ], + evidence: ["Contract smoke passed."], + quota: { + state: "eligible", + spent_slots: 2, + allowed_slots: 24, + reason: "eligible for the next bounded turn", + }, + }, + ], +}; + +const selected = selectChatGoal(fixture, "goal-studio"); +check(selected?.title === "LoopX Chat", "selected Goal should remain stable"); + +const nodes = buildGoalStudioNodes(selected!); +check( + nodes.map((node) => node.kind).join(",") === "goal,todo,gate,evidence,next", + "Goal Studio should preserve the five-node decision path", +); +check(nodes[1]?.detail === "Implement the Goal Studio review card.", "top Todo should feed the map"); +check(nodes[3]?.detail === "Contract smoke passed.", "evidence should feed the map"); +check( + stewardPrompts.map((prompt) => prompt.id).join(",") === "next,gate,evidence", + "问问管家 should expose bounded next-step, gate, and evidence prompts", +); +check( + stewardPrompts.every((prompt) => prompt.prompt.trim().length > prompt.label.length), + "问问管家 shortcuts should expand into concrete questions", +); +check(agentBackendLabel("codex_app_server") === "Codex app-server", "known Agent backend should be human-readable"); +check(agentBackendLabel("multi_adapter") === "Local Agent endpoints", "multi-adapter backend should be human-readable"); +check(agentBackendLabel(null) === "Local Agent", "missing capabilities should retain a safe local fallback"); +check( + pendingGoalReviews(selected!).map((todo) => todo.todo_id).join(",") === "gate-1", + "Decision Inbox should include durable open user gates", +); +check( + completedGoalReviews(selected!).map((todo) => todo.todo_id).join(",") === "action-1", + "Decision Inbox history should include completed reviews without deferred work", +); + +const proposal: TodoProposal = { + kind: "todo", + text: "Implement the Goal Studio review card.", + priority: "P0", + rationale: "It closes the first approval loop.", +}; +check(proposalReviewState(proposal, null, "pending") === "needs_preview", "new proposal needs preview"); +check( + proposalReviewState( + { ...proposal }, + { preview_id: "preview-1", todo: { goal_id: "goal-studio", text: proposal.text } }, + "pending", + ) === "ready_to_approve", + "matching preview should unlock approval", +); +check( + proposalReviewState( + proposal, + { preview_id: "preview-1", todo: { goal_id: "goal-studio", text: "stale" } }, + "pending", + ) === "needs_preview", + "changed proposal should require a new preview", +); +check( + proposalReviewState( + proposal, + { preview_id: "preview-1", todo: { goal_id: "goal-studio", text: proposal.text } }, + "approved", + ) === "approved", + "applied proposal should become approved", +); +check( + proposalReviewState(proposal, null, "rejected") === "rejected", + "rejected proposal should resolve without unlocking a write", +); +check( + proposalReviewState(proposal, null, "cancelled") === "cancelled", + "cancelled preview should resolve without unlocking a write", +); + +const secondGoal = { + ...fixture.goals[0], + goal_id: "loopx-anthropic-ceo-research", + title: "Anthropic CEO Research", + objective: "Track a bounded research objective.", +}; +const multiGoalFixture: ChatStatus = { + ...fixture, + goal_count: 2, + goals: [fixture.goals[0], secondGoal], +}; +const directStatusAnswer = answerLocalStatusQuestion(multiGoalFixture, "当前有哪些goal"); +check(directStatusAnswer?.startsWith("当前有 2 个 Goal:") === true, "Goal list question should use local status"); +check(directStatusAnswer?.includes("goal-studio") === true, "local answer should include the first Goal id"); +check( + directStatusAnswer?.includes("loopx-anthropic-ceo-research") === true, + "local answer should include every connected Goal id", +); +const idTitleAnswer = answerLocalStatusQuestion( + { ...fixture, goals: [{ ...fixture.goals[0], title: "goal-studio" }] }, + "当前有哪些 Goal?", +); +check( + idTitleAnswer?.includes("goal-studio(goal-studio)") === false, + "Goal id fallback titles should not be duplicated in the answer", +); +check( + answerLocalStatusQuestion(multiGoalFixture, "结合当前 Goal,下一步最值得做什么?") === null, + "reasoning questions should remain on the Agent path", +); +check( + sessionInvalidatedByPayload({ session_invalidated: true }) === true, + "server invalidation should clear the browser session", +); +check( + sessionInvalidatedByPayload({ session_invalidated: false }) === false, + "ordinary errors should preserve a healthy session", +); +check( + turnReplaySafeByPayload({ session_invalidated: true, turn_replay_safe: true }) === true, + "a missing server-side session should allow one automatic turn replay", +); +check( + turnReplaySafeByPayload({ session_invalidated: true, turn_replay_safe: false }) === false, + "an Agent failure must not opt into automatic turn replay", +); +check( + turnReplaySafeByPayload({ turn_replay_safe: true }) === false, + "turn replay requires explicit session invalidation", +); +check( + chatFailureMessage("Codex app-server timed out.", true).includes("会话已自动重置,可以直接重试。"), + "invalidated sessions should explain that a direct retry is safe", +); +check( + chatFailureMessage("Temporary agent error.", false) === "Temporary agent error.", + "ordinary failures should keep their original summary", +); + +const receipt: TodoWriteReceipt = { + schema_version: "loopx_chat_todo_receipt_v0", + receipt_id: "receipt-1234567890abcdef", + preview_id: "preview-1", + goal_id: "goal-studio", + todo_id: "todo-1", + status: "applied", + outcome: "todo_added", + already_exists: false, + preview_revision: "sha256:preview", +}; +const preview = { + preview_id: receipt.preview_id, + todo: { + goal_id: receipt.goal_id, + text: proposal.text, + }, +}; +check( + todoPreviewMatchesRequest(preview, { goalId: receipt.goal_id, text: proposal.text }) === true, + "Todo previews should match the requested Goal and Todo text before approval unlocks", +); +check( + todoPreviewMatchesRequest(preview, { goalId: "other-goal", text: proposal.text }) === false, + "a preview from another Goal must not unlock approval", +); +check( + todoPreviewMatchesRequest(preview, { goalId: receipt.goal_id, text: "A different Todo" }) === false, + "a preview for different Todo text must not unlock approval", +); +const applyResult = { + applied: true as const, + ok: true as const, + receipt, + todo: { + text: proposal.text, + todo_id: receipt.todo_id, + }, +}; +const applyRequest = { + goalId: receipt.goal_id, + previewId: receipt.preview_id, + text: proposal.text, +}; +check( + todoApplyResultMatchesRequest(applyResult, applyRequest) === true, + "approved Todo receipts should match the requested Goal, preview, Todo id, and text", +); +check( + todoApplyResultMatchesRequest(applyResult, { ...applyRequest, goalId: "other-goal" }) === false, + "a receipt from another Goal must not complete the approval", +); +check( + todoApplyResultMatchesRequest(applyResult, { ...applyRequest, previewId: "other-preview" }) === false, + "a receipt from another preview must not complete the approval", +); +check( + todoApplyResultMatchesRequest( + { ...applyResult, todo: { ...applyResult.todo, todo_id: "other-todo" } }, + applyRequest, + ) === false, + "receipt and response Todo ids must agree before approval completes", +); +check( + todoApplyResultMatchesRequest( + { ...applyResult, todo: { ...applyResult.todo, text: "A different Todo" } }, + applyRequest, + ) === false, + "a response for different Todo text must not complete the approval", +); +check( + todoReceiptLabel(receipt) === "todo-1 · 回执 receipt-1234", + "approved Todo should expose a compact stable receipt label", +); +check(todoReceiptOutcomeLabel(receipt) === "Todo 已写入", "new Todo receipts should name the write outcome"); +check( + todoReceiptOutcomeLabel({ + ...receipt, + outcome: "todo_already_exists", + already_exists: true, + }) === "Todo 已存在,未重复写入", + "idempotent receipt retries should say that no duplicate write occurred", +); +check( + todoReceiptProjected(fixture, receipt) === false, + "receipt confirmation should require the Todo in the refreshed Goal projection", +); +const projectedFixture: ChatStatus = { + ...fixture, + goals: [ + { + ...fixture.goals[0], + todos: [ + ...fixture.goals[0].todos, + { + todo_id: receipt.todo_id, + role: "agent", + status: "open", + priority: "P0", + text: proposal.text, + action_kind: "loopx_chat_proposal", + task_class: "advancement_task", + claimed_by: null, + evidence: null, + }, + ], + }, + ], +}; +check( + todoReceiptProjected(projectedFixture, receipt) === true, + "receipt confirmation should bind the stable Todo id to the refreshed Goal projection", +); + +const noWriteReceipt: TodoNoWriteReceipt = { + schema_version: "loopx_chat_todo_no_write_receipt_v0", + receipt_id: "no-write-1234567890abcdef", + goal_id: "goal-studio", + status: "not_applied", + outcome: "preview_stale", + write_attempted: false, + current_preview_id: "preview-current", + state_revision: "sha256:current", +}; +check( + todoNoWriteReceiptFromPayload({ todo_receipt: noWriteReceipt })?.receipt_id === noWriteReceipt.receipt_id, + "stale preview errors should expose a typed no-write receipt", +); +check( + todoNoWriteReceiptLabel(noWriteReceipt) === "未写入 · 回执 no-write-123", + "no-write receipts should expose a compact stable label", +); +check( + todoNoWriteReceiptFromPayload({ todo_receipt: { ...noWriteReceipt, write_attempted: true } }) === null, + "unproven write failures must not be presented as no-write receipts", +); + +const completedHistory: StoredDecisionHistoryItem[] = [ + { + id: "decision-approved", + outcome: "approved", + projectionVerified: true, + proposal, + receipt, + }, + { + id: "decision-rejected", + outcome: "rejected", + projectionVerified: null, + proposal: { ...proposal, text: "Reject this proposal." }, + receipt: null, + }, +]; +const serializedHistory = serializeCompletedDecisionHistory("goal-studio", completedHistory); +check( + parseCompletedDecisionHistory(serializedHistory, "goal-studio").length === 2, + "completed Decision Inbox history should survive a same-tab reload", +); +check( + parseCompletedDecisionHistory(serializedHistory, "other-goal").length === 0, + "completed decision history must remain scoped to its Goal", +); +check( + parseCompletedDecisionHistory("{malformed", "goal-studio").length === 0, + "malformed browser history must fail closed", +); +check( + parseCompletedDecisionHistory( + JSON.stringify({ + schema_version: "loopx_chat_decision_history_v0", + goal_id: "goal-studio", + decisions: [{ ...completedHistory[0], receipt: null }], + }), + "goal-studio", + ).length === 0, + "approved browser history without a receipt must fail closed", +); + +async function checkSessionCloseContract() { + const originalFetch = globalThis.fetch; + let observedUrl = ""; + let observedInit: RequestInit | undefined; + globalThis.fetch = (async (input, init) => { + observedUrl = String(input); + observedInit = init; + return new Response( + JSON.stringify({ + closed: true, + ok: true, + session_id: "session-goal-studio", + }), + { headers: { "Content-Type": "application/json" }, status: 200 }, + ); + }) as typeof fetch; + try { + const result = await closeChatSession("session-goal-studio"); + check(result.closed === true, "session close should return a typed close receipt"); + check( + observedUrl === "/api/chat/sessions/session-goal-studio", + "session close should target the exact browser session", + ); + check(observedInit?.method === "DELETE", "session close should use DELETE"); + check(observedInit?.keepalive === true, "page teardown should keep the close request alive"); + } finally { + globalThis.fetch = originalFetch; + } +} + +function historySnapshot( + sessionId: string, + status: string, + messages: ChatSessionSnapshot["messages"], +): ChatSessionSnapshot { + return { + ok: true, + schema_version: "loopx_chat_store_v1", + session: { + session_id: sessionId, + goal_id: "goal-studio", + agent_id: "codex", + adapter_kind: "codex_app_server", + channel_id: "manager", + status, + active_turn_id: null, + last_error_code: status === "resume_failed" ? "resume_failed" : null, + created_at: "2026-08-10T01:00:00Z", + updated_at: "2026-08-10T02:00:00Z", + last_activity_at: "2026-08-10T02:00:00Z", + resumable: status === "ready", + }, + messages, + active_turn: null, + }; +} + +async function checkSessionHistoryRecoveryContract() { + const originalFetch = globalThis.fetch; + const requests: string[] = []; + const failed = historySnapshot("session-failed", "resume_failed", [{ + message_id: "message-old", + turn_id: "turn-old", + role: "user", + text: "重启前的问题", + created_at: "2026-08-10T01:00:00Z", + }]); + const ready = historySnapshot("session-ready", "ready", [{ + message_id: "message-new", + turn_id: "turn-new", + role: "agent", + text: "重启前的回答", + created_at: "2026-08-10T01:01:00Z", + }]); + globalThis.fetch = (async (input) => { + const url = String(input); + requests.push(url); + const body = url.includes("?") + ? { + ok: true, + schema_version: "loopx_chat_session_list_v1", + sessions: [failed.session, ready.session], + } + : url.endsWith("session-failed") + ? failed + : ready; + return new Response(JSON.stringify(body), { + headers: { "Content-Type": "application/json" }, + status: 200, + }); + }) as typeof fetch; + try { + const history = await fetchChatHistory({ agentId: "codex", channelId: "manager" }); + check( + requests[0] === "/api/chat/sessions?agent_id=codex&channel_id=manager", + "manager history should be loaded without requiring a selected Goal", + ); + check( + history.messages.map((message) => message.text).join("|") === "重启前的问题|重启前的回答", + "local visible messages should survive an upstream resume failure", + ); + check( + mergeChatSessionMessages([ready, failed]).map((message) => message.message_id).join(",") + === "message-old,message-new", + "messages from multiple local sessions should retain chronological order", + ); + } finally { + globalThis.fetch = originalFetch; + } +} + +async function checkActiveTurnStreamRecoveryContract() { + const originalFetch = globalThis.fetch; + const requests: string[] = []; + let attempt = 0; + const event = (id: string, kind: string, payload: Record) => + `id: ${id}\nevent: ${kind}\ndata: ${JSON.stringify({ + event_id: id, + sequence: Number(id), + kind, + created_at: "2026-08-10T02:00:00Z", + payload, + })}\n\n`; + globalThis.fetch = (async (input) => { + const url = String(input); + requests.push(url); + attempt += 1; + const body = attempt === 1 + ? event("1", "answer.delta", { text: "恢复中的回答。" }) + : event("2", "turn.completed", { + response: { + schema_version: "loopx_chat_agent_response_v0", + message: "恢复中的回答。", + proposals: [], + gate: null, + }, + }); + return new Response(body, { + headers: { "Content-Type": "text/event-stream" }, + status: 200, + }); + }) as typeof fetch; + const deltas: string[] = []; + try { + const result = await resumeChatTurnStreaming("session-active", "turn-active", { + onDelta: (text) => deltas.push(text), + }); + check(result.response.message === "恢复中的回答。", "active Turn recovery should return the final response"); + check(deltas.join("") === "恢复中的回答。", "active Turn recovery should replay visible deltas once"); + check(requests.length === 2, "a non-terminal SSE close should reconnect"); + check( + requests[0]?.endsWith("/api/chat/sessions/session-active/turns/turn-active/events") === true, + "active Turn recovery should use the persisted Session and Turn ids", + ); + check(requests[1]?.endsWith("/events?after=1") === true, "SSE reconnect should continue after the last event id"); + + globalThis.fetch = (async () => new Response("", { + headers: { "Content-Type": "text/event-stream" }, + status: 200, + })) as typeof fetch; + let reconnectError: unknown = null; + try { + await resumeChatTurnStreaming("session-active", "turn-active"); + } catch (error) { + reconnectError = error; + } + check(reconnectError instanceof Error, "exhausted SSE reconnects should surface an error"); + check( + reconnectError instanceof Error + && "payload" in reconnectError + && (reconnectError as { payload: Record }).payload.reconnectable === true, + "exhausted SSE reconnects should preserve enough state for a Continue connection action", + ); + + globalThis.fetch = (async () => new Response( + event("3", "turn.interrupted", {}), + { headers: { "Content-Type": "text/event-stream" }, status: 200 }, + )) as typeof fetch; + let interruptedError: unknown = null; + try { + await resumeChatTurnStreaming("session-active", "turn-active"); + } catch (error) { + interruptedError = error; + } + check( + interruptedError instanceof Error + && "payload" in interruptedError + && (interruptedError as { payload: Record }).payload.error_code === "turn_interrupted", + "an interrupted recovered Turn should surface an explicit interrupted result", + ); + } finally { + globalThis.fetch = originalFetch; + } +} + +void checkSessionCloseContract() + .then(checkSessionHistoryRecoveryContract) + .then(checkActiveTurnStreamRecoveryContract) + .then(() => console.log("chat-route-smoke: ok")) + .catch((error) => { + console.error(error); + throw error; + }); diff --git a/apps/presentation/dashboard/smoke/home-route-smoke.ts b/apps/presentation/dashboard/smoke/home-route-smoke.ts index edef74647..9a2772c6b 100644 --- a/apps/presentation/dashboard/smoke/home-route-smoke.ts +++ b/apps/presentation/dashboard/smoke/home-route-smoke.ts @@ -2,9 +2,7 @@ import { readFileSync } from "node:fs"; function assert(condition: boolean, message: string) { - if (!condition) { - throw new Error(message); - } + if (!condition) throw new Error(message); } function includes(source: string, snippet: string, label: string) { @@ -17,137 +15,80 @@ function excludes(source: string, snippet: string, label: string) { const routerSource = readFileSync("src/router.tsx", "utf8"); const dashboardSource = readFileSync("src/views/dashboard-page.tsx", "utf8"); -const readmeSource = readFileSync("README.md", "utf8"); -const contractSource = readFileSync("../../../docs/status-data-contract.md", "utf8"); +const workspacePageSource = readFileSync("src/features/personal-workspace/personal-workspace-page.tsx", "utf8"); +const sidebarSource = readFileSync("src/features/personal-workspace/goal-sidebar.tsx", "utf8"); +const shellSource = readFileSync("src/features/personal-workspace/workspace-shell.tsx", "utf8"); +const drawerSource = readFileSync("src/features/personal-workspace/context-drawer.tsx", "utf8"); +const timelineSource = readFileSync("src/features/personal-workspace/channel-timeline.tsx", "utf8"); +const modelSource = readFileSync("src/features/personal-workspace/personal-workspace-model.ts", "utf8"); +const chatDataSource = readFileSync("src/data/chat.ts", "utf8"); +const stylesSource = readFileSync("src/features/personal-workspace/personal-workspace.css", "utf8"); const packageSource = readFileSync("package.json", "utf8"); -const exampleSource = readFileSync("../../../examples/status.example.json", "utf8"); +const viteSource = readFileSync("vite.config.ts", "utf8"); +const dashboardDevSource = readFileSync("../../../scripts/dashboard-dev.sh", "utf8"); +const designSource = readFileSync("design.md", "utf8"); -const shareGoalSpecStart = dashboardSource.indexOf("const shareGoalSpecs"); -const shareGoalSpecEnd = dashboardSource.indexOf("const shareStatusLabel", shareGoalSpecStart); -assert(shareGoalSpecStart >= 0 && shareGoalSpecEnd > shareGoalSpecStart, "missing share goal spec block"); -const shareGoalSpecBlock = dashboardSource.slice(shareGoalSpecStart, shareGoalSpecEnd); -const shareGoalIds = [...shareGoalSpecBlock.matchAll(/id: "([^"]+)"/g)].map((match) => match[1]); -assert(shareGoalIds.length >= 4, "expected public showcase goal specs"); -for (const goalId of shareGoalIds) { - assert( - goalId.startsWith("showcase-") || goalId === "loopx-meta", - `public dashboard goal spec must use showcase/meta id: ${goalId}`, - ); -} +includes(routerSource, 'view: z.enum(["ops", "share"]).optional()', "optional operator route"); +excludes(routerSource, 'view: z.enum(["ops", "share"]).optional().default("share")', "legacy share default"); +includes(dashboardSource, 'data-testid="personal-goal-home"', "personal workspace route"); +includes(dashboardSource, "查看', "repeated row View button"); +excludes(dashboardSource, '>纠偏', "repeated row correction button"); -includes(dashboardSource, 'const defaultGlobalStatusUrl = "http://127.0.0.1:8766/status.json";', "global default status URL"); -includes(dashboardSource, 'return view === "ops" ? "ops" : undefined;', "canonical URL omits non-ops view"); -includes(dashboardSource, 'if (search.view !== "ops" && source.kind === "example") {', "non-ops loads global status source once"); -includes( - dashboardSource, - '[exampleModeRequested, search.statusUrl, search.view, source.kind, source.label]', - "status URL change reload effect", -); -includes(dashboardSource, 'void loadFromUrl(defaultGlobalStatusUrl);', "home loads global status source"); -includes(dashboardSource, 'data-testid="share-overview"', "control-plane home test id"); -includes(dashboardSource, 'data-testid={`share-top-todos-${view.spec.id}`}', "share top todo list test id"); -includes(dashboardSource, 'data-testid={`share-decision-frame-${view.spec.id}`}', "first-screen decision frame test id"); -includes(dashboardSource, "第一屏决策帧", "first-screen decision frame label"); -includes(dashboardSource, "等待方", "first-screen waiting owner label"); -includes(dashboardSource, "推荐动作", "first-screen recommended action label"); -includes(dashboardSource, "安全边界", "first-screen safety boundary label"); -includes(dashboardSource, "首个用户 Todo", "first-screen first user todo label"); -includes(dashboardSource, "最高优 Agent Todo", "first-screen top agent todo label"); -includes(dashboardSource, "Todo 投影缺口", "first-screen todo projection gap label"); -includes(dashboardSource, "前 4 个 Todo", "share top-four todo label"); -includes(dashboardSource, "已完成", "share todo done status"); -includes(dashboardSource, "决策需重新确认", "share decision freshness warning"); -includes(dashboardSource, "这不是仓库回滚", "share decision non-rollback copy"); -includes(dashboardSource, "仅含合成数据", "showcase synthetic-only boundary"); -includes(dashboardSource, '单面改动', "Chinese delivery scale label"); -includes(dashboardSource, '阻塞说明', "Chinese blocker label"); -includes(dashboardSource, '配额守卫', "Chinese quota guard label"); -includes(dashboardSource, '状态写回', "Chinese state writeback label"); -includes(dashboardSource, '

Goal 控制台

', "ops workbench fallback"); -includes(dashboardSource, 'data-testid="operator-mental-model-panel"', "operator mental model panel test id"); -includes(dashboardSource, "操作者概览", "operator mental model title"); -includes(dashboardSource, "首屏把控制面状态整理成五个需要关注的问题。", "operator mental model helper"); -includes(dashboardSource, "下一步", "operator mental model next step label"); -includes(dashboardSource, "需要你判断", "operator mental model judgment label"); -includes(dashboardSource, "是否可继续", "operator mental model continue label"); -includes(dashboardSource, 'data-testid="project-todo-explorer"', "project todo explorer test id"); -includes(dashboardSource, 'data-testid="project-todo-search-input"', "project todo search input test id"); -includes(dashboardSource, 'data-testid="project-todo-id"', "project todo id rendering"); -includes(dashboardSource, "项目 Todo 浏览器", "project todo explorer title"); -includes(dashboardSource, "全部项目", "project todo all-project selector"); -includes(dashboardSource, "todoExplorerProjectOptions", "project todo auto project options"); -includes(dashboardSource, "selectedTodoGoalId", "project todo selected project prop"); -includes(dashboardSource, "claimed_by=", "project todo claimed owner metadata"); -includes(dashboardSource, "action=", "project todo action metadata"); -includes(dashboardSource, "source={item.source}", "project todo source metadata"); -includes(dashboardSource, "latest_event_kind", "project todo historical event metadata"); -includes(dashboardSource, "todoIndex={payload.todo_index}", "project todo index wiring"); -includes(dashboardSource, 'data-testid="agent-management-panel"', "agent management panel test id"); -includes(dashboardSource, 'data-testid="agent-management-row"', "agent management row test id"); -includes(dashboardSource, 'data-testid="agent-management-copy-command"', "agent management copy command test id"); -includes(dashboardSource, 'data-testid="agent-management-handoff-note"', "agent management handoff note test id"); -includes(dashboardSource, 'data-testid="agent-management-workspace-ref"', "agent management workspace hint test id"); -includes(dashboardSource, 'data-testid="agent-management-stale-claim-hint"', "agent management stale claim hint test id"); -includes(dashboardSource, "Agent 管理", "agent management title"); -includes(dashboardSource, "已认领 Todo", "agent management claimed todo label"); -includes(dashboardSource, "最近活动", "agent management activity label"); -includes(dashboardSource, "下一步安全动作", "agent management next action label"); -includes(dashboardSource, "工作区提示", "agent management workspace label"); -includes(dashboardSource, "认领状态可能过期", "agent management stale warning label"); -includes(dashboardSource, "仅提醒", "agent management stale warning-only boundary"); -includes(dashboardSource, "交接信息", "agent management handoff note label"); -includes(dashboardSource, "证据引用", "agent management evidence label"); -includes(dashboardSource, "的只读命令", "agent management read-only command label"); -includes(dashboardSource, "buildAgentManagementRows", "agent management projection builder"); -includes(dashboardSource, "agentManagementProjection={payload.agent_management_projection}", "agent management live projection wiring"); -includes(dashboardSource, "agent_id", "agent management agent id metadata"); -includes(exampleSource, '"todo_index"', "example todo index projection"); -includes(exampleSource, '"source": "live_loopx_status_public_slice"', "example live LoopX status source"); -includes(exampleSource, '"public_safe_export": true', "example public-safe export marker"); -includes(exampleSource, '"agent_id": "codex-main-control"', "example main-control agent row"); -includes(exampleSource, '"agent_id": "codex-product-capability"', "example product-capability agent row"); -includes(exampleSource, '"agent_id": "codex-side-bypass"', "example side-bypass agent row"); -includes(exampleSource, '"agent_id": "codex-value-explorer"', "example value-explorer agent row"); -includes(exampleSource, '"todo_id": "todo_2bf560b48a0c"', "example real main-control todo id"); -includes(exampleSource, '"todo_id": "todo_584f55f8f3b4"', "example real value-explorer todo id"); -includes(exampleSource, '"claimed_by": "codex-value-explorer"', "example claimed agent row"); -includes(exampleSource, '"stale_claim_hint": {', "example live stale claim hint projection"); -excludes(exampleSource, "todo_example_", "synthetic todo rows in bundled example"); -excludes(exampleSource, "experiment-controller-goal", "legacy synthetic goal in bundled example"); -excludes(exampleSource, "department-", "private department label in bundled example"); -excludes(exampleSource, "/Users/", "local absolute path in bundled example"); -excludes(exampleSource, "/private/", "private temp path in bundled example"); -excludes(dashboardSource, "raw internal slot constraints", "raw internal constraint copy"); -includes(contractSource, "todo_id", "status contract todo id metadata"); -includes(readFileSync("src/data/goal-channel-frontstage.ts", "utf8"), "generated_at: z.string().optional().nullable()", "goal channel generated_at optional live status compatibility"); -includes(packageSource, '"smoke:home-route"', "home route smoke script"); -includes(packageSource, '"smoke:home-browser"', "home browser smoke script"); -includes(packageSource, '"smoke:demo-readiness"', "demo readiness smoke script"); -includes(readmeSource, "npm run smoke:home-browser", "README home browser smoke command"); -includes(readmeSource, "npm run smoke:demo-readiness", "README demo readiness smoke command"); -includes(readmeSource, "--skip-browser", "README demo readiness CI skip-browser command"); -includes(readmeSource, "Fresh Clone Public Preview", "README fresh-clone preview section"); -includes(readmeSource, "npm ci", "README fresh-clone npm dependency install"); -includes(readmeSource, "examples/status.example.json", "README bundled public status fixture"); -includes(readmeSource, "without `view=share`", "README home smoke canonical route expectation"); +includes(timelineSource, 'aria-live="polite"', "streaming timeline live region"); +includes(workspacePageSource, 'kind: "attention"', "attention row projection"); +includes(workspacePageSource, 'kind: "run"', "run row projection"); +includes(workspacePageSource, 'kind: "output"', "output row projection"); +includes(workspacePageSource, 'kind: "schedule"', "schedule row projection"); +includes(workspacePageSource, 'kind: "proposal"', "typed proposal projection"); +includes(workspacePageSource, 'actionKind: "goal.create"', "natural language Goal preview"); +includes(workspacePageSource, '"heartbeat.bind" : "monitor.create"', "heartbeat and monitor classification"); +includes(workspacePageSource, 'error.payload.error_code === "protected_action"', "protected host Gate rendering"); +includes(workspacePageSource, 'todo.taskClass === "continuous_monitor"', "canonical continuous monitor projection"); -for (const [source, sourceLabel] of [ - [readmeSource, "dashboard README"], - [contractSource, "status data contract"], -] as const) { - includes(source, "control-plane home", `${sourceLabel} canonical home`); - includes(source, "?view=ops", `${sourceLabel} ops fallback`); - includes(source, "view=share", `${sourceLabel} legacy share compatibility`); -} +includes(drawerSource, 'data-context-kind={selection.kind}', "typed drawer mode"); +includes(drawerSource, 'role="dialog"', "accessible drawer dialog"); +includes(drawerSource, 'event.key === "Escape"', "drawer Escape handling"); +includes(drawerSource, "callbacks.onCorrectRun", "same-session correction action"); +includes(drawerSource, "callbacks.onInterruptRun", "turn interruption action"); +includes(drawerSource, "设置 Heartbeat", "Goal heartbeat entry"); +includes(drawerSource, "添加定时检查", "Goal monitor entry"); +includes(drawerSource, "高级诊断", "collapsed diagnostics"); +includes(drawerSource, "session_id:", "diagnostic Session id"); +includes(drawerSource, "turn_id:", "diagnostic Turn id"); + +includes(chatDataSource, "export async function previewTypedAction", "generic action preview client"); +includes(chatDataSource, "export async function loadTypedAction", "generic action load client"); +includes(chatDataSource, "export async function applyTypedAction", "generic action apply client"); +includes(chatDataSource, "export async function cancelTypedAction", "generic action cancel client"); +includes(modelSource, '"goal.create"', "Goal action contract"); +includes(modelSource, '"agent.bind"', "Agent binding action contract"); +includes(modelSource, '"run.correct"', "run correction action contract"); + +includes(stylesSource, "@media (max-width: 720px)", "mobile workspace layout"); +includes(stylesSource, "prefers-reduced-motion", "reduced-motion behavior"); +includes(viteSource, '"/status.json"', "status proxy"); +includes(viteSource, '"/api/actions"', "typed action proxy"); +includes(packageSource, '"dev": "bash ../../../scripts/dashboard-dev.sh"', "one-command dashboard launcher"); +includes(dashboardDevSource, "serve-status", "status service launcher"); +includes(dashboardDevSource, "loopx.cli chat", "Chat service launcher"); +includes(dashboardDevSource, "wait_for_service", "launcher readiness gate"); -includes(contractSource, "translate raw machine fields", "status contract translation expectation"); -includes(contractSource, "single_surface", "status contract raw machine token example"); +includes(designSource, "## Business Object Mapping", "business object mapping"); +includes(designSource, "## Control-Plane API Requirements", "typed control-plane contract"); +includes(designSource, "## Acceptance Criteria", "design acceptance criteria"); console.log("home-route smoke ok"); diff --git a/apps/presentation/dashboard/smoke/personal-workspace-router-smoke.ts b/apps/presentation/dashboard/smoke/personal-workspace-router-smoke.ts new file mode 100644 index 000000000..1f168697d --- /dev/null +++ b/apps/presentation/dashboard/smoke/personal-workspace-router-smoke.ts @@ -0,0 +1,38 @@ +import { routeWorkspaceInput } from "../src/features/personal-workspace/personal-workspace-router.js"; + +function equal(actual: unknown, expected: unknown, label: string) { + if (actual !== expected) throw new Error(`${label}: expected ${String(expected)}, received ${String(actual)}`); +} + +function ok(value: unknown, label: string) { + if (!value) throw new Error(label); +} + +const goalContext = { + agents: [{ agentId: "codex", label: "Codex" }], + goalId: "demo-goal", + todos: [{ text: "整理验收材料", todoId: "todo-1" }], +}; + +equal(routeWorkspaceInput("我现在该做什么?只读回答,不要修改状态", { ...goalContext, goalId: null }).route, "projection", "manager projection"); +equal(routeWorkspaceInput("不要设置 Heartbeat,只回答当前进度", goalContext).route, "agent_chat", "negated heartbeat"); +equal(routeWorkspaceInput("每天推进这个 Goal,设置 heartbeat", goalContext).actionKind, "heartbeat.bind", "heartbeat outranks generic daily monitor"); +equal(routeWorkspaceInput("创建一个 Todo:整理发布说明", goalContext).actionKind, "todo.create", "todo create"); +equal( + routeWorkspaceInput("做一次只读分析:判断刚刚新增的 Todo 是否与当前 Goal 一致。不要修改状态。", goalContext).route, + "agent_chat", + "existing todo read-only analysis", +); +equal(routeWorkspaceInput("把 todo-1 标记完成", goalContext).actionKind, "todo.update", "todo update"); +equal(routeWorkspaceInput("帮我修复 MR 冲突,跑测试,然后 push", goalContext).actionKind, "todo.create", "execution task"); +equal(routeWorkspaceInput("创建任务并设置 Heartbeat", goalContext).route, "clarify", "compound intent"); +equal(routeWorkspaceInput("创建任务并设置 Heartbeat", goalContext).missingFields.join(","), "single_intent", "compound missing field"); +equal(routeWorkspaceInput("现在部署到生产", goalContext).actionKind, "goal.update", "protected action"); +equal(routeWorkspaceInput("解释一下现在的状态", goalContext).route, "agent_chat", "goal chat"); + +const createGoal = routeWorkspaceInput("创建 Goal:整理每周复盘", { ...goalContext, goalId: null }); +equal(createGoal.route, "typed_action", "goal route"); +equal(createGoal.actionKind, "goal.create", "goal action"); +ok(createGoal.confidence >= 0.9, "goal confidence"); + +console.log("personal workspace router smoke passed"); diff --git a/apps/presentation/dashboard/src/chat-main.tsx b/apps/presentation/dashboard/src/chat-main.tsx new file mode 100644 index 000000000..a4862351d --- /dev/null +++ b/apps/presentation/dashboard/src/chat-main.tsx @@ -0,0 +1,27 @@ +import { createRoot } from "react-dom/client"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { RouterProvider } from "@tanstack/react-router"; + +import { router } from "./router"; +import "./styles.css"; + +const root = document.getElementById("root"); + +if (!root) { + throw new Error("Root element not found"); +} + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + refetchOnWindowFocus: false, + retry: 1, + staleTime: 10_000, + }, + }, +}); +createRoot(root).render( + + + , +); diff --git a/apps/presentation/dashboard/src/data/chat-model.ts b/apps/presentation/dashboard/src/data/chat-model.ts new file mode 100644 index 000000000..ca0667f52 --- /dev/null +++ b/apps/presentation/dashboard/src/data/chat-model.ts @@ -0,0 +1,405 @@ +export type ChatTodo = { + todo_id: string | null; + role: string | null; + status: string; + priority: string | null; + text: string; + action_kind: string | null; + task_class: string | null; + claimed_by: string | null; + evidence: string | null; +}; + +export type ChatGoal = { + goal_id: string; + title: string; + objective: string; + status: string; + waiting_on: string | null; + severity: string | null; + gate: string; + next_action: string; + top_todo: ChatTodo | null; + todos: ChatTodo[]; + evidence: string[]; + quota: { + state: string | null; + spent_slots: number | null; + allowed_slots: number | null; + reason: string | null; + }; +}; + +export type ChatStatus = { + ok: boolean; + schema_version: "loopx_chat_status_v0"; + selected_goal_id: string | null; + goal_count: number; + goals: ChatGoal[]; +}; + +export type ChatCapabilities = { + ok: true; + schema_version: "loopx_chat_capabilities_v0" | "loopx_chat_capabilities_v1"; + agent_backend: string; + sandbox: string; + approval_policy: string; + todo_write: string; + goal_id: string | null; + streaming?: boolean; + resume?: boolean; + interrupt?: boolean; + adapters?: Array<{ + agent_id: string; + display_name: string; + adapter_kind: string; + available: boolean; + streaming: boolean; + resume: boolean; + interrupt: boolean; + }>; +}; + +export type ChatRouteCandidate = { + agentId: string; + available: boolean; +}; + +export function selectAvailableChatAgent( + options: T[], + preferredAgentId: string | undefined, + defaultAgentId: string, +) { + const selected = options.find((option) => + option.agentId === preferredAgentId && option.available + ) ?? options.find((option) => + option.agentId === defaultAgentId && option.available + ) ?? options.find((option) => option.available); + if (!selected) { + throw new Error("Chat requires at least one available route"); + } + return selected; +} + +export type TodoProposal = { + kind: "todo"; + text: string; + priority: "P0" | "P1" | "P2"; + rationale: string; +}; + +export type AgentResponse = { + schema_version: "loopx_chat_agent_response_v0"; + message: string; + proposals: TodoProposal[]; + gate: { + kind: string; + summary: string; + next_action: string; + } | null; +}; + +export type GoalStudioNode = { + detail: string; + eyebrow: string; + kind: "goal" | "todo" | "gate" | "evidence" | "next"; + title: string; + tone: "violet" | "blue" | "amber" | "mint" | "slate"; +}; + +export type TodoPreview = { + preview_id: string; + todo: { + goal_id: string; + text: string; + todo_id?: string; + }; +}; + +export type TodoPreviewRequestIdentity = { + goalId: string; + text: string; +}; + +export type TodoWriteReceipt = { + schema_version: "loopx_chat_todo_receipt_v0"; + receipt_id: string; + preview_id: string; + goal_id: string; + todo_id: string; + status: "applied"; + outcome: "todo_added" | "todo_already_exists"; + already_exists: boolean; + preview_revision: string | null; +}; + +export type TodoNoWriteReceipt = { + schema_version: "loopx_chat_todo_no_write_receipt_v0"; + receipt_id: string; + goal_id: string; + status: "not_applied"; + outcome: "preview_stale"; + write_attempted: false; + current_preview_id: string; + state_revision: string | null; +}; + +export type TodoApplyResult = { + applied: true; + ok: true; + receipt: TodoWriteReceipt; + todo: { + text: string; + todo_id: string; + }; +}; + +export type TodoApplyRequestIdentity = { + goalId: string; + previewId: string; + text: string; +}; + +export type ProposalDecisionOutcome = "pending" | "approved" | "rejected" | "cancelled"; + +export type ProposalReviewState = + | "needs_preview" + | "ready_to_approve" + | "approved" + | "rejected" + | "cancelled"; + +export type StewardPrompt = { + id: "next" | "gate" | "evidence"; + label: string; + prompt: string; +}; + +export function todoReceiptLabel(receipt: TodoWriteReceipt) { + return `${receipt.todo_id} · 回执 ${receipt.receipt_id.slice(0, 12)}`; +} + +export function todoReceiptOutcomeLabel(receipt: TodoWriteReceipt) { + return receipt.already_exists ? "Todo 已存在,未重复写入" : "Todo 已写入"; +} + +export function todoPreviewMatchesRequest( + preview: TodoPreview, + request: TodoPreviewRequestIdentity, +) { + return preview.preview_id.length > 0 && preview.todo.goal_id === request.goalId && preview.todo.text === request.text; +} + +export function todoApplyResultMatchesRequest( + result: TodoApplyResult, + request: TodoApplyRequestIdentity, +) { + return ( + result.receipt.goal_id === request.goalId && + result.receipt.preview_id === request.previewId && + result.receipt.todo_id === result.todo.todo_id && + result.todo.text === request.text + ); +} + +export function todoReceiptProjected(status: ChatStatus | null, receipt: TodoWriteReceipt) { + const goal = status?.goals.find((candidate) => candidate.goal_id === receipt.goal_id); + return Boolean(goal?.todos.some((todo) => todo.todo_id === receipt.todo_id)); +} + +export function todoNoWriteReceiptLabel(receipt: TodoNoWriteReceipt) { + return `未写入 · 回执 ${receipt.receipt_id.slice(0, 12)}`; +} + +export function todoNoWriteReceiptFromPayload(payload: unknown): TodoNoWriteReceipt | null { + if (!payload || typeof payload !== "object") { + return null; + } + const candidate = (payload as { todo_receipt?: unknown }).todo_receipt; + if (!candidate || typeof candidate !== "object") { + return null; + } + const receipt = candidate as Partial; + if ( + receipt.schema_version !== "loopx_chat_todo_no_write_receipt_v0" || + typeof receipt.receipt_id !== "string" || + !receipt.receipt_id || + typeof receipt.goal_id !== "string" || + !receipt.goal_id || + receipt.status !== "not_applied" || + receipt.outcome !== "preview_stale" || + receipt.write_attempted !== false || + typeof receipt.current_preview_id !== "string" || + !receipt.current_preview_id || + (receipt.state_revision !== null && typeof receipt.state_revision !== "string") + ) { + return null; + } + return receipt as TodoNoWriteReceipt; +} + +export const stewardPrompts: StewardPrompt[] = [ + { + id: "next", + label: "找下一步", + prompt: "结合当前 Goal,告诉我现在最值得推进的一个动作,并说明理由。", + }, + { + id: "gate", + label: "看阻塞", + prompt: "当前 Goal 有哪些 Gate 或阻塞?哪些需要我决定?", + }, + { + id: "evidence", + label: "查证据", + prompt: "检查当前 Goal 的 Evidence,告诉我哪些结论已经有依据,哪些还需要验证。", + }, +]; + +export function agentBackendLabel(agentBackend: string | null | undefined) { + if (agentBackend === "multi_adapter") { + return "Local Agent endpoints"; + } + if (agentBackend === "codex_app_server") { + return "Codex app-server"; + } + if (!agentBackend) { + return "Local Agent"; + } + return agentBackend.replaceAll("_", " "); +} + +function normalizedStatusQuestion(value: string) { + return value + .trim() + .toLocaleLowerCase() + .replace(/[\s??!!。,,::;;]/g, ""); +} + +export function answerLocalStatusQuestion(status: ChatStatus | null, message: string) { + if (!status) { + return null; + } + const question = normalizedStatusQuestion(message); + const asksForGoalList = [ + /^(当前|现在)?(有|共有)?哪些(goals?|目标)$/, + /^(当前|现在)?(goals?|目标)有哪些$/, + /^列出(当前|全部|所有)?(goals?|目标)$/, + /^(show|list)(current|all)?goals?$/, + ].some((pattern) => pattern.test(question)); + if (!asksForGoalList) { + return null; + } + if (!status.goals.length) { + return "当前没有已连接的 Goal。"; + } + const lines = status.goals.map((goal, index) => { + const label = goal.title === goal.goal_id ? goal.goal_id : `${goal.title}(${goal.goal_id})`; + return `${index + 1}. ${label} · ${goal.status || "状态未知"}`; + }); + return `当前有 ${status.goals.length} 个 Goal:\n${lines.join("\n")}`; +} + +export function sessionInvalidatedByPayload(payload: unknown) { + return Boolean( + payload && + typeof payload === "object" && + (payload as Record).session_invalidated === true, + ); +} + +export function turnReplaySafeByPayload(payload: unknown) { + return Boolean( + sessionInvalidatedByPayload(payload) && + (payload as Record).turn_replay_safe === true, + ); +} + +export function chatFailureMessage(summary: string, sessionInvalidated: boolean) { + const message = summary.trim() || "Agent 会话暂时不可用。"; + return sessionInvalidated ? `${message} 会话已自动重置,可以直接重试。` : message; +} + +export function selectChatGoal(status: ChatStatus | null, preferredGoalId: string) { + if (!status) { + return null; + } + return ( + status.goals.find((goal) => goal.goal_id === preferredGoalId) ?? + status.goals.find((goal) => goal.goal_id === status.selected_goal_id) ?? + status.goals[0] ?? + null + ); +} + +export function buildGoalStudioNodes(goal: ChatGoal): GoalStudioNode[] { + return [ + { + kind: "goal", + eyebrow: "GOAL", + title: goal.title, + detail: goal.objective || "Keep the long-running objective visible.", + tone: "violet", + }, + { + kind: "todo", + eyebrow: "TOP TODO", + title: goal.top_todo?.priority || "NEXT", + detail: goal.top_todo?.text || "Ask the Agent for one bounded next step.", + tone: "blue", + }, + { + kind: "gate", + eyebrow: "GATE", + title: goal.waiting_on ? `Waiting on ${goal.waiting_on}` : "Clear", + detail: goal.gate || "No active gate.", + tone: "amber", + }, + { + kind: "evidence", + eyebrow: "EVIDENCE", + title: goal.evidence.length ? `${goal.evidence.length} signal${goal.evidence.length > 1 ? "s" : ""}` : "No evidence yet", + detail: goal.evidence[0] || "Validation evidence will appear here.", + tone: "mint", + }, + { + kind: "next", + eyebrow: "NEXT ACTION", + title: "Operator path", + detail: goal.next_action || "Review the next bounded proposal.", + tone: "slate", + }, + ]; +} + +export function pendingGoalReviews(goal: ChatGoal) { + return goal.todos.filter( + (todo) => + todo.role === "user" && + !["done", "completed", "closed", "deferred"].includes(todo.status) && + ["user_gate", "user_action"].includes(todo.task_class || "user_gate"), + ); +} + +export function completedGoalReviews(goal: ChatGoal) { + return goal.todos.filter( + (todo) => + todo.role === "user" && + ["done", "completed", "closed"].includes(todo.status) && + ["user_gate", "user_action"].includes(todo.task_class || "user_gate"), + ); +} + +export function proposalReviewState( + proposal: TodoProposal, + preview: TodoPreview | null, + outcome: ProposalDecisionOutcome, +): ProposalReviewState { + if (outcome !== "pending") { + return outcome; + } + if (preview?.preview_id && preview.todo.text === proposal.text) { + return "ready_to_approve"; + } + return "needs_preview"; +} diff --git a/apps/presentation/dashboard/src/data/chat.ts b/apps/presentation/dashboard/src/data/chat.ts new file mode 100644 index 000000000..c0e7a87f8 --- /dev/null +++ b/apps/presentation/dashboard/src/data/chat.ts @@ -0,0 +1,1053 @@ +import { z } from "zod"; + +import { + todoApplyResultMatchesRequest, + todoPreviewMatchesRequest, + type TodoApplyResult, + type TodoPreview, +} from "./chat-model"; + +export { + agentBackendLabel, + answerLocalStatusQuestion, + buildGoalStudioNodes, + chatFailureMessage, + completedGoalReviews, + pendingGoalReviews, + proposalReviewState, + sessionInvalidatedByPayload, + selectAvailableChatAgent, + selectChatGoal, + stewardPrompts, + turnReplaySafeByPayload, + todoNoWriteReceiptFromPayload, + todoNoWriteReceiptLabel, + todoApplyResultMatchesRequest, + todoPreviewMatchesRequest, + todoReceiptLabel, + todoReceiptOutcomeLabel, + todoReceiptProjected, +} from "./chat-model"; +export type { + AgentResponse, + ChatCapabilities, + ChatGoal, + ChatStatus, + ChatTodo, + GoalStudioNode, + ProposalDecisionOutcome, + ProposalReviewState, + StewardPrompt, + TodoNoWriteReceipt, + TodoPreview, + TodoProposal, + TodoApplyResult, + TodoWriteReceipt, +} from "./chat-model"; + +export const chatTodoSchema = z.object({ + todo_id: z.string().nullable(), + role: z.string().nullable(), + status: z.string(), + priority: z.string().nullable(), + text: z.string(), + action_kind: z.string().nullable(), + task_class: z.string().nullable(), + claimed_by: z.string().nullable(), + evidence: z.string().nullable(), +}); + +export const chatGoalSchema = z.object({ + goal_id: z.string(), + title: z.string(), + objective: z.string(), + status: z.string(), + waiting_on: z.string().nullable(), + severity: z.string().nullable(), + gate: z.string(), + next_action: z.string(), + top_todo: chatTodoSchema.nullable(), + todos: z.array(chatTodoSchema), + evidence: z.array(z.string()), + quota: z.object({ + state: z.string().nullable(), + spent_slots: z.number().nullable(), + allowed_slots: z.number().nullable(), + reason: z.string().nullable(), + }), +}); + +export const chatStatusSchema = z.object({ + ok: z.boolean(), + schema_version: z.literal("loopx_chat_status_v0"), + selected_goal_id: z.string().nullable(), + goal_count: z.number(), + goals: z.array(chatGoalSchema), +}); + +export const chatCapabilitiesSchema = z.object({ + ok: z.literal(true), + schema_version: z.enum(["loopx_chat_capabilities_v0", "loopx_chat_capabilities_v1"]), + agent_backend: z.string(), + sandbox: z.string(), + approval_policy: z.string(), + todo_write: z.string(), + goal_id: z.string().nullable(), + streaming: z.boolean().optional(), + resume: z.boolean().optional(), + interrupt: z.boolean().optional(), + typed_actions: z.boolean().optional(), + action_kinds: z.array(z.string()).optional(), + adapters: z.array(z.object({ + agent_id: z.string(), + display_name: z.string(), + adapter_kind: z.string(), + available: z.boolean(), + streaming: z.boolean(), + resume: z.boolean(), + interrupt: z.boolean(), + location: z.string().optional(), + source: z.string().optional(), + tool_calls: z.boolean().optional(), + trust_scope: z.string().optional(), + })).optional(), +}); + +export const todoProposalSchema = z.object({ + kind: z.literal("todo"), + text: z.string(), + priority: z.enum(["P0", "P1", "P2"]), + rationale: z.string(), +}); + +export const agentResponseSchema = z.object({ + schema_version: z.literal("loopx_chat_agent_response_v0"), + message: z.string(), + proposals: z.array(todoProposalSchema), + gate: z + .object({ + kind: z.string(), + summary: z.string(), + next_action: z.string(), + }) + .nullable(), +}); + +export const chatSessionCloseSchema = z.object({ + closed: z.literal(true), + ok: z.literal(true), + session_id: z.string().min(1), +}); + +export const todoPreviewSchema = z.object({ + dry_run: z.literal(true), + ok: z.literal(true), + preview_id: z.string().min(1), + todo: z.object({ + goal_id: z.string().min(1), + text: z.string(), + todo_id: z.string().optional(), + }), +}); + +export const todoWriteReceiptSchema = z.object({ + schema_version: z.literal("loopx_chat_todo_receipt_v0"), + receipt_id: z.string().min(1), + preview_id: z.string().min(1), + goal_id: z.string().min(1), + todo_id: z.string().min(1), + status: z.literal("applied"), + outcome: z.enum(["todo_added", "todo_already_exists"]), + already_exists: z.boolean(), + preview_revision: z.string().nullable(), +}); + +export const todoApplyResultSchema = z.object({ + applied: z.literal(true), + ok: z.literal(true), + receipt: todoWriteReceiptSchema, + todo: z.object({ + text: z.string(), + todo_id: z.string(), + }), +}); + +export const storedDecisionHistoryItemSchema = z + .object({ + id: z.string().min(1), + outcome: z.enum(["approved", "rejected", "cancelled"]), + projectionVerified: z.boolean().nullable(), + proposal: todoProposalSchema, + receipt: todoWriteReceiptSchema.nullable(), + }) + .superRefine((item, context) => { + if (item.outcome === "approved" && !item.receipt) { + context.addIssue({ + code: "custom", + message: "approved decision history requires a Todo receipt", + path: ["receipt"], + }); + } + if (item.outcome !== "approved" && item.receipt) { + context.addIssue({ + code: "custom", + message: "zero-write decision history must not include a Todo receipt", + path: ["receipt"], + }); + } + }); + +export const storedDecisionHistorySchema = z.object({ + schema_version: z.literal("loopx_chat_decision_history_v0"), + goal_id: z.string().min(1), + decisions: z.array(storedDecisionHistoryItemSchema).max(24), +}); + +export type StoredDecisionHistoryItem = z.infer; + +export class ChatApiError extends Error { + payload: Record; + + constructor(message: string, payload: Record) { + super(message); + this.payload = payload; + } +} + +export const typedActionKindSchema = z.enum([ + "goal.create", + "goal.update", + "todo.create", + "todo.update", + "agent.bind", + "heartbeat.bind", + "monitor.create", + "monitor.update", + "gate.resolve", + "run.correct", +]); + +export const typedActionProposalSchema = z.object({ + schema_version: z.literal("loopx_chat_action_proposal_v1"), + proposal_id: z.string().min(1), + action_kind: typedActionKindSchema, + summary: z.string().min(1), + normalized_parameters: z.record(z.string(), z.unknown()), + context: z.record(z.string(), z.unknown()), + expected_state_fingerprint: z.string().min(1), + permission_classification: z.string().min(1), + validation_evidence: z.array(z.unknown()), + available_transitions: z.array(z.enum(["apply", "cancel", "regenerate", "reject", "defer"])), + status: z.enum(["preview_ready", "applying", "gated", "failed", "rejected", "deferred", "cancelled", "stale", "applied"]), + receipt: z.record(z.string(), z.unknown()).nullable(), + stale: z.record(z.string(), z.unknown()).nullable(), + gate: z.record(z.string(), z.unknown()).nullable().optional(), + error: z.record(z.string(), z.unknown()).nullable().optional(), + checkpoint: z.record(z.string(), z.unknown()).nullable().optional(), + regenerated_from: z.string().nullable().optional(), + created_at: z.string(), + updated_at: z.string(), +}); + +export type TypedActionKind = z.infer; +export type TypedActionProposal = z.infer; + +export type TypedActionPreviewRequest = { + actionKind: TypedActionKind; + context: Record; + idempotencyKey: string; + normalizedParameters: Record; + summary: string; +}; + +const typedActionEnvelopeSchema = z.object({ + ok: z.literal(true), + proposal: typedActionProposalSchema, +}); + +export async function previewTypedAction(request: TypedActionPreviewRequest) { + const payload = await requestJson("/api/actions/preview", { + method: "POST", + body: JSON.stringify({ + action_kind: request.actionKind, + context: request.context, + idempotency_key: request.idempotencyKey, + normalized_parameters: request.normalizedParameters, + summary: request.summary, + }), + }); + return typedActionEnvelopeSchema.parse(payload).proposal; +} + +export async function loadTypedAction(proposalId: string) { + return typedActionEnvelopeSchema.parse( + await requestJson(`/api/actions/${encodeURIComponent(proposalId)}`), + ).proposal; +} + +const typedActionListEnvelopeSchema = z.object({ + ok: z.literal(true), + schema_version: z.literal("loopx_chat_action_list_v1"), + proposals: z.array(typedActionProposalSchema), +}); + +export async function listTypedActions(filters: { contextKind?: string; goalId?: string } = {}) { + const query = new URLSearchParams(); + if (filters.contextKind) query.set("context_kind", filters.contextKind); + if (filters.goalId) query.set("goal_id", filters.goalId); + const suffix = query.size > 0 ? `?${query.toString()}` : ""; + return typedActionListEnvelopeSchema.parse( + await requestJson(`/api/actions${suffix}`), + ).proposals; +} + +export async function applyTypedAction(proposalId: string) { + const payload = await requestJson( + `/api/actions/${encodeURIComponent(proposalId)}/apply`, + { method: "POST", body: "{}" }, + ); + return z.object({ + ok: z.literal(true), + proposal: typedActionProposalSchema, + turn: z.record(z.string(), z.unknown()).nullable().optional(), + }).parse(payload); +} + +export async function cancelTypedAction(proposalId: string) { + return typedActionEnvelopeSchema.parse( + await requestJson(`/api/actions/${encodeURIComponent(proposalId)}/cancel`, { + method: "POST", + body: "{}", + }), + ).proposal; +} + +export async function transitionTypedAction( + proposalId: string, + transition: "regenerate" | "reject" | "defer", +) { + return typedActionEnvelopeSchema.parse( + await requestJson(`/api/actions/${encodeURIComponent(proposalId)}/${transition}`, { + method: "POST", + body: "{}", + }), + ).proposal; +} + +async function requestJson(url: string, init?: RequestInit): Promise { + const response = await fetch(url, { + cache: "no-store", + ...init, + headers: { + "Content-Type": "application/json", + ...init?.headers, + }, + }); + const payload = (await response.json()) as Record; + if (!response.ok) { + const proposal = payload.proposal && typeof payload.proposal === "object" + ? payload.proposal as Record + : null; + const staleMessage = proposal?.status === "stale" + ? "来源状态已变化,请重新生成预览。" + : null; + throw new ChatApiError(staleMessage ?? String(payload.error || `HTTP ${response.status}`), payload); + } + return payload as T; +} + +export async function fetchChatStatus() { + return chatStatusSchema.parse(await requestJson("/status.json")); +} + +export async function fetchChatCapabilities() { + return chatCapabilitiesSchema.parse(await requestJson("/api/chat/capabilities")); +} + +export async function recordProjectionExchange(options: { + answer: string; + contextKind: "goal" | "manager"; + goalId?: string; + question: string; +}) { + return requestJson<{ ok: true; schema_version: "loopx_chat_projection_exchange_v1"; session_id: string }>( + "/api/chat/projection-messages", + { + method: "POST", + body: JSON.stringify({ + answer: options.answer, + context_kind: options.contextKind, + goal_id: options.goalId, + question: options.question, + }), + }, + ); +} + +export async function createChatSession( + goalId: string, + agentId = "codex", + mode: "resume_latest" | "new" = "resume_latest", + contextKind: "goal" | "manager" = "goal", +) { + return requestJson<{ + agent_id: string; + goal_id: string; + ok: true; + resumed: boolean; + session_id: string; + }>("/api/chat/sessions", { + method: "POST", + body: JSON.stringify({ goal_id: goalId, agent_id: agentId, mode, context_kind: contextKind }), + }); +} + +export type ChatStreamEvent = { + event_id: string; + sequence: number; + kind: string; + created_at: string; + payload: Record; +}; + +export type ChatSessionSummary = { + session_id: string; + goal_id: string; + agent_id: string; + adapter_kind: string; + channel_id?: string; + status: string; + active_turn_id: string | null; + last_error_code: string | null; + created_at: string; + updated_at: string; + last_activity_at: string; + resumable: boolean; +}; + +export type ChatVisibleMessage = { + attachments?: ChatImageAttachment[]; + message_id: string; + turn_id: string | null; + role: string; + text: string; + created_at: string; +}; + +export type ChatImageAttachment = { + data_url: string; + id: string; + mime_type: string; + name: string; + size: number; +}; + +export type ChatImageAttachmentInput = { + dataUrl: string; + id: string; + mimeType: string; + name: string; + size: number; +}; + +export type ChatSessionSnapshot = { + ok: true; + schema_version: "loopx_chat_store_v1"; + session: ChatSessionSummary; + messages: ChatVisibleMessage[]; + active_turn: Record | null; +}; + +export async function fetchChatSession(sessionId: string) { + return requestJson(`/api/chat/sessions/${sessionId}`); +} + +export async function fetchChatSessions(options: { + agentId?: string; + channelId?: string; + goalId?: string; +}) { + const query = new URLSearchParams(); + if (options.agentId) query.set("agent_id", options.agentId); + if (options.channelId) query.set("channel_id", options.channelId); + if (options.goalId) query.set("goal_id", options.goalId); + return requestJson<{ + ok: true; + schema_version: "loopx_chat_session_list_v1"; + sessions: ChatSessionSummary[]; + }>(`/api/chat/sessions?${query.toString()}`); +} + +export function mergeChatSessionMessages(snapshots: ChatSessionSnapshot[]) { + const messages = new Map(); + for (const snapshot of snapshots) { + for (const message of snapshot.messages) { + messages.set(message.message_id, message); + } + } + return [...messages.values()].sort((left, right) => + left.created_at.localeCompare(right.created_at) + || left.message_id.localeCompare(right.message_id) + ); +} + +export async function fetchChatHistory(options: { + agentId: string; + channelId: string; + goalId?: string; +}) { + const listed = await fetchChatSessions(options); + const snapshots = await Promise.all( + listed.sessions.map((session) => fetchChatSession(session.session_id)), + ); + return { + messages: mergeChatSessionMessages(snapshots), + sessions: listed.sessions, + snapshots, + }; +} + +export async function acceptChatTurn( + sessionId: string, + message: string, + clientTurnId: string, + attachments: ChatImageAttachmentInput[] = [], +) { + return requestJson<{ + ok: true; + session_id: string; + turn_id: string; + created: boolean; + status: string; + events_url: string; + }>(`/api/chat/sessions/${sessionId}/turns`, { + method: "POST", + body: JSON.stringify({ + message, + client_turn_id: clientTurnId, + ...(attachments.length ? { attachments: attachments.map((attachment) => ({ + data_url: attachment.dataUrl, + id: attachment.id, + mime_type: attachment.mimeType, + name: attachment.name, + size: attachment.size, + })) } : {}), + }), + }); +} + +function parseSseBlock(block: string): ChatStreamEvent | null { + const data = block + .split("\n") + .filter((line) => line.startsWith("data:")) + .map((line) => line.slice(5).trimStart()) + .join("\n"); + if (!data) return null; + try { + const parsed = JSON.parse(data) as Partial; + if (!parsed.kind || !parsed.payload || typeof parsed.payload !== "object") return null; + return { + event_id: String(parsed.event_id ?? ""), + sequence: Number(parsed.sequence ?? 0), + kind: String(parsed.kind), + created_at: String(parsed.created_at ?? ""), + payload: parsed.payload as Record, + }; + } catch { + return null; + } +} + +export async function streamChatTurn( + eventsUrl: string, + onEvent: (event: ChatStreamEvent) => void, + signal?: AbortSignal, +) { + let cursor = ""; + let attempts = 0; + let terminal = false; + while (!terminal && attempts < 4) { + const origin = typeof window === "undefined" ? "http://127.0.0.1" : window.location.origin; + const url = new URL(eventsUrl, origin); + if (cursor) url.searchParams.set("after", cursor); + try { + const response = await fetch(url, { + cache: "no-store", + headers: { Accept: "text/event-stream" }, + signal, + }); + if (!response.ok || !response.body) { + throw new ChatApiError(`SSE HTTP ${response.status}`, { status: response.status }); + } + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + while (true) { + const { done, value } = await reader.read(); + buffer += decoder.decode(value, { stream: !done }).replaceAll("\r\n", "\n"); + let boundary = buffer.indexOf("\n\n"); + while (boundary >= 0) { + const block = buffer.slice(0, boundary); + buffer = buffer.slice(boundary + 2); + const event = parseSseBlock(block); + if (event) { + if (event.event_id) cursor = event.event_id; + onEvent(event); + terminal = ["turn.completed", "turn.interrupted", "turn.failed"].includes(event.kind); + } + boundary = buffer.indexOf("\n\n"); + } + if (done || terminal) break; + } + attempts = terminal ? attempts : attempts + 1; + } catch (error) { + if (signal?.aborted) throw error; + attempts += 1; + if (attempts >= 4) throw error; + await new Promise((resolve) => globalThis.setTimeout(resolve, 250 * 2 ** (attempts - 1))); + } + } + if (!terminal) { + throw new ChatApiError("Agent 事件流连接已断开。", { reconnect_attempts: attempts }); + } +} + +export async function interruptChatTurn(sessionId: string, turnId: string) { + return requestJson<{ ok: true; session_id: string; turn_id: string; status: string }>( + `/api/chat/sessions/${sessionId}/turns/${turnId}/interrupt`, + { method: "POST", body: "{}" }, + ); +} + +export async function sendChatTurnStreaming( + sessionId: string, + message: string, + options: { + attachments?: ChatImageAttachmentInput[]; + clientTurnId?: string; + onDelta?: (text: string) => void; + onActivity?: (label: string) => void; + onPhase?: (phase: string, turnId: string) => void; + signal?: AbortSignal; + } = {}, +) { + const accepted = await acceptChatTurn( + sessionId, + message, + options.clientTurnId ?? crypto.randomUUID(), + options.attachments, + ); + options.onPhase?.("turn.accepted", accepted.turn_id); + return receiveChatTurnStreaming( + sessionId, + accepted.turn_id, + accepted.events_url, + options, + ); +} + +async function receiveChatTurnStreaming( + sessionId: string, + turnId: string, + eventsUrl: string, + options: { + onDelta?: (text: string) => void; + onActivity?: (label: string) => void; + onPhase?: (phase: string, turnId: string) => void; + signal?: AbortSignal; + } = {}, +) { + let finalResponse: unknown = null; + const outcome: { + failure: Record | null; + interrupted: Record | null; + } = { failure: null, interrupted: null }; + try { + await streamChatTurn( + eventsUrl, + (event) => { + options.onPhase?.(event.kind, turnId); + if (event.kind === "answer.delta" || event.kind === "assistant.delta") { + options.onDelta?.(String(event.payload.text ?? "")); + } + if (event.kind === "agent.phase") { + options.onActivity?.(String(event.payload.label ?? "Agent 正在处理")); + } + if (event.kind === "turn.completed") { + finalResponse = event.payload.response; + } + if (event.kind === "turn.failed") { + outcome.failure = event.payload; + } + if (event.kind === "turn.interrupted") { + outcome.interrupted = event.payload; + } + }, + options.signal, + ); + } catch (error) { + if (error instanceof ChatApiError && !options.signal?.aborted) { + throw new ChatApiError(error.message, { + ...error.payload, + events_url: eventsUrl, + reconnectable: true, + session_id: sessionId, + turn_id: turnId, + }); + } + throw error; + } + if (outcome.failure) { + throw new ChatApiError( + String(outcome.failure.message || "Agent 回合失败。"), + outcome.failure, + ); + } + if (outcome.interrupted) { + throw new ChatApiError("Agent 回合已中断。", { + ...outcome.interrupted, + error_code: "turn_interrupted", + session_id: sessionId, + turn_id: turnId, + }); + } + return { + response: agentResponseSchema.parse(finalResponse), + sessionId, + turnId, + }; +} + +export async function resumeChatTurnStreaming( + sessionId: string, + turnId: string, + options: { + onDelta?: (text: string) => void; + onActivity?: (label: string) => void; + onPhase?: (phase: string, turnId: string) => void; + signal?: AbortSignal; + } = {}, +) { + return receiveChatTurnStreaming( + sessionId, + turnId, + `/api/chat/sessions/${sessionId}/turns/${turnId}/events`, + options, + ); +} + +export async function sendChatTurn(sessionId: string, message: string) { + const payload = await requestJson<{ response: unknown }>(`/api/chat/sessions/${sessionId}/turns`, { + method: "POST", + body: JSON.stringify({ message }), + }); + return agentResponseSchema.parse(payload.response); +} + +export async function closeChatSession(sessionId: string) { + const result = chatSessionCloseSchema.parse( + await requestJson(`/api/chat/sessions/${sessionId}`, { + keepalive: true, + method: "DELETE", + }), + ); + if (result.session_id !== sessionId) { + throw new ChatApiError("Agent 会话关闭回执与本次请求不一致。", { + session_id: result.session_id, + }); + } + return result; +} + +export async function resumeChatSession(sessionId: string) { + return requestJson<{ ok: true; schema_version: "loopx_chat_session_resume_v1"; session: ChatSessionSummary }>( + `/api/chat/sessions/${sessionId}/resume`, + { method: "POST", body: "{}" }, + ); +} + +export async function previewTodo(goalId: string, text: string) { + const preview = todoPreviewSchema.parse( + await requestJson("/api/chat/todo/dry-run", { + method: "POST", + body: JSON.stringify({ goal_id: goalId, text }), + }), + ); + if (!todoPreviewMatchesRequest(preview, { goalId, text })) { + throw new ChatApiError("Todo 写入预览与本次请求不一致,已停止进入批准状态。", { + preview, + }); + } + return preview; +} + +export async function applyTodo(goalId: string, text: string, previewId: string) { + const result = todoApplyResultSchema.parse( + await requestJson("/api/chat/todo/apply", { + method: "POST", + body: JSON.stringify({ goal_id: goalId, text, preview_id: previewId }), + }), + ); + if (!todoApplyResultMatchesRequest(result, { goalId, previewId, text })) { + throw new ChatApiError("Todo 写入回执与本次批准不一致,界面已停止更新。", { + receipt: result.receipt, + todo: result.todo, + }); + } + return result; +} + +export function parseCompletedDecisionHistory(raw: string | null, goalId: string) { + if (!raw) return []; + try { + const parsed = storedDecisionHistorySchema.safeParse(JSON.parse(raw)); + if (!parsed.success || parsed.data.goal_id !== goalId) return []; + return parsed.data.decisions; + } catch { + return []; + } +} + +export function serializeCompletedDecisionHistory( + goalId: string, + decisions: StoredDecisionHistoryItem[], +) { + return JSON.stringify( + storedDecisionHistorySchema.parse({ + schema_version: "loopx_chat_decision_history_v0", + goal_id: goalId, + decisions: decisions.slice(0, 24), + }), + ); +} + +export type GoalChannelTarget = { + enabled: boolean; + provider: string; + target_name: string; +}; + +const goalChannelTargetsSchema = z.object({ + ok: z.literal(true), + targets: z.array( + z.object({ + enabled: z.boolean(), + provider: z.string(), + target_name: z.string(), + }), + ), +}); + +export async function fetchGoalChannelTargets() { + return goalChannelTargetsSchema.parse( + await requestJson("/api/chat/goal-channel/targets"), + ).targets; +} + +const goalChannelOperationSchema = z.object({ + ok: z.boolean(), + blocker: z.string().optional(), + public_summary: z.string().optional(), + status: z.string().optional(), +}); + +export type GoalChannelOperation = z.infer; + +export async function setupGoalChannel(options: { execute: boolean; goalId: string; target: string }) { + return goalChannelOperationSchema.parse( + await requestJson("/api/chat/goal-channel/setup", { + method: "POST", + body: JSON.stringify({ + execute: options.execute, + goal_id: options.goalId, + target: options.target, + }), + }), + ); +} + +export async function configureGoalChannelAutoNotify(options: { autoNotify: boolean; goalId: string }) { + return goalChannelOperationSchema.parse( + await requestJson("/api/chat/goal-channel/configure", { + method: "POST", + body: JSON.stringify({ + auto_notify_human_gates: options.autoNotify, + goal_id: options.goalId, + }), + }), + ); +} + +export type GoalRepositoryContext = { + branch: string; + identity: string; + label: string; + read_only: true; +}; + +const goalContextsSchema = z.object({ + ok: z.literal(true), + goals: z.array(z.object({ + goal_id: z.string(), + repository: z.object({ + branch: z.string(), + identity: z.string(), + label: z.string(), + read_only: z.literal(true), + }), + })), +}); + +export async function fetchGoalContexts() { + return goalContextsSchema.parse( + await requestJson("/api/chat/goals/contexts"), + ).goals; +} + +export type LarkApp = { + active: boolean; + app_ref: string; + brand: string; + label: string; + ready: boolean; +}; + +const larkAppsSchema = z.object({ + ok: z.literal(true), + apps: z.array(z.object({ + active: z.boolean(), + app_ref: z.string(), + brand: z.string(), + label: z.string(), + ready: z.boolean(), + })), +}); + +export async function fetchLarkApps() { + return larkAppsSchema.parse( + await requestJson("/api/chat/lark/apps"), + ).apps; +} + +export type LarkAppSetup = { + app_ref: string; + error: string | null; + setup_id: string; + status: "starting" | "waiting_for_feishu" | "ready" | "failed" | "cancelled"; + verification_url: string | null; +}; + +const larkAppSetupSchema = z.object({ + ok: z.literal(true), + app_ref: z.string(), + error: z.string().nullable(), + setup_id: z.string(), + status: z.enum(["starting", "waiting_for_feishu", "ready", "failed", "cancelled"]), + verification_url: z.string().url().nullable(), +}); + +export async function startLarkAppSetup(options: { appRef: string; brand: "feishu" | "lark" }) { + return larkAppSetupSchema.parse( + await requestJson("/api/chat/lark/app-setups", { + method: "POST", + body: JSON.stringify({ app_ref: options.appRef, brand: options.brand }), + }), + ); +} + +export async function fetchLarkAppSetup(setupId: string) { + return larkAppSetupSchema.parse( + await requestJson(`/api/chat/lark/app-setups/${encodeURIComponent(setupId)}`), + ); +} + +export async function cancelLarkAppSetup(setupId: string) { + return larkAppSetupSchema.parse( + await requestJson(`/api/chat/lark/app-setups/${encodeURIComponent(setupId)}`, { + method: "DELETE", + }), + ); +} + +export type LarkGroupChat = { chat_id: string; chat_name: string }; + +const larkGroupChatsSchema = z.object({ + ok: z.literal(true), + chats: z.array(z.object({ chat_id: z.string(), chat_name: z.string() })), +}); + +export async function fetchLarkGroupChats(appRef: string, query?: string) { + const params = new URLSearchParams({ app_ref: appRef }); + if (query) params.set("query", query); + return larkGroupChatsSchema.parse( + await requestJson(`/api/chat/lark/chats?${params.toString()}`), + ).chats; +} + +export type LarkGoalConnection = { + app_label: string; + app_ref: string; + chat_name: string; + enabled: boolean; + goal_id: string; + goal_title: string; + incoming_mode: "mentions" | "all"; + reply_mode: "topic_reply"; + target_ref: string; + topic_name: string; + topic_setup_required: boolean; +}; + +const larkConnectionsSchema = z.object({ + ok: z.literal(true), + connections: z.array(z.object({ + app_label: z.string(), + app_ref: z.string(), + chat_name: z.string(), + enabled: z.boolean(), + goal_id: z.string(), + goal_title: z.string(), + incoming_mode: z.enum(["mentions", "all"]), + reply_mode: z.literal("topic_reply"), + target_ref: z.string(), + topic_name: z.string(), + topic_setup_required: z.boolean(), + })), +}); + +export async function fetchLarkConnections() { + return larkConnectionsSchema.parse( + await requestJson("/api/chat/lark/connections"), + ).connections; +} + +export async function connectLarkGoalTopic(options: { + appRef: string; + chatId: string; + chatName: string; + execute: boolean; + goalId: string; + incomingMode: "mentions" | "all"; +}) { + return goalChannelOperationSchema.parse( + await requestJson("/api/chat/lark/connections", { + method: "POST", + body: JSON.stringify({ + app_ref: options.appRef, + chat_id: options.chatId, + chat_name: options.chatName, + execute: options.execute, + goal_id: options.goalId, + incoming_mode: options.incomingMode, + }), + }), + ); +} + +export async function disconnectLarkGoalTopic(goalId: string) { + return goalChannelOperationSchema.parse( + await requestJson(`/api/chat/lark/connections?goal_id=${encodeURIComponent(goalId)}`, { + method: "DELETE", + }), + ); +} diff --git a/apps/presentation/dashboard/src/data/status.ts b/apps/presentation/dashboard/src/data/status.ts index b4c372206..4922fa197 100644 --- a/apps/presentation/dashboard/src/data/status.ts +++ b/apps/presentation/dashboard/src/data/status.ts @@ -191,6 +191,22 @@ export const agentManagementProjectionSchema = z.object({ agents: z.array(agentManagementRowSchema).optional().default([]), }).passthrough(); +export const goalChannelNotificationRowSchema = z.object({ + goal_id: z.string(), + configured: z.boolean().optional().default(false), + enabled: z.boolean().optional().default(false), + human_gate_auto_notify_enabled: z.boolean().optional().default(false), + target_ref: z.string().optional().nullable(), + receipt_count: z.number().optional().default(0), + last_notified_at: z.string().optional().nullable(), +}).passthrough(); + +export const goalChannelNotificationProjectionSchema = z.object({ + schema_version: z.string().optional().nullable(), + generated_at: z.string().optional().nullable(), + goals: z.array(goalChannelNotificationRowSchema).optional().default([]), +}).passthrough(); + export const projectAssetTodoSummarySchema = z.object({ source_section: z.string().optional().nullable(), open: z.number().optional().default(0), @@ -457,6 +473,7 @@ export const runRecordSchema = z.object({ export const runGoalSchema = z.object({ id: z.string(), + display_name: z.string().optional().nullable(), domain: z.string().optional().nullable(), status: z.string().optional().nullable(), lifecycle_phase: z.string().optional().nullable(), @@ -523,6 +540,16 @@ export const usageTotalsSchema = z.object({ automation_run_count_7d: z.number().optional().default(0), progress_signal_run_count_24h: z.number().optional().default(0), progress_signal_run_count_7d: z.number().optional().default(0), + input_tokens_24h: z.number().optional().default(0), + input_tokens_7d: z.number().optional().default(0), + output_tokens_24h: z.number().optional().default(0), + output_tokens_7d: z.number().optional().default(0), + cache_tokens_24h: z.number().optional().default(0), + cache_tokens_7d: z.number().optional().default(0), + cost_usd_24h: z.number().optional().default(0), + cost_usd_7d: z.number().optional().default(0), + duration_ms_24h: z.number().optional().default(0), + duration_ms_7d: z.number().optional().default(0), }); export const usageGoalSchema = usageTotalsSchema.extend({ @@ -539,6 +566,16 @@ const defaultUsageTotals = { automation_run_count_7d: 0, progress_signal_run_count_24h: 0, progress_signal_run_count_7d: 0, + input_tokens_24h: 0, + input_tokens_7d: 0, + output_tokens_24h: 0, + output_tokens_7d: 0, + cache_tokens_24h: 0, + cache_tokens_7d: 0, + cost_usd_24h: 0, + cost_usd_7d: 0, + duration_ms_24h: 0, + duration_ms_7d: 0, }; export const usageSummarySchema = z.object({ @@ -838,6 +875,7 @@ export const statusPayloadSchema = z.object({ usage_summary: usageSummarySchema.default(null), todo_index: todoIndexSchema.optional().nullable().default(null), agent_management_projection: agentManagementProjectionSchema.optional().nullable().default(null), + goal_channel_notification_projection: goalChannelNotificationProjectionSchema.optional().nullable().default(null), presentation_surfaces: presentationSurfaceCollectionSchema.optional().default( emptyPresentationSurfaceCollection, ), @@ -891,6 +929,8 @@ export type AgentManagementWorkspaceRef = z.infer; export type AgentManagementHandoffNote = z.infer; export type AgentManagementProjection = z.infer; +export type GoalChannelNotificationRow = z.infer; +export type GoalChannelNotificationProjection = z.infer; export type ReviewMaterial = z.infer; export type ProjectMap = z.infer; export type GlobalRegistryHealth = z.infer; diff --git a/apps/presentation/dashboard/src/features/personal-workspace/cards/attention-row.tsx b/apps/presentation/dashboard/src/features/personal-workspace/cards/attention-row.tsx new file mode 100644 index 000000000..33f28a636 --- /dev/null +++ b/apps/presentation/dashboard/src/features/personal-workspace/cards/attention-row.tsx @@ -0,0 +1,20 @@ +import { AlertCircle, ChevronRight } from "lucide-react"; + +import type { WorkspaceAttention } from "../personal-workspace-model"; +import { attentionAgeLabel } from "../personal-workspace-model"; + +export function AttentionRow({ attention, onSelect }: { attention: WorkspaceAttention; onSelect: () => void }) { + const age = attentionAgeLabel(attention.updatedAt); + return ( + + ); +} diff --git a/apps/presentation/dashboard/src/features/personal-workspace/cards/output-row.tsx b/apps/presentation/dashboard/src/features/personal-workspace/cards/output-row.tsx new file mode 100644 index 000000000..30ba759f6 --- /dev/null +++ b/apps/presentation/dashboard/src/features/personal-workspace/cards/output-row.tsx @@ -0,0 +1,18 @@ +import { ChevronRight, FileCheck2 } from "lucide-react"; + +import type { WorkspaceOutput } from "../personal-workspace-model"; + +export function OutputRow({ onSelect, output }: { onSelect: () => void; output: WorkspaceOutput }) { + return ( + + ); +} diff --git a/apps/presentation/dashboard/src/features/personal-workspace/cards/run-row.tsx b/apps/presentation/dashboard/src/features/personal-workspace/cards/run-row.tsx new file mode 100644 index 000000000..81a9a686b --- /dev/null +++ b/apps/presentation/dashboard/src/features/personal-workspace/cards/run-row.tsx @@ -0,0 +1,32 @@ +import { Bot, ChevronRight, LoaderCircle } from "lucide-react"; + +import type { WorkspaceRun } from "../personal-workspace-model"; + +const runStatusLabel: Record = { + completed: "已完成", + failed: "需检查", + interrupted: "已中断", + queued: "已安排", + running: "执行中", + waiting: "等待条件", +}; + +export function RunRow({ onSelect, run }: { onSelect: () => void; run: WorkspaceRun }) { + const progress = run.totalSteps > 0 ? Math.min(100, (run.completedSteps / run.totalSteps) * 100) : 0; + return ( + + ); +} diff --git a/apps/presentation/dashboard/src/features/personal-workspace/cards/schedule-row.tsx b/apps/presentation/dashboard/src/features/personal-workspace/cards/schedule-row.tsx new file mode 100644 index 000000000..fbf35c1a6 --- /dev/null +++ b/apps/presentation/dashboard/src/features/personal-workspace/cards/schedule-row.tsx @@ -0,0 +1,27 @@ +import { CalendarClock, ChevronRight, Radio } from "lucide-react"; + +import type { WorkspaceSchedule } from "../personal-workspace-model"; + +export function ScheduleRow({ onSelect, schedule }: { + onSelect: () => void; + schedule: WorkspaceSchedule; +}) { + const isHeartbeat = schedule.scheduleKind === "heartbeat"; + return ( + + ); +} diff --git a/apps/presentation/dashboard/src/features/personal-workspace/channel-header.tsx b/apps/presentation/dashboard/src/features/personal-workspace/channel-header.tsx new file mode 100644 index 000000000..c8fc34821 --- /dev/null +++ b/apps/presentation/dashboard/src/features/personal-workspace/channel-header.tsx @@ -0,0 +1,102 @@ +import { Bot, ChevronDown, Info, Menu, Palette, RefreshCw } from "lucide-react"; + +import type { WorkspaceAgentOption, WorkspaceGoal, WorkspaceGoalTab } from "./personal-workspace-model"; +import { goalUsageLabel } from "./personal-workspace-model"; + +export function ChannelHeader({ + agents, + managerChatOpen, + mobileNavigationOpen, + onOpenGoalDetail, + onOpenManagerChat, + onRefresh, + onOpenNavigation, + onSelectGoalTab, + onSelectAgent, + onReturnManagerHome, + onToggleTheme, + refreshState, + selectedAgentId, + selectedGoal, + selectedGoalTab, + theme, +}: { + agents: WorkspaceAgentOption[]; + managerChatOpen?: boolean; + mobileNavigationOpen?: boolean; + onOpenGoalDetail?: () => void; + onOpenManagerChat?: () => void; + onRefresh?: () => void; + onOpenNavigation?: () => void; + onSelectGoalTab: (tab: WorkspaceGoalTab) => void; + onSelectAgent: (agentId: string) => void; + onReturnManagerHome?: () => void; + onToggleTheme: () => void; + refreshState?: "idle" | "loading" | "done" | "error"; + selectedAgentId: string; + selectedGoal: WorkspaceGoal | null; + selectedGoalTab: WorkspaceGoalTab; + theme: "brutal" | "paper"; +}) { + return ( +
+ +
+

{selectedGoal?.title ?? "LoopX 管家"}

+

{selectedGoal + ? `${selectedGoal.agentLabel ?? selectedGoal.agentId} · ${selectedGoal.state}${goalUsageLabel(selectedGoal.usage) ? ` · ${goalUsageLabel(selectedGoal.usage)}` : ""} · ${selectedGoal.nextSentence}` + : "跨 Goal 的个人工作入口"}

+
+ {selectedGoal ? ( + + ) : ( + + )} + {selectedGoal && onOpenGoalDetail ? ( + + ) : null} +
+ + 实时 + + {onRefresh ? ( + + {refreshState === "loading" ? 刷新中 : refreshState === "done" ? 刚刚更新 : refreshState === "error" ? 刷新失败 : null} + + + ) : null} +
+
+ ); +} diff --git a/apps/presentation/dashboard/src/features/personal-workspace/channel-timeline.tsx b/apps/presentation/dashboard/src/features/personal-workspace/channel-timeline.tsx new file mode 100644 index 000000000..fb6b2da93 --- /dev/null +++ b/apps/presentation/dashboard/src/features/personal-workspace/channel-timeline.tsx @@ -0,0 +1,97 @@ +import { Bot, Sparkles } from "lucide-react"; + +import { AttentionRow } from "./cards/attention-row"; +import { MarkdownText } from "./markdown"; +import { OutputRow } from "./cards/output-row"; +import { RunRow } from "./cards/run-row"; +import { ScheduleRow } from "./cards/schedule-row"; +import type { WorkspaceDrawerSelection, WorkspaceGoal, WorkspaceTimelineItem } from "./personal-workspace-model"; + +export function ChannelTimeline({ + items, + onSelect, + selectedGoal, +}: { + items: WorkspaceTimelineItem[]; + onSelect: (selection: WorkspaceDrawerSelection) => void; + selectedGoal: WorkspaceGoal | null; +}) { + if (items.length === 0) { + return ( +
+ + {selectedGoal ? "这个 Goal 还没有新动态" : "今天的工作区很安静"} +

{selectedGoal ? "你可以直接询问进度或下发新的纠偏信息。" : "向 LoopX 管家描述一个 Goal,或询问今天最值得关注的事情。"}

+
+ ); + } + + const latestAnnounceable = [...items].reverse().find((item) => + (item.kind === "message" && item.message.role !== "user") + || (item.kind === "proposal" && ["applied", "stale", "error", "gated"].includes(item.proposal.status)) + || (item.kind === "run" && item.run.status === "completed")); + const liveAnnouncement = latestAnnounceable?.kind === "message" + ? `${latestAnnounceable.message.agentLabel ?? "LoopX 管家"}:${latestAnnounceable.message.pending ? "正在回复" : latestAnnounceable.message.text}` + : latestAnnounceable?.kind === "proposal" + ? `${latestAnnounceable.proposal.title}:${latestAnnounceable.proposal.status}` + : latestAnnounceable?.kind === "run" + ? `${latestAnnounceable.run.title}:已完成` + : ""; + + const gatedItems = items.filter((item): item is Extract => + item.kind === "proposal" && item.proposal.status === "gated"); + const primaryItems = items.filter((item) => item.kind !== "proposal"); + const activeProposalItems = items.filter((item): item is Extract => + item.kind === "proposal" && item.proposal.status !== "gated"); + + function renderItem(item: WorkspaceTimelineItem) { + if (item.kind === "attention") { + return onSelect({ item: item.attention, kind: "attention" })} />; + } + if (item.kind === "run") { + return onSelect({ item: item.run, kind: "run" })} run={item.run} />; + } + if (item.kind === "output") { + return onSelect({ item: item.output, kind: "output" })} output={item.output} />; + } + if (item.kind === "schedule") { + return onSelect({ item: item.schedule, kind: "schedule" })} schedule={item.schedule} />; + } + if (item.kind === "proposal") { + return ( + + ); + } + return ( +
+ {item.message.role !== "user" ? : null} +
+
{item.message.role === "user" ? "你" : item.message.agentLabel ?? "LoopX 管家"}{item.message.time ? : null}
+ {item.message.attachments?.length ?
{item.message.attachments.map((attachment) => {attachment.name})}
: null} + {item.message.role === "user" ?

{item.message.text}

: } + {item.message.pending ? 正在整理… : null} +
+
+ ); + } + + return ( + <> +

{liveAnnouncement}

+
+ {primaryItems.map(renderItem)} + {gatedItems.length ? ( +
+ 待你确认{gatedItems.length} 项历史 Gate +
{gatedItems.map(renderItem)}
+
+ ) : null} + {activeProposalItems.map(renderItem)} +
+ + ); +} diff --git a/apps/presentation/dashboard/src/features/personal-workspace/context-drawer.tsx b/apps/presentation/dashboard/src/features/personal-workspace/context-drawer.tsx new file mode 100644 index 000000000..5c7e558d7 --- /dev/null +++ b/apps/presentation/dashboard/src/features/personal-workspace/context-drawer.tsx @@ -0,0 +1,576 @@ +import { useEffect, useRef, useState } from "react"; +import { + ArrowLeft, + Bell, + Bot, + CalendarClock, + Check, + ChevronDown, + Copy, + Download, + ExternalLink, + GitBranch, + MessageCircleQuestion, + MoreHorizontal, + Pause, + Play, + Radio, + RotateCcw, + Send, + Square, + X, +} from "lucide-react"; + +import type { + PersonalWorkspaceCallbacks, + WorkspaceAgentOption, + WorkspaceAttention, + WorkspaceDrawerSelection, + WorkspaceGoal, + WorkspaceGoalNotification, + WorkspaceRun, + WorkspaceTodo, +} from "./personal-workspace-model"; +import type { LarkGoalConnection } from "../../data/chat"; +import { attentionAgeLabel, formatCostUsd, formatDurationMs, formatTokenCount, hasGoalUsage, workspaceSessionStatusLabel } from "./personal-workspace-model"; +import { NotificationSettingsPanel } from "./notification-settings-panel"; + +const focusableSelector = [ + "a[href]", + "button:not([disabled])", + "textarea:not([disabled])", + "select:not([disabled])", + "input:not([disabled])", + "[tabindex]:not([tabindex='-1'])", +].join(","); + +type TodoOperation = "block" | "complete" | "defer" | "successor_create"; + +const todoTransitions = [ + { label: "标记阻塞", operation: "block" }, + { label: "暂缓", operation: "defer" }, + { label: "创建后续 Todo", operation: "successor_create" }, +] as const satisfies readonly { label: string; operation: TodoOperation }[]; + +const decisionTransitions = [ + { label: "拒绝", resolution: "reject" }, + { label: "稍后决定", resolution: "defer" }, +] as const; + +export function ContextDrawer({ agents, callbacks, goalNotifications = [], goals = [], larkConnections = [], onClose, runs = [], selection }: { + agents: WorkspaceAgentOption[]; + callbacks: PersonalWorkspaceCallbacks; + goalNotifications?: WorkspaceGoalNotification[]; + goals?: WorkspaceGoal[]; + larkConnections?: LarkGoalConnection[]; + onClose: () => void; + runs?: WorkspaceRun[]; + selection: WorkspaceDrawerSelection; +}) { + const [correction, setCorrection] = useState(""); + const [diagnosticsOpen, setDiagnosticsOpen] = useState(false); + const [repositoryCopyState, setRepositoryCopyState] = useState<"idle" | "copied" | "error">("idle"); + const [runDrawerTab, setRunDrawerTab] = useState<"record" | "details">("record"); + const [todoAgentId, setTodoAgentId] = useState(agents.find((agent) => agent.available)?.agentId ?? "codex"); + const closeRef = useRef(null); + const drawerRef = useRef(null); + const selectionIdentity = selection.kind === "run" ? `run:${selection.item.runId}` + : selection.kind === "proposal" ? `proposal:${selection.item.previewId}` + : selection.kind === "todo" ? `todo:${selection.item.todoId}` + : selection.kind === "attention" ? `attention:${selection.item.todoId}` + : selection.kind === "output" ? `output:${selection.item.outputId}` + : selection.kind === "schedule" ? `schedule:${selection.item.scheduleId}` + : selection.kind === "notifications" ? `notifications:${selection.goalId ?? "workspace"}` + : `goal:${selection.item.goalId}`; + + useEffect(() => { + setRepositoryCopyState("idle"); + setDiagnosticsOpen(false); + setRunDrawerTab("record"); + }, [selectionIdentity]); + + useEffect(() => { + const previouslyFocused = document.activeElement instanceof HTMLElement ? document.activeElement : null; + closeRef.current?.focus(); + function onKeyDown(event: KeyboardEvent) { + if (event.key === "Escape") { + event.preventDefault(); + onClose(); + return; + } + if (event.key === "Tab") { + const focusable = Array.from(drawerRef.current?.querySelectorAll(focusableSelector) ?? []) + .filter((element) => !element.hasAttribute("disabled") && element.getAttribute("aria-hidden") !== "true"); + if (focusable.length === 0) return; + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + if (event.shiftKey && document.activeElement === first) { + event.preventDefault(); + last.focus(); + } else if (!event.shiftKey && document.activeElement === last) { + event.preventDefault(); + first.focus(); + } + } + } + document.addEventListener("keydown", onKeyDown); + return () => { + document.removeEventListener("keydown", onKeyDown); + previouslyFocused?.focus(); + }; + }, [onClose]); + + const title = selection.kind === "attention" ? "需要你" + : selection.kind === "todo" ? "Todo 详情" + : selection.kind === "run" ? "执行 Session" + : selection.kind === "output" ? "产出详情" + : selection.kind === "proposal" ? (selection.item.status === "applied" ? "执行结果" : "确认执行") + : selection.kind === "schedule" ? (selection.item.scheduleKind === "heartbeat" ? "Heartbeat" : "定时检查") + : selection.kind === "notifications" ? "通知设置" + : "Goal 详情"; + const goalId = selection.kind === "proposal" ? selection.item.goalId ?? "manager" + : selection.kind === "notifications" ? "workspace" + : selection.item.goalId; + const contextLabel = selection.kind === "attention" ? selection.item.goalTitle ?? "当前 Goal" + : selection.kind === "todo" ? selection.item.goalTitle + : selection.kind === "run" ? selection.item.goalTitle + : selection.kind === "output" ? selection.item.goalTitle ?? "当前 Goal" + : selection.kind === "goal" ? selection.item.title + : selection.kind === "schedule" ? "当前 Goal 的自动运行" + : selection.kind === "notifications" ? "飞书群通知绑定" + : selection.item.goalId ? "当前 Goal 的待确认变更" : "管家待确认变更"; + const selectedGoalRun = selection.kind === "goal" + ? runs.find((run) => run.goalId === selection.item.goalId && Boolean(run.sessionId)) + ?? runs.find((run) => run.goalId === selection.item.goalId) + : null; + const hasProjectedRunActivity = selection.kind === "run" && ( + selection.item.completedSteps > 0 + || Boolean(selection.item.latestActivity) + || Boolean(selection.item.outputs?.length) + ); + + async function sendCorrection() { + if (selection.kind !== "run" || !correction.trim()) return; + await callbacks.onCorrectRun?.(selection.item, correction.trim()); + setCorrection(""); + } + + async function previewTodoTransition(todo: WorkspaceTodo, operation: TodoOperation, label: string) { + if (operation === "successor_create") { + await callbacks.onPreviewAction?.({ + actionKind: "todo.create", + context: { goal_id: todo.goalId, kind: "todo", todo_id: todo.todoId }, + idempotencyKey: `workspace-todo-successor-${todo.todoId}-${Date.now().toString(36)}`, + normalizedParameters: { goal_id: todo.goalId, text: `${todo.text} 的后续工作` }, + summary: `创建后续 Todo:${todo.text}`, + }); + return; + } + await callbacks.onPreviewAction?.({ + actionKind: "todo.update", + context: { goal_id: todo.goalId, kind: "todo", todo_id: todo.todoId }, + idempotencyKey: `workspace-todo-${todo.todoId}-${operation}-${Date.now().toString(36)}`, + normalizedParameters: { + goal_id: todo.goalId, + operation, + ...(operation === "block" ? { note: "Owner 标记为阻塞,等待补充上下文。" } : {}), + ...(operation === "defer" ? { resume_when: "owner_resume" } : {}), + todo_id: todo.todoId, + }, + summary: `${label}:${todo.text}`, + }); + } + + async function previewDecision(attention: WorkspaceAttention, decision: "approve" | typeof decisionTransitions[number]["resolution"], label: string) { + await callbacks.onPreviewAction?.({ + actionKind: "gate.resolve", + context: { goal_id: attention.goalId, kind: "todo", todo_id: attention.todoId }, + idempotencyKey: `workspace-decision-${attention.todoId}-${decision}-${Date.now().toString(36)}`, + normalizedParameters: { + goal_id: attention.goalId, + decision, + todo_id: attention.todoId, + }, + summary: `${label}:${attention.text}`, + }); + } + + return ( +
+
+

{title}

{contextLabel}

+ +
+ +
+ {selection.kind === "attention" ? ( + <> +
+ {selection.item.blocking ? "正在阻塞 Agent" : "等待你的决定"} +

{selection.item.text}

+
+
Goal
{selection.item.goalTitle ?? selection.item.goalId}
+
优先级
{selection.item.priority ?? "medium"}
+ {attentionAgeLabel(selection.item.updatedAt) ?
等待
已等待 {attentionAgeLabel(selection.item.updatedAt)}
: null} +
原因
{selection.item.explanation ?? "该决定会影响当前 Todo 的下一步执行。"}
+
证据
{selection.item.evidence ?? "当前状态没有附加公开安全证据;下一步仍会先展示 Preview。"}
+
+
+ +
+ 更多决定 +
+ + {decisionTransitions.map((transition) => ( + + ))} +
+
+ + ) : null} + + {selection.kind === "todo" ? ( + <> +
+ {selection.item.taskClass ?? "bounded_task"} +

{selection.item.text}

+
+
Goal
{selection.item.goalTitle}
+
Owner
{selection.item.ownerLabel ?? selection.item.claimedBy ?? "未分配"}
+
任务类型
{selection.item.taskClass ?? "普通 Todo"}
+
状态
{selection.item.status ?? (selection.item.done ? "completed" : "open")}
+
依赖
{selection.item.dependencies?.join(" · ") || "无"}
+
下一转换
{selection.item.nextTransition ?? (selection.item.done ? "可创建后续 Todo" : "推进或更新状态")}
+
+
+
+ 操作 + {selection.item.done ? null : ( + + )} + +
+ 更多操作 +
+ {todoTransitions.map((transition) => ( + + ))} +
+
+
+ + ) : null} + + {selection.kind === "goal" ? ( + <> +
+ {selection.item.state} +

{selection.item.title}

+

{selection.item.agentSentence}

+ {hasGoalUsage(selection.item.usage) ? ( +
+
Tokens 24h / 7d
{formatTokenCount(selection.item.usage.tokens24h)} / {formatTokenCount(selection.item.usage.tokens7d)}
+
成本 24h / 7d
{formatCostUsd(selection.item.usage.costUsd24h)} / {formatCostUsd(selection.item.usage.costUsd7d)}
+
运行时长 24h / 7d
{formatDurationMs(selection.item.usage.durationMs24h)} / {formatDurationMs(selection.item.usage.durationMs7d)}
+
+ ) : null} +
+ {(() => { + const notification = goalNotifications.find((row) => row.goalId === selection.item.goalId); + const connection = larkConnections.find((row) => row.goal_id === selection.item.goalId); + return ( + <> + {selection.item.repository ? ( +
+
RepositoryRead only
+

{selection.item.repository.label}

+
+
Branch
{selection.item.repository.branch || "detached"}
+
Role
Execution workspace
+
+ + {repositoryCopyState === "error" ?

复制失败,请检查浏览器剪贴板权限。

: repositoryCopyState === "copied" ?

已复制,可粘贴到其他工具。

: null} +
+ ) : null} +
+ Lark connection + {connection ? ( + <> +

{connection.app_label}Connected

+
+
Group
{connection.chat_name}
+
Topic
# {connection.topic_name}
+
Trigger
{connection.incoming_mode === "mentions" ? "Someone mentions the Agent" : "All messages"}
+
Reply mode
Topic reply
+
Human-gate 自动通知
{notification?.humanGateAutoNotifyEnabled ? "开" : "关"}
+ {notification?.lastNotifiedAt ? ( +
最近通知
{notification.lastNotifiedAt}
+ ) : null} +
+ + ) : ( + <> +

未配置

+

配置后,当这个 Goal 出现需要你确认的变更时,会自动发飞书通知。

+ + )} + +
+ + ); + })()} +
+ Execution Session + {selectedGoalRun?.sessionId ? ( + <> +

{workspaceSessionStatusLabel(selectedGoalRun.sessionStatus ?? selectedGoalRun.status)}

+

{selectedGoalRun.title}

+ + + ) : ( + <> +

尚未启动执行 Session

+

Agent 开始执行后,运行记录会稳定显示在这里。

+ + )} +
+
+ + +
+ + ) : null} + + {selection.kind === "run" ? ( + <> +
+ + +
+ {runDrawerTab === "record" ? ( + <> +
+ {selection.item.agentLabel} · {workspaceSessionStatusLabel(selection.item.sessionStatus ?? selection.item.status)} +

{selection.item.title}

+

{selection.item.latestActivity}

+
+
Goal
{selection.item.goalTitle}
+
进度
{selection.item.completedSteps}/{selection.item.totalSteps}
+
+
+
+

运行记录

+ {selection.item.sessionMessages?.length ? ( +
    {selection.item.sessionMessages.map((message) => ( +
  1. + +
    +
    {message.role === "user" ? "收到任务" : message.role === "assistant" ? "已完成" : "需检查"}{message.createdAt ? : null}
    +

    {message.text}

    +
    +
  2. + ))}
+ ) : ( +

{hasProjectedRunActivity + ? `当前没有可展示的逐步运行记录。LoopX 已读取到 ${selection.item.completedSteps}/${selection.item.totalSteps} 的投影进度${selection.item.outputs?.length ? `和 ${selection.item.outputs.length} 项产出` : ""};请在“详情与操作”检查 Session 状态。` + : "还没有运行记录。Agent 尚未开始这次执行;请在“详情与操作”查看等待条件或恢复 Session。"}

+ )} + {selection.item.status === "running" ?
正在分析Agent 正在继续执行,记录会自动更新。
: null} +
+ {selection.item.outputs?.length ? ( +
+

本次运行产出

+
    {selection.item.outputs.map((output) =>
  1. {output.title}{output.createdAt ?? output.kind ?? "公开安全产出"}
  2. )}
+
+ ) : null} + + ) : ( + <> +
+ {selection.item.agentLabel} · {workspaceSessionStatusLabel(selection.item.status)} +

{selection.item.title}

+

{selection.item.latestActivity}

+
+
Goal
{selection.item.goalTitle}
+
进度
{selection.item.completedSteps}/{selection.item.totalSteps}
+
会话状态
{workspaceSessionStatusLabel(selection.item.sessionStatus ?? selection.item.status)}
+
可恢复
{selection.item.resumable === false ? "否" : "是"}
+
+
+ {selection.item.sessionStatus === "resume_failed" ? ( +
+ 上游 Session 恢复失败 +

本地历史已经保留,请选择恢复路径。

+ + +
+ ) : null} +
+
与 {selection.item.agentLabel} 纠偏
+

消息会沿用当前 Goal、Todo 与 Agent Session 上下文。

+
+