From 80cfaf8eaf6389f74c21e47d36042dc8c9ce2d7e Mon Sep 17 00:00:00 2001 From: IcePop Date: Fri, 19 Jun 2026 21:41:26 +0800 Subject: [PATCH 01/49] docs: add Windows desktop redesign spec --- ...6-06-19-windows-desktop-redesign-design.md | 243 ++++++++++++++++++ 1 file changed, 243 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-19-windows-desktop-redesign-design.md diff --git a/docs/superpowers/specs/2026-06-19-windows-desktop-redesign-design.md b/docs/superpowers/specs/2026-06-19-windows-desktop-redesign-design.md new file mode 100644 index 00000000..47ef4f30 --- /dev/null +++ b/docs/superpowers/specs/2026-06-19-windows-desktop-redesign-design.md @@ -0,0 +1,243 @@ +# Merak Windows Desktop 客户端完善设计 + +## 1. 背景与目标 + +Merak Windows Desktop 当前由 Tauri 桌面壳层和 React WebUI 组成。现有实现已经具备会话、世界观数据、文件编辑、LLM 配置、SSE 运行状态和桌面 Runtime 管理能力,但主要界面仍围绕单一三栏工作台组织,无法完整承载设计稿中的十个独立工作区。 + +本次改造以 2026-06-19 提供的十张 `1672 x 941` 设计稿和 `docs/api-reference.md` 为依据,在保留现有 Tauri、React、SSE 与业务能力的前提下,完成桌面端的信息架构、页面体系、API 接入和视觉系统升级。 + +目标如下: + +- 建立统一的桌面应用壳层与十个一级工作区。 +- 已有和新增界面均使用真实 API 数据,不使用演示数据伪造功能。 +- 接入 API 文档中已实现但前端尚未使用的必要能力。 +- 保留现有会话、编辑器、审批、导出、配置和桌面诊断流程。 +- 在设计稿原始尺寸、默认桌面窗口和最小窗口下完成视觉与交互验收。 + +## 2. 已确认的产品原则 + +### 2.1 真实数据优先 + +- API 已支持的数据直接从本机 Merak Runtime 获取。 +- 可由真实响应推导的统计在前端 selector 中计算,并记录明确口径。 +- API 未支持的分享、自动分析、虚构任务系统等功能不使用模拟数据。 +- 无后端支持的入口应隐藏;若入口对理解页面结构必要,则以不可用说明替代可点击控件。 + +### 2.2 模块化改造现有 WebUI + +采用在现有 WebUI 内部重构的方案,不维护并行 V2 应用。保留 React、CSS Modules、AppState、SSE、Tauri 调用和已有业务组件,通过新应用壳层、页面路由和页面级数据模块逐步替换现有单一工作台结构。 + +### 2.3 桌面优先 + +主验收环境是 Windows Desktop。默认窗口为 `1280 x 820`,最小窗口为 `960 x 640`。设计稿原始尺寸 `1672 x 941` 用于高保真对照。窄窗口可以折叠次要栏,但主操作、正文和当前任务不可被裁切。 + +## 3. 信息架构 + +### 3.1 全局应用壳层 + +应用壳层由以下区域组成: + +- 左侧全局导航:品牌、当前世界、搜索入口、十个工作区入口、世界进度摘要和用户/版本区域。 +- 顶部操作栏:世界选择器、当前页面上下文和与当前能力匹配的全局操作。 +- 页面内容区:由页面布局决定,可为总览、列表详情或创作布局。 +- 全局覆盖层:创建弹窗、审批、导出、帮助、连接状态、通知和桌面启动/恢复界面。 + +左侧导航的一级页面为: + +1. 概览 +2. 会话 +3. 世界 +4. 角色 +5. 章节 +6. 场景 +7. 伏笔 +8. 秘密 +9. 文件 +10. 设置 + +### 3.2 页面布局类型 + +#### 总览型 + +用于概览、世界和设置。主内容区展示核心资料或配置,右侧摘要栏展示统计、状态和相关提醒。 + +#### 列表详情型 + +用于角色、伏笔、秘密和文件。资源列表位于左侧,中间为选中资源详情,右侧为关联数据、检查结果或元数据。选中项由稳定 ID 驱动,列表刷新时尽量保留选择。 + +#### 创作型 + +用于会话、章节和场景。中心区域优先承载对话、规划表、场景结构或正文,右侧展示当前上下文、关联资源和实时状态。 + +## 4. 页面设计 + +### 4.1 概览 + +展示当前世界的真实统计、最近会话、创作进度、可验证的提醒和快捷入口。统计来自 `/overview`、`/dashboard` 或资源列表响应。若 `/dashboard` 不可用,则只展示能够由已加载列表准确计算的项目。 + +### 4.2 会话 + +保留并重组现有会话列表、消息时间线、Composer、工具调用、审批、生成文件和运行状态。会话页处理 SSE 实时事件,并补齐 `ask_user_requested`、`creation_requested`、`creation_resolved` 及 `POST /v1/runs/:id/ask-response`。 + +### 4.3 世界 + +展示世界基础资料、世界时间、地点、势力、知识条目、时间线和知识图谱数据。世界页按区块延迟加载,避免进入页面时请求全部世界观端点。编辑操作遵循接口版本和错误约定。 + +### 4.4 角色 + +展示角色列表、角色卡片、图片、日记、关系、记忆摘要、声音分析和出场信息。复用现有角色卡片编辑、图片上传、关系图和创建流程,并补齐搜索、详情和删除等 API 客户端能力。 + +### 4.5 章节 + +展示章节列表、顺序、状态、场景规划和正文入口。支持章节重排、更新、删除、评审和导出。现有 ChapterEditor 迁入新页面流程,文件保存继续使用版本冲突保护。 + +### 4.6 场景 + +展示场景目标、参与角色、地点、关联伏笔、正文和结束场景结果。支持创建、更新、删除和结束场景。知识提取或一致性结果仅在对应 API 有真实响应时展示。 + +### 4.7 伏笔 + +展示伏笔列表、状态、首次出现、计划回收、关联角色和章节时间线。支持创建、更新和删除。提醒、逾期和冲突状态仅由已有字段和真实章节/场景关系推导。 + +### 4.8 秘密 + +展示秘密内容、公开版本、知情者范围、怀疑者、风险、揭示计划和关联资源。受保护内容默认隐藏,由用户主动揭示。支持创建、更新和删除。 + +### 4.9 文件 + +展示工作区目录、搜索、类型筛选、文件列表、内容预览和编辑。支持读取、版本化保存、在系统中打开和世界资源关联。正文和大型文件按需加载,不进入全局启动请求。 + +### 4.10 设置 + +整合 LLM Provider、API Key、Base URL、默认模型、偏好、权限模式、桌面 Runtime、数据库状态和诊断工具。不存在真实读写接口的配置不显示为可编辑字段。Tauri 继续负责 Runtime 重启、日志目录和诊断报告。 + +## 5. 数据与状态架构 + +### 5.1 启动流程 + +启动时并行加载 Runtime 元数据、LLM 配置、能力开关、世界列表、会话列表和用户偏好。恢复上次选中的世界与页面;恢复失败时使用第一个可用世界。没有世界时进入世界创建流程,没有可用角色时显示世界概览和创建入口。 + +### 5.2 页面查询 + +页面数据以 `worldId + resource + filters` 作为查询身份。切换世界时清理世界级选中状态和过期缓存。每个页面独立管理加载、空状态、错误和重试,不将全部资源塞入单一启动请求。 + +项目继续使用现有 React 状态模式,不为本次改造引入新的远程状态依赖。页面查询逻辑封装为按资源划分的 hooks 和 API 方法;跨页面共享的世界、会话、运行状态和用户偏好保留在 AppState。 + +### 5.3 写操作 + +- 创建成功后将新资源插入对应列表并选中。 +- 更新成功后局部合并响应,并使相关统计失效。 +- 删除成功后选择相邻资源或进入空状态。 +- 乐观锁冲突时保留用户输入,不覆盖服务端内容。 +- 删除、结束场景和覆盖保存等危险操作在服务器确认前不改变最终 UI 状态。 + +### 5.4 SSE + +SSE 仅承载会话运行和实时事件。断线后从最后事件序号恢复,Reducer 使用事件 ID 和序号防止重复处理。新增提问与创建请求状态必须具备明确的打开、提交、解决和取消生命周期。 + +### 5.5 Tauri 边界 + +Tauri 只负责本地 Runtime 生命周期、端口和目录、系统打开行为与诊断。世界、会话、文件和配置等业务数据继续通过本机 HTTP API 访问,避免桌面命令与 Web API 形成两套业务契约。 + +## 6. API 层设计 + +API 客户端按资源域组织,同时保留统一的 `request`、错误解析和 API Base URL。需要补齐的能力至少包括: + +- Run 提问响应与相关 SSE 类型。 +- 角色删除、日记详情/搜索、记忆摘要、关系写操作和声音分析。 +- 叙事树、章节/场景详情与删除、章节重排。 +- 伏笔与秘密删除。 +- 完整导入导出。 +- 地点、知识条目和势力 CRUD。 +- Dashboard、世界文件关联、预览与创建流程。 +- Knowledge Graph 与 Timeline 查询/写入。 +- Pipeline retreat 和 clear-error。 + +`GET /v1/runs/:id/audit` 在文档中标记为前端调用但后端未实现。新界面不依赖该端点;现有调用在迁移时移除或保持不可达,不把失败响应作为正常产品功能。 + +错误解析必须兼容文档列出的非标准响应:字符串 `error`、对象 `error` 和 worldbuilding `{ ok: false, error }` 格式。 + +## 7. 视觉系统 + +视觉以提供的设计稿为主,现有 `PRODUCT.md` 和 `DESIGN.md` 作为品牌约束补充。 + +- 背景使用纯白和轻微冷灰,不引入暖黄页面底色。 +- 主品牌色使用设计稿中的深海军蓝,语义色仅用于成功、警告、错误和状态。 +- 字体使用 Inter、Segoe UI 和系统中文无衬线回退。 +- 表面默认使用细边框,不使用大面积阴影或装饰性渐变。 +- 导航、按钮、输入、表格、状态标签和详情区使用统一 tokens 与组件变体。 +- 图标优先使用现有 Lucide 图标;图标含义、线宽、尺寸和选中态必须与设计稿一致。 +- 动效用于页面切换、选择反馈、状态变化和面板展开,并遵循 `prefers-reduced-motion`。 + +不使用 Image Gen 重新生成视觉概念,因为用户提供的十张完整设计稿已经构成获批视觉规格。本次只对已有设计进行忠实实现。 + +## 8. 错误、空状态与恢复 + +- 页面级失败只影响对应区域,应用壳层和其他成功区域保持可用。 +- 多请求页面采用部分成功策略,不因单个接口失败清空已有数据。 +- `400`、`403`、`404`、`409`、`415` 和 `5xx` 映射为可操作的中文提示。 +- `409` 冲突提供重新载入和保留/复制修改内容的路径。 +- Runtime 断开时保留当前页面,显示桌面连接条并自动重连;持续失败后提供重启和诊断入口。 +- 空世界、空角色、空章节等状态提供对应创建入口。 +- 能力缺失与请求失败使用不同的界面文案,避免把未实现功能伪装成系统错误。 + +## 9. 组件边界 + +主要组件和模块边界如下: + +- `DesktopShell`:全局布局、导航、顶部栏和页面出口。 +- `PrimaryNavigation`:十个一级页面、当前世界摘要与响应式折叠。 +- `DesktopTopbar`:世界切换和当前页面允许的全局操作。 +- `PageState`:加载、空状态、错误和重试的统一容器。 +- `ResourceList`:列表筛选、选择、键盘导航与虚拟化扩展点。 +- `DetailPane`:详情页头、标签页、操作和内容滚动边界。 +- `InspectorRail`:页面相关摘要、关联资源和状态。 +- `api/*`:按 sessions、worlds、agents、narrative、files、config、pipeline、graph 和 timeline 划分。 +- `pages/*`:十个页面的组合层;复杂页面继续拆分为领域组件。 + +现有 `App.tsx` 只负责 Provider、启动和高层路由组合,不承载页面内部业务。 + +## 10. 测试与验收 + +### 10.1 自动化测试 + +- API 单元测试覆盖新增端点、查询参数、错误解析和版本冲突。 +- Reducer 测试覆盖页面/世界切换、SSE 提问与创建事件、断线恢复和去重。 +- 页面测试覆盖加载、空状态、部分失败、选择详情和主要写操作。 +- 用户流程覆盖世界创建、角色创建、会话运行、章节与场景操作、文件编辑、配置和诊断。 +- 可访问性检查覆盖键盘导航、焦点、语义标签和减少动画模式。 + +当前基线为生产构建通过,自动化测试 `104/106` 通过。两个既有失败来自 Inspector 文案/标签与测试预期不一致,必须在实现过程中修复,不能作为允许保留的失败。 + +### 10.2 视觉验收 + +逐页在以下尺寸检查: + +- `1672 x 941`:与十张设计稿一一对照。 +- `1280 x 820`:Tauri 默认窗口。 +- `960 x 640`:Tauri 最小窗口。 + +每页至少记录布局、可见文案、字体、颜色、间距、边框、图标、响应式行为和核心交互。最终对照必须使用浏览器截图与原始设计稿进行同轮 `view_image` 检查,并记录有意差异。 + +### 10.3 构建验收 + +- WebUI 测试全部通过。 +- TypeScript 与 Vite 生产构建通过。 +- 主要页面按需加载,避免当前约 `713 kB` 主包继续无界增长。 +- 浏览器完成桌面与最小窗口的核心工作流验证。 +- 原生 Tauri 检查和安装包构建在具备 Rust/Cargo 与 `merak.exe` 的环境中执行。 + +当前开发机缺少 Rust/Cargo 和 `merak.exe`,因此原生安装包构建属于已知环境阻塞,不改变 WebUI、Tauri 源码和浏览器视觉验收范围。 + +## 11. 完成标准 + +本目标只有在以下条件全部满足时才完成: + +1. 十个一级工作区均存在并遵循确认的信息架构。 +2. 每个页面可用区域均接入真实 API 或真实推导数据。 +3. 无 API 支持的功能没有伪造数据或误导性可点击入口。 +4. 会话 SSE 提问与创建请求闭环完成。 +5. 原有编辑、审批、导出、配置和桌面诊断能力无回归。 +6. 自动化测试与生产构建通过。 +7. 十张设计稿完成逐页视觉对照,默认与最小窗口无主内容裁切。 +8. 所有剩余差异均为有依据、已记录的有意差异。 From b540abade9b617b796a9179ec0d7b6bba014f7ea Mon Sep 17 00:00:00 2001 From: IcePop Date: Fri, 19 Jun 2026 21:51:06 +0800 Subject: [PATCH 02/49] docs: add Windows desktop implementation plan --- .../2026-06-19-windows-desktop-redesign.md | 719 ++++++++++++++++++ 1 file changed, 719 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-19-windows-desktop-redesign.md diff --git a/docs/superpowers/plans/2026-06-19-windows-desktop-redesign.md b/docs/superpowers/plans/2026-06-19-windows-desktop-redesign.md new file mode 100644 index 00000000..4956785f --- /dev/null +++ b/docs/superpowers/plans/2026-06-19-windows-desktop-redesign.md @@ -0,0 +1,719 @@ +# Merak Windows Desktop Redesign Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build the ten-page Merak Windows Desktop workbench from the approved screenshots, backed only by real API responses or explicitly derived data. + +**Architecture:** Keep the existing React, AppState, SSE, CSS Modules, and Tauri boundaries. Add a lazy-loaded desktop shell and page modules, split API access by resource domain, and migrate existing working components into page-specific layouts without creating a parallel application. + +**Tech Stack:** React 19, TypeScript 5.8, Vite 6, Vitest, Testing Library, CSS Modules, Lucide React, Tauri 2. + +## Global Constraints + +- Implement ten top-level pages: overview, sessions, world, characters, chapters, scenes, foreshadowing, secrets, files, settings. +- Use real HTTP API data or deterministic selectors over real responses; never ship fabricated dashboard or task data. +- Keep Tauri responsible only for local Runtime lifecycle, OS paths, and diagnostics. +- Preserve existing editor, approval, export, configuration, and desktop recovery workflows. +- Match the supplied `1672 x 941` screenshots and verify `1280 x 820` and `960 x 640` viewports. +- Keep React 19, TypeScript 5.8, Vite 6, CSS Modules, and Lucide React; add no remote-state or component-library dependency. +- Respect `prefers-reduced-motion` and keep all primary workflows keyboard reachable. +- Treat the two existing Inspector test failures as regressions to fix, not accepted baseline failures. +- Do not require `GET /v1/runs/:id/audit`; the backend does not implement it. + +--- + +## File Structure + +Create these focused modules: + +- `webui/src/api/http.ts`: base URL, request helpers, API error normalization. +- `webui/src/api/runtime.ts`: runtime, session, run, approval, SSE URL operations. +- `webui/src/api/worldbuilding.ts`: world, dashboard, agent, narrative, location, faction, knowledge, graph, timeline operations. +- `webui/src/api/files.ts`: workspace file and world file-link operations. +- `webui/src/api/config.ts`: LLM and preference operations. +- `webui/src/api/index.ts`: compatibility `api` facade used during migration. +- `webui/src/hooks/useResource.ts`: cancellable page-level GET lifecycle. +- `webui/src/shell/DesktopShell.tsx`: global navigation, top bar, page outlet, responsive rails. +- `webui/src/shell/DesktopShell.module.css`: shell grid and viewport breakpoints. +- `webui/src/shell/navigation.ts`: page metadata and icon mapping. +- `webui/src/components/layout/PageState.tsx`: loading, partial error, empty, retry states. +- `webui/src/components/layout/ResourceList.tsx`: selectable keyboard list primitive. +- `webui/src/components/layout/DetailPane.tsx`: detail content and optional inspector rail. +- `webui/src/pages/*.tsx`: one composition module for each top-level page. +- `webui/src/pages/*.module.css`: page-specific layout only; shared tokens remain global. +- `webui/src/pages/selectors.ts`: deterministic overview and progress calculations. +- `webui/src/__tests__/shell.test.tsx`: shell navigation and responsive behavior. +- `webui/src/__tests__/pages.test.tsx`: page loading, empty, partial failure, and interactions. +- `webui/src/__tests__/sse-workflows.test.tsx`: ask-user and creation-request workflows. + +Modify these existing modules: + +- `webui/src/App.tsx`: providers, bootstrap, lazy page outlet, global overlays. +- `webui/src/AppState.tsx`: top-level page IDs, selection state, ask/creation SSE state. +- `webui/src/api/types.ts`: API resource and SSE payload types. +- `webui/src/api/client.ts`: temporary re-export during migration, then compatibility-only facade. +- `webui/src/hooks/useSSE.ts`: exported parser, reconnect timer cleanup, duplicate sequence guard. +- `webui/src/styles/global.css`: approved navy/cool-gray tokens and shared control states. +- `webui/src/i18n.tsx`: visible Chinese and English shell/page copy. +- Existing components under `webui/src/components/`: migrate into pages without duplicating working business behavior. + +--- + +### Task 1: Normalize HTTP Errors and Split the API Foundation + +**Files:** +- Create: `webui/src/api/http.ts` +- Create: `webui/src/api/runtime.ts` +- Create: `webui/src/api/index.ts` +- Modify: `webui/src/api/client.ts` +- Modify: `webui/src/__tests__/api.test.ts` + +**Interfaces:** +- Produces: `request(method, path, body?)`, `requestForm(path, form)`, `ApiError`, `formatApiError`, `setApiBase`, `getApiBase`, `apiUrl`, and `runtimeApi`. +- Preserves: `import { api } from './api/client'` until all call sites migrate. + +- [ ] **Step 1: Write failing normalization tests** + +```ts +it.each([ + [{ error: { code: 'world_not_found', message: 'World not found', retryable: false } }, 'world_not_found'], + [{ ok: false, error: { code: 'file_conflict', message: 'Conflict', retryable: true } }, 'file_conflict'], + [{ error: 'session not found' }, undefined], +])('normalizes documented error shapes', async (payload, code) => { + fetchMock.mockResolvedValueOnce(new Response(JSON.stringify(payload), { status: 409 })); + await expect(request('GET', '/failure')).rejects.toMatchObject({ status: 409, code }); +}); +``` + +- [ ] **Step 2: Run the focused tests and confirm failure** + +Run: `npm test -- --run src/__tests__/api.test.ts` + +Expected: FAIL because `request` is not exported from `api/http.ts`. + +- [ ] **Step 3: Implement the shared HTTP module** + +```ts +export class ApiError extends Error { + constructor(message: string, public status: number, public code?: string, public retryable = false) { + super(message); + } +} + +export async function request(method: string, path: string, body?: unknown): Promise { + const response = await fetch(apiUrl(path), { + method, + headers: body === undefined ? undefined : { 'Content-Type': 'application/json' }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + const payload = await response.json().catch(() => ({})); + if (!response.ok) { + const raw = payload?.error; + const detail = typeof raw === 'object' && raw ? raw : {}; + throw new ApiError( + detail.message ?? (typeof raw === 'string' ? raw : `Request failed (${response.status})`), + response.status, + detail.code, + Boolean(detail.retryable), + ); + } + return payload as T; +} +``` + +Move the existing base URL, form/blob helpers, and `formatApiError` unchanged in behavior. Move runtime metadata, sessions, runs, delegations, approvals, creation resolution, and SSE URL methods into `runtimeApi` in `api/runtime.ts`. Re-export a compatibility `api` object from `api/index.ts` and `api/client.ts` by spreading `runtimeApi` so existing call sites retain their method names. + +- [ ] **Step 4: Run API tests and production build** + +Run: `npm test -- --run src/__tests__/api.test.ts && npm run build` + +Expected: API tests PASS; TypeScript and Vite build PASS. + +- [ ] **Step 5: Commit the API foundation** + +```powershell +git add webui/src/api/http.ts webui/src/api/runtime.ts webui/src/api/index.ts webui/src/api/client.ts webui/src/__tests__/api.test.ts +git commit -m "refactor(webui): split shared API transport" +``` + +### Task 2: Add Typed Worldbuilding and File Endpoints + +**Files:** +- Create: `webui/src/api/worldbuilding.ts` +- Create: `webui/src/api/files.ts` +- Create: `webui/src/api/config.ts` +- Modify: `webui/src/api/types.ts` +- Modify: `webui/src/api/index.ts` +- Test: `webui/src/__tests__/api.test.ts` + +**Interfaces:** +- Produces: `worldbuildingApi`, `filesApi`, `configApi`. +- Core signatures: `getDashboard(worldId)`, `listLocations(worldId)`, `listKnowledge(worldId)`, `listFactions(worldId)`, `getTimeline(worldId)`, `listGraphEntities(worldId)`, `reorderChapters(worldId, chapterIds)`, resource delete functions, `listWorldFiles(worldId)`, `linkWorldFile(worldId, link)`. + +- [ ] **Step 1: Add failing URL and body tests** + +```ts +it('reorders chapters with the documented body', async () => { + fetchMock.mockResolvedValueOnce(json({ ok: true })); + await worldbuildingApi.reorderChapters('w 1', ['c1', 'c2']); + expect(fetchMock).toHaveBeenCalledWith( + expect.stringContaining('/api/worldbuilding/w%201/chapters/reorder'), + expect.objectContaining({ method: 'POST', body: JSON.stringify({ chapter_ids: ['c1', 'c2'] }) }), + ); +}); + +it('encodes a linked file path when deleting it', async () => { + fetchMock.mockResolvedValueOnce(json({ ok: true })); + await filesApi.unlinkWorldFile('w1', '章节/第一章.md'); + expect(fetchMock.mock.calls[0][0]).toContain(encodeURIComponent('章节/第一章.md')); +}); +``` + +- [ ] **Step 2: Run tests and confirm missing modules** + +Run: `npm test -- --run src/__tests__/api.test.ts` + +Expected: FAIL on imports for `worldbuildingApi` and `filesApi`. + +- [ ] **Step 3: Define exact shared resource types** + +```ts +export interface ResourceListResponse { ok?: boolean; items?: T[]; } +export interface LocationItem { id: string; name: string; description?: string; version?: number; } +export interface KnowledgeItem { id: string; title: string; content?: string; tags?: string[]; version?: number; } +export interface FactionItem { id: string; name: string; description?: string; version?: number; } +export interface TimelineEvent { id: string; title: string; world_time?: string; description?: string; } +export interface GraphEntity { id: string; type: string; name: string; } +export interface WorldFileLink { file_path: string; entity_type?: string; entity_id?: string; } +``` + +Implement every method with `encodeURIComponent` for path IDs and documented request bodies. Use response adapters that accept documented named arrays and `items` without manufacturing records. + +- [ ] **Step 4: Run API tests, formatter, and build** + +Run: `npm test -- --run src/__tests__/api.test.ts && npm run format:check && npm run build` + +Expected: all commands PASS. + +- [ ] **Step 5: Commit typed API domains** + +```powershell +git add webui/src/api webui/src/__tests__/api.test.ts +git commit -m "feat(webui): add typed worldbuilding APIs" +``` + +### Task 3: Complete Ask-User and Creation SSE Workflows + +**Files:** +- Modify: `webui/src/api/types.ts` +- Modify: `webui/src/api/runtime.ts` +- Modify: `webui/src/AppState.tsx` +- Modify: `webui/src/hooks/useSSE.ts` +- Create: `webui/src/components/AskUserPrompt.tsx` +- Create: `webui/src/components/AskUserPrompt.module.css` +- Create: `webui/src/components/CreationRequestDialog.tsx` +- Test: `webui/src/__tests__/AppState.test.ts` +- Test: `webui/src/__tests__/sse-workflows.test.tsx` + +**Interfaces:** +- Produces: `runtimeApi.respondToAsk(runId: string, response: string)`, `pendingAsk`, `pendingCreation`, `RESOLVE_ASK`, `RESOLVE_CREATION` actions. +- Consumes: existing `resolveCreation(id, decision, modifications?)` behavior. + +- [ ] **Step 1: Add reducer tests for SSE lifecycle and deduplication** + +```ts +const askFrame = { seq: 10, type: 'ask_user_requested', payload: { run_id: 'r1', question: 'Choose POV' } }; +expect(reducer(initialState, { type: 'APPLY_SSE', frame: askFrame }).pendingAsk) + .toEqual({ runId: 'r1', question: 'Choose POV' }); +expect(reducer({ ...initialState, lastSeq: 10 }, { type: 'APPLY_SSE', frame: askFrame })) + .toEqual({ ...initialState, lastSeq: 10 }); +``` + +- [ ] **Step 2: Run focused tests and confirm failure** + +Run: `npm test -- --run src/__tests__/AppState.test.ts src/__tests__/sse-workflows.test.tsx` + +Expected: FAIL because pending workflow state and dialogs do not exist. + +- [ ] **Step 3: Implement typed SSE state and dialogs** + +```ts +export interface PendingAsk { runId: string; question: string; choices?: string[]; } +export interface PendingCreation { id: string; toolName: string; preview?: Record; } +``` + +Reject frames whose `seq` is nonzero and `seq <= state.lastSeq`. On submit, disable the dialog, call the real endpoint, clear state only on success, and show `formatApiError` on failure. Clear creation state on both `creation_resolved` and successful local resolution. + +- [ ] **Step 4: Verify workflow tests and all reducer tests** + +Run: `npm test -- --run src/__tests__/AppState.test.ts src/__tests__/sse-workflows.test.tsx` + +Expected: PASS with one network call per submission and no duplicate frame effects. + +- [ ] **Step 5: Commit SSE workflow completion** + +```powershell +git add webui/src/api webui/src/AppState.tsx webui/src/hooks/useSSE.ts webui/src/components/AskUserPrompt* webui/src/components/CreationRequestDialog.tsx webui/src/__tests__ +git commit -m "feat(webui): complete interactive SSE requests" +``` + +### Task 4: Build Shared Page State and Resource Layout Primitives + +**Files:** +- Create: `webui/src/hooks/useResource.ts` +- Create: `webui/src/components/layout/PageState.tsx` +- Create: `webui/src/components/layout/PageState.module.css` +- Create: `webui/src/components/layout/ResourceList.tsx` +- Create: `webui/src/components/layout/ResourceList.module.css` +- Create: `webui/src/components/layout/DetailPane.tsx` +- Create: `webui/src/components/layout/DetailPane.module.css` +- Test: `webui/src/__tests__/pages.test.tsx` + +**Interfaces:** +- Produces: `useResource(key, loader)`, `PageState`, `ResourceList`, `DetailPane`. + +- [ ] **Step 1: Write failing partial-error and keyboard selection tests** + +```tsx +render( x.id} renderItem={x => x.name} onSelect={onSelect} />); +fireEvent.keyDown(screen.getByRole('listbox'), { key: 'ArrowDown' }); +expect(onSelect).toHaveBeenCalledWith('b'); +``` + +- [ ] **Step 2: Run the focused page tests** + +Run: `npm test -- --run src/__tests__/pages.test.tsx` + +Expected: FAIL because layout primitives are missing. + +- [ ] **Step 3: Implement cancellable resource lifecycle** + +```ts +export type ResourceState = { status: 'loading' | 'ready' | 'error'; data: T | null; error: Error | null; retry(): void }; +export function useResource(key: string, loader: (signal: AbortSignal) => Promise): ResourceState; +``` + +The hook must abort the previous request on key change, retain successful data during retry, and ignore aborted rejections. `PageState` renders skeleton, retained-data warning, full error, or empty state based on explicit props. + +- [ ] **Step 4: Run tests and accessibility assertions** + +Run: `npm test -- --run src/__tests__/pages.test.tsx` + +Expected: PASS; listbox exposes selected option and keyboard navigation. + +- [ ] **Step 5: Commit layout primitives** + +```powershell +git add webui/src/hooks/useResource.ts webui/src/components/layout webui/src/__tests__/pages.test.tsx +git commit -m "feat(webui): add resource page primitives" +``` + +### Task 5: Add the Ten-Page Desktop Shell + +**Files:** +- Create: `webui/src/shell/navigation.ts` +- Create: `webui/src/shell/DesktopShell.tsx` +- Create: `webui/src/shell/DesktopShell.module.css` +- Modify: `webui/src/AppState.tsx` +- Modify: `webui/src/App.tsx` +- Modify: `webui/src/i18n.tsx` +- Test: `webui/src/__tests__/shell.test.tsx` + +**Interfaces:** +- Produces: `AppPage = 'overview' | 'sessions' | 'world' | 'characters' | 'chapters' | 'scenes' | 'foreshadowing' | 'secrets' | 'files' | 'settings' | 'editor'`. +- Produces: `DesktopShell({ page, onNavigate, children })`. + +- [ ] **Step 1: Add failing navigation tests** + +```tsx +const navigation = screen.getByRole('navigation', { name: '主导航' }); +expect(within(navigation).getAllByRole('button')).toHaveLength(10); +fireEvent.click(within(navigation).getByRole('button', { name: /角色/ })); +expect(dispatch).toHaveBeenCalledWith({ type: 'SET_PAGE', page: 'characters' }); +``` + +- [ ] **Step 2: Run shell tests and confirm failure** + +Run: `npm test -- --run src/__tests__/shell.test.tsx` + +Expected: FAIL because the desktop shell is missing. + +- [ ] **Step 3: Implement navigation metadata and lazy outlet** + +```ts +export const desktopPages = [ + ['overview', '概览', LayoutDashboard], ['sessions', 'Sessions 会话', MessagesSquare], + ['world', 'World 世界设定', Globe2], ['characters', 'Characters 角色', Users], + ['chapters', 'Chapters 章节', BookOpen], ['scenes', 'Scenes 场景', PanelsTopLeft], + ['foreshadowing', 'Foreshadowing 伏笔线索', Sparkles], ['secrets', 'Secrets 秘密', KeyRound], + ['files', 'Files 资料库', Files], ['settings', 'Settings 设置', Settings], +] as const; +``` + +Use ` -

