diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 21eda47..282c66e 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -5,7 +5,14 @@ "Bash(npx tsc:*)", "Bash(node ./node_modules/typescript/bin/tsc:*)", "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)" ] } } diff --git a/.vscode/settings.json b/.vscode/settings.json index 6cadd54..c394720 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -3,6 +3,7 @@ "npx vitest": true, "npx tsc": true, "git remote": true, - "npx asar": true + "npx asar": true, + "npx tsx": true } } \ No newline at end of file diff --git a/A2UI.md b/A2UI.md new file mode 100644 index 0000000..65bc786 --- /dev/null +++ b/A2UI.md @@ -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) +- 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 | diff --git a/API.md b/API.md index 53ec1ea..b804d4e 100644 --- a/API.md +++ b/API.md @@ -1,6 +1,6 @@ # 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. @@ -3482,6 +3482,7 @@ Send message to chat conversation. - conversationId (str, required) - message (str, required) +- metadata (dict, optional) **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. diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index 4ffde4e..728e942 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -11,6 +11,7 @@ - [Working with media](#working-with-media) - [Using macros](#using-macros) - [Using scripting (early access)](#using-scripting-early-access) +- [Using the AI assistant](#using-the-ai-assistant) - [Organizing with tags](#organizing-with-tags) - [Importing from WordPress (WXR)](#importing-from-wordpress-wxr) - [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 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. diff --git a/src/main/a2ui/catalog.ts b/src/main/a2ui/catalog.ts new file mode 100644 index 0000000..96b2977 --- /dev/null +++ b/src/main/a2ui/catalog.ts @@ -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(); +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')}`; +} diff --git a/src/main/a2ui/generator.ts b/src/main/a2ui/generator.ts new file mode 100644 index 0000000..a3256ae --- /dev/null +++ b/src/main/a2ui/generator.ts @@ -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 }>; +} + +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) => 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, +): A2UIServerMessage[] | null { + const generator = GENERATORS[toolName]; + if (!generator) { + return null; + } + return generator(conversationId, toolArgs); +} diff --git a/src/main/a2ui/types.ts b/src/main/a2ui/types.ts new file mode 100644 index 0000000..03da853 --- /dev/null +++ b/src/main/a2ui/types.ts @@ -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; + /** 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; + /** 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; +} + +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; +} + +// ---- Surface State (renderer-side) ---- + +export interface A2UISurfaceState { + surfaceId: string; + conversationId: string; + components: Map; + rootIds: string[]; + dataModel: Record; + metadata?: Record; +} + +// ---- Resolved Component Tree (for rendering) ---- + +export interface A2UIResolvedComponent { + id: string; + type: A2UIComponentType; + properties: Record; + /** 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'; diff --git a/src/main/engine/ChatEngine.ts b/src/main/engine/ChatEngine.ts index 0ca1b77..7810a41 100644 --- a/src/main/engine/ChatEngine.ts +++ b/src/main/engine/ChatEngine.ts @@ -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. You can ONLY access information through the tools listed below. Do not claim otherwise. -Available Tools: -- search_posts: Search blog posts using full-text search. Supports category/tag filters. +Available Data Tools: +- 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. -- 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. -- 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. - 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. @@ -321,12 +322,37 @@ Available Tools: - get_post_media: Get media files linked to a post (featured images, galleries). - 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: 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. 3. Be concise and helpful. Format post information clearly when displaying it. 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.`; } /** diff --git a/src/main/engine/OpenCodeManager.ts b/src/main/engine/OpenCodeManager.ts index f472a1f..356d1c2 100644 --- a/src/main/engine/OpenCodeManager.ts +++ b/src/main/engine/OpenCodeManager.ts @@ -13,9 +13,11 @@ import http from 'http'; import { URL } from 'url'; import { BrowserWindow } from 'electron'; import { ChatEngine } from './ChatEngine'; -import { PostEngine } from './PostEngine'; -import { MediaEngine } from './MediaEngine'; +import { PostEngine, type PostData } from './PostEngine'; +import { MediaEngine, type MediaData } from './MediaEngine'; import { getPostMediaEngine } from './PostMediaEngine'; +import { isRenderTool, generateFromToolCall } from '../a2ui/generator'; +import type { A2UIServerMessage } from '../a2ui/types'; // OpenCode Zen API endpoints const ZEN_ANTHROPIC_URL = 'https://opencode.ai/zen/v1/messages'; @@ -66,9 +68,13 @@ export interface ModelInfo { } export interface SendMessageOptions { + metadata?: { + surface?: 'tab' | 'sidebar'; + }; onDelta?: (delta: string) => void; onToolCall?: (toolCall: { name: string; args: unknown }) => void; onToolResult?: (result: { name: string; result: unknown }) => void; + onA2UIMessage?: (message: A2UIServerMessage) => void; } export interface SendMessageResult { @@ -237,7 +243,7 @@ export class OpenCodeManager { userMessage: string, options: SendMessageOptions = {} ): Promise { - const { onDelta, onToolCall, onToolResult } = options; + const { metadata, onDelta, onToolCall, onToolResult, onA2UIMessage } = options; try { const readyCheck = await this.checkReady(); @@ -268,10 +274,14 @@ export class OpenCodeManager { // Get system prompt 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) const dbMessages = conversation.messages.filter(m => m.role !== 'system'); + // Add the new user message dbMessages.push({ conversationId, @@ -283,29 +293,53 @@ export class OpenCodeManager { let fullResponse = ''; 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 { console.log('[OpenCodeManager] Sending to provider:', provider, 'model:', modelId); - if (provider === 'anthropic') { - const result = await this.sendAnthropicMessage( - modelId, - 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); - } + const firstResult = await requestProvider(systemPrompt, dbMessages); + fullResponse = firstResult.content; + toolCallsCollected.push(...firstResult.toolCalls); console.log('[OpenCodeManager] fullResponse length:', fullResponse.length); } catch (error) { console.error('[OpenCodeManager] Request error:', (error as Error).message); @@ -313,12 +347,11 @@ export class OpenCodeManager { if (!isAborted) { throw error; } - // On abort, keep whatever was streamed so far (already in fullResponse or empty) } finally { this.abortControllers.delete(conversationId); } - // Save assistant response (including partial content from aborted requests) + // Save assistant response to history if (fullResponse) { await this.chatEngine.addMessage({ conversationId, @@ -360,7 +393,9 @@ export class OpenCodeManager { onDelta?: (delta: string) => void; onToolCall?: (toolCall: { name: string; args: unknown }) => void; onToolResult?: (result: { name: string; result: unknown }) => void; - } + }, + conversationId: string, + emitA2UIMessages: (messages: A2UIServerMessage[]) => void, ): Promise<{ content: string; toolCalls: Array<{ name: string; args: unknown }> }> { const tools = this.getToolDefinitions(); const allToolCalls: Array<{ name: string; args: unknown }> = []; @@ -451,6 +486,29 @@ export class OpenCodeManager { 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, + ); + 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 const result = await this.executeTool(toolName, toolArgs as Record); @@ -523,7 +581,9 @@ export class OpenCodeManager { onDelta?: (delta: string) => void; onToolCall?: (toolCall: { name: string; args: unknown }) => void; onToolResult?: (result: { name: string; result: unknown }) => void; - } + }, + conversationId: string, + emitA2UIMessages: (messages: A2UIServerMessage[]) => void, ): Promise<{ content: string; toolCalls: Array<{ name: string; args: unknown }> }> { // Build OpenAI-format messages const messages: Array> = [ @@ -637,6 +697,25 @@ export class OpenCodeManager { 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); if (callbacks.onToolResult) { @@ -663,14 +742,17 @@ export class OpenCodeManager { return [ { 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: { type: 'object', properties: { 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")' }, 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)' }, + offset: { type: 'number', description: 'Offset for pagination (default: 0). Use with limit to page through results.' }, }, required: ['query'], }, @@ -688,13 +770,15 @@ export class OpenCodeManager { }, { 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: { type: 'object', properties: { status: { type: 'string', enum: ['draft', 'published', 'archived'], description: 'Filter by post status' }, category: { type: 'string', description: 'Filter by category' }, 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)' }, offset: { type: 'number', description: 'Offset for pagination (default: 0)' }, }, @@ -713,12 +797,16 @@ export class OpenCodeManager { }, { 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: { type: 'object', properties: { 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)' }, + offset: { type: 'number', description: 'Offset for pagination (default: 0)' }, }, }, }, @@ -768,6 +856,14 @@ export class OpenCodeManager { 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', 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'], }, }, + // ── 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)) ); } + 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; - filteredPosts = filteredPosts.slice(0, limit); + filteredPosts = filteredPosts.slice(offset, offset + limit); return { success: true, count: filteredPosts.length, + totalMatches, + hasMore: offset + limit < totalMatches, + offset, + limit, posts: filteredPosts.map(p => ({ id: p!.id, title: p!.title, slug: p!.slug, excerpt: p!.excerpt, status: p!.status, @@ -885,32 +1190,42 @@ export class OpenCodeManager { } 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.tags) filter.tags = args.tags as string[]; if (args.category) filter.categories = [args.category as string]; - - let posts; - 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; - } + if (args.year !== undefined) filter.year = args.year as number; + if (args.month !== undefined && args.year !== undefined) filter.month = (args.month as number) - 1; // Convert 1-indexed to 0-indexed const offset = (args.offset as number) || 0; 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 { success: true, - count: slicedPosts.length, - total: posts.length, - hasMore: offset + limit < posts.length, - posts: slicedPosts.map(p => ({ + count: pageItems.length, + total: globalTotal, + filteredTotal, + hasMore: offset + limit < filteredTotal, + offset, + limit, + posts: pageItems.map(p => ({ id: p.id, title: p.title, slug: p.slug, status: p.status, categories: p.categories, tags: p.tags, createdAt: p.createdAt, updatedAt: p.updatedAt, @@ -934,16 +1249,36 @@ export class OpenCodeManager { } 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) { 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; - mediaList = mediaList.slice(0, limit); + const pageItems = mediaList.slice(offset, offset + limit); return { success: true, - count: mediaList.length, - media: mediaList.map(m => ({ + count: pageItems.length, + total: totalMedia, + filteredTotal, + hasMore: offset + limit < filteredTotal, + offset, + limit, + media: pageItems.map(m => ({ id: m.id, filename: m.filename, originalName: m.originalName, mimeType: m.mimeType, 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: return { success: false, error: `Unknown tool: ${name}` }; } @@ -1240,6 +1594,45 @@ export class OpenCodeManager { // ── 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 { + 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 { const id = modelId.toLowerCase(); if (id.startsWith('claude')) return 'anthropic'; diff --git a/src/main/engine/PostEngine.ts b/src/main/engine/PostEngine.ts index 4a2a9fa..cf11f8f 100644 --- a/src/main/engine/PostEngine.ts +++ b/src/main/engine/PostEngine.ts @@ -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; + 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 = {}; + const uniqueTags = new Set(); + const uniqueCategories = new Set(); + + 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 }[]> { const allPosts = await this.getAllPostsUnpaginated(); const counts = new Map(); diff --git a/src/main/ipc/chatHandlers.ts b/src/main/ipc/chatHandlers.ts index e3e4cb4..ec8dd92 100644 --- a/src/main/ipc/chatHandlers.ts +++ b/src/main/ipc/chatHandlers.ts @@ -256,12 +256,13 @@ export function registerChatHandlers(): void { // ============ Chat Messaging ============ // 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 { const manager = await getOpenCodeManager(); const mainWindow = mainWindowGetter?.(); const result = await manager.sendMessage(conversationId, message, { + metadata, onDelta: (delta) => { if (mainWindow) { mainWindow.webContents.send('chat-stream-delta', { conversationId, delta }); @@ -277,6 +278,11 @@ export function registerChatHandlers(): void { mainWindow.webContents.send('chat-tool-result', { conversationId, result }); } }, + onA2UIMessage: (message) => { + if (mainWindow) { + mainWindow.webContents.send('a2ui-message', { conversationId, message }); + } + }, }); 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 ipcMain.handle('chat:abortMessage', async (_, conversationId: string) => { try { @@ -357,6 +379,20 @@ export function registerChatHandlers(): void { return { success: false, error: (error as Error).message }; } }); + + // ============ A2UI Actions ============ + + ipcMain.handle('a2ui:dispatch', async (_, action: { surfaceId: string; componentId: string; action: string; payload?: Record }) => { + 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 }; + } + }); } /** diff --git a/src/main/ipc/handlers.ts b/src/main/ipc/handlers.ts index 2dcb9e9..a67b900 100644 --- a/src/main/ipc/handlers.ts +++ b/src/main/ipc/handlers.ts @@ -84,7 +84,11 @@ function runWebContentsMenuAction(sender: any, action: AppMenuAction): boolean { sender.selectAll?.(); return true; case 'toggleDevTools': - sender.toggleDevTools?.(); + if (sender.isDevToolsOpened?.()) { + sender.closeDevTools?.(); + } else { + sender.openDevTools?.({ mode: 'detach' }); + } return true; case 'reload': sender.reload?.(); diff --git a/src/main/main.ts b/src/main/main.ts index 69023b1..95d695f 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -49,6 +49,20 @@ interface Rectangle { // Check if dev server is likely running (only in 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 { if (typeof app.getPath !== 'function') { return null; @@ -246,7 +260,7 @@ function createWindow(): void { // F12 or Ctrl+Shift+I to toggle DevTools if (input.key === 'F12' || (input.control && input.shift && input.key.toLowerCase() === 'i')) { - mainWindow?.webContents.toggleDevTools(); + toggleDetachedDevTools(mainWindow); event.preventDefault(); } }); @@ -255,13 +269,13 @@ function createWindow(): void { const rendererPath = path.join(__dirname, '../renderer/index.html'); if (isDev) { mainWindow.loadURL('http://localhost:5173'); - mainWindow.webContents.openDevTools(); + mainWindow.webContents.openDevTools({ mode: 'detach' }); } else if (fs.existsSync(rendererPath)) { mainWindow.loadFile(rendererPath); } else { // Fallback to dev server if built files don't exist mainWindow.loadURL('http://localhost:5173'); - mainWindow.webContents.openDevTools(); + mainWindow.webContents.openDevTools({ mode: 'detach' }); } // Forward events to renderer @@ -571,6 +585,11 @@ function createApplicationMenu(): Menu { return; } + if (action === 'toggleDevTools') { + toggleDetachedDevTools(mainWindow); + return; + } + if (action === 'viewOnGitHub') { void shell.openExternal('https://github.com/rfc1437/bDS'); return; diff --git a/src/main/preload.ts b/src/main/preload.ts index 71ac84b..0b0e5d4 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -299,7 +299,8 @@ export const electronAPI: ElectronAPI = { deleteConversation: (id: string) => ipcRenderer.invoke('chat:deleteConversation', id), // 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), getHistory: (conversationId: string) => ipcRenderer.invoke('chat:getHistory', conversationId), clearMessages: (conversationId: string) => ipcRenderer.invoke('chat:clearMessages', conversationId), @@ -332,6 +333,14 @@ export const electronAPI: ElectronAPI = { ipcRenderer.on('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 diff --git a/src/main/shared/electronApi.ts b/src/main/shared/electronApi.ts index f29b855..9f1445c 100644 --- a/src/main/shared/electronApi.ts +++ b/src/main/shared/electronApi.ts @@ -431,6 +431,14 @@ export interface ChatTitleUpdate { 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 { sitemapPath: string; sitemapChanged: boolean; @@ -726,7 +734,8 @@ export interface ElectronAPI { deleteConversation: (id: string) => Promise; // 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; getHistory: (conversationId: string) => Promise; clearMessages: (conversationId: string) => Promise; @@ -743,6 +752,10 @@ export interface ElectronAPI { onToolCall: (callback: (data: ChatToolCall) => void) => () => void; onToolResult: (callback: (data: ChatToolResult) => 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; once: (channel: string, callback: (...args: unknown[]) => void) => void; diff --git a/src/main/shared/i18n/locales/de.json b/src/main/shared/i18n/locales/de.json index 295e877..0a7fe1f 100644 --- a/src/main/shared/i18n/locales/de.json +++ b/src/main/shared/i18n/locales/de.json @@ -24,6 +24,7 @@ "menu.item.viewMedia": "Medien", "menu.item.toggleSidebar": "Seitenleiste umschalten", "menu.item.togglePanel": "Panel umschalten", + "menu.item.toggleAssistantSidebar": "Assistenz-Seitenleiste umschalten", "menu.item.toggleDevTools": "Entwicklerwerkzeuge umschalten", "menu.item.reload": "Neu laden", "menu.item.forceReload": "Erzwungen neu laden", diff --git a/src/main/shared/i18n/locales/en.json b/src/main/shared/i18n/locales/en.json index d801fc6..6a83c6c 100644 --- a/src/main/shared/i18n/locales/en.json +++ b/src/main/shared/i18n/locales/en.json @@ -24,6 +24,7 @@ "menu.item.viewMedia": "Media", "menu.item.toggleSidebar": "Toggle Sidebar", "menu.item.togglePanel": "Toggle Panel", + "menu.item.toggleAssistantSidebar": "Toggle Assistant Sidebar", "menu.item.toggleDevTools": "Toggle Developer Tools", "menu.item.reload": "Reload", "menu.item.forceReload": "Force Reload", diff --git a/src/main/shared/i18n/locales/es.json b/src/main/shared/i18n/locales/es.json index da940d5..6c32180 100644 --- a/src/main/shared/i18n/locales/es.json +++ b/src/main/shared/i18n/locales/es.json @@ -24,6 +24,7 @@ "menu.item.viewMedia": "Medios", "menu.item.toggleSidebar": "Alternar barra lateral", "menu.item.togglePanel": "Alternar panel", + "menu.item.toggleAssistantSidebar": "Alternar barra del asistente", "menu.item.toggleDevTools": "Alternar herramientas de desarrollo", "menu.item.reload": "Recargar", "menu.item.forceReload": "Forzar recarga", diff --git a/src/main/shared/i18n/locales/fr.json b/src/main/shared/i18n/locales/fr.json index 3badedc..17f1f93 100644 --- a/src/main/shared/i18n/locales/fr.json +++ b/src/main/shared/i18n/locales/fr.json @@ -24,6 +24,7 @@ "menu.item.viewMedia": "Médias", "menu.item.toggleSidebar": "Basculer la barre latérale", "menu.item.togglePanel": "Basculer le panneau", + "menu.item.toggleAssistantSidebar": "Basculer le panneau Assistant", "menu.item.toggleDevTools": "Basculer les outils de développement", "menu.item.reload": "Recharger", "menu.item.forceReload": "Forcer le rechargement", diff --git a/src/main/shared/i18n/locales/it.json b/src/main/shared/i18n/locales/it.json index 407b4fa..15d4e2b 100644 --- a/src/main/shared/i18n/locales/it.json +++ b/src/main/shared/i18n/locales/it.json @@ -24,6 +24,7 @@ "menu.item.viewMedia": "Contenuti media", "menu.item.toggleSidebar": "Attiva/disattiva barra laterale", "menu.item.togglePanel": "Attiva/disattiva pannello", + "menu.item.toggleAssistantSidebar": "Attiva/disattiva barra assistente", "menu.item.toggleDevTools": "Attiva/disattiva strumenti sviluppatore", "menu.item.reload": "Ricarica", "menu.item.forceReload": "Forza ricaricamento", diff --git a/src/main/shared/menuCommands.ts b/src/main/shared/menuCommands.ts index 8d240b1..4125d5a 100644 --- a/src/main/shared/menuCommands.ts +++ b/src/main/shared/menuCommands.ts @@ -19,6 +19,7 @@ export type AppMenuAction = | 'viewMedia' | 'toggleSidebar' | 'togglePanel' + | 'toggleAssistantSidebar' | 'toggleDevTools' | 'reload' | 'forceReload' @@ -103,6 +104,7 @@ export const APP_MENU_GROUPS: AppMenuGroupDefinition[] = [ { label: 'menu.item.viewMedia', action: 'viewMedia', accelerator: 'CmdOrCtrl+2' }, { label: 'menu.item.toggleSidebar', action: 'toggleSidebar', accelerator: 'CmdOrCtrl+B' }, { 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: '', action: 'view-separator-1', separator: true }, { label: 'menu.item.reload', action: 'reload' }, @@ -156,6 +158,7 @@ export const APP_MENU_ACTION_EVENT_MAP: Partial> = viewMedia: 'menu:viewMedia', toggleSidebar: 'menu:toggleSidebar', togglePanel: 'menu:togglePanel', + toggleAssistantSidebar: 'menu:toggleAssistantSidebar', toggleDevTools: 'menu:toggleDevTools', previewPost: 'menu:previewPost', publishSelected: 'menu:publishSelected', diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index b0167fd..1515ec0 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -1,5 +1,5 @@ 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 { loadTabsForProject, saveTabsForProject } from './utils'; import { openSingletonToolTab } from './navigation/tabPolicy'; @@ -33,6 +33,7 @@ const App: React.FC = () => { setLoading, toggleSidebar, togglePanel, + toggleAssistantSidebar, setActiveView, setSelectedPost, setActiveProject, @@ -307,6 +308,12 @@ const App: React.FC = () => { }) || (() => {}) ); + unsubscribers.push( + window.electronAPI?.on('menu:toggleAssistantSidebar', () => { + toggleAssistantSidebar(); + }) || (() => {}) + ); + unsubscribers.push( window.electronAPI?.on('menu:viewPosts', () => { const state = useAppStore.getState(); @@ -538,7 +545,7 @@ const App: React.FC = () => { }; }, []); - const { sidebarVisible } = useAppStore(); + const { sidebarVisible, assistantSidebarVisible } = useAppStore(); return (
@@ -562,6 +569,18 @@ const App: React.FC = () => {
+ {assistantSidebarVisible && ( + + + + )} diff --git a/src/renderer/a2ui/A2UIRenderer.tsx b/src/renderer/a2ui/A2UIRenderer.tsx new file mode 100644 index 0000000..d4af9a8 --- /dev/null +++ b/src/renderer/a2ui/A2UIRenderer.tsx @@ -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; + +const COMPONENT_REGISTRY: Record = { + 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 = ({ + 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 ( + + ); + }; + + if (tree.length === 0) { + return null; + } + + return ( +
+ {tree.map(renderComponent)} +
+ ); +}; diff --git a/src/renderer/a2ui/A2UISurfaceManager.ts b/src/renderer/a2ui/A2UISurfaceManager.ts new file mode 100644 index 0000000..ac3faab --- /dev/null +++ b/src/renderer/a2ui/A2UISurfaceManager.ts @@ -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(); + 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 { + 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, + 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)[key]; + } + + return current; +} + +/** + * Set a value in a JSON object using a JSON Pointer (RFC 6901). + */ +export function setValueAtPointer( + obj: Record, + pointer: string, + value: unknown, +): void { + if (!pointer || pointer === '/') { + return; + } + + const parts = pointer.split('/').filter(Boolean); + + let current: Record = 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; + } + + const lastKey = parts[parts.length - 1].replace(/~1/g, '/').replace(/~0/g, '~'); + current[lastKey] = value; +} diff --git a/src/renderer/a2ui/InlineSurface.css b/src/renderer/a2ui/InlineSurface.css new file mode 100644 index 0000000..73d4bda --- /dev/null +++ b/src/renderer/a2ui/InlineSurface.css @@ -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; +} diff --git a/src/renderer/a2ui/InlineSurface.tsx b/src/renderer/a2ui/InlineSurface.tsx new file mode 100644 index 0000000..9c051b7 --- /dev/null +++ b/src/renderer/a2ui/InlineSurface.tsx @@ -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 = { + 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 = ({ + 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 ( +
setExpanded(true)} + role="button" + tabIndex={0} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + setExpanded(true); + } + }} + > + {surfaceIcon} + {surfaceTitle} + + {onDismiss && ( + + )} +
+ ); + } + + return ( +
+
+ {surfaceIcon} + {surfaceTitle} + + {onDismiss && ( + + )} +
+ +
+ ); +}; diff --git a/src/renderer/a2ui/components/A2UIButton.tsx b/src/renderer/a2ui/components/A2UIButton.tsx new file mode 100644 index 0000000..feeb043 --- /dev/null +++ b/src/renderer/a2ui/components/A2UIButton.tsx @@ -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 = ({ 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 ( + + ); +}; diff --git a/src/renderer/a2ui/components/A2UICard.tsx b/src/renderer/a2ui/components/A2UICard.tsx new file mode 100644 index 0000000..c5d843b --- /dev/null +++ b/src/renderer/a2ui/components/A2UICard.tsx @@ -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 = ({ 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 ( +
+

{title}

+ {subtitle &&

{subtitle}

} +

{body}

+ {actions.length > 0 && ( +
+ {actions.map((actionDef, index) => ( + + ))} +
+ )} +
+ ); +}; diff --git a/src/renderer/a2ui/components/A2UIChart.tsx b/src/renderer/a2ui/components/A2UIChart.tsx new file mode 100644 index 0000000..3e35a07 --- /dev/null +++ b/src/renderer/a2ui/components/A2UIChart.tsx @@ -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(); + 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 ( + + {/* Horizontal grid lines + Y-axis labels */} + {yTicks.map((tick) => { + const y = pad.top + (yMax > 0 ? (1 - tick / yMax) * plotHeight : plotHeight); + return ( + + + + {tick} + + + ); + })} + + {/* Area fill (when showArea is true) */} + {showArea && ( + + )} + + {/* Line */} + + + {/* Dots */} + {points.map((p, i) => ( + + {`${p.entry.label}: ${p.entry.value}`} + + ))} + + {/* X-axis labels */} + {points.map((p, i) => ( + + {p.entry.label} + + ))} + + ); +} + +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 ( + + {`${entry.label}: ${entry.value}`} + + ); + } + + return ( + + {`${entry.label}: ${entry.value}`} + + ); + }); + + const donutInnerRadius = PIE_CHART_RADIUS * 0.58; + + return ( + <> + + {slices} + {isDonut && ( + <> + + + {total} + + + )} + +
+ {series.map((entry, i) => ( + + + {entry.label} + + ))} +
+ + ); +} + +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 ( +
+ {/* Header row: empty corner + column labels */} + + {columnLabels.map((col) => ( + + {col} + + ))} + + {/* Data rows */} + {rows.map((row) => { + const segMap = new Map(); + for (const seg of row.segments!) { + segMap.set(seg.label, seg.value); + } + return ( + + {row.label} + {columnLabels.map((col) => { + const val = segMap.get(col) ?? 0; + const alpha = globalMax > 0 ? val / globalMax : 0; + const { bg, fg } = heatmapCellColors(alpha, ins, del); + return ( + + {val > 0 ? val : ''} + + ); + })} + + ); + })} +
+ ); +} + +export const A2UIChart: React.FC = ({ 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 ( +
+ {title &&

{title}

} +
{chartType}
+ {isPie || isDonut ? ( + renderPieChart(component, series, isDonut) + ) : isHeatmap ? ( + renderHeatmap(component, series) + ) : isLine || isArea ? ( + renderLineChart(component, series, isArea) + ) : ( +
+ {series.map((entry, index) => { + const totalValue = isStacked && entry.segments + ? entry.segments.reduce((sum, s) => sum + s.value, 0) + : entry.value; + + return ( +
+ {entry.label} +
+ {isStacked && entry.segments ? ( + entry.segments.map((seg, si) => { + const segWidth = maxValue > 0 ? (seg.value / maxValue) * 100 : 0; + return ( +
+ ); + }) + ) : ( +
0 ? (entry.value / maxValue) * 100 : 0}%` }} + /> + )} +
+ {totalValue} +
+ ); + })} +
+ )} + {isStacked && segmentLabels.length > 0 && ( +
+ {segmentLabels.map((label, i) => ( + + + {label} + + ))} +
+ )} +
+ ); +}; diff --git a/src/renderer/a2ui/components/A2UICheckBox.tsx b/src/renderer/a2ui/components/A2UICheckBox.tsx new file mode 100644 index 0000000..17b1fd4 --- /dev/null +++ b/src/renderer/a2ui/components/A2UICheckBox.tsx @@ -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 = ({ 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 ( + + ); +}; diff --git a/src/renderer/a2ui/components/A2UIChoicePicker.tsx b/src/renderer/a2ui/components/A2UIChoicePicker.tsx new file mode 100644 index 0000000..deb9425 --- /dev/null +++ b/src/renderer/a2ui/components/A2UIChoicePicker.tsx @@ -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 = ({ 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 ( +
+ + +
+ ); +}; diff --git a/src/renderer/a2ui/components/A2UIColumn.tsx b/src/renderer/a2ui/components/A2UIColumn.tsx new file mode 100644 index 0000000..12bd2fd --- /dev/null +++ b/src/renderer/a2ui/components/A2UIColumn.tsx @@ -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 = ({ component, renderChildren }) => { + return ( +
+ {renderChildren?.(component.children)} +
+ ); +}; diff --git a/src/renderer/a2ui/components/A2UIDateTimeInput.tsx b/src/renderer/a2ui/components/A2UIDateTimeInput.tsx new file mode 100644 index 0000000..56e6bc1 --- /dev/null +++ b/src/renderer/a2ui/components/A2UIDateTimeInput.tsx @@ -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 = ({ 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 ( +
+ + handleChange(e.target.value)} + /> +
+ ); +}; diff --git a/src/renderer/a2ui/components/A2UIDivider.tsx b/src/renderer/a2ui/components/A2UIDivider.tsx new file mode 100644 index 0000000..e9c08f7 --- /dev/null +++ b/src/renderer/a2ui/components/A2UIDivider.tsx @@ -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 = () => { + return
; +}; diff --git a/src/renderer/a2ui/components/A2UIForm.tsx b/src/renderer/a2ui/components/A2UIForm.tsx new file mode 100644 index 0000000..4c9cb51 --- /dev/null +++ b/src/renderer/a2ui/components/A2UIForm.tsx @@ -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 = ({ component, renderChildren }) => { + const title = component.properties.title as string | undefined; + + return ( +
+ {title &&

{title}

} + {renderChildren?.(component.children)} +
+ ); +}; diff --git a/src/renderer/a2ui/components/A2UIImage.tsx b/src/renderer/a2ui/components/A2UIImage.tsx new file mode 100644 index 0000000..6755723 --- /dev/null +++ b/src/renderer/a2ui/components/A2UIImage.tsx @@ -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 = ({ 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 ( +
+ {alt} + {caption &&
{caption}
} +
+ ); +}; diff --git a/src/renderer/a2ui/components/A2UIList.tsx b/src/renderer/a2ui/components/A2UIList.tsx new file mode 100644 index 0000000..58e57c6 --- /dev/null +++ b/src/renderer/a2ui/components/A2UIList.tsx @@ -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 = ({ component }) => { + const title = component.properties.title as string | undefined; + const items = (component.boundValue as string[]) ?? (component.properties.items as string[]) ?? []; + + return ( +
+ {title &&

{title}

} +
    + {items.map((item, index) => ( +
  • {item}
  • + ))} +
+
+ ); +}; diff --git a/src/renderer/a2ui/components/A2UIMetric.tsx b/src/renderer/a2ui/components/A2UIMetric.tsx new file mode 100644 index 0000000..b81e853 --- /dev/null +++ b/src/renderer/a2ui/components/A2UIMetric.tsx @@ -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 = ({ component }) => { + const label = String(component.properties.label ?? ''); + const value = String(component.properties.value ?? ''); + + return ( +
+ {label} + {value} +
+ ); +}; diff --git a/src/renderer/a2ui/components/A2UIRow.tsx b/src/renderer/a2ui/components/A2UIRow.tsx new file mode 100644 index 0000000..8966801 --- /dev/null +++ b/src/renderer/a2ui/components/A2UIRow.tsx @@ -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 = ({ component, renderChildren }) => { + return ( +
+ {renderChildren?.(component.children)} +
+ ); +}; diff --git a/src/renderer/a2ui/components/A2UITable.tsx b/src/renderer/a2ui/components/A2UITable.tsx new file mode 100644 index 0000000..e4092d8 --- /dev/null +++ b/src/renderer/a2ui/components/A2UITable.tsx @@ -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 = ({ 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 ( +
+ {title &&

{title}

} + + + + {columns.map((column, colIndex) => ( + + ))} + + + + {rows.map((row, rowIndex) => ( + + {row.map((cell, cellIndex) => ( + + ))} + + ))} + +
{column}
{cell}
+
+ ); +}; diff --git a/src/renderer/a2ui/components/A2UITabs.tsx b/src/renderer/a2ui/components/A2UITabs.tsx new file mode 100644 index 0000000..4973eb9 --- /dev/null +++ b/src/renderer/a2ui/components/A2UITabs.tsx @@ -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 = ({ component, renderChildren }) => { + const tabLabels = (component.properties.tabLabels as string[]) ?? []; + const [activeTab, setActiveTab] = useState(0); + + const children = component.children; + + return ( +
+
+ {tabLabels.map((label, index) => ( + + ))} +
+
+ {children[activeTab] && renderChildren?.([children[activeTab]])} +
+
+ ); +}; diff --git a/src/renderer/a2ui/components/A2UIText.tsx b/src/renderer/a2ui/components/A2UIText.tsx new file mode 100644 index 0000000..f9d7c89 --- /dev/null +++ b/src/renderer/a2ui/components/A2UIText.tsx @@ -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 = ({ component }) => { + const text = String(component.properties.text ?? ''); + return {text}; +}; diff --git a/src/renderer/a2ui/components/A2UITextField.tsx b/src/renderer/a2ui/components/A2UITextField.tsx new file mode 100644 index 0000000..81dfcc2 --- /dev/null +++ b/src/renderer/a2ui/components/A2UITextField.tsx @@ -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 = ({ 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 ( +
+ +