@@ -5,7 +5,14 @@
|
|||||||
"Bash(npx tsc:*)",
|
"Bash(npx tsc:*)",
|
||||||
"Bash(node ./node_modules/typescript/bin/tsc:*)",
|
"Bash(node ./node_modules/typescript/bin/tsc:*)",
|
||||||
"Bash(npm run build:main:*)",
|
"Bash(npm run build:main:*)",
|
||||||
"Bash(npx vitest:*)"
|
"Bash(npx vitest:*)",
|
||||||
|
"WebFetch(domain:a2ui.org)",
|
||||||
|
"WebFetch(domain:github.com)",
|
||||||
|
"WebFetch(domain:www.npmjs.com)",
|
||||||
|
"WebFetch(domain:a2ui-sdk.js.org)",
|
||||||
|
"WebFetch(domain:www.copilotkit.ai)",
|
||||||
|
"Bash(grep -l \"A2UIRenderer\\\\|useA2UISurface\\\\|a2ui-surface\" /Users/gb/Projects/bDS/src/renderer/components/**/*.tsx)",
|
||||||
|
"Bash(npm test)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
@@ -3,6 +3,7 @@
|
|||||||
"npx vitest": true,
|
"npx vitest": true,
|
||||||
"npx tsc": true,
|
"npx tsc": true,
|
||||||
"git remote": true,
|
"git remote": true,
|
||||||
"npx asar": true
|
"npx asar": true,
|
||||||
|
"npx tsx": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
381
A2UI.md
Normal file
381
A2UI.md
Normal file
@@ -0,0 +1,381 @@
|
|||||||
|
# A2UI Implementation Plan — Full Rework
|
||||||
|
|
||||||
|
## Credits
|
||||||
|
|
||||||
|
- Plan: Claude Opus 4.6 (2026-02-26)
|
||||||
|
- A2UI specification: Google (https://a2ui.org, https://github.com/google/A2UI, Apache 2.0)
|
||||||
|
- A2UI SDK types: @a2ui-sdk community packages (https://a2ui-sdk.js.org/)
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
The current `feature-agui` branch claims to implement "A2UI" but does not implement the actual Google A2UI protocol (https://a2ui.org, https://github.com/google/A2UI). Instead, it has a custom protocol envelope system where the LLM is instructed to output a monolithic JSON blob, which is then validated with hyper-strict Zod schemas. This approach fundamentally doesn't work:
|
||||||
|
|
||||||
|
- The LLM can't reliably produce the exact JSON schema required
|
||||||
|
- Raw JSON streams to the chat UI before being replaced (visible flicker)
|
||||||
|
- Strict validation rejects almost everything, so UI elements never render
|
||||||
|
- A failed retry doubles API cost and latency
|
||||||
|
- The 11 files in `src/main/agentic/` are elaborate infrastructure built on a broken premise
|
||||||
|
|
||||||
|
**Goal:** Replace the broken custom protocol with a proper implementation of Google's A2UI v0.9 protocol — streaming, flat-component, data-binding — so the assistant can progressively render rich interactive UI in both chat surfaces.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture Overview
|
||||||
|
|
||||||
|
### Real A2UI (v0.9) Key Concepts
|
||||||
|
|
||||||
|
- **JSONL streaming**: Server sends a stream of JSON messages, each a complete A2UI message
|
||||||
|
- **4 message types**: `createSurface`, `updateComponents`, `updateDataModel`, `deleteSurface`
|
||||||
|
- **Flat component model**: Components are a flat list with ID references (adjacency list), not nested trees
|
||||||
|
- **Data binding**: Components bind to a data model via JSON Pointer paths (RFC 6901)
|
||||||
|
- **Component catalog**: Client declares which components it supports; agent can only use those
|
||||||
|
- **Actions**: User interactions dispatch events back to the server
|
||||||
|
|
||||||
|
### How This Maps to Our Electron App
|
||||||
|
|
||||||
|
```
|
||||||
|
User types message
|
||||||
|
↓
|
||||||
|
Renderer → IPC → Main Process (OpenCodeManager)
|
||||||
|
↓
|
||||||
|
Main Process calls LLM API (text + tool calls)
|
||||||
|
↓
|
||||||
|
LLM responds with text + calls UI tools (render_chart, render_form, etc.)
|
||||||
|
↓
|
||||||
|
Main Process A2UI Generator creates A2UI messages from tool results
|
||||||
|
↓
|
||||||
|
A2UI messages sent to renderer via IPC events (one event per message)
|
||||||
|
↓
|
||||||
|
Renderer A2UI Engine processes messages:
|
||||||
|
- createSurface → initialize surface state
|
||||||
|
- updateComponents → add/update components in flat buffer
|
||||||
|
- updateDataModel → update data model store
|
||||||
|
↓
|
||||||
|
Renderer A2UI Renderer resolves component tree from flat buffer
|
||||||
|
↓
|
||||||
|
React components render (reusing existing widget implementations)
|
||||||
|
↓
|
||||||
|
User clicks button → action event → IPC → Main Process → feed back to LLM
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key insight: IPC IS the transport.** We don't need JSONL parsing libraries or SSE. The main process generates A2UI message objects and sends them individually via `webContents.send()`. The renderer receives them as JavaScript objects via `ipcRenderer.on()`.
|
||||||
|
|
||||||
|
### Tool-Driven UI Generation (Not Free-Text JSON)
|
||||||
|
|
||||||
|
Instead of asking the LLM to produce A2UI JSON as free text (unreliable), we add **UI-rendering tools** that the LLM calls via the existing tool-use mechanism:
|
||||||
|
|
||||||
|
| Tool | Purpose | A2UI Output |
|
||||||
|
|------|---------|-------------|
|
||||||
|
| `render_chart` | Show bar/stacked-bar/line/pie chart | `updateComponents` with chart component |
|
||||||
|
| `render_table` | Show data table | `updateComponents` with table rows |
|
||||||
|
| `render_form` | Show input form | `updateComponents` with form fields |
|
||||||
|
| `render_card` | Show info card | `updateComponents` with card component |
|
||||||
|
| `render_metric` | Show key-value metric | `updateComponents` with metric display |
|
||||||
|
| `render_list` | Show item list | `updateComponents` with list items |
|
||||||
|
| `render_tabs` | Show tabbed content | `updateComponents` with tabs |
|
||||||
|
|
||||||
|
The LLM calls these tools with structured parameters (validated by the API provider), and our code translates tool results into proper A2UI messages. This is reliable because tool call schemas ARE validated by Claude/GPT APIs.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
### Install
|
||||||
|
|
||||||
|
| Package | Purpose | Why |
|
||||||
|
|---------|---------|-----|
|
||||||
|
| `@a2ui-sdk/types` | TypeScript types for A2UI v0.9 messages | Type-safe message handling without full renderer |
|
||||||
|
|
||||||
|
### Evaluate (may not need)
|
||||||
|
|
||||||
|
| Package | Purpose | Decision Point |
|
||||||
|
|---------|---------|---------------|
|
||||||
|
| `@a2ui-sdk/react` | React renderer + hooks | If it supports component overrides without forcing Tailwind/shadcn. Our app uses VSCode theme variables. If it forces Tailwind, skip and build custom renderer using existing `AssistantPanelControls` widgets. |
|
||||||
|
|
||||||
|
### Do NOT install
|
||||||
|
|
||||||
|
| Package | Why Not |
|
||||||
|
|---------|---------|
|
||||||
|
| `ndjson` / JSONL parsers | IPC is the transport — no JSONL wire format needed |
|
||||||
|
| CopilotKit / AG-UI | Full-stack framework, too heavyweight for integration into existing Electron app |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Files to DELETE (broken protocol machinery)
|
||||||
|
|
||||||
|
All of `src/main/agentic/` — 11 files that implement the broken custom protocol:
|
||||||
|
|
||||||
|
- `src/main/agentic/protocol/types.ts` → replaced by `@a2ui-sdk/types`
|
||||||
|
- `src/main/agentic/protocol/errors.ts` → no longer needed
|
||||||
|
- `src/main/agentic/protocol/validator.ts` → replaced by schema validation in A2UI engine
|
||||||
|
- `src/main/agentic/protocol/uiSchema.ts` → duplicate of renderer schema, deleted
|
||||||
|
- `src/main/agentic/protocol/uiSpecParser.ts` → replaced by A2UI message parser
|
||||||
|
- `src/main/agentic/protocol/responseBuilder.ts` → replaced by A2UI generator
|
||||||
|
- `src/main/agentic/capabilities/registry.ts` → replaced by A2UI client capabilities
|
||||||
|
- `src/main/agentic/workflow/turnStateMachine.ts` → not needed
|
||||||
|
- `src/main/agentic/workflow/checkpointStore.ts` → not needed
|
||||||
|
- `src/main/agentic/policy/actionPolicy.ts` → action policies move into A2UI action handler
|
||||||
|
- `src/main/agentic/observability/protocolTelemetry.ts` → not needed initially
|
||||||
|
|
||||||
|
Also delete these test files for removed code:
|
||||||
|
|
||||||
|
- `tests/engine/agentic/protocol/responseBuilder.test.ts`
|
||||||
|
- `tests/engine/OpenCodeManager.protocol.test.ts`
|
||||||
|
- Any other tests in `tests/` that test the deleted agentic/ modules
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Files to MODIFY
|
||||||
|
|
||||||
|
### `src/main/engine/OpenCodeManager.ts`
|
||||||
|
- Remove `protocolBoundaryInstructions` (line 152-159)
|
||||||
|
- Remove protocol retry mechanism (lines 408-438)
|
||||||
|
- Remove `ProtocolResponseBuilder` usage
|
||||||
|
- Remove `CapabilityRegistryService`, `AgentTurnStateMachine`, `WorkflowCheckpointStore`, `protocolTelemetry` usage
|
||||||
|
- Remove the `protocolVersion`/`envelope` fields from `SendMessageResult`
|
||||||
|
- Add UI-rendering tools to `getToolDefinitions()`: `render_chart`, `render_table`, `render_form`, `render_card`, `render_metric`, `render_list`, `render_tabs`
|
||||||
|
- Add `executeTool` handlers that convert tool args into A2UI messages and emit them via IPC
|
||||||
|
- Keep text streaming (`onDelta`) as-is for conversational responses
|
||||||
|
- Add new callback: `onA2UIMessage` for streaming A2UI messages to renderer
|
||||||
|
|
||||||
|
### `src/main/ipc/chatHandlers.ts`
|
||||||
|
- Add new IPC event: `a2ui-message` for streaming A2UI messages
|
||||||
|
- Add new IPC handler: `a2ui-action` for receiving user actions from renderer
|
||||||
|
- Wire `onA2UIMessage` callback to `webContents.send('a2ui-message', ...)`
|
||||||
|
|
||||||
|
### `src/main/preload.ts`
|
||||||
|
- Add `onA2UIMessage(callback)` listener to `window.electronAPI.chat`
|
||||||
|
- Add `dispatchA2UIAction(surfaceId, action)` method
|
||||||
|
- Add to type definitions
|
||||||
|
|
||||||
|
### `src/renderer/components/ChatPanel/ChatPanel.tsx`
|
||||||
|
- Remove protocol envelope handling (`result.envelope`, `buildActionPoliciesFromEnvelope`, `toClarificationElements`)
|
||||||
|
- Subscribe to `onA2UIMessage` events
|
||||||
|
- Feed A2UI messages to new A2UI surface state manager
|
||||||
|
- Render A2UI surface alongside chat transcript
|
||||||
|
- Handle A2UI actions (dispatch back via IPC)
|
||||||
|
|
||||||
|
### `src/renderer/components/AssistantSidebar/AssistantSidebar.tsx`
|
||||||
|
- Same changes as ChatPanel — A2UI surface rendering, remove protocol envelope
|
||||||
|
|
||||||
|
### `src/main/engine/ChatEngine.ts`
|
||||||
|
- Remove `getBuiltInSystemPrompt()` references to "AGUI payload" and protocol envelope
|
||||||
|
- Update system prompt to describe available UI tools instead
|
||||||
|
- Keep the rest (conversation CRUD, message persistence) as-is
|
||||||
|
|
||||||
|
### `src/renderer/navigation/assistantPanelSpec.ts`
|
||||||
|
- Keep the Zod schemas and `AssistantPanelElement` types — these become our component catalog definitions
|
||||||
|
- Remove `extractAssistantResponseContent()` and `extractAssistantPanelSpec()` (free-text JSON parsing) — no longer needed
|
||||||
|
- Export schemas for use by A2UI component registry
|
||||||
|
|
||||||
|
### `src/renderer/components/AssistantPanelControls/AssistantPanelControls.tsx`
|
||||||
|
- Refactor into individual component files that can be registered in an A2UI catalog
|
||||||
|
- Each component becomes a standalone renderer: `A2UIText`, `A2UIChart`, `A2UIForm`, etc.
|
||||||
|
- These map A2UI flat components (with data binding) to existing widget rendering
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Files to CREATE
|
||||||
|
|
||||||
|
### `src/main/a2ui/types.ts`
|
||||||
|
A2UI message types for our app. If `@a2ui-sdk/types` provides adequate types, re-export from there. Otherwise define:
|
||||||
|
- `A2UIServerMessage` (union of `CreateSurface | UpdateComponents | UpdateDataModel | DeleteSurface`)
|
||||||
|
- `A2UIClientAction` (action events from user interactions)
|
||||||
|
- `A2UIComponent` (flat component with ID + type + properties)
|
||||||
|
- `BDSCatalogId` — our custom catalog identifier
|
||||||
|
|
||||||
|
### `src/main/a2ui/generator.ts`
|
||||||
|
Converts tool call results into A2UI messages:
|
||||||
|
- `createChartSurface(toolArgs)` → `[createSurface, updateComponents, updateDataModel]`
|
||||||
|
- `createFormSurface(toolArgs)` → `[createSurface, updateComponents]`
|
||||||
|
- `createTableSurface(toolArgs)` → `[createSurface, updateComponents, updateDataModel]`
|
||||||
|
- etc.
|
||||||
|
Each function returns an array of A2UI messages to stream to the renderer.
|
||||||
|
|
||||||
|
### `src/main/a2ui/catalog.ts`
|
||||||
|
Defines the bDS component catalog — what component types we support:
|
||||||
|
- Maps A2UI basic catalog components to our implementations
|
||||||
|
- Adds custom components (chart, metric) not in A2UI basic catalog
|
||||||
|
- This is sent as client capabilities to inform the LLM what's available
|
||||||
|
|
||||||
|
### `src/renderer/a2ui/A2UISurfaceManager.ts`
|
||||||
|
Client-side state manager for A2UI surfaces:
|
||||||
|
- Maintains per-surface component buffer (Map<ComponentId, Component>)
|
||||||
|
- Maintains per-surface data model (JSON object)
|
||||||
|
- Processes incoming A2UI messages:
|
||||||
|
- `createSurface` → initialize new surface
|
||||||
|
- `updateComponents` → merge components into buffer
|
||||||
|
- `updateDataModel` → update data at JSON Pointer path
|
||||||
|
- `deleteSurface` → clean up
|
||||||
|
- Resolves flat component list into tree (using `children` ID references)
|
||||||
|
- Resolves data bindings (JSON Pointer → value)
|
||||||
|
- Emits render-ready component tree
|
||||||
|
|
||||||
|
### `src/renderer/a2ui/useA2UISurface.ts`
|
||||||
|
React hook that wraps `A2UISurfaceManager`:
|
||||||
|
- Subscribes to `onA2UIMessage` IPC events
|
||||||
|
- Feeds messages into surface manager
|
||||||
|
- Returns render-ready component tree + dispatch function for actions
|
||||||
|
- Handles progressive rendering (re-render as components arrive)
|
||||||
|
|
||||||
|
### `src/renderer/a2ui/A2UIRenderer.tsx`
|
||||||
|
React component that renders an A2UI surface:
|
||||||
|
- Takes component tree from `useA2UISurface`
|
||||||
|
- Maps each A2UI component type to a React component (from our catalog)
|
||||||
|
- Handles data binding for input components
|
||||||
|
- Dispatches actions on user interaction
|
||||||
|
|
||||||
|
### `src/renderer/a2ui/components/` (directory)
|
||||||
|
Individual component renderers, refactored from `AssistantPanelControls`:
|
||||||
|
- `A2UIText.tsx` — renders Text (with Markdown support)
|
||||||
|
- `A2UIButton.tsx` — renders Button with action
|
||||||
|
- `A2UICard.tsx` — renders Card with title/body/actions
|
||||||
|
- `A2UIChart.tsx` — renders Chart (custom, not in A2UI basic catalog)
|
||||||
|
- `A2UIForm.tsx` — renders form with fields
|
||||||
|
- `A2UITable.tsx` — renders data table
|
||||||
|
- `A2UITabs.tsx` — renders tabbed interface
|
||||||
|
- `A2UITextField.tsx` — renders text input with data binding
|
||||||
|
- `A2UICheckBox.tsx` — renders checkbox with data binding
|
||||||
|
- `A2UIDateTimeInput.tsx` — renders date picker
|
||||||
|
- `A2UIImage.tsx` — renders image with caption
|
||||||
|
- `A2UIMetric.tsx` — renders metric display (custom)
|
||||||
|
- `A2UIList.tsx` — renders item list
|
||||||
|
- `A2UIRow.tsx` / `A2UIColumn.tsx` — layout containers
|
||||||
|
|
||||||
|
### `tests/a2ui/` (directory)
|
||||||
|
- `generator.test.ts` — test A2UI message generation from tool calls
|
||||||
|
- `surfaceManager.test.ts` — test surface state management, component tree resolution, data binding
|
||||||
|
- `catalog.test.ts` — test component catalog registration
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Phases
|
||||||
|
|
||||||
|
### Phase 1: Foundation — A2UI Types, Surface Manager, IPC Transport
|
||||||
|
**Goal:** Get A2UI messages flowing from main process to renderer and being processed correctly.
|
||||||
|
|
||||||
|
1. Install `@a2ui-sdk/types` (or define our own types if the package doesn't cover v0.9 well)
|
||||||
|
2. Create `src/main/a2ui/types.ts` with message types
|
||||||
|
3. Create `src/renderer/a2ui/A2UISurfaceManager.ts` — process messages, maintain state, resolve tree
|
||||||
|
4. Write tests for surface manager (TDD: red → green → refactor)
|
||||||
|
5. Add `a2ui-message` IPC event to `chatHandlers.ts` and preload
|
||||||
|
6. Add `a2ui-action` IPC handler for action dispatch
|
||||||
|
7. Delete `src/main/agentic/` directory and all its tests
|
||||||
|
|
||||||
|
### Phase 2: A2UI Generator — Tool-Driven UI Creation
|
||||||
|
**Goal:** LLM can trigger rich UI by calling tools.
|
||||||
|
|
||||||
|
1. Create `src/main/a2ui/generator.ts` — converts tool args to A2UI messages
|
||||||
|
2. Create `src/main/a2ui/catalog.ts` — defines our component catalog
|
||||||
|
3. Add UI-rendering tools to `OpenCodeManager.getToolDefinitions()`:
|
||||||
|
- `render_chart({ chartType, title, series })` — chartType includes `bar`, `stacked-bar`, `line`, `pie`
|
||||||
|
- `render_table({ title, columns, rows })`
|
||||||
|
- `render_form({ title, fields, submitAction })`
|
||||||
|
- `render_card({ title, body, subtitle, actions })`
|
||||||
|
- `render_metric({ label, value })`
|
||||||
|
- `render_list({ title, items })`
|
||||||
|
- `render_tabs({ tabs: [{ label, content }] })`
|
||||||
|
4. Add `executeTool` handlers that call generator and emit A2UI messages via `onA2UIMessage` callback
|
||||||
|
5. Write tests for generator (TDD)
|
||||||
|
6. Remove `protocolBoundaryInstructions`, protocol retry, envelope building from `OpenCodeManager`
|
||||||
|
7. Update `SendMessageResult` — remove `envelope`/`protocolVersion`/`traceId`
|
||||||
|
|
||||||
|
### Phase 3: A2UI Renderer — React Component Catalog
|
||||||
|
**Goal:** A2UI surfaces render as interactive UI in the chat.
|
||||||
|
|
||||||
|
1. Refactor `AssistantPanelControls` into individual component files under `src/renderer/a2ui/components/`
|
||||||
|
2. Create `src/renderer/a2ui/A2UIRenderer.tsx` — maps component types to React components
|
||||||
|
3. Create `src/renderer/a2ui/useA2UISurface.ts` — React hook for surface state
|
||||||
|
4. Integrate into `ChatPanel.tsx`:
|
||||||
|
- Subscribe to `onA2UIMessage`
|
||||||
|
- Render `A2UIRenderer` for each active surface
|
||||||
|
- Handle actions
|
||||||
|
5. Integrate into `AssistantSidebar.tsx` (same pattern)
|
||||||
|
6. Remove old protocol envelope handling from both components
|
||||||
|
7. Write component tests
|
||||||
|
|
||||||
|
### Phase 4: System Prompt and LLM Integration
|
||||||
|
**Goal:** LLM knows about and uses UI tools effectively.
|
||||||
|
|
||||||
|
1. Update `ChatEngine.getBuiltInSystemPrompt()`:
|
||||||
|
- Remove all "AGUI payload" / "protocol envelope" instructions
|
||||||
|
- Add descriptions of UI tools and when to use them
|
||||||
|
- Include examples: "When showing statistics, use render_chart. When showing a list of posts, use render_table."
|
||||||
|
2. Update system prompt to describe the component catalog available
|
||||||
|
3. Test end-to-end: user asks for a chart → LLM calls `render_chart` → A2UI messages → UI renders
|
||||||
|
|
||||||
|
### Phase 5: Actions, Data Binding, and Polish
|
||||||
|
**Goal:** Full interactivity — forms submit, buttons trigger actions, data flows both ways.
|
||||||
|
|
||||||
|
1. Implement action dispatch: renderer → IPC → main process → feed back to LLM as tool result
|
||||||
|
2. Implement two-way data binding for form inputs:
|
||||||
|
- User edits input → local data model updates
|
||||||
|
- Submit action includes current data model values
|
||||||
|
3. Add action confirmation policies (keep the existing silent/confirm/danger concept)
|
||||||
|
4. Handle surface lifecycle (delete surfaces when conversation changes)
|
||||||
|
5. Clean up: remove unused imports, dead code, duplicate schemas
|
||||||
|
6. Update `API.md` and Python API bindings if affected
|
||||||
|
|
||||||
|
### Phase 6: Cleanup and Tests
|
||||||
|
**Goal:** Zero failing tests, clean build, no dead code.
|
||||||
|
|
||||||
|
1. Delete all files listed in "Files to DELETE"
|
||||||
|
2. Remove all imports of deleted modules across the codebase
|
||||||
|
3. Run full test suite, fix all failures
|
||||||
|
4. Run `npm run build`, fix all build errors
|
||||||
|
5. Update `protocolActionPolicies.ts` and `protocolNeedsInput.ts` — either adapt for A2UI actions or delete if superseded
|
||||||
|
6. Delete `src/renderer/python/pythonApiContractV1.ts` changes if they reference the old protocol
|
||||||
|
7. Final review: no unused code, no commented-out code
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Component Catalog Mapping: Existing Widgets → A2UI Components
|
||||||
|
|
||||||
|
| Existing Widget | A2UI Component | Notes |
|
||||||
|
|----------------|----------------|-------|
|
||||||
|
| `text` | `Text` | Direct mapping, add Markdown support |
|
||||||
|
| `metric` | Custom `Metric` | Not in A2UI basic catalog — register as custom component |
|
||||||
|
| `list` | `List` | A2UI has List container |
|
||||||
|
| `table` | Custom `Table` | Not in A2UI basic catalog — register as custom |
|
||||||
|
| `action` | `Button` | A2UI uses Button with action events |
|
||||||
|
| `chart` | Custom `Chart` | Not in A2UI basic catalog — register as custom |
|
||||||
|
| `input` | `TextField` / `CheckBox` / `DateTimeInput` / `ChoicePicker` | A2UI splits by type |
|
||||||
|
| `form` | `Column` + form fields + `Button` | A2UI doesn't have a Form primitive — compose from layout + inputs |
|
||||||
|
| `card` | `Card` | Direct mapping |
|
||||||
|
| `image` | `Image` | Direct mapping |
|
||||||
|
| `tabs` | `Tabs` | Direct mapping |
|
||||||
|
| `datePicker` | `DateTimeInput` | A2UI equivalent |
|
||||||
|
| (new) `Row` | `Row` | Layout container |
|
||||||
|
| (new) `Column` | `Column` | Layout container |
|
||||||
|
| (new) `Divider` | `Divider` | Visual separator |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
After each phase, verify:
|
||||||
|
|
||||||
|
1. **Tests pass**: `npm test` — zero failures
|
||||||
|
2. **Build succeeds**: `npm run build` — no errors
|
||||||
|
3. **Manual test**: Send chat messages, verify:
|
||||||
|
- Text responses render normally in chat
|
||||||
|
- "Show me post statistics as a chart" → LLM calls `render_chart` → chart renders
|
||||||
|
- "List my recent posts in a table" → LLM calls `render_table` → table renders
|
||||||
|
- Forms render with inputs, submit works
|
||||||
|
- Cards with action buttons work
|
||||||
|
- Tab navigation works
|
||||||
|
- Both ChatPanel and AssistantSidebar work
|
||||||
|
4. **No regression**: Existing tool calls (search_posts, read_post, etc.) still work
|
||||||
|
5. **No raw JSON visible**: Users never see protocol JSON in the chat
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Risk Mitigation
|
||||||
|
|
||||||
|
| Risk | Mitigation |
|
||||||
|
|------|-----------|
|
||||||
|
| `@a2ui-sdk/types` doesn't cover v0.9 well | Define our own types — A2UI messages are simple JSON |
|
||||||
|
| LLM doesn't call UI tools reliably | Good tool descriptions + examples in system prompt; text fallback always works |
|
||||||
|
| Performance: many small IPC messages | Batch `updateComponents` messages; A2UI supports sending multiple components per message |
|
||||||
|
| Breaking existing functionality | Phase 1 deletes old code; each subsequent phase adds new functionality. Keep existing tool calls (search_posts etc.) unchanged. |
|
||||||
|
| A2UI spec changes (v0.9 is draft) | Our implementation is a subset; the flat component + data binding model is stable |
|
||||||
5
API.md
5
API.md
@@ -1,6 +1,6 @@
|
|||||||
# API Documentation
|
# API Documentation
|
||||||
|
|
||||||
Contract version: 1.3.0
|
Contract version: 1.6.0
|
||||||
|
|
||||||
This reference documents all Python runtime API calls available through `bds_api` in embedded Pyodide.
|
This reference documents all Python runtime API calls available through `bds_api` in embedded Pyodide.
|
||||||
|
|
||||||
@@ -3482,6 +3482,7 @@ Send message to chat conversation.
|
|||||||
|
|
||||||
- conversationId (str, required)
|
- conversationId (str, required)
|
||||||
- message (str, required)
|
- message (str, required)
|
||||||
|
- metadata (dict, optional)
|
||||||
|
|
||||||
**Response specification**
|
**Response specification**
|
||||||
|
|
||||||
@@ -4051,4 +4052,4 @@ Stored API key state for chat provider.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
Generated from contract at 2026-02-24T00:00:00.000Z.
|
Generated from contract at 2026-02-25T00:00:00.000Z.
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
- [Working with media](#working-with-media)
|
- [Working with media](#working-with-media)
|
||||||
- [Using macros](#using-macros)
|
- [Using macros](#using-macros)
|
||||||
- [Using scripting (early access)](#using-scripting-early-access)
|
- [Using scripting (early access)](#using-scripting-early-access)
|
||||||
|
- [Using the AI assistant](#using-the-ai-assistant)
|
||||||
- [Organizing with tags](#organizing-with-tags)
|
- [Organizing with tags](#organizing-with-tags)
|
||||||
- [Importing from WordPress (WXR)](#importing-from-wordpress-wxr)
|
- [Importing from WordPress (WXR)](#importing-from-wordpress-wxr)
|
||||||
- [Using Git (Source Control)](#using-git-source-control)
|
- [Using Git (Source Control)](#using-git-source-control)
|
||||||
@@ -255,6 +256,65 @@ Notes:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Using the AI assistant
|
||||||
|
|
||||||
|
The AI assistant is built into bDS to help you manage your blog through natural conversation. You can ask it to search posts, analyze your content, update metadata, and visualize data. Instead of returning only plain text, the assistant can present results as rich interactive elements such as charts, tables, forms, and more.
|
||||||
|
|
||||||
|
The assistant works entirely with your local blog content. It does not have access to the internet or external services. When you ask a question, it uses your posts, media, tags, and categories to find answers and present them in the most useful format. In most cases the assistant automatically picks the right visualization for your request, but you can also ask for a specific format explicitly.
|
||||||
|
|
||||||
|
### Charts
|
||||||
|
|
||||||
|
The assistant can display bar, stacked-bar, line, area, pie, donut, and heatmap charts to help you spot patterns and trends in your blog data. Charts include a title, labeled data points, and a visual representation that makes it easy to compare values at a glance. Use stacked-bar charts when each bar has multiple segments, area charts for cumulative trends, donut charts for proportional breakdowns with a total in the center, and heatmap charts for matrix data where color intensity encodes value.
|
||||||
|
|
||||||
|
**Try asking:** "Show me a chart of posts published per month this year"
|
||||||
|
|
||||||
|
### Tables
|
||||||
|
|
||||||
|
When you need to compare posts side by side or see structured information, the assistant can render a table with columns and rows. Tables are useful for listings, comparisons, and any data that benefits from a grid layout.
|
||||||
|
|
||||||
|
**Try asking:** "Compare my last 10 posts showing title, status, and word count"
|
||||||
|
|
||||||
|
### Cards
|
||||||
|
|
||||||
|
Cards present a focused summary with a title, body text, and optional action buttons. The assistant uses cards when highlighting a specific item, making a recommendation, or presenting a result that you might want to act on.
|
||||||
|
|
||||||
|
**Try asking:** "Give me a summary card for my most recent draft post"
|
||||||
|
|
||||||
|
### Metrics
|
||||||
|
|
||||||
|
A metric is a single prominent number or value with a label. The assistant uses metrics when the answer to your question is one key figure, such as a count, a status, or a statistic.
|
||||||
|
|
||||||
|
**Try asking:** "How many draft posts do I have?"
|
||||||
|
|
||||||
|
### Lists
|
||||||
|
|
||||||
|
Lists display items as a simple bulleted enumeration. They work well for tag listings, next steps, checklists, and any result that is naturally a sequence of items.
|
||||||
|
|
||||||
|
**Try asking:** "List all tags that are used by fewer than 3 posts"
|
||||||
|
|
||||||
|
### Forms
|
||||||
|
|
||||||
|
When the assistant needs structured input from you, it can display an interactive form with text fields, checkboxes, dropdowns, and date pickers. Forms are typically used for metadata updates, multi-field edits, and configuration tasks where typing everything into a single message would be awkward.
|
||||||
|
|
||||||
|
**Try asking:** "Help me update the metadata for my post about React"
|
||||||
|
|
||||||
|
### Tabs
|
||||||
|
|
||||||
|
Tabs let the assistant organize multiple views into a single switchable interface. Each tab can contain any combination of text, charts, tables, metrics, and lists. Tabs are especially useful for multi-dimensional comparisons where you want to explore different slices of data without scrolling through a long response.
|
||||||
|
|
||||||
|
**Try asking:** "Show post statistics by year, with each year as a tab containing a chart of monthly post counts"
|
||||||
|
|
||||||
|
### Key takeaways
|
||||||
|
|
||||||
|
- The assistant picks the right visualization automatically based on your question.
|
||||||
|
- You can ask for a specific format by mentioning it in your prompt ("show as a chart", "put it in a table").
|
||||||
|
- Tabs can contain charts, tables, and other elements for rich multi-view displays.
|
||||||
|
- The assistant can only access your local blog content.
|
||||||
|
|
||||||
|
[↑ Back to In this article](#in-this-article)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Organizing with tags
|
## Organizing with tags
|
||||||
|
|
||||||
Tags are your precision taxonomy tool. Over time, even well-managed projects accumulate near-duplicate tags, naming inconsistencies, and labels that no longer serve users. The Tags section exists to keep taxonomy useful and prevent search and filtering quality from degrading.
|
Tags are your precision taxonomy tool. Over time, even well-managed projects accumulate near-duplicate tags, naming inconsistencies, and labels that no longer serve users. The Tags section exists to keep taxonomy useful and prevent search and filtering quality from degrading.
|
||||||
|
|||||||
64
src/main/a2ui/catalog.ts
Normal file
64
src/main/a2ui/catalog.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
/**
|
||||||
|
* A2UI Component Catalog for bDS
|
||||||
|
*
|
||||||
|
* Defines which A2UI component types the bDS client supports.
|
||||||
|
* This catalog is used to:
|
||||||
|
* 1. Inform the LLM (via system prompt) what UI components are available
|
||||||
|
* 2. Validate incoming A2UI messages
|
||||||
|
* 3. Map component types to React renderers
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { A2UICatalogEntry, A2UIComponentType } from './types';
|
||||||
|
import { BDS_CATALOG_ID } from './types';
|
||||||
|
|
||||||
|
const CATALOG_ENTRIES: A2UICatalogEntry[] = [
|
||||||
|
{ type: 'text', description: 'Text block with Markdown support' },
|
||||||
|
{ type: 'button', description: 'Clickable button that dispatches an action' },
|
||||||
|
{ type: 'card', description: 'Card with title, subtitle, body, and action buttons' },
|
||||||
|
{ type: 'chart', description: 'Bar, stacked-bar, line, area, pie, donut, or heatmap chart visualization', custom: true },
|
||||||
|
{ type: 'table', description: 'Data table with columns and rows', custom: true },
|
||||||
|
{ type: 'textField', description: 'Text input field with data binding' },
|
||||||
|
{ type: 'checkBox', description: 'Checkbox input with data binding' },
|
||||||
|
{ type: 'dateTimeInput', description: 'Date/time picker input' },
|
||||||
|
{ type: 'choicePicker', description: 'Select/dropdown with options' },
|
||||||
|
{ type: 'image', description: 'Image with optional caption and click action' },
|
||||||
|
{ type: 'tabs', description: 'Tabbed container for organizing content' },
|
||||||
|
{ type: 'metric', description: 'Key-value metric display', custom: true },
|
||||||
|
{ type: 'list', description: 'Ordered or unordered item list' },
|
||||||
|
{ type: 'form', description: 'Form container with fields and submit button', custom: true },
|
||||||
|
{ type: 'row', description: 'Horizontal layout container' },
|
||||||
|
{ type: 'column', description: 'Vertical layout container' },
|
||||||
|
{ type: 'divider', description: 'Visual separator' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const catalogMap = new Map<A2UIComponentType, A2UICatalogEntry>();
|
||||||
|
for (const entry of CATALOG_ENTRIES) {
|
||||||
|
catalogMap.set(entry.type, entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCatalogEntries(): A2UICatalogEntry[] {
|
||||||
|
return [...CATALOG_ENTRIES];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isSupportedComponentType(type: string): type is A2UIComponentType {
|
||||||
|
return catalogMap.has(type as A2UIComponentType);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCatalogEntry(type: A2UIComponentType): A2UICatalogEntry | undefined {
|
||||||
|
return catalogMap.get(type);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCatalogId(): string {
|
||||||
|
return BDS_CATALOG_ID;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a description of supported components for inclusion in the LLM system prompt.
|
||||||
|
*/
|
||||||
|
export function buildCatalogDescription(): string {
|
||||||
|
const lines = CATALOG_ENTRIES.map((entry) => {
|
||||||
|
const suffix = entry.custom ? ' (custom)' : '';
|
||||||
|
return ` - ${entry.type}: ${entry.description}${suffix}`;
|
||||||
|
});
|
||||||
|
return `Supported UI component types:\n${lines.join('\n')}`;
|
||||||
|
}
|
||||||
393
src/main/a2ui/generator.ts
Normal file
393
src/main/a2ui/generator.ts
Normal file
@@ -0,0 +1,393 @@
|
|||||||
|
/**
|
||||||
|
* A2UI Generator
|
||||||
|
*
|
||||||
|
* Converts tool call results from the LLM into A2UI server messages.
|
||||||
|
* Each render_* tool call produces a set of A2UI messages:
|
||||||
|
* - createSurface (if new surface needed)
|
||||||
|
* - updateComponents (add/update components)
|
||||||
|
* - updateDataModel (set data values)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
import type {
|
||||||
|
A2UIServerMessage,
|
||||||
|
A2UIComponent,
|
||||||
|
} from './types';
|
||||||
|
|
||||||
|
function makeId(prefix: string): string {
|
||||||
|
return `${prefix}-${uuidv4().slice(0, 8)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createSurfaceMessages(
|
||||||
|
conversationId: string,
|
||||||
|
components: A2UIComponent[],
|
||||||
|
rootIds: string[],
|
||||||
|
dataEntries?: Array<{ path: string; value: unknown }>,
|
||||||
|
): A2UIServerMessage[] {
|
||||||
|
const surfaceId = makeId('surface');
|
||||||
|
const messages: A2UIServerMessage[] = [
|
||||||
|
{
|
||||||
|
type: 'createSurface',
|
||||||
|
surfaceId,
|
||||||
|
conversationId,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'updateComponents',
|
||||||
|
surfaceId,
|
||||||
|
components,
|
||||||
|
rootIds,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
if (dataEntries) {
|
||||||
|
for (const entry of dataEntries) {
|
||||||
|
messages.push({
|
||||||
|
type: 'updateDataModel',
|
||||||
|
surfaceId,
|
||||||
|
path: entry.path,
|
||||||
|
value: entry.value,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return messages;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Tool argument interfaces ----
|
||||||
|
|
||||||
|
export interface RenderChartArgs {
|
||||||
|
chartType: 'bar' | 'stacked-bar' | 'line' | 'area' | 'pie' | 'donut' | 'heatmap';
|
||||||
|
title?: string;
|
||||||
|
series: Array<{ label: string; value: number; segments?: Array<{ label: string; value: number }> }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RenderTableArgs {
|
||||||
|
title?: string;
|
||||||
|
columns: string[];
|
||||||
|
rows: string[][];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RenderFormField {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
inputType: 'text' | 'textarea' | 'select' | 'checkbox' | 'date' | 'number';
|
||||||
|
placeholder?: string;
|
||||||
|
defaultValue?: string | number | boolean;
|
||||||
|
options?: Array<{ label: string; value: string }>;
|
||||||
|
required?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RenderFormArgs {
|
||||||
|
title?: string;
|
||||||
|
fields: RenderFormField[];
|
||||||
|
submitLabel: string;
|
||||||
|
submitAction?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RenderCardArgs {
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
subtitle?: string;
|
||||||
|
actions?: Array<{ label: string; action: string; payload?: Record<string, unknown> }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RenderMetricArgs {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RenderListArgs {
|
||||||
|
title?: string;
|
||||||
|
items: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RenderTabArgs {
|
||||||
|
label: string;
|
||||||
|
content: Array<{
|
||||||
|
type: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RenderTabsArgs {
|
||||||
|
tabs: RenderTabArgs[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Generators ----
|
||||||
|
|
||||||
|
export function generateChart(
|
||||||
|
conversationId: string,
|
||||||
|
args: RenderChartArgs,
|
||||||
|
): A2UIServerMessage[] {
|
||||||
|
const chartId = makeId('chart');
|
||||||
|
const component: A2UIComponent = {
|
||||||
|
id: chartId,
|
||||||
|
type: 'chart',
|
||||||
|
properties: {
|
||||||
|
chartType: args.chartType,
|
||||||
|
title: args.title,
|
||||||
|
},
|
||||||
|
dataBinding: '/chartData',
|
||||||
|
};
|
||||||
|
|
||||||
|
return createSurfaceMessages(
|
||||||
|
conversationId,
|
||||||
|
[component],
|
||||||
|
[chartId],
|
||||||
|
[{ path: '/chartData', value: args.series }],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateTable(
|
||||||
|
conversationId: string,
|
||||||
|
args: RenderTableArgs,
|
||||||
|
): A2UIServerMessage[] {
|
||||||
|
const tableId = makeId('table');
|
||||||
|
const components: A2UIComponent[] = [
|
||||||
|
{
|
||||||
|
id: tableId,
|
||||||
|
type: 'table',
|
||||||
|
properties: {
|
||||||
|
title: args.title,
|
||||||
|
columns: args.columns,
|
||||||
|
},
|
||||||
|
dataBinding: '/tableRows',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return createSurfaceMessages(
|
||||||
|
conversationId,
|
||||||
|
components,
|
||||||
|
[tableId],
|
||||||
|
[{ path: '/tableRows', value: args.rows }],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateForm(
|
||||||
|
conversationId: string,
|
||||||
|
args: RenderFormArgs,
|
||||||
|
): A2UIServerMessage[] {
|
||||||
|
const formId = makeId('form');
|
||||||
|
const fieldComponents: A2UIComponent[] = [];
|
||||||
|
const fieldIds: string[] = [];
|
||||||
|
|
||||||
|
for (const field of args.fields) {
|
||||||
|
const fieldId = makeId('field');
|
||||||
|
fieldIds.push(fieldId);
|
||||||
|
|
||||||
|
let componentType: A2UIComponent['type'] = 'textField';
|
||||||
|
if (field.inputType === 'checkbox') {
|
||||||
|
componentType = 'checkBox';
|
||||||
|
} else if (field.inputType === 'date') {
|
||||||
|
componentType = 'dateTimeInput';
|
||||||
|
} else if (field.inputType === 'select') {
|
||||||
|
componentType = 'choicePicker';
|
||||||
|
}
|
||||||
|
|
||||||
|
fieldComponents.push({
|
||||||
|
id: fieldId,
|
||||||
|
type: componentType,
|
||||||
|
properties: {
|
||||||
|
key: field.key,
|
||||||
|
label: field.label,
|
||||||
|
inputType: field.inputType,
|
||||||
|
placeholder: field.placeholder,
|
||||||
|
defaultValue: field.defaultValue,
|
||||||
|
options: field.options,
|
||||||
|
required: field.required,
|
||||||
|
},
|
||||||
|
dataBinding: `/formData/${field.key}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const submitId = makeId('submit');
|
||||||
|
fieldComponents.push({
|
||||||
|
id: submitId,
|
||||||
|
type: 'button',
|
||||||
|
properties: {
|
||||||
|
label: args.submitLabel,
|
||||||
|
},
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
eventType: 'click',
|
||||||
|
action: args.submitAction || 'submitForm',
|
||||||
|
payload: { formId },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const formComponent: A2UIComponent = {
|
||||||
|
id: formId,
|
||||||
|
type: 'form',
|
||||||
|
properties: {
|
||||||
|
title: args.title,
|
||||||
|
submitLabel: args.submitLabel,
|
||||||
|
},
|
||||||
|
children: [...fieldIds, submitId],
|
||||||
|
};
|
||||||
|
|
||||||
|
// Set initial data model values for fields with defaults
|
||||||
|
const dataEntries: Array<{ path: string; value: unknown }> = [];
|
||||||
|
for (const field of args.fields) {
|
||||||
|
if (field.defaultValue !== undefined) {
|
||||||
|
dataEntries.push({ path: `/formData/${field.key}`, value: field.defaultValue });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return createSurfaceMessages(
|
||||||
|
conversationId,
|
||||||
|
[formComponent, ...fieldComponents],
|
||||||
|
[formId],
|
||||||
|
dataEntries.length > 0 ? dataEntries : undefined,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateCard(
|
||||||
|
conversationId: string,
|
||||||
|
args: RenderCardArgs,
|
||||||
|
): A2UIServerMessage[] {
|
||||||
|
const cardId = makeId('card');
|
||||||
|
const cardActions = args.actions?.map((a) => ({
|
||||||
|
eventType: 'click',
|
||||||
|
action: a.action,
|
||||||
|
payload: a.payload,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const component: A2UIComponent = {
|
||||||
|
id: cardId,
|
||||||
|
type: 'card',
|
||||||
|
properties: {
|
||||||
|
title: args.title,
|
||||||
|
body: args.body,
|
||||||
|
subtitle: args.subtitle,
|
||||||
|
},
|
||||||
|
actions: cardActions,
|
||||||
|
};
|
||||||
|
|
||||||
|
return createSurfaceMessages(conversationId, [component], [cardId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateMetric(
|
||||||
|
conversationId: string,
|
||||||
|
args: RenderMetricArgs,
|
||||||
|
): A2UIServerMessage[] {
|
||||||
|
const metricId = makeId('metric');
|
||||||
|
const component: A2UIComponent = {
|
||||||
|
id: metricId,
|
||||||
|
type: 'metric',
|
||||||
|
properties: {
|
||||||
|
label: args.label,
|
||||||
|
value: args.value,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return createSurfaceMessages(conversationId, [component], [metricId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateList(
|
||||||
|
conversationId: string,
|
||||||
|
args: RenderListArgs,
|
||||||
|
): A2UIServerMessage[] {
|
||||||
|
const listId = makeId('list');
|
||||||
|
const component: A2UIComponent = {
|
||||||
|
id: listId,
|
||||||
|
type: 'list',
|
||||||
|
properties: {
|
||||||
|
title: args.title,
|
||||||
|
},
|
||||||
|
dataBinding: '/listItems',
|
||||||
|
};
|
||||||
|
|
||||||
|
return createSurfaceMessages(
|
||||||
|
conversationId,
|
||||||
|
[component],
|
||||||
|
[listId],
|
||||||
|
[{ path: '/listItems', value: args.items }],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateTabs(
|
||||||
|
conversationId: string,
|
||||||
|
args: RenderTabsArgs,
|
||||||
|
): A2UIServerMessage[] {
|
||||||
|
const tabsId = makeId('tabs');
|
||||||
|
const tabComponents: A2UIComponent[] = [];
|
||||||
|
const tabIds: string[] = [];
|
||||||
|
|
||||||
|
for (const tab of args.tabs) {
|
||||||
|
const tabId = makeId('tab');
|
||||||
|
tabIds.push(tabId);
|
||||||
|
|
||||||
|
const childComponents: A2UIComponent[] = [];
|
||||||
|
const childIds: string[] = [];
|
||||||
|
|
||||||
|
for (const contentItem of tab.content) {
|
||||||
|
const childId = makeId('child');
|
||||||
|
childIds.push(childId);
|
||||||
|
childComponents.push({
|
||||||
|
id: childId,
|
||||||
|
type: contentItem.type as A2UIComponent['type'],
|
||||||
|
properties: { ...contentItem, type: undefined },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
tabComponents.push({
|
||||||
|
id: tabId,
|
||||||
|
type: 'column',
|
||||||
|
properties: { label: tab.label },
|
||||||
|
children: childIds,
|
||||||
|
});
|
||||||
|
|
||||||
|
tabComponents.push(...childComponents);
|
||||||
|
}
|
||||||
|
|
||||||
|
const tabsComponent: A2UIComponent = {
|
||||||
|
id: tabsId,
|
||||||
|
type: 'tabs',
|
||||||
|
properties: {
|
||||||
|
tabLabels: args.tabs.map((t) => t.label),
|
||||||
|
},
|
||||||
|
children: tabIds,
|
||||||
|
};
|
||||||
|
|
||||||
|
return createSurfaceMessages(
|
||||||
|
conversationId,
|
||||||
|
[tabsComponent, ...tabComponents],
|
||||||
|
[tabsId],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Tool name to generator dispatch ----
|
||||||
|
|
||||||
|
const GENERATORS: Record<string, (conversationId: string, args: Record<string, unknown>) => A2UIServerMessage[]> = {
|
||||||
|
render_chart: (cid, args) => generateChart(cid, args as unknown as RenderChartArgs),
|
||||||
|
render_table: (cid, args) => generateTable(cid, args as unknown as RenderTableArgs),
|
||||||
|
render_form: (cid, args) => generateForm(cid, args as unknown as RenderFormArgs),
|
||||||
|
render_card: (cid, args) => generateCard(cid, args as unknown as RenderCardArgs),
|
||||||
|
render_metric: (cid, args) => generateMetric(cid, args as unknown as RenderMetricArgs),
|
||||||
|
render_list: (cid, args) => generateList(cid, args as unknown as RenderListArgs),
|
||||||
|
render_tabs: (cid, args) => generateTabs(cid, args as unknown as RenderTabsArgs),
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a tool name is a UI-rendering tool.
|
||||||
|
*/
|
||||||
|
export function isRenderTool(toolName: string): boolean {
|
||||||
|
return toolName in GENERATORS;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate A2UI messages for a render tool call.
|
||||||
|
* Returns null if the tool name is not a render tool.
|
||||||
|
*/
|
||||||
|
export function generateFromToolCall(
|
||||||
|
conversationId: string,
|
||||||
|
toolName: string,
|
||||||
|
toolArgs: Record<string, unknown>,
|
||||||
|
): A2UIServerMessage[] | null {
|
||||||
|
const generator = GENERATORS[toolName];
|
||||||
|
if (!generator) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return generator(conversationId, toolArgs);
|
||||||
|
}
|
||||||
134
src/main/a2ui/types.ts
Normal file
134
src/main/a2ui/types.ts
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
/**
|
||||||
|
* A2UI v0.9 types for bDS
|
||||||
|
*
|
||||||
|
* Implements the core A2UI protocol concepts:
|
||||||
|
* - JSONL streaming via IPC (not HTTP/SSE)
|
||||||
|
* - 4 server message types: createSurface, updateComponents, updateDataModel, deleteSurface
|
||||||
|
* - Flat component model with ID references
|
||||||
|
* - Data binding via JSON Pointer paths (RFC 6901)
|
||||||
|
* - Actions dispatched from client back to server
|
||||||
|
*
|
||||||
|
* @see https://a2ui.org
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ---- Component Types ----
|
||||||
|
|
||||||
|
export type A2UIComponentType =
|
||||||
|
| 'text'
|
||||||
|
| 'button'
|
||||||
|
| 'card'
|
||||||
|
| 'chart'
|
||||||
|
| 'table'
|
||||||
|
| 'form'
|
||||||
|
| 'textField'
|
||||||
|
| 'checkBox'
|
||||||
|
| 'dateTimeInput'
|
||||||
|
| 'choicePicker'
|
||||||
|
| 'image'
|
||||||
|
| 'tabs'
|
||||||
|
| 'metric'
|
||||||
|
| 'list'
|
||||||
|
| 'row'
|
||||||
|
| 'column'
|
||||||
|
| 'divider';
|
||||||
|
|
||||||
|
export interface A2UIComponent {
|
||||||
|
id: string;
|
||||||
|
type: A2UIComponentType;
|
||||||
|
properties: Record<string, unknown>;
|
||||||
|
/** JSON Pointer path for data binding (RFC 6901) */
|
||||||
|
dataBinding?: string;
|
||||||
|
/** Ordered child component IDs */
|
||||||
|
children?: string[];
|
||||||
|
/** Actions this component can dispatch */
|
||||||
|
actions?: A2UIComponentAction[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface A2UIComponentAction {
|
||||||
|
eventType: string;
|
||||||
|
action: string;
|
||||||
|
payload?: Record<string, unknown>;
|
||||||
|
/** Policy for this action: silent = no confirm, confirm = ask user, danger = warn */
|
||||||
|
policy?: 'silent' | 'confirm' | 'danger';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Server Messages (main → renderer) ----
|
||||||
|
|
||||||
|
export interface A2UICreateSurface {
|
||||||
|
type: 'createSurface';
|
||||||
|
surfaceId: string;
|
||||||
|
conversationId: string;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface A2UIUpdateComponents {
|
||||||
|
type: 'updateComponents';
|
||||||
|
surfaceId: string;
|
||||||
|
components: A2UIComponent[];
|
||||||
|
/** Root component IDs for top-level rendering order */
|
||||||
|
rootIds?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface A2UIUpdateDataModel {
|
||||||
|
type: 'updateDataModel';
|
||||||
|
surfaceId: string;
|
||||||
|
/** JSON Pointer path (RFC 6901) */
|
||||||
|
path: string;
|
||||||
|
value: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface A2UIDeleteSurface {
|
||||||
|
type: 'deleteSurface';
|
||||||
|
surfaceId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type A2UIServerMessage =
|
||||||
|
| A2UICreateSurface
|
||||||
|
| A2UIUpdateComponents
|
||||||
|
| A2UIUpdateDataModel
|
||||||
|
| A2UIDeleteSurface;
|
||||||
|
|
||||||
|
// ---- Client Actions (renderer → main) ----
|
||||||
|
|
||||||
|
export interface A2UIClientAction {
|
||||||
|
surfaceId: string;
|
||||||
|
componentId: string;
|
||||||
|
action: string;
|
||||||
|
payload?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Surface State (renderer-side) ----
|
||||||
|
|
||||||
|
export interface A2UISurfaceState {
|
||||||
|
surfaceId: string;
|
||||||
|
conversationId: string;
|
||||||
|
components: Map<string, A2UIComponent>;
|
||||||
|
rootIds: string[];
|
||||||
|
dataModel: Record<string, unknown>;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Resolved Component Tree (for rendering) ----
|
||||||
|
|
||||||
|
export interface A2UIResolvedComponent {
|
||||||
|
id: string;
|
||||||
|
type: A2UIComponentType;
|
||||||
|
properties: Record<string, unknown>;
|
||||||
|
/** JSON Pointer path for data binding (carried from raw component) */
|
||||||
|
dataBinding?: string;
|
||||||
|
/** Resolved value from data binding */
|
||||||
|
boundValue?: unknown;
|
||||||
|
actions?: A2UIComponentAction[];
|
||||||
|
children: A2UIResolvedComponent[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Catalog ----
|
||||||
|
|
||||||
|
export interface A2UICatalogEntry {
|
||||||
|
type: A2UIComponentType;
|
||||||
|
description: string;
|
||||||
|
/** Whether this is a standard A2UI component or a custom bDS extension */
|
||||||
|
custom?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const BDS_CATALOG_ID = 'bds-blogging-v1';
|
||||||
@@ -305,12 +305,13 @@ Your role is to help users manage their blog posts and media files using ONLY th
|
|||||||
IMPORTANT: You do NOT have access to the internet, real-time data, or any external services.
|
IMPORTANT: You do NOT have access to the internet, real-time data, or any external services.
|
||||||
You can ONLY access information through the tools listed below. Do not claim otherwise.
|
You can ONLY access information through the tools listed below. Do not claim otherwise.
|
||||||
|
|
||||||
Available Tools:
|
Available Data Tools:
|
||||||
- search_posts: Search blog posts using full-text search. Supports category/tag filters.
|
- get_blog_stats: Get comprehensive blog statistics (total posts, date range, posts per year, tag/category counts, media count). ALWAYS call this first when you need to understand the scope of the data.
|
||||||
|
- search_posts: Search blog posts using full-text search. Supports category/tag/year/month filters and pagination (offset/limit).
|
||||||
- read_post: Read the full content and metadata of a specific post by ID.
|
- read_post: Read the full content and metadata of a specific post by ID.
|
||||||
- list_posts: List posts with optional filtering by status, category, or tags.
|
- list_posts: List posts with optional filtering by status, category, tags, year, and month. Supports pagination (offset/limit). Returns "total" (global count) and "filteredTotal" (matching filter). ALWAYS use the year filter when you need posts from a specific year — this is much faster than paginating through all posts.
|
||||||
- get_media: Get information about a specific media file by ID.
|
- get_media: Get information about a specific media file by ID.
|
||||||
- list_media: List media files with optional MIME type filtering.
|
- list_media: List media files with optional MIME type, year, month, and tag filtering. Supports pagination (offset/limit). Use year/month filters to narrow efficiently.
|
||||||
- view_image: View an image to analyze its visual content. Use this when you need to describe or analyze what an image looks like.
|
- view_image: View an image to analyze its visual content. Use this when you need to describe or analyze what an image looks like.
|
||||||
- update_post_metadata: Update a post's title, excerpt, tags, or categories.
|
- update_post_metadata: Update a post's title, excerpt, tags, or categories.
|
||||||
- update_media_metadata: Update a media file's title, alt text, caption, or tags.
|
- update_media_metadata: Update a media file's title, alt text, caption, or tags.
|
||||||
@@ -321,12 +322,37 @@ Available Tools:
|
|||||||
- get_post_media: Get media files linked to a post (featured images, galleries).
|
- get_post_media: Get media files linked to a post (featured images, galleries).
|
||||||
- get_media_posts: Get posts that use a specific media file.
|
- get_media_posts: Get posts that use a specific media file.
|
||||||
|
|
||||||
|
Available UI Render Tools (use these to show rich interactive elements):
|
||||||
|
- render_chart: Show data as a bar, stacked-bar, line, area, pie, donut, or heatmap chart. Use when presenting statistics or comparisons. Use stacked-bar when each bar has multiple segments (e.g., published vs draft posts per year). Use area for cumulative or trend data where the filled region emphasizes volume. Use donut for proportional breakdowns with a total displayed in the center. Use heatmap for grid/matrix visualizations where color intensity shows magnitude — e.g., posts per month across years (each series entry is a row like a year, each segment is a column like a month), or a calendar view where rows are weekdays and columns are week numbers. ALWAYS prefer heatmap over a table with emojis or color indicators when showing intensity grids or calendar-style activity views.
|
||||||
|
- render_table: Show data in a structured table. Use for tabular comparisons and listings.
|
||||||
|
- render_form: Show an interactive form to collect user input (e.g., metadata edits, settings).
|
||||||
|
- render_card: Show an information card with title, body, and action buttons.
|
||||||
|
- render_metric: Show a single KPI or statistic prominently.
|
||||||
|
- render_list: Show a bulleted list of items.
|
||||||
|
- render_tabs: Organize information into switchable tabs. Tab content supports all content types: text, metrics, lists, charts, and tables.
|
||||||
|
|
||||||
When answering questions:
|
When answering questions:
|
||||||
1. USE THE TOOLS to find information. Never make up data about posts or media.
|
1. USE THE TOOLS to find information. Never make up data about posts or media.
|
||||||
2. If asked about something outside your tools (weather, news, websites), explain that you can only access the user's local blog content.
|
2. If asked about something outside your tools (weather, news, websites), explain that you can only access the user's local blog content.
|
||||||
3. Be concise and helpful. Format post information clearly when displaying it.
|
3. Be concise and helpful. Format post information clearly when displaying it.
|
||||||
4. If a search returns no results, suggest alternative queries or filters.
|
4. If a search returns no results, suggest alternative queries or filters.
|
||||||
5. When asked to describe or analyze an image, use the view_image tool to see the actual image content.`;
|
5. When asked to describe or analyze an image, use the view_image tool to see the actual image content.
|
||||||
|
6. When presenting data, statistics, or comparisons, prefer using render tools (render_chart, render_table, render_metric) to show rich interactive UI instead of plain text.
|
||||||
|
7. When you need user input for a multi-field operation, use render_form to present a structured form.
|
||||||
|
8. Use render_card with action buttons when presenting items the user might want to navigate to (e.g., posts, media).
|
||||||
|
9. When comparing data across multiple dimensions (e.g., statistics per year), use render_tabs with embedded charts or tables in each tab.
|
||||||
|
|
||||||
|
CRITICAL - Efficient data access:
|
||||||
|
10. This blog may contain thousands or tens of thousands of posts spanning many years. NEVER assume the first page of results represents all data.
|
||||||
|
11. Always check the "total" and "filteredTotal" fields in list_posts and list_media responses. If total > limit, there are more results available via pagination.
|
||||||
|
12. ALWAYS use year/month filters when working with a specific time period. For example, to get posts from 2015, use list_posts with year=2015 — do NOT paginate through all posts with offsets to find the right year. Year/month filters are executed directly in the database and are much faster.
|
||||||
|
13. When reporting counts or statistics, always use get_blog_stats or check the total fields rather than counting the items in a single page of results.
|
||||||
|
14. Never claim there are only N posts when you have only fetched one page. State the total count from the API response.
|
||||||
|
|
||||||
|
CRITICAL - Heatmap and complex visualizations:
|
||||||
|
15. When building a heatmap, plan the data structure BEFORE fetching data. A heatmap needs series entries (rows) with segments (columns). Decide what the rows and columns represent first, then fetch only the data you need using year/month filters.
|
||||||
|
16. For tag-based heatmaps, use list_tags first to know which tags exist, then use list_posts with year filter to get posts for the target period, and aggregate tag counts from the results. Do not try to fetch all posts across all years.
|
||||||
|
17. When building any visualization that requires aggregated data, ALWAYS render the chart as soon as you have enough data. Do not wait to describe what you will do — just do it.`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -13,9 +13,11 @@ import http from 'http';
|
|||||||
import { URL } from 'url';
|
import { URL } from 'url';
|
||||||
import { BrowserWindow } from 'electron';
|
import { BrowserWindow } from 'electron';
|
||||||
import { ChatEngine } from './ChatEngine';
|
import { ChatEngine } from './ChatEngine';
|
||||||
import { PostEngine } from './PostEngine';
|
import { PostEngine, type PostData } from './PostEngine';
|
||||||
import { MediaEngine } from './MediaEngine';
|
import { MediaEngine, type MediaData } from './MediaEngine';
|
||||||
import { getPostMediaEngine } from './PostMediaEngine';
|
import { getPostMediaEngine } from './PostMediaEngine';
|
||||||
|
import { isRenderTool, generateFromToolCall } from '../a2ui/generator';
|
||||||
|
import type { A2UIServerMessage } from '../a2ui/types';
|
||||||
|
|
||||||
// OpenCode Zen API endpoints
|
// OpenCode Zen API endpoints
|
||||||
const ZEN_ANTHROPIC_URL = 'https://opencode.ai/zen/v1/messages';
|
const ZEN_ANTHROPIC_URL = 'https://opencode.ai/zen/v1/messages';
|
||||||
@@ -66,9 +68,13 @@ export interface ModelInfo {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface SendMessageOptions {
|
export interface SendMessageOptions {
|
||||||
|
metadata?: {
|
||||||
|
surface?: 'tab' | 'sidebar';
|
||||||
|
};
|
||||||
onDelta?: (delta: string) => void;
|
onDelta?: (delta: string) => void;
|
||||||
onToolCall?: (toolCall: { name: string; args: unknown }) => void;
|
onToolCall?: (toolCall: { name: string; args: unknown }) => void;
|
||||||
onToolResult?: (result: { name: string; result: unknown }) => void;
|
onToolResult?: (result: { name: string; result: unknown }) => void;
|
||||||
|
onA2UIMessage?: (message: A2UIServerMessage) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SendMessageResult {
|
export interface SendMessageResult {
|
||||||
@@ -237,7 +243,7 @@ export class OpenCodeManager {
|
|||||||
userMessage: string,
|
userMessage: string,
|
||||||
options: SendMessageOptions = {}
|
options: SendMessageOptions = {}
|
||||||
): Promise<SendMessageResult> {
|
): Promise<SendMessageResult> {
|
||||||
const { onDelta, onToolCall, onToolResult } = options;
|
const { metadata, onDelta, onToolCall, onToolResult, onA2UIMessage } = options;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const readyCheck = await this.checkReady();
|
const readyCheck = await this.checkReady();
|
||||||
@@ -268,10 +274,14 @@ export class OpenCodeManager {
|
|||||||
|
|
||||||
// Get system prompt
|
// Get system prompt
|
||||||
const systemMessage = conversation.messages.find(m => m.role === 'system');
|
const systemMessage = conversation.messages.find(m => m.role === 'system');
|
||||||
const systemPrompt = systemMessage?.content || await this.chatEngine.getDefaultSystemPrompt();
|
const basePrompt = systemMessage?.content || await this.chatEngine.getDefaultSystemPrompt();
|
||||||
|
|
||||||
|
// Inject live blog stats into system prompt for data volume awareness
|
||||||
|
const systemPrompt = await this.appendBlogStats(basePrompt);
|
||||||
|
|
||||||
// Build message history from DB (excluding system messages)
|
// Build message history from DB (excluding system messages)
|
||||||
const dbMessages = conversation.messages.filter(m => m.role !== 'system');
|
const dbMessages = conversation.messages.filter(m => m.role !== 'system');
|
||||||
|
|
||||||
// Add the new user message
|
// Add the new user message
|
||||||
dbMessages.push({
|
dbMessages.push({
|
||||||
conversationId,
|
conversationId,
|
||||||
@@ -283,29 +293,53 @@ export class OpenCodeManager {
|
|||||||
let fullResponse = '';
|
let fullResponse = '';
|
||||||
const toolCallsCollected: Array<{ name: string; args: unknown }> = [];
|
const toolCallsCollected: Array<{ name: string; args: unknown }> = [];
|
||||||
|
|
||||||
|
// Compute turn index for surface-to-message association
|
||||||
|
const turnIndex = dbMessages.filter(m => m.role === 'user').length - 1;
|
||||||
|
|
||||||
|
// Wrap onA2UIMessage emission for render tools
|
||||||
|
const emitA2UIMessages = (messages: A2UIServerMessage[]) => {
|
||||||
|
if (onA2UIMessage) {
|
||||||
|
for (const msg of messages) {
|
||||||
|
if (msg.type === 'createSurface') {
|
||||||
|
msg.metadata = { ...msg.metadata, turnIndex };
|
||||||
|
}
|
||||||
|
onA2UIMessage(msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const requestProvider = async (
|
||||||
|
prompt: string,
|
||||||
|
messages: Array<{ role: string; content?: string; toolCalls?: string; toolCallId?: string }>,
|
||||||
|
) => {
|
||||||
|
if (provider === 'anthropic') {
|
||||||
|
return this.sendAnthropicMessage(
|
||||||
|
modelId,
|
||||||
|
prompt,
|
||||||
|
messages,
|
||||||
|
abortController.signal,
|
||||||
|
{ onDelta, onToolCall, onToolResult },
|
||||||
|
conversationId,
|
||||||
|
emitA2UIMessages,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.sendOpenAIMessage(
|
||||||
|
modelId,
|
||||||
|
prompt,
|
||||||
|
messages,
|
||||||
|
abortController.signal,
|
||||||
|
{ onDelta, onToolCall, onToolResult },
|
||||||
|
conversationId,
|
||||||
|
emitA2UIMessages,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
console.log('[OpenCodeManager] Sending to provider:', provider, 'model:', modelId);
|
console.log('[OpenCodeManager] Sending to provider:', provider, 'model:', modelId);
|
||||||
if (provider === 'anthropic') {
|
const firstResult = await requestProvider(systemPrompt, dbMessages);
|
||||||
const result = await this.sendAnthropicMessage(
|
fullResponse = firstResult.content;
|
||||||
modelId,
|
toolCallsCollected.push(...firstResult.toolCalls);
|
||||||
systemPrompt,
|
|
||||||
dbMessages,
|
|
||||||
abortController.signal,
|
|
||||||
{ onDelta, onToolCall, onToolResult }
|
|
||||||
);
|
|
||||||
fullResponse = result.content;
|
|
||||||
toolCallsCollected.push(...result.toolCalls);
|
|
||||||
} else {
|
|
||||||
const result = await this.sendOpenAIMessage(
|
|
||||||
modelId,
|
|
||||||
systemPrompt,
|
|
||||||
dbMessages,
|
|
||||||
abortController.signal,
|
|
||||||
{ onDelta, onToolCall, onToolResult }
|
|
||||||
);
|
|
||||||
fullResponse = result.content;
|
|
||||||
toolCallsCollected.push(...result.toolCalls);
|
|
||||||
}
|
|
||||||
console.log('[OpenCodeManager] fullResponse length:', fullResponse.length);
|
console.log('[OpenCodeManager] fullResponse length:', fullResponse.length);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[OpenCodeManager] Request error:', (error as Error).message);
|
console.error('[OpenCodeManager] Request error:', (error as Error).message);
|
||||||
@@ -313,12 +347,11 @@ export class OpenCodeManager {
|
|||||||
if (!isAborted) {
|
if (!isAborted) {
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
// On abort, keep whatever was streamed so far (already in fullResponse or empty)
|
|
||||||
} finally {
|
} finally {
|
||||||
this.abortControllers.delete(conversationId);
|
this.abortControllers.delete(conversationId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save assistant response (including partial content from aborted requests)
|
// Save assistant response to history
|
||||||
if (fullResponse) {
|
if (fullResponse) {
|
||||||
await this.chatEngine.addMessage({
|
await this.chatEngine.addMessage({
|
||||||
conversationId,
|
conversationId,
|
||||||
@@ -360,7 +393,9 @@ export class OpenCodeManager {
|
|||||||
onDelta?: (delta: string) => void;
|
onDelta?: (delta: string) => void;
|
||||||
onToolCall?: (toolCall: { name: string; args: unknown }) => void;
|
onToolCall?: (toolCall: { name: string; args: unknown }) => void;
|
||||||
onToolResult?: (result: { name: string; result: unknown }) => void;
|
onToolResult?: (result: { name: string; result: unknown }) => void;
|
||||||
}
|
},
|
||||||
|
conversationId: string,
|
||||||
|
emitA2UIMessages: (messages: A2UIServerMessage[]) => void,
|
||||||
): Promise<{ content: string; toolCalls: Array<{ name: string; args: unknown }> }> {
|
): Promise<{ content: string; toolCalls: Array<{ name: string; args: unknown }> }> {
|
||||||
const tools = this.getToolDefinitions();
|
const tools = this.getToolDefinitions();
|
||||||
const allToolCalls: Array<{ name: string; args: unknown }> = [];
|
const allToolCalls: Array<{ name: string; args: unknown }> = [];
|
||||||
@@ -451,6 +486,29 @@ export class OpenCodeManager {
|
|||||||
callbacks.onToolCall({ name: toolName, args: toolArgs });
|
callbacks.onToolCall({ name: toolName, args: toolArgs });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if this is a render tool — generate A2UI messages instead of executing
|
||||||
|
if (isRenderTool(toolName)) {
|
||||||
|
const a2uiMessages = generateFromToolCall(
|
||||||
|
conversationId,
|
||||||
|
toolName,
|
||||||
|
toolArgs as Record<string, unknown>,
|
||||||
|
);
|
||||||
|
if (a2uiMessages) {
|
||||||
|
emitA2UIMessages(a2uiMessages);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (callbacks.onToolResult) {
|
||||||
|
callbacks.onToolResult({ name: toolName, result: { success: true, rendered: true } });
|
||||||
|
}
|
||||||
|
|
||||||
|
toolResults.push({
|
||||||
|
type: 'tool_result',
|
||||||
|
tool_use_id: toolUseId,
|
||||||
|
content: JSON.stringify({ success: true, rendered: true }),
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
// Execute the tool
|
// Execute the tool
|
||||||
const result = await this.executeTool(toolName, toolArgs as Record<string, unknown>);
|
const result = await this.executeTool(toolName, toolArgs as Record<string, unknown>);
|
||||||
|
|
||||||
@@ -523,7 +581,9 @@ export class OpenCodeManager {
|
|||||||
onDelta?: (delta: string) => void;
|
onDelta?: (delta: string) => void;
|
||||||
onToolCall?: (toolCall: { name: string; args: unknown }) => void;
|
onToolCall?: (toolCall: { name: string; args: unknown }) => void;
|
||||||
onToolResult?: (result: { name: string; result: unknown }) => void;
|
onToolResult?: (result: { name: string; result: unknown }) => void;
|
||||||
}
|
},
|
||||||
|
conversationId: string,
|
||||||
|
emitA2UIMessages: (messages: A2UIServerMessage[]) => void,
|
||||||
): Promise<{ content: string; toolCalls: Array<{ name: string; args: unknown }> }> {
|
): Promise<{ content: string; toolCalls: Array<{ name: string; args: unknown }> }> {
|
||||||
// Build OpenAI-format messages
|
// Build OpenAI-format messages
|
||||||
const messages: Array<Record<string, unknown>> = [
|
const messages: Array<Record<string, unknown>> = [
|
||||||
@@ -637,6 +697,25 @@ export class OpenCodeManager {
|
|||||||
callbacks.onToolCall({ name: toolName, args: toolArgs });
|
callbacks.onToolCall({ name: toolName, args: toolArgs });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if this is a render tool
|
||||||
|
if (isRenderTool(toolName)) {
|
||||||
|
const a2uiMessages = generateFromToolCall(conversationId, toolName, toolArgs);
|
||||||
|
if (a2uiMessages) {
|
||||||
|
emitA2UIMessages(a2uiMessages);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (callbacks.onToolResult) {
|
||||||
|
callbacks.onToolResult({ name: toolName, result: { success: true, rendered: true } });
|
||||||
|
}
|
||||||
|
|
||||||
|
messages.push({
|
||||||
|
role: 'tool',
|
||||||
|
content: JSON.stringify({ success: true, rendered: true }),
|
||||||
|
tool_call_id: toolCall.id,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
const result = await this.executeTool(toolName, toolArgs);
|
const result = await this.executeTool(toolName, toolArgs);
|
||||||
|
|
||||||
if (callbacks.onToolResult) {
|
if (callbacks.onToolResult) {
|
||||||
@@ -663,14 +742,17 @@ export class OpenCodeManager {
|
|||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
name: 'search_posts',
|
name: 'search_posts',
|
||||||
description: 'Search blog posts using full-text search. Can filter by category or tags. Returns matching posts with their metadata.',
|
description: 'Search blog posts using full-text search. Can filter by category, tags, year, or month. Returns paginated results with totalMatches count. Use offset to page through results when totalMatches > limit.',
|
||||||
input_schema: {
|
input_schema: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
query: { type: 'string', description: 'The search query text to find in posts' },
|
query: { type: 'string', description: 'The search query text to find in posts' },
|
||||||
category: { type: 'string', description: 'Optional category to filter by (e.g., "article", "picture", "aside", "page")' },
|
category: { type: 'string', description: 'Optional category to filter by (e.g., "article", "picture", "aside", "page")' },
|
||||||
tags: { type: 'array', items: { type: 'string' }, description: 'Optional array of tags to filter by' },
|
tags: { type: 'array', items: { type: 'string' }, description: 'Optional array of tags to filter by' },
|
||||||
|
year: { type: 'number', description: 'Filter to posts created in this year (e.g., 2024)' },
|
||||||
|
month: { type: 'number', description: 'Filter to posts created in this month (1-12). Requires year.' },
|
||||||
limit: { type: 'number', description: 'Maximum number of results to return (default: 10)' },
|
limit: { type: 'number', description: 'Maximum number of results to return (default: 10)' },
|
||||||
|
offset: { type: 'number', description: 'Offset for pagination (default: 0). Use with limit to page through results.' },
|
||||||
},
|
},
|
||||||
required: ['query'],
|
required: ['query'],
|
||||||
},
|
},
|
||||||
@@ -688,13 +770,15 @@ export class OpenCodeManager {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'list_posts',
|
name: 'list_posts',
|
||||||
description: 'List blog posts with optional filtering by status, category, or tags.',
|
description: 'List blog posts with optional filtering by status, category, tags, year, or month. Returns paginated results. The response includes "total" (global post count in the blog) and "filteredTotal" (count matching current filters). Use year/month filters to efficiently narrow to a time period instead of paginating through all posts. Use offset/limit to page through filtered results.',
|
||||||
input_schema: {
|
input_schema: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
status: { type: 'string', enum: ['draft', 'published', 'archived'], description: 'Filter by post status' },
|
status: { type: 'string', enum: ['draft', 'published', 'archived'], description: 'Filter by post status' },
|
||||||
category: { type: 'string', description: 'Filter by category' },
|
category: { type: 'string', description: 'Filter by category' },
|
||||||
tags: { type: 'array', items: { type: 'string' }, description: 'Filter by tags (posts must have all specified tags)' },
|
tags: { type: 'array', items: { type: 'string' }, description: 'Filter by tags (posts must have all specified tags)' },
|
||||||
|
year: { type: 'number', description: 'Filter to posts created in this year (e.g., 2024). Use this to efficiently narrow results by time period.' },
|
||||||
|
month: { type: 'number', description: 'Filter to posts created in this month (1-12). Requires year.' },
|
||||||
limit: { type: 'number', description: 'Maximum number of results (default: 20)' },
|
limit: { type: 'number', description: 'Maximum number of results (default: 20)' },
|
||||||
offset: { type: 'number', description: 'Offset for pagination (default: 0)' },
|
offset: { type: 'number', description: 'Offset for pagination (default: 0)' },
|
||||||
},
|
},
|
||||||
@@ -713,12 +797,16 @@ export class OpenCodeManager {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'list_media',
|
name: 'list_media',
|
||||||
description: 'List all media files in the current project with optional filtering.',
|
description: 'List media files in the current project with optional filtering by MIME type, year, month, or tags. Returns paginated results with total count. Use year/month filters to efficiently narrow to a time period.',
|
||||||
input_schema: {
|
input_schema: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
mimeTypeFilter: { type: 'string', description: 'Filter by MIME type prefix (e.g., "image/")' },
|
mimeTypeFilter: { type: 'string', description: 'Filter by MIME type prefix (e.g., "image/")' },
|
||||||
|
tags: { type: 'array', items: { type: 'string' }, description: 'Filter by tags (media must have all specified tags)' },
|
||||||
|
year: { type: 'number', description: 'Filter to media created in this year (e.g., 2024)' },
|
||||||
|
month: { type: 'number', description: 'Filter to media created in this month (1-12). Requires year.' },
|
||||||
limit: { type: 'number', description: 'Maximum number of results (default: 20)' },
|
limit: { type: 'number', description: 'Maximum number of results (default: 20)' },
|
||||||
|
offset: { type: 'number', description: 'Offset for pagination (default: 0)' },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -768,6 +856,14 @@ export class OpenCodeManager {
|
|||||||
properties: {},
|
properties: {},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'get_blog_stats',
|
||||||
|
description: 'Get comprehensive blog statistics: total posts, drafts, published, archived counts, date range (oldest to newest post), posts per year breakdown, number of unique tags and categories, and total media count. Use this FIRST to understand the full scope of the blog before making queries. This is essential to understand the data volume.',
|
||||||
|
input_schema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {},
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'view_image',
|
name: 'view_image',
|
||||||
description: 'View an image to analyze its visual content. Returns the actual image for visual inspection. Use this when you need to see, describe, or analyze what an image looks like. Only works with image files (not PDFs or other media types).',
|
description: 'View an image to analyze its visual content. Returns the actual image for visual inspection. Use this when you need to see, describe, or analyze what an image looks like. Only works with image files (not PDFs or other media types).',
|
||||||
@@ -828,6 +924,201 @@ export class OpenCodeManager {
|
|||||||
required: ['mediaId'],
|
required: ['mediaId'],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
// ── A2UI Render Tools ──
|
||||||
|
{
|
||||||
|
name: 'render_chart',
|
||||||
|
description: 'Render an interactive chart in the chat UI. Use this when the user asks for a chart, graph, or data visualization. The chart will be displayed as a rich UI element in the conversation.',
|
||||||
|
input_schema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
chartType: { type: 'string', enum: ['bar', 'stacked-bar', 'line', 'area', 'pie', 'donut', 'heatmap'], description: 'The type of chart to render. Use stacked-bar when each bar has multiple segments (categories). Use area for trend/cumulative data. Use donut for proportional data with a total in the center. Use heatmap for grid/matrix visualizations where color intensity shows magnitude (e.g., posts per month across years). Prefer heatmap over tables with emojis for intensity data.' },
|
||||||
|
title: { type: 'string', description: 'Optional chart title' },
|
||||||
|
series: {
|
||||||
|
type: 'array',
|
||||||
|
items: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
label: { type: 'string', description: 'Data point label (row label for heatmaps, e.g., year)' },
|
||||||
|
value: { type: 'number', description: 'Data point value (total for stacked bars, ignored for heatmaps)' },
|
||||||
|
segments: {
|
||||||
|
type: 'array',
|
||||||
|
items: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
label: { type: 'string', description: 'Segment/column label (e.g., month name for heatmaps)' },
|
||||||
|
value: { type: 'number', description: 'Segment value (color intensity for heatmaps)' },
|
||||||
|
},
|
||||||
|
required: ['label', 'value'],
|
||||||
|
},
|
||||||
|
description: 'Segments within this data point. Required for stacked-bar and heatmap charts. For heatmaps, each segment becomes a cell in that row.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ['label', 'value'],
|
||||||
|
},
|
||||||
|
description: 'Array of data points. For stacked-bar and heatmap charts, include segments. For heatmaps, each entry is a row and segments are columns.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ['chartType', 'series'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'render_table',
|
||||||
|
description: 'Render a data table in the chat UI. Use this when the user asks for tabular data, comparisons, or structured information. The table will be displayed as a rich UI element.',
|
||||||
|
input_schema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
title: { type: 'string', description: 'Optional table title' },
|
||||||
|
columns: { type: 'array', items: { type: 'string' }, description: 'Column header names' },
|
||||||
|
rows: { type: 'array', items: { type: 'array', items: { type: 'string' } }, description: 'Table rows, each row is an array of cell values' },
|
||||||
|
},
|
||||||
|
required: ['columns', 'rows'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'render_form',
|
||||||
|
description: 'Render an interactive form in the chat UI. Use this when you need to collect structured input from the user, such as metadata updates, configuration, or multi-field data entry.',
|
||||||
|
input_schema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
title: { type: 'string', description: 'Optional form title' },
|
||||||
|
fields: {
|
||||||
|
type: 'array',
|
||||||
|
items: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
key: { type: 'string', description: 'Field identifier' },
|
||||||
|
label: { type: 'string', description: 'Field label shown to user' },
|
||||||
|
inputType: { type: 'string', enum: ['text', 'textarea', 'select', 'checkbox', 'date', 'number'], description: 'Type of input control' },
|
||||||
|
placeholder: { type: 'string', description: 'Placeholder text' },
|
||||||
|
defaultValue: { description: 'Default value for the field' },
|
||||||
|
options: { type: 'array', items: { type: 'object', properties: { label: { type: 'string' }, value: { type: 'string' } }, required: ['label', 'value'] }, description: 'Options for select fields' },
|
||||||
|
required: { type: 'boolean', description: 'Whether the field is required' },
|
||||||
|
},
|
||||||
|
required: ['key', 'label', 'inputType'],
|
||||||
|
},
|
||||||
|
description: 'Form fields to display',
|
||||||
|
},
|
||||||
|
submitLabel: { type: 'string', description: 'Label for the submit button' },
|
||||||
|
submitAction: { type: 'string', description: 'Action to dispatch on submit' },
|
||||||
|
},
|
||||||
|
required: ['fields', 'submitLabel'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'render_card',
|
||||||
|
description: 'Render an information card in the chat UI. Use this for displaying a summary, highlight, or actionable item with a title, body, and optional action buttons.',
|
||||||
|
input_schema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
title: { type: 'string', description: 'Card title' },
|
||||||
|
body: { type: 'string', description: 'Card body text (supports markdown)' },
|
||||||
|
subtitle: { type: 'string', description: 'Optional subtitle' },
|
||||||
|
actions: {
|
||||||
|
type: 'array',
|
||||||
|
items: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
label: { type: 'string', description: 'Button label' },
|
||||||
|
action: { type: 'string', description: 'Action name to dispatch (e.g., openPost, openMedia)' },
|
||||||
|
payload: { type: 'object', description: 'Optional action payload' },
|
||||||
|
},
|
||||||
|
required: ['label', 'action'],
|
||||||
|
},
|
||||||
|
description: 'Optional action buttons on the card',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ['title', 'body'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'render_metric',
|
||||||
|
description: 'Render a single metric/KPI display in the chat UI. Use this for showing a single important value with a label, such as post counts, statistics, or status indicators.',
|
||||||
|
input_schema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
label: { type: 'string', description: 'Metric label' },
|
||||||
|
value: { type: 'string', description: 'Metric value (displayed prominently)' },
|
||||||
|
},
|
||||||
|
required: ['label', 'value'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'render_list',
|
||||||
|
description: 'Render a list of items in the chat UI. Use this for displaying bullet-point style lists, checklists, or simple enumerations.',
|
||||||
|
input_schema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
title: { type: 'string', description: 'Optional list title' },
|
||||||
|
items: { type: 'array', items: { type: 'string' }, description: 'List items' },
|
||||||
|
},
|
||||||
|
required: ['items'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'render_tabs',
|
||||||
|
description: 'Render a tabbed interface in the chat UI. Use this when you want to organize information into multiple tabs that the user can switch between. Each tab can contain any combination of text, metrics, lists, charts, and tables.',
|
||||||
|
input_schema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
tabs: {
|
||||||
|
type: 'array',
|
||||||
|
items: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
label: { type: 'string', description: 'Tab label' },
|
||||||
|
content: {
|
||||||
|
type: 'array',
|
||||||
|
items: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
type: { type: 'string', enum: ['text', 'metric', 'list', 'chart', 'table'], description: 'Content type' },
|
||||||
|
text: { type: 'string', description: 'Text content (for type text)' },
|
||||||
|
label: { type: 'string', description: 'Label (for type metric)' },
|
||||||
|
value: { type: 'string', description: 'Display value (for type metric)' },
|
||||||
|
title: { type: 'string', description: 'Title (for type list, chart, or table)' },
|
||||||
|
items: { type: 'array', items: { type: 'string' }, description: 'Items (for type list)' },
|
||||||
|
chartType: { type: 'string', enum: ['bar', 'stacked-bar', 'line', 'area', 'pie', 'donut', 'heatmap'], description: 'Chart type (for type chart). Use heatmap for intensity grids.' },
|
||||||
|
series: {
|
||||||
|
type: 'array',
|
||||||
|
items: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
label: { type: 'string' },
|
||||||
|
value: { type: 'number' },
|
||||||
|
segments: {
|
||||||
|
type: 'array',
|
||||||
|
items: {
|
||||||
|
type: 'object',
|
||||||
|
properties: { label: { type: 'string' }, value: { type: 'number' } },
|
||||||
|
required: ['label', 'value'],
|
||||||
|
},
|
||||||
|
description: 'Segments for stacked-bar and heatmap charts',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ['label', 'value'],
|
||||||
|
},
|
||||||
|
description: 'Data series (for type chart)',
|
||||||
|
},
|
||||||
|
columns: { type: 'array', items: { type: 'string' }, description: 'Column headers (for type table)' },
|
||||||
|
rows: {
|
||||||
|
type: 'array',
|
||||||
|
items: { type: 'array', items: { type: 'string' } },
|
||||||
|
description: 'Table rows (for type table)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ['type'],
|
||||||
|
},
|
||||||
|
description: 'Content items within the tab',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ['label', 'content'],
|
||||||
|
},
|
||||||
|
description: 'Array of tabs',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ['tabs'],
|
||||||
|
},
|
||||||
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -852,13 +1143,27 @@ export class OpenCodeManager {
|
|||||||
(args.tags as string[]).every(tag => p!.tags.includes(tag))
|
(args.tags as string[]).every(tag => p!.tags.includes(tag))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (args.year !== undefined) {
|
||||||
|
const year = args.year as number;
|
||||||
|
filteredPosts = filteredPosts.filter(p => p!.createdAt.getFullYear() === year);
|
||||||
|
}
|
||||||
|
if (args.month !== undefined && args.year !== undefined) {
|
||||||
|
const month = (args.month as number) - 1; // Convert 1-indexed to 0-indexed
|
||||||
|
filteredPosts = filteredPosts.filter(p => p!.createdAt.getMonth() === month);
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalMatches = filteredPosts.length;
|
||||||
|
const offset = (args.offset as number) || 0;
|
||||||
const limit = (args.limit as number) || 10;
|
const limit = (args.limit as number) || 10;
|
||||||
filteredPosts = filteredPosts.slice(0, limit);
|
filteredPosts = filteredPosts.slice(offset, offset + limit);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
count: filteredPosts.length,
|
count: filteredPosts.length,
|
||||||
|
totalMatches,
|
||||||
|
hasMore: offset + limit < totalMatches,
|
||||||
|
offset,
|
||||||
|
limit,
|
||||||
posts: filteredPosts.map(p => ({
|
posts: filteredPosts.map(p => ({
|
||||||
id: p!.id, title: p!.title, slug: p!.slug,
|
id: p!.id, title: p!.title, slug: p!.slug,
|
||||||
excerpt: p!.excerpt, status: p!.status,
|
excerpt: p!.excerpt, status: p!.status,
|
||||||
@@ -885,32 +1190,42 @@ export class OpenCodeManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
case 'list_posts': {
|
case 'list_posts': {
|
||||||
const filter: { status?: 'draft' | 'published' | 'archived'; tags?: string[]; categories?: string[] } = {};
|
const filter: { status?: 'draft' | 'published' | 'archived'; tags?: string[]; categories?: string[]; year?: number; month?: number } = {};
|
||||||
if (args.status) filter.status = args.status as 'draft' | 'published' | 'archived';
|
if (args.status) filter.status = args.status as 'draft' | 'published' | 'archived';
|
||||||
if (args.tags) filter.tags = args.tags as string[];
|
if (args.tags) filter.tags = args.tags as string[];
|
||||||
if (args.category) filter.categories = [args.category as string];
|
if (args.category) filter.categories = [args.category as string];
|
||||||
|
if (args.year !== undefined) filter.year = args.year as number;
|
||||||
let posts;
|
if (args.month !== undefined && args.year !== undefined) filter.month = (args.month as number) - 1; // Convert 1-indexed to 0-indexed
|
||||||
if (Object.keys(filter).length > 0) {
|
|
||||||
posts = await this.postEngine.getPostsFiltered(filter);
|
|
||||||
} else {
|
|
||||||
const result = await this.postEngine.getAllPosts({
|
|
||||||
limit: (args.limit as number) || 20,
|
|
||||||
offset: (args.offset as number) || 0,
|
|
||||||
});
|
|
||||||
posts = result.items;
|
|
||||||
}
|
|
||||||
|
|
||||||
const offset = (args.offset as number) || 0;
|
const offset = (args.offset as number) || 0;
|
||||||
const limit = (args.limit as number) || 20;
|
const limit = (args.limit as number) || 20;
|
||||||
const slicedPosts = posts.slice(offset, offset + limit);
|
|
||||||
|
// Always get global total for awareness
|
||||||
|
const globalStats = await this.postEngine.getDashboardStats();
|
||||||
|
const globalTotal = globalStats.totalPosts;
|
||||||
|
|
||||||
|
let pageItems: PostData[];
|
||||||
|
let filteredTotal: number;
|
||||||
|
|
||||||
|
if (Object.keys(filter).length > 0) {
|
||||||
|
const allFiltered = await this.postEngine.getPostsFiltered(filter);
|
||||||
|
filteredTotal = allFiltered.length;
|
||||||
|
pageItems = allFiltered.slice(offset, offset + limit);
|
||||||
|
} else {
|
||||||
|
const result = await this.postEngine.getAllPosts({ limit, offset });
|
||||||
|
pageItems = result.items;
|
||||||
|
filteredTotal = result.total;
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
count: slicedPosts.length,
|
count: pageItems.length,
|
||||||
total: posts.length,
|
total: globalTotal,
|
||||||
hasMore: offset + limit < posts.length,
|
filteredTotal,
|
||||||
posts: slicedPosts.map(p => ({
|
hasMore: offset + limit < filteredTotal,
|
||||||
|
offset,
|
||||||
|
limit,
|
||||||
|
posts: pageItems.map(p => ({
|
||||||
id: p.id, title: p.title, slug: p.slug,
|
id: p.id, title: p.title, slug: p.slug,
|
||||||
status: p.status, categories: p.categories,
|
status: p.status, categories: p.categories,
|
||||||
tags: p.tags, createdAt: p.createdAt, updatedAt: p.updatedAt,
|
tags: p.tags, createdAt: p.createdAt, updatedAt: p.updatedAt,
|
||||||
@@ -934,16 +1249,36 @@ export class OpenCodeManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
case 'list_media': {
|
case 'list_media': {
|
||||||
let mediaList = await this.mediaEngine.getAllMedia();
|
const hasMediaFilter = args.year !== undefined || (args.tags && Array.isArray(args.tags) && (args.tags as string[]).length > 0);
|
||||||
|
let mediaList: MediaData[];
|
||||||
|
|
||||||
|
if (hasMediaFilter) {
|
||||||
|
const mediaFilter: { year?: number; month?: number; tags?: string[] } = {};
|
||||||
|
if (args.year !== undefined) mediaFilter.year = args.year as number;
|
||||||
|
if (args.month !== undefined && args.year !== undefined) mediaFilter.month = (args.month as number) - 1; // Convert 1-indexed to 0-indexed
|
||||||
|
if (args.tags) mediaFilter.tags = args.tags as string[];
|
||||||
|
mediaList = await this.mediaEngine.getMediaFiltered(mediaFilter);
|
||||||
|
} else {
|
||||||
|
mediaList = await this.mediaEngine.getAllMedia();
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalMedia = mediaList.length;
|
||||||
if (args.mimeTypeFilter) {
|
if (args.mimeTypeFilter) {
|
||||||
mediaList = mediaList.filter(m => m.mimeType.startsWith(args.mimeTypeFilter as string));
|
mediaList = mediaList.filter(m => m.mimeType.startsWith(args.mimeTypeFilter as string));
|
||||||
}
|
}
|
||||||
|
const filteredTotal = mediaList.length;
|
||||||
|
const offset = (args.offset as number) || 0;
|
||||||
const limit = (args.limit as number) || 20;
|
const limit = (args.limit as number) || 20;
|
||||||
mediaList = mediaList.slice(0, limit);
|
const pageItems = mediaList.slice(offset, offset + limit);
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
count: mediaList.length,
|
count: pageItems.length,
|
||||||
media: mediaList.map(m => ({
|
total: totalMedia,
|
||||||
|
filteredTotal,
|
||||||
|
hasMore: offset + limit < filteredTotal,
|
||||||
|
offset,
|
||||||
|
limit,
|
||||||
|
media: pageItems.map(m => ({
|
||||||
id: m.id, filename: m.filename,
|
id: m.id, filename: m.filename,
|
||||||
originalName: m.originalName, mimeType: m.mimeType,
|
originalName: m.originalName, mimeType: m.mimeType,
|
||||||
title: m.title, alt: m.alt, tags: m.tags,
|
title: m.title, alt: m.alt, tags: m.tags,
|
||||||
@@ -1119,6 +1454,25 @@ export class OpenCodeManager {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case 'get_blog_stats': {
|
||||||
|
const stats = await this.postEngine.getBlogStats();
|
||||||
|
const mediaList = await this.mediaEngine.getAllMedia();
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
totalPosts: stats.totalPosts,
|
||||||
|
draftCount: stats.draftCount,
|
||||||
|
publishedCount: stats.publishedCount,
|
||||||
|
archivedCount: stats.archivedCount,
|
||||||
|
dateRange: stats.oldestPostDate && stats.newestPostDate
|
||||||
|
? { oldest: stats.oldestPostDate, newest: stats.newestPostDate }
|
||||||
|
: null,
|
||||||
|
postsPerYear: stats.postsPerYear,
|
||||||
|
tagCount: stats.tagCount,
|
||||||
|
categoryCount: stats.categoryCount,
|
||||||
|
totalMedia: mediaList.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return { success: false, error: `Unknown tool: ${name}` };
|
return { success: false, error: `Unknown tool: ${name}` };
|
||||||
}
|
}
|
||||||
@@ -1240,6 +1594,45 @@ export class OpenCodeManager {
|
|||||||
|
|
||||||
// ── Helpers ──
|
// ── Helpers ──
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Append live blog statistics to the system prompt so the AI
|
||||||
|
* knows the true scale of the data before its first tool call.
|
||||||
|
*/
|
||||||
|
private async appendBlogStats(basePrompt: string): Promise<string> {
|
||||||
|
try {
|
||||||
|
const stats = await this.postEngine.getBlogStats();
|
||||||
|
const mediaList = await this.mediaEngine.getAllMedia();
|
||||||
|
|
||||||
|
if (stats.totalPosts === 0) {
|
||||||
|
return basePrompt;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dateRange = stats.oldestPostDate && stats.newestPostDate
|
||||||
|
? `from ${stats.oldestPostDate.toISOString().split('T')[0]} to ${stats.newestPostDate.toISOString().split('T')[0]}`
|
||||||
|
: 'unknown';
|
||||||
|
|
||||||
|
const yearBreakdown = Object.entries(stats.postsPerYear)
|
||||||
|
.sort(([a], [b]) => Number(a) - Number(b))
|
||||||
|
.map(([year, count]) => `${year}: ${count}`)
|
||||||
|
.join(', ');
|
||||||
|
|
||||||
|
const statsSummary = `
|
||||||
|
|
||||||
|
--- CURRENT BLOG DATA SUMMARY ---
|
||||||
|
Total posts: ${stats.totalPosts} (${stats.publishedCount} published, ${stats.draftCount} drafts, ${stats.archivedCount} archived)
|
||||||
|
Date range: ${dateRange}
|
||||||
|
Posts per year: ${yearBreakdown}
|
||||||
|
Unique tags: ${stats.tagCount}, Unique categories: ${stats.categoryCount}
|
||||||
|
Total media files: ${mediaList.length}
|
||||||
|
NOTE: Use pagination (offset/limit) in list_posts and search_posts to access all data. Default page size is 20.`;
|
||||||
|
|
||||||
|
return basePrompt + statsSummary;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[OpenCodeManager] Failed to append blog stats:', error);
|
||||||
|
return basePrompt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private detectProvider(modelId: string): string {
|
private detectProvider(modelId: string): string {
|
||||||
const id = modelId.toLowerCase();
|
const id = modelId.toLowerCase();
|
||||||
if (id.startsWith('claude')) return 'anthropic';
|
if (id.startsWith('claude')) return 'anthropic';
|
||||||
|
|||||||
@@ -958,6 +958,67 @@ export class PostEngine extends EventEmitter {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getBlogStats(): Promise<{
|
||||||
|
totalPosts: number;
|
||||||
|
draftCount: number;
|
||||||
|
publishedCount: number;
|
||||||
|
archivedCount: number;
|
||||||
|
oldestPostDate: Date | null;
|
||||||
|
newestPostDate: Date | null;
|
||||||
|
postsPerYear: Record<number, number>;
|
||||||
|
tagCount: number;
|
||||||
|
categoryCount: number;
|
||||||
|
}> {
|
||||||
|
const db = getDatabase().getLocal();
|
||||||
|
const dbPosts = await db
|
||||||
|
.select({ status: posts.status, createdAt: posts.createdAt, tags: posts.tags, categories: posts.categories })
|
||||||
|
.from(posts)
|
||||||
|
.where(eq(posts.projectId, this.currentProjectId))
|
||||||
|
.all();
|
||||||
|
|
||||||
|
let draftCount = 0;
|
||||||
|
let publishedCount = 0;
|
||||||
|
let archivedCount = 0;
|
||||||
|
let oldestPostDate: Date | null = null;
|
||||||
|
let newestPostDate: Date | null = null;
|
||||||
|
const postsPerYear: Record<number, number> = {};
|
||||||
|
const uniqueTags = new Set<string>();
|
||||||
|
const uniqueCategories = new Set<string>();
|
||||||
|
|
||||||
|
for (const row of dbPosts) {
|
||||||
|
switch (row.status) {
|
||||||
|
case 'draft': draftCount++; break;
|
||||||
|
case 'published': publishedCount++; break;
|
||||||
|
case 'archived': archivedCount++; break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const created = row.createdAt;
|
||||||
|
if (!oldestPostDate || created < oldestPostDate) oldestPostDate = created;
|
||||||
|
if (!newestPostDate || created > newestPostDate) newestPostDate = created;
|
||||||
|
|
||||||
|
const year = created.getFullYear();
|
||||||
|
postsPerYear[year] = (postsPerYear[year] || 0) + 1;
|
||||||
|
|
||||||
|
const parsedTags: string[] = JSON.parse(row.tags || '[]');
|
||||||
|
for (const tag of parsedTags) uniqueTags.add(tag);
|
||||||
|
|
||||||
|
const parsedCategories: string[] = JSON.parse(row.categories || '[]');
|
||||||
|
for (const cat of parsedCategories) uniqueCategories.add(cat);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalPosts: dbPosts.length,
|
||||||
|
draftCount,
|
||||||
|
publishedCount,
|
||||||
|
archivedCount,
|
||||||
|
oldestPostDate,
|
||||||
|
newestPostDate,
|
||||||
|
postsPerYear,
|
||||||
|
tagCount: uniqueTags.size,
|
||||||
|
categoryCount: uniqueCategories.size,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async getPostsByYearMonth(): Promise<{ year: number; month: number; count: number }[]> {
|
async getPostsByYearMonth(): Promise<{ year: number; month: number; count: number }[]> {
|
||||||
const allPosts = await this.getAllPostsUnpaginated();
|
const allPosts = await this.getAllPostsUnpaginated();
|
||||||
const counts = new Map<string, { year: number; month: number; count: number }>();
|
const counts = new Map<string, { year: number; month: number; count: number }>();
|
||||||
|
|||||||
@@ -256,12 +256,13 @@ export function registerChatHandlers(): void {
|
|||||||
// ============ Chat Messaging ============
|
// ============ Chat Messaging ============
|
||||||
|
|
||||||
// Send a message
|
// Send a message
|
||||||
ipcMain.handle('chat:sendMessage', async (_, conversationId: string, message: string) => {
|
ipcMain.handle('chat:sendMessage', async (_, conversationId: string, message: string, metadata?: { surface?: 'tab' | 'sidebar' }) => {
|
||||||
try {
|
try {
|
||||||
const manager = await getOpenCodeManager();
|
const manager = await getOpenCodeManager();
|
||||||
const mainWindow = mainWindowGetter?.();
|
const mainWindow = mainWindowGetter?.();
|
||||||
|
|
||||||
const result = await manager.sendMessage(conversationId, message, {
|
const result = await manager.sendMessage(conversationId, message, {
|
||||||
|
metadata,
|
||||||
onDelta: (delta) => {
|
onDelta: (delta) => {
|
||||||
if (mainWindow) {
|
if (mainWindow) {
|
||||||
mainWindow.webContents.send('chat-stream-delta', { conversationId, delta });
|
mainWindow.webContents.send('chat-stream-delta', { conversationId, delta });
|
||||||
@@ -277,6 +278,11 @@ export function registerChatHandlers(): void {
|
|||||||
mainWindow.webContents.send('chat-tool-result', { conversationId, result });
|
mainWindow.webContents.send('chat-tool-result', { conversationId, result });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
onA2UIMessage: (message) => {
|
||||||
|
if (mainWindow) {
|
||||||
|
mainWindow.webContents.send('a2ui-message', { conversationId, message });
|
||||||
|
}
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
@@ -286,6 +292,22 @@ export function registerChatHandlers(): void {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('chat:addSystemEvent', async (_, conversationId: string, content: string) => {
|
||||||
|
try {
|
||||||
|
const engine = getChatEngine();
|
||||||
|
await engine.addMessage({
|
||||||
|
conversationId,
|
||||||
|
role: 'system',
|
||||||
|
content,
|
||||||
|
createdAt: new Date(),
|
||||||
|
});
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Chat IPC] Error adding system event:', error);
|
||||||
|
return { success: false, error: (error as Error).message };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Abort a running message
|
// Abort a running message
|
||||||
ipcMain.handle('chat:abortMessage', async (_, conversationId: string) => {
|
ipcMain.handle('chat:abortMessage', async (_, conversationId: string) => {
|
||||||
try {
|
try {
|
||||||
@@ -357,6 +379,20 @@ export function registerChatHandlers(): void {
|
|||||||
return { success: false, error: (error as Error).message };
|
return { success: false, error: (error as Error).message };
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ============ A2UI Actions ============
|
||||||
|
|
||||||
|
ipcMain.handle('a2ui:dispatch', async (_, action: { surfaceId: string; componentId: string; action: string; payload?: Record<string, unknown> }) => {
|
||||||
|
try {
|
||||||
|
console.log('[Chat IPC] A2UI action dispatched:', action);
|
||||||
|
// Currently, A2UI actions are handled client-side (navigation, UI toggles).
|
||||||
|
// Server-side action handling can be added here in the future.
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Chat IPC] Error dispatching A2UI action:', error);
|
||||||
|
return { success: false, error: (error as Error).message };
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -84,7 +84,11 @@ function runWebContentsMenuAction(sender: any, action: AppMenuAction): boolean {
|
|||||||
sender.selectAll?.();
|
sender.selectAll?.();
|
||||||
return true;
|
return true;
|
||||||
case 'toggleDevTools':
|
case 'toggleDevTools':
|
||||||
sender.toggleDevTools?.();
|
if (sender.isDevToolsOpened?.()) {
|
||||||
|
sender.closeDevTools?.();
|
||||||
|
} else {
|
||||||
|
sender.openDevTools?.({ mode: 'detach' });
|
||||||
|
}
|
||||||
return true;
|
return true;
|
||||||
case 'reload':
|
case 'reload':
|
||||||
sender.reload?.();
|
sender.reload?.();
|
||||||
|
|||||||
@@ -49,6 +49,20 @@ interface Rectangle {
|
|||||||
// Check if dev server is likely running (only in development)
|
// Check if dev server is likely running (only in development)
|
||||||
const isDev = process.env.NODE_ENV === 'development';
|
const isDev = process.env.NODE_ENV === 'development';
|
||||||
|
|
||||||
|
function toggleDetachedDevTools(targetWindow: BrowserWindow | null): void {
|
||||||
|
const webContents = targetWindow?.webContents;
|
||||||
|
if (!webContents) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (webContents.isDevToolsOpened()) {
|
||||||
|
webContents.closeDevTools();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
webContents.openDevTools({ mode: 'detach' });
|
||||||
|
}
|
||||||
|
|
||||||
function getWindowStatePath(): string | null {
|
function getWindowStatePath(): string | null {
|
||||||
if (typeof app.getPath !== 'function') {
|
if (typeof app.getPath !== 'function') {
|
||||||
return null;
|
return null;
|
||||||
@@ -246,7 +260,7 @@ function createWindow(): void {
|
|||||||
// F12 or Ctrl+Shift+I to toggle DevTools
|
// F12 or Ctrl+Shift+I to toggle DevTools
|
||||||
if (input.key === 'F12' ||
|
if (input.key === 'F12' ||
|
||||||
(input.control && input.shift && input.key.toLowerCase() === 'i')) {
|
(input.control && input.shift && input.key.toLowerCase() === 'i')) {
|
||||||
mainWindow?.webContents.toggleDevTools();
|
toggleDetachedDevTools(mainWindow);
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -255,13 +269,13 @@ function createWindow(): void {
|
|||||||
const rendererPath = path.join(__dirname, '../renderer/index.html');
|
const rendererPath = path.join(__dirname, '../renderer/index.html');
|
||||||
if (isDev) {
|
if (isDev) {
|
||||||
mainWindow.loadURL('http://localhost:5173');
|
mainWindow.loadURL('http://localhost:5173');
|
||||||
mainWindow.webContents.openDevTools();
|
mainWindow.webContents.openDevTools({ mode: 'detach' });
|
||||||
} else if (fs.existsSync(rendererPath)) {
|
} else if (fs.existsSync(rendererPath)) {
|
||||||
mainWindow.loadFile(rendererPath);
|
mainWindow.loadFile(rendererPath);
|
||||||
} else {
|
} else {
|
||||||
// Fallback to dev server if built files don't exist
|
// Fallback to dev server if built files don't exist
|
||||||
mainWindow.loadURL('http://localhost:5173');
|
mainWindow.loadURL('http://localhost:5173');
|
||||||
mainWindow.webContents.openDevTools();
|
mainWindow.webContents.openDevTools({ mode: 'detach' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Forward events to renderer
|
// Forward events to renderer
|
||||||
@@ -571,6 +585,11 @@ function createApplicationMenu(): Menu {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (action === 'toggleDevTools') {
|
||||||
|
toggleDetachedDevTools(mainWindow);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (action === 'viewOnGitHub') {
|
if (action === 'viewOnGitHub') {
|
||||||
void shell.openExternal('https://github.com/rfc1437/bDS');
|
void shell.openExternal('https://github.com/rfc1437/bDS');
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -299,7 +299,8 @@ export const electronAPI: ElectronAPI = {
|
|||||||
deleteConversation: (id: string) => ipcRenderer.invoke('chat:deleteConversation', id),
|
deleteConversation: (id: string) => ipcRenderer.invoke('chat:deleteConversation', id),
|
||||||
|
|
||||||
// Messaging
|
// Messaging
|
||||||
sendMessage: (conversationId: string, message: string) => ipcRenderer.invoke('chat:sendMessage', conversationId, message),
|
sendMessage: (conversationId: string, message: string, metadata?: { surface?: 'tab' | 'sidebar' }) => ipcRenderer.invoke('chat:sendMessage', conversationId, message, metadata),
|
||||||
|
addSystemEvent: (conversationId: string, content: string) => ipcRenderer.invoke('chat:addSystemEvent', conversationId, content),
|
||||||
abortMessage: (conversationId: string) => ipcRenderer.invoke('chat:abortMessage', conversationId),
|
abortMessage: (conversationId: string) => ipcRenderer.invoke('chat:abortMessage', conversationId),
|
||||||
getHistory: (conversationId: string) => ipcRenderer.invoke('chat:getHistory', conversationId),
|
getHistory: (conversationId: string) => ipcRenderer.invoke('chat:getHistory', conversationId),
|
||||||
clearMessages: (conversationId: string) => ipcRenderer.invoke('chat:clearMessages', conversationId),
|
clearMessages: (conversationId: string) => ipcRenderer.invoke('chat:clearMessages', conversationId),
|
||||||
@@ -332,6 +333,14 @@ export const electronAPI: ElectronAPI = {
|
|||||||
ipcRenderer.on('chat-title-updated', subscription);
|
ipcRenderer.on('chat-title-updated', subscription);
|
||||||
return () => ipcRenderer.removeListener('chat-title-updated', subscription);
|
return () => ipcRenderer.removeListener('chat-title-updated', subscription);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// A2UI streaming
|
||||||
|
onA2UIMessage: (callback: (data: { conversationId: string; message: import('./a2ui/types').A2UIServerMessage }) => void) => {
|
||||||
|
const subscription = (_event: Electron.IpcRendererEvent, data: { conversationId: string; message: import('./a2ui/types').A2UIServerMessage }) => callback(data);
|
||||||
|
ipcRenderer.on('a2ui-message', subscription);
|
||||||
|
return () => ipcRenderer.removeListener('a2ui-message', subscription);
|
||||||
|
},
|
||||||
|
dispatchA2UIAction: (action: import('./a2ui/types').A2UIClientAction) => ipcRenderer.invoke('a2ui:dispatch', action),
|
||||||
},
|
},
|
||||||
|
|
||||||
// Event listeners
|
// Event listeners
|
||||||
|
|||||||
@@ -431,6 +431,14 @@ export interface ChatTitleUpdate {
|
|||||||
title: string;
|
title: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ChatSendMetadata {
|
||||||
|
surface?: 'tab' | 'sidebar';
|
||||||
|
}
|
||||||
|
|
||||||
|
// A2UI types imported for use in ElectronAPI and re-exported for renderer
|
||||||
|
import type { A2UIServerMessage, A2UIClientAction } from '../a2ui/types';
|
||||||
|
export type { A2UIServerMessage, A2UIClientAction };
|
||||||
|
|
||||||
export interface SiteValidationReport {
|
export interface SiteValidationReport {
|
||||||
sitemapPath: string;
|
sitemapPath: string;
|
||||||
sitemapChanged: boolean;
|
sitemapChanged: boolean;
|
||||||
@@ -726,7 +734,8 @@ export interface ElectronAPI {
|
|||||||
deleteConversation: (id: string) => Promise<boolean>;
|
deleteConversation: (id: string) => Promise<boolean>;
|
||||||
|
|
||||||
// Messaging
|
// Messaging
|
||||||
sendMessage: (conversationId: string, message: string) => Promise<{ success: boolean; message?: string; error?: string }>;
|
sendMessage: (conversationId: string, message: string, metadata?: ChatSendMetadata) => Promise<{ success: boolean; message?: string; error?: string }>;
|
||||||
|
addSystemEvent: (conversationId: string, content: string) => Promise<{ success: boolean; error?: string }>;
|
||||||
abortMessage: (conversationId: string) => Promise<void>;
|
abortMessage: (conversationId: string) => Promise<void>;
|
||||||
getHistory: (conversationId: string) => Promise<ChatMessage[]>;
|
getHistory: (conversationId: string) => Promise<ChatMessage[]>;
|
||||||
clearMessages: (conversationId: string) => Promise<void>;
|
clearMessages: (conversationId: string) => Promise<void>;
|
||||||
@@ -743,6 +752,10 @@ export interface ElectronAPI {
|
|||||||
onToolCall: (callback: (data: ChatToolCall) => void) => () => void;
|
onToolCall: (callback: (data: ChatToolCall) => void) => () => void;
|
||||||
onToolResult: (callback: (data: ChatToolResult) => void) => () => void;
|
onToolResult: (callback: (data: ChatToolResult) => void) => () => void;
|
||||||
onTitleUpdated: (callback: (data: ChatTitleUpdate) => void) => () => void;
|
onTitleUpdated: (callback: (data: ChatTitleUpdate) => void) => () => void;
|
||||||
|
|
||||||
|
// A2UI streaming
|
||||||
|
onA2UIMessage: (callback: (data: { conversationId: string; message: A2UIServerMessage }) => void) => () => void;
|
||||||
|
dispatchA2UIAction: (action: A2UIClientAction) => Promise<{ success: boolean; error?: string }>;
|
||||||
};
|
};
|
||||||
on: (channel: string, callback: (...args: unknown[]) => void) => () => void;
|
on: (channel: string, callback: (...args: unknown[]) => void) => () => void;
|
||||||
once: (channel: string, callback: (...args: unknown[]) => void) => void;
|
once: (channel: string, callback: (...args: unknown[]) => void) => void;
|
||||||
|
|||||||
@@ -24,6 +24,7 @@
|
|||||||
"menu.item.viewMedia": "Medien",
|
"menu.item.viewMedia": "Medien",
|
||||||
"menu.item.toggleSidebar": "Seitenleiste umschalten",
|
"menu.item.toggleSidebar": "Seitenleiste umschalten",
|
||||||
"menu.item.togglePanel": "Panel umschalten",
|
"menu.item.togglePanel": "Panel umschalten",
|
||||||
|
"menu.item.toggleAssistantSidebar": "Assistenz-Seitenleiste umschalten",
|
||||||
"menu.item.toggleDevTools": "Entwicklerwerkzeuge umschalten",
|
"menu.item.toggleDevTools": "Entwicklerwerkzeuge umschalten",
|
||||||
"menu.item.reload": "Neu laden",
|
"menu.item.reload": "Neu laden",
|
||||||
"menu.item.forceReload": "Erzwungen neu laden",
|
"menu.item.forceReload": "Erzwungen neu laden",
|
||||||
|
|||||||
@@ -24,6 +24,7 @@
|
|||||||
"menu.item.viewMedia": "Media",
|
"menu.item.viewMedia": "Media",
|
||||||
"menu.item.toggleSidebar": "Toggle Sidebar",
|
"menu.item.toggleSidebar": "Toggle Sidebar",
|
||||||
"menu.item.togglePanel": "Toggle Panel",
|
"menu.item.togglePanel": "Toggle Panel",
|
||||||
|
"menu.item.toggleAssistantSidebar": "Toggle Assistant Sidebar",
|
||||||
"menu.item.toggleDevTools": "Toggle Developer Tools",
|
"menu.item.toggleDevTools": "Toggle Developer Tools",
|
||||||
"menu.item.reload": "Reload",
|
"menu.item.reload": "Reload",
|
||||||
"menu.item.forceReload": "Force Reload",
|
"menu.item.forceReload": "Force Reload",
|
||||||
|
|||||||
@@ -24,6 +24,7 @@
|
|||||||
"menu.item.viewMedia": "Medios",
|
"menu.item.viewMedia": "Medios",
|
||||||
"menu.item.toggleSidebar": "Alternar barra lateral",
|
"menu.item.toggleSidebar": "Alternar barra lateral",
|
||||||
"menu.item.togglePanel": "Alternar panel",
|
"menu.item.togglePanel": "Alternar panel",
|
||||||
|
"menu.item.toggleAssistantSidebar": "Alternar barra del asistente",
|
||||||
"menu.item.toggleDevTools": "Alternar herramientas de desarrollo",
|
"menu.item.toggleDevTools": "Alternar herramientas de desarrollo",
|
||||||
"menu.item.reload": "Recargar",
|
"menu.item.reload": "Recargar",
|
||||||
"menu.item.forceReload": "Forzar recarga",
|
"menu.item.forceReload": "Forzar recarga",
|
||||||
|
|||||||
@@ -24,6 +24,7 @@
|
|||||||
"menu.item.viewMedia": "Médias",
|
"menu.item.viewMedia": "Médias",
|
||||||
"menu.item.toggleSidebar": "Basculer la barre latérale",
|
"menu.item.toggleSidebar": "Basculer la barre latérale",
|
||||||
"menu.item.togglePanel": "Basculer le panneau",
|
"menu.item.togglePanel": "Basculer le panneau",
|
||||||
|
"menu.item.toggleAssistantSidebar": "Basculer le panneau Assistant",
|
||||||
"menu.item.toggleDevTools": "Basculer les outils de développement",
|
"menu.item.toggleDevTools": "Basculer les outils de développement",
|
||||||
"menu.item.reload": "Recharger",
|
"menu.item.reload": "Recharger",
|
||||||
"menu.item.forceReload": "Forcer le rechargement",
|
"menu.item.forceReload": "Forcer le rechargement",
|
||||||
|
|||||||
@@ -24,6 +24,7 @@
|
|||||||
"menu.item.viewMedia": "Contenuti media",
|
"menu.item.viewMedia": "Contenuti media",
|
||||||
"menu.item.toggleSidebar": "Attiva/disattiva barra laterale",
|
"menu.item.toggleSidebar": "Attiva/disattiva barra laterale",
|
||||||
"menu.item.togglePanel": "Attiva/disattiva pannello",
|
"menu.item.togglePanel": "Attiva/disattiva pannello",
|
||||||
|
"menu.item.toggleAssistantSidebar": "Attiva/disattiva barra assistente",
|
||||||
"menu.item.toggleDevTools": "Attiva/disattiva strumenti sviluppatore",
|
"menu.item.toggleDevTools": "Attiva/disattiva strumenti sviluppatore",
|
||||||
"menu.item.reload": "Ricarica",
|
"menu.item.reload": "Ricarica",
|
||||||
"menu.item.forceReload": "Forza ricaricamento",
|
"menu.item.forceReload": "Forza ricaricamento",
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ export type AppMenuAction =
|
|||||||
| 'viewMedia'
|
| 'viewMedia'
|
||||||
| 'toggleSidebar'
|
| 'toggleSidebar'
|
||||||
| 'togglePanel'
|
| 'togglePanel'
|
||||||
|
| 'toggleAssistantSidebar'
|
||||||
| 'toggleDevTools'
|
| 'toggleDevTools'
|
||||||
| 'reload'
|
| 'reload'
|
||||||
| 'forceReload'
|
| 'forceReload'
|
||||||
@@ -103,6 +104,7 @@ export const APP_MENU_GROUPS: AppMenuGroupDefinition[] = [
|
|||||||
{ label: 'menu.item.viewMedia', action: 'viewMedia', accelerator: 'CmdOrCtrl+2' },
|
{ label: 'menu.item.viewMedia', action: 'viewMedia', accelerator: 'CmdOrCtrl+2' },
|
||||||
{ label: 'menu.item.toggleSidebar', action: 'toggleSidebar', accelerator: 'CmdOrCtrl+B' },
|
{ label: 'menu.item.toggleSidebar', action: 'toggleSidebar', accelerator: 'CmdOrCtrl+B' },
|
||||||
{ label: 'menu.item.togglePanel', action: 'togglePanel', accelerator: 'CmdOrCtrl+J' },
|
{ label: 'menu.item.togglePanel', action: 'togglePanel', accelerator: 'CmdOrCtrl+J' },
|
||||||
|
{ label: 'menu.item.toggleAssistantSidebar', action: 'toggleAssistantSidebar', accelerator: 'CmdOrCtrl+\\' },
|
||||||
{ label: 'menu.item.toggleDevTools', action: 'toggleDevTools', accelerator: 'CmdOrCtrl+Shift+I' },
|
{ label: 'menu.item.toggleDevTools', action: 'toggleDevTools', accelerator: 'CmdOrCtrl+Shift+I' },
|
||||||
{ label: '', action: 'view-separator-1', separator: true },
|
{ label: '', action: 'view-separator-1', separator: true },
|
||||||
{ label: 'menu.item.reload', action: 'reload' },
|
{ label: 'menu.item.reload', action: 'reload' },
|
||||||
@@ -156,6 +158,7 @@ export const APP_MENU_ACTION_EVENT_MAP: Partial<Record<AppMenuAction, string>> =
|
|||||||
viewMedia: 'menu:viewMedia',
|
viewMedia: 'menu:viewMedia',
|
||||||
toggleSidebar: 'menu:toggleSidebar',
|
toggleSidebar: 'menu:toggleSidebar',
|
||||||
togglePanel: 'menu:togglePanel',
|
togglePanel: 'menu:togglePanel',
|
||||||
|
toggleAssistantSidebar: 'menu:toggleAssistantSidebar',
|
||||||
toggleDevTools: 'menu:toggleDevTools',
|
toggleDevTools: 'menu:toggleDevTools',
|
||||||
previewPost: 'menu:previewPost',
|
previewPost: 'menu:previewPost',
|
||||||
publishSelected: 'menu:publishSelected',
|
publishSelected: 'menu:publishSelected',
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useEffect, useRef } from 'react';
|
import React, { useEffect, useRef } from 'react';
|
||||||
import { ActivityBar, Sidebar, Editor, StatusBar, Panel, TabBar, ToastContainer, showToast, ResizablePanel, WindowTitleBar } from './components';
|
import { ActivityBar, Sidebar, Editor, StatusBar, Panel, TabBar, ToastContainer, showToast, ResizablePanel, WindowTitleBar, AssistantSidebar } from './components';
|
||||||
import { useAppStore, PostData, MediaData, TaskProgress } from './store';
|
import { useAppStore, PostData, MediaData, TaskProgress } from './store';
|
||||||
import { loadTabsForProject, saveTabsForProject } from './utils';
|
import { loadTabsForProject, saveTabsForProject } from './utils';
|
||||||
import { openSingletonToolTab } from './navigation/tabPolicy';
|
import { openSingletonToolTab } from './navigation/tabPolicy';
|
||||||
@@ -33,6 +33,7 @@ const App: React.FC = () => {
|
|||||||
setLoading,
|
setLoading,
|
||||||
toggleSidebar,
|
toggleSidebar,
|
||||||
togglePanel,
|
togglePanel,
|
||||||
|
toggleAssistantSidebar,
|
||||||
setActiveView,
|
setActiveView,
|
||||||
setSelectedPost,
|
setSelectedPost,
|
||||||
setActiveProject,
|
setActiveProject,
|
||||||
@@ -307,6 +308,12 @@ const App: React.FC = () => {
|
|||||||
}) || (() => {})
|
}) || (() => {})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
unsubscribers.push(
|
||||||
|
window.electronAPI?.on('menu:toggleAssistantSidebar', () => {
|
||||||
|
toggleAssistantSidebar();
|
||||||
|
}) || (() => {})
|
||||||
|
);
|
||||||
|
|
||||||
unsubscribers.push(
|
unsubscribers.push(
|
||||||
window.electronAPI?.on('menu:viewPosts', () => {
|
window.electronAPI?.on('menu:viewPosts', () => {
|
||||||
const state = useAppStore.getState();
|
const state = useAppStore.getState();
|
||||||
@@ -538,7 +545,7 @@ const App: React.FC = () => {
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const { sidebarVisible } = useAppStore();
|
const { sidebarVisible, assistantSidebarVisible } = useAppStore();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="app">
|
<div className="app">
|
||||||
@@ -562,6 +569,18 @@ const App: React.FC = () => {
|
|||||||
<Editor />
|
<Editor />
|
||||||
<Panel />
|
<Panel />
|
||||||
</div>
|
</div>
|
||||||
|
{assistantSidebarVisible && (
|
||||||
|
<ResizablePanel
|
||||||
|
direction="horizontal"
|
||||||
|
initialSize={360}
|
||||||
|
minSize={280}
|
||||||
|
maxSize={640}
|
||||||
|
storageKey="assistant-sidebar-width"
|
||||||
|
resizerPosition="start"
|
||||||
|
>
|
||||||
|
<AssistantSidebar />
|
||||||
|
</ResizablePanel>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<StatusBar />
|
<StatusBar />
|
||||||
<ToastContainer />
|
<ToastContainer />
|
||||||
|
|||||||
101
src/renderer/a2ui/A2UIRenderer.tsx
Normal file
101
src/renderer/a2ui/A2UIRenderer.tsx
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
/**
|
||||||
|
* A2UI Renderer
|
||||||
|
*
|
||||||
|
* Maps A2UI resolved component trees to React components.
|
||||||
|
* Uses the component catalog to look up renderers for each component type.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import type { A2UIResolvedComponent, A2UIClientAction } from '../../main/a2ui/types';
|
||||||
|
import { A2UIText } from './components/A2UIText';
|
||||||
|
import { A2UIButton } from './components/A2UIButton';
|
||||||
|
import { A2UICard } from './components/A2UICard';
|
||||||
|
import { A2UIChart } from './components/A2UIChart';
|
||||||
|
import { A2UITable } from './components/A2UITable';
|
||||||
|
import { A2UIForm } from './components/A2UIForm';
|
||||||
|
import { A2UITextField } from './components/A2UITextField';
|
||||||
|
import { A2UICheckBox } from './components/A2UICheckBox';
|
||||||
|
import { A2UIDateTimeInput } from './components/A2UIDateTimeInput';
|
||||||
|
import { A2UIChoicePicker } from './components/A2UIChoicePicker';
|
||||||
|
import { A2UIImage } from './components/A2UIImage';
|
||||||
|
import { A2UITabs } from './components/A2UITabs';
|
||||||
|
import { A2UIMetric } from './components/A2UIMetric';
|
||||||
|
import { A2UIList } from './components/A2UIList';
|
||||||
|
import { A2UIRow } from './components/A2UIRow';
|
||||||
|
import { A2UIColumn } from './components/A2UIColumn';
|
||||||
|
import { A2UIDivider } from './components/A2UIDivider';
|
||||||
|
|
||||||
|
export interface A2UIComponentProps {
|
||||||
|
component: A2UIResolvedComponent;
|
||||||
|
surfaceId: string;
|
||||||
|
onAction: (action: A2UIClientAction) => void;
|
||||||
|
onDataChange?: (surfaceId: string, path: string, value: unknown) => void;
|
||||||
|
renderChildren?: (children: A2UIResolvedComponent[]) => React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ComponentRenderer = React.FC<A2UIComponentProps>;
|
||||||
|
|
||||||
|
const COMPONENT_REGISTRY: Record<string, ComponentRenderer> = {
|
||||||
|
text: A2UIText,
|
||||||
|
button: A2UIButton,
|
||||||
|
card: A2UICard,
|
||||||
|
chart: A2UIChart,
|
||||||
|
table: A2UITable,
|
||||||
|
form: A2UIForm,
|
||||||
|
textField: A2UITextField,
|
||||||
|
checkBox: A2UICheckBox,
|
||||||
|
dateTimeInput: A2UIDateTimeInput,
|
||||||
|
choicePicker: A2UIChoicePicker,
|
||||||
|
image: A2UIImage,
|
||||||
|
tabs: A2UITabs,
|
||||||
|
metric: A2UIMetric,
|
||||||
|
list: A2UIList,
|
||||||
|
row: A2UIRow,
|
||||||
|
column: A2UIColumn,
|
||||||
|
divider: A2UIDivider,
|
||||||
|
};
|
||||||
|
|
||||||
|
interface A2UIRendererProps {
|
||||||
|
surfaceId: string;
|
||||||
|
tree: A2UIResolvedComponent[];
|
||||||
|
onAction: (action: A2UIClientAction) => void;
|
||||||
|
onDataChange?: (surfaceId: string, path: string, value: unknown) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const A2UIRenderer: React.FC<A2UIRendererProps> = ({
|
||||||
|
surfaceId,
|
||||||
|
tree,
|
||||||
|
onAction,
|
||||||
|
onDataChange,
|
||||||
|
}) => {
|
||||||
|
const renderComponent = (component: A2UIResolvedComponent): React.ReactNode => {
|
||||||
|
const Renderer = COMPONENT_REGISTRY[component.type];
|
||||||
|
if (!Renderer) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderChildren = (children: A2UIResolvedComponent[]): React.ReactNode =>
|
||||||
|
children.map(renderComponent);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Renderer
|
||||||
|
key={component.id}
|
||||||
|
component={component}
|
||||||
|
surfaceId={surfaceId}
|
||||||
|
onAction={onAction}
|
||||||
|
onDataChange={onDataChange}
|
||||||
|
renderChildren={renderChildren}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (tree.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="a2ui-surface assistant-panel-controls chat-surface-section">
|
||||||
|
{tree.map(renderComponent)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
244
src/renderer/a2ui/A2UISurfaceManager.ts
Normal file
244
src/renderer/a2ui/A2UISurfaceManager.ts
Normal file
@@ -0,0 +1,244 @@
|
|||||||
|
/**
|
||||||
|
* A2UI Surface Manager
|
||||||
|
*
|
||||||
|
* Client-side state manager that processes incoming A2UI server messages
|
||||||
|
* and maintains surface state (component buffer, data model, component tree).
|
||||||
|
*
|
||||||
|
* This is a pure state manager with no React dependency — it can be tested
|
||||||
|
* independently and wrapped by a React hook.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type {
|
||||||
|
A2UIServerMessage,
|
||||||
|
A2UISurfaceState,
|
||||||
|
A2UIResolvedComponent,
|
||||||
|
} from '../../main/a2ui/types';
|
||||||
|
|
||||||
|
export type SurfaceChangeListener = (surfaceId: string) => void;
|
||||||
|
|
||||||
|
export class A2UISurfaceManager {
|
||||||
|
private surfaces = new Map<string, A2UISurfaceState>();
|
||||||
|
private listeners: SurfaceChangeListener[] = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process an incoming A2UI server message.
|
||||||
|
*/
|
||||||
|
processMessage(message: A2UIServerMessage): void {
|
||||||
|
switch (message.type) {
|
||||||
|
case 'createSurface':
|
||||||
|
this.surfaces.set(message.surfaceId, {
|
||||||
|
surfaceId: message.surfaceId,
|
||||||
|
conversationId: message.conversationId,
|
||||||
|
components: new Map(),
|
||||||
|
rootIds: [],
|
||||||
|
dataModel: {},
|
||||||
|
metadata: message.metadata,
|
||||||
|
});
|
||||||
|
this.notify(message.surfaceId);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'updateComponents': {
|
||||||
|
const surface = this.surfaces.get(message.surfaceId);
|
||||||
|
if (!surface) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const component of message.components) {
|
||||||
|
surface.components.set(component.id, component);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.rootIds) {
|
||||||
|
surface.rootIds = message.rootIds;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.notify(message.surfaceId);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'updateDataModel': {
|
||||||
|
const surface = this.surfaces.get(message.surfaceId);
|
||||||
|
if (!surface) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setValueAtPointer(surface.dataModel, message.path, message.value);
|
||||||
|
this.notify(message.surfaceId);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'deleteSurface':
|
||||||
|
this.surfaces.delete(message.surfaceId);
|
||||||
|
this.notify(message.surfaceId);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all active surface IDs for a conversation.
|
||||||
|
*/
|
||||||
|
getSurfaceIds(conversationId: string): string[] {
|
||||||
|
const ids: string[] = [];
|
||||||
|
for (const [surfaceId, state] of this.surfaces) {
|
||||||
|
if (state.conversationId === conversationId) {
|
||||||
|
ids.push(surfaceId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get raw surface state.
|
||||||
|
*/
|
||||||
|
getSurface(surfaceId: string): A2UISurfaceState | undefined {
|
||||||
|
return this.surfaces.get(surfaceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the component tree for a surface.
|
||||||
|
* Converts flat component buffer + ID references into a nested tree.
|
||||||
|
*/
|
||||||
|
resolveTree(surfaceId: string): A2UIResolvedComponent[] {
|
||||||
|
const surface = this.surfaces.get(surfaceId);
|
||||||
|
if (!surface) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return surface.rootIds
|
||||||
|
.map((id) => this.resolveComponent(surface, id))
|
||||||
|
.filter((c): c is A2UIResolvedComponent => c !== null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the local data model value (for input binding).
|
||||||
|
*/
|
||||||
|
updateLocalData(surfaceId: string, path: string, value: unknown): void {
|
||||||
|
const surface = this.surfaces.get(surfaceId);
|
||||||
|
if (!surface) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setValueAtPointer(surface.dataModel, path, value);
|
||||||
|
this.notify(surfaceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the data model for a surface.
|
||||||
|
*/
|
||||||
|
getDataModel(surfaceId: string): Record<string, unknown> {
|
||||||
|
return this.surfaces.get(surfaceId)?.dataModel ?? {};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete all surfaces for a conversation.
|
||||||
|
*/
|
||||||
|
clearConversation(conversationId: string): void {
|
||||||
|
const toDelete: string[] = [];
|
||||||
|
for (const [surfaceId, state] of this.surfaces) {
|
||||||
|
if (state.conversationId === conversationId) {
|
||||||
|
toDelete.push(surfaceId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const surfaceId of toDelete) {
|
||||||
|
this.surfaces.delete(surfaceId);
|
||||||
|
this.notify(surfaceId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscribe to surface changes.
|
||||||
|
*/
|
||||||
|
onChange(listener: SurfaceChangeListener): () => void {
|
||||||
|
this.listeners.push(listener);
|
||||||
|
return () => {
|
||||||
|
this.listeners = this.listeners.filter((l) => l !== listener);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private notify(surfaceId: string): void {
|
||||||
|
for (const listener of this.listeners) {
|
||||||
|
listener(surfaceId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveComponent(
|
||||||
|
surface: A2UISurfaceState,
|
||||||
|
componentId: string,
|
||||||
|
): A2UIResolvedComponent | null {
|
||||||
|
const component = surface.components.get(componentId);
|
||||||
|
if (!component) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const children = (component.children ?? [])
|
||||||
|
.map((childId) => this.resolveComponent(surface, childId))
|
||||||
|
.filter((c): c is A2UIResolvedComponent => c !== null);
|
||||||
|
|
||||||
|
let boundValue: unknown = undefined;
|
||||||
|
if (component.dataBinding) {
|
||||||
|
boundValue = getValueAtPointer(surface.dataModel, component.dataBinding);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: component.id,
|
||||||
|
type: component.type,
|
||||||
|
properties: component.properties,
|
||||||
|
dataBinding: component.dataBinding,
|
||||||
|
boundValue,
|
||||||
|
actions: component.actions,
|
||||||
|
children,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a value from a JSON object using a JSON Pointer (RFC 6901).
|
||||||
|
*/
|
||||||
|
export function getValueAtPointer(
|
||||||
|
obj: Record<string, unknown>,
|
||||||
|
pointer: string,
|
||||||
|
): unknown {
|
||||||
|
if (!pointer || pointer === '/') {
|
||||||
|
return obj;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parts = pointer.split('/').filter(Boolean);
|
||||||
|
let current: unknown = obj;
|
||||||
|
|
||||||
|
for (const part of parts) {
|
||||||
|
const key = part.replace(/~1/g, '/').replace(/~0/g, '~');
|
||||||
|
if (current === null || current === undefined || typeof current !== 'object') {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
current = (current as Record<string, unknown>)[key];
|
||||||
|
}
|
||||||
|
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set a value in a JSON object using a JSON Pointer (RFC 6901).
|
||||||
|
*/
|
||||||
|
export function setValueAtPointer(
|
||||||
|
obj: Record<string, unknown>,
|
||||||
|
pointer: string,
|
||||||
|
value: unknown,
|
||||||
|
): void {
|
||||||
|
if (!pointer || pointer === '/') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parts = pointer.split('/').filter(Boolean);
|
||||||
|
|
||||||
|
let current: Record<string, unknown> = obj;
|
||||||
|
|
||||||
|
for (let i = 0; i < parts.length - 1; i++) {
|
||||||
|
const key = parts[i].replace(/~1/g, '/').replace(/~0/g, '~');
|
||||||
|
if (!current[key] || typeof current[key] !== 'object') {
|
||||||
|
current[key] = {};
|
||||||
|
}
|
||||||
|
current = current[key] as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastKey = parts[parts.length - 1].replace(/~1/g, '/').replace(/~0/g, '~');
|
||||||
|
current[lastKey] = value;
|
||||||
|
}
|
||||||
69
src/renderer/a2ui/InlineSurface.css
Normal file
69
src/renderer/a2ui/InlineSurface.css
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
.inline-surface {
|
||||||
|
margin: 8px 0;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--vscode-editorGroup-border, var(--vscode-panel-border));
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-surface.collapsed {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
background: var(--vscode-textBlockQuote-background, rgba(127, 127, 127, 0.1));
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-surface.collapsed:hover {
|
||||||
|
background: var(--vscode-list-hoverBackground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-surface-icon {
|
||||||
|
font-size: 16px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-surface-title {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--vscode-foreground);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-surface-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-bottom: 1px solid var(--vscode-editorGroup-border, var(--vscode-panel-border));
|
||||||
|
background: var(--vscode-textBlockQuote-background, rgba(127, 127, 127, 0.1));
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-surface-expand,
|
||||||
|
.inline-surface-collapse,
|
||||||
|
.inline-surface-dismiss {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--vscode-descriptionForeground);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 2px 6px;
|
||||||
|
font-size: 14px;
|
||||||
|
border-radius: 4px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-surface-expand:hover,
|
||||||
|
.inline-surface-collapse:hover,
|
||||||
|
.inline-surface-dismiss:hover {
|
||||||
|
background: var(--vscode-toolbar-hoverBackground);
|
||||||
|
color: var(--vscode-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-surface.expanded > .a2ui-surface {
|
||||||
|
border-top: none;
|
||||||
|
padding-top: 0;
|
||||||
|
}
|
||||||
153
src/renderer/a2ui/InlineSurface.tsx
Normal file
153
src/renderer/a2ui/InlineSurface.tsx
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
/**
|
||||||
|
* InlineSurface component.
|
||||||
|
*
|
||||||
|
* Wraps A2UIRenderer with expand/collapse and dismiss controls.
|
||||||
|
* Renders inline within the chat transcript, anchored to the
|
||||||
|
* assistant message turn that created the surface.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import type { A2UIResolvedComponent, A2UIClientAction } from '../../main/a2ui/types';
|
||||||
|
import { A2UIRenderer } from './A2UIRenderer';
|
||||||
|
import './InlineSurface.css';
|
||||||
|
|
||||||
|
interface InlineSurfaceProps {
|
||||||
|
surfaceId: string;
|
||||||
|
tree: A2UIResolvedComponent[];
|
||||||
|
isExpanded: boolean;
|
||||||
|
onDismiss?: (surfaceId: string) => void;
|
||||||
|
onAction?: (action: A2UIClientAction) => void;
|
||||||
|
onDataChange?: (surfaceId: string, path: string, value: unknown) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derive a display title from the surface's component tree.
|
||||||
|
* Tries the root component's `title` or `label` property,
|
||||||
|
* then falls back to the capitalized component type.
|
||||||
|
*/
|
||||||
|
export function deriveSurfaceTitle(tree: A2UIResolvedComponent[]): string {
|
||||||
|
if (tree.length === 0) {
|
||||||
|
return 'Surface';
|
||||||
|
}
|
||||||
|
const root = tree[0];
|
||||||
|
const title = root.properties?.title as string | undefined;
|
||||||
|
if (title) {
|
||||||
|
return title;
|
||||||
|
}
|
||||||
|
const label = root.properties?.label as string | undefined;
|
||||||
|
if (label) {
|
||||||
|
return label;
|
||||||
|
}
|
||||||
|
return root.type.charAt(0).toUpperCase() + root.type.slice(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get an icon character for the surface based on the root component type.
|
||||||
|
*/
|
||||||
|
export function getSurfaceIcon(tree: A2UIResolvedComponent[]): string {
|
||||||
|
if (tree.length === 0) {
|
||||||
|
return '\u25A0';
|
||||||
|
}
|
||||||
|
const type = tree[0].type;
|
||||||
|
const icons: Record<string, string> = {
|
||||||
|
chart: '\u{1F4CA}',
|
||||||
|
table: '\u{1F4CB}',
|
||||||
|
form: '\u{1F4DD}',
|
||||||
|
card: '\u{1F4C4}',
|
||||||
|
metric: '\u{1F4CF}',
|
||||||
|
list: '\u{1F4CB}',
|
||||||
|
tabs: '\u{1F4C2}',
|
||||||
|
};
|
||||||
|
return icons[type] || '\u25A0';
|
||||||
|
}
|
||||||
|
|
||||||
|
export const InlineSurface: React.FC<InlineSurfaceProps> = ({
|
||||||
|
surfaceId,
|
||||||
|
tree,
|
||||||
|
isExpanded: defaultExpanded,
|
||||||
|
onDismiss,
|
||||||
|
onAction,
|
||||||
|
onDataChange,
|
||||||
|
}) => {
|
||||||
|
const [expanded, setExpanded] = useState(defaultExpanded);
|
||||||
|
|
||||||
|
// Auto-collapse/expand when the parent changes which surface is latest
|
||||||
|
useEffect(() => {
|
||||||
|
setExpanded(defaultExpanded);
|
||||||
|
}, [defaultExpanded]);
|
||||||
|
|
||||||
|
const surfaceTitle = deriveSurfaceTitle(tree);
|
||||||
|
const surfaceIcon = getSurfaceIcon(tree);
|
||||||
|
|
||||||
|
if (!expanded) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="inline-surface collapsed"
|
||||||
|
onClick={() => setExpanded(true)}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
setExpanded(true);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="inline-surface-icon">{surfaceIcon}</span>
|
||||||
|
<span className="inline-surface-title">{surfaceTitle}</span>
|
||||||
|
<button
|
||||||
|
className="inline-surface-expand"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setExpanded(true);
|
||||||
|
}}
|
||||||
|
aria-label="Expand surface"
|
||||||
|
>
|
||||||
|
{'\u25B6'}
|
||||||
|
</button>
|
||||||
|
{onDismiss && (
|
||||||
|
<button
|
||||||
|
className="inline-surface-dismiss"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onDismiss(surfaceId);
|
||||||
|
}}
|
||||||
|
aria-label="Dismiss surface"
|
||||||
|
>
|
||||||
|
{'\u2715'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="inline-surface expanded">
|
||||||
|
<div className="inline-surface-header">
|
||||||
|
<span className="inline-surface-icon">{surfaceIcon}</span>
|
||||||
|
<span className="inline-surface-title">{surfaceTitle}</span>
|
||||||
|
<button
|
||||||
|
className="inline-surface-collapse"
|
||||||
|
onClick={() => setExpanded(false)}
|
||||||
|
aria-label="Collapse surface"
|
||||||
|
>
|
||||||
|
{'\u25BC'}
|
||||||
|
</button>
|
||||||
|
{onDismiss && (
|
||||||
|
<button
|
||||||
|
className="inline-surface-dismiss"
|
||||||
|
onClick={() => onDismiss(surfaceId)}
|
||||||
|
aria-label="Dismiss surface"
|
||||||
|
>
|
||||||
|
{'\u2715'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<A2UIRenderer
|
||||||
|
surfaceId={surfaceId}
|
||||||
|
tree={tree}
|
||||||
|
onAction={onAction!}
|
||||||
|
onDataChange={onDataChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
41
src/renderer/a2ui/components/A2UIButton.tsx
Normal file
41
src/renderer/a2ui/components/A2UIButton.tsx
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import type { A2UIResolvedComponent, A2UIClientAction } from '../../../main/a2ui/types';
|
||||||
|
|
||||||
|
interface A2UIComponentProps {
|
||||||
|
component: A2UIResolvedComponent;
|
||||||
|
surfaceId: string;
|
||||||
|
onAction: (action: A2UIClientAction) => void;
|
||||||
|
onDataChange?: (surfaceId: string, path: string, value: unknown) => void;
|
||||||
|
renderChildren?: (children: A2UIResolvedComponent[]) => React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const A2UIButton: React.FC<A2UIComponentProps> = ({ component, surfaceId, onAction }) => {
|
||||||
|
const label = String(component.properties.label ?? '');
|
||||||
|
|
||||||
|
const handleClick = () => {
|
||||||
|
const actionDef = component.actions?.[0];
|
||||||
|
if (!actionDef) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (actionDef.policy === 'confirm' || actionDef.policy === 'danger') {
|
||||||
|
const confirmed = window.confirm(label || actionDef.action);
|
||||||
|
if (!confirmed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onAction({
|
||||||
|
surfaceId,
|
||||||
|
componentId: component.id,
|
||||||
|
action: actionDef.action,
|
||||||
|
payload: actionDef.payload,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button type="button" onClick={handleClick}>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
};
|
||||||
54
src/renderer/a2ui/components/A2UICard.tsx
Normal file
54
src/renderer/a2ui/components/A2UICard.tsx
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import type { A2UIResolvedComponent, A2UIClientAction } from '../../../main/a2ui/types';
|
||||||
|
|
||||||
|
interface A2UIComponentProps {
|
||||||
|
component: A2UIResolvedComponent;
|
||||||
|
surfaceId: string;
|
||||||
|
onAction: (action: A2UIClientAction) => void;
|
||||||
|
onDataChange?: (surfaceId: string, path: string, value: unknown) => void;
|
||||||
|
renderChildren?: (children: A2UIResolvedComponent[]) => React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const A2UICard: React.FC<A2UIComponentProps> = ({ component, surfaceId, onAction }) => {
|
||||||
|
const title = String(component.properties.title ?? '');
|
||||||
|
const body = String(component.properties.body ?? '');
|
||||||
|
const subtitle = component.properties.subtitle as string | undefined;
|
||||||
|
const actions = component.actions ?? [];
|
||||||
|
|
||||||
|
const triggerAction = (actionDef: typeof actions[number]) => {
|
||||||
|
if (actionDef.policy === 'confirm' || actionDef.policy === 'danger') {
|
||||||
|
const confirmed = window.confirm(actionDef.action);
|
||||||
|
if (!confirmed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onAction({
|
||||||
|
surfaceId,
|
||||||
|
componentId: component.id,
|
||||||
|
action: actionDef.action,
|
||||||
|
payload: actionDef.payload,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article className="assistant-panel-card">
|
||||||
|
<h4>{title}</h4>
|
||||||
|
{subtitle && <p className="assistant-panel-card-subtitle">{subtitle}</p>}
|
||||||
|
<p>{body}</p>
|
||||||
|
{actions.length > 0 && (
|
||||||
|
<div className="assistant-panel-card-actions">
|
||||||
|
{actions.map((actionDef, index) => (
|
||||||
|
<button
|
||||||
|
key={`${component.id}-action-${index}`}
|
||||||
|
type="button"
|
||||||
|
onClick={() => triggerAction(actionDef)}
|
||||||
|
>
|
||||||
|
{String(actionDef.payload?.label ?? actionDef.action)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
};
|
||||||
502
src/renderer/a2ui/components/A2UIChart.tsx
Normal file
502
src/renderer/a2ui/components/A2UIChart.tsx
Normal file
@@ -0,0 +1,502 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import type { A2UIResolvedComponent, A2UIClientAction } from '../../../main/a2ui/types';
|
||||||
|
|
||||||
|
interface A2UIComponentProps {
|
||||||
|
component: A2UIResolvedComponent;
|
||||||
|
surfaceId: string;
|
||||||
|
onAction: (action: A2UIClientAction) => void;
|
||||||
|
onDataChange?: (surfaceId: string, path: string, value: unknown) => void;
|
||||||
|
renderChildren?: (children: A2UIResolvedComponent[]) => React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SegmentEntry {
|
||||||
|
label: string;
|
||||||
|
value: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SeriesEntry {
|
||||||
|
label: string;
|
||||||
|
value: number;
|
||||||
|
segments?: SegmentEntry[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const SEGMENT_COLORS = [
|
||||||
|
'var(--vscode-charts-blue, #75beff)',
|
||||||
|
'var(--vscode-charts-green, #89d185)',
|
||||||
|
'var(--vscode-charts-orange, #d18616)',
|
||||||
|
'var(--vscode-charts-red, #f14c4c)',
|
||||||
|
'var(--vscode-charts-purple, #b180d7)',
|
||||||
|
'var(--vscode-charts-yellow, #e2e210)',
|
||||||
|
];
|
||||||
|
|
||||||
|
function getSegmentColor(index: number): string {
|
||||||
|
return SEGMENT_COLORS[index % SEGMENT_COLORS.length];
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Heatmap colour helpers ── */
|
||||||
|
|
||||||
|
type RGB = [number, number, number];
|
||||||
|
|
||||||
|
const FALLBACK_INS: RGB = [53, 117, 56];
|
||||||
|
const FALLBACK_DEL: RGB = [183, 72, 72];
|
||||||
|
|
||||||
|
/** Parse a CSS color value (hex or rgb/rgba) into an [r,g,b] triple. */
|
||||||
|
function parseCssColor(raw: string): RGB | null {
|
||||||
|
const v = raw.trim();
|
||||||
|
const hexMatch = v.match(/^#([0-9a-f]{6})$/i);
|
||||||
|
if (hexMatch) {
|
||||||
|
const hex = hexMatch[1];
|
||||||
|
return [
|
||||||
|
Number.parseInt(hex.slice(0, 2), 16),
|
||||||
|
Number.parseInt(hex.slice(2, 4), 16),
|
||||||
|
Number.parseInt(hex.slice(4, 6), 16),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
const rgbMatch = v.match(/^rgba?\(([^)]+)\)$/i);
|
||||||
|
if (rgbMatch) {
|
||||||
|
const channels = rgbMatch[1].split(',').map((c) => Math.round(Number.parseFloat(c.trim()))).slice(0, 3);
|
||||||
|
if (channels.length === 3 && channels.every((c) => Number.isFinite(c))) {
|
||||||
|
return channels.map((c) => Math.max(0, Math.min(255, c))) as RGB;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function lerpRGB(a: RGB, b: RGB, t: number): RGB {
|
||||||
|
return [
|
||||||
|
Math.round(a[0] + (b[0] - a[0]) * t),
|
||||||
|
Math.round(a[1] + (b[1] - a[1]) * t),
|
||||||
|
Math.round(a[2] + (b[2] - a[2]) * t),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Relative luminance (0-255 scale) for contrast decision. */
|
||||||
|
function luminance(c: RGB): number {
|
||||||
|
return 0.299 * c[0] + 0.587 * c[1] + 0.114 * c[2];
|
||||||
|
}
|
||||||
|
|
||||||
|
function readPicoInsDelColors(): { ins: RGB; del: RGB } {
|
||||||
|
try {
|
||||||
|
const style = window.getComputedStyle(document.documentElement);
|
||||||
|
const ins = parseCssColor(style.getPropertyValue('--pico-ins-color')) ?? FALLBACK_INS;
|
||||||
|
const del = parseCssColor(style.getPropertyValue('--pico-del-color')) ?? FALLBACK_DEL;
|
||||||
|
return { ins, del };
|
||||||
|
} catch {
|
||||||
|
return { ins: FALLBACK_INS, del: FALLBACK_DEL };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Compute heatmap cell background and contrasting text color. */
|
||||||
|
function heatmapCellColors(alpha: number, ins: RGB, del: RGB): { bg: string; fg: string } {
|
||||||
|
if (alpha <= 0) return { bg: 'transparent', fg: 'inherit' };
|
||||||
|
const rgb = lerpRGB(ins, del, alpha);
|
||||||
|
// Scale opacity so even low values are visible (0.25 → 1.0)
|
||||||
|
const opacity = 0.25 + alpha * 0.75;
|
||||||
|
const bg = `rgba(${rgb[0]},${rgb[1]},${rgb[2]},${opacity.toFixed(2)})`;
|
||||||
|
// Blend the effective RGB with the assumed dark background (~30) for contrast calc
|
||||||
|
const effective: RGB = [
|
||||||
|
Math.round(rgb[0] * opacity + 30 * (1 - opacity)),
|
||||||
|
Math.round(rgb[1] * opacity + 30 * (1 - opacity)),
|
||||||
|
Math.round(rgb[2] * opacity + 30 * (1 - opacity)),
|
||||||
|
];
|
||||||
|
const fg = luminance(effective) > 140 ? '#000' : '#fff';
|
||||||
|
return { bg, fg };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Collect unique segment labels across all series entries, preserving order. */
|
||||||
|
function collectSegmentLabels(series: SeriesEntry[]): string[] {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const labels: string[] = [];
|
||||||
|
for (const entry of series) {
|
||||||
|
if (entry.segments) {
|
||||||
|
for (const seg of entry.segments) {
|
||||||
|
if (!seen.has(seg.label)) {
|
||||||
|
seen.add(seg.label);
|
||||||
|
labels.push(seg.label);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return labels;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Generate nice round tick values for the Y-axis. */
|
||||||
|
function computeYTicks(maxValue: number, tickCount: number = 4): number[] {
|
||||||
|
if (maxValue <= 0) return [0];
|
||||||
|
const rawStep = maxValue / (tickCount - 1);
|
||||||
|
const magnitude = Math.pow(10, Math.floor(Math.log10(rawStep)));
|
||||||
|
const normalised = rawStep / magnitude;
|
||||||
|
let niceStep: number;
|
||||||
|
if (normalised <= 1) niceStep = magnitude;
|
||||||
|
else if (normalised <= 2) niceStep = 2 * magnitude;
|
||||||
|
else if (normalised <= 5) niceStep = 5 * magnitude;
|
||||||
|
else niceStep = 10 * magnitude;
|
||||||
|
|
||||||
|
const ticks: number[] = [];
|
||||||
|
for (let v = 0; v <= maxValue + niceStep * 0.01; v += niceStep) {
|
||||||
|
ticks.push(Math.round(v * 1e6) / 1e6);
|
||||||
|
}
|
||||||
|
if (ticks.length < 2) ticks.push(niceStep);
|
||||||
|
return ticks;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LINE_CHART_PADDING = { top: 8, right: 12, bottom: 24, left: 40 };
|
||||||
|
const LINE_CHART_HEIGHT = 140;
|
||||||
|
|
||||||
|
function renderLineChart(component: A2UIResolvedComponent, series: SeriesEntry[], showArea: boolean = false): React.ReactNode {
|
||||||
|
if (series.length === 0) return null;
|
||||||
|
|
||||||
|
const maxValue = Math.max(...series.map((e) => e.value), 0);
|
||||||
|
const yTicks = computeYTicks(maxValue);
|
||||||
|
const yMax = yTicks[yTicks.length - 1];
|
||||||
|
|
||||||
|
const pad = LINE_CHART_PADDING;
|
||||||
|
const plotWidth = 300 - pad.left - pad.right;
|
||||||
|
const plotHeight = LINE_CHART_HEIGHT - pad.top - pad.bottom;
|
||||||
|
|
||||||
|
const xStep = series.length > 1 ? plotWidth / (series.length - 1) : 0;
|
||||||
|
|
||||||
|
const points = series.map((entry, i) => {
|
||||||
|
const x = pad.left + (series.length > 1 ? i * xStep : plotWidth / 2);
|
||||||
|
const y = pad.top + (yMax > 0 ? (1 - entry.value / yMax) * plotHeight : plotHeight);
|
||||||
|
return { x, y, entry };
|
||||||
|
});
|
||||||
|
|
||||||
|
const polylinePoints = points.map((p) => `${p.x},${p.y}`).join(' ');
|
||||||
|
const baselineY = pad.top + plotHeight;
|
||||||
|
const areaPoints = showArea
|
||||||
|
? `${points[0].x},${baselineY} ${polylinePoints} ${points[points.length - 1].x},${baselineY}`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
className="assistant-panel-chart-line-svg"
|
||||||
|
viewBox={`0 0 300 ${LINE_CHART_HEIGHT}`}
|
||||||
|
preserveAspectRatio="xMidYMid meet"
|
||||||
|
>
|
||||||
|
{/* Horizontal grid lines + Y-axis labels */}
|
||||||
|
{yTicks.map((tick) => {
|
||||||
|
const y = pad.top + (yMax > 0 ? (1 - tick / yMax) * plotHeight : plotHeight);
|
||||||
|
return (
|
||||||
|
<g key={`grid-${tick}`}>
|
||||||
|
<line
|
||||||
|
className="assistant-panel-chart-line-grid"
|
||||||
|
x1={pad.left}
|
||||||
|
y1={y}
|
||||||
|
x2={pad.left + plotWidth}
|
||||||
|
y2={y}
|
||||||
|
/>
|
||||||
|
<text
|
||||||
|
className="assistant-panel-chart-line-y-label"
|
||||||
|
x={pad.left - 4}
|
||||||
|
y={y}
|
||||||
|
textAnchor="end"
|
||||||
|
dominantBaseline="middle"
|
||||||
|
>
|
||||||
|
{tick}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Area fill (when showArea is true) */}
|
||||||
|
{showArea && (
|
||||||
|
<polygon
|
||||||
|
className="assistant-panel-chart-area-fill"
|
||||||
|
points={areaPoints}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Line */}
|
||||||
|
<polyline
|
||||||
|
className="assistant-panel-chart-line-path"
|
||||||
|
points={polylinePoints}
|
||||||
|
fill="none"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Dots */}
|
||||||
|
{points.map((p, i) => (
|
||||||
|
<circle
|
||||||
|
key={`${component.id}-dot-${i}`}
|
||||||
|
className="assistant-panel-chart-line-dot"
|
||||||
|
cx={p.x}
|
||||||
|
cy={p.y}
|
||||||
|
r={3}
|
||||||
|
>
|
||||||
|
<title>{`${p.entry.label}: ${p.entry.value}`}</title>
|
||||||
|
</circle>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* X-axis labels */}
|
||||||
|
{points.map((p, i) => (
|
||||||
|
<text
|
||||||
|
key={`${component.id}-xlabel-${i}`}
|
||||||
|
className="assistant-panel-chart-line-x-label"
|
||||||
|
x={p.x}
|
||||||
|
y={LINE_CHART_HEIGHT - 4}
|
||||||
|
textAnchor="middle"
|
||||||
|
>
|
||||||
|
{p.entry.label}
|
||||||
|
</text>
|
||||||
|
))}
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const PIE_CHART_SIZE = 140;
|
||||||
|
const PIE_CHART_RADIUS = 56;
|
||||||
|
const PIE_CHART_CENTER = PIE_CHART_SIZE / 2;
|
||||||
|
|
||||||
|
function describePieSlice(cx: number, cy: number, r: number, startAngle: number, endAngle: number): string {
|
||||||
|
const startRad = (startAngle - 90) * (Math.PI / 180);
|
||||||
|
const endRad = (endAngle - 90) * (Math.PI / 180);
|
||||||
|
const x1 = cx + r * Math.cos(startRad);
|
||||||
|
const y1 = cy + r * Math.sin(startRad);
|
||||||
|
const x2 = cx + r * Math.cos(endRad);
|
||||||
|
const y2 = cy + r * Math.sin(endRad);
|
||||||
|
const largeArc = endAngle - startAngle > 180 ? 1 : 0;
|
||||||
|
return `M${cx},${cy} L${x1},${y1} A${r},${r} 0 ${largeArc} 1 ${x2},${y2} Z`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPieChart(component: A2UIResolvedComponent, series: SeriesEntry[], isDonut: boolean = false): React.ReactNode {
|
||||||
|
if (series.length === 0) return null;
|
||||||
|
|
||||||
|
const total = series.reduce((sum, e) => sum + e.value, 0);
|
||||||
|
if (total <= 0) return null;
|
||||||
|
|
||||||
|
let currentAngle = 0;
|
||||||
|
const slices = series.map((entry, i) => {
|
||||||
|
const sliceAngle = (entry.value / total) * 360;
|
||||||
|
const startAngle = currentAngle;
|
||||||
|
const endAngle = currentAngle + sliceAngle;
|
||||||
|
currentAngle = endAngle;
|
||||||
|
|
||||||
|
// For a full circle (single slice), use a circle element instead
|
||||||
|
if (sliceAngle >= 359.99) {
|
||||||
|
return (
|
||||||
|
<circle
|
||||||
|
key={`${component.id}-slice-${i}`}
|
||||||
|
className="assistant-panel-chart-pie-slice"
|
||||||
|
cx={PIE_CHART_CENTER}
|
||||||
|
cy={PIE_CHART_CENTER}
|
||||||
|
r={PIE_CHART_RADIUS}
|
||||||
|
fill={getSegmentColor(i)}
|
||||||
|
>
|
||||||
|
<title>{`${entry.label}: ${entry.value}`}</title>
|
||||||
|
</circle>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<path
|
||||||
|
key={`${component.id}-slice-${i}`}
|
||||||
|
className="assistant-panel-chart-pie-slice"
|
||||||
|
d={describePieSlice(PIE_CHART_CENTER, PIE_CHART_CENTER, PIE_CHART_RADIUS, startAngle, endAngle)}
|
||||||
|
fill={getSegmentColor(i)}
|
||||||
|
>
|
||||||
|
<title>{`${entry.label}: ${entry.value}`}</title>
|
||||||
|
</path>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const donutInnerRadius = PIE_CHART_RADIUS * 0.58;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<svg
|
||||||
|
className="assistant-panel-chart-pie-svg"
|
||||||
|
viewBox={`0 0 ${PIE_CHART_SIZE} ${PIE_CHART_SIZE}`}
|
||||||
|
preserveAspectRatio="xMidYMid meet"
|
||||||
|
>
|
||||||
|
{slices}
|
||||||
|
{isDonut && (
|
||||||
|
<>
|
||||||
|
<circle
|
||||||
|
className="assistant-panel-chart-donut-hole"
|
||||||
|
cx={PIE_CHART_CENTER}
|
||||||
|
cy={PIE_CHART_CENTER}
|
||||||
|
r={donutInnerRadius}
|
||||||
|
/>
|
||||||
|
<text
|
||||||
|
className="assistant-panel-chart-donut-total"
|
||||||
|
x={PIE_CHART_CENTER}
|
||||||
|
y={PIE_CHART_CENTER}
|
||||||
|
textAnchor="middle"
|
||||||
|
dominantBaseline="central"
|
||||||
|
>
|
||||||
|
{total}
|
||||||
|
</text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</svg>
|
||||||
|
<div className="assistant-panel-chart-legend">
|
||||||
|
{series.map((entry, i) => (
|
||||||
|
<span key={entry.label} className="assistant-panel-chart-legend-item">
|
||||||
|
<span
|
||||||
|
className="assistant-panel-chart-legend-swatch"
|
||||||
|
style={{ backgroundColor: getSegmentColor(i) }}
|
||||||
|
/>
|
||||||
|
{entry.label}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderHeatmap(component: A2UIResolvedComponent, series: SeriesEntry[]): React.ReactNode {
|
||||||
|
// Heatmap requires segments — each entry is a row, each segment is a column
|
||||||
|
const rows = series.filter((e) => e.segments && e.segments.length > 0);
|
||||||
|
if (rows.length === 0) return null;
|
||||||
|
|
||||||
|
const columnLabels = collectSegmentLabels(rows);
|
||||||
|
if (columnLabels.length === 0) return null;
|
||||||
|
|
||||||
|
// Find global max for normalisation
|
||||||
|
let globalMax = 0;
|
||||||
|
for (const row of rows) {
|
||||||
|
for (const seg of row.segments!) {
|
||||||
|
if (seg.value > globalMax) globalMax = seg.value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const { ins, del } = readPicoInsDelColors();
|
||||||
|
|
||||||
|
// Build a lookup for each row's segment values by column label
|
||||||
|
const columnCount = columnLabels.length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="assistant-panel-chart-heatmap"
|
||||||
|
style={{
|
||||||
|
gridTemplateColumns: `auto repeat(${columnCount}, 1fr)`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Header row: empty corner + column labels */}
|
||||||
|
<span className="assistant-panel-chart-heatmap-corner" />
|
||||||
|
{columnLabels.map((col) => (
|
||||||
|
<span key={`col-${col}`} className="assistant-panel-chart-heatmap-col-label">
|
||||||
|
{col}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Data rows */}
|
||||||
|
{rows.map((row) => {
|
||||||
|
const segMap = new Map<string, number>();
|
||||||
|
for (const seg of row.segments!) {
|
||||||
|
segMap.set(seg.label, seg.value);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<React.Fragment key={`${component.id}-row-${row.label}`}>
|
||||||
|
<span className="assistant-panel-chart-heatmap-row-label">{row.label}</span>
|
||||||
|
{columnLabels.map((col) => {
|
||||||
|
const val = segMap.get(col) ?? 0;
|
||||||
|
const alpha = globalMax > 0 ? val / globalMax : 0;
|
||||||
|
const { bg, fg } = heatmapCellColors(alpha, ins, del);
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
key={`${component.id}-cell-${row.label}-${col}`}
|
||||||
|
className="assistant-panel-chart-heatmap-cell"
|
||||||
|
style={{ background: bg, color: fg }}
|
||||||
|
title={String(val)}
|
||||||
|
>
|
||||||
|
{val > 0 ? val : ''}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const A2UIChart: React.FC<A2UIComponentProps> = ({ component }) => {
|
||||||
|
const chartType = String(component.properties.chartType ?? 'bar');
|
||||||
|
const title = component.properties.title as string | undefined;
|
||||||
|
const series = (component.boundValue as SeriesEntry[]) ?? (component.properties.series as SeriesEntry[]) ?? [];
|
||||||
|
const isStacked = chartType === 'stacked-bar';
|
||||||
|
|
||||||
|
const maxValue = Math.max(
|
||||||
|
...series.map((entry) => {
|
||||||
|
if (isStacked && entry.segments) {
|
||||||
|
return entry.segments.reduce((sum, s) => sum + s.value, 0);
|
||||||
|
}
|
||||||
|
return entry.value;
|
||||||
|
}),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
|
||||||
|
const segmentLabels = isStacked ? collectSegmentLabels(series) : [];
|
||||||
|
|
||||||
|
const isLine = chartType === 'line';
|
||||||
|
const isArea = chartType === 'area';
|
||||||
|
const isPie = chartType === 'pie';
|
||||||
|
const isDonut = chartType === 'donut';
|
||||||
|
const isHeatmap = chartType === 'heatmap';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="assistant-panel-chart">
|
||||||
|
{title && <p className="assistant-panel-chart-title">{title}</p>}
|
||||||
|
<div className="assistant-panel-chart-type">{chartType}</div>
|
||||||
|
{isPie || isDonut ? (
|
||||||
|
renderPieChart(component, series, isDonut)
|
||||||
|
) : isHeatmap ? (
|
||||||
|
renderHeatmap(component, series)
|
||||||
|
) : isLine || isArea ? (
|
||||||
|
renderLineChart(component, series, isArea)
|
||||||
|
) : (
|
||||||
|
<div className="assistant-panel-chart-body">
|
||||||
|
{series.map((entry, index) => {
|
||||||
|
const totalValue = isStacked && entry.segments
|
||||||
|
? entry.segments.reduce((sum, s) => sum + s.value, 0)
|
||||||
|
: entry.value;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={`${component.id}-series-${index}`} className="assistant-panel-chart-item">
|
||||||
|
<span className="assistant-panel-chart-label">{entry.label}</span>
|
||||||
|
<div className="assistant-panel-chart-bar-track">
|
||||||
|
{isStacked && entry.segments ? (
|
||||||
|
entry.segments.map((seg, si) => {
|
||||||
|
const segWidth = maxValue > 0 ? (seg.value / maxValue) * 100 : 0;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={`${component.id}-seg-${index}-${si}`}
|
||||||
|
className="assistant-panel-chart-bar-segment"
|
||||||
|
style={{
|
||||||
|
width: `${segWidth}%`,
|
||||||
|
backgroundColor: getSegmentColor(segmentLabels.indexOf(seg.label)),
|
||||||
|
}}
|
||||||
|
title={`${seg.label}: ${seg.value}`}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className="assistant-panel-chart-bar-fill"
|
||||||
|
style={{ width: `${maxValue > 0 ? (entry.value / maxValue) * 100 : 0}%` }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className="assistant-panel-chart-value">{totalValue}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{isStacked && segmentLabels.length > 0 && (
|
||||||
|
<div className="assistant-panel-chart-legend">
|
||||||
|
{segmentLabels.map((label, i) => (
|
||||||
|
<span key={label} className="assistant-panel-chart-legend-item">
|
||||||
|
<span
|
||||||
|
className="assistant-panel-chart-legend-swatch"
|
||||||
|
style={{ backgroundColor: getSegmentColor(i) }}
|
||||||
|
/>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
32
src/renderer/a2ui/components/A2UICheckBox.tsx
Normal file
32
src/renderer/a2ui/components/A2UICheckBox.tsx
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import type { A2UIResolvedComponent, A2UIClientAction } from '../../../main/a2ui/types';
|
||||||
|
|
||||||
|
interface A2UIComponentProps {
|
||||||
|
component: A2UIResolvedComponent;
|
||||||
|
surfaceId: string;
|
||||||
|
onAction: (action: A2UIClientAction) => void;
|
||||||
|
onDataChange?: (surfaceId: string, path: string, value: unknown) => void;
|
||||||
|
renderChildren?: (children: A2UIResolvedComponent[]) => React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const A2UICheckBox: React.FC<A2UIComponentProps> = ({ component, surfaceId, onDataChange }) => {
|
||||||
|
const label = String(component.properties.label ?? '');
|
||||||
|
const checked = Boolean(component.boundValue ?? false);
|
||||||
|
|
||||||
|
const handleChange = (newChecked: boolean) => {
|
||||||
|
if (onDataChange && component.dataBinding) {
|
||||||
|
onDataChange(surfaceId, component.dataBinding, newChecked);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<label className="assistant-panel-checkbox">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={checked}
|
||||||
|
onChange={(e) => handleChange(e.target.checked)}
|
||||||
|
/>
|
||||||
|
<span>{label}</span>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
};
|
||||||
44
src/renderer/a2ui/components/A2UIChoicePicker.tsx
Normal file
44
src/renderer/a2ui/components/A2UIChoicePicker.tsx
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import type { A2UIResolvedComponent, A2UIClientAction } from '../../../main/a2ui/types';
|
||||||
|
|
||||||
|
interface A2UIComponentProps {
|
||||||
|
component: A2UIResolvedComponent;
|
||||||
|
surfaceId: string;
|
||||||
|
onAction: (action: A2UIClientAction) => void;
|
||||||
|
onDataChange?: (surfaceId: string, path: string, value: unknown) => void;
|
||||||
|
renderChildren?: (children: A2UIResolvedComponent[]) => React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ChoiceOption {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const A2UIChoicePicker: React.FC<A2UIComponentProps> = ({ component, surfaceId, onDataChange }) => {
|
||||||
|
const label = String(component.properties.label ?? '');
|
||||||
|
const options = (component.properties.options as ChoiceOption[]) ?? [];
|
||||||
|
const value = String(component.boundValue ?? options[0]?.value ?? '');
|
||||||
|
|
||||||
|
const handleChange = (newValue: string) => {
|
||||||
|
if (onDataChange && component.dataBinding) {
|
||||||
|
onDataChange(surfaceId, component.dataBinding, newValue);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="assistant-panel-widget-block">
|
||||||
|
<label className="assistant-panel-widget-label">{label}</label>
|
||||||
|
<select
|
||||||
|
className="assistant-panel-widget-input chat-surface-input"
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => handleChange(e.target.value)}
|
||||||
|
>
|
||||||
|
{options.map((option) => (
|
||||||
|
<option key={`${component.id}-opt-${option.value}`} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
18
src/renderer/a2ui/components/A2UIColumn.tsx
Normal file
18
src/renderer/a2ui/components/A2UIColumn.tsx
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import type { A2UIResolvedComponent, A2UIClientAction } from '../../../main/a2ui/types';
|
||||||
|
|
||||||
|
interface A2UIComponentProps {
|
||||||
|
component: A2UIResolvedComponent;
|
||||||
|
surfaceId: string;
|
||||||
|
onAction: (action: A2UIClientAction) => void;
|
||||||
|
onDataChange?: (surfaceId: string, path: string, value: unknown) => void;
|
||||||
|
renderChildren?: (children: A2UIResolvedComponent[]) => React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const A2UIColumn: React.FC<A2UIComponentProps> = ({ component, renderChildren }) => {
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||||
|
{renderChildren?.(component.children)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
37
src/renderer/a2ui/components/A2UIDateTimeInput.tsx
Normal file
37
src/renderer/a2ui/components/A2UIDateTimeInput.tsx
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import type { A2UIResolvedComponent, A2UIClientAction } from '../../../main/a2ui/types';
|
||||||
|
|
||||||
|
interface A2UIComponentProps {
|
||||||
|
component: A2UIResolvedComponent;
|
||||||
|
surfaceId: string;
|
||||||
|
onAction: (action: A2UIClientAction) => void;
|
||||||
|
onDataChange?: (surfaceId: string, path: string, value: unknown) => void;
|
||||||
|
renderChildren?: (children: A2UIResolvedComponent[]) => React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const A2UIDateTimeInput: React.FC<A2UIComponentProps> = ({ component, surfaceId, onDataChange }) => {
|
||||||
|
const label = String(component.properties.label ?? '');
|
||||||
|
const min = component.properties.min as string | undefined;
|
||||||
|
const max = component.properties.max as string | undefined;
|
||||||
|
const value = String(component.boundValue ?? '');
|
||||||
|
|
||||||
|
const handleChange = (newValue: string) => {
|
||||||
|
if (onDataChange && component.dataBinding) {
|
||||||
|
onDataChange(surfaceId, component.dataBinding, newValue);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="assistant-panel-widget-block">
|
||||||
|
<label className="assistant-panel-widget-label">{label}</label>
|
||||||
|
<input
|
||||||
|
className="assistant-panel-widget-input chat-surface-input"
|
||||||
|
type="date"
|
||||||
|
value={value}
|
||||||
|
min={min}
|
||||||
|
max={max}
|
||||||
|
onChange={(e) => handleChange(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
14
src/renderer/a2ui/components/A2UIDivider.tsx
Normal file
14
src/renderer/a2ui/components/A2UIDivider.tsx
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import type { A2UIResolvedComponent, A2UIClientAction } from '../../../main/a2ui/types';
|
||||||
|
|
||||||
|
interface A2UIComponentProps {
|
||||||
|
component: A2UIResolvedComponent;
|
||||||
|
surfaceId: string;
|
||||||
|
onAction: (action: A2UIClientAction) => void;
|
||||||
|
onDataChange?: (surfaceId: string, path: string, value: unknown) => void;
|
||||||
|
renderChildren?: (children: A2UIResolvedComponent[]) => React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const A2UIDivider: React.FC<A2UIComponentProps> = () => {
|
||||||
|
return <hr />;
|
||||||
|
};
|
||||||
21
src/renderer/a2ui/components/A2UIForm.tsx
Normal file
21
src/renderer/a2ui/components/A2UIForm.tsx
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import type { A2UIResolvedComponent, A2UIClientAction } from '../../../main/a2ui/types';
|
||||||
|
|
||||||
|
interface A2UIComponentProps {
|
||||||
|
component: A2UIResolvedComponent;
|
||||||
|
surfaceId: string;
|
||||||
|
onAction: (action: A2UIClientAction) => void;
|
||||||
|
onDataChange?: (surfaceId: string, path: string, value: unknown) => void;
|
||||||
|
renderChildren?: (children: A2UIResolvedComponent[]) => React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const A2UIForm: React.FC<A2UIComponentProps> = ({ component, renderChildren }) => {
|
||||||
|
const title = component.properties.title as string | undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="assistant-panel-form">
|
||||||
|
{title && <p className="assistant-panel-form-title">{title}</p>}
|
||||||
|
{renderChildren?.(component.children)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
48
src/renderer/a2ui/components/A2UIImage.tsx
Normal file
48
src/renderer/a2ui/components/A2UIImage.tsx
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import type { A2UIResolvedComponent, A2UIClientAction } from '../../../main/a2ui/types';
|
||||||
|
|
||||||
|
interface A2UIComponentProps {
|
||||||
|
component: A2UIResolvedComponent;
|
||||||
|
surfaceId: string;
|
||||||
|
onAction: (action: A2UIClientAction) => void;
|
||||||
|
onDataChange?: (surfaceId: string, path: string, value: unknown) => void;
|
||||||
|
renderChildren?: (children: A2UIResolvedComponent[]) => React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const A2UIImage: React.FC<A2UIComponentProps> = ({ component, surfaceId, onAction }) => {
|
||||||
|
const src = String(component.properties.src ?? '');
|
||||||
|
const alt = String(component.properties.alt ?? '');
|
||||||
|
const caption = component.properties.caption as string | undefined;
|
||||||
|
const actionDef = component.actions?.[0];
|
||||||
|
|
||||||
|
const handleClick = () => {
|
||||||
|
if (!actionDef) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (actionDef.policy === 'confirm' || actionDef.policy === 'danger') {
|
||||||
|
const confirmed = window.confirm(caption || alt || actionDef.action);
|
||||||
|
if (!confirmed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onAction({
|
||||||
|
surfaceId,
|
||||||
|
componentId: component.id,
|
||||||
|
action: actionDef.action,
|
||||||
|
payload: actionDef.payload,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<figure className="assistant-panel-image">
|
||||||
|
<img
|
||||||
|
src={src}
|
||||||
|
alt={alt}
|
||||||
|
onClick={handleClick}
|
||||||
|
/>
|
||||||
|
{caption && <figcaption>{caption}</figcaption>}
|
||||||
|
</figure>
|
||||||
|
);
|
||||||
|
};
|
||||||
26
src/renderer/a2ui/components/A2UIList.tsx
Normal file
26
src/renderer/a2ui/components/A2UIList.tsx
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import type { A2UIResolvedComponent, A2UIClientAction } from '../../../main/a2ui/types';
|
||||||
|
|
||||||
|
interface A2UIComponentProps {
|
||||||
|
component: A2UIResolvedComponent;
|
||||||
|
surfaceId: string;
|
||||||
|
onAction: (action: A2UIClientAction) => void;
|
||||||
|
onDataChange?: (surfaceId: string, path: string, value: unknown) => void;
|
||||||
|
renderChildren?: (children: A2UIResolvedComponent[]) => React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const A2UIList: React.FC<A2UIComponentProps> = ({ component }) => {
|
||||||
|
const title = component.properties.title as string | undefined;
|
||||||
|
const items = (component.boundValue as string[]) ?? (component.properties.items as string[]) ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{title && <p>{title}</p>}
|
||||||
|
<ul>
|
||||||
|
{items.map((item, index) => (
|
||||||
|
<li key={`${component.id}-item-${index}`}>{item}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
22
src/renderer/a2ui/components/A2UIMetric.tsx
Normal file
22
src/renderer/a2ui/components/A2UIMetric.tsx
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import type { A2UIResolvedComponent, A2UIClientAction } from '../../../main/a2ui/types';
|
||||||
|
|
||||||
|
interface A2UIComponentProps {
|
||||||
|
component: A2UIResolvedComponent;
|
||||||
|
surfaceId: string;
|
||||||
|
onAction: (action: A2UIClientAction) => void;
|
||||||
|
onDataChange?: (surfaceId: string, path: string, value: unknown) => void;
|
||||||
|
renderChildren?: (children: A2UIResolvedComponent[]) => React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const A2UIMetric: React.FC<A2UIComponentProps> = ({ component }) => {
|
||||||
|
const label = String(component.properties.label ?? '');
|
||||||
|
const value = String(component.properties.value ?? '');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="assistant-panel-metric">
|
||||||
|
<span className="assistant-panel-metric-label">{label}</span>
|
||||||
|
<strong className="assistant-panel-metric-value">{value}</strong>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
18
src/renderer/a2ui/components/A2UIRow.tsx
Normal file
18
src/renderer/a2ui/components/A2UIRow.tsx
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import type { A2UIResolvedComponent, A2UIClientAction } from '../../../main/a2ui/types';
|
||||||
|
|
||||||
|
interface A2UIComponentProps {
|
||||||
|
component: A2UIResolvedComponent;
|
||||||
|
surfaceId: string;
|
||||||
|
onAction: (action: A2UIClientAction) => void;
|
||||||
|
onDataChange?: (surfaceId: string, path: string, value: unknown) => void;
|
||||||
|
renderChildren?: (children: A2UIResolvedComponent[]) => React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const A2UIRow: React.FC<A2UIComponentProps> = ({ component, renderChildren }) => {
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'row', gap: '8px' }}>
|
||||||
|
{renderChildren?.(component.children)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
40
src/renderer/a2ui/components/A2UITable.tsx
Normal file
40
src/renderer/a2ui/components/A2UITable.tsx
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import type { A2UIResolvedComponent, A2UIClientAction } from '../../../main/a2ui/types';
|
||||||
|
|
||||||
|
interface A2UIComponentProps {
|
||||||
|
component: A2UIResolvedComponent;
|
||||||
|
surfaceId: string;
|
||||||
|
onAction: (action: A2UIClientAction) => void;
|
||||||
|
onDataChange?: (surfaceId: string, path: string, value: unknown) => void;
|
||||||
|
renderChildren?: (children: A2UIResolvedComponent[]) => React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const A2UITable: React.FC<A2UIComponentProps> = ({ component }) => {
|
||||||
|
const columns = (component.properties.columns as string[]) ?? [];
|
||||||
|
const rows = (component.boundValue as string[][]) ?? (component.properties.rows as string[][]) ?? [];
|
||||||
|
const title = component.properties.title as string | undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{title && <p>{title}</p>}
|
||||||
|
<table className="assistant-panel-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
{columns.map((column, colIndex) => (
|
||||||
|
<th key={`${component.id}-col-${colIndex}`}>{column}</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{rows.map((row, rowIndex) => (
|
||||||
|
<tr key={`${component.id}-row-${rowIndex}`}>
|
||||||
|
{row.map((cell, cellIndex) => (
|
||||||
|
<td key={`${component.id}-cell-${rowIndex}-${cellIndex}`}>{cell}</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
37
src/renderer/a2ui/components/A2UITabs.tsx
Normal file
37
src/renderer/a2ui/components/A2UITabs.tsx
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import type { A2UIResolvedComponent, A2UIClientAction } from '../../../main/a2ui/types';
|
||||||
|
|
||||||
|
interface A2UIComponentProps {
|
||||||
|
component: A2UIResolvedComponent;
|
||||||
|
surfaceId: string;
|
||||||
|
onAction: (action: A2UIClientAction) => void;
|
||||||
|
onDataChange?: (surfaceId: string, path: string, value: unknown) => void;
|
||||||
|
renderChildren?: (children: A2UIResolvedComponent[]) => React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const A2UITabs: React.FC<A2UIComponentProps> = ({ component, renderChildren }) => {
|
||||||
|
const tabLabels = (component.properties.tabLabels as string[]) ?? [];
|
||||||
|
const [activeTab, setActiveTab] = useState(0);
|
||||||
|
|
||||||
|
const children = component.children;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="assistant-panel-tabs">
|
||||||
|
<div className="assistant-panel-tab-strip">
|
||||||
|
{tabLabels.map((label, index) => (
|
||||||
|
<button
|
||||||
|
key={`${component.id}-tab-${index}`}
|
||||||
|
type="button"
|
||||||
|
className={`assistant-panel-tab-button ${index === activeTab ? 'active' : ''}`}
|
||||||
|
onClick={() => setActiveTab(index)}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="assistant-panel-tab-panel">
|
||||||
|
{children[activeTab] && renderChildren?.([children[activeTab]])}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
16
src/renderer/a2ui/components/A2UIText.tsx
Normal file
16
src/renderer/a2ui/components/A2UIText.tsx
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import Markdown from 'marked-react';
|
||||||
|
import type { A2UIResolvedComponent, A2UIClientAction } from '../../../main/a2ui/types';
|
||||||
|
|
||||||
|
interface A2UIComponentProps {
|
||||||
|
component: A2UIResolvedComponent;
|
||||||
|
surfaceId: string;
|
||||||
|
onAction: (action: A2UIClientAction) => void;
|
||||||
|
onDataChange?: (surfaceId: string, path: string, value: unknown) => void;
|
||||||
|
renderChildren?: (children: A2UIResolvedComponent[]) => React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const A2UIText: React.FC<A2UIComponentProps> = ({ component }) => {
|
||||||
|
const text = String(component.properties.text ?? '');
|
||||||
|
return <Markdown>{text}</Markdown>;
|
||||||
|
};
|
||||||
51
src/renderer/a2ui/components/A2UITextField.tsx
Normal file
51
src/renderer/a2ui/components/A2UITextField.tsx
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import type { A2UIResolvedComponent, A2UIClientAction } from '../../../main/a2ui/types';
|
||||||
|
|
||||||
|
interface A2UIComponentProps {
|
||||||
|
component: A2UIResolvedComponent;
|
||||||
|
surfaceId: string;
|
||||||
|
onAction: (action: A2UIClientAction) => void;
|
||||||
|
onDataChange?: (surfaceId: string, path: string, value: unknown) => void;
|
||||||
|
renderChildren?: (children: A2UIResolvedComponent[]) => React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const A2UITextField: React.FC<A2UIComponentProps> = ({ component, surfaceId, onDataChange }) => {
|
||||||
|
const label = String(component.properties.label ?? '');
|
||||||
|
const placeholder = (component.properties.placeholder as string) ?? '';
|
||||||
|
const inputType = component.properties.inputType as string | undefined;
|
||||||
|
const value = String(component.boundValue ?? '');
|
||||||
|
|
||||||
|
const handleChange = (newValue: string) => {
|
||||||
|
if (onDataChange && component.dataBinding) {
|
||||||
|
onDataChange(surfaceId, component.dataBinding, newValue);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (inputType === 'textarea') {
|
||||||
|
return (
|
||||||
|
<div className="assistant-panel-widget-block">
|
||||||
|
<label className="assistant-panel-widget-label">{label}</label>
|
||||||
|
<textarea
|
||||||
|
className="assistant-panel-widget-input chat-surface-input"
|
||||||
|
value={value}
|
||||||
|
placeholder={placeholder}
|
||||||
|
onChange={(e) => handleChange(e.target.value)}
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="assistant-panel-widget-block">
|
||||||
|
<label className="assistant-panel-widget-label">{label}</label>
|
||||||
|
<input
|
||||||
|
className="assistant-panel-widget-input chat-surface-input"
|
||||||
|
type="text"
|
||||||
|
value={value}
|
||||||
|
placeholder={placeholder}
|
||||||
|
onChange={(e) => handleChange(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
17
src/renderer/a2ui/components/index.ts
Normal file
17
src/renderer/a2ui/components/index.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
export { A2UIText } from './A2UIText';
|
||||||
|
export { A2UIButton } from './A2UIButton';
|
||||||
|
export { A2UICard } from './A2UICard';
|
||||||
|
export { A2UIChart } from './A2UIChart';
|
||||||
|
export { A2UITable } from './A2UITable';
|
||||||
|
export { A2UIForm } from './A2UIForm';
|
||||||
|
export { A2UITextField } from './A2UITextField';
|
||||||
|
export { A2UICheckBox } from './A2UICheckBox';
|
||||||
|
export { A2UIDateTimeInput } from './A2UIDateTimeInput';
|
||||||
|
export { A2UIChoicePicker } from './A2UIChoicePicker';
|
||||||
|
export { A2UIImage } from './A2UIImage';
|
||||||
|
export { A2UITabs } from './A2UITabs';
|
||||||
|
export { A2UIMetric } from './A2UIMetric';
|
||||||
|
export { A2UIList } from './A2UIList';
|
||||||
|
export { A2UIRow } from './A2UIRow';
|
||||||
|
export { A2UIColumn } from './A2UIColumn';
|
||||||
|
export { A2UIDivider } from './A2UIDivider';
|
||||||
99
src/renderer/a2ui/surfaceAssociation.ts
Normal file
99
src/renderer/a2ui/surfaceAssociation.ts
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
/**
|
||||||
|
* Surface-to-message association utilities.
|
||||||
|
*
|
||||||
|
* Computes the turn index for a message based on its position
|
||||||
|
* in the message array, enabling inline surface rendering.
|
||||||
|
*
|
||||||
|
* Also provides replay logic to reconstruct A2UI surfaces from
|
||||||
|
* persisted tool calls when reloading a saved conversation.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { ChatMessage } from '../../main/shared/electronApi';
|
||||||
|
import type { A2UIServerMessage } from '../../main/a2ui/types';
|
||||||
|
import { isRenderTool, generateFromToolCall } from '../../main/a2ui/generator';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute the turn index for a message at a given position.
|
||||||
|
*
|
||||||
|
* Turn index is defined as the 0-based count of user messages
|
||||||
|
* seen up to and including the given position, minus 1.
|
||||||
|
* System and tool messages do not affect the count.
|
||||||
|
*
|
||||||
|
* Returns -1 if no user message has been seen at or before the index.
|
||||||
|
*/
|
||||||
|
export function computeTurnIndex(messages: ChatMessage[], currentIndex: number): number {
|
||||||
|
let userCount = 0;
|
||||||
|
for (let i = 0; i <= currentIndex; i++) {
|
||||||
|
if (messages[i].role === 'user') {
|
||||||
|
userCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return userCount - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StoredToolCall {
|
||||||
|
name: string;
|
||||||
|
args?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replay A2UI surfaces from persisted chat messages.
|
||||||
|
*
|
||||||
|
* Scans assistant messages for render tool calls stored in their
|
||||||
|
* `toolCalls` JSON field and regenerates the A2UI server messages
|
||||||
|
* needed to reconstruct the surfaces in the manager.
|
||||||
|
*
|
||||||
|
* @param conversationId - The conversation ID for surface creation
|
||||||
|
* @param messages - The full ordered message history from the database
|
||||||
|
* @returns Array of A2UI server messages to feed into A2UISurfaceManager
|
||||||
|
*/
|
||||||
|
export function replaySurfacesFromMessages(
|
||||||
|
conversationId: string,
|
||||||
|
messages: ChatMessage[],
|
||||||
|
): A2UIServerMessage[] {
|
||||||
|
const allA2UIMessages: A2UIServerMessage[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < messages.length; i++) {
|
||||||
|
const message = messages[i];
|
||||||
|
if (message.role !== 'assistant' || !message.toolCalls) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let toolCalls: StoredToolCall[];
|
||||||
|
try {
|
||||||
|
toolCalls = JSON.parse(message.toolCalls) as StoredToolCall[];
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Array.isArray(toolCalls)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const turnIndex = computeTurnIndex(messages, i);
|
||||||
|
|
||||||
|
for (const toolCall of toolCalls) {
|
||||||
|
if (!isRenderTool(toolCall.name)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const a2uiMessages = generateFromToolCall(
|
||||||
|
conversationId,
|
||||||
|
toolCall.name,
|
||||||
|
toolCall.args ?? {},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (a2uiMessages) {
|
||||||
|
// Inject turnIndex into createSurface metadata, matching live behavior
|
||||||
|
for (const msg of a2uiMessages) {
|
||||||
|
if (msg.type === 'createSurface') {
|
||||||
|
msg.metadata = { ...msg.metadata, turnIndex };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
allA2UIMessages.push(...a2uiMessages);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return allA2UIMessages;
|
||||||
|
}
|
||||||
192
src/renderer/a2ui/useA2UISurface.ts
Normal file
192
src/renderer/a2ui/useA2UISurface.ts
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
/**
|
||||||
|
* React hook for A2UI surface state.
|
||||||
|
*
|
||||||
|
* Wraps A2UISurfaceManager and provides reactive state for React components.
|
||||||
|
* Subscribes to IPC events and feeds messages into the surface manager.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { A2UISurfaceManager } from './A2UISurfaceManager';
|
||||||
|
import { replaySurfacesFromMessages } from './surfaceAssociation';
|
||||||
|
import type { A2UIResolvedComponent, A2UIServerMessage, A2UIClientAction } from '../../main/a2ui/types';
|
||||||
|
import type { ChatMessage } from '../../main/shared/electronApi';
|
||||||
|
|
||||||
|
interface UseA2UISurfaceInput {
|
||||||
|
conversationId: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SurfaceEntry {
|
||||||
|
surfaceId: string;
|
||||||
|
tree: A2UIResolvedComponent[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseA2UISurfaceResult {
|
||||||
|
/** All active surface trees for this conversation */
|
||||||
|
surfaces: SurfaceEntry[];
|
||||||
|
/** Surfaces grouped by the turn index that created them */
|
||||||
|
surfacesByTurn: Map<number, SurfaceEntry[]>;
|
||||||
|
/** The surfaceId of the most recently created surface */
|
||||||
|
latestSurfaceId: string | null;
|
||||||
|
/** Set of surface IDs that the user has dismissed */
|
||||||
|
dismissedSurfaceIds: Set<string>;
|
||||||
|
/** Dismiss a surface by ID */
|
||||||
|
dismissSurface: (surfaceId: string) => void;
|
||||||
|
/** Dispatch an action back to the main process */
|
||||||
|
dispatchAction: (action: A2UIClientAction) => void;
|
||||||
|
/** Update a local data binding (for form inputs) */
|
||||||
|
updateLocalData: (surfaceId: string, path: string, value: unknown) => void;
|
||||||
|
/** Get the data model for a surface */
|
||||||
|
getDataModel: (surfaceId: string) => Record<string, unknown>;
|
||||||
|
/** Clear all surfaces for the conversation */
|
||||||
|
clearSurfaces: () => void;
|
||||||
|
/** Replay surfaces from persisted chat messages */
|
||||||
|
replayFromMessages: (messages: ChatMessage[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useA2UISurface(input: UseA2UISurfaceInput): UseA2UISurfaceResult {
|
||||||
|
const { conversationId } = input;
|
||||||
|
const managerRef = useRef<A2UISurfaceManager>(new A2UISurfaceManager());
|
||||||
|
const [renderTick, setRenderTick] = useState(0);
|
||||||
|
const [dismissedSurfaceIds, setDismissedSurfaceIds] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
|
// Subscribe to surface changes
|
||||||
|
useEffect(() => {
|
||||||
|
const manager = managerRef.current;
|
||||||
|
const unsubscribe = manager.onChange(() => {
|
||||||
|
setRenderTick((prev) => prev + 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
return unsubscribe;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Subscribe to A2UI IPC events
|
||||||
|
useEffect(() => {
|
||||||
|
if (!conversationId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const unsubscribe = window.electronAPI?.chat.onA2UIMessage?.((data: { conversationId: string; message: A2UIServerMessage }) => {
|
||||||
|
if (data.conversationId === conversationId) {
|
||||||
|
managerRef.current.processMessage(data.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
unsubscribe?.();
|
||||||
|
};
|
||||||
|
}, [conversationId]);
|
||||||
|
|
||||||
|
// Clear surfaces and dismissed set when conversation changes
|
||||||
|
useEffect(() => {
|
||||||
|
setDismissedSurfaceIds(new Set());
|
||||||
|
return () => {
|
||||||
|
if (conversationId) {
|
||||||
|
managerRef.current.clearConversation(conversationId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [conversationId]);
|
||||||
|
|
||||||
|
const surfaces = useMemo(() => {
|
||||||
|
// renderTick ensures this recalculates on surface changes
|
||||||
|
void renderTick;
|
||||||
|
|
||||||
|
if (!conversationId) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const manager = managerRef.current;
|
||||||
|
const surfaceIds = manager.getSurfaceIds(conversationId);
|
||||||
|
return surfaceIds.map((surfaceId) => ({
|
||||||
|
surfaceId,
|
||||||
|
tree: manager.resolveTree(surfaceId),
|
||||||
|
}));
|
||||||
|
}, [conversationId, renderTick]);
|
||||||
|
|
||||||
|
const surfacesByTurn = useMemo(() => {
|
||||||
|
void renderTick;
|
||||||
|
|
||||||
|
const map = new Map<number, SurfaceEntry[]>();
|
||||||
|
if (!conversationId) {
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
const manager = managerRef.current;
|
||||||
|
const surfaceIds = manager.getSurfaceIds(conversationId);
|
||||||
|
|
||||||
|
for (const surfaceId of surfaceIds) {
|
||||||
|
const surface = manager.getSurface(surfaceId);
|
||||||
|
const turnIndex = (surface?.metadata?.turnIndex as number) ?? -1;
|
||||||
|
const entry: SurfaceEntry = { surfaceId, tree: manager.resolveTree(surfaceId) };
|
||||||
|
|
||||||
|
const existing = map.get(turnIndex);
|
||||||
|
if (existing) {
|
||||||
|
existing.push(entry);
|
||||||
|
} else {
|
||||||
|
map.set(turnIndex, [entry]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return map;
|
||||||
|
}, [conversationId, renderTick]);
|
||||||
|
|
||||||
|
const latestSurfaceId = useMemo(() => {
|
||||||
|
void renderTick;
|
||||||
|
|
||||||
|
if (!conversationId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ids = managerRef.current.getSurfaceIds(conversationId);
|
||||||
|
return ids.length > 0 ? ids[ids.length - 1] : null;
|
||||||
|
}, [conversationId, renderTick]);
|
||||||
|
|
||||||
|
const dismissSurface = useCallback((surfaceId: string) => {
|
||||||
|
setDismissedSurfaceIds((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.add(surfaceId);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const dispatchAction = useCallback((action: A2UIClientAction) => {
|
||||||
|
window.electronAPI?.chat.dispatchA2UIAction?.(action);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const updateLocalData = useCallback((surfaceId: string, path: string, value: unknown) => {
|
||||||
|
managerRef.current.updateLocalData(surfaceId, path, value);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const getDataModel = useCallback((surfaceId: string) => {
|
||||||
|
return managerRef.current.getDataModel(surfaceId);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const clearSurfaces = useCallback(() => {
|
||||||
|
if (conversationId) {
|
||||||
|
managerRef.current.clearConversation(conversationId);
|
||||||
|
}
|
||||||
|
}, [conversationId]);
|
||||||
|
|
||||||
|
const replayFromMessages = useCallback((msgs: ChatMessage[]) => {
|
||||||
|
if (!conversationId) return;
|
||||||
|
const manager = managerRef.current;
|
||||||
|
// Only replay if no surfaces exist yet (avoid duplicates on re-render)
|
||||||
|
if (manager.getSurfaceIds(conversationId).length > 0) return;
|
||||||
|
const a2uiMessages = replaySurfacesFromMessages(conversationId, msgs);
|
||||||
|
for (const msg of a2uiMessages) {
|
||||||
|
manager.processMessage(msg);
|
||||||
|
}
|
||||||
|
}, [conversationId]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
surfaces,
|
||||||
|
surfacesByTurn,
|
||||||
|
latestSurfaceId,
|
||||||
|
dismissedSurfaceIds,
|
||||||
|
dismissSurface,
|
||||||
|
dispatchAction,
|
||||||
|
updateLocalData,
|
||||||
|
getDataModel,
|
||||||
|
clearSurfaces,
|
||||||
|
replayFromMessages,
|
||||||
|
};
|
||||||
|
}
|
||||||
432
src/renderer/components/AssistantSidebar/AssistantSidebar.css
Normal file
432
src/renderer/components/AssistantSidebar/AssistantSidebar.css
Normal file
@@ -0,0 +1,432 @@
|
|||||||
|
.assistant-sidebar {
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px;
|
||||||
|
overflow-y: auto;
|
||||||
|
background-color: var(--vscode-sideBar-background);
|
||||||
|
color: var(--vscode-sideBar-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-sidebar-header h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-sidebar-header p {
|
||||||
|
margin: 6px 0 0;
|
||||||
|
font-size: 12px;
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-sidebar-context {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 8px;
|
||||||
|
border: 1px solid var(--vscode-sideBarSectionHeader-border, var(--vscode-panel-border));
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--vscode-editorWidget-background, rgba(0, 0, 0, 0.2));
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-sidebar-context-label {
|
||||||
|
font-size: 11px;
|
||||||
|
opacity: 0.75;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-sidebar-context-value {
|
||||||
|
font-size: 12px;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-sidebar-prompt {
|
||||||
|
width: 100%;
|
||||||
|
resize: vertical;
|
||||||
|
min-height: 120px;
|
||||||
|
padding: 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid var(--vscode-input-border, transparent);
|
||||||
|
background: var(--vscode-input-background);
|
||||||
|
color: var(--vscode-input-foreground);
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-sidebar-start-button {
|
||||||
|
align-self: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-sidebar-error {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--vscode-errorForeground);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-sidebar-panel-output {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
border-top: 1px solid var(--vscode-panel-border);
|
||||||
|
padding-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-panel-metric {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: baseline;
|
||||||
|
padding: 8px;
|
||||||
|
border: 1px solid var(--vscode-panel-border);
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-panel-metric-label {
|
||||||
|
font-size: 12px;
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-panel-metric-value {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-panel-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-panel-table th,
|
||||||
|
.assistant-panel-table td {
|
||||||
|
border: 1px solid var(--vscode-panel-border);
|
||||||
|
padding: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-sidebar-raw-message {
|
||||||
|
border-top: 1px solid var(--vscode-panel-border);
|
||||||
|
padding-top: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-panel-widget-block {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-panel-widget-label {
|
||||||
|
font-size: 12px;
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-panel-widget-input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-panel-checkbox {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-panel-chart {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
border: 1px solid var(--vscode-panel-border);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-panel-chart-title {
|
||||||
|
margin: 0;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-panel-chart-type {
|
||||||
|
font-size: 11px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-panel-chart-body {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto 1fr auto;
|
||||||
|
gap: 4px 8px;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-panel-chart-item {
|
||||||
|
display: contents;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-panel-chart-label {
|
||||||
|
justify-self: end;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
max-width: 140px;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-panel-chart-bar-track {
|
||||||
|
height: 14px;
|
||||||
|
background: var(--vscode-input-background);
|
||||||
|
border-radius: 3px;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
min-width: 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-panel-chart-bar-fill {
|
||||||
|
height: 100%;
|
||||||
|
background: var(--vscode-charts-blue, #75beff);
|
||||||
|
border-radius: 3px;
|
||||||
|
min-width: 2px;
|
||||||
|
transition: width 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-panel-chart-bar-segment {
|
||||||
|
height: 100%;
|
||||||
|
min-width: 1px;
|
||||||
|
transition: width 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-panel-chart-bar-segment:first-child {
|
||||||
|
border-radius: 3px 0 0 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-panel-chart-bar-segment:last-child {
|
||||||
|
border-radius: 0 3px 3px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-panel-chart-bar-segment:only-child {
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-panel-chart-value {
|
||||||
|
text-align: right;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
white-space: nowrap;
|
||||||
|
min-width: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-panel-chart-legend {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
font-size: 11px;
|
||||||
|
padding-top: 4px;
|
||||||
|
border-top: 1px solid var(--vscode-panel-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-panel-chart-legend-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-panel-chart-legend-swatch {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 2px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Line chart */
|
||||||
|
.assistant-panel-chart-line-svg {
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
max-height: 180px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-panel-chart-line-grid {
|
||||||
|
stroke: var(--vscode-panel-border, #444);
|
||||||
|
stroke-width: 0.5;
|
||||||
|
stroke-dasharray: 3 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-panel-chart-line-y-label {
|
||||||
|
font-size: 9px;
|
||||||
|
fill: var(--vscode-foreground, #ccc);
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-panel-chart-line-path {
|
||||||
|
stroke: var(--vscode-charts-blue, #75beff);
|
||||||
|
stroke-width: 2;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
stroke-linecap: round;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-panel-chart-line-dot {
|
||||||
|
fill: var(--vscode-charts-blue, #75beff);
|
||||||
|
stroke: var(--vscode-editor-background, #1e1e1e);
|
||||||
|
stroke-width: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-panel-chart-line-x-label {
|
||||||
|
font-size: 9px;
|
||||||
|
fill: var(--vscode-foreground, #ccc);
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-panel-chart-area-fill {
|
||||||
|
fill: var(--vscode-charts-blue, #75beff);
|
||||||
|
opacity: 0.15;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Pie chart */
|
||||||
|
.assistant-panel-chart-pie-svg {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 160px;
|
||||||
|
height: auto;
|
||||||
|
align-self: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-panel-chart-pie-slice {
|
||||||
|
stroke: var(--vscode-editor-background, #1e1e1e);
|
||||||
|
stroke-width: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Donut chart */
|
||||||
|
.assistant-panel-chart-donut-hole {
|
||||||
|
fill: var(--vscode-editor-background, #1e1e1e);
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-panel-chart-donut-total {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
fill: var(--vscode-foreground, #ccc);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Heatmap chart */
|
||||||
|
.assistant-panel-chart-heatmap {
|
||||||
|
display: grid;
|
||||||
|
gap: 2px;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-panel-chart-heatmap-corner {
|
||||||
|
/* empty top-left cell */
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-panel-chart-heatmap-col-label {
|
||||||
|
text-align: center;
|
||||||
|
opacity: 0.7;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-panel-chart-heatmap-row-label {
|
||||||
|
text-align: right;
|
||||||
|
padding-right: 4px;
|
||||||
|
opacity: 0.7;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
max-width: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-panel-chart-heatmap-cell {
|
||||||
|
aspect-ratio: 1;
|
||||||
|
min-width: 14px;
|
||||||
|
min-height: 14px;
|
||||||
|
border-radius: 2px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-panel-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
border: 1px solid var(--vscode-panel-border);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-panel-form-title {
|
||||||
|
margin: 0;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-panel-card {
|
||||||
|
border: 1px solid var(--vscode-panel-border);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 8px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-panel-card h4,
|
||||||
|
.assistant-panel-card p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-panel-card-subtitle {
|
||||||
|
font-size: 12px;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-panel-card-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-panel-image {
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-panel-image img {
|
||||||
|
max-width: 100%;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid var(--vscode-panel-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-panel-image figcaption {
|
||||||
|
font-size: 12px;
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-panel-tabs {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-panel-tab-strip {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-panel-tab-button.active {
|
||||||
|
border-color: var(--vscode-focusBorder);
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-panel-tab-panel {
|
||||||
|
border: 1px solid var(--vscode-panel-border);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 8px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
294
src/renderer/components/AssistantSidebar/AssistantSidebar.tsx
Normal file
294
src/renderer/components/AssistantSidebar/AssistantSidebar.tsx
Normal file
@@ -0,0 +1,294 @@
|
|||||||
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { useAppStore } from '../../store';
|
||||||
|
import { resolveAssistantEditorContext } from '../../navigation/assistantPromptContext';
|
||||||
|
import { planAssistantRequest } from '../../navigation/assistantConversation';
|
||||||
|
import { dispatchAssistantAction } from '../../navigation/assistantActionDispatcher';
|
||||||
|
import { ensureConversationId } from '../../navigation/chatSession';
|
||||||
|
import { getChatSurfaceMode } from '../../navigation/chatSurfaceMode';
|
||||||
|
import { useChatMessageSender } from '../../navigation/useChatMessageSender';
|
||||||
|
import { useChatSurfaceState } from '../../navigation/useChatSurfaceState';
|
||||||
|
import { useA2UISurface } from '../../a2ui/useA2UISurface';
|
||||||
|
import { ChatTranscript } from '../ChatSurface';
|
||||||
|
import { useI18n } from '../../i18n';
|
||||||
|
import '../../styles/chatSurface.css';
|
||||||
|
import './AssistantSidebar.css';
|
||||||
|
|
||||||
|
export const AssistantSidebar: React.FC = () => {
|
||||||
|
const { t: tr } = useI18n();
|
||||||
|
const surfaceMode = getChatSurfaceMode('sidebar');
|
||||||
|
const [prompt, setPrompt] = useState('');
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||||
|
const [conversationId, setConversationId] = useState<string | null>(null);
|
||||||
|
const [actionError, setActionError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const {
|
||||||
|
tabs,
|
||||||
|
activeTabId,
|
||||||
|
posts,
|
||||||
|
media,
|
||||||
|
setSelectedPost,
|
||||||
|
setSelectedMedia,
|
||||||
|
openTab,
|
||||||
|
setActiveView,
|
||||||
|
toggleSidebar,
|
||||||
|
togglePanel,
|
||||||
|
toggleAssistantSidebar,
|
||||||
|
} = useAppStore();
|
||||||
|
const { sendMessage: sendChatMessage } = useChatMessageSender({
|
||||||
|
chatService: window.electronAPI?.chat,
|
||||||
|
});
|
||||||
|
const {
|
||||||
|
messages,
|
||||||
|
isStreaming,
|
||||||
|
streamingContent,
|
||||||
|
toolEvents,
|
||||||
|
beginUserTurn,
|
||||||
|
appendStreamDelta,
|
||||||
|
recordToolCall,
|
||||||
|
recordToolResult,
|
||||||
|
finalizeAssistantTurn,
|
||||||
|
appendAssistantMessage,
|
||||||
|
stopStreaming,
|
||||||
|
getStreamingContent,
|
||||||
|
} = useChatSurfaceState();
|
||||||
|
|
||||||
|
// A2UI surface rendering
|
||||||
|
const {
|
||||||
|
surfacesByTurn,
|
||||||
|
latestSurfaceId,
|
||||||
|
dismissedSurfaceIds,
|
||||||
|
dismissSurface,
|
||||||
|
dispatchAction,
|
||||||
|
updateLocalData,
|
||||||
|
} = useA2UISurface({ conversationId });
|
||||||
|
|
||||||
|
// Current turn index for associating streaming surfaces
|
||||||
|
const currentTurnIndex = useMemo(() => {
|
||||||
|
return messages.filter(m => m.role === 'user').length - 1;
|
||||||
|
}, [messages]);
|
||||||
|
|
||||||
|
const activeTab = useMemo(() => tabs.find((tab) => tab.id === activeTabId) ?? null, [tabs, activeTabId]);
|
||||||
|
|
||||||
|
const editorContext = useMemo(
|
||||||
|
() => resolveAssistantEditorContext({ activeTab, posts, media }),
|
||||||
|
[activeTab, posts, media],
|
||||||
|
);
|
||||||
|
|
||||||
|
const contextSummary = useMemo(() => {
|
||||||
|
if (!editorContext) {
|
||||||
|
return tr('assistantSidebar.context.none');
|
||||||
|
}
|
||||||
|
|
||||||
|
const title = editorContext.title ? ` • ${editorContext.title}` : '';
|
||||||
|
const id = editorContext.id ? ` (${editorContext.id})` : '';
|
||||||
|
return `${editorContext.tabType}${id}${title}`;
|
||||||
|
}, [editorContext, tr]);
|
||||||
|
|
||||||
|
const persistActionEvent = async (message: string) => {
|
||||||
|
if (!conversationId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await window.electronAPI?.chat.addSystemEvent(conversationId, message);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to persist assistant action event:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const unsubDelta = window.electronAPI?.chat.onStreamDelta((data) => {
|
||||||
|
if (data.conversationId === conversationId) {
|
||||||
|
appendStreamDelta(data.delta);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const unsubToolCall = window.electronAPI?.chat.onToolCall((data) => {
|
||||||
|
if (data.conversationId === conversationId) {
|
||||||
|
const toolCall = data.toolCall as { name: string; arguments: Record<string, unknown> };
|
||||||
|
recordToolCall(toolCall.name, toolCall.arguments);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const unsubToolResult = window.electronAPI?.chat.onToolResult((data) => {
|
||||||
|
if (data.conversationId === conversationId) {
|
||||||
|
const result = data.result as { name: string; result: unknown };
|
||||||
|
recordToolResult(result.name);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const unsubTitle = window.electronAPI?.chat.onTitleUpdated((data) => {
|
||||||
|
if (data.conversationId === conversationId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
unsubDelta?.();
|
||||||
|
unsubToolCall?.();
|
||||||
|
unsubToolResult?.();
|
||||||
|
unsubTitle?.();
|
||||||
|
};
|
||||||
|
}, [conversationId, appendStreamDelta, recordToolCall, recordToolResult]);
|
||||||
|
|
||||||
|
const handleStart = async () => {
|
||||||
|
const trimmed = prompt.trim();
|
||||||
|
if (!trimmed || isSubmitting) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSubmitting(true);
|
||||||
|
setErrorMessage(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const chatService = window.electronAPI?.chat;
|
||||||
|
if (!chatService) {
|
||||||
|
throw new Error('Chat service unavailable');
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolvedConversationId = await ensureConversationId({
|
||||||
|
currentConversationId: conversationId,
|
||||||
|
createTitle: tr('assistantSidebar.conversationTitle'),
|
||||||
|
chatService,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!conversationId) {
|
||||||
|
setConversationId(resolvedConversationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestPlan = planAssistantRequest({
|
||||||
|
conversationId,
|
||||||
|
userPrompt: trimmed,
|
||||||
|
context: editorContext,
|
||||||
|
});
|
||||||
|
|
||||||
|
beginUserTurn(resolvedConversationId, trimmed);
|
||||||
|
|
||||||
|
const sendResult = await sendChatMessage({
|
||||||
|
conversationId: resolvedConversationId,
|
||||||
|
message: requestPlan.outboundMessage,
|
||||||
|
metadata: { surface: 'sidebar' },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!sendResult.success) {
|
||||||
|
appendAssistantMessage(
|
||||||
|
resolvedConversationId,
|
||||||
|
tr('chat.errorPrefix', { error: sendResult.error || tr('chat.errorNoResponse') }),
|
||||||
|
);
|
||||||
|
stopStreaming();
|
||||||
|
throw new Error(sendResult.error || 'Failed to send assistant message');
|
||||||
|
}
|
||||||
|
|
||||||
|
const assistantContent = getStreamingContent() || sendResult.message;
|
||||||
|
if (assistantContent) {
|
||||||
|
finalizeAssistantTurn(resolvedConversationId, assistantContent);
|
||||||
|
} else {
|
||||||
|
appendAssistantMessage(resolvedConversationId, tr('chat.errorEmptyResponse'));
|
||||||
|
stopStreaming();
|
||||||
|
}
|
||||||
|
|
||||||
|
setPrompt('');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to start assistant conversation:', error);
|
||||||
|
setErrorMessage(tr('assistantSidebar.error.startFailed'));
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAssistantAction = (action: string, payload?: Record<string, unknown>) => {
|
||||||
|
const result = dispatchAssistantAction(
|
||||||
|
{
|
||||||
|
action,
|
||||||
|
payload,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
setSelectedPost,
|
||||||
|
setSelectedMedia,
|
||||||
|
openTab,
|
||||||
|
setActiveView,
|
||||||
|
toggleSidebar,
|
||||||
|
togglePanel,
|
||||||
|
toggleAssistantSidebar,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!result.handled) {
|
||||||
|
setActionError(result.error || tr('assistantSidebar.error.actionFailed'));
|
||||||
|
void persistActionEvent(
|
||||||
|
`Assistant action failed: ${action}${result.error ? ` (${result.error})` : ''}`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setActionError(null);
|
||||||
|
void persistActionEvent(
|
||||||
|
`Assistant action executed: ${action}${payload ? ` ${JSON.stringify(payload)}` : ''}`,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="assistant-sidebar chat-surface">
|
||||||
|
<div className="assistant-sidebar-header">
|
||||||
|
<h3>{tr('assistantSidebar.title')}</h3>
|
||||||
|
<p>{tr('assistantSidebar.description')}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="assistant-sidebar-context chat-surface-section">
|
||||||
|
<span className="assistant-sidebar-context-label">{tr('assistantSidebar.context.label')}</span>
|
||||||
|
<span className="assistant-sidebar-context-value">{contextSummary}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<textarea
|
||||||
|
className="assistant-sidebar-prompt chat-surface-input"
|
||||||
|
value={prompt}
|
||||||
|
onChange={(event) => setPrompt(event.target.value)}
|
||||||
|
placeholder={tr('assistantSidebar.prompt.placeholder')}
|
||||||
|
rows={6}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="assistant-sidebar-start-button"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
onClick={() => void handleStart()}
|
||||||
|
>
|
||||||
|
{isSubmitting ? tr('assistantSidebar.button.starting') : tr('assistantSidebar.button.start')}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{errorMessage && <p className="assistant-sidebar-error chat-surface-error">{errorMessage}</p>}
|
||||||
|
|
||||||
|
{actionError && <p className="assistant-sidebar-error chat-surface-error">{actionError}</p>}
|
||||||
|
|
||||||
|
{surfaceMode.showWelcomeTips && messages.filter(m => m.role !== 'system' && m.role !== 'tool').length === 0 && !isStreaming && (
|
||||||
|
<div className="assistant-sidebar-raw-message chat-surface-section">
|
||||||
|
{tr('chat.welcomeDescription')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{messages.length > 0 && (
|
||||||
|
<div className="assistant-sidebar-raw-message chat-surface-section">
|
||||||
|
<ChatTranscript
|
||||||
|
messages={messages}
|
||||||
|
isStreaming={isStreaming}
|
||||||
|
streamingContent={streamingContent}
|
||||||
|
toolEvents={toolEvents}
|
||||||
|
assistantRoleLabel={tr('chat.role.assistant')}
|
||||||
|
userRoleLabel={tr('chat.role.you')}
|
||||||
|
showToolMarkers={surfaceMode.showToolMarkers}
|
||||||
|
surfacesByTurn={surfacesByTurn}
|
||||||
|
latestSurfaceId={latestSurfaceId}
|
||||||
|
dismissedSurfaceIds={dismissedSurfaceIds}
|
||||||
|
onSurfaceDismiss={dismissSurface}
|
||||||
|
onSurfaceAction={dispatchAction}
|
||||||
|
onSurfaceDataChange={updateLocalData}
|
||||||
|
currentTurnIndex={currentTurnIndex}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AssistantSidebar;
|
||||||
1
src/renderer/components/AssistantSidebar/index.ts
Normal file
1
src/renderer/components/AssistantSidebar/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { AssistantSidebar } from './AssistantSidebar';
|
||||||
@@ -98,6 +98,8 @@
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 32px;
|
padding: 32px;
|
||||||
|
min-height: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
color: var(--vscode-descriptionForeground);
|
color: var(--vscode-descriptionForeground);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,14 @@
|
|||||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react';
|
||||||
import Markdown from 'marked-react';
|
import type { ChatConversation, ChatModel } from '../../types/electron';
|
||||||
import type { ChatMessage, ChatConversation, ChatModel } from '../../types/electron';
|
import { useChatMessageSender } from '../../navigation/useChatMessageSender';
|
||||||
|
import { useChatSurfaceState } from '../../navigation/useChatSurfaceState';
|
||||||
|
import { getChatSurfaceMode } from '../../navigation/chatSurfaceMode';
|
||||||
|
import { dispatchAssistantAction } from '../../navigation/assistantActionDispatcher';
|
||||||
|
import { useA2UISurface } from '../../a2ui/useA2UISurface';
|
||||||
|
import { useAppStore } from '../../store';
|
||||||
|
import { ChatTranscript } from '../ChatSurface';
|
||||||
import { useI18n } from '../../i18n';
|
import { useI18n } from '../../i18n';
|
||||||
|
import '../../styles/chatSurface.css';
|
||||||
import './ChatPanel.css';
|
import './ChatPanel.css';
|
||||||
|
|
||||||
interface ChatPanelProps {
|
interface ChatPanelProps {
|
||||||
@@ -10,22 +17,62 @@ interface ChatPanelProps {
|
|||||||
|
|
||||||
export const ChatPanel: React.FC<ChatPanelProps> = ({ conversationId }) => {
|
export const ChatPanel: React.FC<ChatPanelProps> = ({ conversationId }) => {
|
||||||
const { t: tr } = useI18n();
|
const { t: tr } = useI18n();
|
||||||
|
const surfaceMode = getChatSurfaceMode('tab');
|
||||||
const [conversation, setConversation] = useState<ChatConversation | null>(null);
|
const [conversation, setConversation] = useState<ChatConversation | null>(null);
|
||||||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
|
||||||
const [inputValue, setInputValue] = useState('');
|
const [inputValue, setInputValue] = useState('');
|
||||||
const [isStreaming, setIsStreaming] = useState(false);
|
|
||||||
const [streamingContent, setStreamingContent] = useState('');
|
|
||||||
const [toolEvents, setToolEvents] = useState<Array<{ type: 'call' | 'result'; name: string; args?: unknown; timestamp: number }>>([]);
|
|
||||||
const [availableModels, setAvailableModels] = useState<ChatModel[]>([]);
|
const [availableModels, setAvailableModels] = useState<ChatModel[]>([]);
|
||||||
const [showModelSelector, setShowModelSelector] = useState(false);
|
const [showModelSelector, setShowModelSelector] = useState(false);
|
||||||
const [needsApiKey, setNeedsApiKey] = useState(false);
|
const [needsApiKey, setNeedsApiKey] = useState(false);
|
||||||
const [apiKeyInput, setApiKeyInput] = useState('');
|
const [apiKeyInput, setApiKeyInput] = useState('');
|
||||||
const [apiKeyError, setApiKeyError] = useState('');
|
const [apiKeyError, setApiKeyError] = useState('');
|
||||||
const [isValidating, setIsValidating] = useState(false);
|
const [isValidating, setIsValidating] = useState(false);
|
||||||
|
const [actionError, setActionError] = useState<string | null>(null);
|
||||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||||
const streamingRef = useRef('');
|
const {
|
||||||
const toolEventsRef = useRef<Array<{ name: string; args?: unknown }>>([]);
|
setSelectedPost,
|
||||||
|
setSelectedMedia,
|
||||||
|
openTab,
|
||||||
|
setActiveView,
|
||||||
|
toggleSidebar,
|
||||||
|
togglePanel,
|
||||||
|
toggleAssistantSidebar,
|
||||||
|
} = useAppStore();
|
||||||
|
const { sendMessage: sendChatMessage } = useChatMessageSender({
|
||||||
|
chatService: window.electronAPI?.chat,
|
||||||
|
});
|
||||||
|
const {
|
||||||
|
messages,
|
||||||
|
isStreaming,
|
||||||
|
streamingContent,
|
||||||
|
toolEvents,
|
||||||
|
setMessages,
|
||||||
|
beginUserTurn,
|
||||||
|
appendStreamDelta,
|
||||||
|
recordToolCall,
|
||||||
|
recordToolResult,
|
||||||
|
appendAssistantMessage,
|
||||||
|
finalizeAssistantTurn,
|
||||||
|
stopStreaming,
|
||||||
|
abortStreaming,
|
||||||
|
getStreamingContent,
|
||||||
|
} = useChatSurfaceState();
|
||||||
|
|
||||||
|
// A2UI surface rendering
|
||||||
|
const {
|
||||||
|
surfacesByTurn,
|
||||||
|
latestSurfaceId,
|
||||||
|
dismissedSurfaceIds,
|
||||||
|
dismissSurface,
|
||||||
|
dispatchAction,
|
||||||
|
updateLocalData,
|
||||||
|
replayFromMessages,
|
||||||
|
} = useA2UISurface({ conversationId });
|
||||||
|
|
||||||
|
// Current turn index for associating streaming surfaces
|
||||||
|
const currentTurnIndex = useMemo(() => {
|
||||||
|
return messages.filter(m => m.role === 'user').length - 1;
|
||||||
|
}, [messages]);
|
||||||
|
|
||||||
// Scroll to bottom when messages change
|
// Scroll to bottom when messages change
|
||||||
const scrollToBottom = useCallback(() => {
|
const scrollToBottom = useCallback(() => {
|
||||||
@@ -56,12 +103,15 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ conversationId }) => {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
if (conv) setConversation(conv);
|
if (conv) setConversation(conv);
|
||||||
if (msgs) setMessages(msgs);
|
if (msgs) {
|
||||||
|
setMessages(msgs);
|
||||||
|
replayFromMessages(msgs);
|
||||||
|
}
|
||||||
if (modelsResult?.models) setAvailableModels(modelsResult.models);
|
if (modelsResult?.models) setAvailableModels(modelsResult.models);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load chat data:', error);
|
console.error('Failed to load chat data:', error);
|
||||||
}
|
}
|
||||||
}, [conversationId]);
|
}, [conversationId, replayFromMessages]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
checkReady();
|
checkReady();
|
||||||
@@ -70,8 +120,7 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ conversationId }) => {
|
|||||||
// Subscribe to stream events
|
// Subscribe to stream events
|
||||||
const unsubDelta = window.electronAPI?.chat.onStreamDelta((data) => {
|
const unsubDelta = window.electronAPI?.chat.onStreamDelta((data) => {
|
||||||
if (data.conversationId === conversationId) {
|
if (data.conversationId === conversationId) {
|
||||||
streamingRef.current += data.delta;
|
appendStreamDelta(data.delta);
|
||||||
setStreamingContent(streamingRef.current);
|
|
||||||
scrollToBottom();
|
scrollToBottom();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -80,8 +129,7 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ conversationId }) => {
|
|||||||
console.log('[ChatPanel] Tool call received:', data);
|
console.log('[ChatPanel] Tool call received:', data);
|
||||||
if (data.conversationId === conversationId) {
|
if (data.conversationId === conversationId) {
|
||||||
const toolCall = data.toolCall as { name: string; arguments: Record<string, unknown> };
|
const toolCall = data.toolCall as { name: string; arguments: Record<string, unknown> };
|
||||||
toolEventsRef.current.push({ name: toolCall.name, args: toolCall.arguments });
|
recordToolCall(toolCall.name, toolCall.arguments);
|
||||||
setToolEvents(prev => [...prev, { type: 'call', name: toolCall.name, args: toolCall.arguments, timestamp: Date.now() }]);
|
|
||||||
scrollToBottom();
|
scrollToBottom();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -90,7 +138,7 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ conversationId }) => {
|
|||||||
console.log('[ChatPanel] Tool result received:', data);
|
console.log('[ChatPanel] Tool result received:', data);
|
||||||
if (data.conversationId === conversationId) {
|
if (data.conversationId === conversationId) {
|
||||||
const result = data.result as { name: string; result: unknown };
|
const result = data.result as { name: string; result: unknown };
|
||||||
setToolEvents(prev => [...prev, { type: 'result', name: result.name, timestamp: Date.now() }]);
|
recordToolResult(result.name);
|
||||||
scrollToBottom();
|
scrollToBottom();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -107,7 +155,7 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ conversationId }) => {
|
|||||||
unsubToolResult?.();
|
unsubToolResult?.();
|
||||||
unsubTitle?.();
|
unsubTitle?.();
|
||||||
};
|
};
|
||||||
}, [conversationId, loadData, scrollToBottom, checkReady]);
|
}, [conversationId, loadData, scrollToBottom, checkReady, appendStreamDelta, recordToolCall, recordToolResult]);
|
||||||
|
|
||||||
// Scroll on new messages or streaming content
|
// Scroll on new messages or streaming content
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -146,76 +194,81 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ conversationId }) => {
|
|||||||
if (inputRef.current) {
|
if (inputRef.current) {
|
||||||
inputRef.current.style.height = 'auto';
|
inputRef.current.style.height = 'auto';
|
||||||
}
|
}
|
||||||
setIsStreaming(true);
|
beginUserTurn(conversationId, message);
|
||||||
streamingRef.current = '';
|
|
||||||
setStreamingContent('');
|
|
||||||
setToolEvents([]);
|
|
||||||
toolEventsRef.current = [];
|
|
||||||
|
|
||||||
// Add user message optimistically
|
|
||||||
const userMessage: ChatMessage = {
|
|
||||||
id: `temp-${Date.now()}`,
|
|
||||||
conversationId,
|
|
||||||
role: 'user',
|
|
||||||
content: message,
|
|
||||||
createdAt: new Date().toISOString()
|
|
||||||
};
|
|
||||||
setMessages(prev => [...prev, userMessage]);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Send message and wait for complete response
|
const result = await sendChatMessage({
|
||||||
const result = await window.electronAPI?.chat.sendMessage(conversationId, message);
|
conversationId,
|
||||||
|
message,
|
||||||
|
metadata: { surface: 'tab' },
|
||||||
|
});
|
||||||
|
|
||||||
// Use the streamed content we accumulated via onStreamDelta
|
// Use the streamed content we accumulated via onStreamDelta
|
||||||
// Fall back to the backend result message if streaming didn't capture the content
|
// Fall back to the backend result message if streaming didn't capture the content
|
||||||
const assistantContent = streamingRef.current || (result?.success ? result.message : '');
|
const assistantContent = getStreamingContent() || (result.success ? result.message : '');
|
||||||
|
|
||||||
if (assistantContent) {
|
if (assistantContent) {
|
||||||
const assistantMessage: ChatMessage = {
|
finalizeAssistantTurn(conversationId, assistantContent);
|
||||||
id: `assistant-${Date.now()}`,
|
} else if (!result.success) {
|
||||||
conversationId,
|
appendAssistantMessage(conversationId, tr('chat.errorPrefix', { error: result.error || tr('chat.errorNoResponse') }));
|
||||||
role: 'assistant',
|
stopStreaming();
|
||||||
content: assistantContent,
|
|
||||||
toolCalls: toolEventsRef.current.length > 0 ? JSON.stringify(toolEventsRef.current) : undefined,
|
|
||||||
createdAt: new Date().toISOString()
|
|
||||||
};
|
|
||||||
setMessages(prev => [...prev, assistantMessage]);
|
|
||||||
} else if (result && !result.success) {
|
|
||||||
// Backend returned an error (API failure, model unavailable, etc.)
|
|
||||||
const errorMessage: ChatMessage = {
|
|
||||||
id: `error-${Date.now()}`,
|
|
||||||
conversationId,
|
|
||||||
role: 'assistant',
|
|
||||||
content: tr('chat.errorPrefix', { error: result.error || tr('chat.errorNoResponse') }),
|
|
||||||
createdAt: new Date().toISOString()
|
|
||||||
};
|
|
||||||
setMessages(prev => [...prev, errorMessage]);
|
|
||||||
} else {
|
} else {
|
||||||
// No content from streaming AND no error, but also no success message
|
appendAssistantMessage(conversationId, tr('chat.errorEmptyResponse'));
|
||||||
// This can happen with some models that don't return content properly
|
stopStreaming();
|
||||||
const noContentMessage: ChatMessage = {
|
|
||||||
id: `empty-${Date.now()}`,
|
|
||||||
conversationId,
|
|
||||||
role: 'assistant',
|
|
||||||
content: tr('chat.errorEmptyResponse'),
|
|
||||||
createdAt: new Date().toISOString()
|
|
||||||
};
|
|
||||||
setMessages(prev => [...prev, noContentMessage]);
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to send message:', error);
|
console.error('Failed to send message:', error);
|
||||||
const errorMessage: ChatMessage = {
|
appendAssistantMessage(conversationId, tr('chat.errorGeneric'));
|
||||||
id: `error-${Date.now()}`,
|
stopStreaming();
|
||||||
conversationId,
|
|
||||||
role: 'assistant',
|
|
||||||
content: tr('chat.errorGeneric'),
|
|
||||||
createdAt: new Date().toISOString()
|
|
||||||
};
|
|
||||||
setMessages(prev => [...prev, errorMessage]);
|
|
||||||
} finally {
|
} finally {
|
||||||
setIsStreaming(false);
|
if (isStreaming) {
|
||||||
setStreamingContent('');
|
stopStreaming();
|
||||||
streamingRef.current = '';
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const persistActionEvent = async (message: string) => {
|
||||||
|
try {
|
||||||
|
await window.electronAPI?.chat.addSystemEvent(conversationId, message);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to persist chat action event:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAssistantAction = (action: string, payload?: Record<string, unknown>) => {
|
||||||
|
const result = dispatchAssistantAction(
|
||||||
|
{
|
||||||
|
action,
|
||||||
|
payload,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
setSelectedPost,
|
||||||
|
setSelectedMedia,
|
||||||
|
openTab,
|
||||||
|
setActiveView,
|
||||||
|
toggleSidebar,
|
||||||
|
togglePanel,
|
||||||
|
toggleAssistantSidebar,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!result.handled) {
|
||||||
|
setActionError(result.error || tr('assistantSidebar.error.actionFailed'));
|
||||||
|
void persistActionEvent(`Assistant action failed: ${action}${result.error ? ` (${result.error})` : ''}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setActionError(null);
|
||||||
|
void persistActionEvent(`Assistant action executed: ${action}${payload ? ` ${JSON.stringify(payload)}` : ''}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleModelChange = async (modelId: string) => {
|
||||||
|
try {
|
||||||
|
await window.electronAPI?.chat.setConversationModel(conversationId, modelId);
|
||||||
|
setConversation((previous) => (previous ? { ...previous, model: modelId } : null));
|
||||||
|
setShowModelSelector(false);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to change model:', error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -232,140 +285,18 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ conversationId }) => {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to abort:', error);
|
console.error('Failed to abort:', error);
|
||||||
} finally {
|
} finally {
|
||||||
// Keep any streamed content as a visible message
|
abortStreaming(conversationId, tr('chat.cancelledSuffix'));
|
||||||
const partialContent = streamingRef.current;
|
|
||||||
setIsStreaming(false);
|
|
||||||
setStreamingContent('');
|
|
||||||
streamingRef.current = '';
|
|
||||||
|
|
||||||
if (partialContent) {
|
|
||||||
const partialMessage: ChatMessage = {
|
|
||||||
id: `partial-${Date.now()}`,
|
|
||||||
conversationId,
|
|
||||||
role: 'assistant',
|
|
||||||
content: `${partialContent}\n\n*(${tr('chat.cancelledSuffix')})*`,
|
|
||||||
createdAt: new Date().toISOString()
|
|
||||||
};
|
|
||||||
setMessages(prev => [...prev, partialMessage]);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleModelChange = async (modelId: string) => {
|
|
||||||
try {
|
|
||||||
await window.electronAPI?.chat.setConversationModel(conversationId, modelId);
|
|
||||||
setConversation(prev => prev ? { ...prev, model: modelId } : null);
|
|
||||||
setShowModelSelector(false);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to change model:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderToolMarkers = (events: Array<{ type: 'call' | 'result'; name: string; args?: unknown; timestamp: number }>) => {
|
|
||||||
if (events.length === 0) return null;
|
|
||||||
|
|
||||||
// Group into pairs: call + result for each tool invocation
|
|
||||||
const markers: Array<{ name: string; args?: unknown; completed: boolean }> = [];
|
|
||||||
const pendingCalls = new Map<string, number>();
|
|
||||||
|
|
||||||
for (const event of events) {
|
|
||||||
if (event.type === 'call') {
|
|
||||||
markers.push({ name: event.name, args: event.args, completed: false });
|
|
||||||
const count = pendingCalls.get(event.name) || 0;
|
|
||||||
pendingCalls.set(event.name, count + 1);
|
|
||||||
} else if (event.type === 'result') {
|
|
||||||
// Find the last uncompleted marker for this tool
|
|
||||||
for (let i = markers.length - 1; i >= 0; i--) {
|
|
||||||
if (markers[i].name === event.name && !markers[i].completed) {
|
|
||||||
markers[i].completed = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="tool-markers">
|
|
||||||
{markers.map((marker, i) => {
|
|
||||||
const argsPreview = marker.args
|
|
||||||
? Object.entries(marker.args as Record<string, unknown>)
|
|
||||||
.map(([k, v]) => `${k}: ${typeof v === 'string' ? `"${v.length > 30 ? v.slice(0, 30) + '...' : v}"` : JSON.stringify(v)}`)
|
|
||||||
.join(', ')
|
|
||||||
: '';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div key={i} className={`tool-marker ${marker.completed ? 'completed' : 'pending'}`}>
|
|
||||||
<span className="tool-marker-icon">{marker.completed ? '\u2713' : '\u25CF'}</span>
|
|
||||||
<span className="tool-marker-name">{marker.name}</span>
|
|
||||||
{argsPreview && <span className="tool-marker-args">({argsPreview})</span>}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderMessage = (msg: ChatMessage) => {
|
|
||||||
if (msg.role === 'system' || msg.role === 'tool') return null;
|
|
||||||
|
|
||||||
// Parse tool calls from stored message data
|
|
||||||
const storedToolCalls: Array<{ name: string; args?: unknown; completed: boolean }> = [];
|
|
||||||
if (msg.role === 'assistant' && msg.toolCalls) {
|
|
||||||
try {
|
|
||||||
const calls = JSON.parse(msg.toolCalls) as Array<{ name: string; args?: unknown }>;
|
|
||||||
calls.forEach(c => storedToolCalls.push({ name: c.name, args: c.args, completed: true }));
|
|
||||||
} catch { /* ignore parse errors */ }
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div key={msg.id} className={`chat-message ${msg.role}`}>
|
|
||||||
<div className="chat-message-avatar">
|
|
||||||
{msg.role === 'user' ? '\u{1F464}' : '\u{1F916}'}
|
|
||||||
</div>
|
|
||||||
<div className="chat-message-content">
|
|
||||||
<div className="chat-message-header">
|
|
||||||
<span className="chat-message-role">
|
|
||||||
{msg.role === 'user' ? tr('chat.role.you') : tr('chat.role.assistant')}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{storedToolCalls.length > 0 && (
|
|
||||||
<div className="tool-markers">
|
|
||||||
{storedToolCalls.map((marker, i) => {
|
|
||||||
const argsPreview = marker.args
|
|
||||||
? Object.entries(marker.args as Record<string, unknown>)
|
|
||||||
.map(([k, v]) => `${k}: ${typeof v === 'string' ? `"${v.length > 30 ? v.slice(0, 30) + '...' : v}"` : JSON.stringify(v)}`)
|
|
||||||
.join(', ')
|
|
||||||
: '';
|
|
||||||
return (
|
|
||||||
<div key={i} className="tool-marker completed">
|
|
||||||
<span className="tool-marker-icon">{'\u2713'}</span>
|
|
||||||
<span className="tool-marker-name">{marker.name}</span>
|
|
||||||
{argsPreview && <span className="tool-marker-args">({argsPreview})</span>}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="chat-message-text">
|
|
||||||
{msg.role === 'assistant' ? (
|
|
||||||
<Markdown gfm>{msg.content}</Markdown>
|
|
||||||
) : (
|
|
||||||
msg.content
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// API key setup screen
|
// API key setup screen
|
||||||
if (needsApiKey) {
|
if (needsApiKey) {
|
||||||
return (
|
return (
|
||||||
<div className="chat-panel">
|
<div className="chat-panel chat-surface">
|
||||||
<div className="chat-panel-header">
|
<div className="chat-panel-header">
|
||||||
<div className="chat-panel-title">{tr('chat.setupTitle')}</div>
|
<div className="chat-panel-title">{tr('chat.setupTitle')}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="chat-messages">
|
<div className="chat-messages chat-surface-scroll">
|
||||||
<div className="chat-welcome">
|
<div className="chat-welcome">
|
||||||
<div className="chat-welcome-icon">{'\u{1F511}'}</div>
|
<div className="chat-welcome-icon">{'\u{1F511}'}</div>
|
||||||
<h2>{tr('chat.apiKeyRequiredTitle')}</h2>
|
<h2>{tr('chat.apiKeyRequiredTitle')}</h2>
|
||||||
@@ -396,83 +327,72 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ conversationId }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="chat-panel">
|
<div className="chat-panel chat-surface">
|
||||||
<div className="chat-panel-header">
|
<div className="chat-panel-header">
|
||||||
<div className="chat-panel-title">
|
<div className="chat-panel-title">
|
||||||
{conversation?.title || tr('chat.newChat')}
|
{conversation?.title || tr('chat.newChat')}
|
||||||
</div>
|
</div>
|
||||||
<div className="chat-panel-model">
|
{surfaceMode.showModelSelector && (
|
||||||
<button
|
<div className="chat-panel-model">
|
||||||
className="model-selector-button"
|
<button
|
||||||
onClick={() => setShowModelSelector(!showModelSelector)}
|
className="model-selector-button"
|
||||||
>
|
onClick={() => setShowModelSelector(!showModelSelector)}
|
||||||
{conversation?.model || 'claude-sonnet-4'}
|
>
|
||||||
<span className="model-dropdown-icon">{'\u25BE'}</span>
|
{conversation?.model || 'claude-sonnet-4'}
|
||||||
</button>
|
<span className="model-dropdown-icon">{'\u25BE'}</span>
|
||||||
{showModelSelector && (
|
</button>
|
||||||
<div className="model-dropdown">
|
{showModelSelector && (
|
||||||
{availableModels.map(model => (
|
<div className="model-dropdown">
|
||||||
<button
|
{availableModels.map(model => (
|
||||||
key={model.id}
|
<button
|
||||||
className={`model-option ${conversation?.model === model.id ? 'active' : ''}`}
|
key={model.id}
|
||||||
onClick={() => handleModelChange(model.id)}
|
className={`model-option ${conversation?.model === model.id ? 'active' : ''}`}
|
||||||
>
|
onClick={() => handleModelChange(model.id)}
|
||||||
{model.name}
|
>
|
||||||
</button>
|
{model.name}
|
||||||
))}
|
</button>
|
||||||
</div>
|
))}
|
||||||
)}
|
</div>
|
||||||
</div>
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="chat-messages">
|
<div className="chat-messages chat-surface-scroll">
|
||||||
{messages.length === 0 && !isStreaming && (
|
{surfaceMode.showWelcomeTips && messages.filter(m => m.role !== 'system' && m.role !== 'tool').length === 0 && !isStreaming && (
|
||||||
<div className="chat-welcome">
|
<div className="chat-welcome">
|
||||||
<div className="chat-welcome-icon">{'\u{1F916}'}</div>
|
<div className="chat-welcome-icon">{'\u{1F916}'}</div>
|
||||||
<h2>{tr('chat.welcomeTitle')}</h2>
|
<h2>{tr('chat.welcomeTitle')}</h2>
|
||||||
<p>{tr('chat.welcomeDescription')}</p>
|
<p>{tr('chat.welcomeDescription')}</p>
|
||||||
<ul>
|
<ul>
|
||||||
<li>{tr('chat.welcomeTipSearch')}</li>
|
<li>{tr('chat.welcomeTipSearch')}</li>
|
||||||
<li>{tr('chat.welcomeTipDetails')}</li>
|
<li>{tr('chat.welcomeTipChart')}</li>
|
||||||
<li>{tr('chat.welcomeTipTags')}</li>
|
<li>{tr('chat.welcomeTipTable')}</li>
|
||||||
<li>{tr('chat.welcomeTipMetadata')}</li>
|
<li>{tr('chat.welcomeTipMetadata')}</li>
|
||||||
<li>{tr('chat.welcomeTipImages')}</li>
|
<li>{tr('chat.welcomeTipTabs')}</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{messages.map(renderMessage)}
|
<ChatTranscript
|
||||||
|
messages={messages}
|
||||||
|
isStreaming={isStreaming}
|
||||||
|
streamingContent={streamingContent}
|
||||||
|
toolEvents={toolEvents}
|
||||||
|
assistantRoleLabel={tr('chat.role.assistant')}
|
||||||
|
userRoleLabel={tr('chat.role.you')}
|
||||||
|
showToolMarkers={surfaceMode.showToolMarkers}
|
||||||
|
endRef={messagesEndRef}
|
||||||
|
surfacesByTurn={surfacesByTurn}
|
||||||
|
latestSurfaceId={latestSurfaceId}
|
||||||
|
dismissedSurfaceIds={dismissedSurfaceIds}
|
||||||
|
onSurfaceDismiss={dismissSurface}
|
||||||
|
onSurfaceAction={dispatchAction}
|
||||||
|
onSurfaceDataChange={updateLocalData}
|
||||||
|
currentTurnIndex={currentTurnIndex}
|
||||||
|
/>
|
||||||
|
|
||||||
{isStreaming && (streamingContent || toolEvents.length > 0) && (
|
{actionError && <p className="chat-surface-error">{actionError}</p>}
|
||||||
<div className="chat-message assistant streaming">
|
|
||||||
<div className="chat-message-avatar">{'\u{1F916}'}</div>
|
|
||||||
<div className="chat-message-content">
|
|
||||||
<div className="chat-message-header">
|
|
||||||
<span className="chat-message-role">{tr('chat.role.assistant')}</span>
|
|
||||||
<span className="streaming-indicator">{'\u25CF'}</span>
|
|
||||||
</div>
|
|
||||||
{renderToolMarkers(toolEvents)}
|
|
||||||
{streamingContent && (
|
|
||||||
<div className="chat-message-text">
|
|
||||||
<Markdown gfm>{streamingContent}</Markdown>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isStreaming && !streamingContent && toolEvents.length === 0 && (
|
|
||||||
<div className="chat-message assistant thinking">
|
|
||||||
<div className="chat-message-avatar">{'\u{1F916}'}</div>
|
|
||||||
<div className="chat-message-content">
|
|
||||||
<div className="chat-thinking-indicator">
|
|
||||||
<span></span><span></span><span></span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div ref={messagesEndRef} />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="chat-input-container">
|
<div className="chat-input-container">
|
||||||
@@ -484,7 +404,7 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ conversationId }) => {
|
|||||||
<div className="chat-input-wrapper">
|
<div className="chat-input-wrapper">
|
||||||
<textarea
|
<textarea
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
className="chat-input"
|
className="chat-input chat-surface-input"
|
||||||
value={inputValue}
|
value={inputValue}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setInputValue(e.target.value);
|
setInputValue(e.target.value);
|
||||||
|
|||||||
225
src/renderer/components/ChatSurface/ChatTranscript.tsx
Normal file
225
src/renderer/components/ChatSurface/ChatTranscript.tsx
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import Markdown from 'marked-react';
|
||||||
|
import type { ChatMessage } from '../../types/electron';
|
||||||
|
import type { ChatToolEvent } from '../../navigation/useChatSurfaceState';
|
||||||
|
import type { A2UIResolvedComponent, A2UIClientAction } from '../../../main/a2ui/types';
|
||||||
|
import { InlineSurface } from '../../a2ui/InlineSurface';
|
||||||
|
import type { SurfaceEntry } from '../../a2ui/useA2UISurface';
|
||||||
|
import { computeTurnIndex } from '../../a2ui/surfaceAssociation';
|
||||||
|
|
||||||
|
interface ChatTranscriptProps {
|
||||||
|
messages: ChatMessage[];
|
||||||
|
isStreaming: boolean;
|
||||||
|
streamingContent: string;
|
||||||
|
toolEvents: ChatToolEvent[];
|
||||||
|
assistantRoleLabel: string;
|
||||||
|
userRoleLabel: string;
|
||||||
|
showToolMarkers?: boolean;
|
||||||
|
endRef?: React.RefObject<HTMLDivElement | null>;
|
||||||
|
/** Surfaces grouped by the turn index that created them */
|
||||||
|
surfacesByTurn?: Map<number, SurfaceEntry[]>;
|
||||||
|
/** The surfaceId of the most recently created surface */
|
||||||
|
latestSurfaceId?: string | null;
|
||||||
|
/** Set of surface IDs the user has dismissed */
|
||||||
|
dismissedSurfaceIds?: Set<string>;
|
||||||
|
/** Callback to dismiss a surface */
|
||||||
|
onSurfaceDismiss?: (surfaceId: string) => void;
|
||||||
|
/** Callback for surface actions */
|
||||||
|
onSurfaceAction?: (action: A2UIClientAction) => void;
|
||||||
|
/** Callback for surface data changes */
|
||||||
|
onSurfaceDataChange?: (surfaceId: string, path: string, value: unknown) => void;
|
||||||
|
/** The current streaming turn index */
|
||||||
|
currentTurnIndex?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ChatTranscript: React.FC<ChatTranscriptProps> = ({
|
||||||
|
messages,
|
||||||
|
isStreaming,
|
||||||
|
streamingContent,
|
||||||
|
toolEvents,
|
||||||
|
assistantRoleLabel,
|
||||||
|
userRoleLabel,
|
||||||
|
showToolMarkers = true,
|
||||||
|
endRef,
|
||||||
|
surfacesByTurn,
|
||||||
|
latestSurfaceId,
|
||||||
|
dismissedSurfaceIds,
|
||||||
|
onSurfaceDismiss,
|
||||||
|
onSurfaceAction,
|
||||||
|
onSurfaceDataChange,
|
||||||
|
currentTurnIndex,
|
||||||
|
}) => {
|
||||||
|
const renderToolMarkers = (events: ChatToolEvent[]) => {
|
||||||
|
if (events.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const markers: Array<{ name: string; args?: unknown; completed: boolean }> = [];
|
||||||
|
|
||||||
|
for (const event of events) {
|
||||||
|
if (event.type === 'call') {
|
||||||
|
markers.push({ name: event.name, args: event.args, completed: false });
|
||||||
|
} else if (event.type === 'result') {
|
||||||
|
for (let markerIndex = markers.length - 1; markerIndex >= 0; markerIndex -= 1) {
|
||||||
|
if (markers[markerIndex].name === event.name && !markers[markerIndex].completed) {
|
||||||
|
markers[markerIndex].completed = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="tool-markers">
|
||||||
|
{markers.map((marker, index) => {
|
||||||
|
const argsPreview = marker.args
|
||||||
|
? Object.entries(marker.args as Record<string, unknown>)
|
||||||
|
.map(([key, value]) => `${key}: ${typeof value === 'string' ? `"${value.length > 30 ? value.slice(0, 30) + '...' : value}"` : JSON.stringify(value)}`)
|
||||||
|
.join(', ')
|
||||||
|
: '';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={`${marker.name}-${index}`} className={`tool-marker ${marker.completed ? 'completed' : 'pending'}`}>
|
||||||
|
<span className="tool-marker-icon">{marker.completed ? '\u2713' : '\u25CF'}</span>
|
||||||
|
<span className="tool-marker-name">{marker.name}</span>
|
||||||
|
{argsPreview && <span className="tool-marker-args">({argsPreview})</span>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderInlineSurfaces = (turnIndex: number) => {
|
||||||
|
if (!surfacesByTurn?.has(turnIndex)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const turnSurfaces = surfacesByTurn.get(turnIndex)!;
|
||||||
|
const visibleSurfaces = turnSurfaces.filter(
|
||||||
|
(s) => !dismissedSurfaceIds?.has(s.surfaceId),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (visibleSurfaces.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return visibleSurfaces.map((surface) => (
|
||||||
|
<InlineSurface
|
||||||
|
key={surface.surfaceId}
|
||||||
|
surfaceId={surface.surfaceId}
|
||||||
|
tree={surface.tree}
|
||||||
|
isExpanded={surface.surfaceId === latestSurfaceId}
|
||||||
|
onDismiss={onSurfaceDismiss}
|
||||||
|
onAction={onSurfaceAction}
|
||||||
|
onDataChange={onSurfaceDataChange}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderMessage = (message: ChatMessage, messageIndex: number) => {
|
||||||
|
if (message.role === 'system' || message.role === 'tool') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const storedToolCalls: Array<{ name: string; args?: unknown; completed: boolean }> = [];
|
||||||
|
if (message.role === 'assistant' && message.toolCalls) {
|
||||||
|
try {
|
||||||
|
const parsedToolCalls = JSON.parse(message.toolCalls) as Array<{ name: string; args?: unknown }>;
|
||||||
|
parsedToolCalls.forEach((toolCall) => storedToolCalls.push({ name: toolCall.name, args: toolCall.args, completed: true }));
|
||||||
|
} catch {
|
||||||
|
// no-op
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const messageEl = (
|
||||||
|
<div key={message.id} className={`chat-message ${message.role}`}>
|
||||||
|
<div className="chat-message-avatar">
|
||||||
|
{message.role === 'user' ? '\u{1F464}' : '\u{1F916}'}
|
||||||
|
</div>
|
||||||
|
<div className="chat-message-content">
|
||||||
|
<div className="chat-message-header">
|
||||||
|
<span className="chat-message-role">{message.role === 'user' ? userRoleLabel : assistantRoleLabel}</span>
|
||||||
|
</div>
|
||||||
|
{showToolMarkers && storedToolCalls.length > 0 && (
|
||||||
|
<div className="tool-markers">
|
||||||
|
{storedToolCalls.map((marker, markerIndex) => {
|
||||||
|
const argsPreview = marker.args
|
||||||
|
? Object.entries(marker.args as Record<string, unknown>)
|
||||||
|
.map(([key, value]) => `${key}: ${typeof value === 'string' ? `"${value.length > 30 ? value.slice(0, 30) + '...' : value}"` : JSON.stringify(value)}`)
|
||||||
|
.join(', ')
|
||||||
|
: '';
|
||||||
|
return (
|
||||||
|
<div key={`${marker.name}-${markerIndex}`} className="tool-marker completed">
|
||||||
|
<span className="tool-marker-icon">{'\u2713'}</span>
|
||||||
|
<span className="tool-marker-name">{marker.name}</span>
|
||||||
|
{argsPreview && <span className="tool-marker-args">({argsPreview})</span>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="chat-message-text">
|
||||||
|
{message.role === 'assistant' ? <Markdown gfm>{message.content}</Markdown> : message.content}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
// After an assistant message, render any inline surfaces for this turn
|
||||||
|
if (message.role === 'assistant' && surfacesByTurn) {
|
||||||
|
const turnIndex = computeTurnIndex(messages, messageIndex);
|
||||||
|
const inlineSurfaces = renderInlineSurfaces(turnIndex);
|
||||||
|
if (inlineSurfaces) {
|
||||||
|
return (
|
||||||
|
<React.Fragment key={message.id}>
|
||||||
|
{messageEl}
|
||||||
|
{inlineSurfaces}
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return messageEl;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{messages.map((message, index) => renderMessage(message, index))}
|
||||||
|
|
||||||
|
{isStreaming && (streamingContent || (showToolMarkers && toolEvents.length > 0)) && (
|
||||||
|
<>
|
||||||
|
<div className="chat-message assistant streaming">
|
||||||
|
<div className="chat-message-avatar">{'\u{1F916}'}</div>
|
||||||
|
<div className="chat-message-content">
|
||||||
|
<div className="chat-message-header">
|
||||||
|
<span className="chat-message-role">{assistantRoleLabel}</span>
|
||||||
|
<span className="streaming-indicator">{'\u25CF'}</span>
|
||||||
|
</div>
|
||||||
|
{showToolMarkers ? renderToolMarkers(toolEvents) : null}
|
||||||
|
{streamingContent && (
|
||||||
|
<div className="chat-message-text">
|
||||||
|
<Markdown gfm>{streamingContent}</Markdown>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{currentTurnIndex !== undefined && renderInlineSurfaces(currentTurnIndex)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isStreaming && !streamingContent && (!showToolMarkers || toolEvents.length === 0) && (
|
||||||
|
<div className="chat-message assistant thinking">
|
||||||
|
<div className="chat-message-avatar">{'\u{1F916}'}</div>
|
||||||
|
<div className="chat-message-content">
|
||||||
|
<div className="chat-thinking-indicator">
|
||||||
|
<span></span><span></span><span></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div ref={endRef} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
1
src/renderer/components/ChatSurface/index.ts
Normal file
1
src/renderer/components/ChatSurface/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { ChatTranscript } from './ChatTranscript';
|
||||||
@@ -236,6 +236,41 @@
|
|||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.window-titlebar-assistant-icon {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
border: 1.5px solid currentColor;
|
||||||
|
border-radius: 2px;
|
||||||
|
display: block;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.window-titlebar-assistant-icon::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
left: 66.6667%;
|
||||||
|
width: 1.5px;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
background-color: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
.window-titlebar-assistant-pane {
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
top: 0;
|
||||||
|
width: 33.3333%;
|
||||||
|
height: 100%;
|
||||||
|
background-color: currentColor;
|
||||||
|
transition: opacity 120ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.window-titlebar-assistant-icon.is-inactive .window-titlebar-assistant-pane {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.window-titlebar-action-button:hover {
|
.window-titlebar-action-button:hover {
|
||||||
background-color: var(--vscode-toolbar-hoverBackground, rgba(90, 93, 94, 0.31));
|
background-color: var(--vscode-toolbar-hoverBackground, rgba(90, 93, 94, 0.31));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ type WindowControlsOverlayLike = {
|
|||||||
|
|
||||||
export const WindowTitleBar: React.FC = () => {
|
export const WindowTitleBar: React.FC = () => {
|
||||||
const { language, t } = useI18n();
|
const { language, t } = useI18n();
|
||||||
const { sidebarVisible, panelVisible, toggleSidebar, togglePanel } = useAppStore();
|
const { sidebarVisible, panelVisible, assistantSidebarVisible, toggleSidebar, togglePanel, toggleAssistantSidebar } = useAppStore();
|
||||||
const [windowTitle, setWindowTitle] = useState<string>(document.title || 'Blogging Desktop Server');
|
const [windowTitle, setWindowTitle] = useState<string>(document.title || 'Blogging Desktop Server');
|
||||||
const [openMenu, setOpenMenu] = useState<{ label: string; left: number } | null>(null);
|
const [openMenu, setOpenMenu] = useState<{ label: string; left: number } | null>(null);
|
||||||
const [showMnemonics, setShowMnemonics] = useState<boolean>(false);
|
const [showMnemonics, setShowMnemonics] = useState<boolean>(false);
|
||||||
@@ -456,6 +456,20 @@ export const WindowTitleBar: React.FC = () => {
|
|||||||
<span className="window-titlebar-panel-pane" data-shape="bottom-half" />
|
<span className="window-titlebar-panel-pane" data-shape="bottom-half" />
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
className="window-titlebar-action-button"
|
||||||
|
aria-label={t('windowTitleBar.toggleAssistantSidebar')}
|
||||||
|
onClick={toggleAssistantSidebar}
|
||||||
|
title={assistantSidebarVisible ? t('windowTitleBar.hideAssistantSidebar') : t('windowTitleBar.showAssistantSidebar')}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`window-titlebar-assistant-icon ${assistantSidebarVisible ? 'is-active' : 'is-inactive'}`}
|
||||||
|
data-shape="frame-square"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<span className="window-titlebar-assistant-pane" data-shape="right-half" />
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{!isMac && openMenu && activeMenu && (
|
{!isMac && openMenu && activeMenu && (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -27,3 +27,4 @@ export { WindowTitleBar } from './WindowTitleBar';
|
|||||||
export { DocumentationView } from './DocumentationView/DocumentationView';
|
export { DocumentationView } from './DocumentationView/DocumentationView';
|
||||||
export { SiteValidationView } from './SiteValidationView';
|
export { SiteValidationView } from './SiteValidationView';
|
||||||
export { ScriptsView } from './ScriptsView/ScriptsView';
|
export { ScriptsView } from './ScriptsView/ScriptsView';
|
||||||
|
export { AssistantSidebar } from './AssistantSidebar';
|
||||||
|
|||||||
@@ -196,12 +196,12 @@
|
|||||||
"chat.apiKeyValidationFailed": "API-Schlüssel konnte nicht validiert werden.",
|
"chat.apiKeyValidationFailed": "API-Schlüssel konnte nicht validiert werden.",
|
||||||
"chat.newChat": "Neuer Chat",
|
"chat.newChat": "Neuer Chat",
|
||||||
"chat.welcomeTitle": "Willkommen beim KI-Assistenten",
|
"chat.welcomeTitle": "Willkommen beim KI-Assistenten",
|
||||||
"chat.welcomeDescription": "Ich kann dir helfen, deine Beiträge und Medien zu verwalten. Frag mich zum Beispiel:",
|
"chat.welcomeDescription": "Ich kann dir helfen, deinen Blog mit anschaulichen Darstellungen zu verwalten. Frag mich zum Beispiel:",
|
||||||
"chat.welcomeTipSearch": "Nach Beiträgen zu einem bestimmten Thema suchen",
|
"chat.welcomeTipSearch": "Nach Beiträgen zu einem bestimmten Thema suchen",
|
||||||
"chat.welcomeTipDetails": "Details zu einem bestimmten Beitrag anzeigen",
|
"chat.welcomeTipChart": "Ein Diagramm der pro Monat veröffentlichten Beiträge anzeigen",
|
||||||
"chat.welcomeTipTags": "Alle Tags oder Kategorien in deinem Blog auflisten",
|
"chat.welcomeTipTable": "Meine letzten Beiträge in einer Tabelle vergleichen",
|
||||||
"chat.welcomeTipMetadata": "Metadaten für Beiträge oder Medien aktualisieren",
|
"chat.welcomeTipMetadata": "Metadaten für Beiträge oder Medien aktualisieren",
|
||||||
"chat.welcomeTipImages": "Alle Bilder in deiner Mediathek auflisten",
|
"chat.welcomeTipTabs": "Beitragsstatistiken nach Jahr in Tabs mit Diagrammen anzeigen",
|
||||||
"chat.role.you": "Du",
|
"chat.role.you": "Du",
|
||||||
"chat.role.assistant": "Assistent",
|
"chat.role.assistant": "Assistent",
|
||||||
"chat.stop": "Stopp",
|
"chat.stop": "Stopp",
|
||||||
@@ -528,6 +528,9 @@
|
|||||||
"dashboard.stats.images": "{count} Bilder",
|
"dashboard.stats.images": "{count} Bilder",
|
||||||
"dashboard.stats.tags": "Schlagwörter",
|
"dashboard.stats.tags": "Schlagwörter",
|
||||||
"dashboard.stats.categories": "{count} Kategorien",
|
"dashboard.stats.categories": "{count} Kategorien",
|
||||||
|
"dashboard.stats.protocolHealth": "Protokollzustand",
|
||||||
|
"dashboard.stats.blockedActions": "{count} blockierte Aktionen",
|
||||||
|
"dashboard.stats.fallbackTurns": "{count} Fallback-Durchläufe",
|
||||||
"dashboard.section.postsOverTime": "Beiträge im Zeitverlauf",
|
"dashboard.section.postsOverTime": "Beiträge im Zeitverlauf",
|
||||||
"dashboard.section.tags": "Schlagwörter",
|
"dashboard.section.tags": "Schlagwörter",
|
||||||
"dashboard.section.categories": "Kategorien",
|
"dashboard.section.categories": "Kategorien",
|
||||||
@@ -830,6 +833,19 @@
|
|||||||
"windowTitleBar.togglePanel": "Panel umschalten",
|
"windowTitleBar.togglePanel": "Panel umschalten",
|
||||||
"windowTitleBar.hidePanel": "Panel ausblenden (Ctrl+J)",
|
"windowTitleBar.hidePanel": "Panel ausblenden (Ctrl+J)",
|
||||||
"windowTitleBar.showPanel": "Panel anzeigen (Ctrl+J)",
|
"windowTitleBar.showPanel": "Panel anzeigen (Ctrl+J)",
|
||||||
|
"windowTitleBar.toggleAssistantSidebar": "Assistenz-Seitenleiste umschalten",
|
||||||
|
"windowTitleBar.hideAssistantSidebar": "Assistenz-Seitenleiste ausblenden (Ctrl+\\)",
|
||||||
|
"windowTitleBar.showAssistantSidebar": "Assistenz-Seitenleiste anzeigen (Ctrl+\\)",
|
||||||
|
"assistantSidebar.title": "KI-Assistent",
|
||||||
|
"assistantSidebar.description": "Starten Sie mit einem gezielten Prompt inklusive aktuellem Editor-Kontext.",
|
||||||
|
"assistantSidebar.context.label": "Aktueller Kontext",
|
||||||
|
"assistantSidebar.context.none": "Kein aktiver Editor-Kontext",
|
||||||
|
"assistantSidebar.prompt.placeholder": "Fragen Sie den Assistenten nach Analyse oder Abfragen Ihres aktuellen Stands…",
|
||||||
|
"assistantSidebar.button.start": "Mit Kontext starten",
|
||||||
|
"assistantSidebar.button.starting": "Startet…",
|
||||||
|
"assistantSidebar.conversationTitle": "Assistent-Sitzung",
|
||||||
|
"assistantSidebar.error.startFailed": "Assistent-Sitzung konnte nicht gestartet werden",
|
||||||
|
"assistantSidebar.error.actionFailed": "Assistent-Aktion konnte nicht ausgeführt werden",
|
||||||
"tagInput.alreadyAdded": "Tag bereits hinzugefügt",
|
"tagInput.alreadyAdded": "Tag bereits hinzugefügt",
|
||||||
"tagInput.remove": "{tag} entfernen",
|
"tagInput.remove": "{tag} entfernen",
|
||||||
"tagInput.createdTag": "Tag \"{name}\" erstellt",
|
"tagInput.createdTag": "Tag \"{name}\" erstellt",
|
||||||
|
|||||||
@@ -196,12 +196,12 @@
|
|||||||
"chat.apiKeyValidationFailed": "Failed to validate API key.",
|
"chat.apiKeyValidationFailed": "Failed to validate API key.",
|
||||||
"chat.newChat": "New Chat",
|
"chat.newChat": "New Chat",
|
||||||
"chat.welcomeTitle": "Welcome to the AI Assistant",
|
"chat.welcomeTitle": "Welcome to the AI Assistant",
|
||||||
"chat.welcomeDescription": "I can help you manage your posts and media. Try asking me to:",
|
"chat.welcomeDescription": "I can help you manage your blog with rich visualizations. Try asking me to:",
|
||||||
"chat.welcomeTipSearch": "Search for posts about a specific topic",
|
"chat.welcomeTipSearch": "Search for posts about a specific topic",
|
||||||
"chat.welcomeTipDetails": "Get details about a specific post",
|
"chat.welcomeTipChart": "Show a chart of posts published per month",
|
||||||
"chat.welcomeTipTags": "List all tags or categories in your blog",
|
"chat.welcomeTipTable": "Compare my recent posts in a table",
|
||||||
"chat.welcomeTipMetadata": "Update metadata for posts or media",
|
"chat.welcomeTipMetadata": "Update metadata for posts or media",
|
||||||
"chat.welcomeTipImages": "List all images in your media library",
|
"chat.welcomeTipTabs": "Show post statistics by year in tabs with charts",
|
||||||
"chat.role.you": "You",
|
"chat.role.you": "You",
|
||||||
"chat.role.assistant": "Assistant",
|
"chat.role.assistant": "Assistant",
|
||||||
"chat.stop": "Stop",
|
"chat.stop": "Stop",
|
||||||
@@ -528,6 +528,9 @@
|
|||||||
"dashboard.stats.images": "{count} images",
|
"dashboard.stats.images": "{count} images",
|
||||||
"dashboard.stats.tags": "Tags",
|
"dashboard.stats.tags": "Tags",
|
||||||
"dashboard.stats.categories": "{count} categories",
|
"dashboard.stats.categories": "{count} categories",
|
||||||
|
"dashboard.stats.protocolHealth": "Protocol Health",
|
||||||
|
"dashboard.stats.blockedActions": "{count} blocked actions",
|
||||||
|
"dashboard.stats.fallbackTurns": "{count} fallback turns",
|
||||||
"dashboard.section.postsOverTime": "Posts Over Time",
|
"dashboard.section.postsOverTime": "Posts Over Time",
|
||||||
"dashboard.section.tags": "Tags",
|
"dashboard.section.tags": "Tags",
|
||||||
"dashboard.section.categories": "Categories",
|
"dashboard.section.categories": "Categories",
|
||||||
@@ -830,6 +833,19 @@
|
|||||||
"windowTitleBar.togglePanel": "Toggle Panel",
|
"windowTitleBar.togglePanel": "Toggle Panel",
|
||||||
"windowTitleBar.hidePanel": "Hide Panel (Ctrl+J)",
|
"windowTitleBar.hidePanel": "Hide Panel (Ctrl+J)",
|
||||||
"windowTitleBar.showPanel": "Show Panel (Ctrl+J)",
|
"windowTitleBar.showPanel": "Show Panel (Ctrl+J)",
|
||||||
|
"windowTitleBar.toggleAssistantSidebar": "Toggle Assistant Sidebar",
|
||||||
|
"windowTitleBar.hideAssistantSidebar": "Hide Assistant Sidebar (Ctrl+\\)",
|
||||||
|
"windowTitleBar.showAssistantSidebar": "Show Assistant Sidebar (Ctrl+\\)",
|
||||||
|
"assistantSidebar.title": "AI Assistant",
|
||||||
|
"assistantSidebar.description": "Start with a focused prompt and include your current editor context.",
|
||||||
|
"assistantSidebar.context.label": "Current context",
|
||||||
|
"assistantSidebar.context.none": "No active editor context",
|
||||||
|
"assistantSidebar.prompt.placeholder": "Ask the assistant to analyze or query your current work…",
|
||||||
|
"assistantSidebar.button.start": "Start with context",
|
||||||
|
"assistantSidebar.button.starting": "Starting…",
|
||||||
|
"assistantSidebar.conversationTitle": "Assistant Session",
|
||||||
|
"assistantSidebar.error.startFailed": "Failed to start assistant session",
|
||||||
|
"assistantSidebar.error.actionFailed": "Assistant action could not be executed",
|
||||||
"tagInput.alreadyAdded": "Tag already added",
|
"tagInput.alreadyAdded": "Tag already added",
|
||||||
"tagInput.remove": "Remove {tag}",
|
"tagInput.remove": "Remove {tag}",
|
||||||
"tagInput.createdTag": "Tag \"{name}\" created",
|
"tagInput.createdTag": "Tag \"{name}\" created",
|
||||||
|
|||||||
@@ -196,12 +196,12 @@
|
|||||||
"chat.apiKeyValidationFailed": "No se pudo validar la clave API.",
|
"chat.apiKeyValidationFailed": "No se pudo validar la clave API.",
|
||||||
"chat.newChat": "Nuevo chat",
|
"chat.newChat": "Nuevo chat",
|
||||||
"chat.welcomeTitle": "Bienvenido al asistente de IA",
|
"chat.welcomeTitle": "Bienvenido al asistente de IA",
|
||||||
"chat.welcomeDescription": "Puedo ayudarte a gestionar tus entradas y medios. Prueba a pedirme que:",
|
"chat.welcomeDescription": "Puedo ayudarte a gestionar tu blog con visualizaciones interactivas. Prueba a pedirme que:",
|
||||||
"chat.welcomeTipSearch": "Busque entradas sobre un tema específico",
|
"chat.welcomeTipSearch": "Busque entradas sobre un tema específico",
|
||||||
"chat.welcomeTipDetails": "Muestre detalles de una entrada específica",
|
"chat.welcomeTipChart": "Muestre un gráfico de entradas publicadas por mes",
|
||||||
"chat.welcomeTipTags": "Lista todas las etiquetas o categorías de tu blog",
|
"chat.welcomeTipTable": "Compare mis entradas recientes en una tabla",
|
||||||
"chat.welcomeTipMetadata": "Actualice metadatos de entradas o medios",
|
"chat.welcomeTipMetadata": "Actualice metadatos de entradas o medios",
|
||||||
"chat.welcomeTipImages": "Liste todas las imágenes de tu biblioteca de medios",
|
"chat.welcomeTipTabs": "Muestre estadísticas por año en pestañas con gráficos",
|
||||||
"chat.role.you": "Tú",
|
"chat.role.you": "Tú",
|
||||||
"chat.role.assistant": "Asistente",
|
"chat.role.assistant": "Asistente",
|
||||||
"chat.stop": "Detener",
|
"chat.stop": "Detener",
|
||||||
@@ -528,6 +528,9 @@
|
|||||||
"dashboard.stats.images": "{count} imágenes",
|
"dashboard.stats.images": "{count} imágenes",
|
||||||
"dashboard.stats.tags": "Etiquetas",
|
"dashboard.stats.tags": "Etiquetas",
|
||||||
"dashboard.stats.categories": "{count} categorías",
|
"dashboard.stats.categories": "{count} categorías",
|
||||||
|
"dashboard.stats.protocolHealth": "Salud del protocolo",
|
||||||
|
"dashboard.stats.blockedActions": "{count} acciones bloqueadas",
|
||||||
|
"dashboard.stats.fallbackTurns": "{count} respuestas de respaldo",
|
||||||
"dashboard.section.postsOverTime": "Entradas a lo largo del tiempo",
|
"dashboard.section.postsOverTime": "Entradas a lo largo del tiempo",
|
||||||
"dashboard.section.tags": "Etiquetas",
|
"dashboard.section.tags": "Etiquetas",
|
||||||
"dashboard.section.categories": "Categorías",
|
"dashboard.section.categories": "Categorías",
|
||||||
@@ -830,6 +833,19 @@
|
|||||||
"windowTitleBar.togglePanel": "Alternar panel",
|
"windowTitleBar.togglePanel": "Alternar panel",
|
||||||
"windowTitleBar.hidePanel": "Ocultar panel",
|
"windowTitleBar.hidePanel": "Ocultar panel",
|
||||||
"windowTitleBar.showPanel": "Mostrar panel",
|
"windowTitleBar.showPanel": "Mostrar panel",
|
||||||
|
"windowTitleBar.toggleAssistantSidebar": "Alternar barra del asistente",
|
||||||
|
"windowTitleBar.hideAssistantSidebar": "Ocultar barra del asistente (Ctrl+\\)",
|
||||||
|
"windowTitleBar.showAssistantSidebar": "Mostrar barra del asistente (Ctrl+\\)",
|
||||||
|
"assistantSidebar.title": "Asistente IA",
|
||||||
|
"assistantSidebar.description": "Comienza con un prompt enfocado y enriquecido con el contexto actual del editor.",
|
||||||
|
"assistantSidebar.context.label": "Contexto actual",
|
||||||
|
"assistantSidebar.context.none": "Sin contexto de editor activo",
|
||||||
|
"assistantSidebar.prompt.placeholder": "Pide al asistente analizar o consultar tu trabajo actual…",
|
||||||
|
"assistantSidebar.button.start": "Iniciar con contexto",
|
||||||
|
"assistantSidebar.button.starting": "Iniciando…",
|
||||||
|
"assistantSidebar.conversationTitle": "Sesión de asistente",
|
||||||
|
"assistantSidebar.error.startFailed": "No se pudo iniciar la sesión del asistente",
|
||||||
|
"assistantSidebar.error.actionFailed": "No se pudo ejecutar la acción del asistente",
|
||||||
"tagInput.alreadyAdded": "La etiqueta “{tag}” ya está añadida",
|
"tagInput.alreadyAdded": "La etiqueta “{tag}” ya está añadida",
|
||||||
"tagInput.remove": "Quitar",
|
"tagInput.remove": "Quitar",
|
||||||
"tagInput.createdTag": "Etiqueta “{tag}” creada",
|
"tagInput.createdTag": "Etiqueta “{tag}” creada",
|
||||||
|
|||||||
@@ -196,12 +196,12 @@
|
|||||||
"chat.apiKeyValidationFailed": "Impossible de valider la clé API.",
|
"chat.apiKeyValidationFailed": "Impossible de valider la clé API.",
|
||||||
"chat.newChat": "Nouveau chat",
|
"chat.newChat": "Nouveau chat",
|
||||||
"chat.welcomeTitle": "Bienvenue dans l’assistant IA",
|
"chat.welcomeTitle": "Bienvenue dans l’assistant IA",
|
||||||
"chat.welcomeDescription": "Je peux vous aider à gérer vos articles et médias. Essayez par exemple :",
|
"chat.welcomeDescription": "Je peux vous aider à gérer votre blog avec des visualisations riches. Essayez par exemple :",
|
||||||
"chat.welcomeTipSearch": "Rechercher des articles sur un sujet précis",
|
"chat.welcomeTipSearch": "Rechercher des articles sur un sujet précis",
|
||||||
"chat.welcomeTipDetails": "Afficher les détails d’un article précis",
|
"chat.welcomeTipChart": "Afficher un graphique des articles publiés par mois",
|
||||||
"chat.welcomeTipTags": "Lister toutes les étiquettes ou catégories de votre blog",
|
"chat.welcomeTipTable": "Comparer mes derniers articles dans un tableau",
|
||||||
"chat.welcomeTipMetadata": "Mettre à jour les métadonnées des articles ou médias",
|
"chat.welcomeTipMetadata": "Mettre à jour les métadonnées des articles ou médias",
|
||||||
"chat.welcomeTipImages": "Lister toutes les images de votre bibliothèque média",
|
"chat.welcomeTipTabs": "Afficher les statistiques par année dans des onglets avec graphiques",
|
||||||
"chat.role.you": "Vous",
|
"chat.role.you": "Vous",
|
||||||
"chat.role.assistant": "Assistant IA",
|
"chat.role.assistant": "Assistant IA",
|
||||||
"chat.stop": "Arrêter",
|
"chat.stop": "Arrêter",
|
||||||
@@ -528,6 +528,9 @@
|
|||||||
"dashboard.stats.images": "{count} images",
|
"dashboard.stats.images": "{count} images",
|
||||||
"dashboard.stats.tags": "Étiquettes",
|
"dashboard.stats.tags": "Étiquettes",
|
||||||
"dashboard.stats.categories": "{count} catégories",
|
"dashboard.stats.categories": "{count} catégories",
|
||||||
|
"dashboard.stats.protocolHealth": "Santé du protocole",
|
||||||
|
"dashboard.stats.blockedActions": "{count} actions bloquées",
|
||||||
|
"dashboard.stats.fallbackTurns": "{count} tours de secours",
|
||||||
"dashboard.section.postsOverTime": "Articles dans le temps",
|
"dashboard.section.postsOverTime": "Articles dans le temps",
|
||||||
"dashboard.section.tags": "Étiquettes",
|
"dashboard.section.tags": "Étiquettes",
|
||||||
"dashboard.section.categories": "Catégories",
|
"dashboard.section.categories": "Catégories",
|
||||||
@@ -830,6 +833,19 @@
|
|||||||
"windowTitleBar.togglePanel": "Basculer le panneau",
|
"windowTitleBar.togglePanel": "Basculer le panneau",
|
||||||
"windowTitleBar.hidePanel": "Masquer le panneau",
|
"windowTitleBar.hidePanel": "Masquer le panneau",
|
||||||
"windowTitleBar.showPanel": "Afficher le panneau",
|
"windowTitleBar.showPanel": "Afficher le panneau",
|
||||||
|
"windowTitleBar.toggleAssistantSidebar": "Basculer le panneau Assistant",
|
||||||
|
"windowTitleBar.hideAssistantSidebar": "Masquer le panneau Assistant (Ctrl+\\)",
|
||||||
|
"windowTitleBar.showAssistantSidebar": "Afficher le panneau Assistant (Ctrl+\\)",
|
||||||
|
"assistantSidebar.title": "Assistant IA",
|
||||||
|
"assistantSidebar.description": "Commencez avec une requête ciblée enrichie du contexte éditeur actuel.",
|
||||||
|
"assistantSidebar.context.label": "Contexte actuel",
|
||||||
|
"assistantSidebar.context.none": "Aucun contexte éditeur actif",
|
||||||
|
"assistantSidebar.prompt.placeholder": "Demandez à l’assistant d’analyser ou d’interroger votre travail en cours…",
|
||||||
|
"assistantSidebar.button.start": "Démarrer avec contexte",
|
||||||
|
"assistantSidebar.button.starting": "Démarrage…",
|
||||||
|
"assistantSidebar.conversationTitle": "Session Assistant",
|
||||||
|
"assistantSidebar.error.startFailed": "Impossible de démarrer la session assistant",
|
||||||
|
"assistantSidebar.error.actionFailed": "L’action assistant n’a pas pu être exécutée",
|
||||||
"tagInput.alreadyAdded": "Le tag « {tag} » est déjà ajouté",
|
"tagInput.alreadyAdded": "Le tag « {tag} » est déjà ajouté",
|
||||||
"tagInput.remove": "Supprimer",
|
"tagInput.remove": "Supprimer",
|
||||||
"tagInput.createdTag": "Tag « {tag} » créé",
|
"tagInput.createdTag": "Tag « {tag} » créé",
|
||||||
|
|||||||
@@ -196,12 +196,12 @@
|
|||||||
"chat.apiKeyValidationFailed": "Impossibile convalidare la chiave API.",
|
"chat.apiKeyValidationFailed": "Impossibile convalidare la chiave API.",
|
||||||
"chat.newChat": "Nuova chat",
|
"chat.newChat": "Nuova chat",
|
||||||
"chat.welcomeTitle": "Benvenuto nell’assistente IA",
|
"chat.welcomeTitle": "Benvenuto nell’assistente IA",
|
||||||
"chat.welcomeDescription": "Posso aiutarti a gestire post e media. Prova a chiedermi di:",
|
"chat.welcomeDescription": "Posso aiutarti a gestire il tuo blog con visualizzazioni interattive. Prova a chiedermi di:",
|
||||||
"chat.welcomeTipSearch": "Cercare post su un argomento specifico",
|
"chat.welcomeTipSearch": "Cercare post su un argomento specifico",
|
||||||
"chat.welcomeTipDetails": "Ottieni dettagli su un post specifico",
|
"chat.welcomeTipChart": "Mostrare un grafico dei post pubblicati per mese",
|
||||||
"chat.welcomeTipTags": "Elenca tutti i tag o le categorie del tuo blog",
|
"chat.welcomeTipTable": "Confrontare i miei post recenti in una tabella",
|
||||||
"chat.welcomeTipMetadata": "Aggiornare i metadati di post o media",
|
"chat.welcomeTipMetadata": "Aggiornare i metadati di post o media",
|
||||||
"chat.welcomeTipImages": "Elenca tutte le immagini nella tua libreria media",
|
"chat.welcomeTipTabs": "Mostrare statistiche per anno in schede con grafici",
|
||||||
"chat.role.you": "Tu",
|
"chat.role.you": "Tu",
|
||||||
"chat.role.assistant": "Assistente",
|
"chat.role.assistant": "Assistente",
|
||||||
"chat.stop": "Ferma",
|
"chat.stop": "Ferma",
|
||||||
@@ -528,6 +528,9 @@
|
|||||||
"dashboard.stats.images": "{count} immagini",
|
"dashboard.stats.images": "{count} immagini",
|
||||||
"dashboard.stats.tags": "Tag",
|
"dashboard.stats.tags": "Tag",
|
||||||
"dashboard.stats.categories": "{count} categorie",
|
"dashboard.stats.categories": "{count} categorie",
|
||||||
|
"dashboard.stats.protocolHealth": "Salute del protocollo",
|
||||||
|
"dashboard.stats.blockedActions": "{count} azioni bloccate",
|
||||||
|
"dashboard.stats.fallbackTurns": "{count} risposte di fallback",
|
||||||
"dashboard.section.postsOverTime": "Post nel tempo",
|
"dashboard.section.postsOverTime": "Post nel tempo",
|
||||||
"dashboard.section.tags": "Tag",
|
"dashboard.section.tags": "Tag",
|
||||||
"dashboard.section.categories": "Categorie",
|
"dashboard.section.categories": "Categorie",
|
||||||
@@ -830,6 +833,19 @@
|
|||||||
"windowTitleBar.togglePanel": "Mostra/Nascondi pannello",
|
"windowTitleBar.togglePanel": "Mostra/Nascondi pannello",
|
||||||
"windowTitleBar.hidePanel": "Nascondi pannello",
|
"windowTitleBar.hidePanel": "Nascondi pannello",
|
||||||
"windowTitleBar.showPanel": "Mostra pannello",
|
"windowTitleBar.showPanel": "Mostra pannello",
|
||||||
|
"windowTitleBar.toggleAssistantSidebar": "Mostra/Nascondi barra assistente",
|
||||||
|
"windowTitleBar.hideAssistantSidebar": "Nascondi barra assistente (Ctrl+\\)",
|
||||||
|
"windowTitleBar.showAssistantSidebar": "Mostra barra assistente (Ctrl+\\)",
|
||||||
|
"assistantSidebar.title": "Assistente IA",
|
||||||
|
"assistantSidebar.description": "Inizia con un prompt mirato arricchito dal contesto editor corrente.",
|
||||||
|
"assistantSidebar.context.label": "Contesto attuale",
|
||||||
|
"assistantSidebar.context.none": "Nessun contesto editor attivo",
|
||||||
|
"assistantSidebar.prompt.placeholder": "Chiedi all’assistente di analizzare o interrogare il lavoro corrente…",
|
||||||
|
"assistantSidebar.button.start": "Avvia con contesto",
|
||||||
|
"assistantSidebar.button.starting": "Avvio…",
|
||||||
|
"assistantSidebar.conversationTitle": "Sessione assistente",
|
||||||
|
"assistantSidebar.error.startFailed": "Impossibile avviare la sessione assistente",
|
||||||
|
"assistantSidebar.error.actionFailed": "Impossibile eseguire l’azione dell’assistente",
|
||||||
"tagInput.alreadyAdded": "Il tag “{tag}” è già stato aggiunto",
|
"tagInput.alreadyAdded": "Il tag “{tag}” è già stato aggiunto",
|
||||||
"tagInput.remove": "Rimuovi",
|
"tagInput.remove": "Rimuovi",
|
||||||
"tagInput.createdTag": "Tag “{tag}” creato",
|
"tagInput.createdTag": "Tag “{tag}” creato",
|
||||||
|
|||||||
134
src/renderer/navigation/assistantActionDispatcher.ts
Normal file
134
src/renderer/navigation/assistantActionDispatcher.ts
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
import type { SidebarView } from './sidebarViewRegistry';
|
||||||
|
import type { TabType } from '../store/appStore';
|
||||||
|
import { isSidebarView } from './sidebarViewRegistry';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
export interface AssistantActionInput {
|
||||||
|
action: string;
|
||||||
|
payload?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AssistantActionDependencies {
|
||||||
|
setSelectedPost: (id: string | null) => void;
|
||||||
|
setSelectedMedia: (id: string | null) => void;
|
||||||
|
openTab: (tab: { type: TabType; id: string; isTransient: boolean }) => void;
|
||||||
|
setActiveView: (view: SidebarView) => void;
|
||||||
|
toggleSidebar: () => void;
|
||||||
|
togglePanel: () => void;
|
||||||
|
toggleAssistantSidebar: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AssistantActionResult {
|
||||||
|
handled: boolean;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const openPostPayloadSchema = z.object({
|
||||||
|
postId: z.string().min(1),
|
||||||
|
});
|
||||||
|
|
||||||
|
const openMediaPayloadSchema = z.object({
|
||||||
|
mediaId: z.string().min(1),
|
||||||
|
});
|
||||||
|
|
||||||
|
const switchViewPayloadSchema = z
|
||||||
|
.object({
|
||||||
|
view: z.string().min(1),
|
||||||
|
})
|
||||||
|
.refine((payload) => isSidebarView(payload.view), {
|
||||||
|
message: 'view must be a valid sidebar view',
|
||||||
|
});
|
||||||
|
|
||||||
|
const openChatPayloadSchema = z.object({
|
||||||
|
conversationId: z.string().min(1),
|
||||||
|
});
|
||||||
|
|
||||||
|
function invalidPayloadError(action: string): AssistantActionResult {
|
||||||
|
return {
|
||||||
|
handled: false,
|
||||||
|
error: `Invalid payload for ${action} action`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dispatchAssistantAction(
|
||||||
|
input: AssistantActionInput,
|
||||||
|
dependencies: AssistantActionDependencies,
|
||||||
|
): AssistantActionResult {
|
||||||
|
const payload = input.payload ?? {};
|
||||||
|
|
||||||
|
if (input.action === 'openPost') {
|
||||||
|
const parsed = openPostPayloadSchema.safeParse(payload);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return invalidPayloadError('openPost');
|
||||||
|
}
|
||||||
|
const { postId } = parsed.data;
|
||||||
|
|
||||||
|
dependencies.setActiveView('posts');
|
||||||
|
dependencies.setSelectedPost(postId);
|
||||||
|
dependencies.openTab({ type: 'post', id: postId, isTransient: false });
|
||||||
|
return { handled: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.action === 'openMedia') {
|
||||||
|
const parsed = openMediaPayloadSchema.safeParse(payload);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return invalidPayloadError('openMedia');
|
||||||
|
}
|
||||||
|
const { mediaId } = parsed.data;
|
||||||
|
|
||||||
|
dependencies.setActiveView('media');
|
||||||
|
dependencies.setSelectedMedia(mediaId);
|
||||||
|
dependencies.openTab({ type: 'media', id: mediaId, isTransient: false });
|
||||||
|
return { handled: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.action === 'switchView' || input.action === 'setActiveView') {
|
||||||
|
const parsed = switchViewPayloadSchema.safeParse(payload);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return invalidPayloadError(input.action);
|
||||||
|
}
|
||||||
|
const { view } = parsed.data;
|
||||||
|
|
||||||
|
dependencies.setActiveView(view as SidebarView);
|
||||||
|
return { handled: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.action === 'openChat') {
|
||||||
|
const parsed = openChatPayloadSchema.safeParse(payload);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return invalidPayloadError('openChat');
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies.setActiveView('chat');
|
||||||
|
dependencies.openTab({ type: 'chat', id: parsed.data.conversationId, isTransient: false });
|
||||||
|
return { handled: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.action === 'openSettings') {
|
||||||
|
dependencies.setActiveView('settings');
|
||||||
|
dependencies.openTab({ type: 'settings', id: 'settings', isTransient: false });
|
||||||
|
return { handled: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.action === 'toggleSidebar') {
|
||||||
|
dependencies.toggleSidebar();
|
||||||
|
return { handled: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.action === 'togglePanel') {
|
||||||
|
dependencies.togglePanel();
|
||||||
|
return { handled: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.action === 'openPanel') {
|
||||||
|
dependencies.togglePanel();
|
||||||
|
return { handled: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.action === 'toggleAssistantSidebar') {
|
||||||
|
dependencies.toggleAssistantSidebar();
|
||||||
|
return { handled: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { handled: false, error: `Unsupported action: ${input.action}` };
|
||||||
|
}
|
||||||
30
src/renderer/navigation/assistantConversation.ts
Normal file
30
src/renderer/navigation/assistantConversation.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import type { AssistantEditorContext } from './assistantPromptContext';
|
||||||
|
import { buildAssistantStartPrompt } from './assistantPromptContext';
|
||||||
|
|
||||||
|
export interface AssistantRequestPlan {
|
||||||
|
shouldCreateConversation: boolean;
|
||||||
|
outboundMessage: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function planAssistantRequest(input: {
|
||||||
|
conversationId: string | null;
|
||||||
|
userPrompt: string;
|
||||||
|
context: AssistantEditorContext | null;
|
||||||
|
}): AssistantRequestPlan {
|
||||||
|
const userPrompt = input.userPrompt.trim();
|
||||||
|
|
||||||
|
if (input.conversationId) {
|
||||||
|
return {
|
||||||
|
shouldCreateConversation: false,
|
||||||
|
outboundMessage: userPrompt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
shouldCreateConversation: true,
|
||||||
|
outboundMessage: buildAssistantStartPrompt({
|
||||||
|
userPrompt,
|
||||||
|
context: input.context,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
164
src/renderer/navigation/assistantPanelSpec.ts
Normal file
164
src/renderer/navigation/assistantPanelSpec.ts
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
const textElementSchema = z.object({
|
||||||
|
type: z.literal('text'),
|
||||||
|
text: z.string().min(1),
|
||||||
|
});
|
||||||
|
|
||||||
|
const metricElementSchema = z.object({
|
||||||
|
type: z.literal('metric'),
|
||||||
|
label: z.string().min(1),
|
||||||
|
value: z.string().min(1),
|
||||||
|
});
|
||||||
|
|
||||||
|
const listElementSchema = z.object({
|
||||||
|
type: z.literal('list'),
|
||||||
|
title: z.string().optional(),
|
||||||
|
items: z.array(z.string().min(1)).min(1),
|
||||||
|
});
|
||||||
|
|
||||||
|
const tableElementSchema = z.object({
|
||||||
|
type: z.literal('table'),
|
||||||
|
columns: z.array(z.string().min(1)).min(1),
|
||||||
|
rows: z.array(z.array(z.string())).min(1),
|
||||||
|
});
|
||||||
|
|
||||||
|
const actionElementSchema = z.object({
|
||||||
|
type: z.literal('action'),
|
||||||
|
label: z.string().min(1),
|
||||||
|
action: z.string().min(1),
|
||||||
|
payload: z.record(z.string(), z.unknown()).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const segmentSchema = z.object({
|
||||||
|
label: z.string().min(1),
|
||||||
|
value: z.number(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const chartElementSchema = z.object({
|
||||||
|
type: z.literal('chart'),
|
||||||
|
chartType: z.enum(['bar', 'stacked-bar', 'line', 'area', 'pie', 'donut', 'heatmap']),
|
||||||
|
title: z.string().min(1).optional(),
|
||||||
|
series: z.array(
|
||||||
|
z.object({
|
||||||
|
label: z.string().min(1),
|
||||||
|
value: z.number(),
|
||||||
|
segments: z.array(segmentSchema).optional(),
|
||||||
|
}),
|
||||||
|
).min(1),
|
||||||
|
});
|
||||||
|
|
||||||
|
const inputTypeSchema = z.enum(['text', 'textarea', 'select', 'checkbox', 'date', 'number']);
|
||||||
|
|
||||||
|
const inputOptionSchema = z.object({
|
||||||
|
label: z.string().min(1),
|
||||||
|
value: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const inputElementSchema = z.object({
|
||||||
|
type: z.literal('input'),
|
||||||
|
key: z.string().min(1),
|
||||||
|
label: z.string().min(1),
|
||||||
|
inputType: inputTypeSchema,
|
||||||
|
placeholder: z.string().optional(),
|
||||||
|
defaultValue: z.union([z.string(), z.number(), z.boolean()]).optional(),
|
||||||
|
options: z.array(inputOptionSchema).optional(),
|
||||||
|
action: z.string().min(1).optional(),
|
||||||
|
submitLabel: z.string().min(1).optional(),
|
||||||
|
payload: z.record(z.string(), z.unknown()).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const datePickerElementSchema = z.object({
|
||||||
|
type: z.literal('datePicker'),
|
||||||
|
key: z.string().min(1),
|
||||||
|
label: z.string().min(1),
|
||||||
|
defaultValue: z.string().optional(),
|
||||||
|
min: z.string().optional(),
|
||||||
|
max: z.string().optional(),
|
||||||
|
action: z.string().min(1).optional(),
|
||||||
|
submitLabel: z.string().min(1).optional(),
|
||||||
|
payload: z.record(z.string(), z.unknown()).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const formFieldSchema = z.object({
|
||||||
|
key: z.string().min(1),
|
||||||
|
label: z.string().min(1),
|
||||||
|
inputType: inputTypeSchema,
|
||||||
|
placeholder: z.string().optional(),
|
||||||
|
defaultValue: z.union([z.string(), z.number(), z.boolean()]).optional(),
|
||||||
|
options: z.array(inputOptionSchema).optional(),
|
||||||
|
required: z.boolean().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const formElementSchema = z.object({
|
||||||
|
type: z.literal('form'),
|
||||||
|
formId: z.string().min(1),
|
||||||
|
title: z.string().optional(),
|
||||||
|
submitLabel: z.string().min(1),
|
||||||
|
action: z.string().min(1),
|
||||||
|
payload: z.record(z.string(), z.unknown()).optional(),
|
||||||
|
fields: z.array(formFieldSchema).min(1),
|
||||||
|
});
|
||||||
|
|
||||||
|
const cardActionSchema = z.object({
|
||||||
|
label: z.string().min(1),
|
||||||
|
action: z.string().min(1),
|
||||||
|
payload: z.record(z.string(), z.unknown()).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const cardElementSchema = z.object({
|
||||||
|
type: z.literal('card'),
|
||||||
|
title: z.string().min(1),
|
||||||
|
body: z.string().min(1),
|
||||||
|
subtitle: z.string().optional(),
|
||||||
|
actions: z.array(cardActionSchema).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const imageElementSchema = z.object({
|
||||||
|
type: z.literal('image'),
|
||||||
|
src: z.string().min(1),
|
||||||
|
alt: z.string().optional(),
|
||||||
|
caption: z.string().optional(),
|
||||||
|
action: z.string().min(1).optional(),
|
||||||
|
payload: z.record(z.string(), z.unknown()).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
let assistantPanelElementSchemaRef: z.ZodTypeAny;
|
||||||
|
|
||||||
|
const tabsElementSchema: z.ZodTypeAny = z.lazy(() => z.object({
|
||||||
|
type: z.literal('tabs'),
|
||||||
|
widgetId: z.string().min(1).optional(),
|
||||||
|
defaultTabId: z.string().min(1).optional(),
|
||||||
|
tabs: z.array(
|
||||||
|
z.object({
|
||||||
|
id: z.string().min(1),
|
||||||
|
label: z.string().min(1),
|
||||||
|
elements: z.array(assistantPanelElementSchemaRef).min(1),
|
||||||
|
}),
|
||||||
|
).min(1),
|
||||||
|
}));
|
||||||
|
|
||||||
|
assistantPanelElementSchemaRef = z.discriminatedUnion('type', [
|
||||||
|
textElementSchema,
|
||||||
|
metricElementSchema,
|
||||||
|
listElementSchema,
|
||||||
|
tableElementSchema,
|
||||||
|
actionElementSchema,
|
||||||
|
chartElementSchema,
|
||||||
|
inputElementSchema,
|
||||||
|
formElementSchema,
|
||||||
|
datePickerElementSchema,
|
||||||
|
cardElementSchema,
|
||||||
|
imageElementSchema,
|
||||||
|
tabsElementSchema,
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const assistantPanelElementSchema = assistantPanelElementSchemaRef;
|
||||||
|
|
||||||
|
export const assistantPanelSpecSchema = z.object({
|
||||||
|
specVersion: z.literal('1'),
|
||||||
|
elements: z.array(assistantPanelElementSchema).min(1),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type AssistantPanelElement = z.infer<typeof assistantPanelElementSchema>;
|
||||||
|
export type AssistantPanelSpec = z.infer<typeof assistantPanelSpecSchema>;
|
||||||
67
src/renderer/navigation/assistantPromptContext.ts
Normal file
67
src/renderer/navigation/assistantPromptContext.ts
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import type { MediaData, PostData, Tab } from '../store/appStore';
|
||||||
|
|
||||||
|
export interface AssistantEditorContext {
|
||||||
|
tabType: Tab['type'] | 'none';
|
||||||
|
id?: string;
|
||||||
|
title?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveAssistantEditorContext(input: {
|
||||||
|
activeTab: Tab | null;
|
||||||
|
posts: PostData[];
|
||||||
|
media: MediaData[];
|
||||||
|
}): AssistantEditorContext | null {
|
||||||
|
const { activeTab, posts, media } = input;
|
||||||
|
if (!activeTab) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeTab.type === 'post') {
|
||||||
|
const currentPost = posts.find((post) => post.id === activeTab.id);
|
||||||
|
return {
|
||||||
|
tabType: 'post',
|
||||||
|
id: activeTab.id,
|
||||||
|
title: currentPost?.title,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeTab.type === 'media') {
|
||||||
|
const currentMedia = media.find((item) => item.id === activeTab.id);
|
||||||
|
return {
|
||||||
|
tabType: 'media',
|
||||||
|
id: activeTab.id,
|
||||||
|
title: currentMedia?.originalName || currentMedia?.filename || currentMedia?.title,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
tabType: activeTab.type,
|
||||||
|
id: activeTab.id,
|
||||||
|
title: activeTab.id,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildAssistantStartPrompt(input: {
|
||||||
|
userPrompt: string;
|
||||||
|
context: AssistantEditorContext | null;
|
||||||
|
}): string {
|
||||||
|
const userPrompt = input.userPrompt.trim();
|
||||||
|
const context = input.context;
|
||||||
|
|
||||||
|
const lines = [
|
||||||
|
`User request: ${userPrompt}`,
|
||||||
|
`Current editor context type: ${context?.tabType ?? 'none'}`,
|
||||||
|
];
|
||||||
|
|
||||||
|
if (context?.id) {
|
||||||
|
lines.push(`Current editor context id: ${context.id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (context?.title) {
|
||||||
|
lines.push(`Current editor context title: ${context.title}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push('Use this context when analyzing data and proposing UI updates.');
|
||||||
|
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
73
src/renderer/navigation/chatSession.ts
Normal file
73
src/renderer/navigation/chatSession.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
export interface ChatService {
|
||||||
|
createConversation: (title?: string, model?: string) => Promise<{ id: string } | null | undefined>;
|
||||||
|
sendMessage: (
|
||||||
|
conversationId: string,
|
||||||
|
message: string,
|
||||||
|
metadata?: SendMessageMetadata,
|
||||||
|
) => Promise<{ success: boolean; message?: string; error?: string } | null | undefined>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SendMessageMetadata {
|
||||||
|
surface?: 'tab' | 'sidebar';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EnsureConversationIdInput {
|
||||||
|
currentConversationId: string | null;
|
||||||
|
createTitle: string;
|
||||||
|
chatService: Pick<ChatService, 'createConversation'>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SendConversationMessageInput {
|
||||||
|
conversationId: string;
|
||||||
|
message: string;
|
||||||
|
metadata?: SendMessageMetadata;
|
||||||
|
chatService: Pick<ChatService, 'sendMessage'>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SendConversationMessageResult {
|
||||||
|
success: boolean;
|
||||||
|
message: string;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function ensureConversationId(input: EnsureConversationIdInput): Promise<string> {
|
||||||
|
if (input.currentConversationId) {
|
||||||
|
return input.currentConversationId;
|
||||||
|
}
|
||||||
|
|
||||||
|
const conversation = await input.chatService.createConversation(input.createTitle);
|
||||||
|
if (!conversation?.id) {
|
||||||
|
throw new Error('No conversation id returned');
|
||||||
|
}
|
||||||
|
|
||||||
|
return conversation.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendConversationMessage(
|
||||||
|
input: SendConversationMessageInput,
|
||||||
|
): Promise<SendConversationMessageResult> {
|
||||||
|
const result = input.metadata
|
||||||
|
? await input.chatService.sendMessage(input.conversationId, input.message, input.metadata)
|
||||||
|
: await input.chatService.sendMessage(input.conversationId, input.message);
|
||||||
|
|
||||||
|
if (result?.success === false) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: '',
|
||||||
|
error: result.error || 'Failed to send message',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!result) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: '',
|
||||||
|
error: 'No response returned',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: result.message || '',
|
||||||
|
};
|
||||||
|
}
|
||||||
24
src/renderer/navigation/chatSurfaceMode.ts
Normal file
24
src/renderer/navigation/chatSurfaceMode.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
export type ChatSurfaceModeId = 'tab' | 'sidebar';
|
||||||
|
|
||||||
|
export interface ChatSurfaceMode {
|
||||||
|
showModelSelector: boolean;
|
||||||
|
showWelcomeTips: boolean;
|
||||||
|
showToolMarkers: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CHAT_SURFACE_MODE_REGISTRY: Record<ChatSurfaceModeId, ChatSurfaceMode> = {
|
||||||
|
tab: {
|
||||||
|
showModelSelector: true,
|
||||||
|
showWelcomeTips: true,
|
||||||
|
showToolMarkers: true,
|
||||||
|
},
|
||||||
|
sidebar: {
|
||||||
|
showModelSelector: false,
|
||||||
|
showWelcomeTips: false,
|
||||||
|
showToolMarkers: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getChatSurfaceMode(modeId: ChatSurfaceModeId): ChatSurfaceMode {
|
||||||
|
return CHAT_SURFACE_MODE_REGISTRY[modeId];
|
||||||
|
}
|
||||||
56
src/renderer/navigation/useChatMessageSender.ts
Normal file
56
src/renderer/navigation/useChatMessageSender.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import { useCallback, useState } from 'react';
|
||||||
|
import { sendConversationMessage, type ChatService, type SendMessageMetadata } from './chatSession';
|
||||||
|
|
||||||
|
interface UseChatMessageSenderInput {
|
||||||
|
chatService: Pick<ChatService, 'sendMessage'> | null | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseChatMessageSenderParams {
|
||||||
|
conversationId: string;
|
||||||
|
message: string;
|
||||||
|
metadata?: SendMessageMetadata;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useChatMessageSender(input: UseChatMessageSenderInput) {
|
||||||
|
const [lastError, setLastError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const sendMessage = useCallback(
|
||||||
|
async (params: UseChatMessageSenderParams) => {
|
||||||
|
if (!input.chatService) {
|
||||||
|
const error = 'Chat service unavailable';
|
||||||
|
setLastError(error);
|
||||||
|
return {
|
||||||
|
success: false as const,
|
||||||
|
message: '',
|
||||||
|
error,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await sendConversationMessage({
|
||||||
|
conversationId: params.conversationId,
|
||||||
|
message: params.message,
|
||||||
|
metadata: params.metadata,
|
||||||
|
chatService: input.chatService,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
setLastError(result.error || 'Failed to send message');
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLastError(null);
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
[input.chatService],
|
||||||
|
);
|
||||||
|
|
||||||
|
const clearError = useCallback(() => {
|
||||||
|
setLastError(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
sendMessage,
|
||||||
|
lastError,
|
||||||
|
clearError,
|
||||||
|
};
|
||||||
|
}
|
||||||
127
src/renderer/navigation/useChatSurfaceState.ts
Normal file
127
src/renderer/navigation/useChatSurfaceState.ts
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
import { useCallback, useRef, useState } from 'react';
|
||||||
|
import type { ChatMessage } from '../types/electron';
|
||||||
|
|
||||||
|
export interface ChatToolEvent {
|
||||||
|
type: 'call' | 'result';
|
||||||
|
name: string;
|
||||||
|
args?: unknown;
|
||||||
|
timestamp: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useChatSurfaceState() {
|
||||||
|
const [messages, setMessagesState] = useState<ChatMessage[]>([]);
|
||||||
|
const [isStreaming, setIsStreaming] = useState(false);
|
||||||
|
const [streamingContent, setStreamingContent] = useState('');
|
||||||
|
const [toolEvents, setToolEvents] = useState<ChatToolEvent[]>([]);
|
||||||
|
const streamingRef = useRef('');
|
||||||
|
const toolEventsRef = useRef<Array<{ name: string; args?: unknown }>>([]);
|
||||||
|
|
||||||
|
const setMessages = useCallback((nextMessages: ChatMessage[]) => {
|
||||||
|
setMessagesState(nextMessages);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const beginUserTurn = useCallback((conversationId: string, content: string) => {
|
||||||
|
const userMessage: ChatMessage = {
|
||||||
|
id: `temp-${Date.now()}`,
|
||||||
|
conversationId,
|
||||||
|
role: 'user',
|
||||||
|
content,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
setMessagesState((prev) => [...prev, userMessage]);
|
||||||
|
setIsStreaming(true);
|
||||||
|
streamingRef.current = '';
|
||||||
|
setStreamingContent('');
|
||||||
|
setToolEvents([]);
|
||||||
|
toolEventsRef.current = [];
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const appendStreamDelta = useCallback((delta: string) => {
|
||||||
|
streamingRef.current += delta;
|
||||||
|
setStreamingContent(streamingRef.current);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const recordToolCall = useCallback((name: string, args?: unknown) => {
|
||||||
|
toolEventsRef.current.push({ name, args });
|
||||||
|
setToolEvents((prev) => [...prev, { type: 'call', name, args, timestamp: Date.now() }]);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const recordToolResult = useCallback((name: string) => {
|
||||||
|
setToolEvents((prev) => [...prev, { type: 'result', name, timestamp: Date.now() }]);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const stopStreaming = useCallback(() => {
|
||||||
|
setIsStreaming(false);
|
||||||
|
setStreamingContent('');
|
||||||
|
streamingRef.current = '';
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const appendAssistantMessage = useCallback((conversationId: string, content: string) => {
|
||||||
|
const assistantMessage: ChatMessage = {
|
||||||
|
id: `assistant-${Date.now()}`,
|
||||||
|
conversationId,
|
||||||
|
role: 'assistant',
|
||||||
|
content,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
setMessagesState((prev) => [...prev, assistantMessage]);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const finalizeAssistantTurn = useCallback((conversationId: string, content: string) => {
|
||||||
|
if (content) {
|
||||||
|
const assistantMessage: ChatMessage = {
|
||||||
|
id: `assistant-${Date.now()}`,
|
||||||
|
conversationId,
|
||||||
|
role: 'assistant',
|
||||||
|
content,
|
||||||
|
toolCalls: toolEventsRef.current.length > 0 ? JSON.stringify(toolEventsRef.current) : undefined,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
setMessagesState((prev) => [...prev, assistantMessage]);
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsStreaming(false);
|
||||||
|
setStreamingContent('');
|
||||||
|
streamingRef.current = '';
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const abortStreaming = useCallback((conversationId: string, cancelledSuffix: string) => {
|
||||||
|
const partialContent = streamingRef.current;
|
||||||
|
setIsStreaming(false);
|
||||||
|
setStreamingContent('');
|
||||||
|
streamingRef.current = '';
|
||||||
|
|
||||||
|
if (!partialContent) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const partialMessage: ChatMessage = {
|
||||||
|
id: `partial-${Date.now()}`,
|
||||||
|
conversationId,
|
||||||
|
role: 'assistant',
|
||||||
|
content: `${partialContent}\n\n*(${cancelledSuffix})*`,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
setMessagesState((prev) => [...prev, partialMessage]);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const getStreamingContent = useCallback(() => streamingRef.current, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
messages,
|
||||||
|
isStreaming,
|
||||||
|
streamingContent,
|
||||||
|
toolEvents,
|
||||||
|
setMessages,
|
||||||
|
beginUserTurn,
|
||||||
|
appendStreamDelta,
|
||||||
|
recordToolCall,
|
||||||
|
recordToolResult,
|
||||||
|
appendAssistantMessage,
|
||||||
|
finalizeAssistantTurn,
|
||||||
|
stopStreaming,
|
||||||
|
abortStreaming,
|
||||||
|
getStreamingContent,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -185,7 +185,7 @@ const METHODS_V1: PythonApiMethodContractV1[] = [
|
|||||||
method('chat.getConversation', 'Fetch one chat conversation by id.', [requiredString('id')], 'ChatConversation | null'),
|
method('chat.getConversation', 'Fetch one chat conversation by id.', [requiredString('id')], 'ChatConversation | null'),
|
||||||
method('chat.updateConversation', 'Update chat conversation metadata.', [requiredString('id'), requiredObject('updates')], 'ChatConversation | null'),
|
method('chat.updateConversation', 'Update chat conversation metadata.', [requiredString('id'), requiredObject('updates')], 'ChatConversation | null'),
|
||||||
method('chat.deleteConversation', 'Delete chat conversation by id.', [requiredString('id')], 'boolean'),
|
method('chat.deleteConversation', 'Delete chat conversation by id.', [requiredString('id')], 'boolean'),
|
||||||
method('chat.sendMessage', 'Send message to chat conversation.', [requiredString('conversationId'), requiredString('message')], '{ success: boolean; message?: string; error?: string }'),
|
method('chat.sendMessage', 'Send message to chat conversation.', [requiredString('conversationId'), requiredString('message'), optionalObject('metadata')], '{ success: boolean; message?: string; error?: string }'),
|
||||||
method('chat.abortMessage', 'Abort active streaming chat response.', [requiredString('conversationId')], 'void'),
|
method('chat.abortMessage', 'Abort active streaming chat response.', [requiredString('conversationId')], 'void'),
|
||||||
method('chat.getHistory', 'Get message history for conversation.', [requiredString('conversationId')], 'ChatMessage[]'),
|
method('chat.getHistory', 'Get message history for conversation.', [requiredString('conversationId')], 'ChatMessage[]'),
|
||||||
method('chat.clearMessages', 'Clear messages for conversation.', [requiredString('conversationId')], 'void'),
|
method('chat.clearMessages', 'Clear messages for conversation.', [requiredString('conversationId')], 'void'),
|
||||||
@@ -362,8 +362,8 @@ const DATA_STRUCTURES_V1: PythonApiDataStructureContractV1[] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export const BDS_PYTHON_API_CONTRACT_V1: PythonApiContractV1 = {
|
export const BDS_PYTHON_API_CONTRACT_V1: PythonApiContractV1 = {
|
||||||
version: '1.3.0',
|
version: '1.6.0',
|
||||||
generatedAt: '2026-02-24T00:00:00.000Z',
|
generatedAt: '2026-02-25T00:00:00.000Z',
|
||||||
methods: METHODS_V1,
|
methods: METHODS_V1,
|
||||||
dataStructures: DATA_STRUCTURES_V1,
|
dataStructures: DATA_STRUCTURES_V1,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ interface AppState {
|
|||||||
activeView: SidebarView;
|
activeView: SidebarView;
|
||||||
sidebarVisible: boolean;
|
sidebarVisible: boolean;
|
||||||
panelVisible: boolean;
|
panelVisible: boolean;
|
||||||
|
assistantSidebarVisible: boolean;
|
||||||
panelActiveTab: PanelTab;
|
panelActiveTab: PanelTab;
|
||||||
panelOutputEntries: PanelOutputEntry[];
|
panelOutputEntries: PanelOutputEntry[];
|
||||||
selectedPostId: string | null;
|
selectedPostId: string | null;
|
||||||
@@ -119,6 +120,7 @@ interface AppState {
|
|||||||
setActiveView: (view: SidebarView) => void;
|
setActiveView: (view: SidebarView) => void;
|
||||||
toggleSidebar: () => void;
|
toggleSidebar: () => void;
|
||||||
togglePanel: () => void;
|
togglePanel: () => void;
|
||||||
|
toggleAssistantSidebar: () => void;
|
||||||
setPanelActiveTab: (tab: PanelTab) => void;
|
setPanelActiveTab: (tab: PanelTab) => void;
|
||||||
appendPanelOutputEntry: (entry: PanelOutputEntry) => void;
|
appendPanelOutputEntry: (entry: PanelOutputEntry) => void;
|
||||||
clearPanelOutputEntries: () => void;
|
clearPanelOutputEntries: () => void;
|
||||||
@@ -175,6 +177,7 @@ export const useAppStore = create<AppState>()(
|
|||||||
activeView: 'posts',
|
activeView: 'posts',
|
||||||
sidebarVisible: true,
|
sidebarVisible: true,
|
||||||
panelVisible: false,
|
panelVisible: false,
|
||||||
|
assistantSidebarVisible: false,
|
||||||
panelActiveTab: 'tasks',
|
panelActiveTab: 'tasks',
|
||||||
panelOutputEntries: [],
|
panelOutputEntries: [],
|
||||||
selectedPostId: null,
|
selectedPostId: null,
|
||||||
@@ -300,6 +303,7 @@ export const useAppStore = create<AppState>()(
|
|||||||
setActiveView: (view) => set({ activeView: view }),
|
setActiveView: (view) => set({ activeView: view }),
|
||||||
toggleSidebar: () => set((state) => ({ sidebarVisible: !state.sidebarVisible })),
|
toggleSidebar: () => set((state) => ({ sidebarVisible: !state.sidebarVisible })),
|
||||||
togglePanel: () => set((state) => ({ panelVisible: !state.panelVisible })),
|
togglePanel: () => set((state) => ({ panelVisible: !state.panelVisible })),
|
||||||
|
toggleAssistantSidebar: () => set((state) => ({ assistantSidebarVisible: !state.assistantSidebarVisible })),
|
||||||
setPanelActiveTab: (panelActiveTab) => set({ panelActiveTab }),
|
setPanelActiveTab: (panelActiveTab) => set({ panelActiveTab }),
|
||||||
appendPanelOutputEntry: (entry) => set((state) => ({
|
appendPanelOutputEntry: (entry) => set((state) => ({
|
||||||
panelOutputEntries: [...state.panelOutputEntries, entry],
|
panelOutputEntries: [...state.panelOutputEntries, entry],
|
||||||
|
|||||||
27
src/renderer/styles/chatSurface.css
Normal file
27
src/renderer/styles/chatSurface.css
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
.chat-surface {
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-surface-scroll {
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-surface-input {
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid var(--vscode-input-border, transparent);
|
||||||
|
background: var(--vscode-input-background);
|
||||||
|
color: var(--vscode-input-foreground);
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-surface-error {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--vscode-errorForeground);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-surface-section {
|
||||||
|
border-top: 1px solid var(--vscode-panel-border);
|
||||||
|
padding-top: 8px;
|
||||||
|
}
|
||||||
@@ -638,7 +638,11 @@ describe('ChatEngine', () => {
|
|||||||
const result = await chatEngine.getDefaultSystemPrompt();
|
const result = await chatEngine.getDefaultSystemPrompt();
|
||||||
|
|
||||||
expect(result).toContain('Blogging Desktop Server');
|
expect(result).toContain('Blogging Desktop Server');
|
||||||
expect(result).toContain('Available Tools');
|
expect(result).toContain('Available Data Tools');
|
||||||
|
expect(result).toContain('UI Render Tools');
|
||||||
|
expect(result).toContain('render_chart');
|
||||||
|
expect(result).toContain('tabs');
|
||||||
|
expect(result).toContain('render_form');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return built-in prompt when saved prompt is empty', async () => {
|
it('should return built-in prompt when saved prompt is empty', async () => {
|
||||||
|
|||||||
@@ -2619,6 +2619,88 @@ Published snapshot content`);
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('getBlogStats', () => {
|
||||||
|
it('should return comprehensive blog statistics', async () => {
|
||||||
|
vi.mocked(mockLocalDb.select).mockImplementation(() => {
|
||||||
|
const chain = createSelectChain();
|
||||||
|
chain.where = vi.fn().mockReturnValue({
|
||||||
|
...chain,
|
||||||
|
orderBy: vi.fn().mockReturnThis(),
|
||||||
|
all: vi.fn().mockResolvedValue([
|
||||||
|
{ status: 'draft', createdAt: new Date('2015-03-10'), tags: '["travel","photo"]', categories: '["article"]' },
|
||||||
|
{ status: 'published', createdAt: new Date('2016-07-22'), tags: '["tech"]', categories: '["article"]' },
|
||||||
|
{ status: 'published', createdAt: new Date('2020-01-05'), tags: '["travel"]', categories: '["aside"]' },
|
||||||
|
{ status: 'published', createdAt: new Date('2024-11-30'), tags: '["tech","ai"]', categories: '["article"]' },
|
||||||
|
{ status: 'archived', createdAt: new Date('2018-06-15'), tags: '[]', categories: '["page"]' },
|
||||||
|
]),
|
||||||
|
});
|
||||||
|
return chain;
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await postEngine.getBlogStats();
|
||||||
|
|
||||||
|
expect(result.totalPosts).toBe(5);
|
||||||
|
expect(result.draftCount).toBe(1);
|
||||||
|
expect(result.publishedCount).toBe(3);
|
||||||
|
expect(result.archivedCount).toBe(1);
|
||||||
|
expect(result.oldestPostDate).toEqual(new Date('2015-03-10'));
|
||||||
|
expect(result.newestPostDate).toEqual(new Date('2024-11-30'));
|
||||||
|
expect(result.postsPerYear).toEqual({
|
||||||
|
2015: 1,
|
||||||
|
2016: 1,
|
||||||
|
2018: 1,
|
||||||
|
2020: 1,
|
||||||
|
2024: 1,
|
||||||
|
});
|
||||||
|
expect(result.tagCount).toBe(4);
|
||||||
|
expect(result.categoryCount).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle empty project', async () => {
|
||||||
|
vi.mocked(mockLocalDb.select).mockImplementation(() => {
|
||||||
|
const chain = createSelectChain();
|
||||||
|
chain.where = vi.fn().mockReturnValue({
|
||||||
|
...chain,
|
||||||
|
orderBy: vi.fn().mockReturnThis(),
|
||||||
|
all: vi.fn().mockResolvedValue([]),
|
||||||
|
});
|
||||||
|
return chain;
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await postEngine.getBlogStats();
|
||||||
|
|
||||||
|
expect(result.totalPosts).toBe(0);
|
||||||
|
expect(result.draftCount).toBe(0);
|
||||||
|
expect(result.publishedCount).toBe(0);
|
||||||
|
expect(result.archivedCount).toBe(0);
|
||||||
|
expect(result.oldestPostDate).toBeNull();
|
||||||
|
expect(result.newestPostDate).toBeNull();
|
||||||
|
expect(result.postsPerYear).toEqual({});
|
||||||
|
expect(result.tagCount).toBe(0);
|
||||||
|
expect(result.categoryCount).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should count unique tags and categories', async () => {
|
||||||
|
vi.mocked(mockLocalDb.select).mockImplementation(() => {
|
||||||
|
const chain = createSelectChain();
|
||||||
|
chain.where = vi.fn().mockReturnValue({
|
||||||
|
...chain,
|
||||||
|
orderBy: vi.fn().mockReturnThis(),
|
||||||
|
all: vi.fn().mockResolvedValue([
|
||||||
|
{ status: 'published', createdAt: new Date('2023-01-01'), tags: '["a","b","c"]', categories: '["x"]' },
|
||||||
|
{ status: 'published', createdAt: new Date('2023-06-01'), tags: '["b","c","d"]', categories: '["x","y"]' },
|
||||||
|
]),
|
||||||
|
});
|
||||||
|
return chain;
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await postEngine.getBlogStats();
|
||||||
|
|
||||||
|
expect(result.tagCount).toBe(4); // a, b, c, d
|
||||||
|
expect(result.categoryCount).toBe(2); // x, y
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('extractInternalLinks', () => {
|
describe('extractInternalLinks', () => {
|
||||||
it('should extract markdown-style internal links', () => {
|
it('should extract markdown-style internal links', () => {
|
||||||
const content = 'Check out [my post](/posts/my-post) for more info.';
|
const content = 'Check out [my post](/posts/my-post) for more info.';
|
||||||
|
|||||||
80
tests/engine/a2ui/catalog.test.ts
Normal file
80
tests/engine/a2ui/catalog.test.ts
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import {
|
||||||
|
getCatalogEntries,
|
||||||
|
isSupportedComponentType,
|
||||||
|
getCatalogEntry,
|
||||||
|
getCatalogId,
|
||||||
|
buildCatalogDescription,
|
||||||
|
} from '../../../src/main/a2ui/catalog';
|
||||||
|
import { BDS_CATALOG_ID } from '../../../src/main/a2ui/types';
|
||||||
|
|
||||||
|
describe('A2UI catalog', () => {
|
||||||
|
it('returns all 17 catalog entries', () => {
|
||||||
|
const entries = getCatalogEntries();
|
||||||
|
expect(entries).toHaveLength(17);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns a copy of catalog entries to prevent mutation', () => {
|
||||||
|
const entries1 = getCatalogEntries();
|
||||||
|
const entries2 = getCatalogEntries();
|
||||||
|
expect(entries1).not.toBe(entries2);
|
||||||
|
expect(entries1).toEqual(entries2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('recognises all supported component types', () => {
|
||||||
|
const types = [
|
||||||
|
'text', 'button', 'card', 'chart', 'table', 'form',
|
||||||
|
'textField', 'checkBox', 'dateTimeInput', 'choicePicker',
|
||||||
|
'image', 'tabs', 'metric', 'list', 'row', 'column', 'divider',
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const type of types) {
|
||||||
|
expect(isSupportedComponentType(type)).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects unsupported component types', () => {
|
||||||
|
expect(isSupportedComponentType('video')).toBe(false);
|
||||||
|
expect(isSupportedComponentType('slider')).toBe(false);
|
||||||
|
expect(isSupportedComponentType('')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns catalog entry by type', () => {
|
||||||
|
const entry = getCatalogEntry('chart');
|
||||||
|
expect(entry).toEqual({
|
||||||
|
type: 'chart',
|
||||||
|
description: 'Bar, stacked-bar, line, area, pie, donut, or heatmap chart visualization',
|
||||||
|
custom: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns undefined for unknown type', () => {
|
||||||
|
expect(getCatalogEntry('unknown' as never)).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns the bDS catalog ID', () => {
|
||||||
|
expect(getCatalogId()).toBe(BDS_CATALOG_ID);
|
||||||
|
expect(getCatalogId()).toBe('bds-blogging-v1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('builds a catalog description for LLM system prompt', () => {
|
||||||
|
const description = buildCatalogDescription();
|
||||||
|
expect(description).toContain('Supported UI component types:');
|
||||||
|
expect(description).toContain('text: Text block with Markdown support');
|
||||||
|
expect(description).toContain('chart: Bar, stacked-bar, line, area, pie, donut, or heatmap chart visualization (custom)');
|
||||||
|
expect(description).toContain('table: Data table with columns and rows (custom)');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks custom components correctly', () => {
|
||||||
|
const entries = getCatalogEntries();
|
||||||
|
const customEntries = entries.filter((e) => e.custom);
|
||||||
|
const customTypes = customEntries.map((e) => e.type);
|
||||||
|
|
||||||
|
expect(customTypes).toContain('chart');
|
||||||
|
expect(customTypes).toContain('table');
|
||||||
|
expect(customTypes).toContain('metric');
|
||||||
|
expect(customTypes).toContain('form');
|
||||||
|
expect(customTypes).not.toContain('text');
|
||||||
|
expect(customTypes).not.toContain('button');
|
||||||
|
});
|
||||||
|
});
|
||||||
368
tests/engine/a2ui/generator.test.ts
Normal file
368
tests/engine/a2ui/generator.test.ts
Normal file
@@ -0,0 +1,368 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import {
|
||||||
|
isRenderTool,
|
||||||
|
generateFromToolCall,
|
||||||
|
generateChart,
|
||||||
|
generateTable,
|
||||||
|
generateForm,
|
||||||
|
generateCard,
|
||||||
|
generateMetric,
|
||||||
|
generateList,
|
||||||
|
generateTabs,
|
||||||
|
} from '../../../src/main/a2ui/generator';
|
||||||
|
import type { A2UIServerMessage } from '../../../src/main/a2ui/types';
|
||||||
|
|
||||||
|
describe('A2UI generator', () => {
|
||||||
|
describe('isRenderTool', () => {
|
||||||
|
it('returns true for all render tools', () => {
|
||||||
|
expect(isRenderTool('render_chart')).toBe(true);
|
||||||
|
expect(isRenderTool('render_table')).toBe(true);
|
||||||
|
expect(isRenderTool('render_form')).toBe(true);
|
||||||
|
expect(isRenderTool('render_card')).toBe(true);
|
||||||
|
expect(isRenderTool('render_metric')).toBe(true);
|
||||||
|
expect(isRenderTool('render_list')).toBe(true);
|
||||||
|
expect(isRenderTool('render_tabs')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false for non-render tools', () => {
|
||||||
|
expect(isRenderTool('search_posts')).toBe(false);
|
||||||
|
expect(isRenderTool('get_post')).toBe(false);
|
||||||
|
expect(isRenderTool('render_unknown')).toBe(false);
|
||||||
|
expect(isRenderTool('')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('generateFromToolCall', () => {
|
||||||
|
it('dispatches to chart generator', () => {
|
||||||
|
const messages = generateFromToolCall('conv-1', 'render_chart', {
|
||||||
|
chartType: 'bar',
|
||||||
|
series: [{ label: 'A', value: 1 }],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(messages).not.toBeNull();
|
||||||
|
expect(messages!.length).toBeGreaterThanOrEqual(2);
|
||||||
|
expect(messages![0].type).toBe('createSurface');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null for unknown tool', () => {
|
||||||
|
expect(generateFromToolCall('conv-1', 'search_posts', {})).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('generateChart', () => {
|
||||||
|
it('creates surface with chart component and data binding', () => {
|
||||||
|
const messages = generateChart('conv-1', {
|
||||||
|
chartType: 'bar',
|
||||||
|
title: 'Sales',
|
||||||
|
series: [
|
||||||
|
{ label: 'Jan', value: 10 },
|
||||||
|
{ label: 'Feb', value: 20 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(messages).toHaveLength(3); // createSurface + updateComponents + updateDataModel
|
||||||
|
|
||||||
|
const createMsg = messages[0] as Extract<A2UIServerMessage, { type: 'createSurface' }>;
|
||||||
|
expect(createMsg.type).toBe('createSurface');
|
||||||
|
expect(createMsg.conversationId).toBe('conv-1');
|
||||||
|
|
||||||
|
const updateMsg = messages[1] as Extract<A2UIServerMessage, { type: 'updateComponents' }>;
|
||||||
|
expect(updateMsg.type).toBe('updateComponents');
|
||||||
|
expect(updateMsg.components).toHaveLength(1);
|
||||||
|
expect(updateMsg.components[0].type).toBe('chart');
|
||||||
|
expect(updateMsg.components[0].properties.chartType).toBe('bar');
|
||||||
|
expect(updateMsg.components[0].dataBinding).toBe('/chartData');
|
||||||
|
expect(updateMsg.rootIds).toHaveLength(1);
|
||||||
|
|
||||||
|
const dataMsg = messages[2] as Extract<A2UIServerMessage, { type: 'updateDataModel' }>;
|
||||||
|
expect(dataMsg.type).toBe('updateDataModel');
|
||||||
|
expect(dataMsg.path).toBe('/chartData');
|
||||||
|
expect(dataMsg.value).toEqual([
|
||||||
|
{ label: 'Jan', value: 10 },
|
||||||
|
{ label: 'Feb', value: 20 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates stacked-bar chart with segment data', () => {
|
||||||
|
const messages = generateChart('conv-1', {
|
||||||
|
chartType: 'stacked-bar',
|
||||||
|
title: 'Posts by Year',
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
label: '2023',
|
||||||
|
value: 30,
|
||||||
|
segments: [
|
||||||
|
{ label: 'Published', value: 20 },
|
||||||
|
{ label: 'Draft', value: 10 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '2024',
|
||||||
|
value: 45,
|
||||||
|
segments: [
|
||||||
|
{ label: 'Published', value: 35 },
|
||||||
|
{ label: 'Draft', value: 10 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(messages).toHaveLength(3);
|
||||||
|
|
||||||
|
const updateMsg = messages[1] as Extract<A2UIServerMessage, { type: 'updateComponents' }>;
|
||||||
|
expect(updateMsg.components[0].properties.chartType).toBe('stacked-bar');
|
||||||
|
|
||||||
|
const dataMsg = messages[2] as Extract<A2UIServerMessage, { type: 'updateDataModel' }>;
|
||||||
|
expect(dataMsg.value).toEqual([
|
||||||
|
{
|
||||||
|
label: '2023',
|
||||||
|
value: 30,
|
||||||
|
segments: [
|
||||||
|
{ label: 'Published', value: 20 },
|
||||||
|
{ label: 'Draft', value: 10 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '2024',
|
||||||
|
value: 45,
|
||||||
|
segments: [
|
||||||
|
{ label: 'Published', value: 35 },
|
||||||
|
{ label: 'Draft', value: 10 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('generateTable', () => {
|
||||||
|
it('creates surface with table component and row data', () => {
|
||||||
|
const messages = generateTable('conv-1', {
|
||||||
|
title: 'Posts',
|
||||||
|
columns: ['Title', 'Status'],
|
||||||
|
rows: [['Hello', 'published'], ['Draft', 'draft']],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(messages).toHaveLength(3);
|
||||||
|
|
||||||
|
const updateMsg = messages[1] as Extract<A2UIServerMessage, { type: 'updateComponents' }>;
|
||||||
|
expect(updateMsg.components[0].type).toBe('table');
|
||||||
|
expect(updateMsg.components[0].properties.columns).toEqual(['Title', 'Status']);
|
||||||
|
|
||||||
|
const dataMsg = messages[2] as Extract<A2UIServerMessage, { type: 'updateDataModel' }>;
|
||||||
|
expect(dataMsg.path).toBe('/tableRows');
|
||||||
|
expect(dataMsg.value).toEqual([['Hello', 'published'], ['Draft', 'draft']]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('generateCard', () => {
|
||||||
|
it('creates surface with card component', () => {
|
||||||
|
const messages = generateCard('conv-1', {
|
||||||
|
title: 'My Card',
|
||||||
|
body: 'Card body text',
|
||||||
|
subtitle: 'Optional subtitle',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(messages).toHaveLength(2); // No data model for card
|
||||||
|
|
||||||
|
const updateMsg = messages[1] as Extract<A2UIServerMessage, { type: 'updateComponents' }>;
|
||||||
|
expect(updateMsg.components[0].type).toBe('card');
|
||||||
|
expect(updateMsg.components[0].properties.title).toBe('My Card');
|
||||||
|
expect(updateMsg.components[0].properties.body).toBe('Card body text');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes card actions when provided', () => {
|
||||||
|
const messages = generateCard('conv-1', {
|
||||||
|
title: 'Action Card',
|
||||||
|
body: 'Has actions',
|
||||||
|
actions: [{ label: 'Open', action: 'openPost', payload: { postId: 'p1' } }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateMsg = messages[1] as Extract<A2UIServerMessage, { type: 'updateComponents' }>;
|
||||||
|
expect(updateMsg.components[0].actions).toEqual([
|
||||||
|
{ eventType: 'click', action: 'openPost', payload: { postId: 'p1' } },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('generateMetric', () => {
|
||||||
|
it('creates surface with metric component', () => {
|
||||||
|
const messages = generateMetric('conv-1', {
|
||||||
|
label: 'Total Posts',
|
||||||
|
value: '42',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(messages).toHaveLength(2);
|
||||||
|
|
||||||
|
const updateMsg = messages[1] as Extract<A2UIServerMessage, { type: 'updateComponents' }>;
|
||||||
|
expect(updateMsg.components[0].type).toBe('metric');
|
||||||
|
expect(updateMsg.components[0].properties.label).toBe('Total Posts');
|
||||||
|
expect(updateMsg.components[0].properties.value).toBe('42');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('generateList', () => {
|
||||||
|
it('creates surface with list component and item data', () => {
|
||||||
|
const messages = generateList('conv-1', {
|
||||||
|
title: 'Tags',
|
||||||
|
items: ['react', 'typescript', 'electron'],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(messages).toHaveLength(3);
|
||||||
|
|
||||||
|
const updateMsg = messages[1] as Extract<A2UIServerMessage, { type: 'updateComponents' }>;
|
||||||
|
expect(updateMsg.components[0].type).toBe('list');
|
||||||
|
|
||||||
|
const dataMsg = messages[2] as Extract<A2UIServerMessage, { type: 'updateDataModel' }>;
|
||||||
|
expect(dataMsg.path).toBe('/listItems');
|
||||||
|
expect(dataMsg.value).toEqual(['react', 'typescript', 'electron']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('generateForm', () => {
|
||||||
|
it('creates surface with form, field components, and submit button', () => {
|
||||||
|
const messages = generateForm('conv-1', {
|
||||||
|
title: 'Edit Post',
|
||||||
|
submitLabel: 'Save',
|
||||||
|
fields: [
|
||||||
|
{ key: 'title', label: 'Title', inputType: 'text', defaultValue: 'Hello' },
|
||||||
|
{ key: 'draft', label: 'Draft', inputType: 'checkbox', defaultValue: true },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
// createSurface + updateComponents + 2 updateDataModel (one per default value)
|
||||||
|
expect(messages).toHaveLength(4);
|
||||||
|
|
||||||
|
const updateMsg = messages[1] as Extract<A2UIServerMessage, { type: 'updateComponents' }>;
|
||||||
|
// form + 2 fields + 1 submit button = 4
|
||||||
|
expect(updateMsg.components).toHaveLength(4);
|
||||||
|
|
||||||
|
const formComponent = updateMsg.components.find((c) => c.type === 'form');
|
||||||
|
expect(formComponent).toBeDefined();
|
||||||
|
expect(formComponent!.children).toHaveLength(3); // 2 fields + submit button
|
||||||
|
|
||||||
|
const textField = updateMsg.components.find((c) => c.type === 'textField');
|
||||||
|
expect(textField).toBeDefined();
|
||||||
|
expect(textField!.dataBinding).toBe('/formData/title');
|
||||||
|
|
||||||
|
const checkBox = updateMsg.components.find((c) => c.type === 'checkBox');
|
||||||
|
expect(checkBox).toBeDefined();
|
||||||
|
expect(checkBox!.dataBinding).toBe('/formData/draft');
|
||||||
|
|
||||||
|
const submitButton = updateMsg.components.find((c) => c.type === 'button');
|
||||||
|
expect(submitButton).toBeDefined();
|
||||||
|
expect(submitButton!.properties.label).toBe('Save');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps select inputType to choicePicker', () => {
|
||||||
|
const messages = generateForm('conv-1', {
|
||||||
|
submitLabel: 'Go',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
key: 'status',
|
||||||
|
label: 'Status',
|
||||||
|
inputType: 'select',
|
||||||
|
options: [
|
||||||
|
{ label: 'Draft', value: 'draft' },
|
||||||
|
{ label: 'Published', value: 'published' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateMsg = messages[1] as Extract<A2UIServerMessage, { type: 'updateComponents' }>;
|
||||||
|
const picker = updateMsg.components.find((c) => c.type === 'choicePicker');
|
||||||
|
expect(picker).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps date inputType to dateTimeInput', () => {
|
||||||
|
const messages = generateForm('conv-1', {
|
||||||
|
submitLabel: 'Set',
|
||||||
|
fields: [{ key: 'date', label: 'Date', inputType: 'date' }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateMsg = messages[1] as Extract<A2UIServerMessage, { type: 'updateComponents' }>;
|
||||||
|
const dateInput = updateMsg.components.find((c) => c.type === 'dateTimeInput');
|
||||||
|
expect(dateInput).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('generateTabs', () => {
|
||||||
|
it('creates surface with tabs and child components', () => {
|
||||||
|
const messages = generateTabs('conv-1', {
|
||||||
|
tabs: [
|
||||||
|
{
|
||||||
|
label: 'Overview',
|
||||||
|
content: [{ type: 'text', text: 'Tab content' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Details',
|
||||||
|
content: [{ type: 'metric', label: 'Count', value: '5' }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(messages).toHaveLength(2); // createSurface + updateComponents
|
||||||
|
|
||||||
|
const updateMsg = messages[1] as Extract<A2UIServerMessage, { type: 'updateComponents' }>;
|
||||||
|
const tabsComponent = updateMsg.components.find((c) => c.type === 'tabs');
|
||||||
|
expect(tabsComponent).toBeDefined();
|
||||||
|
expect(tabsComponent!.children).toHaveLength(2);
|
||||||
|
expect(tabsComponent!.properties.tabLabels).toEqual(['Overview', 'Details']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates chart components inside tabs with series in properties', () => {
|
||||||
|
const messages = generateTabs('conv-1', {
|
||||||
|
tabs: [
|
||||||
|
{
|
||||||
|
label: 'Stats',
|
||||||
|
content: [{
|
||||||
|
type: 'chart',
|
||||||
|
chartType: 'bar',
|
||||||
|
title: 'Monthly Posts',
|
||||||
|
series: [{ label: 'Jan', value: 5 }, { label: 'Feb', value: 8 }],
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(messages).toHaveLength(2);
|
||||||
|
const updateMsg = messages[1] as Extract<A2UIServerMessage, { type: 'updateComponents' }>;
|
||||||
|
|
||||||
|
const chartComponent = updateMsg.components.find((c) => c.type === 'chart');
|
||||||
|
expect(chartComponent).toBeDefined();
|
||||||
|
expect(chartComponent!.properties.chartType).toBe('bar');
|
||||||
|
expect(chartComponent!.properties.title).toBe('Monthly Posts');
|
||||||
|
expect(chartComponent!.properties.series).toEqual([
|
||||||
|
{ label: 'Jan', value: 5 },
|
||||||
|
{ label: 'Feb', value: 8 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates table components inside tabs with rows in properties', () => {
|
||||||
|
const messages = generateTabs('conv-1', {
|
||||||
|
tabs: [
|
||||||
|
{
|
||||||
|
label: 'Data',
|
||||||
|
content: [{
|
||||||
|
type: 'table',
|
||||||
|
title: 'Recent Posts',
|
||||||
|
columns: ['Title', 'Status'],
|
||||||
|
rows: [['Hello', 'published'], ['Draft', 'draft']],
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(messages).toHaveLength(2);
|
||||||
|
const updateMsg = messages[1] as Extract<A2UIServerMessage, { type: 'updateComponents' }>;
|
||||||
|
|
||||||
|
const tableComponent = updateMsg.components.find((c) => c.type === 'table');
|
||||||
|
expect(tableComponent).toBeDefined();
|
||||||
|
expect(tableComponent!.properties.columns).toEqual(['Title', 'Status']);
|
||||||
|
expect(tableComponent!.properties.rows).toEqual([
|
||||||
|
['Hello', 'published'],
|
||||||
|
['Draft', 'draft'],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
360
tests/engine/a2ui/surfaceManager.test.ts
Normal file
360
tests/engine/a2ui/surfaceManager.test.ts
Normal file
@@ -0,0 +1,360 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import {
|
||||||
|
A2UISurfaceManager,
|
||||||
|
getValueAtPointer,
|
||||||
|
setValueAtPointer,
|
||||||
|
} from '../../../src/renderer/a2ui/A2UISurfaceManager';
|
||||||
|
import type { A2UIServerMessage, A2UIComponent } from '../../../src/main/a2ui/types';
|
||||||
|
|
||||||
|
describe('A2UISurfaceManager', () => {
|
||||||
|
function createTestComponent(overrides: Partial<A2UIComponent> = {}): A2UIComponent {
|
||||||
|
return {
|
||||||
|
id: 'comp-1',
|
||||||
|
type: 'text',
|
||||||
|
properties: { text: 'Hello' },
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('createSurface', () => {
|
||||||
|
it('creates a new surface with empty state', () => {
|
||||||
|
const manager = new A2UISurfaceManager();
|
||||||
|
|
||||||
|
manager.processMessage({
|
||||||
|
type: 'createSurface',
|
||||||
|
surfaceId: 'surface-1',
|
||||||
|
conversationId: 'conv-1',
|
||||||
|
});
|
||||||
|
|
||||||
|
const surface = manager.getSurface('surface-1');
|
||||||
|
expect(surface).toBeDefined();
|
||||||
|
expect(surface!.conversationId).toBe('conv-1');
|
||||||
|
expect(surface!.components.size).toBe(0);
|
||||||
|
expect(surface!.rootIds).toEqual([]);
|
||||||
|
expect(surface!.dataModel).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves metadata including turnIndex on createSurface', () => {
|
||||||
|
const manager = new A2UISurfaceManager();
|
||||||
|
|
||||||
|
manager.processMessage({
|
||||||
|
type: 'createSurface',
|
||||||
|
surfaceId: 'surface-1',
|
||||||
|
conversationId: 'conv-1',
|
||||||
|
metadata: { turnIndex: 3 },
|
||||||
|
});
|
||||||
|
|
||||||
|
const surface = manager.getSurface('surface-1');
|
||||||
|
expect(surface).toBeDefined();
|
||||||
|
expect(surface!.metadata).toEqual({ turnIndex: 3 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('notifies listeners on surface creation', () => {
|
||||||
|
const manager = new A2UISurfaceManager();
|
||||||
|
const listener = vi.fn();
|
||||||
|
manager.onChange(listener);
|
||||||
|
|
||||||
|
manager.processMessage({
|
||||||
|
type: 'createSurface',
|
||||||
|
surfaceId: 'surface-1',
|
||||||
|
conversationId: 'conv-1',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(listener).toHaveBeenCalledWith('surface-1');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('updateComponents', () => {
|
||||||
|
it('adds components to an existing surface', () => {
|
||||||
|
const manager = new A2UISurfaceManager();
|
||||||
|
|
||||||
|
manager.processMessage({
|
||||||
|
type: 'createSurface',
|
||||||
|
surfaceId: 'surface-1',
|
||||||
|
conversationId: 'conv-1',
|
||||||
|
});
|
||||||
|
|
||||||
|
const component = createTestComponent();
|
||||||
|
manager.processMessage({
|
||||||
|
type: 'updateComponents',
|
||||||
|
surfaceId: 'surface-1',
|
||||||
|
components: [component],
|
||||||
|
rootIds: ['comp-1'],
|
||||||
|
});
|
||||||
|
|
||||||
|
const surface = manager.getSurface('surface-1');
|
||||||
|
expect(surface!.components.size).toBe(1);
|
||||||
|
expect(surface!.components.get('comp-1')).toEqual(component);
|
||||||
|
expect(surface!.rootIds).toEqual(['comp-1']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores updateComponents for non-existent surfaces', () => {
|
||||||
|
const manager = new A2UISurfaceManager();
|
||||||
|
const listener = vi.fn();
|
||||||
|
manager.onChange(listener);
|
||||||
|
|
||||||
|
manager.processMessage({
|
||||||
|
type: 'updateComponents',
|
||||||
|
surfaceId: 'nonexistent',
|
||||||
|
components: [createTestComponent()],
|
||||||
|
rootIds: ['comp-1'],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(listener).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('updateDataModel', () => {
|
||||||
|
it('sets a value in the data model', () => {
|
||||||
|
const manager = new A2UISurfaceManager();
|
||||||
|
|
||||||
|
manager.processMessage({
|
||||||
|
type: 'createSurface',
|
||||||
|
surfaceId: 'surface-1',
|
||||||
|
conversationId: 'conv-1',
|
||||||
|
});
|
||||||
|
|
||||||
|
manager.processMessage({
|
||||||
|
type: 'updateDataModel',
|
||||||
|
surfaceId: 'surface-1',
|
||||||
|
path: '/chartData',
|
||||||
|
value: [{ label: 'A', value: 1 }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const dataModel = manager.getDataModel('surface-1');
|
||||||
|
expect(dataModel).toEqual({ chartData: [{ label: 'A', value: 1 }] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores updateDataModel for non-existent surfaces', () => {
|
||||||
|
const manager = new A2UISurfaceManager();
|
||||||
|
|
||||||
|
manager.processMessage({
|
||||||
|
type: 'updateDataModel',
|
||||||
|
surfaceId: 'nonexistent',
|
||||||
|
path: '/foo',
|
||||||
|
value: 'bar',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(manager.getDataModel('nonexistent')).toEqual({});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('deleteSurface', () => {
|
||||||
|
it('removes a surface', () => {
|
||||||
|
const manager = new A2UISurfaceManager();
|
||||||
|
|
||||||
|
manager.processMessage({
|
||||||
|
type: 'createSurface',
|
||||||
|
surfaceId: 'surface-1',
|
||||||
|
conversationId: 'conv-1',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(manager.getSurface('surface-1')).toBeDefined();
|
||||||
|
|
||||||
|
manager.processMessage({
|
||||||
|
type: 'deleteSurface',
|
||||||
|
surfaceId: 'surface-1',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(manager.getSurface('surface-1')).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getSurfaceIds', () => {
|
||||||
|
it('returns surface IDs for a specific conversation', () => {
|
||||||
|
const manager = new A2UISurfaceManager();
|
||||||
|
|
||||||
|
manager.processMessage({ type: 'createSurface', surfaceId: 's1', conversationId: 'conv-1' });
|
||||||
|
manager.processMessage({ type: 'createSurface', surfaceId: 's2', conversationId: 'conv-1' });
|
||||||
|
manager.processMessage({ type: 'createSurface', surfaceId: 's3', conversationId: 'conv-2' });
|
||||||
|
|
||||||
|
expect(manager.getSurfaceIds('conv-1')).toEqual(['s1', 's2']);
|
||||||
|
expect(manager.getSurfaceIds('conv-2')).toEqual(['s3']);
|
||||||
|
expect(manager.getSurfaceIds('conv-3')).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('resolveTree', () => {
|
||||||
|
it('resolves a flat component buffer into a nested tree', () => {
|
||||||
|
const manager = new A2UISurfaceManager();
|
||||||
|
|
||||||
|
manager.processMessage({ type: 'createSurface', surfaceId: 's1', conversationId: 'conv-1' });
|
||||||
|
manager.processMessage({
|
||||||
|
type: 'updateComponents',
|
||||||
|
surfaceId: 's1',
|
||||||
|
components: [
|
||||||
|
{ id: 'root', type: 'column', properties: {}, children: ['child-1', 'child-2'] },
|
||||||
|
{ id: 'child-1', type: 'text', properties: { text: 'Hello' } },
|
||||||
|
{ id: 'child-2', type: 'button', properties: { label: 'Click' } },
|
||||||
|
],
|
||||||
|
rootIds: ['root'],
|
||||||
|
});
|
||||||
|
|
||||||
|
const tree = manager.resolveTree('s1');
|
||||||
|
|
||||||
|
expect(tree).toHaveLength(1);
|
||||||
|
expect(tree[0].type).toBe('column');
|
||||||
|
expect(tree[0].children).toHaveLength(2);
|
||||||
|
expect(tree[0].children[0].type).toBe('text');
|
||||||
|
expect(tree[0].children[0].properties.text).toBe('Hello');
|
||||||
|
expect(tree[0].children[1].type).toBe('button');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves data bindings to bound values', () => {
|
||||||
|
const manager = new A2UISurfaceManager();
|
||||||
|
|
||||||
|
manager.processMessage({ type: 'createSurface', surfaceId: 's1', conversationId: 'conv-1' });
|
||||||
|
manager.processMessage({
|
||||||
|
type: 'updateComponents',
|
||||||
|
surfaceId: 's1',
|
||||||
|
components: [
|
||||||
|
{ id: 'chart-1', type: 'chart', properties: { chartType: 'bar' }, dataBinding: '/data' },
|
||||||
|
],
|
||||||
|
rootIds: ['chart-1'],
|
||||||
|
});
|
||||||
|
manager.processMessage({
|
||||||
|
type: 'updateDataModel',
|
||||||
|
surfaceId: 's1',
|
||||||
|
path: '/data',
|
||||||
|
value: [1, 2, 3],
|
||||||
|
});
|
||||||
|
|
||||||
|
const tree = manager.resolveTree('s1');
|
||||||
|
expect(tree[0].boundValue).toEqual([1, 2, 3]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty array for non-existent surface', () => {
|
||||||
|
const manager = new A2UISurfaceManager();
|
||||||
|
expect(manager.resolveTree('nonexistent')).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('filters out unresolvable child references', () => {
|
||||||
|
const manager = new A2UISurfaceManager();
|
||||||
|
|
||||||
|
manager.processMessage({ type: 'createSurface', surfaceId: 's1', conversationId: 'conv-1' });
|
||||||
|
manager.processMessage({
|
||||||
|
type: 'updateComponents',
|
||||||
|
surfaceId: 's1',
|
||||||
|
components: [
|
||||||
|
{ id: 'root', type: 'column', properties: {}, children: ['child-1', 'missing'] },
|
||||||
|
{ id: 'child-1', type: 'text', properties: { text: 'Hello' } },
|
||||||
|
],
|
||||||
|
rootIds: ['root'],
|
||||||
|
});
|
||||||
|
|
||||||
|
const tree = manager.resolveTree('s1');
|
||||||
|
expect(tree[0].children).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('updateLocalData', () => {
|
||||||
|
it('updates data model and notifies listeners', () => {
|
||||||
|
const manager = new A2UISurfaceManager();
|
||||||
|
const listener = vi.fn();
|
||||||
|
|
||||||
|
manager.processMessage({ type: 'createSurface', surfaceId: 's1', conversationId: 'conv-1' });
|
||||||
|
manager.onChange(listener);
|
||||||
|
|
||||||
|
manager.updateLocalData('s1', '/formData/name', 'John');
|
||||||
|
|
||||||
|
expect(manager.getDataModel('s1')).toEqual({ formData: { name: 'John' } });
|
||||||
|
expect(listener).toHaveBeenCalledWith('s1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores updates for non-existent surfaces', () => {
|
||||||
|
const manager = new A2UISurfaceManager();
|
||||||
|
const listener = vi.fn();
|
||||||
|
manager.onChange(listener);
|
||||||
|
|
||||||
|
manager.updateLocalData('nonexistent', '/foo', 'bar');
|
||||||
|
|
||||||
|
expect(listener).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('clearConversation', () => {
|
||||||
|
it('removes all surfaces for a conversation', () => {
|
||||||
|
const manager = new A2UISurfaceManager();
|
||||||
|
|
||||||
|
manager.processMessage({ type: 'createSurface', surfaceId: 's1', conversationId: 'conv-1' });
|
||||||
|
manager.processMessage({ type: 'createSurface', surfaceId: 's2', conversationId: 'conv-1' });
|
||||||
|
manager.processMessage({ type: 'createSurface', surfaceId: 's3', conversationId: 'conv-2' });
|
||||||
|
|
||||||
|
manager.clearConversation('conv-1');
|
||||||
|
|
||||||
|
expect(manager.getSurfaceIds('conv-1')).toEqual([]);
|
||||||
|
expect(manager.getSurfaceIds('conv-2')).toEqual(['s3']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('onChange', () => {
|
||||||
|
it('returns an unsubscribe function', () => {
|
||||||
|
const manager = new A2UISurfaceManager();
|
||||||
|
const listener = vi.fn();
|
||||||
|
|
||||||
|
const unsubscribe = manager.onChange(listener);
|
||||||
|
manager.processMessage({ type: 'createSurface', surfaceId: 's1', conversationId: 'conv-1' });
|
||||||
|
expect(listener).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
unsubscribe();
|
||||||
|
manager.processMessage({ type: 'createSurface', surfaceId: 's2', conversationId: 'conv-1' });
|
||||||
|
expect(listener).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('JSON Pointer utilities', () => {
|
||||||
|
describe('getValueAtPointer', () => {
|
||||||
|
it('returns the root object for empty or "/" pointer', () => {
|
||||||
|
const obj = { foo: 'bar' };
|
||||||
|
expect(getValueAtPointer(obj, '')).toBe(obj);
|
||||||
|
expect(getValueAtPointer(obj, '/')).toBe(obj);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gets a top-level value', () => {
|
||||||
|
expect(getValueAtPointer({ name: 'Alice' }, '/name')).toBe('Alice');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gets a nested value', () => {
|
||||||
|
const obj = { a: { b: { c: 42 } } };
|
||||||
|
expect(getValueAtPointer(obj, '/a/b/c')).toBe(42);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns undefined for missing paths', () => {
|
||||||
|
expect(getValueAtPointer({ a: 1 }, '/b')).toBeUndefined();
|
||||||
|
expect(getValueAtPointer({ a: 1 }, '/a/b/c')).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles escaped pointer characters', () => {
|
||||||
|
const obj = { 'a/b': { '~c': 'value' } };
|
||||||
|
expect(getValueAtPointer(obj, '/a~1b/~0c')).toBe('value');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('setValueAtPointer', () => {
|
||||||
|
it('sets a top-level value', () => {
|
||||||
|
const obj: Record<string, unknown> = {};
|
||||||
|
setValueAtPointer(obj, '/name', 'Alice');
|
||||||
|
expect(obj.name).toBe('Alice');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates intermediate objects for nested paths', () => {
|
||||||
|
const obj: Record<string, unknown> = {};
|
||||||
|
setValueAtPointer(obj, '/a/b/c', 42);
|
||||||
|
expect(obj).toEqual({ a: { b: { c: 42 } } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does nothing for empty or root pointer', () => {
|
||||||
|
const obj = { foo: 'bar' };
|
||||||
|
setValueAtPointer(obj, '', 'new');
|
||||||
|
setValueAtPointer(obj, '/', 'new');
|
||||||
|
expect(obj).toEqual({ foo: 'bar' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles escaped pointer characters', () => {
|
||||||
|
const obj: Record<string, unknown> = {};
|
||||||
|
setValueAtPointer(obj, '/a~1b', 'value');
|
||||||
|
expect(obj['a/b']).toBe('value');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
139
tests/ipc/chatHandlers.test.ts
Normal file
139
tests/ipc/chatHandlers.test.ts
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
const registeredHandlers = new Map<string, (...args: any[]) => Promise<any>>();
|
||||||
|
|
||||||
|
const webContentsSend = vi.fn();
|
||||||
|
const mainWindowMock = {
|
||||||
|
webContents: {
|
||||||
|
send: webContentsSend,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const chatEngineInstances: Array<Record<string, any>> = [];
|
||||||
|
const openCodeManagerInstances: Array<Record<string, any>> = [];
|
||||||
|
|
||||||
|
vi.mock('electron', () => ({
|
||||||
|
BrowserWindow: {
|
||||||
|
fromWebContents: vi.fn(),
|
||||||
|
},
|
||||||
|
ipcMain: {
|
||||||
|
handle: vi.fn((channel: string, handler: (...args: any[]) => Promise<any>) => {
|
||||||
|
registeredHandlers.set(channel, handler);
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../src/main/database', () => ({
|
||||||
|
getDatabase: vi.fn(() => ({})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../src/main/engine/PostEngine', () => ({
|
||||||
|
getPostEngine: vi.fn(() => ({})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../src/main/engine/MediaEngine', () => ({
|
||||||
|
getMediaEngine: vi.fn(() => ({})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../src/main/engine/ChatEngine', () => ({
|
||||||
|
ChatEngine: class {
|
||||||
|
constructor() {
|
||||||
|
const instance = {
|
||||||
|
getSetting: vi.fn(async (key: string) => (key === 'opencode_api_key' ? 'stored-key' : null)),
|
||||||
|
setSetting: vi.fn(async () => undefined),
|
||||||
|
getSelectedModel: vi.fn(async () => 'gpt-5'),
|
||||||
|
getDefaultSystemPrompt: vi.fn(async () => 'system prompt'),
|
||||||
|
};
|
||||||
|
chatEngineInstances.push(instance);
|
||||||
|
return instance;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../src/main/engine/OpenCodeManager', () => ({
|
||||||
|
OpenCodeManager: class {
|
||||||
|
constructor() {
|
||||||
|
const instance = {
|
||||||
|
setApiKey: vi.fn(),
|
||||||
|
checkReady: vi.fn(async () => ({ ready: true })),
|
||||||
|
validateApiKey: vi.fn(async () => ({ isValid: true, models: [] })),
|
||||||
|
getApiKey: vi.fn(() => 'abc12345'),
|
||||||
|
getAvailableModels: vi.fn(async () => []),
|
||||||
|
sendMessage: vi.fn(async (_conversationId: string, _message: string, options: any) => {
|
||||||
|
options?.onDelta?.('stream-delta');
|
||||||
|
options?.onToolCall?.({ name: 'search_posts', args: { query: 'q' } });
|
||||||
|
options?.onToolResult?.({ name: 'search_posts', result: { ok: true } });
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: 'assistant reply',
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
abortMessage: vi.fn(async () => ({ success: true })),
|
||||||
|
analyzeTaxonomy: vi.fn(async () => ({ success: true })),
|
||||||
|
analyzeMediaImage: vi.fn(async () => ({ success: true })),
|
||||||
|
stop: vi.fn(async () => undefined),
|
||||||
|
};
|
||||||
|
openCodeManagerInstances.push(instance);
|
||||||
|
return instance;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('chatHandlers', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
registeredHandlers.clear();
|
||||||
|
webContentsSend.mockReset();
|
||||||
|
chatEngineInstances.length = 0;
|
||||||
|
openCodeManagerInstances.length = 0;
|
||||||
|
vi.resetModules();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
const mod = await import('../../src/main/ipc/chatHandlers');
|
||||||
|
await mod.cleanupChatHandlers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('streams sendMessage callbacks through main window events', async () => {
|
||||||
|
const mod = await import('../../src/main/ipc/chatHandlers');
|
||||||
|
mod.initializeChatHandlers(() => mainWindowMock as never);
|
||||||
|
mod.registerChatHandlers();
|
||||||
|
|
||||||
|
const handler = registeredHandlers.get('chat:sendMessage');
|
||||||
|
expect(handler).toBeDefined();
|
||||||
|
|
||||||
|
const result = await handler!(
|
||||||
|
undefined,
|
||||||
|
'conversation-1',
|
||||||
|
'hello assistant',
|
||||||
|
{ surface: 'sidebar' },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
|
||||||
|
const manager = openCodeManagerInstances[0];
|
||||||
|
expect(manager.setApiKey).toHaveBeenCalledWith('stored-key');
|
||||||
|
expect(manager.sendMessage).toHaveBeenCalledWith(
|
||||||
|
'conversation-1',
|
||||||
|
'hello assistant',
|
||||||
|
expect.objectContaining({
|
||||||
|
metadata: { surface: 'sidebar' },
|
||||||
|
onDelta: expect.any(Function),
|
||||||
|
onToolCall: expect.any(Function),
|
||||||
|
onToolResult: expect.any(Function),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(webContentsSend).toHaveBeenCalledWith('chat-stream-delta', {
|
||||||
|
conversationId: 'conversation-1',
|
||||||
|
delta: 'stream-delta',
|
||||||
|
});
|
||||||
|
expect(webContentsSend).toHaveBeenCalledWith('chat-tool-call', {
|
||||||
|
conversationId: 'conversation-1',
|
||||||
|
toolCall: { name: 'search_posts', args: { query: 'q' } },
|
||||||
|
});
|
||||||
|
expect(webContentsSend).toHaveBeenCalledWith('chat-tool-result', {
|
||||||
|
conversationId: 'conversation-1',
|
||||||
|
result: { name: 'search_posts', result: { ok: true } },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1638,14 +1638,27 @@ describe('IPC Handlers', () => {
|
|||||||
expect(send).not.toHaveBeenCalled();
|
expect(send).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should execute toggleDevTools on sender when action is toggleDevTools', async () => {
|
it('should open detached devtools on sender when action is toggleDevTools and devtools are closed', async () => {
|
||||||
const toggleDevTools = vi.fn();
|
const openDevTools = vi.fn();
|
||||||
|
const isDevToolsOpened = vi.fn(() => false);
|
||||||
const send = vi.fn();
|
const send = vi.fn();
|
||||||
const event = { sender: { toggleDevTools, send } };
|
const event = { sender: { openDevTools, isDevToolsOpened, send } };
|
||||||
|
|
||||||
await invokeHandlerWithEvent(event, 'app:triggerMenuAction', 'toggleDevTools');
|
await invokeHandlerWithEvent(event, 'app:triggerMenuAction', 'toggleDevTools');
|
||||||
|
|
||||||
expect(toggleDevTools).toHaveBeenCalled();
|
expect(openDevTools).toHaveBeenCalledWith({ mode: 'detach' });
|
||||||
|
expect(send).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should close devtools on sender when action is toggleDevTools and devtools are open', async () => {
|
||||||
|
const closeDevTools = vi.fn();
|
||||||
|
const isDevToolsOpened = vi.fn(() => true);
|
||||||
|
const send = vi.fn();
|
||||||
|
const event = { sender: { closeDevTools, isDevToolsOpened, send } };
|
||||||
|
|
||||||
|
await invokeHandlerWithEvent(event, 'app:triggerMenuAction', 'toggleDevTools');
|
||||||
|
|
||||||
|
expect(closeDevTools).toHaveBeenCalled();
|
||||||
expect(send).not.toHaveBeenCalled();
|
expect(send).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
838
tests/renderer/a2ui/A2UIChart.test.tsx
Normal file
838
tests/renderer/a2ui/A2UIChart.test.tsx
Normal file
@@ -0,0 +1,838 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { A2UIChart } from '../../../src/renderer/a2ui/components/A2UIChart';
|
||||||
|
import type { A2UIResolvedComponent, A2UIClientAction } from '../../../src/main/a2ui/types';
|
||||||
|
|
||||||
|
function makeChartComponent(
|
||||||
|
overrides: Partial<A2UIResolvedComponent> = {},
|
||||||
|
series?: unknown,
|
||||||
|
): A2UIResolvedComponent {
|
||||||
|
return {
|
||||||
|
id: 'chart-1',
|
||||||
|
type: 'chart',
|
||||||
|
properties: {
|
||||||
|
chartType: 'bar',
|
||||||
|
title: 'Test Chart',
|
||||||
|
...(overrides.properties ?? {}),
|
||||||
|
},
|
||||||
|
children: [],
|
||||||
|
boundValue: series ?? [
|
||||||
|
{ label: 'Alpha', value: 10 },
|
||||||
|
{ label: 'Beta', value: 25 },
|
||||||
|
{ label: 'Gamma', value: 15 },
|
||||||
|
],
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const noopAction = vi.fn<(action: A2UIClientAction) => void>();
|
||||||
|
|
||||||
|
describe('A2UIChart', () => {
|
||||||
|
describe('bar chart tabular layout', () => {
|
||||||
|
it('renders chart title', () => {
|
||||||
|
const comp = makeChartComponent();
|
||||||
|
const { container } = render(
|
||||||
|
<A2UIChart component={comp} surfaceId="s1" onAction={noopAction} />,
|
||||||
|
);
|
||||||
|
expect(screen.getByText('Test Chart')).toBeInTheDocument();
|
||||||
|
expect(container.querySelector('.assistant-panel-chart-title')).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders chart type label', () => {
|
||||||
|
const comp = makeChartComponent();
|
||||||
|
render(<A2UIChart component={comp} surfaceId="s1" onAction={noopAction} />);
|
||||||
|
expect(screen.getByText('bar')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders all series labels', () => {
|
||||||
|
const comp = makeChartComponent();
|
||||||
|
render(<A2UIChart component={comp} surfaceId="s1" onAction={noopAction} />);
|
||||||
|
expect(screen.getByText('Alpha')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Beta')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Gamma')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders all series values', () => {
|
||||||
|
const comp = makeChartComponent();
|
||||||
|
render(<A2UIChart component={comp} surfaceId="s1" onAction={noopAction} />);
|
||||||
|
expect(screen.getByText('10')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('25')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('15')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders bar chart items in a three-column grid layout', () => {
|
||||||
|
const comp = makeChartComponent();
|
||||||
|
const { container } = render(
|
||||||
|
<A2UIChart component={comp} surfaceId="s1" onAction={noopAction} />,
|
||||||
|
);
|
||||||
|
const items = container.querySelectorAll('.assistant-panel-chart-item');
|
||||||
|
expect(items).toHaveLength(3);
|
||||||
|
|
||||||
|
// Each item should have label, bar container, and value as separate elements
|
||||||
|
for (const item of items) {
|
||||||
|
const label = item.querySelector('.assistant-panel-chart-label');
|
||||||
|
const barContainer = item.querySelector('.assistant-panel-chart-bar-track');
|
||||||
|
const value = item.querySelector('.assistant-panel-chart-value');
|
||||||
|
expect(label).not.toBeNull();
|
||||||
|
expect(barContainer).not.toBeNull();
|
||||||
|
expect(value).not.toBeNull();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders bar fill with correct percentage width', () => {
|
||||||
|
const comp = makeChartComponent({}, [
|
||||||
|
{ label: 'A', value: 50 },
|
||||||
|
{ label: 'B', value: 100 },
|
||||||
|
]);
|
||||||
|
const { container } = render(
|
||||||
|
<A2UIChart component={comp} surfaceId="s1" onAction={noopAction} />,
|
||||||
|
);
|
||||||
|
const fills = container.querySelectorAll('.assistant-panel-chart-bar-fill');
|
||||||
|
expect(fills).toHaveLength(2);
|
||||||
|
// A = 50/100 = 50%, B = 100/100 = 100%
|
||||||
|
expect((fills[0] as HTMLElement).style.width).toBe('50%');
|
||||||
|
expect((fills[1] as HTMLElement).style.width).toBe('100%');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders without title when title is not provided', () => {
|
||||||
|
const comp = makeChartComponent({ properties: { chartType: 'bar' } });
|
||||||
|
const { container } = render(
|
||||||
|
<A2UIChart component={comp} surfaceId="s1" onAction={noopAction} />,
|
||||||
|
);
|
||||||
|
expect(container.querySelector('.assistant-panel-chart-title')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders empty state when series is empty', () => {
|
||||||
|
const comp = makeChartComponent({}, []);
|
||||||
|
const { container } = render(
|
||||||
|
<A2UIChart component={comp} surfaceId="s1" onAction={noopAction} />,
|
||||||
|
);
|
||||||
|
const items = container.querySelectorAll('.assistant-panel-chart-item');
|
||||||
|
expect(items).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('stacked bar chart', () => {
|
||||||
|
const stackedSeries = [
|
||||||
|
{
|
||||||
|
label: '2023',
|
||||||
|
value: 30,
|
||||||
|
segments: [
|
||||||
|
{ label: 'Published', value: 20 },
|
||||||
|
{ label: 'Draft', value: 10 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '2024',
|
||||||
|
value: 45,
|
||||||
|
segments: [
|
||||||
|
{ label: 'Published', value: 35 },
|
||||||
|
{ label: 'Draft', value: 10 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
it('renders stacked bar items', () => {
|
||||||
|
const comp = makeChartComponent(
|
||||||
|
{ properties: { chartType: 'stacked-bar', title: 'Posts by Year' } },
|
||||||
|
stackedSeries,
|
||||||
|
);
|
||||||
|
const { container } = render(
|
||||||
|
<A2UIChart component={comp} surfaceId="s1" onAction={noopAction} />,
|
||||||
|
);
|
||||||
|
const items = container.querySelectorAll('.assistant-panel-chart-item');
|
||||||
|
expect(items).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders multiple segment fills per bar', () => {
|
||||||
|
const comp = makeChartComponent(
|
||||||
|
{ properties: { chartType: 'stacked-bar', title: 'Posts' } },
|
||||||
|
stackedSeries,
|
||||||
|
);
|
||||||
|
const { container } = render(
|
||||||
|
<A2UIChart component={comp} surfaceId="s1" onAction={noopAction} />,
|
||||||
|
);
|
||||||
|
// Each bar should have multiple fill segments
|
||||||
|
const tracks = container.querySelectorAll('.assistant-panel-chart-bar-track');
|
||||||
|
expect(tracks).toHaveLength(2);
|
||||||
|
|
||||||
|
const firstBarSegments = tracks[0].querySelectorAll('.assistant-panel-chart-bar-segment');
|
||||||
|
expect(firstBarSegments).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders segment widths as proportion of total across all bars', () => {
|
||||||
|
const comp = makeChartComponent(
|
||||||
|
{ properties: { chartType: 'stacked-bar' } },
|
||||||
|
stackedSeries,
|
||||||
|
);
|
||||||
|
const { container } = render(
|
||||||
|
<A2UIChart component={comp} surfaceId="s1" onAction={noopAction} />,
|
||||||
|
);
|
||||||
|
// maxValue is 45 (2024 total)
|
||||||
|
// 2023 row: Published=20/45, Draft=10/45 → total bar width = 30/45
|
||||||
|
// We need segment widths relative to the bar track via the fill percentage
|
||||||
|
const tracks = container.querySelectorAll('.assistant-panel-chart-bar-track');
|
||||||
|
expect(tracks.length).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders a legend for stacked bar charts', () => {
|
||||||
|
const comp = makeChartComponent(
|
||||||
|
{ properties: { chartType: 'stacked-bar', title: 'Posts' } },
|
||||||
|
stackedSeries,
|
||||||
|
);
|
||||||
|
const { container } = render(
|
||||||
|
<A2UIChart component={comp} surfaceId="s1" onAction={noopAction} />,
|
||||||
|
);
|
||||||
|
const legend = container.querySelector('.assistant-panel-chart-legend');
|
||||||
|
expect(legend).not.toBeNull();
|
||||||
|
expect(screen.getByText('Published')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Draft')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows total value for stacked bars', () => {
|
||||||
|
const comp = makeChartComponent(
|
||||||
|
{ properties: { chartType: 'stacked-bar' } },
|
||||||
|
stackedSeries,
|
||||||
|
);
|
||||||
|
render(<A2UIChart component={comp} surfaceId="s1" onAction={noopAction} />);
|
||||||
|
|
||||||
|
expect(screen.getByText('30')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('45')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not render legend for regular bar charts', () => {
|
||||||
|
const comp = makeChartComponent(
|
||||||
|
{ properties: { chartType: 'bar' } },
|
||||||
|
[{ label: 'A', value: 10 }],
|
||||||
|
);
|
||||||
|
const { container } = render(
|
||||||
|
<A2UIChart component={comp} surfaceId="s1" onAction={noopAction} />,
|
||||||
|
);
|
||||||
|
expect(container.querySelector('.assistant-panel-chart-legend')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('line chart', () => {
|
||||||
|
const lineSeries = [
|
||||||
|
{ label: 'Jan', value: 10 },
|
||||||
|
{ label: 'Feb', value: 25 },
|
||||||
|
{ label: 'Mar', value: 15 },
|
||||||
|
{ label: 'Apr', value: 30 },
|
||||||
|
];
|
||||||
|
|
||||||
|
it('renders an SVG element for line charts', () => {
|
||||||
|
const comp = makeChartComponent(
|
||||||
|
{ properties: { chartType: 'line', title: 'Monthly Posts' } },
|
||||||
|
lineSeries,
|
||||||
|
);
|
||||||
|
const { container } = render(
|
||||||
|
<A2UIChart component={comp} surfaceId="s1" onAction={noopAction} />,
|
||||||
|
);
|
||||||
|
const svg = container.querySelector('.assistant-panel-chart-line-svg');
|
||||||
|
expect(svg).not.toBeNull();
|
||||||
|
expect(svg!.tagName.toLowerCase()).toBe('svg');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not render bar chart body for line charts', () => {
|
||||||
|
const comp = makeChartComponent(
|
||||||
|
{ properties: { chartType: 'line' } },
|
||||||
|
lineSeries,
|
||||||
|
);
|
||||||
|
const { container } = render(
|
||||||
|
<A2UIChart component={comp} surfaceId="s1" onAction={noopAction} />,
|
||||||
|
);
|
||||||
|
expect(container.querySelector('.assistant-panel-chart-body')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders a polyline connecting data points', () => {
|
||||||
|
const comp = makeChartComponent(
|
||||||
|
{ properties: { chartType: 'line' } },
|
||||||
|
lineSeries,
|
||||||
|
);
|
||||||
|
const { container } = render(
|
||||||
|
<A2UIChart component={comp} surfaceId="s1" onAction={noopAction} />,
|
||||||
|
);
|
||||||
|
const polyline = container.querySelector('polyline');
|
||||||
|
expect(polyline).not.toBeNull();
|
||||||
|
const points = polyline!.getAttribute('points')!;
|
||||||
|
// Should have 4 coordinate pairs
|
||||||
|
const pairs = points.trim().split(/\s+/);
|
||||||
|
expect(pairs).toHaveLength(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders circle dots at each data point', () => {
|
||||||
|
const comp = makeChartComponent(
|
||||||
|
{ properties: { chartType: 'line' } },
|
||||||
|
lineSeries,
|
||||||
|
);
|
||||||
|
const { container } = render(
|
||||||
|
<A2UIChart component={comp} surfaceId="s1" onAction={noopAction} />,
|
||||||
|
);
|
||||||
|
const dots = container.querySelectorAll('.assistant-panel-chart-line-dot');
|
||||||
|
expect(dots).toHaveLength(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders x-axis labels for each data point', () => {
|
||||||
|
const comp = makeChartComponent(
|
||||||
|
{ properties: { chartType: 'line' } },
|
||||||
|
lineSeries,
|
||||||
|
);
|
||||||
|
render(<A2UIChart component={comp} surfaceId="s1" onAction={noopAction} />);
|
||||||
|
expect(screen.getByText('Jan')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Feb')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Mar')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Apr')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders y-axis value labels', () => {
|
||||||
|
const comp = makeChartComponent(
|
||||||
|
{ properties: { chartType: 'line' } },
|
||||||
|
lineSeries,
|
||||||
|
);
|
||||||
|
const { container } = render(
|
||||||
|
<A2UIChart component={comp} surfaceId="s1" onAction={noopAction} />,
|
||||||
|
);
|
||||||
|
const yLabels = container.querySelectorAll('.assistant-panel-chart-line-y-label');
|
||||||
|
expect(yLabels.length).toBeGreaterThanOrEqual(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders chart title for line charts', () => {
|
||||||
|
const comp = makeChartComponent(
|
||||||
|
{ properties: { chartType: 'line', title: 'Trend' } },
|
||||||
|
lineSeries,
|
||||||
|
);
|
||||||
|
render(<A2UIChart component={comp} surfaceId="s1" onAction={noopAction} />);
|
||||||
|
expect(screen.getByText('Trend')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders chart type label', () => {
|
||||||
|
const comp = makeChartComponent(
|
||||||
|
{ properties: { chartType: 'line' } },
|
||||||
|
lineSeries,
|
||||||
|
);
|
||||||
|
render(<A2UIChart component={comp} surfaceId="s1" onAction={noopAction} />);
|
||||||
|
expect(screen.getByText('line')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles single data point', () => {
|
||||||
|
const comp = makeChartComponent(
|
||||||
|
{ properties: { chartType: 'line' } },
|
||||||
|
[{ label: 'Only', value: 42 }],
|
||||||
|
);
|
||||||
|
const { container } = render(
|
||||||
|
<A2UIChart component={comp} surfaceId="s1" onAction={noopAction} />,
|
||||||
|
);
|
||||||
|
const dots = container.querySelectorAll('.assistant-panel-chart-line-dot');
|
||||||
|
expect(dots).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles empty series for line chart', () => {
|
||||||
|
const comp = makeChartComponent(
|
||||||
|
{ properties: { chartType: 'line' } },
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
const { container } = render(
|
||||||
|
<A2UIChart component={comp} surfaceId="s1" onAction={noopAction} />,
|
||||||
|
);
|
||||||
|
expect(container.querySelector('.assistant-panel-chart-line-svg')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders horizontal grid lines', () => {
|
||||||
|
const comp = makeChartComponent(
|
||||||
|
{ properties: { chartType: 'line' } },
|
||||||
|
lineSeries,
|
||||||
|
);
|
||||||
|
const { container } = render(
|
||||||
|
<A2UIChart component={comp} surfaceId="s1" onAction={noopAction} />,
|
||||||
|
);
|
||||||
|
const gridLines = container.querySelectorAll('.assistant-panel-chart-line-grid');
|
||||||
|
expect(gridLines.length).toBeGreaterThanOrEqual(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('pie chart', () => {
|
||||||
|
const pieSeries = [
|
||||||
|
{ label: 'Published', value: 60 },
|
||||||
|
{ label: 'Draft', value: 25 },
|
||||||
|
{ label: 'Archived', value: 15 },
|
||||||
|
];
|
||||||
|
|
||||||
|
it('renders an SVG element for pie charts', () => {
|
||||||
|
const comp = makeChartComponent(
|
||||||
|
{ properties: { chartType: 'pie', title: 'Post Status' } },
|
||||||
|
pieSeries,
|
||||||
|
);
|
||||||
|
const { container } = render(
|
||||||
|
<A2UIChart component={comp} surfaceId="s1" onAction={noopAction} />,
|
||||||
|
);
|
||||||
|
const svg = container.querySelector('.assistant-panel-chart-pie-svg');
|
||||||
|
expect(svg).not.toBeNull();
|
||||||
|
expect(svg!.tagName.toLowerCase()).toBe('svg');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not render bar chart body for pie charts', () => {
|
||||||
|
const comp = makeChartComponent(
|
||||||
|
{ properties: { chartType: 'pie' } },
|
||||||
|
pieSeries,
|
||||||
|
);
|
||||||
|
const { container } = render(
|
||||||
|
<A2UIChart component={comp} surfaceId="s1" onAction={noopAction} />,
|
||||||
|
);
|
||||||
|
expect(container.querySelector('.assistant-panel-chart-body')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders a path slice for each data point', () => {
|
||||||
|
const comp = makeChartComponent(
|
||||||
|
{ properties: { chartType: 'pie' } },
|
||||||
|
pieSeries,
|
||||||
|
);
|
||||||
|
const { container } = render(
|
||||||
|
<A2UIChart component={comp} surfaceId="s1" onAction={noopAction} />,
|
||||||
|
);
|
||||||
|
const slices = container.querySelectorAll('.assistant-panel-chart-pie-slice');
|
||||||
|
expect(slices).toHaveLength(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders a legend with all labels and values', () => {
|
||||||
|
const comp = makeChartComponent(
|
||||||
|
{ properties: { chartType: 'pie' } },
|
||||||
|
pieSeries,
|
||||||
|
);
|
||||||
|
const { container } = render(
|
||||||
|
<A2UIChart component={comp} surfaceId="s1" onAction={noopAction} />,
|
||||||
|
);
|
||||||
|
const legend = container.querySelector('.assistant-panel-chart-legend');
|
||||||
|
expect(legend).not.toBeNull();
|
||||||
|
expect(screen.getByText('Published')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Draft')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Archived')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders chart title for pie charts', () => {
|
||||||
|
const comp = makeChartComponent(
|
||||||
|
{ properties: { chartType: 'pie', title: 'Distribution' } },
|
||||||
|
pieSeries,
|
||||||
|
);
|
||||||
|
render(<A2UIChart component={comp} surfaceId="s1" onAction={noopAction} />);
|
||||||
|
expect(screen.getByText('Distribution')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders chart type label', () => {
|
||||||
|
const comp = makeChartComponent(
|
||||||
|
{ properties: { chartType: 'pie' } },
|
||||||
|
pieSeries,
|
||||||
|
);
|
||||||
|
render(<A2UIChart component={comp} surfaceId="s1" onAction={noopAction} />);
|
||||||
|
expect(screen.getByText('pie')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles single slice', () => {
|
||||||
|
const comp = makeChartComponent(
|
||||||
|
{ properties: { chartType: 'pie' } },
|
||||||
|
[{ label: 'All', value: 100 }],
|
||||||
|
);
|
||||||
|
const { container } = render(
|
||||||
|
<A2UIChart component={comp} surfaceId="s1" onAction={noopAction} />,
|
||||||
|
);
|
||||||
|
const slices = container.querySelectorAll('.assistant-panel-chart-pie-slice');
|
||||||
|
expect(slices).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles empty series for pie chart', () => {
|
||||||
|
const comp = makeChartComponent(
|
||||||
|
{ properties: { chartType: 'pie' } },
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
const { container } = render(
|
||||||
|
<A2UIChart component={comp} surfaceId="s1" onAction={noopAction} />,
|
||||||
|
);
|
||||||
|
expect(container.querySelector('.assistant-panel-chart-pie-svg')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('assigns different colors to each slice', () => {
|
||||||
|
const comp = makeChartComponent(
|
||||||
|
{ properties: { chartType: 'pie' } },
|
||||||
|
pieSeries,
|
||||||
|
);
|
||||||
|
const { container } = render(
|
||||||
|
<A2UIChart component={comp} surfaceId="s1" onAction={noopAction} />,
|
||||||
|
);
|
||||||
|
const slices = container.querySelectorAll('.assistant-panel-chart-pie-slice');
|
||||||
|
const fills = Array.from(slices).map((s) => (s as SVGElement).getAttribute('fill'));
|
||||||
|
// All fills should be different
|
||||||
|
const unique = new Set(fills);
|
||||||
|
expect(unique.size).toBe(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('area chart', () => {
|
||||||
|
const areaSeries = [
|
||||||
|
{ label: 'Jan', value: 10 },
|
||||||
|
{ label: 'Feb', value: 25 },
|
||||||
|
{ label: 'Mar', value: 15 },
|
||||||
|
{ label: 'Apr', value: 30 },
|
||||||
|
];
|
||||||
|
|
||||||
|
it('renders an SVG element for area charts', () => {
|
||||||
|
const comp = makeChartComponent(
|
||||||
|
{ properties: { chartType: 'area', title: 'Cumulative Posts' } },
|
||||||
|
areaSeries,
|
||||||
|
);
|
||||||
|
const { container } = render(
|
||||||
|
<A2UIChart component={comp} surfaceId="s1" onAction={noopAction} />,
|
||||||
|
);
|
||||||
|
const svg = container.querySelector('.assistant-panel-chart-line-svg');
|
||||||
|
expect(svg).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not render bar chart body for area charts', () => {
|
||||||
|
const comp = makeChartComponent(
|
||||||
|
{ properties: { chartType: 'area' } },
|
||||||
|
areaSeries,
|
||||||
|
);
|
||||||
|
const { container } = render(
|
||||||
|
<A2UIChart component={comp} surfaceId="s1" onAction={noopAction} />,
|
||||||
|
);
|
||||||
|
expect(container.querySelector('.assistant-panel-chart-body')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders a filled polygon area beneath the line', () => {
|
||||||
|
const comp = makeChartComponent(
|
||||||
|
{ properties: { chartType: 'area' } },
|
||||||
|
areaSeries,
|
||||||
|
);
|
||||||
|
const { container } = render(
|
||||||
|
<A2UIChart component={comp} surfaceId="s1" onAction={noopAction} />,
|
||||||
|
);
|
||||||
|
const area = container.querySelector('.assistant-panel-chart-area-fill');
|
||||||
|
expect(area).not.toBeNull();
|
||||||
|
expect(area!.tagName.toLowerCase()).toBe('polygon');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders dots at each data point', () => {
|
||||||
|
const comp = makeChartComponent(
|
||||||
|
{ properties: { chartType: 'area' } },
|
||||||
|
areaSeries,
|
||||||
|
);
|
||||||
|
const { container } = render(
|
||||||
|
<A2UIChart component={comp} surfaceId="s1" onAction={noopAction} />,
|
||||||
|
);
|
||||||
|
const dots = container.querySelectorAll('.assistant-panel-chart-line-dot');
|
||||||
|
expect(dots).toHaveLength(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders a polyline on top of the area', () => {
|
||||||
|
const comp = makeChartComponent(
|
||||||
|
{ properties: { chartType: 'area' } },
|
||||||
|
areaSeries,
|
||||||
|
);
|
||||||
|
const { container } = render(
|
||||||
|
<A2UIChart component={comp} surfaceId="s1" onAction={noopAction} />,
|
||||||
|
);
|
||||||
|
const polyline = container.querySelector('polyline');
|
||||||
|
expect(polyline).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders x-axis labels', () => {
|
||||||
|
const comp = makeChartComponent(
|
||||||
|
{ properties: { chartType: 'area' } },
|
||||||
|
areaSeries,
|
||||||
|
);
|
||||||
|
render(<A2UIChart component={comp} surfaceId="s1" onAction={noopAction} />);
|
||||||
|
expect(screen.getByText('Jan')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Apr')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles empty series for area chart', () => {
|
||||||
|
const comp = makeChartComponent(
|
||||||
|
{ properties: { chartType: 'area' } },
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
const { container } = render(
|
||||||
|
<A2UIChart component={comp} surfaceId="s1" onAction={noopAction} />,
|
||||||
|
);
|
||||||
|
expect(container.querySelector('.assistant-panel-chart-line-svg')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('donut chart', () => {
|
||||||
|
const donutSeries = [
|
||||||
|
{ label: 'Published', value: 60 },
|
||||||
|
{ label: 'Draft', value: 25 },
|
||||||
|
{ label: 'Archived', value: 15 },
|
||||||
|
];
|
||||||
|
|
||||||
|
it('renders an SVG element for donut charts', () => {
|
||||||
|
const comp = makeChartComponent(
|
||||||
|
{ properties: { chartType: 'donut', title: 'Post Status' } },
|
||||||
|
donutSeries,
|
||||||
|
);
|
||||||
|
const { container } = render(
|
||||||
|
<A2UIChart component={comp} surfaceId="s1" onAction={noopAction} />,
|
||||||
|
);
|
||||||
|
const svg = container.querySelector('.assistant-panel-chart-pie-svg');
|
||||||
|
expect(svg).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not render bar chart body for donut charts', () => {
|
||||||
|
const comp = makeChartComponent(
|
||||||
|
{ properties: { chartType: 'donut' } },
|
||||||
|
donutSeries,
|
||||||
|
);
|
||||||
|
const { container } = render(
|
||||||
|
<A2UIChart component={comp} surfaceId="s1" onAction={noopAction} />,
|
||||||
|
);
|
||||||
|
expect(container.querySelector('.assistant-panel-chart-body')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders arc path slices (not filled wedges)', () => {
|
||||||
|
const comp = makeChartComponent(
|
||||||
|
{ properties: { chartType: 'donut' } },
|
||||||
|
donutSeries,
|
||||||
|
);
|
||||||
|
const { container } = render(
|
||||||
|
<A2UIChart component={comp} surfaceId="s1" onAction={noopAction} />,
|
||||||
|
);
|
||||||
|
const slices = container.querySelectorAll('.assistant-panel-chart-pie-slice');
|
||||||
|
expect(slices).toHaveLength(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders a center hole circle', () => {
|
||||||
|
const comp = makeChartComponent(
|
||||||
|
{ properties: { chartType: 'donut' } },
|
||||||
|
donutSeries,
|
||||||
|
);
|
||||||
|
const { container } = render(
|
||||||
|
<A2UIChart component={comp} surfaceId="s1" onAction={noopAction} />,
|
||||||
|
);
|
||||||
|
const hole = container.querySelector('.assistant-panel-chart-donut-hole');
|
||||||
|
expect(hole).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders center total text', () => {
|
||||||
|
const comp = makeChartComponent(
|
||||||
|
{ properties: { chartType: 'donut' } },
|
||||||
|
donutSeries,
|
||||||
|
);
|
||||||
|
const { container } = render(
|
||||||
|
<A2UIChart component={comp} surfaceId="s1" onAction={noopAction} />,
|
||||||
|
);
|
||||||
|
const centerText = container.querySelector('.assistant-panel-chart-donut-total');
|
||||||
|
expect(centerText).not.toBeNull();
|
||||||
|
expect(centerText!.textContent).toBe('100');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders a legend with all labels', () => {
|
||||||
|
const comp = makeChartComponent(
|
||||||
|
{ properties: { chartType: 'donut' } },
|
||||||
|
donutSeries,
|
||||||
|
);
|
||||||
|
const { container } = render(
|
||||||
|
<A2UIChart component={comp} surfaceId="s1" onAction={noopAction} />,
|
||||||
|
);
|
||||||
|
const legend = container.querySelector('.assistant-panel-chart-legend');
|
||||||
|
expect(legend).not.toBeNull();
|
||||||
|
expect(screen.getByText('Published')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Draft')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles single slice donut', () => {
|
||||||
|
const comp = makeChartComponent(
|
||||||
|
{ properties: { chartType: 'donut' } },
|
||||||
|
[{ label: 'All', value: 100 }],
|
||||||
|
);
|
||||||
|
const { container } = render(
|
||||||
|
<A2UIChart component={comp} surfaceId="s1" onAction={noopAction} />,
|
||||||
|
);
|
||||||
|
const slices = container.querySelectorAll('.assistant-panel-chart-pie-slice');
|
||||||
|
expect(slices).toHaveLength(1);
|
||||||
|
expect(container.querySelector('.assistant-panel-chart-donut-hole')).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles empty series for donut chart', () => {
|
||||||
|
const comp = makeChartComponent(
|
||||||
|
{ properties: { chartType: 'donut' } },
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
const { container } = render(
|
||||||
|
<A2UIChart component={comp} surfaceId="s1" onAction={noopAction} />,
|
||||||
|
);
|
||||||
|
expect(container.querySelector('.assistant-panel-chart-pie-svg')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('heatmap chart', () => {
|
||||||
|
const heatmapSeries = [
|
||||||
|
{
|
||||||
|
label: 'Mon',
|
||||||
|
value: 0,
|
||||||
|
segments: [
|
||||||
|
{ label: 'W1', value: 3 },
|
||||||
|
{ label: 'W2', value: 0 },
|
||||||
|
{ label: 'W3', value: 5 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Tue',
|
||||||
|
value: 0,
|
||||||
|
segments: [
|
||||||
|
{ label: 'W1', value: 1 },
|
||||||
|
{ label: 'W2', value: 4 },
|
||||||
|
{ label: 'W3', value: 2 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
it('renders a grid container for heatmap charts', () => {
|
||||||
|
const comp = makeChartComponent(
|
||||||
|
{ properties: { chartType: 'heatmap', title: 'Activity' } },
|
||||||
|
heatmapSeries,
|
||||||
|
);
|
||||||
|
const { container } = render(
|
||||||
|
<A2UIChart component={comp} surfaceId="s1" onAction={noopAction} />,
|
||||||
|
);
|
||||||
|
const grid = container.querySelector('.assistant-panel-chart-heatmap');
|
||||||
|
expect(grid).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not render bar chart body for heatmap', () => {
|
||||||
|
const comp = makeChartComponent(
|
||||||
|
{ properties: { chartType: 'heatmap' } },
|
||||||
|
heatmapSeries,
|
||||||
|
);
|
||||||
|
const { container } = render(
|
||||||
|
<A2UIChart component={comp} surfaceId="s1" onAction={noopAction} />,
|
||||||
|
);
|
||||||
|
expect(container.querySelector('.assistant-panel-chart-body')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders cells for each data point', () => {
|
||||||
|
const comp = makeChartComponent(
|
||||||
|
{ properties: { chartType: 'heatmap' } },
|
||||||
|
heatmapSeries,
|
||||||
|
);
|
||||||
|
const { container } = render(
|
||||||
|
<A2UIChart component={comp} surfaceId="s1" onAction={noopAction} />,
|
||||||
|
);
|
||||||
|
// 2 rows x 3 columns = 6 cells
|
||||||
|
const cells = container.querySelectorAll('.assistant-panel-chart-heatmap-cell');
|
||||||
|
expect(cells).toHaveLength(6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders row labels', () => {
|
||||||
|
const comp = makeChartComponent(
|
||||||
|
{ properties: { chartType: 'heatmap' } },
|
||||||
|
heatmapSeries,
|
||||||
|
);
|
||||||
|
render(<A2UIChart component={comp} surfaceId="s1" onAction={noopAction} />);
|
||||||
|
expect(screen.getByText('Mon')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Tue')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders column header labels', () => {
|
||||||
|
const comp = makeChartComponent(
|
||||||
|
{ properties: { chartType: 'heatmap' } },
|
||||||
|
heatmapSeries,
|
||||||
|
);
|
||||||
|
render(<A2UIChart component={comp} surfaceId="s1" onAction={noopAction} />);
|
||||||
|
expect(screen.getByText('W1')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('W2')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('W3')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies ins-to-del gradient background based on value', () => {
|
||||||
|
const comp = makeChartComponent(
|
||||||
|
{ properties: { chartType: 'heatmap' } },
|
||||||
|
heatmapSeries,
|
||||||
|
);
|
||||||
|
const { container } = render(
|
||||||
|
<A2UIChart component={comp} surfaceId="s1" onAction={noopAction} />,
|
||||||
|
);
|
||||||
|
const cells = container.querySelectorAll('.assistant-panel-chart-heatmap-cell');
|
||||||
|
// Cell with value 0 should have transparent background
|
||||||
|
const zeroCell = Array.from(cells).find((c) => c.getAttribute('title') === '0');
|
||||||
|
expect(zeroCell).toBeDefined();
|
||||||
|
expect((zeroCell as HTMLElement).style.background).toBe('transparent');
|
||||||
|
// Cell with max value (5) should have an rgba/rgb background (del color at full opacity)
|
||||||
|
const maxCell = Array.from(cells).find((c) => c.getAttribute('title') === '5');
|
||||||
|
expect(maxCell).toBeDefined();
|
||||||
|
expect((maxCell as HTMLElement).style.background).toMatch(/^rgba?\(/);
|
||||||
|
// Mid-value cell should also have an rgba/rgb background
|
||||||
|
const midCell = Array.from(cells).find((c) => c.getAttribute('title') === '3');
|
||||||
|
expect(midCell).toBeDefined();
|
||||||
|
expect((midCell as HTMLElement).style.background).toMatch(/^rgba?\(/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sets contrasting text color on cells', () => {
|
||||||
|
const comp = makeChartComponent(
|
||||||
|
{ properties: { chartType: 'heatmap' } },
|
||||||
|
heatmapSeries,
|
||||||
|
);
|
||||||
|
const { container } = render(
|
||||||
|
<A2UIChart component={comp} surfaceId="s1" onAction={noopAction} />,
|
||||||
|
);
|
||||||
|
const cells = container.querySelectorAll('.assistant-panel-chart-heatmap-cell');
|
||||||
|
// Non-zero cells should have explicit black or white text color
|
||||||
|
const maxCell = Array.from(cells).find((c) => c.getAttribute('title') === '5');
|
||||||
|
expect(maxCell).toBeDefined();
|
||||||
|
const maxColor = (maxCell as HTMLElement).style.color;
|
||||||
|
expect(maxColor).toMatch(/#(000|fff)|rgb\((0, 0, 0|255, 255, 255)\)/);
|
||||||
|
// Zero cell should have inherit
|
||||||
|
const zeroCell = Array.from(cells).find((c) => c.getAttribute('title') === '0');
|
||||||
|
expect(zeroCell).toBeDefined();
|
||||||
|
expect((zeroCell as HTMLElement).style.color).toBe('inherit');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('displays value text inside non-zero cells', () => {
|
||||||
|
const comp = makeChartComponent(
|
||||||
|
{ properties: { chartType: 'heatmap' } },
|
||||||
|
heatmapSeries,
|
||||||
|
);
|
||||||
|
const { container } = render(
|
||||||
|
<A2UIChart component={comp} surfaceId="s1" onAction={noopAction} />,
|
||||||
|
);
|
||||||
|
const cells = container.querySelectorAll('.assistant-panel-chart-heatmap-cell');
|
||||||
|
// Non-zero cell should display value as text
|
||||||
|
const cell3 = Array.from(cells).find((c) => c.getAttribute('title') === '3');
|
||||||
|
expect(cell3).toBeDefined();
|
||||||
|
expect(cell3!.textContent).toBe('3');
|
||||||
|
// Zero cell should be empty
|
||||||
|
const cell0 = Array.from(cells).find((c) => c.getAttribute('title') === '0');
|
||||||
|
expect(cell0).toBeDefined();
|
||||||
|
expect(cell0!.textContent).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders chart title for heatmap', () => {
|
||||||
|
const comp = makeChartComponent(
|
||||||
|
{ properties: { chartType: 'heatmap', title: 'Posting Activity' } },
|
||||||
|
heatmapSeries,
|
||||||
|
);
|
||||||
|
render(<A2UIChart component={comp} surfaceId="s1" onAction={noopAction} />);
|
||||||
|
expect(screen.getByText('Posting Activity')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles empty series for heatmap', () => {
|
||||||
|
const comp = makeChartComponent(
|
||||||
|
{ properties: { chartType: 'heatmap' } },
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
const { container } = render(
|
||||||
|
<A2UIChart component={comp} surfaceId="s1" onAction={noopAction} />,
|
||||||
|
);
|
||||||
|
expect(container.querySelector('.assistant-panel-chart-heatmap')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles series without segments gracefully', () => {
|
||||||
|
const comp = makeChartComponent(
|
||||||
|
{ properties: { chartType: 'heatmap' } },
|
||||||
|
[{ label: 'Mon', value: 5 }],
|
||||||
|
);
|
||||||
|
const { container } = render(
|
||||||
|
<A2UIChart component={comp} surfaceId="s1" onAction={noopAction} />,
|
||||||
|
);
|
||||||
|
// No segments → no cells, no grid rendered
|
||||||
|
expect(container.querySelector('.assistant-panel-chart-heatmap')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
72
tests/renderer/a2ui/InlineSurface.test.ts
Normal file
72
tests/renderer/a2ui/InlineSurface.test.ts
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { deriveSurfaceTitle, getSurfaceIcon } from '../../../src/renderer/a2ui/InlineSurface';
|
||||||
|
import type { A2UIResolvedComponent } from '../../../src/main/a2ui/types';
|
||||||
|
|
||||||
|
function makeTree(type: string, properties: Record<string, unknown> = {}): A2UIResolvedComponent[] {
|
||||||
|
return [{
|
||||||
|
id: `${type}-1`,
|
||||||
|
type: type as A2UIResolvedComponent['type'],
|
||||||
|
properties,
|
||||||
|
children: [],
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('deriveSurfaceTitle', () => {
|
||||||
|
it('extracts title from root component properties', () => {
|
||||||
|
expect(deriveSurfaceTitle(makeTree('chart', { title: 'Post Views' }))).toBe('Post Views');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('extracts label from root component properties when no title', () => {
|
||||||
|
expect(deriveSurfaceTitle(makeTree('metric', { label: 'Total Posts' }))).toBe('Total Posts');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to capitalized component type when no title or label', () => {
|
||||||
|
expect(deriveSurfaceTitle(makeTree('chart'))).toBe('Chart');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns "Surface" for an empty tree', () => {
|
||||||
|
expect(deriveSurfaceTitle([])).toBe('Surface');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('capitalizes multi-word types correctly', () => {
|
||||||
|
expect(deriveSurfaceTitle(makeTree('textField'))).toBe('TextField');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getSurfaceIcon', () => {
|
||||||
|
it('returns chart icon for chart type', () => {
|
||||||
|
expect(getSurfaceIcon(makeTree('chart'))).toBe('\u{1F4CA}');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns table icon for table type', () => {
|
||||||
|
expect(getSurfaceIcon(makeTree('table'))).toBe('\u{1F4CB}');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns form icon for form type', () => {
|
||||||
|
expect(getSurfaceIcon(makeTree('form'))).toBe('\u{1F4DD}');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns card icon for card type', () => {
|
||||||
|
expect(getSurfaceIcon(makeTree('card'))).toBe('\u{1F4C4}');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns metric icon for metric type', () => {
|
||||||
|
expect(getSurfaceIcon(makeTree('metric'))).toBe('\u{1F4CF}');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns list icon for list type', () => {
|
||||||
|
expect(getSurfaceIcon(makeTree('list'))).toBe('\u{1F4CB}');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns tabs icon for tabs type', () => {
|
||||||
|
expect(getSurfaceIcon(makeTree('tabs'))).toBe('\u{1F4C2}');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns default icon for unknown types', () => {
|
||||||
|
expect(getSurfaceIcon(makeTree('text'))).toBe('\u25A0');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns default icon for empty tree', () => {
|
||||||
|
expect(getSurfaceIcon([])).toBe('\u25A0');
|
||||||
|
});
|
||||||
|
});
|
||||||
45
tests/renderer/a2ui/inlineSurfaceAssociation.test.ts
Normal file
45
tests/renderer/a2ui/inlineSurfaceAssociation.test.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { computeTurnIndex } from '../../../src/renderer/a2ui/surfaceAssociation';
|
||||||
|
import type { ChatMessage } from '../../../src/main/shared/electronApi';
|
||||||
|
|
||||||
|
function msg(role: ChatMessage['role'], id = `msg-${Date.now()}-${Math.random()}`): ChatMessage {
|
||||||
|
return { id, conversationId: 'conv-1', role, content: '', createdAt: new Date().toISOString() };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('computeTurnIndex', () => {
|
||||||
|
it('returns 0 for the first user message', () => {
|
||||||
|
const messages = [msg('user')];
|
||||||
|
expect(computeTurnIndex(messages, 0)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 0 for an assistant message following the first user message', () => {
|
||||||
|
const messages = [msg('user'), msg('assistant')];
|
||||||
|
expect(computeTurnIndex(messages, 1)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('increments turn index for each user message', () => {
|
||||||
|
const messages = [msg('user'), msg('assistant'), msg('user'), msg('assistant')];
|
||||||
|
expect(computeTurnIndex(messages, 0)).toBe(0);
|
||||||
|
expect(computeTurnIndex(messages, 1)).toBe(0);
|
||||||
|
expect(computeTurnIndex(messages, 2)).toBe(1);
|
||||||
|
expect(computeTurnIndex(messages, 3)).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips system and tool messages in turn counting', () => {
|
||||||
|
const messages = [msg('system'), msg('user'), msg('tool'), msg('assistant')];
|
||||||
|
expect(computeTurnIndex(messages, 1)).toBe(0); // user
|
||||||
|
expect(computeTurnIndex(messages, 3)).toBe(0); // assistant after first user
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns -1 when no user messages precede the index', () => {
|
||||||
|
const messages = [msg('system'), msg('assistant')];
|
||||||
|
expect(computeTurnIndex(messages, 0)).toBe(-1);
|
||||||
|
expect(computeTurnIndex(messages, 1)).toBe(-1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles multiple assistant messages in the same turn', () => {
|
||||||
|
const messages = [msg('user'), msg('assistant'), msg('assistant')];
|
||||||
|
expect(computeTurnIndex(messages, 1)).toBe(0);
|
||||||
|
expect(computeTurnIndex(messages, 2)).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
213
tests/renderer/a2ui/surfaceReplay.test.ts
Normal file
213
tests/renderer/a2ui/surfaceReplay.test.ts
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { replaySurfacesFromMessages } from '../../../src/renderer/a2ui/surfaceAssociation';
|
||||||
|
import type { ChatMessage } from '../../../src/main/shared/electronApi';
|
||||||
|
|
||||||
|
function msg(
|
||||||
|
role: ChatMessage['role'],
|
||||||
|
overrides: Partial<ChatMessage> = {},
|
||||||
|
): ChatMessage {
|
||||||
|
return {
|
||||||
|
id: `msg-${Math.random()}`,
|
||||||
|
conversationId: 'conv-1',
|
||||||
|
role,
|
||||||
|
content: '',
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('replaySurfacesFromMessages', () => {
|
||||||
|
it('returns empty array when there are no messages', () => {
|
||||||
|
const result = replaySurfacesFromMessages('conv-1', []);
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty array when no assistant messages have toolCalls', () => {
|
||||||
|
const messages = [
|
||||||
|
msg('user'),
|
||||||
|
msg('assistant', { content: 'Hello!' }),
|
||||||
|
];
|
||||||
|
const result = replaySurfacesFromMessages('conv-1', messages);
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty array when assistant has non-render tool calls only', () => {
|
||||||
|
const messages = [
|
||||||
|
msg('user'),
|
||||||
|
msg('assistant', {
|
||||||
|
toolCalls: JSON.stringify([
|
||||||
|
{ name: 'search_posts', args: { query: 'test' } },
|
||||||
|
]),
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
const result = replaySurfacesFromMessages('conv-1', messages);
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('replays a single render_chart tool call', () => {
|
||||||
|
const messages = [
|
||||||
|
msg('user'),
|
||||||
|
msg('assistant', {
|
||||||
|
toolCalls: JSON.stringify([
|
||||||
|
{
|
||||||
|
name: 'render_chart',
|
||||||
|
args: {
|
||||||
|
chartType: 'bar',
|
||||||
|
title: 'Test Chart',
|
||||||
|
series: [{ label: 'A', value: 10 }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
const result = replaySurfacesFromMessages('conv-1', messages);
|
||||||
|
|
||||||
|
expect(result.length).toBeGreaterThanOrEqual(2); // createSurface + updateComponents + updateDataModel
|
||||||
|
|
||||||
|
// Verify createSurface has correct turnIndex
|
||||||
|
const createMsg = result.find((m) => m.type === 'createSurface');
|
||||||
|
expect(createMsg).toBeDefined();
|
||||||
|
expect(createMsg!.type).toBe('createSurface');
|
||||||
|
if (createMsg!.type === 'createSurface') {
|
||||||
|
expect(createMsg!.conversationId).toBe('conv-1');
|
||||||
|
expect(createMsg!.metadata?.turnIndex).toBe(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify chart component was generated
|
||||||
|
const updateMsg = result.find((m) => m.type === 'updateComponents');
|
||||||
|
expect(updateMsg).toBeDefined();
|
||||||
|
if (updateMsg!.type === 'updateComponents') {
|
||||||
|
expect(updateMsg!.components[0].type).toBe('chart');
|
||||||
|
expect(updateMsg!.components[0].properties.chartType).toBe('bar');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('assigns correct turnIndex based on message position', () => {
|
||||||
|
const messages = [
|
||||||
|
msg('user'),
|
||||||
|
msg('assistant', { content: 'turn 0' }),
|
||||||
|
msg('user'),
|
||||||
|
msg('assistant', {
|
||||||
|
toolCalls: JSON.stringify([
|
||||||
|
{
|
||||||
|
name: 'render_metric',
|
||||||
|
args: { label: 'Total', value: '42' },
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
const result = replaySurfacesFromMessages('conv-1', messages);
|
||||||
|
|
||||||
|
const createMsg = result.find((m) => m.type === 'createSurface');
|
||||||
|
expect(createMsg).toBeDefined();
|
||||||
|
if (createMsg!.type === 'createSurface') {
|
||||||
|
expect(createMsg!.metadata?.turnIndex).toBe(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('replays multiple render tools from the same assistant message', () => {
|
||||||
|
const messages = [
|
||||||
|
msg('user'),
|
||||||
|
msg('assistant', {
|
||||||
|
toolCalls: JSON.stringify([
|
||||||
|
{
|
||||||
|
name: 'render_chart',
|
||||||
|
args: { chartType: 'bar', series: [{ label: 'A', value: 1 }] },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'render_metric',
|
||||||
|
args: { label: 'Count', value: '5' },
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
const result = replaySurfacesFromMessages('conv-1', messages);
|
||||||
|
|
||||||
|
// Should have two createSurface messages (one per render tool)
|
||||||
|
const creates = result.filter((m) => m.type === 'createSurface');
|
||||||
|
expect(creates).toHaveLength(2);
|
||||||
|
|
||||||
|
// Both should have the same turnIndex
|
||||||
|
for (const c of creates) {
|
||||||
|
if (c.type === 'createSurface') {
|
||||||
|
expect(c.metadata?.turnIndex).toBe(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('replays render tools across multiple turns', () => {
|
||||||
|
const messages = [
|
||||||
|
msg('user'),
|
||||||
|
msg('assistant', {
|
||||||
|
toolCalls: JSON.stringify([
|
||||||
|
{
|
||||||
|
name: 'render_chart',
|
||||||
|
args: { chartType: 'bar', series: [{ label: 'A', value: 1 }] },
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
}),
|
||||||
|
msg('user'),
|
||||||
|
msg('assistant', {
|
||||||
|
toolCalls: JSON.stringify([
|
||||||
|
{
|
||||||
|
name: 'render_table',
|
||||||
|
args: { columns: ['Name'], rows: [['X']] },
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
const result = replaySurfacesFromMessages('conv-1', messages);
|
||||||
|
|
||||||
|
const creates = result.filter((m) => m.type === 'createSurface');
|
||||||
|
expect(creates).toHaveLength(2);
|
||||||
|
|
||||||
|
if (creates[0].type === 'createSurface' && creates[1].type === 'createSurface') {
|
||||||
|
expect(creates[0].metadata?.turnIndex).toBe(0);
|
||||||
|
expect(creates[1].metadata?.turnIndex).toBe(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('mixes data tools and render tools, only replaying render tools', () => {
|
||||||
|
const messages = [
|
||||||
|
msg('user'),
|
||||||
|
msg('assistant', {
|
||||||
|
toolCalls: JSON.stringify([
|
||||||
|
{ name: 'search_posts', args: { query: 'hello' } },
|
||||||
|
{
|
||||||
|
name: 'render_chart',
|
||||||
|
args: { chartType: 'pie', series: [{ label: 'Yes', value: 3 }] },
|
||||||
|
},
|
||||||
|
{ name: 'list_tags', args: {} },
|
||||||
|
]),
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
const result = replaySurfacesFromMessages('conv-1', messages);
|
||||||
|
|
||||||
|
const creates = result.filter((m) => m.type === 'createSurface');
|
||||||
|
expect(creates).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles malformed toolCalls JSON gracefully', () => {
|
||||||
|
const messages = [
|
||||||
|
msg('user'),
|
||||||
|
msg('assistant', { toolCalls: 'not-valid-json{{{' }),
|
||||||
|
];
|
||||||
|
const result = replaySurfacesFromMessages('conv-1', messages);
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles toolCalls with missing args gracefully', () => {
|
||||||
|
const messages = [
|
||||||
|
msg('user'),
|
||||||
|
msg('assistant', {
|
||||||
|
toolCalls: JSON.stringify([
|
||||||
|
{ name: 'render_chart' },
|
||||||
|
]),
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
// Should not throw
|
||||||
|
const result = replaySurfacesFromMessages('conv-1', messages);
|
||||||
|
// We expect it to try to generate (generator handles validation)
|
||||||
|
expect(result.length).toBeGreaterThanOrEqual(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
16
tests/renderer/components/AssistantSidebar.styles.test.ts
Normal file
16
tests/renderer/components/AssistantSidebar.styles.test.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import * as fs from 'node:fs';
|
||||||
|
import * as path from 'node:path';
|
||||||
|
|
||||||
|
describe('AssistantSidebar styles', () => {
|
||||||
|
const cssPath = path.resolve(
|
||||||
|
__dirname,
|
||||||
|
'../../../src/renderer/components/AssistantSidebar/AssistantSidebar.css'
|
||||||
|
);
|
||||||
|
|
||||||
|
it('keeps the sidebar container scrollable for long assistant content', () => {
|
||||||
|
const css = fs.readFileSync(cssPath, 'utf8');
|
||||||
|
|
||||||
|
expect(css).toMatch(/\.assistant-sidebar\s*\{[^}]*min-height:\s*0;[^}]*overflow-y:\s*auto;[^}]*\}/s);
|
||||||
|
});
|
||||||
|
});
|
||||||
52
tests/renderer/components/AssistantSidebar.wiring.test.tsx
Normal file
52
tests/renderer/components/AssistantSidebar.wiring.test.tsx
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { describe, expect, it, beforeEach, vi } from 'vitest';
|
||||||
|
import { render } from '@testing-library/react';
|
||||||
|
import { AssistantSidebar } from '../../../src/renderer/components/AssistantSidebar/AssistantSidebar';
|
||||||
|
|
||||||
|
describe('AssistantSidebar wiring', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
const onStreamDelta = vi.fn(() => vi.fn());
|
||||||
|
const onToolCall = vi.fn(() => vi.fn());
|
||||||
|
const onToolResult = vi.fn(() => vi.fn());
|
||||||
|
const onTitleUpdated = vi.fn(() => vi.fn());
|
||||||
|
|
||||||
|
window.electronAPI.chat = {
|
||||||
|
checkReady: vi.fn(),
|
||||||
|
validateApiKey: vi.fn(),
|
||||||
|
setApiKey: vi.fn(),
|
||||||
|
getApiKey: vi.fn(),
|
||||||
|
getAvailableModels: vi.fn(),
|
||||||
|
setDefaultModel: vi.fn(),
|
||||||
|
getSystemPrompt: vi.fn(),
|
||||||
|
setSystemPrompt: vi.fn(),
|
||||||
|
getConversations: vi.fn(),
|
||||||
|
createConversation: vi.fn(),
|
||||||
|
getConversation: vi.fn(),
|
||||||
|
updateConversation: vi.fn(),
|
||||||
|
deleteConversation: vi.fn(),
|
||||||
|
sendMessage: vi.fn(),
|
||||||
|
addSystemEvent: vi.fn(),
|
||||||
|
abortMessage: vi.fn(),
|
||||||
|
getHistory: vi.fn(),
|
||||||
|
clearMessages: vi.fn(),
|
||||||
|
setConversationModel: vi.fn(),
|
||||||
|
analyzeTaxonomy: vi.fn(),
|
||||||
|
analyzeMediaImage: vi.fn(),
|
||||||
|
onStreamDelta,
|
||||||
|
onToolCall,
|
||||||
|
onToolResult,
|
||||||
|
onTitleUpdated,
|
||||||
|
onA2UIMessage: vi.fn(() => vi.fn()),
|
||||||
|
dispatchA2UIAction: vi.fn(),
|
||||||
|
} as never;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('subscribes to chat streaming events on mount', () => {
|
||||||
|
render(<AssistantSidebar />);
|
||||||
|
|
||||||
|
expect(window.electronAPI.chat.onStreamDelta).toHaveBeenCalledTimes(1);
|
||||||
|
expect(window.electronAPI.chat.onToolCall).toHaveBeenCalledTimes(1);
|
||||||
|
expect(window.electronAPI.chat.onToolResult).toHaveBeenCalledTimes(1);
|
||||||
|
expect(window.electronAPI.chat.onTitleUpdated).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
41
tests/renderer/components/ChatSurface.sharedStyles.test.ts
Normal file
41
tests/renderer/components/ChatSurface.sharedStyles.test.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import * as fs from 'node:fs';
|
||||||
|
import * as path from 'node:path';
|
||||||
|
|
||||||
|
describe('Chat surface shared styles', () => {
|
||||||
|
const sharedCssPath = path.resolve(
|
||||||
|
__dirname,
|
||||||
|
'../../../src/renderer/styles/chatSurface.css'
|
||||||
|
);
|
||||||
|
const chatPanelPath = path.resolve(
|
||||||
|
__dirname,
|
||||||
|
'../../../src/renderer/components/ChatPanel/ChatPanel.tsx'
|
||||||
|
);
|
||||||
|
const assistantSidebarPath = path.resolve(
|
||||||
|
__dirname,
|
||||||
|
'../../../src/renderer/components/AssistantSidebar/AssistantSidebar.tsx'
|
||||||
|
);
|
||||||
|
|
||||||
|
it('defines reusable surface primitives', () => {
|
||||||
|
const css = fs.readFileSync(sharedCssPath, 'utf8');
|
||||||
|
|
||||||
|
expect(css).toContain('.chat-surface');
|
||||||
|
expect(css).toContain('.chat-surface-scroll');
|
||||||
|
expect(css).toContain('.chat-surface-input');
|
||||||
|
expect(css).toContain('.chat-surface-error');
|
||||||
|
expect(css).toContain('.chat-surface-section');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies shared surface class names in both chat renderers', () => {
|
||||||
|
const chatPanel = fs.readFileSync(chatPanelPath, 'utf8');
|
||||||
|
const assistantSidebar = fs.readFileSync(assistantSidebarPath, 'utf8');
|
||||||
|
|
||||||
|
expect(chatPanel).toContain('chat-surface');
|
||||||
|
expect(chatPanel).toContain('chat-surface-scroll');
|
||||||
|
|
||||||
|
expect(assistantSidebar).toContain('chat-surface');
|
||||||
|
expect(assistantSidebar).toContain('chat-surface-input');
|
||||||
|
expect(assistantSidebar).toContain('chat-surface-error');
|
||||||
|
expect(assistantSidebar).toContain('chat-surface-section');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -55,6 +55,7 @@ describe('Editor dashboard timeline', () => {
|
|||||||
]);
|
]);
|
||||||
(window as any).electronAPI.posts.getTagsWithCounts = vi.fn().mockResolvedValue([]);
|
(window as any).electronAPI.posts.getTagsWithCounts = vi.fn().mockResolvedValue([]);
|
||||||
(window as any).electronAPI.posts.getCategoriesWithCounts = vi.fn().mockResolvedValue([]);
|
(window as any).electronAPI.posts.getCategoriesWithCounts = vi.fn().mockResolvedValue([]);
|
||||||
|
(window as any).electronAPI.chat = {};
|
||||||
(window as any).electronAPI.tags = {
|
(window as any).electronAPI.tags = {
|
||||||
getAll: vi.fn().mockResolvedValue([]),
|
getAll: vi.fn().mockResolvedValue([]),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ describe('WindowTitleBar', () => {
|
|||||||
useAppStore.setState({
|
useAppStore.setState({
|
||||||
sidebarVisible: true,
|
sidebarVisible: true,
|
||||||
panelVisible: false,
|
panelVisible: false,
|
||||||
|
assistantSidebarVisible: false,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -32,6 +33,7 @@ describe('WindowTitleBar', () => {
|
|||||||
expect(screen.queryByRole('button', { name: 'Edit' })).toBeNull();
|
expect(screen.queryByRole('button', { name: 'Edit' })).toBeNull();
|
||||||
expect(screen.getByLabelText('Toggle Sidebar')).toBeInTheDocument();
|
expect(screen.getByLabelText('Toggle Sidebar')).toBeInTheDocument();
|
||||||
expect(screen.getByLabelText('Toggle Panel')).toBeInTheDocument();
|
expect(screen.getByLabelText('Toggle Panel')).toBeInTheDocument();
|
||||||
|
expect(screen.getByLabelText('Toggle Assistant Sidebar')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not request macOS title bar metrics when simulated title bar is disabled', async () => {
|
it('does not request macOS title bar metrics when simulated title bar is disabled', async () => {
|
||||||
@@ -139,9 +141,23 @@ describe('WindowTitleBar', () => {
|
|||||||
|
|
||||||
const actionButtons = Array.from(document.querySelectorAll('.window-titlebar-actions .window-titlebar-action-button'));
|
const actionButtons = Array.from(document.querySelectorAll('.window-titlebar-actions .window-titlebar-action-button'));
|
||||||
|
|
||||||
expect(actionButtons).toHaveLength(2);
|
expect(actionButtons).toHaveLength(3);
|
||||||
expect(actionButtons[0]).toHaveAttribute('aria-label', 'Toggle Sidebar');
|
expect(actionButtons[0]).toHaveAttribute('aria-label', 'Toggle Sidebar');
|
||||||
expect(actionButtons[1]).toHaveAttribute('aria-label', 'Toggle Panel');
|
expect(actionButtons[1]).toHaveAttribute('aria-label', 'Toggle Panel');
|
||||||
|
expect(actionButtons[2]).toHaveAttribute('aria-label', 'Toggle Assistant Sidebar');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders a right-side assistant sidebar toggle button and toggles assistant sidebar visibility', () => {
|
||||||
|
render(<WindowTitleBar />);
|
||||||
|
|
||||||
|
const toggleButton = screen.getByLabelText('Toggle Assistant Sidebar');
|
||||||
|
expect(toggleButton).toBeInTheDocument();
|
||||||
|
expect(toggleButton).toHaveAttribute('title', 'Show Assistant Sidebar (Ctrl+\\)');
|
||||||
|
|
||||||
|
fireEvent.click(toggleButton);
|
||||||
|
|
||||||
|
expect(useAppStore.getState().assistantSidebarVisible).toBe(true);
|
||||||
|
expect(toggleButton).toHaveAttribute('title', 'Hide Assistant Sidebar (Ctrl+\\)');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('updates overlay inset CSS variables when window controls geometry changes', () => {
|
it('updates overlay inset CSS variables when window controls geometry changes', () => {
|
||||||
@@ -248,6 +264,7 @@ describe('WindowTitleBar', () => {
|
|||||||
expect(screen.getByRole('button', { name: 'Media Ctrl+2' })).toBeInTheDocument();
|
expect(screen.getByRole('button', { name: 'Media Ctrl+2' })).toBeInTheDocument();
|
||||||
expect(screen.getByRole('button', { name: 'Toggle Sidebar Ctrl+B' })).toBeInTheDocument();
|
expect(screen.getByRole('button', { name: 'Toggle Sidebar Ctrl+B' })).toBeInTheDocument();
|
||||||
expect(screen.getByRole('button', { name: 'Toggle Panel Ctrl+J' })).toBeInTheDocument();
|
expect(screen.getByRole('button', { name: 'Toggle Panel Ctrl+J' })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('button', { name: 'Toggle Assistant Sidebar Ctrl+\\' })).toBeInTheDocument();
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'Blog' }));
|
fireEvent.click(screen.getByRole('button', { name: 'Blog' }));
|
||||||
expect(screen.getByRole('button', { name: 'Publish Selected Ctrl+Shift+P' })).toBeInTheDocument();
|
expect(screen.getByRole('button', { name: 'Publish Selected Ctrl+Shift+P' })).toBeInTheDocument();
|
||||||
|
|||||||
240
tests/renderer/navigation/assistantActionDispatcher.test.ts
Normal file
240
tests/renderer/navigation/assistantActionDispatcher.test.ts
Normal file
@@ -0,0 +1,240 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import { dispatchAssistantAction } from '../../../src/renderer/navigation/assistantActionDispatcher';
|
||||||
|
|
||||||
|
describe('assistantActionDispatcher', () => {
|
||||||
|
it('opens a post from action payload', () => {
|
||||||
|
const setSelectedPost = vi.fn();
|
||||||
|
const openTab = vi.fn();
|
||||||
|
const setActiveView = vi.fn();
|
||||||
|
|
||||||
|
const result = dispatchAssistantAction(
|
||||||
|
{
|
||||||
|
action: 'openPost',
|
||||||
|
payload: { postId: 'post-123' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
setSelectedPost,
|
||||||
|
setSelectedMedia: vi.fn(),
|
||||||
|
openTab,
|
||||||
|
setActiveView,
|
||||||
|
toggleSidebar: vi.fn(),
|
||||||
|
togglePanel: vi.fn(),
|
||||||
|
toggleAssistantSidebar: vi.fn(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.handled).toBe(true);
|
||||||
|
expect(setActiveView).toHaveBeenCalledWith('posts');
|
||||||
|
expect(setSelectedPost).toHaveBeenCalledWith('post-123');
|
||||||
|
expect(openTab).toHaveBeenCalledWith({ type: 'post', id: 'post-123', isTransient: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens media from action payload', () => {
|
||||||
|
const setSelectedMedia = vi.fn();
|
||||||
|
const openTab = vi.fn();
|
||||||
|
const setActiveView = vi.fn();
|
||||||
|
|
||||||
|
const result = dispatchAssistantAction(
|
||||||
|
{
|
||||||
|
action: 'openMedia',
|
||||||
|
payload: { mediaId: 'media-321' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
setSelectedPost: vi.fn(),
|
||||||
|
setSelectedMedia,
|
||||||
|
openTab,
|
||||||
|
setActiveView,
|
||||||
|
toggleSidebar: vi.fn(),
|
||||||
|
togglePanel: vi.fn(),
|
||||||
|
toggleAssistantSidebar: vi.fn(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.handled).toBe(true);
|
||||||
|
expect(setActiveView).toHaveBeenCalledWith('media');
|
||||||
|
expect(setSelectedMedia).toHaveBeenCalledWith('media-321');
|
||||||
|
expect(openTab).toHaveBeenCalledWith({ type: 'media', id: 'media-321', isTransient: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('switches sidebar view', () => {
|
||||||
|
const setActiveView = vi.fn();
|
||||||
|
|
||||||
|
const result = dispatchAssistantAction(
|
||||||
|
{
|
||||||
|
action: 'switchView',
|
||||||
|
payload: { view: 'tags' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
setSelectedPost: vi.fn(),
|
||||||
|
setSelectedMedia: vi.fn(),
|
||||||
|
openTab: vi.fn(),
|
||||||
|
setActiveView,
|
||||||
|
toggleSidebar: vi.fn(),
|
||||||
|
togglePanel: vi.fn(),
|
||||||
|
toggleAssistantSidebar: vi.fn(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.handled).toBe(true);
|
||||||
|
expect(setActiveView).toHaveBeenCalledWith('tags');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('supports setActiveView action alias for protocol compatibility', () => {
|
||||||
|
const setActiveView = vi.fn();
|
||||||
|
|
||||||
|
const result = dispatchAssistantAction(
|
||||||
|
{
|
||||||
|
action: 'setActiveView',
|
||||||
|
payload: { view: 'chat' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
setSelectedPost: vi.fn(),
|
||||||
|
setSelectedMedia: vi.fn(),
|
||||||
|
openTab: vi.fn(),
|
||||||
|
setActiveView,
|
||||||
|
toggleSidebar: vi.fn(),
|
||||||
|
togglePanel: vi.fn(),
|
||||||
|
toggleAssistantSidebar: vi.fn(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.handled).toBe(true);
|
||||||
|
expect(setActiveView).toHaveBeenCalledWith('chat');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('supports openPanel action alias for protocol compatibility', () => {
|
||||||
|
const togglePanel = vi.fn();
|
||||||
|
|
||||||
|
const result = dispatchAssistantAction(
|
||||||
|
{
|
||||||
|
action: 'openPanel',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
setSelectedPost: vi.fn(),
|
||||||
|
setSelectedMedia: vi.fn(),
|
||||||
|
openTab: vi.fn(),
|
||||||
|
setActiveView: vi.fn(),
|
||||||
|
toggleSidebar: vi.fn(),
|
||||||
|
togglePanel,
|
||||||
|
toggleAssistantSidebar: vi.fn(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.handled).toBe(true);
|
||||||
|
expect(togglePanel).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects switchView payload when view is invalid', () => {
|
||||||
|
const setActiveView = vi.fn();
|
||||||
|
|
||||||
|
const result = dispatchAssistantAction(
|
||||||
|
{
|
||||||
|
action: 'switchView',
|
||||||
|
payload: { view: 'not-a-view' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
setSelectedPost: vi.fn(),
|
||||||
|
setSelectedMedia: vi.fn(),
|
||||||
|
openTab: vi.fn(),
|
||||||
|
setActiveView,
|
||||||
|
toggleSidebar: vi.fn(),
|
||||||
|
togglePanel: vi.fn(),
|
||||||
|
toggleAssistantSidebar: vi.fn(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.handled).toBe(false);
|
||||||
|
expect(result.error).toContain('Invalid payload');
|
||||||
|
expect(setActiveView).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens chat tab for openChat action', () => {
|
||||||
|
const setActiveView = vi.fn();
|
||||||
|
const openTab = vi.fn();
|
||||||
|
|
||||||
|
const result = dispatchAssistantAction(
|
||||||
|
{
|
||||||
|
action: 'openChat',
|
||||||
|
payload: { conversationId: 'conversation-42' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
setSelectedPost: vi.fn(),
|
||||||
|
setSelectedMedia: vi.fn(),
|
||||||
|
openTab,
|
||||||
|
setActiveView,
|
||||||
|
toggleSidebar: vi.fn(),
|
||||||
|
togglePanel: vi.fn(),
|
||||||
|
toggleAssistantSidebar: vi.fn(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.handled).toBe(true);
|
||||||
|
expect(setActiveView).toHaveBeenCalledWith('chat');
|
||||||
|
expect(openTab).toHaveBeenCalledWith({ type: 'chat', id: 'conversation-42', isTransient: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens settings tab for openSettings action', () => {
|
||||||
|
const setActiveView = vi.fn();
|
||||||
|
const openTab = vi.fn();
|
||||||
|
|
||||||
|
const result = dispatchAssistantAction(
|
||||||
|
{
|
||||||
|
action: 'openSettings',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
setSelectedPost: vi.fn(),
|
||||||
|
setSelectedMedia: vi.fn(),
|
||||||
|
openTab,
|
||||||
|
setActiveView,
|
||||||
|
toggleSidebar: vi.fn(),
|
||||||
|
togglePanel: vi.fn(),
|
||||||
|
toggleAssistantSidebar: vi.fn(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.handled).toBe(true);
|
||||||
|
expect(setActiveView).toHaveBeenCalledWith('settings');
|
||||||
|
expect(openTab).toHaveBeenCalledWith({ type: 'settings', id: 'settings', isTransient: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects invalid payload for openChat action', () => {
|
||||||
|
const result = dispatchAssistantAction(
|
||||||
|
{
|
||||||
|
action: 'openChat',
|
||||||
|
payload: {},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
setSelectedPost: vi.fn(),
|
||||||
|
setSelectedMedia: vi.fn(),
|
||||||
|
openTab: vi.fn(),
|
||||||
|
setActiveView: vi.fn(),
|
||||||
|
toggleSidebar: vi.fn(),
|
||||||
|
togglePanel: vi.fn(),
|
||||||
|
toggleAssistantSidebar: vi.fn(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.handled).toBe(false);
|
||||||
|
expect(result.error).toContain('Invalid payload');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns an error for unknown actions', () => {
|
||||||
|
const result = dispatchAssistantAction(
|
||||||
|
{
|
||||||
|
action: 'doesNotExist',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
setSelectedPost: vi.fn(),
|
||||||
|
setSelectedMedia: vi.fn(),
|
||||||
|
openTab: vi.fn(),
|
||||||
|
setActiveView: vi.fn(),
|
||||||
|
toggleSidebar: vi.fn(),
|
||||||
|
togglePanel: vi.fn(),
|
||||||
|
toggleAssistantSidebar: vi.fn(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.handled).toBe(false);
|
||||||
|
expect(result.error).toContain('Unsupported action');
|
||||||
|
});
|
||||||
|
});
|
||||||
35
tests/renderer/navigation/assistantConversation.test.ts
Normal file
35
tests/renderer/navigation/assistantConversation.test.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { planAssistantRequest } from '../../../src/renderer/navigation/assistantConversation';
|
||||||
|
|
||||||
|
describe('assistantConversation', () => {
|
||||||
|
it('creates enriched first message when no conversation exists yet', () => {
|
||||||
|
const result = planAssistantRequest({
|
||||||
|
conversationId: null,
|
||||||
|
userPrompt: 'Find weak tags',
|
||||||
|
context: {
|
||||||
|
tabType: 'post',
|
||||||
|
id: 'post-1',
|
||||||
|
title: 'Launch Notes',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.shouldCreateConversation).toBe(true);
|
||||||
|
expect(result.outboundMessage).toContain('User request: Find weak tags');
|
||||||
|
expect(result.outboundMessage).toContain('Current editor context type: post');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends plain follow-up message when conversation already exists', () => {
|
||||||
|
const result = planAssistantRequest({
|
||||||
|
conversationId: 'conv-1',
|
||||||
|
userPrompt: 'What next?',
|
||||||
|
context: {
|
||||||
|
tabType: 'post',
|
||||||
|
id: 'post-1',
|
||||||
|
title: 'Launch Notes',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.shouldCreateConversation).toBe(false);
|
||||||
|
expect(result.outboundMessage).toBe('What next?');
|
||||||
|
});
|
||||||
|
});
|
||||||
47
tests/renderer/navigation/assistantPromptContext.test.ts
Normal file
47
tests/renderer/navigation/assistantPromptContext.test.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { buildAssistantStartPrompt } from '../../../src/renderer/navigation/assistantPromptContext';
|
||||||
|
|
||||||
|
describe('assistantPromptContext', () => {
|
||||||
|
it('enriches prompt with active post context', () => {
|
||||||
|
const result = buildAssistantStartPrompt({
|
||||||
|
userPrompt: 'Find weak tags',
|
||||||
|
context: {
|
||||||
|
tabType: 'post',
|
||||||
|
id: 'post-1',
|
||||||
|
title: 'Launch Notes',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toContain('User request: Find weak tags');
|
||||||
|
expect(result).toContain('Current editor context type: post');
|
||||||
|
expect(result).toContain('Current editor context id: post-1');
|
||||||
|
expect(result).toContain('Current editor context title: Launch Notes');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('enriches prompt with active media context', () => {
|
||||||
|
const result = buildAssistantStartPrompt({
|
||||||
|
userPrompt: 'Suggest alt text variants',
|
||||||
|
context: {
|
||||||
|
tabType: 'media',
|
||||||
|
id: 'media-4',
|
||||||
|
title: 'cover.jpg',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toContain('User request: Suggest alt text variants');
|
||||||
|
expect(result).toContain('Current editor context type: media');
|
||||||
|
expect(result).toContain('Current editor context id: media-4');
|
||||||
|
expect(result).toContain('Current editor context title: cover.jpg');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to none when no active editor context is available', () => {
|
||||||
|
const result = buildAssistantStartPrompt({
|
||||||
|
userPrompt: 'Summarize current project health',
|
||||||
|
context: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toContain('User request: Summarize current project health');
|
||||||
|
expect(result).toContain('Current editor context type: none');
|
||||||
|
expect(result).not.toContain('Current editor context id:');
|
||||||
|
});
|
||||||
|
});
|
||||||
47
tests/renderer/navigation/assistantSidebarGuards.test.ts
Normal file
47
tests/renderer/navigation/assistantSidebarGuards.test.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { render } from '@testing-library/react';
|
||||||
|
import { AssistantSidebar } from '../../../src/renderer/components/AssistantSidebar/AssistantSidebar';
|
||||||
|
import { useAppStore } from '../../../src/renderer/store';
|
||||||
|
|
||||||
|
describe('assistant sidebar guard rails', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
useAppStore.setState({ tabs: [], activeTabId: null, activeView: 'posts' });
|
||||||
|
|
||||||
|
window.electronAPI.chat = {
|
||||||
|
checkReady: vi.fn().mockResolvedValue({ ready: true }),
|
||||||
|
validateApiKey: vi.fn(),
|
||||||
|
setApiKey: vi.fn(),
|
||||||
|
getApiKey: vi.fn(),
|
||||||
|
getAvailableModels: vi.fn(),
|
||||||
|
setDefaultModel: vi.fn(),
|
||||||
|
getSystemPrompt: vi.fn(),
|
||||||
|
setSystemPrompt: vi.fn(),
|
||||||
|
getConversations: vi.fn(),
|
||||||
|
createConversation: vi.fn(),
|
||||||
|
getConversation: vi.fn(),
|
||||||
|
updateConversation: vi.fn(),
|
||||||
|
deleteConversation: vi.fn(),
|
||||||
|
sendMessage: vi.fn(),
|
||||||
|
addSystemEvent: vi.fn(),
|
||||||
|
abortMessage: vi.fn(),
|
||||||
|
getHistory: vi.fn().mockResolvedValue([]),
|
||||||
|
clearMessages: vi.fn(),
|
||||||
|
setConversationModel: vi.fn(),
|
||||||
|
analyzeTaxonomy: vi.fn(),
|
||||||
|
analyzeMediaImage: vi.fn(),
|
||||||
|
onStreamDelta: vi.fn(() => vi.fn()),
|
||||||
|
onToolCall: vi.fn(() => vi.fn()),
|
||||||
|
onToolResult: vi.fn(() => vi.fn()),
|
||||||
|
onTitleUpdated: vi.fn(() => vi.fn()),
|
||||||
|
onA2UIMessage: vi.fn(() => vi.fn()),
|
||||||
|
dispatchA2UIAction: vi.fn(),
|
||||||
|
} as never;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps assistant sidebar self-contained and avoids opening chat tabs on mount', () => {
|
||||||
|
render(React.createElement(AssistantSidebar));
|
||||||
|
|
||||||
|
expect(useAppStore.getState().tabs.some((tab) => tab.type === 'chat')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
98
tests/renderer/navigation/chatSession.test.ts
Normal file
98
tests/renderer/navigation/chatSession.test.ts
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import {
|
||||||
|
ensureConversationId,
|
||||||
|
sendConversationMessage,
|
||||||
|
type ChatService,
|
||||||
|
} from '../../../src/renderer/navigation/chatSession';
|
||||||
|
|
||||||
|
describe('chatSession', () => {
|
||||||
|
it('reuses existing conversation id when available', async () => {
|
||||||
|
const chatService: Pick<ChatService, 'createConversation'> = {
|
||||||
|
createConversation: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const conversationId = await ensureConversationId({
|
||||||
|
currentConversationId: 'conv-existing',
|
||||||
|
createTitle: 'Ignored',
|
||||||
|
chatService,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(conversationId).toBe('conv-existing');
|
||||||
|
expect(chatService.createConversation).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates conversation when no id exists', async () => {
|
||||||
|
const chatService: Pick<ChatService, 'createConversation'> = {
|
||||||
|
createConversation: vi.fn().mockResolvedValue({ id: 'conv-created' }),
|
||||||
|
};
|
||||||
|
|
||||||
|
const conversationId = await ensureConversationId({
|
||||||
|
currentConversationId: null,
|
||||||
|
createTitle: 'Assistant Session',
|
||||||
|
chatService,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(conversationId).toBe('conv-created');
|
||||||
|
expect(chatService.createConversation).toHaveBeenCalledWith('Assistant Session');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws when conversation creation returns no id', async () => {
|
||||||
|
const chatService: Pick<ChatService, 'createConversation'> = {
|
||||||
|
createConversation: vi.fn().mockResolvedValue(null),
|
||||||
|
};
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
ensureConversationId({
|
||||||
|
currentConversationId: null,
|
||||||
|
createTitle: 'Assistant Session',
|
||||||
|
chatService,
|
||||||
|
}),
|
||||||
|
).rejects.toThrow('No conversation id returned');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('normalizes successful send response', async () => {
|
||||||
|
const chatService: Pick<ChatService, 'sendMessage'> = {
|
||||||
|
sendMessage: vi.fn().mockResolvedValue({ success: true, message: 'Response text' }),
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await sendConversationMessage({
|
||||||
|
conversationId: 'conv-1',
|
||||||
|
message: 'Hello',
|
||||||
|
chatService,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.message).toBe('Response text');
|
||||||
|
expect(chatService.sendMessage).toHaveBeenCalledWith('conv-1', 'Hello');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('normalizes error send response', async () => {
|
||||||
|
const chatService: Pick<ChatService, 'sendMessage'> = {
|
||||||
|
sendMessage: vi.fn().mockResolvedValue({ success: false, error: 'Failed' }),
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await sendConversationMessage({
|
||||||
|
conversationId: 'conv-1',
|
||||||
|
message: 'Hello',
|
||||||
|
chatService,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.error).toBe('Failed');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('forwards send metadata such as UI surface', async () => {
|
||||||
|
const chatService: Pick<ChatService, 'sendMessage'> = {
|
||||||
|
sendMessage: vi.fn().mockResolvedValue({ success: true, message: 'ok' }),
|
||||||
|
};
|
||||||
|
|
||||||
|
await sendConversationMessage({
|
||||||
|
conversationId: 'conv-1',
|
||||||
|
message: 'Hello',
|
||||||
|
metadata: { surface: 'sidebar' },
|
||||||
|
chatService,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(chatService.sendMessage).toHaveBeenCalledWith('conv-1', 'Hello', { surface: 'sidebar' });
|
||||||
|
});
|
||||||
|
});
|
||||||
25
tests/renderer/navigation/chatSurfaceMode.test.ts
Normal file
25
tests/renderer/navigation/chatSurfaceMode.test.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import {
|
||||||
|
getChatSurfaceMode,
|
||||||
|
type ChatSurfaceModeId,
|
||||||
|
} from '../../../src/renderer/navigation/chatSurfaceMode';
|
||||||
|
|
||||||
|
describe('chatSurfaceMode', () => {
|
||||||
|
it('returns mode flags for tab and sidebar surfaces', () => {
|
||||||
|
const tabMode = getChatSurfaceMode('tab');
|
||||||
|
const sidebarMode = getChatSurfaceMode('sidebar');
|
||||||
|
|
||||||
|
expect(tabMode.showModelSelector).toBe(true);
|
||||||
|
expect(tabMode.showWelcomeTips).toBe(true);
|
||||||
|
expect(tabMode.showToolMarkers).toBe(true);
|
||||||
|
|
||||||
|
expect(sidebarMode.showModelSelector).toBe(false);
|
||||||
|
expect(sidebarMode.showWelcomeTips).toBe(false);
|
||||||
|
expect(sidebarMode.showToolMarkers).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('covers all declared mode ids', () => {
|
||||||
|
const modeIds: ChatSurfaceModeId[] = ['tab', 'sidebar'];
|
||||||
|
expect(() => modeIds.forEach((modeId) => getChatSurfaceMode(modeId))).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
62
tests/renderer/navigation/chatSurfaceModeUsageGuards.test.ts
Normal file
62
tests/renderer/navigation/chatSurfaceModeUsageGuards.test.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { ChatPanel } from '../../../src/renderer/components/ChatPanel/ChatPanel';
|
||||||
|
import { AssistantSidebar } from '../../../src/renderer/components/AssistantSidebar/AssistantSidebar';
|
||||||
|
|
||||||
|
describe('chat surface mode usage guards', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
if (!Element.prototype.scrollIntoView) {
|
||||||
|
Element.prototype.scrollIntoView = vi.fn();
|
||||||
|
}
|
||||||
|
|
||||||
|
window.electronAPI.chat = {
|
||||||
|
checkReady: vi.fn().mockResolvedValue({ ready: true }),
|
||||||
|
validateApiKey: vi.fn(),
|
||||||
|
setApiKey: vi.fn(),
|
||||||
|
getApiKey: vi.fn(),
|
||||||
|
getAvailableModels: vi.fn().mockResolvedValue({
|
||||||
|
success: true,
|
||||||
|
models: [{ id: 'gpt-5', name: 'GPT-5' }],
|
||||||
|
}),
|
||||||
|
setDefaultModel: vi.fn(),
|
||||||
|
getSystemPrompt: vi.fn(),
|
||||||
|
setSystemPrompt: vi.fn(),
|
||||||
|
getConversations: vi.fn(),
|
||||||
|
createConversation: vi.fn(),
|
||||||
|
getConversation: vi.fn().mockResolvedValue({
|
||||||
|
id: 'conv-tab',
|
||||||
|
title: 'Chat',
|
||||||
|
model: 'gpt-5',
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
}),
|
||||||
|
updateConversation: vi.fn(),
|
||||||
|
deleteConversation: vi.fn(),
|
||||||
|
sendMessage: vi.fn(),
|
||||||
|
addSystemEvent: vi.fn(),
|
||||||
|
abortMessage: vi.fn(),
|
||||||
|
getHistory: vi.fn().mockResolvedValue([]),
|
||||||
|
clearMessages: vi.fn(),
|
||||||
|
setConversationModel: vi.fn(),
|
||||||
|
analyzeTaxonomy: vi.fn(),
|
||||||
|
analyzeMediaImage: vi.fn(),
|
||||||
|
onStreamDelta: vi.fn(() => vi.fn()),
|
||||||
|
onToolCall: vi.fn(() => vi.fn()),
|
||||||
|
onToolResult: vi.fn(() => vi.fn()),
|
||||||
|
onTitleUpdated: vi.fn(() => vi.fn()),
|
||||||
|
onA2UIMessage: vi.fn(() => vi.fn()),
|
||||||
|
dispatchA2UIAction: vi.fn(),
|
||||||
|
} as never;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows model selector in tab chat but not in assistant sidebar', async () => {
|
||||||
|
const { container: tabContainer } = render(React.createElement(ChatPanel, { conversationId: 'conv-tab' }));
|
||||||
|
|
||||||
|
expect(tabContainer.querySelector('.model-selector-button')).not.toBeNull();
|
||||||
|
|
||||||
|
render(React.createElement(AssistantSidebar));
|
||||||
|
|
||||||
|
expect(screen.queryByText('gpt-5')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
80
tests/renderer/navigation/chatSurfaceUsageGuards.test.ts
Normal file
80
tests/renderer/navigation/chatSurfaceUsageGuards.test.ts
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { render } from '@testing-library/react';
|
||||||
|
import { ChatPanel } from '../../../src/renderer/components/ChatPanel/ChatPanel';
|
||||||
|
import { AssistantSidebar } from '../../../src/renderer/components/AssistantSidebar/AssistantSidebar';
|
||||||
|
import { useAppStore } from '../../../src/renderer/store';
|
||||||
|
|
||||||
|
describe('chat surface shared usage guards', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
if (!Element.prototype.scrollIntoView) {
|
||||||
|
Element.prototype.scrollIntoView = vi.fn();
|
||||||
|
}
|
||||||
|
|
||||||
|
useAppStore.setState({ tabs: [], activeTabId: null, activeView: 'posts' });
|
||||||
|
|
||||||
|
window.electronAPI.chat = {
|
||||||
|
checkReady: vi.fn().mockResolvedValue({ ready: true }),
|
||||||
|
validateApiKey: vi.fn(),
|
||||||
|
setApiKey: vi.fn(),
|
||||||
|
getApiKey: vi.fn(),
|
||||||
|
getAvailableModels: vi.fn().mockResolvedValue({
|
||||||
|
success: true,
|
||||||
|
models: [{ id: 'gpt-5', name: 'GPT-5' }],
|
||||||
|
}),
|
||||||
|
setDefaultModel: vi.fn(),
|
||||||
|
getSystemPrompt: vi.fn(),
|
||||||
|
setSystemPrompt: vi.fn(),
|
||||||
|
getConversations: vi.fn(),
|
||||||
|
createConversation: vi.fn(),
|
||||||
|
getConversation: vi.fn().mockResolvedValue({
|
||||||
|
id: 'conv-tab',
|
||||||
|
title: 'Chat',
|
||||||
|
model: 'gpt-5',
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
}),
|
||||||
|
updateConversation: vi.fn(),
|
||||||
|
deleteConversation: vi.fn(),
|
||||||
|
sendMessage: vi.fn(),
|
||||||
|
addSystemEvent: vi.fn(),
|
||||||
|
abortMessage: vi.fn(),
|
||||||
|
getHistory: vi.fn().mockResolvedValue([]),
|
||||||
|
clearMessages: vi.fn(),
|
||||||
|
setConversationModel: vi.fn(),
|
||||||
|
analyzeTaxonomy: vi.fn(),
|
||||||
|
analyzeMediaImage: vi.fn(),
|
||||||
|
onStreamDelta: vi.fn(() => vi.fn()),
|
||||||
|
onToolCall: vi.fn(() => vi.fn()),
|
||||||
|
onToolResult: vi.fn(() => vi.fn()),
|
||||||
|
onTitleUpdated: vi.fn(() => vi.fn()),
|
||||||
|
onA2UIMessage: vi.fn(() => vi.fn()),
|
||||||
|
dispatchA2UIAction: vi.fn(),
|
||||||
|
} as never;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('wires chat panel to load data and subscribe to stream/tool/title events', () => {
|
||||||
|
render(React.createElement(ChatPanel, { conversationId: 'conv-tab' }));
|
||||||
|
|
||||||
|
expect(window.electronAPI.chat.checkReady).toHaveBeenCalledTimes(1);
|
||||||
|
expect(window.electronAPI.chat.getConversation).toHaveBeenCalledWith('conv-tab');
|
||||||
|
expect(window.electronAPI.chat.getHistory).toHaveBeenCalledWith('conv-tab');
|
||||||
|
expect(window.electronAPI.chat.getAvailableModels).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
expect(window.electronAPI.chat.onStreamDelta).toHaveBeenCalledTimes(1);
|
||||||
|
expect(window.electronAPI.chat.onToolCall).toHaveBeenCalledTimes(1);
|
||||||
|
expect(window.electronAPI.chat.onToolResult).toHaveBeenCalledTimes(1);
|
||||||
|
expect(window.electronAPI.chat.onTitleUpdated).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('wires assistant sidebar stream subscriptions and does not auto-open chat tab', () => {
|
||||||
|
render(React.createElement(AssistantSidebar));
|
||||||
|
|
||||||
|
expect(window.electronAPI.chat.onStreamDelta).toHaveBeenCalledTimes(1);
|
||||||
|
expect(window.electronAPI.chat.onToolCall).toHaveBeenCalledTimes(1);
|
||||||
|
expect(window.electronAPI.chat.onToolResult).toHaveBeenCalledTimes(1);
|
||||||
|
expect(window.electronAPI.chat.onTitleUpdated).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
expect(useAppStore.getState().tabs.some((tab) => tab.type === 'chat')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
58
tests/renderer/navigation/useChatMessageSender.test.tsx
Normal file
58
tests/renderer/navigation/useChatMessageSender.test.tsx
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import { act, renderHook } from '@testing-library/react';
|
||||||
|
import { useChatMessageSender } from '../../../src/renderer/navigation/useChatMessageSender';
|
||||||
|
|
||||||
|
describe('useChatMessageSender', () => {
|
||||||
|
it('sends message and clears error on success', async () => {
|
||||||
|
const chatService = {
|
||||||
|
sendMessage: vi.fn().mockResolvedValue({ success: true, message: 'ok' }),
|
||||||
|
};
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useChatMessageSender({ chatService }));
|
||||||
|
|
||||||
|
let response: Awaited<ReturnType<typeof result.current.sendMessage>> | null = null;
|
||||||
|
await act(async () => {
|
||||||
|
response = await result.current.sendMessage({
|
||||||
|
conversationId: 'conv-1',
|
||||||
|
message: 'hello',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response?.success).toBe(true);
|
||||||
|
expect(response?.message).toBe('ok');
|
||||||
|
expect(result.current.lastError).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stores normalized error when send fails', async () => {
|
||||||
|
const chatService = {
|
||||||
|
sendMessage: vi.fn().mockResolvedValue({ success: false, error: 'boom' }),
|
||||||
|
};
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useChatMessageSender({ chatService }));
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.sendMessage({
|
||||||
|
conversationId: 'conv-1',
|
||||||
|
message: 'hello',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.lastError).toBe('boom');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns default error when service is unavailable', async () => {
|
||||||
|
const { result } = renderHook(() => useChatMessageSender({ chatService: null }));
|
||||||
|
|
||||||
|
let response: Awaited<ReturnType<typeof result.current.sendMessage>> | null = null;
|
||||||
|
await act(async () => {
|
||||||
|
response = await result.current.sendMessage({
|
||||||
|
conversationId: 'conv-1',
|
||||||
|
message: 'hello',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response?.success).toBe(false);
|
||||||
|
expect(response?.error).toContain('Chat service unavailable');
|
||||||
|
expect(result.current.lastError).toContain('Chat service unavailable');
|
||||||
|
});
|
||||||
|
});
|
||||||
40
tests/renderer/navigation/useChatSurfaceState.test.tsx
Normal file
40
tests/renderer/navigation/useChatSurfaceState.test.tsx
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { act, renderHook } from '@testing-library/react';
|
||||||
|
import { useChatSurfaceState } from '../../../src/renderer/navigation/useChatSurfaceState';
|
||||||
|
|
||||||
|
describe('useChatSurfaceState', () => {
|
||||||
|
it('tracks a full user-assistant turn including streaming and tool calls', () => {
|
||||||
|
const { result } = renderHook(() => useChatSurfaceState());
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.beginUserTurn('conv-1', 'hello');
|
||||||
|
result.current.appendStreamDelta('A');
|
||||||
|
result.current.appendStreamDelta('B');
|
||||||
|
result.current.recordToolCall('list_posts', { query: 'hello' });
|
||||||
|
result.current.recordToolResult('list_posts');
|
||||||
|
result.current.finalizeAssistantTurn('conv-1', 'AB');
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.messages).toHaveLength(2);
|
||||||
|
expect(result.current.messages[0].role).toBe('user');
|
||||||
|
expect(result.current.messages[1].role).toBe('assistant');
|
||||||
|
expect(result.current.messages[1].content).toBe('AB');
|
||||||
|
expect(result.current.messages[1].toolCalls).toContain('list_posts');
|
||||||
|
expect(result.current.isStreaming).toBe(false);
|
||||||
|
expect(result.current.streamingContent).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('aborts a stream into a partial assistant message', () => {
|
||||||
|
const { result } = renderHook(() => useChatSurfaceState());
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.beginUserTurn('conv-2', 'hello');
|
||||||
|
result.current.appendStreamDelta('partial content');
|
||||||
|
result.current.abortStreaming('conv-2', 'Cancelled');
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.messages).toHaveLength(2);
|
||||||
|
expect(result.current.messages[1].content).toContain('partial content');
|
||||||
|
expect(result.current.messages[1].content).toContain('Cancelled');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -43,9 +43,34 @@ describe('pythonApiContractV1', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('documents chat.sendMessage return contract and metadata input', () => {
|
||||||
|
expect(getPythonApiMethodContract('chat.sendMessage')).toEqual({
|
||||||
|
method: 'chat.sendMessage',
|
||||||
|
description: 'Send message to chat conversation.',
|
||||||
|
params: [
|
||||||
|
{
|
||||||
|
name: 'conversationId',
|
||||||
|
type: 'string',
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'message',
|
||||||
|
type: 'string',
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'metadata',
|
||||||
|
type: 'object',
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
returns: '{ success: boolean; message?: string; error?: string }',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('contains semantic version metadata for compatibility checks', () => {
|
it('contains semantic version metadata for compatibility checks', () => {
|
||||||
expect(BDS_PYTHON_API_CONTRACT_V1).toMatchObject({
|
expect(BDS_PYTHON_API_CONTRACT_V1).toMatchObject({
|
||||||
version: '1.3.0',
|
version: '1.6.0',
|
||||||
generatedAt: expect.any(String),
|
generatedAt: expect.any(String),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -74,7 +99,7 @@ describe('generatePythonApiModuleV1', () => {
|
|||||||
expect(moduleCode).toContain('async def search(self, query):');
|
expect(moduleCode).toContain('async def search(self, query):');
|
||||||
expect(moduleCode).toContain('async def get_project_metadata(self):');
|
expect(moduleCode).toContain('async def get_project_metadata(self):');
|
||||||
expect(moduleCode).toContain('async def get_conversations(self):');
|
expect(moduleCode).toContain('async def get_conversations(self):');
|
||||||
expect(moduleCode).toContain('async def send_message(self, conversation_id, message):');
|
expect(moduleCode).toContain('async def send_message(self, conversation_id, message, metadata=None):');
|
||||||
expect(moduleCode).toContain('class BdsApi:');
|
expect(moduleCode).toContain('class BdsApi:');
|
||||||
expect(moduleCode).toContain('bds = BdsApi(_transport)');
|
expect(moduleCode).toContain('bds = BdsApi(_transport)');
|
||||||
});
|
});
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user