- {state.activeEditorChapterTitle || '章节编辑'} -

+

{state.activeEditorChapterTitle || '章节编辑'}

@@ -255,7 +253,9 @@ function AppInner() { return ( setHelpOpen(true)} /> - {helpOpen && setHelpOpen(false)} />} + + {helpOpen && setHelpOpen(false)} />} + ); } @@ -264,7 +264,9 @@ function AppInner() { return ( setHelpOpen(true)} /> - {helpOpen && setHelpOpen(false)} />} + + {helpOpen && setHelpOpen(false)} />} + ); } @@ -289,7 +291,9 @@ function AppInner() { />
- setHelpOpen(false)} /> + + setHelpOpen(false)} /> + setInspectorOpen(false)} /> @@ -298,7 +302,9 @@ function AppInner() { {/* Setup Wizard — shown when LLM is not configured */} {state.showSetupWizard && ( - dispatch({ type: 'SET_LLM_CONFIGURED', configured: true })} /> + dispatch({ type: 'SET_LLM_CONFIGURED', configured: true })} + /> )} @@ -324,6 +330,19 @@ function AppInner() { )} + {state.pendingAsk && ( + dispatch({ type: 'RESOLVE_ASK' })} + /> + )} + {state.pendingCreation && ( + dispatch({ type: 'RESOLVE_CREATION' })} + /> + )} + {/* Export Dialog */} {state.showExportDialog && state.worldId && ( diff --git a/webui/src/AppState.tsx b/webui/src/AppState.tsx index 73964498..96494b04 100644 --- a/webui/src/AppState.tsx +++ b/webui/src/AppState.tsx @@ -4,6 +4,8 @@ import type { ConditionState, ForeshadowingItem, Message, + PendingAsk, + PendingCreation, PhaseTransition, PipelineViewData, RuntimeMetadata, @@ -48,6 +50,8 @@ export interface AppState { sessionId: string; lastSeq: number; currentRun: string | null; + pendingAsk: PendingAsk | null; + pendingCreation: PendingCreation | null; lastRunId: string | null; messages: Message[]; status: StatusLabel; @@ -116,6 +120,8 @@ export const initialState: AppState = { sessionId: '', lastSeq: 0, currentRun: null, + pendingAsk: null, + pendingCreation: null, lastRunId: null, messages: [], status: 'idle', @@ -308,6 +314,8 @@ export type Action = | { type: 'COMMIT_ACTIVE' } | { type: 'SET_STORY_VERSION' } | { type: 'APPLY_SSE'; frame: SseFrame } + | { type: 'RESOLVE_ASK' } + | { type: 'RESOLVE_CREATION' } | { type: 'SET_PIPELINE_CONDITIONS'; conditions: ConditionState[] } | { type: 'SET_PIPELINE_VIEW'; view: Partial } | { type: 'DISMISS_PHASE_PROMPT' } @@ -339,6 +347,8 @@ export function reducer(state: AppState, action: Action): AppState { lastSeq: 0, currentRun: null, lastRunId: null, + pendingAsk: null, + pendingCreation: null, status: 'idle', }; @@ -351,6 +361,8 @@ export function reducer(state: AppState, action: Action): AppState { lastSeq: 0, currentRun: null, lastRunId: null, + pendingAsk: null, + pendingCreation: null, status: 'idle', }; @@ -485,7 +497,8 @@ export function reducer(state: AppState, action: Action): AppState { ...state, editorBuffers: { ...state.editorBuffers, - [action.fileId]: state.editorOriginals[action.fileId] ?? state.editorBuffers[action.fileId] ?? '', + [action.fileId]: + state.editorOriginals[action.fileId] ?? state.editorBuffers[action.fileId] ?? '', }, workspaceFiles: state.workspaceFiles.map((file) => file.id === action.fileId ? { ...file, dirty: false } : file, @@ -678,10 +691,17 @@ export function reducer(state: AppState, action: Action): AppState { case 'APPLY_SSE': { const { frame } = action; + if (frame.seq > 0 && frame.seq <= state.lastSeq) return state; if (frame.seq > 0) state = { ...state, lastSeq: Math.max(state.lastSeq, frame.seq) }; return applySseFrame(state, frame); } + case 'RESOLVE_ASK': + return { ...state, pendingAsk: null }; + + case 'RESOLVE_CREATION': + return { ...state, pendingCreation: null }; + case 'SET_PIPELINE_CONDITIONS': return { ...state, pipelineConditions: action.conditions }; @@ -868,6 +888,39 @@ function applySseFrame(state: AppState, frame: SseFrame): AppState { case 'approval_resolved': return reducer(state, { type: 'CLEAR_APPROVAL' }); + case 'ask_user_requested': { + const choices = Array.isArray(p.options) + ? p.options.filter((choice): choice is string => typeof choice === 'string') + : undefined; + return { + ...state, + pendingAsk: { + runId: (p.run_id as string) || state.currentRun || '', + ...(typeof p.call_id === 'string' && p.call_id ? { callId: p.call_id } : {}), + question: (p.question as string) ?? '', + ...(choices?.length ? { choices } : {}), + ...(typeof p.multi_select === 'boolean' ? { multiSelect: p.multi_select } : {}), + }, + }; + } + + case 'creation_requested': + return { + ...state, + pendingCreation: { + id: (p.creation_id as string) ?? '', + toolName: (p.tool as string) ?? '', + ...(p.preview && typeof p.preview === 'object' + ? { preview: p.preview as Record } + : {}), + }, + }; + + case 'creation_resolved': + return state.pendingCreation?.id === p.creation_id || !p.creation_id + ? reducer(state, { type: 'RESOLVE_CREATION' }) + : state; + case 'usage_updated': return reducer(state, { type: 'SET_USAGE', diff --git a/webui/src/__tests__/AppState.test.ts b/webui/src/__tests__/AppState.test.ts index 31ade23a..d188f9c4 100644 --- a/webui/src/__tests__/AppState.test.ts +++ b/webui/src/__tests__/AppState.test.ts @@ -8,13 +8,20 @@ function state(overrides: Partial = {}): AppState { describe('AppState reducer', () => { it('SET_SESSION resets messages and sets sessionId', () => { - const prev = state({ messages: [{ id: 'm1', kind: 'user', text: 'hi' }], lastSeq: 5 }); + const prev = state({ + messages: [{ id: 'm1', kind: 'user', text: 'hi' }], + lastSeq: 5, + pendingAsk: { runId: 'old-run', question: 'Old question' }, + pendingCreation: { id: 'old-creation', toolName: 'create_scene' }, + }); const next = reducer(prev, { type: 'SET_SESSION', sessionId: 'new-id' }); expect(next.sessionId).toBe('new-id'); expect(next.messages).toHaveLength(0); expect(next.lastSeq).toBe(0); expect(next.currentRun).toBeNull(); expect(next.status).toBe('idle'); + expect(next.pendingAsk).toBeNull(); + expect(next.pendingCreation).toBeNull(); }); it('SET_METADATA stores metadata and sets selectedModel', () => { @@ -42,7 +49,16 @@ describe('AppState reducer', () => { const next = reducer(prev, { type: 'SET_SESSIONS', sessions: [ - { id: 's1', title: 'Test', world_id: null, agent_id: null, last_seq: 0, created_at: '', updated_at: '', archived_at: null }, + { + id: 's1', + title: 'Test', + world_id: null, + agent_id: null, + last_seq: 0, + created_at: '', + updated_at: '', + archived_at: null, + }, ], }); expect(next.sessions).toHaveLength(1); @@ -200,6 +216,67 @@ describe('AppState reducer', () => { expect(next2.lastSeq).toBe(7); }); + it('tracks and resolves interactive SSE requests', () => { + const askFrame = { + seq: 10, + type: 'ask_user_requested', + payload: { + run_id: 'r1', + call_id: 'call_1', + question: 'Choose POV', + options: ['First person', 'Third person'], + }, + }; + const asked = reducer(state(), { type: 'APPLY_SSE', frame: askFrame }); + expect(asked.pendingAsk).toEqual({ + runId: 'r1', + callId: 'call_1', + question: 'Choose POV', + choices: ['First person', 'Third person'], + }); + expect(reducer(asked, { type: 'RESOLVE_ASK' }).pendingAsk).toBeNull(); + + const creationFrame = { + seq: 11, + type: 'creation_requested', + payload: { creation_id: 'creation_1', tool: 'create_scene', preview: { title: 'Arrival' } }, + }; + const requested = reducer(asked, { type: 'APPLY_SSE', frame: creationFrame }); + expect(requested.pendingCreation).toEqual({ + id: 'creation_1', + toolName: 'create_scene', + preview: { title: 'Arrival' }, + }); + expect( + reducer(requested, { + type: 'APPLY_SSE', + frame: { + seq: 12, + type: 'creation_resolved', + payload: { creation_id: 'creation_1', decision: 'allow' }, + }, + }).pendingCreation, + ).toBeNull(); + expect(reducer(requested, { type: 'RESOLVE_CREATION' }).pendingCreation).toBeNull(); + }); + + it('ignores duplicate and stale nonzero SSE frames before applying their effects', () => { + const askFrame = { + seq: 10, + type: 'ask_user_requested', + payload: { run_id: 'r1', question: 'Choose POV' }, + }; + const prev = state({ lastSeq: 10 }); + expect(reducer(prev, { type: 'APPLY_SSE', frame: askFrame })).toEqual(prev); + + const zeroSeq = reducer(prev, { + type: 'APPLY_SSE', + frame: { ...askFrame, seq: 0 }, + }); + expect(zeroSeq.pendingAsk).toEqual({ runId: 'r1', question: 'Choose POV' }); + expect(zeroSeq.lastSeq).toBe(10); + }); + describe('SSE frame: run_started', () => { it('creates user message from payload.message', () => { const prev = state(); diff --git a/webui/src/__tests__/sse-workflows.test.tsx b/webui/src/__tests__/sse-workflows.test.tsx new file mode 100644 index 00000000..3d1e3612 --- /dev/null +++ b/webui/src/__tests__/sse-workflows.test.tsx @@ -0,0 +1,109 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { runtimeApi } from '../api/runtime'; +import AskUserPrompt from '../components/AskUserPrompt'; +import CreationRequestDialog from '../components/CreationRequestDialog'; + +const apiMocks = vi.hoisted(() => ({ + respondToAsk: vi.fn(), + resolveCreation: vi.fn(), +})); + +vi.mock('../api/client', () => ({ + api: apiMocks, + formatApiError: (error: unknown, fallback = 'Request failed') => + error instanceof Error ? error.message : fallback, +})); + +describe('interactive SSE workflows', () => { + beforeEach(() => vi.clearAllMocks()); + + it('exposes the ask-user response endpoint', () => { + expect(runtimeApi.respondToAsk).toBeTypeOf('function'); + }); + + it('submits an ask response once and resolves only after success', async () => { + let complete!: () => void; + apiMocks.respondToAsk.mockReturnValue( + new Promise((resolve) => { + complete = resolve; + }), + ); + const onResolved = vi.fn(); + render( + , + ); + + fireEvent.click(screen.getByRole('button', { name: 'First person' })); + const submit = screen.getByRole('button', { name: 'Send response' }); + fireEvent.click(submit); + fireEvent.click(submit); + + expect(apiMocks.respondToAsk).toHaveBeenCalledOnce(); + expect(apiMocks.respondToAsk).toHaveBeenCalledWith('run_1', 'First person', 'call_1'); + expect(onResolved).not.toHaveBeenCalled(); + expect(screen.getByRole('button', { name: 'Sending...' })).toBeDisabled(); + + complete(); + await waitFor(() => expect(onResolved).toHaveBeenCalledOnce()); + }); + + it('keeps the ask dialog open and formats request errors', async () => { + apiMocks.respondToAsk.mockRejectedValue(new Error('Run is no longer waiting')); + const onResolved = vi.fn(); + render( + , + ); + fireEvent.change(screen.getByLabelText('Your response'), { target: { value: 'Yes' } }); + fireEvent.click(screen.getByRole('button', { name: 'Send response' })); + + expect(await screen.findByRole('alert')).toHaveTextContent('Run is no longer waiting'); + expect(onResolved).not.toHaveBeenCalled(); + }); + + it.each([ + ['Allow', 'allow'], + ['Deny', 'deny'], + ] as const)('resolves a creation request once via %s', async (button, decision) => { + apiMocks.resolveCreation.mockResolvedValue({ ok: true }); + const onResolved = vi.fn(); + render( + , + ); + fireEvent.click(screen.getByRole('button', { name: button })); + + await waitFor(() => expect(onResolved).toHaveBeenCalledOnce()); + expect(apiMocks.resolveCreation).toHaveBeenCalledOnce(); + expect(apiMocks.resolveCreation).toHaveBeenCalledWith( + 'creation_1', + decision, + decision === 'allow' ? { title: 'Arrival' } : undefined, + ); + }); + + it('keeps the creation dialog open when resolution fails', async () => { + apiMocks.resolveCreation.mockRejectedValue(new Error('Creation expired')); + const onResolved = vi.fn(); + render( + , + ); + fireEvent.click(screen.getByRole('button', { name: 'Allow' })); + + expect(await screen.findByRole('alert')).toHaveTextContent('Creation expired'); + expect(onResolved).not.toHaveBeenCalled(); + }); +}); diff --git a/webui/src/api/runtime.ts b/webui/src/api/runtime.ts index 6e804cf5..eea02ef8 100644 --- a/webui/src/api/runtime.ts +++ b/webui/src/api/runtime.ts @@ -2,6 +2,7 @@ import { getApiBase, request } from './http'; import type { ApprovalResponse, ArchiveSessionResponse, + AskResponse, CancelRunResponse, CreateSessionResponse, GenerateTitleResponse, @@ -75,6 +76,11 @@ export const runtimeApi = { decision: allow ? 'allow' : 'deny', }), cancelRun: (id: string) => request('POST', `/v1/runs/${id}/cancel`), + respondToAsk: (runId: string, response: string, callId = runId) => + request('POST', `/v1/runs/${runId}/ask-response`, { + call_id: callId, + response, + }), resolveCreation: (id: string, decision: string, modifications?: Record) => request('POST', `/v1/creations/${id}/resolve`, { decision, diff --git a/webui/src/api/types.ts b/webui/src/api/types.ts index f2e6f42a..4b2e6ff2 100644 --- a/webui/src/api/types.ts +++ b/webui/src/api/types.ts @@ -4,6 +4,26 @@ export interface SseFrame { payload: Record; } +export interface PendingAsk { + runId: string; + callId?: string; + question: string; + choices?: string[]; + multiSelect?: boolean; +} + +export interface PendingCreation { + id: string; + toolName: string; + preview?: Record; +} + +export interface AskResponse { + ok: boolean; + run_id: string; + call_id: string; +} + export type MessageKind = 'user' | 'assistant' | 'tool' | 'system' | 'approval' | 'status_pill'; export type StatusLabel = diff --git a/webui/src/components/AskUserPrompt.module.css b/webui/src/components/AskUserPrompt.module.css new file mode 100644 index 00000000..1fd9cfca --- /dev/null +++ b/webui/src/components/AskUserPrompt.module.css @@ -0,0 +1,75 @@ +.overlay { + position: fixed; + inset: 0; + z-index: 1100; + display: grid; + place-items: center; + padding: 24px; + background: rgba(0, 0, 0, 0.55); +} + +.card { + width: min(520px, 100%); + padding: 24px; + border: 1px solid var(--border); + border-radius: 12px; + background: var(--surface); + color: var(--text); +} + +.question { + white-space: pre-wrap; +} +.choices { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin: 16px 0; +} +.choice, +.selected, +.submit { + padding: 8px 14px; + border: 1px solid var(--border); + border-radius: 7px; + cursor: pointer; +} +.choice { + background: var(--bg-secondary); + color: var(--text); +} +.selected, +.submit { + background: var(--accent); + color: white; +} +.label { + display: block; + margin: 16px 0 6px; + font-size: 0.9rem; +} +.input { + box-sizing: border-box; + width: 100%; + min-height: 88px; + padding: 10px; + border: 1px solid var(--border); + border-radius: 7px; + background: var(--input-bg); + color: var(--text); + resize: vertical; +} +.actions { + display: flex; + justify-content: flex-end; + margin-top: 16px; +} +.error { + color: var(--ruby); +} +.submit:disabled, +.choice:disabled, +.selected:disabled { + cursor: not-allowed; + opacity: 0.6; +} diff --git a/webui/src/components/AskUserPrompt.tsx b/webui/src/components/AskUserPrompt.tsx new file mode 100644 index 00000000..cd115e52 --- /dev/null +++ b/webui/src/components/AskUserPrompt.tsx @@ -0,0 +1,81 @@ +import { FormEvent, useState } from 'react'; +import { api, formatApiError } from '../api/client'; +import type { PendingAsk } from '../api/types'; +import styles from './AskUserPrompt.module.css'; + +interface Props { + request: PendingAsk; + onResolved: () => void; +} + +export default function AskUserPrompt({ request, onResolved }: Props) { + const [response, setResponse] = useState(''); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + + async function submit(event: FormEvent) { + event.preventDefault(); + const answer = response.trim(); + if (!answer || submitting) return; + setSubmitting(true); + setError(null); + try { + await api.respondToAsk(request.runId, answer, request.callId); + onResolved(); + } catch (cause) { + setError(formatApiError(cause, 'Could not send your response.')); + } finally { + setSubmitting(false); + } + } + + return ( +
+
+

Agent question

+

{request.question}

+ {request.choices?.length ? ( +
+ {request.choices.map((choice) => ( + + ))} +
+ ) : null} + +