From 4bce8317aabc1dda1f1137a195e9b4b4a60cb897 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Sun, 1 Feb 2026 00:23:35 +0800 Subject: [PATCH] wip(web-app): built an initial web app using node --- .cursor/commands/dw-1-refine-prd.md | 174 -- .cursor/commands/dw-2-design.md | 114 -- .cursor/commands/dw-3-task.md | 83 - .cursor/commands/dw-4-code.md | 67 - .cursor/commands/dw-5-revise.md | 147 -- .cursor/commands/dw-6-archive.md | 80 - .cursor/commands/openspec-apply.md | 28 - .cursor/commands/openspec-archive.md | 32 - .cursor/commands/openspec-proposal.md | 33 - .../gui_for_qwen_code_cli_4c6156e2.plan.md | 194 --- .../plans/web_gui_for_cli_d1dcfda7.plan.md | 181 --- .gitignore | 2 +- AGENTS.md | 18 - QWEN.md | 18 - eslint.config.js | 4 +- openspec/AGENTS.md | 456 ------ .../add-web-gui/context/1_refined_prd.dev.md | 427 ----- .../context/2_technical_design.dev.md | 1063 ------------ .../add-web-gui/context/3_dev_plan.dev.md | 186 --- openspec/changes/add-web-gui/proposal.md | 28 - .../changes/add-web-gui/specs/web-gui/spec.md | 159 -- openspec/changes/add-web-gui/tasks.md | 106 -- openspec/project.md | 31 - package-lock.json | 1437 ++++++++++++++++- packages/cli/package.json | 1 - packages/cli/src/gemini.tsx | 199 +++ .../cli/src/services/BuiltinCommandLoader.ts | 2 - packages/cli/src/ui/commands/webCommand.ts | 155 -- .../core/src/services/sessionService.test.ts | 68 +- packages/core/src/utils/shell-utils.ts | 12 +- packages/web-app/package.json | 35 +- packages/web-app/src/client/App.tsx | 204 ++- packages/web-app/src/client/assets.d.ts | 4 + packages/web-app/src/client/assets/icon.png | Bin 0 -> 53565 bytes .../src/client/components/ChatArea.tsx | 685 ++++++-- .../web-app/src/client/components/Sidebar.tsx | 408 +++-- .../src/client/components/ui/button.tsx | 57 + .../client/components/ui/qwen-code-brand.tsx | 43 + .../src/client/components/ui/resizable.tsx | 42 + .../src/client/components/ui/sonner.tsx | 23 + .../src/client/components/ui/switch.tsx | 26 + .../src/client/components/ui/theme-toggle.tsx | 39 + .../src/client/components/ui/tooltip.tsx | 27 + .../web-app/src/client/hooks/useWebSocket.ts | 69 +- packages/web-app/src/client/lib/utils.ts | 30 + packages/web-app/src/client/main.tsx | 1 + packages/web-app/src/client/styles/global.css | 252 ++- packages/web-app/src/client/version.ts | 1 + packages/web-app/src/server/app.ts | 8 +- .../web-app/src/server/cli/qwen-wrapper.ts | 92 ++ packages/web-app/src/server/index.ts | 40 +- .../web-app/src/server/routes/sessions.ts | 45 +- packages/web-app/src/server/sessionManager.ts | 46 + .../web-app/src/server/websocket/handler.ts | 133 +- .../src/server/websocket/sessionRunner.ts | 1202 ++++++++++---- packages/web-app/src/shared/types.ts | 53 +- packages/web-app/tailwind.config.js | 48 +- packages/web-app/tsconfig.json | 7 +- packages/web-app/tsconfig.server.json | 1 + packages/web-app/vite.config.ts | 14 +- 60 files changed, 4473 insertions(+), 4667 deletions(-) delete mode 100644 .cursor/commands/dw-1-refine-prd.md delete mode 100644 .cursor/commands/dw-2-design.md delete mode 100644 .cursor/commands/dw-3-task.md delete mode 100644 .cursor/commands/dw-4-code.md delete mode 100644 .cursor/commands/dw-5-revise.md delete mode 100644 .cursor/commands/dw-6-archive.md delete mode 100644 .cursor/commands/openspec-apply.md delete mode 100644 .cursor/commands/openspec-archive.md delete mode 100644 .cursor/commands/openspec-proposal.md delete mode 100644 .cursor/plans/gui_for_qwen_code_cli_4c6156e2.plan.md delete mode 100644 .cursor/plans/web_gui_for_cli_d1dcfda7.plan.md delete mode 100644 AGENTS.md delete mode 100644 QWEN.md delete mode 100644 openspec/AGENTS.md delete mode 100644 openspec/changes/add-web-gui/context/1_refined_prd.dev.md delete mode 100644 openspec/changes/add-web-gui/context/2_technical_design.dev.md delete mode 100644 openspec/changes/add-web-gui/context/3_dev_plan.dev.md delete mode 100644 openspec/changes/add-web-gui/proposal.md delete mode 100644 openspec/changes/add-web-gui/specs/web-gui/spec.md delete mode 100644 openspec/changes/add-web-gui/tasks.md delete mode 100644 openspec/project.md delete mode 100644 packages/cli/src/ui/commands/webCommand.ts create mode 100644 packages/web-app/src/client/assets.d.ts create mode 100644 packages/web-app/src/client/assets/icon.png create mode 100644 packages/web-app/src/client/components/ui/button.tsx create mode 100644 packages/web-app/src/client/components/ui/qwen-code-brand.tsx create mode 100644 packages/web-app/src/client/components/ui/resizable.tsx create mode 100644 packages/web-app/src/client/components/ui/sonner.tsx create mode 100644 packages/web-app/src/client/components/ui/switch.tsx create mode 100644 packages/web-app/src/client/components/ui/theme-toggle.tsx create mode 100644 packages/web-app/src/client/components/ui/tooltip.tsx create mode 100644 packages/web-app/src/client/lib/utils.ts create mode 100644 packages/web-app/src/client/version.ts create mode 100644 packages/web-app/src/server/cli/qwen-wrapper.ts create mode 100644 packages/web-app/src/server/sessionManager.ts diff --git a/.cursor/commands/dw-1-refine-prd.md b/.cursor/commands/dw-1-refine-prd.md deleted file mode 100644 index 6096b12cec..0000000000 --- a/.cursor/commands/dw-1-refine-prd.md +++ /dev/null @@ -1,174 +0,0 @@ ---- -name: /dw-1-refine-prd -id: dw-1-refine-prd -category: DW Workflow -description: Refine PRD, generate proposal and spec deltas. ---- - - - -**角色(Role)** -你是一名经验丰富的产品需求文档(PRD)分析师,擅长将复杂的多来源资料(包括业务需求文档、会议讨论记录、原型设计图、API 文档等)整理并提炼成**逻辑清晰、结构合理、无前端代码实现细节**的**面向前端研发版 PRD**。 - -你具备以下优势: - -- 对业务背景和交互逻辑有敏锐理解力 -- 具备识别和标记设计冲突、待定问题的能力 -- 能将零碎信息重组为结构化文档,方便研发快速理解 -- 能够生成符合 OpenSpec 规范的提案和 spec delta - ---- - -**Guardrails** - -- 优先采用简单直接的方案,仅在明确需要时增加复杂度 -- 保持改动范围与请求一致 -- 需要时参考 `openspec/AGENTS.md` 获取 OpenSpec 规范和约定 -- 识别模糊或歧义的细节,在编辑文件前询问后续问题 -- 在提案阶段不编写代码,仅创建设计文档 - ---- - -**目标(Objective)** -将提供的以下信息: - -- PRD 文档 -- 附图资料 -- 后端 API 文档 -- 现有 API - -整理成**面向前端研发的 PRD 文档版本**,并生成符合 OpenSpec 规范的提案,要求: - -1. 不包含前端底层实现术语(如路由路径、组件变量名等) -2. 结构分明,逻辑清晰,便于研发理解业务与交互逻辑 -3. 标注明确的「有争议」或「待定」内容,并记录来源和讨论背景 -4. 不遗漏 API 需求与交互关联问题 -5. 生成符合 OpenSpec 规范的 `proposal.md` 和 spec delta - ---- - -**背景(Context)** -该文档将在研发评审阶段直接使用,能否完整准确反映业务和交互需求,将影响研发效率、避免返工,以及减少跨团队沟通成本。 -记录并标注不确定或待确认事项,对推动设计与开发流程顺畅至关重要。 - ---- - -**执行规则(Instructions)** - -**PRD 优化规则:** - -1. 严格保留所有有效信息,禁止编造或随意修改事实 -2. 缺失或模糊的地方用 `(!!待确认)` 标注,并且在「待定与存疑点」板块记录 -3. 发现 PRD、附图、API 文档冲突时,需在文档中标明并记录来源 -4. 对 API 数据与交互的匹配问题进行检查,发现不足需提出建议 -5. 输出文档建议包含以下四个部分(可按实际延展): - - **需求背景与目标** - - **功能结构与范围**(可用树状结构示例) - - **功能 & 交互设计说明** - (保证清晰易读,但!!一定保证不能丢失原 prd 中有的信息) - (每个模块,需要用到哪一个接口 or 缺少接口信息,简单提一下。) - (如果有待定和存疑点,输出结果用粗体 "(!!有争议)" 标注提示出来) - (如果给定文案,要完整在输出中体现) - - **待定与存疑点(For Discussion)** -6. 在描述中,不能出现纯前端实现层面的技术细节,但需要对业务/交互描述具体到研发能直接理解需求的程度 - -**OpenSpec 提案规则:** - -1. 先运行 `openspec list` 和 `openspec list --specs` 了解当前状态 -2. 选择唯一的 verb-led `change-id`(如 `add-`、`update-`、`remove-`、`refactor-`) -3. 创建 `proposal.md`,包含 Why、What Changes、Impact 三个部分 -4. 按需在 `specs//spec.md` 下创建 spec delta 文件: - - 使用 `## ADDED|MODIFIED|REMOVED Requirements` 格式 - - 每个 Requirement 必须包含至少一个 `#### Scenario:` -5. 最后运行 `openspec validate --strict` 验证 - ---- - -**Tree of Thoughts(思维树)方法执行流程** - -1. **信息理解阶段** - - 阅读 PRD、讨论记录、附图、API 文档 - - 提取关键信息,包括业务目标、功能点、交互逻辑、接口数据关系 - -2. **信息整合阶段** - - 将相关内容进行对齐整合,建立模块与功能的整体树状结构 - -3. **冲突与风险识别阶段** - - 对比不同资料间的描述,查找可能的矛盾、遗漏或待确认事项 - -4. **优先级评估阶段** - - 将冲突/待定问题按影响程度分类,并补充来源与讨论历史 - -5. **输出整理阶段** - - 按以下结构输出面向前端研发的 PRD,包括背景、功能结构、交互说明、存疑列表 - - 生成符合 OpenSpec 规范的 proposal.md 和 spec delta - ---- - -**输出结构参考示例** - -``` -# 1. 需求背景与目标 -- 背景: -- 目标: - -# 2. 功能结构与范围(示例) -首页 - ├── 资讯列表 - │ ├── 分类切换(TAB) - │ ├── 文章卡片展示 - │ └── 置顶推荐区 - ├── 搜索功能 - ├── 搜索输入与建议 - ├── 搜索结果展示 - └── 搜索历史管理 - -# 3. 功能设计 & 交互设计 - -# 4. 待定与存疑点(For Discussion) -1.【功能/模块名称】问题描述 -来源: -讨论点: -备注: -2.【...】 -``` - ---- - -**输入资料** - -- PRD 文档及讨论记录:读取 `context/prd.md` 文档(如果存在) -- 附图资料:读取 `context/` 下的所有图片!!!一定要看文件夹中的图片!! -- 后端 API 文档:读取 `context/api.md` -- 前端代码中现有后端 API:读取 `/src` 中跟后端相关的代码 -- 用户对话内容:如果用户在对话框中直接描述了需求 - -**自动初始化规则** -如果用户未执行 `dwspec dw new ` 初始化(即 `openspec/changes//` 目录不存在): - -1. 当用户在对话中直接描述需求内容时,从指令参数或对话中提取 change-id -2. 自动创建 `openspec/changes//context/` 目录结构 -3. 将用户提供的需求描述及相关上下文信息保存到 `context/`中 -4. 继续执行标准 PRD 优化流程 - -注意:change-id 必须符合 kebab-case 格式(如 `add-user-profile`、`fix-login-bug`) - ---- - -**注意事项(Notes)** - -- 不得添加事实中不存在的信息 -- 必须明确标记冲突与待定事项,并保留完整来源信息 -- 确保生成的 PRD 对前端研发来说足够业务清晰、交互明确 - -**输出** - -- `1_refined_prd.dev.md` - 详尽版 PRD,供开发者使用 -- `proposal.md` - 精简版提案,符合 OpenSpec 格式(包含 Why、What Changes、Impact) -- `specs//spec.md` - Spec Delta 文件(如适用) - -**Reference** - -- 使用 `openspec show --json --deltas-only` 或 `openspec show --type spec` 检查详情 -- 使用 `rg -n "Requirement:|Scenario:" openspec/specs` 搜索现有需求 - diff --git a/.cursor/commands/dw-2-design.md b/.cursor/commands/dw-2-design.md deleted file mode 100644 index 531428e08e..0000000000 --- a/.cursor/commands/dw-2-design.md +++ /dev/null @@ -1,114 +0,0 @@ ---- -name: /dw-2-design -id: dw-2-design -category: DW Workflow -description: Generate technical design and spec deltas. ---- - - - -**角色(Role)** -你是一名经验丰富的技术架构师和前端开发专家,精通代码设计和工程实践。你擅长将产品需求转化为清晰的技术设计文档,帮助 AI 和开发人员在现有代码库中高效、准确地实施功能。 - -你具备以下优势: - -- 深刻理解前端架构和代码组织方式 -- 能够分析现有代码库,识别可复用的模块和组件 -- 擅长编写清晰的技术设计,明确指出代码改动位置和参考范例 -- 对组件库使用有深入理解,能够提供详细的使用指导 - ---- - -**目标(Objective)** -根据提供的以下资料: - -- 优化后的 PRD -- 现有代码库 -- 后端 API 文档 -- 补充信息 - -生成一份**代码层面的技术设计文档**,用于指导 AI 在代码中进行施工。 - -设计文档要求: - -1. 明确每个功能模块需要修改或新增的文件 -2. 说明每个改动参考了哪部分代码,复用哪些现有功能 -3. 对于组件库(如 DCD)的使用,提供清晰的说明和代码参考范例 -4. 保持克制,不要过度提取常量到 const 文件夹中 -5. 结构清晰,便于 AI 理解和执行 - ---- - -**背景(Context)** -这份设计文档将直接用于 AI 辅助开发,文档的清晰度和准确度将直接影响代码生成的质量。需要在保持灵活性的同时,提供足够的细节和上下文。 - ---- - -**执行规则(Instructions)** - -1. **代码分析优先**:首先分析现有代码库结构,识别可复用的模块、组件和工具函数 -2. **引用具体代码**:对于每个改动,明确指出参考的文件路径和代码片段 -3. **组件库说明**:由于 AI 不认识特定组件库(如 DCD),涉及组件库时要: - - 详细说明组件的用途和特性 - - 提供参考代码示例 - - 指出项目中已有的类似用法 -4. **保持克制**:不要过度设计,避免不必要的抽象和常量提取 -5. **输出文档应包含以下部分**: - - **设计概述**:整体技术方案和架构调整 - - **文件改动清单**:列出需要新增、修改的文件 - - **详细设计**:每个模块的具体实现方案 - - **代码参考**:指出可复用的代码和参考范例 - - **技术细节**:状态管理、数据流、API 调用等 - - **注意事项**:潜在风险和需要特别关注的点 - ---- - -**Tree of Thoughts(思维树)方法执行流程** - -1. **代码库理解阶段** - - 分析现有代码库结构 - - 识别关键模块、组件和工具函数 - - 理解现有的架构模式和代码风格 - -2. **需求映射阶段** - - 将 PRD 中的功能需求映射到代码层面 - - 识别需要新增和修改的部分 - -3. **设计方案制定阶段** - - 制定技术实现方案 - - 确定文件改动清单 - - 规划代码复用策略 - -4. **细节补充阶段** - - 补充具体的实现细节 - - 提供代码参考和示例 - - 标注特殊处理和注意事项 - -5. **输出整理阶段** - - 按照结构化格式输出设计文档 - - 确保文档清晰、完整、可执行 - ---- - -**输入资料** - -- 现有代码库:去 `src` 中 grep 有用的信息 -- 后端 API 文档:读取 `context/api.md` 文档 -- 优化后的 PRD:读取 `1_refined_prd.dev.md` 文档 -- 补充信息:读取 `context/` 下的补充信息 - ---- - -**注意事项(Notes)** - -- 设计要基于现有代码库,不要凭空设计 -- 每个改动都要说明参考了哪部分代码,复用哪些功能 -- 对于不熟悉的组件库,必须提供详细说明和参考范例 -- 保持设计的实用性,避免过度设计 -- 确保 AI 能够根据此设计文档准确地进行代码实施 - -**输出** - -- `2_technical_design.dev.md` - 详尽技术设计 -- `specs//spec.md` - 正式的 Spec Delta 文件(如适用) - diff --git a/.cursor/commands/dw-3-task.md b/.cursor/commands/dw-3-task.md deleted file mode 100644 index 439416c307..0000000000 --- a/.cursor/commands/dw-3-task.md +++ /dev/null @@ -1,83 +0,0 @@ ---- -name: /dw-3-task -id: dw-3-task -category: DW Workflow -description: Generate development tasks and checklist. ---- - - - -**角色(Role)** -你是一名经验丰富的项目管理专家和技术 Leader,擅长将技术设计文档拆解为可分步执行的开发任务。你能够合理评估任务的优先级、依赖关系和工作量,帮助团队高效地推进项目开发。 - -你具备以下优势: - -- 深刻理解软件开发流程和最佳实践 -- 能够识别任务之间的依赖关系和执行顺序 -- 擅长将复杂的技术方案拆解为独立、可并行的任务 -- 能够合理评估任务的复杂度和工作量 - ---- - -**目标(Objective)** -根据提供的以下资料: - -- 优化后的 PRD -- 技术设计文档 -- 现有代码库 - -生成一份**可分步执行的任务拆解文档**。 - ---- - -**Tree of Thoughts(思维树)方法执行流程** - -1. **需求理解阶段** - - 全面理解 PRD 中的业务需求 - - 掌握技术设计文档中的实现方案 - - 分析代码库的现状和改动范围 - -2. **任务识别阶段** - - 从设计文档中提取出独立的功能模块 - - 识别基础任务、核心任务和优化任务 - - 考虑测试、文档等辅助任务 - -3. **依赖分析阶段** - - 分析任务之间的技术依赖 - - 识别可以并行的任务 - - 确定关键路径 - -4. **优先级评估阶段** - - 基于业务价值评估任务优先级 - - 考虑技术依赖调整执行顺序 - - 识别快速见效的任务(quick wins) - -5. **输出整理阶段** - - 按照结构化格式输出任务拆解文档 - - 提供清晰的执行建议 - - 标注风险和注意事项 - ---- - -**输入资料** - -- 优化后的 PRD:读取 `1_refined_prd.dev.md` 文档 -- 技术设计文档:读取 `2_technical_design.dev.md` 文档 -- 现有代码库:在 `src` 中查找需要的信息 - ---- - -**注意事项(Notes)** - -- 任务拆解要基于实际的技术设计文档 -- 合理评估任务的依赖关系,避免阻塞 -- 优先级要综合考虑业务价值和技术依赖 -- 每个任务都要有清晰的验收标准 -- 提供合理的工时评估,但不要过于精确 -- 标注潜在的风险和注意事项 - -**输出** - -- `3_dev_plan.dev.md` - 详尽开发计划 -- `tasks.md` - 精简任务清单,供工具消费 - diff --git a/.cursor/commands/dw-4-code.md b/.cursor/commands/dw-4-code.md deleted file mode 100644 index 479e6a64e1..0000000000 --- a/.cursor/commands/dw-4-code.md +++ /dev/null @@ -1,67 +0,0 @@ ---- -name: /dw-4-code -id: dw-4-code -category: DW Workflow -description: Implement the change and apply OpenSpec workflow. ---- - - - -**角色(Role)** -你是一名经验丰富的前端开发工程师,精通代码实现和工程实践。 - ---- - -**Guardrails** - -- 优先采用简单直接的实现,仅在明确需要时增加复杂度 -- 保持改动范围与请求一致 -- 需要时参考 `openspec/AGENTS.md` 获取 OpenSpec 规范和约定 - ---- - -**目标(Objective)** -根据所有设计文档完成代码编写,并遵循 OpenSpec Apply 工作流。 - ---- - -**输入资料** - -- 后端 API 文档:读取 `context/api.md` 文档 -- 需求文档:读取 `1_refined_prd.dev.md` 文档 -- 设计文档:读取 `2_technical_design.dev.md` 文档 -- 任务分解:读取 `3_dev_plan.dev.md` 文档 -- 提案文档:读取 `proposal.md` 确认范围 -- 详细设计:读取 `design.md`(如存在) - ---- - -**执行规则(Instructions)** - -Track these steps as TODOs and complete them one by one. - -1. **确认范围**:先读取 `proposal.md`、`design.md`(如存在)和 `tasks.md`,确认范围和验收标准 -2. **顺序执行**:按照 `tasks.md` 中的任务顺序执行 -3. **按设计实现**:按照技术设计文档逐步实现 -4. **保持一致**:保持代码风格一致,遵循现有架构模式 -5. **保持聚焦**:保持编辑最小化,聚焦于请求的变更 -6. **确认完成**:**确认所有任务完成后**再更新状态——确保 `tasks.md` 中的每个任务都已完成 -7. **更新状态**:所有工作完成后,将每个任务标记为 `- [x]`,确保反映实际情况 -8. **测试验证**:测试功能是否正常 - ---- - -**注意事项(Notes)** - -- 不要在任务未完成时就标记为完成 -- 如果发现设计文档有问题,先停下来讨论 -- 保持提交粒度合理,每个任务对应一个逻辑完整的变更 - -**输出** -修改 `src/` 下的源代码,完成功能实现 - -**Reference** - -- 使用 `openspec show --json --deltas-only` 获取 proposal 的额外上下文 -- 使用 `openspec list` 或 `openspec show ` 获取更多信息 - diff --git a/.cursor/commands/dw-5-revise.md b/.cursor/commands/dw-5-revise.md deleted file mode 100644 index 5fb5675959..0000000000 --- a/.cursor/commands/dw-5-revise.md +++ /dev/null @@ -1,147 +0,0 @@ ---- -name: /dw-5-revise -id: dw-5-revise -category: DW Workflow -description: Revise and update existing change documents. ---- - - - -**角色(Role)** -你是一名经验丰富的增量修改专家,擅长分析现有文档并识别变更影响。你能够根据用户的修改需求,精准定位需要更新的文档和代码,并保持所有文档的一致性。 - -你具备以下优势: - -- 能够快速理解现有文档的结构和内容 -- 擅长分析变更对各层文档的影响 -- 能够保持修改后文档的内部一致性 -- 熟悉 OpenSpec 规范和 DW 工作流 - ---- - -**Guardrails** - -- 只更新与用户需求直接相关的部分,避免过度修改 -- 保持与现有文档风格一致 -- 需要时参考 `openspec/AGENTS.md` 获取 OpenSpec 规范和约定 -- 如果变更影响范围不明确,先询问用户确认 - ---- - -**目标(Objective)** -根据用户描述的修改需求,分析所有中间文档,识别需要变更的部分,并应用变更。 - ---- - -**背景(Context)** -用户已经完成了一轮 DW 工作流(dw-1 到 dw-4),生成了多份中间文档。现在需要对现有 change 进行增量修改,可能的场景包括: - -- 需求变更:产品经理修改了部分需求 -- 发现问题:在实现过程中发现设计有问题 -- 补充功能:需要添加新的小功能 -- 修复遗漏:发现某些场景没有覆盖到 - ---- - -**执行规则(Instructions)** - -Track these steps as TODOs and complete them one by one. - -**阶段 1:加载上下文** - -读取以下文档(如存在): - -- `proposal.md` - 了解原始变更范围 -- `1_refined_prd.dev.md` - 当前需求 -- `2_technical_design.dev.md` - 技术方案 -- `3_dev_plan.dev.md` - 开发计划 -- `tasks.md` - 任务状态 -- `context/` - 原始资料(如 prd.md、api.md) -- `src/` 中相关的代码文件 - -**阶段 2:分析变更影响** - -根据用户描述的修改需求: - -1. 识别哪些文档需要更新 -2. 评估变更对现有设计的影响 -3. 列出需要修改的文档清单和变更点 - -输出变更分析: - -``` -## 变更分析 - -### 影响的文档 -- [ ] 1_refined_prd.dev.md - [说明需要变更的内容] -- [ ] 2_technical_design.dev.md - [说明需要变更的内容] -- [ ] 3_dev_plan.dev.md - [说明需要变更的内容] -- [ ] tasks.md - [说明需要变更的内容] -- [ ] proposal.md - [追加修订记录] -- [ ] src/... - [说明需要变更的代码] - -### 变更范围评估 -[简要说明变更的影响范围和复杂度] -``` - -**阶段 3:应用变更** - -按需更新相关文档: - -1. 如果需求层面变更 → 更新 `1_refined_prd.dev.md` -2. 如果技术设计变更 → 更新 `2_technical_design.dev.md` -3. 如果任务变更 → 更新 `3_dev_plan.dev.md` 和 `tasks.md` -4. 如果涉及代码 → 更新 `src/` 中的代码文件 -5. 如果涉及 spec delta → 更新 `specs//spec.md` -6. 在 `proposal.md` 末尾追加修订记录 - -**阶段 4:验证完成** - -1. 如果更新了 spec delta,运行 `openspec validate --strict` 验证 -2. 确保所有更新的文档保持一致性 -3. 总结本次修改的内容 - ---- - -**修订记录格式** - -在 `proposal.md` 末尾追加: - -```markdown -## Revisions - -### [YYYY-MM-DD] 修订说明 - -- **变更原因**:[用户描述的原因] -- **影响范围**:[列出修改的文档/代码] -- **主要变更**: - - [变更点 1] - - [变更点 2] -``` - ---- - -**输入资料** - -- 现有中间文档:读取 change 目录下的所有 `.dev.md` 和 `.md` 文件 -- 用户修改需求:用户在对话框中描述的变更内容 -- 相关代码:读取 `src/` 中相关的代码文件 - ---- - -**注意事项(Notes)** - -- 如果 change 目录不存在或缺少 `1_refined_prd.dev.md`,提示用户先运行 dw-1 到 dw-4 -- 保持与现有文档风格一致 -- 避免过度修改,只更新与用户需求直接相关的部分 -- 确保更新后的文档保持内部一致性 -- 如果变更复杂,可以分阶段进行 - -**输出** -更新相关的中间文档和/或代码文件 - -**Reference** - -- 使用 `openspec show --json --deltas-only` 获取 proposal 的额外上下文 -- 使用 `openspec list` 或 `openspec show ` 获取更多信息 - diff --git a/.cursor/commands/dw-6-archive.md b/.cursor/commands/dw-6-archive.md deleted file mode 100644 index b6b860ddbe..0000000000 --- a/.cursor/commands/dw-6-archive.md +++ /dev/null @@ -1,80 +0,0 @@ ---- -name: /dw-6-archive -id: dw-6-archive -category: DW Workflow -description: Smart archive with auto spec skeleton creation. ---- - - - -**角色(Role)** -你是一名 OpenSpec 归档专家,负责执行智能归档流程。你能够检测缺失的 spec 文件,自动创建骨架文件,并完成标准归档流程。 - ---- - -**Guardrails** - -- 优先执行标准 archive 流程,仅在必要时创建骨架文件 -- 保持改动范围最小化 -- 需要时参考 `openspec/AGENTS.md` 获取 OpenSpec 规范和约定 - ---- - -**目标(Objective)** -智能归档指定的 change,处理缺失 spec 文件的场景。 - ---- - -**执行规则(Instructions)** - -Track these steps as TODOs and complete them one by one. - -1. **获取变更信息**:运行 `openspec show --json --deltas-only` 获取变更的 delta 信息 - -2. **分析 delta**:检查每个受影响的 capability: - - 检查 `openspec/specs//spec.md` 是否存在 - - 检查 delta 是否包含 MODIFIED、REMOVED 或 RENAMED 操作 - -3. **处理缺失 spec**:对于每个缺失 spec 且包含非 ADDED 操作的 capability: - - 向用户说明将创建骨架文件 - - 创建 `openspec/specs//spec.md` 骨架文件,格式如下: - -```markdown -# Specification - -## Purpose - -TBD - created by archiving change . Update Purpose after archive. - -## Requirements -``` - -4. **执行归档**:运行 `openspec archive --yes` - -5. **汇报结果**: - - 如果创建了骨架文件,列出创建的文件 - - 报告归档结果 - ---- - -**输入资料** - -- 变更 ID:从用户指令参数或对话中获取 -- 可选选项:`--skip-specs`、`--no-validate` 等 - ---- - -**注意事项(Notes)** - -- 骨架文件格式必须与 `archive.ts` 中的 `buildSpecSkeleton` 方法一致 -- 如果 spec 已存在,不要修改它,直接进行标准归档 -- 归档完成后,提醒用户更新骨架文件的 Purpose 部分 - -**输出** -执行 `openspec archive` 命令,完成归档流程 - -**Reference** - -- 使用 `openspec show --json --deltas-only` 获取 delta 信息 -- 使用 `openspec archive --yes` 执行归档 - diff --git a/.cursor/commands/openspec-apply.md b/.cursor/commands/openspec-apply.md deleted file mode 100644 index f6b81e83b0..0000000000 --- a/.cursor/commands/openspec-apply.md +++ /dev/null @@ -1,28 +0,0 @@ ---- -name: /openspec-apply -id: openspec-apply -category: OpenSpec -description: Implement an approved OpenSpec change and keep tasks in sync. ---- - - - -**Guardrails** - -- Favor straightforward, minimal implementations first and add complexity only when it is requested or clearly required. -- Keep changes tightly scoped to the requested outcome. -- Refer to `openspec/AGENTS.md` (located inside the `openspec/` directory—run `ls openspec` or `openspec update` if you don't see it) if you need additional OpenSpec conventions or clarifications. - -**Steps** -Track these steps as TODOs and complete them one by one. - -1. Read `changes//proposal.md`, `design.md` (if present), and `tasks.md` to confirm scope and acceptance criteria. -2. Work through tasks sequentially, keeping edits minimal and focused on the requested change. -3. Confirm completion before updating statuses—make sure every item in `tasks.md` is finished. -4. Update the checklist after all work is done so each task is marked `- [x]` and reflects reality. -5. Reference `openspec list` or `openspec show ` when additional context is required. - -**Reference** - -- Use `openspec show --json --deltas-only` if you need additional context from the proposal while implementing. - diff --git a/.cursor/commands/openspec-archive.md b/.cursor/commands/openspec-archive.md deleted file mode 100644 index c024642086..0000000000 --- a/.cursor/commands/openspec-archive.md +++ /dev/null @@ -1,32 +0,0 @@ ---- -name: /openspec-archive -id: openspec-archive -category: OpenSpec -description: Archive a deployed OpenSpec change and update specs. ---- - - - -**Guardrails** - -- Favor straightforward, minimal implementations first and add complexity only when it is requested or clearly required. -- Keep changes tightly scoped to the requested outcome. -- Refer to `openspec/AGENTS.md` (located inside the `openspec/` directory—run `ls openspec` or `openspec update` if you don't see it) if you need additional OpenSpec conventions or clarifications. - -**Steps** - -1. Determine the change ID to archive: - - If this prompt already includes a specific change ID (for example inside a `` block populated by slash-command arguments), use that value after trimming whitespace. - - If the conversation references a change loosely (for example by title or summary), run `openspec list` to surface likely IDs, share the relevant candidates, and confirm which one the user intends. - - Otherwise, review the conversation, run `openspec list`, and ask the user which change to archive; wait for a confirmed change ID before proceeding. - - If you still cannot identify a single change ID, stop and tell the user you cannot archive anything yet. -2. Validate the change ID by running `openspec list` (or `openspec show `) and stop if the change is missing, already archived, or otherwise not ready to archive. -3. Run `openspec archive --yes` so the CLI moves the change and applies spec updates without prompts (use `--skip-specs` only for tooling-only work). -4. Review the command output to confirm the target specs were updated and the change landed in `changes/archive/`. -5. Validate with `openspec validate --strict` and inspect with `openspec show ` if anything looks off. - -**Reference** - -- Use `openspec list` to confirm change IDs before archiving. -- Inspect refreshed specs with `openspec list --specs` and address any validation issues before handing off. - diff --git a/.cursor/commands/openspec-proposal.md b/.cursor/commands/openspec-proposal.md deleted file mode 100644 index 95520d9ddd..0000000000 --- a/.cursor/commands/openspec-proposal.md +++ /dev/null @@ -1,33 +0,0 @@ ---- -name: /openspec-proposal -id: openspec-proposal -category: OpenSpec -description: Scaffold a new OpenSpec change and validate strictly. ---- - - - -**Guardrails** - -- Favor straightforward, minimal implementations first and add complexity only when it is requested or clearly required. -- Keep changes tightly scoped to the requested outcome. -- Refer to `openspec/AGENTS.md` (located inside the `openspec/` directory—run `ls openspec` or `openspec update` if you don't see it) if you need additional OpenSpec conventions or clarifications. -- Identify any vague or ambiguous details and ask the necessary follow-up questions before editing files. -- Do not write any code during the proposal stage. Only create design documents (proposal.md, tasks.md, design.md, and spec deltas). Implementation happens in the apply stage after approval. - -**Steps** - -1. Review `openspec/project.md`, run `openspec list` and `openspec list --specs`, and inspect related code or docs (e.g., via `rg`/`ls`) to ground the proposal in current behaviour; note any gaps that require clarification. -2. Choose a unique verb-led `change-id` and scaffold `proposal.md`, `tasks.md`, and `design.md` (when needed) under `openspec/changes//`. -3. Map the change into concrete capabilities or requirements, breaking multi-scope efforts into distinct spec deltas with clear relationships and sequencing. -4. Capture architectural reasoning in `design.md` when the solution spans multiple systems, introduces new patterns, or demands trade-off discussion before committing to specs. -5. Draft spec deltas in `changes//specs//spec.md` (one folder per capability) using `## ADDED|MODIFIED|REMOVED Requirements` with at least one `#### Scenario:` per requirement and cross-reference related capabilities when relevant. -6. Draft `tasks.md` as an ordered list of small, verifiable work items that deliver user-visible progress, include validation (tests, tooling), and highlight dependencies or parallelizable work. -7. Validate with `openspec validate --strict` and resolve every issue before sharing the proposal. - -**Reference** - -- Use `openspec show --json --deltas-only` or `openspec show --type spec` to inspect details when validation fails. -- Search existing requirements with `rg -n "Requirement:|Scenario:" openspec/specs` before writing new ones. -- Explore the codebase with `rg `, `ls`, or direct file reads so proposals align with current implementation realities. - diff --git a/.cursor/plans/gui_for_qwen_code_cli_4c6156e2.plan.md b/.cursor/plans/gui_for_qwen_code_cli_4c6156e2.plan.md deleted file mode 100644 index 8ca4fc76db..0000000000 --- a/.cursor/plans/gui_for_qwen_code_cli_4c6156e2.plan.md +++ /dev/null @@ -1,194 +0,0 @@ ---- -name: GUI for Qwen Code CLI -overview: 为 Qwen Code CLI 添加 Web GUI 功能,通过 `/web` 命令启动本地 Web 服务器,提供完整的聊天界面、会话管理、设置等功能。采用子进程模式与 CLI 通信,使用 Fastify 作为后端框架,复用现有 webui 组件库。 -todos: - - id: cli-wire-mode - content: 为 CLI 添加 --wire-mode 参数,实现 JSON-RPC stdin/stdout 通信模式 - status: pending - - id: web-package-setup - content: 创建 packages/web 包,配置 package.json、tsconfig、vite 等 - status: pending - - id: fastify-server - content: 实现 Fastify 后端服务器(REST API + WebSocket) - status: pending - - id: process-manager - content: 实现 CLI 进程管理器(启动/停止/通信) - status: pending - - id: frontend-app - content: 实现 React 前端应用,复用 webui 组件 - status: pending - - id: web-command - content: 在 CLI 中添加 /web 命令启动 Web 服务器 - status: pending - - id: integration-test - content: 端到端测试:/web 命令 -> 浏览器交互 -> 对话完成 - status: pending -isProject: false ---- - -# GUI for Qwen Code CLI 实现计划 - -## 架构设计 - -```mermaid -graph TB - subgraph Browser[浏览器] - WebApp[React Web App] - WSClient[WebSocket Client] - end - - subgraph WebServer[packages/web - Fastify Server] - Fastify[Fastify App] - SessionAPI[Session API] - ConfigAPI[Config API] - WSHandler[WebSocket Handler] - ProcessManager[CLI Process Manager] - end - - subgraph CLIProcess[CLI 子进程] - CLI[qwen --wire-mode] - end - - subgraph Existing[现有模块] - Core[packages/core] - WebUI[packages/webui] - CLIPkg[packages/cli] - end - - WebApp -->|复用| WebUI - WebApp <--> WSClient - WSClient <-->|WebSocket| WSHandler - Fastify --> SessionAPI - Fastify --> ConfigAPI - SessionAPI -->|调用| Core - ConfigAPI -->|调用| Core - WSHandler --> ProcessManager - ProcessManager <-->|stdin/stdout JSON-RPC| CLI - CLI -->|使用| Core -``` - -## 目录结构 - -``` -packages/web/ -├── package.json -├── tsconfig.json -├── vite.config.ts # 前端构建配置 -├── index.html # 前端入口 -├── src/ -│ ├── server/ # 后端代码 -│ │ ├── index.ts # 服务器入口 -│ │ ├── app.ts # Fastify 应用 -│ │ ├── routes/ -│ │ │ ├── sessions.ts # Session CRUD API -│ │ │ ├── config.ts # 配置 API -│ │ │ └── health.ts # 健康检查 -│ │ ├── websocket/ -│ │ │ ├── handler.ts # WebSocket 连接处理 -│ │ │ └── protocol.ts # JSON-RPC 协议定义 -│ │ └── process/ -│ │ ├── manager.ts # CLI 进程管理器 -│ │ └── session.ts # 单个会话进程 -│ └── client/ # 前端代码 -│ ├── main.tsx # React 入口 -│ ├── App.tsx # 主应用组件 -│ ├── hooks/ -│ │ ├── useWebSocket.ts -│ │ └── useSession.ts -│ ├── pages/ -│ │ └── Chat.tsx # 主聊天页面 -│ └── store/ # 状态管理 -│ └── chatStore.ts -``` - -## 关键实现细节 - -### 1. CLI Wire Mode(需新增) - -在 `packages/cli` 中添加 `--wire-mode` 参数,使 CLI 以 JSON-RPC 模式运行: - -- 从 stdin 读取 JSON-RPC 请求 -- 向 stdout 输出 JSON-RPC 响应/事件 -- 参考现有 [acp.ts](packages/cli/src/acp-integration/acp.ts) 中的 JSON-RPC 实现 - -### 2. 后端服务器 (Fastify) - -关键依赖: - -- `fastify` + `@fastify/websocket` + `@fastify/static` + `@fastify/cors` -- 复用 `@qwen-code/qwen-code-core` 的 `SessionService` - -REST API 端点: - -- `GET /api/sessions` - 列出会话(分页) -- `POST /api/sessions` - 创建新会话 -- `GET /api/sessions/:id` - 获取会话详情 -- `DELETE /api/sessions/:id` - 删除会话 -- `GET /api/config` - 获取配置 -- `PUT /api/config` - 更新配置 - -WebSocket 端点: - -- `/ws/sessions/:id` - 会话实时通信 - -### 3. CLI 进程管理器 - -参考 kimi 的 `KimiCLIRunner` 设计: - -- 每个活跃会话对应一个 CLI 子进程 -- 通过 stdin/stdout 进行 JSON-RPC 通信 -- 支持多 WebSocket 客户端连接同一会话 -- 进程空闲超时自动关闭 - -### 4. 前端应用 - -复用现有 webui 组件: - -- [InputForm](packages/webui/src/components/layout/InputForm.tsx) - 输入框 -- [SessionSelector](packages/webui/src/components/layout/SessionSelector.tsx) - 会话选择 -- [ChatViewer](packages/webui/src/components/ChatViewer/ChatViewer.tsx) - 消息展示 -- [ChatHeader](packages/webui/src/components/layout/ChatHeader.tsx) - 头部导航 - -### 5. `/web` 命令 - -在 [BuiltinCommandLoader.ts](packages/cli/src/services/BuiltinCommandLoader.ts) 中添加 `webCommand`: - -- 启动 Fastify 服务器 -- 查找可用端口(默认 5173) -- 自动打开浏览器 - -## 通信协议(JSON-RPC 2.0) - -```typescript -// 客户端 -> 服务器 -interface PromptRequest { - jsonrpc: '2.0'; - id: string; - method: 'prompt'; - params: { - content: string; - attachments?: Array<{ type: string; data: string }>; - }; -} - -// 服务器 -> 客户端(事件流) -interface StreamEvent { - jsonrpc: '2.0'; - method: 'stream'; - params: { - type: 'text' | 'tool_call' | 'thinking' | 'done' | 'error'; - data: unknown; - }; -} -``` - -## 依赖关系 - -``` -packages/web -├── @qwen-code/qwen-code-core (Session, Config) -├── @qwen-code/webui (UI 组件) -├── fastify ecosystem (后端) -├── react + vite (前端构建) -└── packages/cli (spawn 子进程) -``` diff --git a/.cursor/plans/web_gui_for_cli_d1dcfda7.plan.md b/.cursor/plans/web_gui_for_cli_d1dcfda7.plan.md deleted file mode 100644 index 22e26bf1d8..0000000000 --- a/.cursor/plans/web_gui_for_cli_d1dcfda7.plan.md +++ /dev/null @@ -1,181 +0,0 @@ ---- -name: Web GUI for CLI -overview: 为 Qwen Code CLI 添加 Web GUI 功能,通过 `/web` 命令启动本地 HTTP 服务器,提供基于 React 的浏览器交互界面,复用现有 webui 组件库和 session 存储机制。 -todos: - - id: setup-package - content: 创建 packages/web-app 包结构和构建配置 - status: pending - - id: implement-server - content: 实现 Express HTTP 服务器 + WebSocket 服务 - status: pending - - id: implement-api - content: 实现 Session 管理 REST API 路由 - status: pending - - id: implement-ws-handler - content: 实现 WebSocket 消息处理和 AI 响应流 - status: pending - - id: implement-frontend - content: 实现 React 前端应用 (Sidebar + ChatArea + Settings) - status: pending - - id: add-web-command - content: 在 CLI 中添加 /web 命令 - status: pending - - id: integrate-core - content: 集成 Core 包的 Session 和 Model 功能 - status: pending - - id: add-tests - content: 添加单元测试和集成测试 - status: pending -isProject: false ---- - -# Web GUI for Qwen Code CLI - -## 架构概述 - -```mermaid -graph TB - subgraph Browser["Browser"] - ReactApp["React App
(packages/web-app/client)"] - end - - subgraph WebServer["Web Server (packages/web-app/server)"] - Express["Express/Fastify"] - WSHandler["WebSocket Handler"] - APIRoutes["REST API Routes"] - end - - subgraph CLI["CLI (packages/cli)"] - WebCmd["/web Command"] - end - - subgraph Core["Core (packages/core)"] - SessionMgr["Session Manager"] - ModelClient["Model Client"] - Tools["Tool Execution"] - end - - subgraph Storage["Storage"] - Sessions["~/.qwen/sessions"] - end - - ReactApp -->|"HTTP/WS"| Express - Express --> APIRoutes - Express --> WSHandler - WebCmd -->|"启动"| Express - APIRoutes --> SessionMgr - WSHandler --> ModelClient - WSHandler --> Tools - SessionMgr --> Sessions -``` - -## 技术方案 - -### 1. 包结构 - -- 新建 `packages/web-app`,包含 server 和 client 两部分 -- 复用 `@qwen-code/webui` 组件库 -- 复用 `@qwen-code/qwen-code-core` 的 Session 和 Model 功能 - -### 2. 后端技术栈 - -- **HTTP 服务**: Express.js (轻量、生态成熟) -- **WebSocket**: `ws` 库 (与 core 包一致) -- **静态文件**: 内嵌 Vite 构建产物 - -### 3. 前端技术栈 - -- **框架**: React 18+ (与 webui 一致) -- **构建**: Vite -- **样式**: Tailwind CSS -- **组件**: 复用 `@qwen-code/webui` 导出的组件 - -### 4. 关键文件 - -**CLI 命令** - 新增 `/web` 命令: - -```typescript -// packages/cli/src/ui/commands/webCommand.ts -export const webCommand: SlashCommand = { - name: 'web', - kind: CommandKind.BUILT_IN, - description: 'Start Web GUI server', - action: async (context, args) => { - // 解析参数 --port, --host, --no-open - // 启动 web-app 服务器 - // 自动打开浏览器 - }, -}; -``` - -**API 路由** - Session 管理: - -```typescript -// packages/web-app/src/server/routes/sessions.ts -router.get('/api/sessions', async (req, res) => { - /* 列表 */ -}); -router.post('/api/sessions', async (req, res) => { - /* 创建 */ -}); -router.get('/api/sessions/:id', async (req, res) => { - /* 详情 */ -}); -``` - -**WebSocket 协议**: - -```typescript -// 消息类型 -type WSMessage = - | { type: 'user_message'; content: string } - | { type: 'assistant_message'; content: string; streaming: boolean } - | { type: 'tool_call'; name: string; args: unknown; status: string } - | { type: 'permission_request'; operation: string } - | { type: 'permission_response'; allow: boolean; scope: string } - | { type: 'cancel' }; -``` - -**前端应用**: - -```typescript -// packages/web-app/src/client/App.tsx -function App() { - return ( -
- -
- - - -
-
- ); -} -``` - -## 复用的现有组件 - -从 `@qwen-code/webui` 复用: - -- `ChatViewer` - 聊天消息展示 -- `SessionSelector` - Session 选择器 (适配为 Sidebar) -- `InputForm` - 输入表单 -- `ContextIndicator` - 上下文指示器 -- `*ToolCall` 组件族 - 工具调用展示 -- `PermissionDrawer` - 权限审批 -- 全套 Icons - -## 待确认问题 - -1. **Session 状态同步**: 多客户端同时操作同一 Session 如何处理? -2. **文件上传**: Web 端是否支持直接上传文件作为上下文? -3. **安全性**: `--host 0.0.0.0` 时是否需要认证? -4. **工作目录**: Web 端创建 Session 使用什么工作目录? - -## OpenSpec 文档位置 - -- PRD: [openspec/changes/add-web-gui/context/1_refined_prd.dev.md](openspec/changes/add-web-gui/context/1_refined_prd.dev.md) -- Proposal: [openspec/changes/add-web-gui/proposal.md](openspec/changes/add-web-gui/proposal.md) -- Tasks: [openspec/changes/add-web-gui/tasks.md](openspec/changes/add-web-gui/tasks.md) -- Spec: [openspec/changes/add-web-gui/specs/web-gui/spec.md](openspec/changes/add-web-gui/specs/web-gui/spec.md) diff --git a/.gitignore b/.gitignore index 51bb971f10..a923e9bc15 100644 --- a/.gitignore +++ b/.gitignore @@ -22,7 +22,7 @@ package-lock.json # Editors .idea *.iml -# .cursor +.cursor .qoder # OS metadata diff --git a/AGENTS.md b/AGENTS.md deleted file mode 100644 index 0669699416..0000000000 --- a/AGENTS.md +++ /dev/null @@ -1,18 +0,0 @@ - -# OpenSpec Instructions - -These instructions are for AI assistants working in this project. - -Always open `@/openspec/AGENTS.md` when the request: -- Mentions planning or proposals (words like proposal, spec, change, plan) -- Introduces new capabilities, breaking changes, architecture shifts, or big performance/security work -- Sounds ambiguous and you need the authoritative spec before coding - -Use `@/openspec/AGENTS.md` to learn: -- How to create and apply change proposals -- Spec format and conventions -- Project structure and guidelines - -Keep this managed block so 'openspec update' can refresh the instructions. - - \ No newline at end of file diff --git a/QWEN.md b/QWEN.md deleted file mode 100644 index 0669699416..0000000000 --- a/QWEN.md +++ /dev/null @@ -1,18 +0,0 @@ - -# OpenSpec Instructions - -These instructions are for AI assistants working in this project. - -Always open `@/openspec/AGENTS.md` when the request: -- Mentions planning or proposals (words like proposal, spec, change, plan) -- Introduces new capabilities, breaking changes, architecture shifts, or big performance/security work -- Sounds ambiguous and you need the authoritative spec before coding - -Use `@/openspec/AGENTS.md` to learn: -- How to create and apply change proposals -- Spec format and conventions -- Project structure and guidelines - -Keep this managed block so 'openspec update' can refresh the instructions. - - \ No newline at end of file diff --git a/eslint.config.js b/eslint.config.js index ea3158688a..bfefeb5ae0 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -122,9 +122,7 @@ export default tseslint.config( 'yargs/**', 'msw/node', '**/generated/**', - './styles/tailwind.css', - './styles/App.css', - './styles/style.css' + '**/styles/*.css' ], }, ], diff --git a/openspec/AGENTS.md b/openspec/AGENTS.md deleted file mode 100644 index 96ab0bb390..0000000000 --- a/openspec/AGENTS.md +++ /dev/null @@ -1,456 +0,0 @@ -# OpenSpec Instructions - -Instructions for AI coding assistants using OpenSpec for spec-driven development. - -## TL;DR Quick Checklist - -- Search existing work: `openspec spec list --long`, `openspec list` (use `rg` only for full-text search) -- Decide scope: new capability vs modify existing capability -- Pick a unique `change-id`: kebab-case, verb-led (`add-`, `update-`, `remove-`, `refactor-`) -- Scaffold: `proposal.md`, `tasks.md`, `design.md` (only if needed), and delta specs per affected capability -- Write deltas: use `## ADDED|MODIFIED|REMOVED|RENAMED Requirements`; include at least one `#### Scenario:` per requirement -- Validate: `openspec validate [change-id] --strict` and fix issues -- Request approval: Do not start implementation until proposal is approved - -## Three-Stage Workflow - -### Stage 1: Creating Changes -Create proposal when you need to: -- Add features or functionality -- Make breaking changes (API, schema) -- Change architecture or patterns -- Optimize performance (changes behavior) -- Update security patterns - -Triggers (examples): -- "Help me create a change proposal" -- "Help me plan a change" -- "Help me create a proposal" -- "I want to create a spec proposal" -- "I want to create a spec" - -Loose matching guidance: -- Contains one of: `proposal`, `change`, `spec` -- With one of: `create`, `plan`, `make`, `start`, `help` - -Skip proposal for: -- Bug fixes (restore intended behavior) -- Typos, formatting, comments -- Dependency updates (non-breaking) -- Configuration changes -- Tests for existing behavior - -**Workflow** -1. Review `openspec/project.md`, `openspec list`, and `openspec list --specs` to understand current context. -2. Choose a unique verb-led `change-id` and scaffold `proposal.md`, `tasks.md`, optional `design.md`, and spec deltas under `openspec/changes//`. -3. Draft spec deltas using `## ADDED|MODIFIED|REMOVED Requirements` with at least one `#### Scenario:` per requirement. -4. Run `openspec validate --strict` and resolve any issues before sharing the proposal. - -### Stage 2: Implementing Changes -Track these steps as TODOs and complete them one by one. -1. **Read proposal.md** - Understand what's being built -2. **Read design.md** (if exists) - Review technical decisions -3. **Read tasks.md** - Get implementation checklist -4. **Implement tasks sequentially** - Complete in order -5. **Confirm completion** - Ensure every item in `tasks.md` is finished before updating statuses -6. **Update checklist** - After all work is done, set every task to `- [x]` so the list reflects reality -7. **Approval gate** - Do not start implementation until the proposal is reviewed and approved - -### Stage 3: Archiving Changes -After deployment, create separate PR to: -- Move `changes/[name]/` → `changes/archive/YYYY-MM-DD-[name]/` -- Update `specs/` if capabilities changed -- Use `openspec archive --skip-specs --yes` for tooling-only changes (always pass the change ID explicitly) -- Run `openspec validate --strict` to confirm the archived change passes checks - -## Before Any Task - -**Context Checklist:** -- [ ] Read relevant specs in `specs/[capability]/spec.md` -- [ ] Check pending changes in `changes/` for conflicts -- [ ] Read `openspec/project.md` for conventions -- [ ] Run `openspec list` to see active changes -- [ ] Run `openspec list --specs` to see existing capabilities - -**Before Creating Specs:** -- Always check if capability already exists -- Prefer modifying existing specs over creating duplicates -- Use `openspec show [spec]` to review current state -- If request is ambiguous, ask 1–2 clarifying questions before scaffolding - -### Search Guidance -- Enumerate specs: `openspec spec list --long` (or `--json` for scripts) -- Enumerate changes: `openspec list` (or `openspec change list --json` - deprecated but available) -- Show details: - - Spec: `openspec show --type spec` (use `--json` for filters) - - Change: `openspec show --json --deltas-only` -- Full-text search (use ripgrep): `rg -n "Requirement:|Scenario:" openspec/specs` - -## Quick Start - -### CLI Commands - -```bash -# Essential commands -openspec list # List active changes -openspec list --specs # List specifications -openspec show [item] # Display change or spec -openspec validate [item] # Validate changes or specs -openspec archive [--yes|-y] # Archive after deployment (add --yes for non-interactive runs) - -# Project management -openspec init [path] # Initialize OpenSpec -openspec update [path] # Update instruction files - -# Interactive mode -openspec show # Prompts for selection -openspec validate # Bulk validation mode - -# Debugging -openspec show [change] --json --deltas-only -openspec validate [change] --strict -``` - -### Command Flags - -- `--json` - Machine-readable output -- `--type change|spec` - Disambiguate items -- `--strict` - Comprehensive validation -- `--no-interactive` - Disable prompts -- `--skip-specs` - Archive without spec updates -- `--yes`/`-y` - Skip confirmation prompts (non-interactive archive) - -## Directory Structure - -``` -openspec/ -├── project.md # Project conventions -├── specs/ # Current truth - what IS built -│ └── [capability]/ # Single focused capability -│ ├── spec.md # Requirements and scenarios -│ └── design.md # Technical patterns -├── changes/ # Proposals - what SHOULD change -│ ├── [change-name]/ -│ │ ├── proposal.md # Why, what, impact -│ │ ├── tasks.md # Implementation checklist -│ │ ├── design.md # Technical decisions (optional; see criteria) -│ │ └── specs/ # Delta changes -│ │ └── [capability]/ -│ │ └── spec.md # ADDED/MODIFIED/REMOVED -│ └── archive/ # Completed changes -``` - -## Creating Change Proposals - -### Decision Tree - -``` -New request? -├─ Bug fix restoring spec behavior? → Fix directly -├─ Typo/format/comment? → Fix directly -├─ New feature/capability? → Create proposal -├─ Breaking change? → Create proposal -├─ Architecture change? → Create proposal -└─ Unclear? → Create proposal (safer) -``` - -### Proposal Structure - -1. **Create directory:** `changes/[change-id]/` (kebab-case, verb-led, unique) - -2. **Write proposal.md:** -```markdown -# Change: [Brief description of change] - -## Why -[1-2 sentences on problem/opportunity] - -## What Changes -- [Bullet list of changes] -- [Mark breaking changes with **BREAKING**] - -## Impact -- Affected specs: [list capabilities] -- Affected code: [key files/systems] -``` - -3. **Create spec deltas:** `specs/[capability]/spec.md` -```markdown -## ADDED Requirements -### Requirement: New Feature -The system SHALL provide... - -#### Scenario: Success case -- **WHEN** user performs action -- **THEN** expected result - -## MODIFIED Requirements -### Requirement: Existing Feature -[Complete modified requirement] - -## REMOVED Requirements -### Requirement: Old Feature -**Reason**: [Why removing] -**Migration**: [How to handle] -``` -If multiple capabilities are affected, create multiple delta files under `changes/[change-id]/specs//spec.md`—one per capability. - -4. **Create tasks.md:** -```markdown -## 1. Implementation -- [ ] 1.1 Create database schema -- [ ] 1.2 Implement API endpoint -- [ ] 1.3 Add frontend component -- [ ] 1.4 Write tests -``` - -5. **Create design.md when needed:** -Create `design.md` if any of the following apply; otherwise omit it: -- Cross-cutting change (multiple services/modules) or a new architectural pattern -- New external dependency or significant data model changes -- Security, performance, or migration complexity -- Ambiguity that benefits from technical decisions before coding - -Minimal `design.md` skeleton: -```markdown -## Context -[Background, constraints, stakeholders] - -## Goals / Non-Goals -- Goals: [...] -- Non-Goals: [...] - -## Decisions -- Decision: [What and why] -- Alternatives considered: [Options + rationale] - -## Risks / Trade-offs -- [Risk] → Mitigation - -## Migration Plan -[Steps, rollback] - -## Open Questions -- [...] -``` - -## Spec File Format - -### Critical: Scenario Formatting - -**CORRECT** (use #### headers): -```markdown -#### Scenario: User login success -- **WHEN** valid credentials provided -- **THEN** return JWT token -``` - -**WRONG** (don't use bullets or bold): -```markdown -- **Scenario: User login** ❌ -**Scenario**: User login ❌ -### Scenario: User login ❌ -``` - -Every requirement MUST have at least one scenario. - -### Requirement Wording -- Use SHALL/MUST for normative requirements (avoid should/may unless intentionally non-normative) - -### Delta Operations - -- `## ADDED Requirements` - New capabilities -- `## MODIFIED Requirements` - Changed behavior -- `## REMOVED Requirements` - Deprecated features -- `## RENAMED Requirements` - Name changes - -Headers matched with `trim(header)` - whitespace ignored. - -#### When to use ADDED vs MODIFIED -- ADDED: Introduces a new capability or sub-capability that can stand alone as a requirement. Prefer ADDED when the change is orthogonal (e.g., adding "Slash Command Configuration") rather than altering the semantics of an existing requirement. -- MODIFIED: Changes the behavior, scope, or acceptance criteria of an existing requirement. Always paste the full, updated requirement content (header + all scenarios). The archiver will replace the entire requirement with what you provide here; partial deltas will drop previous details. -- RENAMED: Use when only the name changes. If you also change behavior, use RENAMED (name) plus MODIFIED (content) referencing the new name. - -Common pitfall: Using MODIFIED to add a new concern without including the previous text. This causes loss of detail at archive time. If you aren’t explicitly changing the existing requirement, add a new requirement under ADDED instead. - -Authoring a MODIFIED requirement correctly: -1) Locate the existing requirement in `openspec/specs//spec.md`. -2) Copy the entire requirement block (from `### Requirement: ...` through its scenarios). -3) Paste it under `## MODIFIED Requirements` and edit to reflect the new behavior. -4) Ensure the header text matches exactly (whitespace-insensitive) and keep at least one `#### Scenario:`. - -Example for RENAMED: -```markdown -## RENAMED Requirements -- FROM: `### Requirement: Login` -- TO: `### Requirement: User Authentication` -``` - -## Troubleshooting - -### Common Errors - -**"Change must have at least one delta"** -- Check `changes/[name]/specs/` exists with .md files -- Verify files have operation prefixes (## ADDED Requirements) - -**"Requirement must have at least one scenario"** -- Check scenarios use `#### Scenario:` format (4 hashtags) -- Don't use bullet points or bold for scenario headers - -**Silent scenario parsing failures** -- Exact format required: `#### Scenario: Name` -- Debug with: `openspec show [change] --json --deltas-only` - -### Validation Tips - -```bash -# Always use strict mode for comprehensive checks -openspec validate [change] --strict - -# Debug delta parsing -openspec show [change] --json | jq '.deltas' - -# Check specific requirement -openspec show [spec] --json -r 1 -``` - -## Happy Path Script - -```bash -# 1) Explore current state -openspec spec list --long -openspec list -# Optional full-text search: -# rg -n "Requirement:|Scenario:" openspec/specs -# rg -n "^#|Requirement:" openspec/changes - -# 2) Choose change id and scaffold -CHANGE=add-two-factor-auth -mkdir -p openspec/changes/$CHANGE/{specs/auth} -printf "## Why\n...\n\n## What Changes\n- ...\n\n## Impact\n- ...\n" > openspec/changes/$CHANGE/proposal.md -printf "## 1. Implementation\n- [ ] 1.1 ...\n" > openspec/changes/$CHANGE/tasks.md - -# 3) Add deltas (example) -cat > openspec/changes/$CHANGE/specs/auth/spec.md << 'EOF' -## ADDED Requirements -### Requirement: Two-Factor Authentication -Users MUST provide a second factor during login. - -#### Scenario: OTP required -- **WHEN** valid credentials are provided -- **THEN** an OTP challenge is required -EOF - -# 4) Validate -openspec validate $CHANGE --strict -``` - -## Multi-Capability Example - -``` -openspec/changes/add-2fa-notify/ -├── proposal.md -├── tasks.md -└── specs/ - ├── auth/ - │ └── spec.md # ADDED: Two-Factor Authentication - └── notifications/ - └── spec.md # ADDED: OTP email notification -``` - -auth/spec.md -```markdown -## ADDED Requirements -### Requirement: Two-Factor Authentication -... -``` - -notifications/spec.md -```markdown -## ADDED Requirements -### Requirement: OTP Email Notification -... -``` - -## Best Practices - -### Simplicity First -- Default to <100 lines of new code -- Single-file implementations until proven insufficient -- Avoid frameworks without clear justification -- Choose boring, proven patterns - -### Complexity Triggers -Only add complexity with: -- Performance data showing current solution too slow -- Concrete scale requirements (>1000 users, >100MB data) -- Multiple proven use cases requiring abstraction - -### Clear References -- Use `file.ts:42` format for code locations -- Reference specs as `specs/auth/spec.md` -- Link related changes and PRs - -### Capability Naming -- Use verb-noun: `user-auth`, `payment-capture` -- Single purpose per capability -- 10-minute understandability rule -- Split if description needs "AND" - -### Change ID Naming -- Use kebab-case, short and descriptive: `add-two-factor-auth` -- Prefer verb-led prefixes: `add-`, `update-`, `remove-`, `refactor-` -- Ensure uniqueness; if taken, append `-2`, `-3`, etc. - -## Tool Selection Guide - -| Task | Tool | Why | -|------|------|-----| -| Find files by pattern | Glob | Fast pattern matching | -| Search code content | Grep | Optimized regex search | -| Read specific files | Read | Direct file access | -| Explore unknown scope | Task | Multi-step investigation | - -## Error Recovery - -### Change Conflicts -1. Run `openspec list` to see active changes -2. Check for overlapping specs -3. Coordinate with change owners -4. Consider combining proposals - -### Validation Failures -1. Run with `--strict` flag -2. Check JSON output for details -3. Verify spec file format -4. Ensure scenarios properly formatted - -### Missing Context -1. Read project.md first -2. Check related specs -3. Review recent archives -4. Ask for clarification - -## Quick Reference - -### Stage Indicators -- `changes/` - Proposed, not yet built -- `specs/` - Built and deployed -- `archive/` - Completed changes - -### File Purposes -- `proposal.md` - Why and what -- `tasks.md` - Implementation steps -- `design.md` - Technical decisions -- `spec.md` - Requirements and behavior - -### CLI Essentials -```bash -openspec list # What's in progress? -openspec show [item] # View details -openspec validate --strict # Is it correct? -openspec archive [--yes|-y] # Mark complete (add --yes for automation) -``` - -Remember: Specs are truth. Changes are proposals. Keep them in sync. diff --git a/openspec/changes/add-web-gui/context/1_refined_prd.dev.md b/openspec/changes/add-web-gui/context/1_refined_prd.dev.md deleted file mode 100644 index 974b960408..0000000000 --- a/openspec/changes/add-web-gui/context/1_refined_prd.dev.md +++ /dev/null @@ -1,427 +0,0 @@ -# Qwen Code Web GUI - PRD (面向前端研发版) - -## 1. 需求背景与目标 - -### 背景 -Qwen Code 目前仅支持终端(CLI)交互方式,用户需要在命令行中与 AI 进行对话。参考 [kimi-cli PR #775](https://github.com/MoonshotAI/kimi-cli/pull/775) 的实现,希望为 Qwen Code 添加一个 Web GUI 界面,让用户可以通过浏览器与 AI 进行交互。 - -### 目标 -1. 实现 `/web` 斜杠命令,启动本地 Web 服务 -2. 提供完整的 Chat UI 界面,支持与 CLI 相同的功能 -3. 复用现有 CLI 的 Session 存储机制,确保数据一致性 -4. 复用 `@qwen-code/webui` 组件库,保持 UI 风格统一 - ---- - -## 2. 功能结构与范围 - -``` -Web GUI -├── 核心功能 -│ ├── /web 命令启动 -│ │ ├── 启动本地 HTTP 服务器 -│ │ ├── 自动打开浏览器 -│ │ └── 支持端口/host 配置 -│ └── Session 管理 -│ ├── Session 列表展示 -│ ├── Session 搜索 -│ ├── 创建新 Session -│ ├── 切换 Session -│ └── Session 状态显示(运行中/空闲) -│ -├── 聊天界面 -│ ├── 消息列表 -│ │ ├── 用户消息 -│ │ ├── Assistant 消息(Markdown + 代码块) -│ │ ├── Thinking 过程展示(可折叠) -│ │ └── Tool 调用展示 -│ │ ├── Read/Write/Edit 文件操作 -│ │ ├── Glob/Grep 搜索操作 -│ │ ├── Shell 命令执行 -│ │ ├── Web 搜索/抓取 -│ │ └── 其他工具调用 -│ ├── 输入区域 -│ │ ├── 文本输入框 -│ │ ├── 附件/上下文按钮 -│ │ ├── 模型选择器 -│ │ ├── Thinking 模式开关 -│ │ └── 发送/停止按钮 -│ └── 上下文指示 -│ └── Context 使用率显示 -│ -├── 设置功能 -│ ├── 主题切换(亮色/暗色) -│ ├── 模型配置 -│ └── 其他设置项 -│ -└── 权限与审批 - ├── Tool 执行权限请求 - └── Shell 命令审批 -``` - ---- - -## 3. 功能设计 & 交互设计 - -### 3.1 `/web` 命令启动 - -**功能描述:** -用户在 CLI 中输入 `/web` 命令后,系统在本地启动一个 HTTP 服务器,并自动在默认浏览器中打开 Web GUI 界面。 - -**命令格式:** -```bash -/web # 默认启动 http://127.0.0.1:5494 -/web --port 8080 # 指定端口 -/web --host 0.0.0.0 # 允许局域网访问 -/web --no-open # 不自动打开浏览器 -``` - -**交互流程:** -1. 用户在 CLI 中输入 `/web` -2. CLI 检查端口是否可用,如被占用则自动尝试下一个端口 -3. 启动 HTTP 服务器 + WebSocket 服务 -4. 在终端显示启动信息(URL、端口等) -5. 自动打开浏览器(除非指定 `--no-open`) -6. CLI 保持运行,作为 Web GUI 的后端服务 - -**接口需求:** -- 需要新增 `/web` 命令处理逻辑 -- 需要新增 HTTP 服务器模块 -- 需要新增 WebSocket 服务模块 - ---- - -### 3.2 左侧边栏 - Session 管理 - -**功能描述:** -左侧边栏显示 Session 列表,支持搜索、新建和切换 Session。 - -**UI 布局:** -``` -┌─────────────────────┐ -│ [品牌标识] Qwen Code │ -├─────────────────────┤ -│ SESSIONS [🔄] [+] │ -├─────────────────────┤ -│ [🔍 Search sessions] │ -├─────────────────────┤ -│ ▸ Today │ -│ • Session 1 4m │ -│ • Session 2 1h │ -│ ▸ Yesterday │ -│ • Session 3 1d │ -├─────────────────────┤ -│ [🌙/☀️] [⚙️] │ -└─────────────────────┘ -``` - -**交互说明:** -1. **品牌标识**:显示 "Qwen Code" + 版本号 -2. **刷新按钮 [🔄]**:重新加载 Session 列表 -3. **新建按钮 [+]**:创建新 Session 并自动切换 -4. **搜索框**:实时过滤 Session(按标题匹配) -5. **Session 列表**: - - 按日期分组(Today / Yesterday / This Week / Older) - - 显示 Session 标题 + 相对时间 - - 当前 Session 高亮显示 - - 运行中的 Session 显示状态指示器 -6. **底部工具栏**: - - 主题切换按钮 - - 设置按钮 - -**接口需求:** -- `GET /api/sessions` - 获取 Session 列表 -- `POST /api/sessions` - 创建新 Session -- `GET /api/sessions/:id` - 获取单个 Session 详情 - -**复用组件:** -- `SessionSelector` 组件(需要适配为边栏形式) -- `groupSessionsByDate` 工具函数 - ---- - -### 3.3 主内容区 - 聊天界面 - -**功能描述:** -主内容区显示当前 Session 的聊天记录,支持实时消息流。 - -**UI 布局:** -``` -┌─────────────────────────────────────────┐ -│ [Session Title] (session-id) 12% ctx │ -├─────────────────────────────────────────┤ -│ │ -│ ┌─ User ─────────────────────────────┐ │ -│ │ 你的问题内容... │ │ -│ └────────────────────────────────────┘ │ -│ │ -│ ● Thinking ▸ │ -│ │ -│ ● Used Glob (web/**/*) ✓ │ -│ ● Used Grep (kimi web) ✓ │ -│ │ -│ ┌─ Assistant ────────────────────────┐ │ -│ │ AI 的回复内容... │ │ -│ │ ```typescript │ │ -│ │ const example = "code"; │ │ -│ │ ``` │ │ -│ └────────────────────────────────────┘ │ -│ │ -├─────────────────────────────────────────┤ -│ [📎] [Edit Mode ▾] │ 12% │ [/] [@] [→] │ -│ ┌─────────────────────────────────────┐ │ -│ │ Ask Qwen Code... │ │ -│ └─────────────────────────────────────┘ │ -└─────────────────────────────────────────┘ -``` - -**顶部标题栏:** -- Session 标题(可编辑) -- Session ID(短格式) -- Context 使用率指示器(百分比 + 进度条) - -**消息列表:** -1. **用户消息**: - - 显示用户输入内容 - - 支持文件上下文附件显示 - - 时间戳显示 - -2. **Thinking 消息**: - - 可折叠/展开 - - 显示 AI 思考过程 - - 橙色指示器 - -3. **Tool 调用消息**: - - 显示工具名称和参数 - - 显示执行状态(进行中/成功/失败) - - 可折叠详细输出 - - 支持的工具类型: - - Read/Write/Edit 文件 - - Glob/Grep 搜索 - - Shell 命令 - - Web Fetch/Search - - Think/Plan - -4. **Assistant 消息**: - - Markdown 渲染 - - 代码块高亮 - - 文件路径可点击 - -**接口需求:** -- `WebSocket /ws/sessions/:id` - 实时消息流 -- `GET /api/sessions/:id/messages` - 获取历史消息 -- `POST /api/sessions/:id/messages` - 发送新消息 - -**复用组件:** -- `ChatViewer` - 聊天视图 -- `UserMessage` - 用户消息 -- `AssistantMessage` - AI 消息 -- `ThinkingMessage` - 思考消息 -- `*ToolCall` 组件族 - 工具调用展示 - ---- - -### 3.4 输入区域 - -**功能描述:** -底部输入区域,支持消息输入、模式切换和发送。 - -**UI 元素:** -1. **附件按钮 [📎]**:选择文件作为上下文 -2. **Edit Mode 选择器**: - - Code (默认) - - Auto - - Plan -3. **Context 使用率**:简洁显示当前使用百分比 -4. **命令菜单按钮 [/]**:打开斜杠命令菜单 -5. **上下文附加按钮 [@]**:选择代码/文件作为上下文 -6. **发送/停止按钮 [→/■]**: - - 空闲时显示发送按钮 - - 生成中显示停止按钮 - -**交互说明:** -- Enter 发送消息 -- Shift + Enter 换行 -- Escape 停止生成 -- `/` 触发命令补全 - -**接口需求:** -- `POST /api/sessions/:id/cancel` - 取消当前生成 - -**复用组件:** -- `InputForm` - 输入表单 -- `CompletionMenu` - 命令补全菜单 -- `ContextIndicator` - 上下文指示器 - ---- - -### 3.5 权限审批 - -**功能描述:** -当 AI 需要执行敏感操作时(如 Shell 命令),弹出权限审批对话框。 - -**UI 布局:** -``` -┌────────────────────────────────────┐ -│ Permission Request │ -├────────────────────────────────────┤ -│ Qwen Code wants to run: │ -│ ┌────────────────────────────────┐ │ -│ │ npm install express │ │ -│ └────────────────────────────────┘ │ -│ │ -│ ○ Allow once │ -│ ○ Allow for this session │ -│ ○ Always allow │ -│ │ -│ [Deny] [Allow] │ -└────────────────────────────────────┘ -``` - -**交互说明:** -- 显示待执行的命令/操作 -- 提供权限范围选项 -- Deny 拒绝执行 -- Allow 允许执行 - -**接口需求:** -- `WebSocket` 消息:`permission_request` / `permission_response` - -**复用组件:** -- `PermissionDrawer` - 权限抽屉组件 - ---- - -### 3.6 设置面板 - -**功能描述:** -设置面板用于配置 Web GUI 的各项参数。 - -**设置项:** -1. **外观**: - - 主题(Light / Dark / System) -2. **模型**: - - 默认模型选择 - - API Key 配置(如需要) -3. **行为**: - - Thinking 模式默认开关 - - 自动滚动设置 - -**(!!待确认) 设置项是否需要与 CLI 设置同步?** - ---- - -## 4. 技术架构 - -### 4.1 包结构 - -``` -packages/ -├── web-app/ # 新增:Web 应用包 -│ ├── package.json -│ ├── src/ -│ │ ├── server/ # HTTP + WebSocket 服务 -│ │ │ ├── index.ts -│ │ │ ├── routes/ -│ │ │ │ ├── sessions.ts -│ │ │ │ └── config.ts -│ │ │ └── websocket/ -│ │ │ └── handler.ts -│ │ ├── client/ # React 前端应用 -│ │ │ ├── App.tsx -│ │ │ ├── components/ -│ │ │ │ ├── Sidebar.tsx -│ │ │ │ ├── ChatArea.tsx -│ │ │ │ └── Settings.tsx -│ │ │ └── hooks/ -│ │ └── shared/ # 前后端共享类型 -│ │ └── types.ts -│ └── vite.config.ts -├── cli/ # 现有 CLI 包 -│ └── src/ui/commands/ -│ └── webCommand.ts # 新增:/web 命令 -├── core/ # 现有 Core 包 -└── webui/ # 现有 WebUI 组件库 -``` - -### 4.2 技术选型 - -| 层级 | 技术选择 | 说明 | -|------|---------|------| -| 前端框架 | React 18+ | 复用 webui 组件 | -| 前端构建 | Vite | 快速开发和构建 | -| UI 组件 | @qwen-code/webui | 复用现有组件库 | -| 样式方案 | Tailwind CSS | 与 webui 保持一致 | -| 后端框架 | Express / Fastify | Node.js HTTP 服务 | -| 实时通信 | WebSocket (ws) | 消息流和状态同步 | -| 进程通信 | Core API | 复用 @qwen-code/core | - -### 4.3 数据流 - -``` -┌─────────────┐ HTTP/WS ┌──────────────┐ -│ Browser │ ◄──────────────► │ Web Server │ -│ (React UI) │ │ (Express) │ -└─────────────┘ └──────┬───────┘ - │ - │ 调用 - ▼ - ┌──────────────┐ - │ Qwen Core │ - │ (Session) │ - └──────┬───────┘ - │ - │ 读写 - ▼ - ┌──────────────┐ - │ ~/.qwen/ │ - │ sessions/ │ - └──────────────┘ -``` - ---- - -## 5. 待定与存疑点(For Discussion) - -### 5.1 【Session 状态同步】多客户端同时访问同一 Session 的处理 -**来源**:架构设计 -**讨论点**: -- 如果用户在 CLI 和 Web 同时操作同一个 Session,如何处理冲突? -- 是否需要实现乐观锁或消息广播机制? -**备注**:kimi-cli 似乎通过 WebSocket 广播解决 - -### 5.2 【文件上传】Web 端文件上下文的实现方式 -**来源**:截图分析 -**讨论点**: -- Web 端是否支持直接上传文件作为上下文? -- 还是只能引用服务器本地文件? -**备注**:如支持上传,需要考虑文件大小限制和临时存储 - -### 5.3 【安全性】Web 服务的访问控制 -**来源**:安全考虑 -**讨论点**: -- 默认绑定 127.0.0.1 是否足够? -- 如果 --host 0.0.0.0,是否需要认证机制? -- 是否需要 CORS 配置? - -### 5.4 【模型选择】Web 端模型配置与 CLI 的关系 -**来源**:截图(底部显示 kimi-k2-5) -**讨论点**: -- Web 端是否可以独立选择模型? -- 还是强制使用 CLI 配置的模型? - -### 5.5 【工作目录】Web 端的工作目录设定 -**来源**:功能设计 -**讨论点**: -- Web 端创建的 Session 使用什么工作目录? -- 是否允许用户选择/切换工作目录? -**备注**:kimi-cli 支持在创建 Session 时指定 work_dir - ---- - -## 6. 参考资料 - -- [kimi-cli PR #775](https://github.com/MoonshotAI/kimi-cli/pull/775) - Web UI 实现参考 -- [kimi-cli web 文档](https://github.com/MoonshotAI/kimi-cli/blob/main/docs/en/reference/kimi-web.md) -- 现有 `@qwen-code/webui` 组件库 -- 现有 CLI 命令系统实现 diff --git a/openspec/changes/add-web-gui/context/2_technical_design.dev.md b/openspec/changes/add-web-gui/context/2_technical_design.dev.md deleted file mode 100644 index a9e2db516e..0000000000 --- a/openspec/changes/add-web-gui/context/2_technical_design.dev.md +++ /dev/null @@ -1,1063 +0,0 @@ -# Qwen Code Web GUI - 技术设计文档 - -## 1. 设计概述 - -### 1.1 整体架构 - -``` -┌─────────────────────────────────────────────────────────────────────┐ -│ packages/web-app │ -├─────────────────────────────────────────────────────────────────────┤ -│ ┌──────────────────────┐ ┌──────────────────────────────────┐ │ -│ │ Server (Express) │ │ Client (React + Vite) │ │ -│ ├──────────────────────┤ ├──────────────────────────────────┤ │ -│ │ routes/ │ │ App.tsx │ │ -│ │ sessions.ts │◄────►│ components/ │ │ -│ │ config.ts │ HTTP │ Sidebar.tsx │ │ -│ ├──────────────────────┤ │ ChatArea.tsx │ │ -│ │ websocket/ │ │ SettingsDialog.tsx │ │ -│ │ handler.ts │◄────►│ hooks/ │ │ -│ │ sessionRunner.ts │ WS │ useWebSocket.ts │ │ -│ └──────────┬───────────┘ │ useSession.ts │ │ -│ │ └──────────────────────────────────┘ │ -└─────────────┼───────────────────────────────────────────────────────┘ - │ - │ 调用 Core API - ▼ -┌─────────────────────────────────────────────────────────────────────┐ -│ @qwen-code/qwen-code-core │ -├─────────────────────────────────────────────────────────────────────┤ -│ SessionService │ ChatRecordingService │ Config │ Storage │ -└─────────────────────────────────────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────────────┐ -│ ~/.qwen//chats/ │ -│ .jsonl │ -└─────────────────────────────────────────────────────────────────────┘ -``` - -### 1.2 技术选型 - -| 组件 | 技术 | 理由 | -|------|------|------| -| HTTP 服务 | Express.js | 轻量、成熟、Core 包已使用 | -| WebSocket | ws | 与 Core 包一致 | -| 前端框架 | React 18+ | 复用 webui 组件 | -| 构建工具 | Vite | 快速开发、HMR 支持 | -| 样式 | Tailwind CSS | 与 webui 一致 | -| 静态文件 | 内嵌构建产物 | 简化部署 | - ---- - -## 2. 文件改动清单 - -### 2.1 新增文件 - -``` -packages/web-app/ # 新包 -├── package.json -├── tsconfig.json -├── vite.config.ts -├── tailwind.config.js -├── index.html -├── src/ -│ ├── server/ -│ │ ├── index.ts # 服务入口 -│ │ ├── app.ts # Express 应用 -│ │ ├── routes/ -│ │ │ ├── sessions.ts # Session API -│ │ │ └── config.ts # 配置 API -│ │ └── websocket/ -│ │ ├── handler.ts # WS 连接处理 -│ │ └── sessionRunner.ts # Session 运行器 -│ ├── client/ -│ │ ├── main.tsx # 前端入口 -│ │ ├── App.tsx # 根组件 -│ │ ├── components/ -│ │ │ ├── Sidebar.tsx # 侧边栏 -│ │ │ ├── ChatArea.tsx # 聊天区域 -│ │ │ ├── Header.tsx # 顶部标题栏 -│ │ │ └── SettingsDialog.tsx # 设置对话框 -│ │ ├── hooks/ -│ │ │ ├── useWebSocket.ts # WebSocket Hook -│ │ │ ├── useSessions.ts # Sessions Hook -│ │ │ └── useMessages.ts # Messages Hook -│ │ └── styles/ -│ │ └── global.css # 全局样式 -│ └── shared/ -│ └── types.ts # 共享类型定义 -``` - -### 2.2 修改文件 - -``` -packages/cli/src/ -├── ui/commands/ -│ └── webCommand.ts # 新增: /web 命令 -├── services/ -│ └── BuiltinCommandLoader.ts # 修改: 注册 webCommand -``` - ---- - -## 3. 详细设计 - -### 3.1 `/web` 命令实现 - -**文件**: `packages/cli/src/ui/commands/webCommand.ts` - -```typescript -import type { - SlashCommand, - CommandContext, - MessageActionReturn, - StreamMessagesActionReturn, -} from './types.js'; -import { CommandKind } from './types.js'; -import { t } from '../../i18n/index.js'; - -export const webCommand: SlashCommand = { - name: 'web', - kind: CommandKind.BUILT_IN, - get description() { - return t('Start Web GUI server'); - }, - action: async ( - context: CommandContext, - args: string, - ): Promise => { - // 解析参数 - const parsed = parseWebArgs(args); - - // 返回流式消息,在后台启动服务器 - return { - type: 'stream_messages', - messages: startWebServer(parsed, context), - }; - }, -}; - -interface WebArgs { - port: number; - host: string; - open: boolean; -} - -function parseWebArgs(args: string): WebArgs { - const parts = args.trim().split(/\s+/); - const result: WebArgs = { - port: 5494, - host: '127.0.0.1', - open: true, - }; - - for (let i = 0; i < parts.length; i++) { - if (parts[i] === '--port' && parts[i + 1]) { - result.port = parseInt(parts[i + 1], 10); - i++; - } else if (parts[i] === '--host' && parts[i + 1]) { - result.host = parts[i + 1]; - i++; - } else if (parts[i] === '--no-open') { - result.open = false; - } - } - - return result; -} - -async function* startWebServer( - args: WebArgs, - context: CommandContext, -): AsyncGenerator<{ messageType: 'info' | 'error'; content: string }> { - yield { messageType: 'info', content: t('Starting Web GUI server...') }; - - try { - // 动态导入 web-app 包 - const { startServer } = await import('@qwen-code/web-app/server'); - - const actualPort = await startServer({ - port: args.port, - host: args.host, - config: context.services.config, - }); - - const url = `http://${args.host}:${actualPort}`; - yield { - messageType: 'info', - content: t('Web GUI running at {{url}}', { url }), - }; - - if (args.open) { - const open = (await import('open')).default; - await open(url); - yield { messageType: 'info', content: t('Browser opened') }; - } - } catch (error) { - yield { - messageType: 'error', - content: t('Failed to start server: {{error}}', { - error: error instanceof Error ? error.message : String(error), - }), - }; - } -} -``` - -**注册命令** - 修改 `BuiltinCommandLoader.ts`: - -```typescript -// 添加导入 -import { webCommand } from '../ui/commands/webCommand.js'; - -// 在 loadCommands 中添加 -const allDefinitions: Array = [ - // ... 其他命令 - webCommand, - // ... -]; -``` - ---- - -### 3.2 Server 模块 - -#### 3.2.1 服务入口 - -**文件**: `packages/web-app/src/server/index.ts` - -```typescript -import { createApp } from './app.js'; -import { WebSocketServer } from 'ws'; -import type { Config } from '@qwen-code/qwen-code-core'; -import { findAvailablePort } from './utils/port.js'; - -export interface ServerOptions { - port: number; - host: string; - config: Config | null; -} - -export async function startServer(options: ServerOptions): Promise { - const { port, host, config } = options; - - // 查找可用端口 - const actualPort = await findAvailablePort(host, port); - - // 创建 Express 应用 - const app = createApp(config); - - // 启动 HTTP 服务 - const server = app.listen(actualPort, host, () => { - console.log(`Web GUI server listening on http://${host}:${actualPort}`); - }); - - // 创建 WebSocket 服务 - const wss = new WebSocketServer({ server, path: '/ws' }); - setupWebSocket(wss, config); - - return actualPort; -} -``` - -#### 3.2.2 Express 应用 - -**文件**: `packages/web-app/src/server/app.ts` - -```typescript -import express from 'express'; -import path from 'path'; -import { fileURLToPath } from 'url'; -import type { Config } from '@qwen-code/qwen-code-core'; -import { sessionsRouter } from './routes/sessions.js'; -import { configRouter } from './routes/config.js'; - -const __dirname = path.dirname(fileURLToPath(import.meta.url)); - -export function createApp(config: Config | null) { - const app = express(); - - app.use(express.json()); - - // CORS for development - app.use((req, res, next) => { - res.header('Access-Control-Allow-Origin', 'http://localhost:5173'); - res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE'); - res.header('Access-Control-Allow-Headers', 'Content-Type'); - next(); - }); - - // API 路由 - app.use('/api/sessions', sessionsRouter(config)); - app.use('/api/config', configRouter(config)); - - // 健康检查 - app.get('/healthz', (req, res) => { - res.json({ status: 'ok' }); - }); - - // 静态文件 (生产环境) - const staticDir = path.join(__dirname, '../../dist/client'); - app.use(express.static(staticDir)); - app.get('*', (req, res) => { - res.sendFile(path.join(staticDir, 'index.html')); - }); - - return app; -} -``` - -#### 3.2.3 Sessions API - -**文件**: `packages/web-app/src/server/routes/sessions.ts` - -参考 Core 包中的 `SessionService`: - -```typescript -import { Router } from 'express'; -import type { Config, SessionService } from '@qwen-code/qwen-code-core'; - -export function sessionsRouter(config: Config | null) { - const router = Router(); - - // 复用 Core 的 SessionService - // 参考: packages/core/src/services/sessionService.ts - - // GET /api/sessions - 列出所有 sessions - router.get('/', async (req, res) => { - if (!config) { - return res.status(500).json({ error: 'Config not available' }); - } - - const sessionService = config.getSessionService(); - const limit = parseInt(req.query.limit as string) || 50; - const offset = parseInt(req.query.offset as string) || 0; - - // 复用 SessionService.listSessions() - const result = await sessionService.listSessions(limit, offset); - - res.json({ - sessions: result.sessions.map(s => ({ - id: s.sessionId, - title: s.title || 'Untitled', - lastUpdated: s.lastUpdated, - startTime: s.startTime, - })), - hasMore: result.hasMore, - }); - }); - - // POST /api/sessions - 创建新 session - router.post('/', async (req, res) => { - if (!config) { - return res.status(500).json({ error: 'Config not available' }); - } - - const sessionId = config.startNewSession(); - - res.json({ - id: sessionId, - title: 'New Session', - lastUpdated: new Date().toISOString(), - }); - }); - - // GET /api/sessions/:id - 获取 session 详情 - router.get('/:id', async (req, res) => { - if (!config) { - return res.status(500).json({ error: 'Config not available' }); - } - - const sessionService = config.getSessionService(); - const session = await sessionService.loadSession(req.params.id); - - if (!session) { - return res.status(404).json({ error: 'Session not found' }); - } - - res.json({ - id: session.conversation.sessionId, - title: session.conversation.title || 'Untitled', - messages: session.conversation.messages, - lastUpdated: session.conversation.lastUpdated, - }); - }); - - return router; -} -``` - -#### 3.2.4 WebSocket Handler - -**文件**: `packages/web-app/src/server/websocket/handler.ts` - -参考 Core 包中的消息格式: - -```typescript -import type { WebSocket, WebSocketServer } from 'ws'; -import type { Config } from '@qwen-code/qwen-code-core'; -import { SessionRunner } from './sessionRunner.js'; - -// WebSocket 消息类型 -// 参考: packages/core/src/services/chatRecordingService.ts ChatRecord -interface WSMessage { - type: string; - sessionId?: string; - content?: string; - [key: string]: unknown; -} - -export function setupWebSocket(wss: WebSocketServer, config: Config | null) { - const sessionRunners = new Map(); - - wss.on('connection', (ws: WebSocket) => { - let currentSessionId: string | null = null; - - ws.on('message', async (data) => { - try { - const message: WSMessage = JSON.parse(data.toString()); - - switch (message.type) { - case 'join_session': - currentSessionId = message.sessionId || null; - if (currentSessionId) { - let runner = sessionRunners.get(currentSessionId); - if (!runner) { - runner = new SessionRunner(currentSessionId, config); - sessionRunners.set(currentSessionId, runner); - } - runner.addClient(ws); - - // 发送历史消息 - const history = await runner.getHistory(); - ws.send(JSON.stringify({ type: 'history', messages: history })); - } - break; - - case 'user_message': - if (currentSessionId) { - const runner = sessionRunners.get(currentSessionId); - if (runner) { - await runner.handleUserMessage(message.content || ''); - } - } - break; - - case 'cancel': - if (currentSessionId) { - const runner = sessionRunners.get(currentSessionId); - if (runner) { - runner.cancel(); - } - } - break; - - case 'permission_response': - if (currentSessionId) { - const runner = sessionRunners.get(currentSessionId); - if (runner) { - runner.handlePermissionResponse(message); - } - } - break; - } - } catch (error) { - console.error('WebSocket message error:', error); - } - }); - - ws.on('close', () => { - if (currentSessionId) { - const runner = sessionRunners.get(currentSessionId); - if (runner) { - runner.removeClient(ws); - } - } - }); - }); -} -``` - ---- - -### 3.3 Client 模块 - -#### 3.3.1 应用入口 - -**文件**: `packages/web-app/src/client/App.tsx` - -```typescript -import { useState, useCallback } from 'react'; -import { Sidebar } from './components/Sidebar'; -import { ChatArea } from './components/ChatArea'; -import { SettingsDialog } from './components/SettingsDialog'; -import { useSessions } from './hooks/useSessions'; -import { useWebSocket } from './hooks/useWebSocket'; -import { useMessages } from './hooks/useMessages'; - -// 复用 webui 的样式 -import '@qwen-code/webui/styles.css'; - -export function App() { - const [currentSessionId, setCurrentSessionId] = useState(null); - const [settingsOpen, setSettingsOpen] = useState(false); - - const { sessions, createSession, refreshSessions, isLoading } = useSessions(); - const { messages, addMessage, setMessages } = useMessages(); - const { - send, - isConnected, - isStreaming, - permissionRequest, - respondToPermission, - } = useWebSocket(currentSessionId, { - onMessage: (msg) => addMessage(msg), - onHistory: (history) => setMessages(history), - }); - - const handleSelectSession = useCallback((sessionId: string) => { - setCurrentSessionId(sessionId); - setMessages([]); - }, [setMessages]); - - const handleCreateSession = useCallback(async () => { - const newSession = await createSession(); - if (newSession) { - setCurrentSessionId(newSession.id); - setMessages([]); - } - }, [createSession, setMessages]); - - const handleSendMessage = useCallback((content: string) => { - if (!currentSessionId || !content.trim()) return; - send({ type: 'user_message', content }); - }, [currentSessionId, send]); - - const handleCancel = useCallback(() => { - send({ type: 'cancel' }); - }, [send]); - - return ( -
- {/* 侧边栏 - 复用 SessionSelector 逻辑 */} - setSettingsOpen(true)} - isLoading={isLoading} - /> - - {/* 主内容区 */} - - - {/* 设置对话框 */} - setSettingsOpen(false)} - /> -
- ); -} -``` - -#### 3.3.2 Sidebar 组件 - -**文件**: `packages/web-app/src/client/components/Sidebar.tsx` - -复用 `@qwen-code/webui` 的 `groupSessionsByDate` 和样式: - -```typescript -import { useState, useMemo, Fragment } from 'react'; -import { - groupSessionsByDate, - getTimeAgo, - SearchIcon, - PlusIcon, - RefreshIcon, -} from '@qwen-code/webui'; -import type { Session } from '../../shared/types'; - -interface SidebarProps { - sessions: Session[]; - currentSessionId: string | null; - onSelectSession: (id: string) => void; - onCreateSession: () => void; - onRefresh: () => void; - onOpenSettings: () => void; - isLoading: boolean; -} - -export function Sidebar({ - sessions, - currentSessionId, - onSelectSession, - onCreateSession, - onRefresh, - onOpenSettings, - isLoading, -}: SidebarProps) { - const [searchQuery, setSearchQuery] = useState(''); - - // 搜索过滤 - const filteredSessions = useMemo(() => { - if (!searchQuery.trim()) return sessions; - const query = searchQuery.toLowerCase(); - return sessions.filter(s => - s.title.toLowerCase().includes(query) - ); - }, [sessions, searchQuery]); - - // 按日期分组 - 复用 webui 工具函数 - const groupedSessions = useMemo(() => { - return groupSessionsByDate(filteredSessions); - }, [filteredSessions]); - - return ( - - ); -} -``` - -#### 3.3.3 ChatArea 组件 - -**文件**: `packages/web-app/src/client/components/ChatArea.tsx` - -复用 `@qwen-code/webui` 的消息组件: - -```typescript -import { useRef, useCallback } from 'react'; -import { - ChatViewer, - InputForm, - PermissionDrawer, - getEditModeIcon, -} from '@qwen-code/webui'; -import type { ChatViewerHandle, ChatMessageData } from '@qwen-code/webui'; -import type { Message, PermissionRequest } from '../../shared/types'; - -interface ChatAreaProps { - sessionId: string | null; - messages: Message[]; - isConnected: boolean; - isStreaming: boolean; - permissionRequest: PermissionRequest | null; - onSendMessage: (content: string) => void; - onCancel: () => void; - onPermissionResponse: (allow: boolean, scope: string) => void; -} - -export function ChatArea({ - sessionId, - messages, - isConnected, - isStreaming, - permissionRequest, - onSendMessage, - onCancel, - onPermissionResponse, -}: ChatAreaProps) { - const chatViewerRef = useRef(null); - const inputRef = useRef(null); - const [inputText, setInputText] = useState(''); - const [isComposing, setIsComposing] = useState(false); - - // 转换消息格式以适配 ChatViewer - const chatMessages: ChatMessageData[] = useMemo(() => { - return messages.map((msg) => ({ - uuid: msg.uuid, - parentUuid: msg.parentUuid, - timestamp: msg.timestamp, - type: msg.type, - message: msg.message, - toolCall: msg.toolCall, - })); - }, [messages]); - - const handleSubmit = useCallback((e: React.FormEvent) => { - e.preventDefault(); - if (!inputText.trim() || isStreaming) return; - onSendMessage(inputText); - setInputText(''); - }, [inputText, isStreaming, onSendMessage]); - - // 空状态 - if (!sessionId) { - return ( -
-
-
💬
-

Select a session or create a new one

-
-
- ); - } - - return ( -
- {/* 顶部标题栏 */} -
-
-

Session

- - {sessionId.slice(0, 8)}... - -
-
- {!isConnected && ( - Disconnected - )} -
-
- - {/* 消息列表 - 复用 ChatViewer */} -
- -
- - {/* 输入区域 - 复用 InputForm */} - setIsComposing(true)} - onCompositionEnd={() => setIsComposing(false)} - onKeyDown={() => {}} - onSubmit={handleSubmit} - onCancel={onCancel} - onToggleEditMode={() => {}} - onToggleThinking={() => {}} - onToggleSkipAutoActiveContext={() => {}} - onShowCommandMenu={() => {}} - onAttachContext={() => {}} - completionIsOpen={false} - /> - - {/* 权限请求 - 复用 PermissionDrawer */} - {permissionRequest && ( - onPermissionResponse(true, scope)} - onDeny={() => onPermissionResponse(false, '')} - /> - )} -
- ); -} -``` - -#### 3.3.4 WebSocket Hook - -**文件**: `packages/web-app/src/client/hooks/useWebSocket.ts` - -```typescript -import { useState, useEffect, useCallback, useRef } from 'react'; -import type { Message, PermissionRequest } from '../../shared/types'; - -interface UseWebSocketOptions { - onMessage: (message: Message) => void; - onHistory: (messages: Message[]) => void; -} - -export function useWebSocket( - sessionId: string | null, - options: UseWebSocketOptions, -) { - const [isConnected, setIsConnected] = useState(false); - const [isStreaming, setIsStreaming] = useState(false); - const [permissionRequest, setPermissionRequest] = useState(null); - - const wsRef = useRef(null); - const { onMessage, onHistory } = options; - - // 连接 WebSocket - useEffect(() => { - if (!sessionId) return; - - const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; - const ws = new WebSocket(`${protocol}//${window.location.host}/ws`); - wsRef.current = ws; - - ws.onopen = () => { - setIsConnected(true); - ws.send(JSON.stringify({ type: 'join_session', sessionId })); - }; - - ws.onmessage = (event) => { - const data = JSON.parse(event.data); - - switch (data.type) { - case 'history': - onHistory(data.messages); - break; - case 'user_message': - case 'assistant_message': - case 'tool_call': - case 'thinking': - onMessage(data); - break; - case 'stream_start': - setIsStreaming(true); - break; - case 'stream_end': - setIsStreaming(false); - break; - case 'permission_request': - setPermissionRequest(data); - break; - } - }; - - ws.onclose = () => { - setIsConnected(false); - }; - - return () => { - ws.close(); - }; - }, [sessionId, onMessage, onHistory]); - - // 发送消息 - const send = useCallback((message: object) => { - if (wsRef.current?.readyState === WebSocket.OPEN) { - wsRef.current.send(JSON.stringify(message)); - } - }, []); - - // 响应权限请求 - const respondToPermission = useCallback((allow: boolean, scope: string) => { - send({ type: 'permission_response', allow, scope }); - setPermissionRequest(null); - }, [send]); - - return { - isConnected, - isStreaming, - permissionRequest, - send, - respondToPermission, - }; -} -``` - ---- - -## 4. 代码参考 - -### 4.1 Session 管理 - -| 需求 | 参考文件 | 关键函数 | -|------|---------|---------| -| 列出 Sessions | `packages/core/src/services/sessionService.ts` | `listSessions()` | -| 加载 Session | `packages/core/src/services/sessionService.ts` | `loadSession()` | -| 创建 Session | `packages/core/src/config/config.ts` | `startNewSession()` | -| 记录消息 | `packages/core/src/services/chatRecordingService.ts` | `recordUserMessage()` | - -### 4.2 UI 组件复用 - -| 需求 | 复用组件 | 来源 | -|------|---------|------| -| 聊天展示 | `ChatViewer` | `@qwen-code/webui` | -| 用户消息 | `UserMessage` | `@qwen-code/webui` | -| AI 消息 | `AssistantMessage` | `@qwen-code/webui` | -| 思考消息 | `ThinkingMessage` | `@qwen-code/webui` | -| 工具调用 | `*ToolCall` 组件族 | `@qwen-code/webui` | -| 输入表单 | `InputForm` | `@qwen-code/webui` | -| 权限抽屉 | `PermissionDrawer` | `@qwen-code/webui` | -| 日期分组 | `groupSessionsByDate` | `@qwen-code/webui` | - -### 4.3 命令系统 - -| 需求 | 参考文件 | 说明 | -|------|---------|------| -| 命令定义 | `packages/cli/src/ui/commands/types.ts` | `SlashCommand` 接口 | -| 命令注册 | `packages/cli/src/services/BuiltinCommandLoader.ts` | 添加到 `allDefinitions` | -| 流式返回 | `packages/cli/src/ui/commands/types.ts` | `StreamMessagesActionReturn` | - ---- - -## 5. 技术细节 - -### 5.1 消息格式 - -WebSocket 消息遵循 Core 包的 `ChatRecord` 格式: - -```typescript -// 参考: packages/core/src/services/chatRecordingService.ts -interface Message { - uuid: string; - parentUuid: string | null; - sessionId: string; - timestamp: string; - type: 'user' | 'assistant' | 'tool_call' | 'thinking'; - message?: { - role: string; - parts: Array<{ text: string }>; - }; - toolCall?: { - name: string; - args: Record; - status: 'pending' | 'running' | 'success' | 'error'; - result?: unknown; - }; -} -``` - -### 5.2 状态管理 - -前端使用 React Hooks 管理状态,无需引入 Redux: - -- `useSessions` - 管理 session 列表 -- `useMessages` - 管理当前 session 的消息 -- `useWebSocket` - 管理 WebSocket 连接和实时通信 - -### 5.3 构建配置 - -Vite 配置需要支持: -- React 编译 -- Tailwind CSS -- 代理 API 请求到后端(开发环境) -- 生产构建输出到 `dist/client` - ---- - -## 6. 注意事项 - -### 6.1 潜在风险 - -1. **端口冲突**: 需要实现端口自动递增逻辑 -2. **WebSocket 重连**: 需要实现断线重连机制 -3. **大 Session**: 历史消息过多时的性能问题 - -### 6.2 安全考虑 - -1. 默认绑定 `127.0.0.1`,仅本地访问 -2. `--host 0.0.0.0` 时显示警告 -3. CORS 仅允许 localhost 来源 - -### 6.3 测试策略 - -1. 单元测试: API 路由、WebSocket 处理 -2. 集成测试: 完整的消息流 -3. E2E 测试: 浏览器自动化测试 diff --git a/openspec/changes/add-web-gui/context/3_dev_plan.dev.md b/openspec/changes/add-web-gui/context/3_dev_plan.dev.md deleted file mode 100644 index f567a9ab0f..0000000000 --- a/openspec/changes/add-web-gui/context/3_dev_plan.dev.md +++ /dev/null @@ -1,186 +0,0 @@ -# Qwen Code Web GUI - 开发计划 - -## 执行顺序 - -### Phase 1: 基础设施 (Infrastructure) - -| Task | 描述 | 依赖 | 预估 | -|------|-----|------|------| -| 1.1 | 创建 `packages/web-app` 包结构 | 无 | 15min | -| 1.2 | 配置 package.json 和 TypeScript | 1.1 | 10min | -| 1.3 | 配置 Vite 和 Tailwind | 1.2 | 15min | - -### Phase 2: 后端 API (Server) - -| Task | 描述 | 依赖 | 预估 | -|------|-----|------|------| -| 2.1 | 实现 Express 服务器入口 | 1.x | 20min | -| 2.2 | 实现 Sessions API 路由 | 2.1 | 30min | -| 2.3 | 实现 Config API 路由 | 2.1 | 15min | -| 2.4 | 实现 WebSocket 服务 | 2.1 | 45min | -| 2.5 | 实现 Session Runner | 2.4 | 60min | - -### Phase 3: 前端应用 (Client) - -| Task | 描述 | 依赖 | 预估 | -|------|-----|------|------| -| 3.1 | 创建前端入口和 App 组件 | 1.x | 20min | -| 3.2 | 实现 Sidebar 组件 | 3.1 | 30min | -| 3.3 | 实现 ChatArea 组件 | 3.1 | 45min | -| 3.4 | 实现 WebSocket Hook | 3.1 | 30min | -| 3.5 | 实现 Sessions Hook | 3.1 | 20min | - -### Phase 4: CLI 集成 (Integration) - -| Task | 描述 | 依赖 | 预估 | -|------|-----|------|------| -| 4.1 | 创建 `/web` 命令 | 2.x | 30min | -| 4.2 | 注册命令到 BuiltinCommandLoader | 4.1 | 5min | - -### Phase 5: 测试和文档 (Testing) - -| Task | 描述 | 依赖 | 预估 | -|------|-----|------|------| -| 5.1 | 端到端测试 | 4.x | 30min | -| 5.2 | 文档更新 | 4.x | 15min | - ---- - -## 详细任务分解 - -### 1.1 创建包结构 - -```bash -mkdir -p packages/web-app/src/{server,client,shared} -mkdir -p packages/web-app/src/server/{routes,websocket} -mkdir -p packages/web-app/src/client/{components,hooks,styles} -``` - -**验收标准**: 目录结构创建完成 - -### 1.2 配置 package.json - -创建 `packages/web-app/package.json`: -- 添加依赖: express, ws, react, vite -- 配置 scripts: dev, build, start -- 配置 workspace 引用 - -**验收标准**: `npm install` 成功 - -### 1.3 配置 Vite 和 Tailwind - -- 创建 `vite.config.ts` -- 创建 `tailwind.config.js` -- 创建 `index.html` -- 配置 webui 样式导入 - -**验收标准**: `npm run dev` 启动前端开发服务器 - -### 2.1 实现 Express 服务器 - -- `src/server/index.ts` - 入口,导出 startServer -- `src/server/app.ts` - Express 应用配置 -- 实现端口查找逻辑 - -**验收标准**: 服务器可以启动并响应 `/healthz` - -### 2.2 实现 Sessions API - -- GET /api/sessions - 复用 SessionService.listSessions -- POST /api/sessions - 复用 Config.startNewSession -- GET /api/sessions/:id - 复用 SessionService.loadSession - -**验收标准**: API 返回正确的 session 数据 - -### 2.3 实现 Config API - -- GET /api/config - 返回基础配置 -- PUT /api/config/theme - 更新主题 - -**验收标准**: API 正常工作 - -### 2.4 实现 WebSocket 服务 - -- 连接管理 -- 消息路由 -- Session 绑定 - -**验收标准**: WebSocket 可以连接和发送消息 - -### 2.5 实现 Session Runner - -- 管理 AI 对话流程 -- 流式消息广播 -- 权限请求处理 - -**验收标准**: 可以发送消息并接收 AI 响应 - -### 3.1 创建前端入口 - -- `src/client/main.tsx` - React 入口 -- `src/client/App.tsx` - 根组件 -- 导入 webui 样式 - -**验收标准**: 前端页面可以渲染 - -### 3.2 实现 Sidebar - -- Session 列表显示 -- 搜索过滤 -- 新建/切换 Session - -**验收标准**: Sidebar 功能完整 - -### 3.3 实现 ChatArea - -- 复用 ChatViewer 组件 -- 复用 InputForm 组件 -- 集成 PermissionDrawer - -**验收标准**: 聊天界面可以显示消息和输入 - -### 3.4 实现 WebSocket Hook - -- 连接管理 -- 消息处理 -- 重连机制 - -**验收标准**: 实时通信正常 - -### 3.5 实现 Sessions Hook - -- 列表获取 -- 创建/删除 -- 状态管理 - -**验收标准**: Session 管理功能完整 - -### 4.1 创建 /web 命令 - -- 参数解析 (--port, --host, --no-open) -- 启动服务器 -- 打开浏览器 - -**验收标准**: `/web` 命令可以启动 Web GUI - -### 4.2 注册命令 - -修改 BuiltinCommandLoader 添加 webCommand - -**验收标准**: 命令在帮助列表中显示 - -### 5.1 端到端测试 - -- 启动服务器 -- 创建 Session -- 发送消息 -- 验证响应 - -**验收标准**: E2E 流程通过 - -### 5.2 文档更新 - -- 更新 CLI 文档 -- 添加 Web GUI 说明 - -**验收标准**: 文档完整 diff --git a/openspec/changes/add-web-gui/proposal.md b/openspec/changes/add-web-gui/proposal.md deleted file mode 100644 index e51f560dc2..0000000000 --- a/openspec/changes/add-web-gui/proposal.md +++ /dev/null @@ -1,28 +0,0 @@ -# Change: Add Web GUI for Qwen Code CLI - -## Why - -Qwen Code currently only supports terminal-based interaction, which limits accessibility for users who prefer a visual interface. Adding a Web GUI will provide a browser-based alternative while maintaining feature parity with the CLI, enabling users to chat naturally, manage sessions, and watch code changes in real-time through a modern React-based interface. - -## What Changes - -- **ADDED** `/web` slash command to start a local HTTP server with WebSocket support -- **ADDED** New `packages/web-app` package containing: - - Express/Fastify HTTP server for REST API - - WebSocket handler for real-time message streaming - - React frontend application reusing `@qwen-code/webui` components -- **ADDED** REST API endpoints for session management (`/api/sessions/*`) -- **ADDED** WebSocket protocol for real-time chat communication -- **ADDED** Sidebar component for session list management -- **ADDED** Main chat area with message display and input form -- **ADDED** Permission approval dialog for sensitive operations -- **ADDED** Settings panel for theme and model configuration - -## Impact - -- **Affected specs**: New `web-gui` capability -- **Affected code**: - - `packages/cli/src/ui/commands/` - Add `/web` command - - `packages/web-app/` - New package (to be created) -- **Dependencies**: Reuses `@qwen-code/webui`, `@qwen-code/qwen-code-core` -- **User impact**: New feature, no breaking changes to existing CLI workflow diff --git a/openspec/changes/add-web-gui/specs/web-gui/spec.md b/openspec/changes/add-web-gui/specs/web-gui/spec.md deleted file mode 100644 index 6ac559e138..0000000000 --- a/openspec/changes/add-web-gui/specs/web-gui/spec.md +++ /dev/null @@ -1,159 +0,0 @@ -## ADDED Requirements - -### Requirement: Web Command - -The CLI SHALL provide a `/web` slash command that starts a local HTTP server to serve the Web GUI. - -#### Scenario: Default startup -- **WHEN** user executes `/web` command without arguments -- **THEN** system SHALL start HTTP server on `127.0.0.1:5494` -- **AND** system SHALL automatically open the default browser to the server URL -- **AND** CLI SHALL display startup information including URL and port - -#### Scenario: Custom port -- **WHEN** user executes `/web --port 8080` -- **THEN** system SHALL start HTTP server on port 8080 -- **AND** if port 8080 is unavailable, system SHALL try subsequent ports - -#### Scenario: LAN access -- **WHEN** user executes `/web --host 0.0.0.0` -- **THEN** system SHALL bind to all network interfaces -- **AND** system SHALL display a warning about LAN exposure - -#### Scenario: Disable browser auto-open -- **WHEN** user executes `/web --no-open` -- **THEN** system SHALL NOT automatically open the browser -- **AND** system SHALL still display the server URL in terminal - ---- - -### Requirement: Session Management API - -The Web server SHALL provide REST API endpoints for managing sessions. - -#### Scenario: List sessions -- **WHEN** client sends GET request to `/api/sessions` -- **THEN** server SHALL return a list of all sessions -- **AND** each session SHALL include id, title, lastUpdated, and status fields - -#### Scenario: Create session -- **WHEN** client sends POST request to `/api/sessions` -- **THEN** server SHALL create a new session in the existing storage location -- **AND** server SHALL return the created session details -- **AND** session SHALL be immediately available for use - -#### Scenario: Get session details -- **WHEN** client sends GET request to `/api/sessions/:id` -- **THEN** server SHALL return full session details including message history -- **AND** if session does not exist, server SHALL return 404 status - ---- - -### Requirement: Real-time Chat Communication - -The Web server SHALL provide WebSocket connection for real-time chat communication. - -#### Scenario: User sends message -- **WHEN** client sends a message through WebSocket -- **THEN** server SHALL forward the message to the AI model -- **AND** server SHALL stream AI response back to client in real-time -- **AND** server SHALL send tool call events as they occur - -#### Scenario: Cancel generation -- **WHEN** client sends cancel request through WebSocket -- **THEN** server SHALL stop the current AI generation -- **AND** server SHALL send confirmation of cancellation to client - -#### Scenario: Permission request -- **WHEN** AI requires permission for a sensitive operation -- **THEN** server SHALL send permission request to client via WebSocket -- **AND** server SHALL wait for client response before proceeding -- **AND** client response SHALL be one of: allow_once, allow_session, always_allow, deny - ---- - -### Requirement: Web GUI Layout - -The Web GUI SHALL provide a responsive layout with sidebar and main content area. - -#### Scenario: Sidebar session list -- **WHEN** user views the Web GUI -- **THEN** sidebar SHALL display list of sessions grouped by date -- **AND** sidebar SHALL include search input for filtering sessions -- **AND** sidebar SHALL include button to create new session -- **AND** current session SHALL be visually highlighted - -#### Scenario: Main chat area -- **WHEN** user selects a session -- **THEN** main area SHALL display session title and context usage -- **AND** main area SHALL display message history with user, assistant, and tool call messages -- **AND** main area SHALL auto-scroll to latest message -- **AND** main area SHALL include input form at the bottom - ---- - -### Requirement: Message Display - -The Web GUI SHALL display chat messages with appropriate formatting and tool call visualization. - -#### Scenario: User message display -- **WHEN** displaying a user message -- **THEN** system SHALL show message content with user styling -- **AND** system SHALL show any attached file contexts - -#### Scenario: Assistant message display -- **WHEN** displaying an assistant message -- **THEN** system SHALL render Markdown content -- **AND** system SHALL syntax-highlight code blocks -- **AND** system SHALL make file paths clickable - -#### Scenario: Thinking message display -- **WHEN** displaying a thinking message -- **THEN** system SHALL show thinking content in collapsible section -- **AND** section SHALL be collapsed by default -- **AND** user SHALL be able to expand/collapse - -#### Scenario: Tool call display -- **WHEN** displaying a tool call -- **THEN** system SHALL show tool name and status indicator -- **AND** system SHALL show tool parameters -- **AND** system SHALL show tool output in collapsible section -- **AND** different tool types SHALL have specialized visualizations - ---- - -### Requirement: Input Form - -The Web GUI SHALL provide an input form for composing and sending messages. - -#### Scenario: Send message -- **WHEN** user types message and presses Enter -- **THEN** message SHALL be sent to the server -- **AND** input field SHALL be cleared -- **AND** message SHALL appear in chat immediately - -#### Scenario: Stop generation -- **WHEN** AI is generating and user clicks stop button -- **THEN** generation SHALL be cancelled -- **AND** partial response SHALL remain visible - -#### Scenario: Edit mode toggle -- **WHEN** user clicks edit mode button -- **THEN** system SHALL cycle through available edit modes -- **AND** current mode SHALL be visually indicated - ---- - -### Requirement: Theme Support - -The Web GUI SHALL support light and dark themes. - -#### Scenario: Theme toggle -- **WHEN** user clicks theme toggle button -- **THEN** system SHALL switch between light and dark themes -- **AND** theme preference SHALL be persisted in local storage - -#### Scenario: System theme -- **WHEN** theme is set to system/auto -- **THEN** system SHALL follow OS dark mode preference -- **AND** system SHALL update when OS preference changes diff --git a/openspec/changes/add-web-gui/tasks.md b/openspec/changes/add-web-gui/tasks.md deleted file mode 100644 index f98e6b1c91..0000000000 --- a/openspec/changes/add-web-gui/tasks.md +++ /dev/null @@ -1,106 +0,0 @@ -# Implementation Tasks - -## 1. Infrastructure Setup - -- [x] 1.1 Create `packages/web-app` package with proper configuration -- [x] 1.2 Set up Vite build configuration for frontend -- [x] 1.3 Configure Express/Fastify server with TypeScript -- [x] 1.4 Set up WebSocket server infrastructure -- [x] 1.5 Configure workspace dependencies and build scripts - -## 2. Backend API Development - -- [x] 2.1 Implement session management API routes - - [x] GET /api/sessions - List all sessions - - [x] POST /api/sessions - Create new session - - [x] GET /api/sessions/:id - Get session details - - [x] DELETE /api/sessions/:id - Delete session -- [x] 2.2 Implement config API routes - - [x] GET /api/config - Get current configuration - - [x] PUT /api/config - Update configuration -- [x] 2.3 Implement WebSocket message handler - - [x] Handle incoming user messages - - [x] **AI Integration: Stream real AI responses** (basic implementation done) - - [x] Handle permission requests/responses - - [ ] Implement session status broadcasting - -## 3. Frontend Application - -- [x] 3.1 Create main App component with layout structure -- [x] 3.2 Implement Sidebar component - - [x] Session list with date grouping - - [x] Search functionality - - [x] New session creation - - [ ] **Theme toggle button** -- [x] 3.3 Implement ChatArea component - - [x] Message list display - - [x] Auto-scroll behavior - - [ ] **Context usage indicator** -- [x] 3.4 Integrate existing webui components - - [x] ChatViewer / Message components - - [ ] **Use webui InputForm component** (currently using custom textarea) - - [ ] **ToolCall components integration** - - [x] PermissionDrawer component (basic modal) -- [x] 3.5 Implement WebSocket client hooks - - [x] useWebSocket hook for connection management - - [x] Message state management - - [x] Reconnection handling -- [ ] **3.6 Implement Settings panel** - - [ ] Theme settings (Light/Dark/System) - - [ ] Model configuration - - [ ] Other preferences - -## 4. CLI Integration - -- [x] 4.1 Create `/web` command handler -- [x] 4.2 Implement server startup logic - - [x] Port availability check - - [x] Auto port increment on conflict - - [x] Browser auto-open functionality -- [x] 4.3 Register command in BuiltinCommandLoader -- [x] 4.4 Add command-line argument parsing (--port, --host, --no-open) - -## 5. Core Integration (AI Model) - -- [ ] **5.1 Create session runner bridge** - - [ ] Start/stop session processes - - [ ] Message routing between WebSocket and Core - - [ ] Integrate with GeminiClient/ModelClient -- [ ] **5.2 Implement permission request flow** - - [ ] Tool execution approval - - [ ] Shell command approval -- [ ] **5.3 Handle tool call execution and streaming** - - [ ] Real-time tool call status updates - - [ ] Tool result display - -## 6. Testing - -- [ ] 6.1 Unit tests for API routes -- [ ] 6.2 Unit tests for WebSocket handler -- [ ] 6.3 Component tests for frontend -- [ ] 6.4 Integration tests for full flow -- [ ] 6.5 E2E tests for critical user journeys - -## 7. Documentation - -- [ ] 7.1 Update CLI documentation with /web command -- [ ] 7.2 Add Web GUI user guide -- [ ] 7.3 Add developer documentation for web-app package - ---- - -## Summary of Remaining Work - -### High Priority (Core Functionality) -1. **AI Model Integration** - Connect WebSocket to actual AI model for real responses -2. **Tool Call Streaming** - Display tool calls in real-time - -### Medium Priority (UI Enhancement) -3. **Settings Panel** - Theme and configuration management -4. **InputForm Integration** - Use webui InputForm component -5. **Context Indicator** - Show context usage percentage - -### Lower Priority (Polish) -6. **Theme Toggle** - Light/dark mode support -7. **Testing** - Comprehensive test coverage -8. **Documentation** - User and developer guides diff --git a/openspec/project.md b/openspec/project.md deleted file mode 100644 index 3da5119d0a..0000000000 --- a/openspec/project.md +++ /dev/null @@ -1,31 +0,0 @@ -# Project Context - -## Purpose -[Describe your project's purpose and goals] - -## Tech Stack -- [List your primary technologies] -- [e.g., TypeScript, React, Node.js] - -## Project Conventions - -### Code Style -[Describe your code style preferences, formatting rules, and naming conventions] - -### Architecture Patterns -[Document your architectural decisions and patterns] - -### Testing Strategy -[Explain your testing approach and requirements] - -### Git Workflow -[Describe your branching strategy and commit conventions] - -## Domain Context -[Add domain-specific knowledge that AI assistants need to understand] - -## Important Constraints -[List any technical, business, or regulatory constraints] - -## External Dependencies -[Document key external services, APIs, or systems] diff --git a/package-lock.json b/package-lock.json index 3a5ad1e1b8..83c4c72ff1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1527,6 +1527,44 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@floating-ui/core": { + "version": "1.7.4", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.4.tgz", + "integrity": "sha512-C3HlIdsBxszvm5McXlB8PeOEWfBhcGBTZGkGlWc2U0KFY5IwG5OQEuQ8rq52DZmcHDlPLd+YFBK+cZcytwIFWg==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.10" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.5.tgz", + "integrity": "sha512-N0bD2kIPInNHUHehXhMke1rBGs1dwqvC9O9KYMyyjK7iXt7GAhnro7UlcuYcGdS/yYOlq0MAVgrow8IbWJwyqg==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.4", + "@floating-ui/utils": "^0.2.10" + } + }, + "node_modules/@floating-ui/react-dom": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.7.tgz", + "integrity": "sha512-0tLRojf/1Go2JgEVm+3Frg9A3IW8bJgKgdO0BN5RkF//ufuz2joZM63Npau2ff3J6lUVYgDSNzNkR+aH3IVfjg==", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.7.5" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.10", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz", + "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==", + "license": "MIT" + }, "node_modules/@google/gemini-cli-test-utils": { "resolved": "packages/test-utils", "link": true @@ -1562,6 +1600,23 @@ "node": ">=6" } }, + "node_modules/@hapi/hoek": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.3.0.tgz", + "integrity": "sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@hapi/topo": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@hapi/topo/-/topo-5.1.0.tgz", + "integrity": "sha512-foQZKJig7Ob0BMAYBfcJk8d77QtOe7Wo4ox7ff1lQYoNNAb6jwcY1ncdoy2e9wQZzvNy7ODZCYJkK8kzmcAnAg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/hoek": "^9.0.0" + } + }, "node_modules/@hono/node-server": { "version": "1.19.7", "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.7.tgz", @@ -3139,6 +3194,1025 @@ "resolved": "packages/webui", "link": true }, + "node_modules/@radix-ui/number": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz", + "integrity": "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==", + "license": "MIT" + }, + "node_modules/@radix-ui/primitive": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", + "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==", + "license": "MIT" + }, + "node_modules/@radix-ui/react-arrow": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz", + "integrity": "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collapsible": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.12.tgz", + "integrity": "sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collection": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz", + "integrity": "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collection/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", + "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.15.tgz", + "integrity": "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-direction": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz", + "integrity": "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz", + "integrity": "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-escape-keydown": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dropdown-menu": { + "version": "2.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.16.tgz", + "integrity": "sha512-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-menu": "2.1.16", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-guards": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz", + "integrity": "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-scope": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.7.tgz", + "integrity": "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-hover-card": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-hover-card/-/react-hover-card-1.1.15.tgz", + "integrity": "sha512-qgTkjNT1CfKMoP0rcasmlH2r1DAiYicWsDsufxl940sT2wHNEWWv6FMWIQXWhVdmC1d/HYfbhQx60KYyAtKxjg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-id": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz", + "integrity": "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-menu": { + "version": "2.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.16.tgz", + "integrity": "sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popper": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.8.tgz", + "integrity": "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==", + "license": "MIT", + "dependencies": { + "@floating-ui/react-dom": "^2.0.0", + "@radix-ui/react-arrow": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-rect": "1.1.1", + "@radix-ui/react-use-size": "1.1.1", + "@radix-ui/rect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-portal": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz", + "integrity": "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-presence": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz", + "integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-primitive/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-roving-focus": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.11.tgz", + "integrity": "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-scroll-area": { + "version": "1.2.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-scroll-area/-/react-scroll-area-1.2.10.tgz", + "integrity": "sha512-tAXIa1g3sM5CGpVT0uIbUx/U3Gs5N8T52IICuCtObaos1S8fzsrPXG5WObkQN3S6NVl6wKgPhAIiBGbWnvc97A==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.1", + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-select": { + "version": "2.2.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.2.6.tgz", + "integrity": "sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.1", + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-visually-hidden": "1.2.3", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-separator": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.8.tgz", + "integrity": "sha512-sDvqVY4itsKwwSMEe0jtKgfTh+72Sy3gPmQpjqcQneqQ4PFmr/1I0YA+2/puilhggCe2gJcx5EBAYFkWkdpa5g==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-separator/node_modules/@radix-ui/react-primitive": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.4.tgz", + "integrity": "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slot": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.4.tgz", + "integrity": "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-switch": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-switch/-/react-switch-1.2.6.tgz", + "integrity": "sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-use-size": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toggle": { + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle/-/react-toggle-1.1.10.tgz", + "integrity": "sha512-lS1odchhFTeZv3xwHH31YPObmJn8gOg7Lq12inrr0+BH/l3Tsq32VfjqH1oh80ARM3mlkfMic15n0kg4sD1poQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toggle-group": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle-group/-/react-toggle-group-1.1.11.tgz", + "integrity": "sha512-5umnS0T8JQzQT6HbPyO7Hh9dgd82NmS36DQr+X/YJ9ctFNCiiQd6IJAYYZ33LUwm8M+taCz5t2ui29fHZc4Y6Q==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-toggle": "1.1.10", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tooltip": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.8.tgz", + "integrity": "sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-visually-hidden": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz", + "integrity": "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz", + "integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-effect-event": "0.0.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-effect-event": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz", + "integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-escape-keydown": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.1.tgz", + "integrity": "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-callback-ref": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz", + "integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-previous": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.1.tgz", + "integrity": "sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-rect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.1.tgz", + "integrity": "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==", + "license": "MIT", + "dependencies": { + "@radix-ui/rect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-size": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.1.tgz", + "integrity": "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-visually-hidden": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.3.tgz", + "integrity": "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/rect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.1.tgz", + "integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==", + "license": "MIT" + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-beta.27", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", @@ -3850,6 +4924,30 @@ "url": "https://ko-fi.com/killymxi" } }, + "node_modules/@sideway/address": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@sideway/address/-/address-4.1.5.tgz", + "integrity": "sha512-IqO/DUQHUkPeixNQ8n0JA6102hT9CmaljNTPmQ1u8MEhBo/R4Q8eKLN/vGZxuebwOroDB4cbpjheD4+/sKFK4Q==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/hoek": "^9.0.0" + } + }, + "node_modules/@sideway/formula": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sideway/formula/-/formula-3.0.1.tgz", + "integrity": "sha512-/poHZJJVjx3L+zVD6g9KgHfYnb443oi7wLu/XKojDviHy6HOEOA6z1Trk5aR1dGcmPenJEgb2sK2I80LeS3MIg==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@sideway/pinpoint": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@sideway/pinpoint/-/pinpoint-2.0.0.tgz", + "integrity": "sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ==", + "dev": true, + "license": "BSD-3-Clause" + }, "node_modules/@sinclair/typebox": { "version": "0.34.37", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.37.tgz", @@ -4724,7 +5822,7 @@ "version": "19.2.3", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", - "dev": true, + "devOptional": true, "license": "MIT", "peerDependencies": { "@types/react": "^19.2.0" @@ -6281,6 +7379,18 @@ "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "license": "Python-2.0" }, + "node_modules/aria-hidden": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz", + "integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/aria-query": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", @@ -6634,6 +7744,18 @@ "node": ">=4" } }, + "node_modules/axios": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.4.tgz", + "integrity": "sha512-1wVkUaAO6WyaYtCkcYCOx12ZgpGf9Zif+qXa4n+oYzK558YryKqiL6UWwd5DqiH3VRW0GYhTZQ/vlgJrCoNQlg==", + "dev": true, + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.4", + "proxy-from-env": "^1.1.0" + } + }, "node_modules/azure-devops-node-api": { "version": "12.5.0", "resolved": "https://registry.npmjs.org/azure-devops-node-api/-/azure-devops-node-api-12.5.0.tgz", @@ -7294,6 +8416,18 @@ "integrity": "sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==", "license": "MIT" }, + "node_modules/class-variance-authority": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", + "integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==", + "license": "Apache-2.0", + "dependencies": { + "clsx": "^2.1.1" + }, + "funding": { + "url": "https://polar.sh/cva" + } + }, "node_modules/cli-boxes": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/cli-boxes/-/cli-boxes-3.0.0.tgz", @@ -7485,6 +8619,31 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/cmdk": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cmdk/-/cmdk-1.1.1.tgz", + "integrity": "sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "^1.1.1", + "@radix-ui/react-dialog": "^1.1.6", + "@radix-ui/react-id": "^1.1.0", + "@radix-ui/react-primitive": "^2.0.2" + }, + "peerDependencies": { + "react": "^18 || ^19 || ^19.0.0-rc", + "react-dom": "^18 || ^19 || ^19.0.0-rc" + } + }, "node_modules/cockatiel": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/cockatiel/-/cockatiel-3.2.1.tgz", @@ -8293,6 +9452,12 @@ "node": ">=8" } }, + "node_modules/detect-node-es": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", + "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", + "license": "MIT" + }, "node_modules/devlop": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", @@ -9992,6 +11157,27 @@ "dev": true, "license": "ISC" }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, "node_modules/for-each": { "version": "0.3.5", "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", @@ -10296,6 +11482,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/get-nonce": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", + "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/get-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", @@ -12051,6 +13246,20 @@ "dev": true, "license": "MIT" }, + "node_modules/joi": { + "version": "17.13.3", + "resolved": "https://registry.npmjs.org/joi/-/joi-17.13.3.tgz", + "integrity": "sha512-otDA4ldcIx+ZXsKHWmp0YizCweVRZG96J10b0FevjfuncLO1oX59THoAmHkNubYJ+9gWsYsp5k8v4ib6oDv1fA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/hoek": "^9.3.0", + "@hapi/topo": "^5.1.0", + "@sideway/address": "^4.1.5", + "@sideway/formula": "^3.0.1", + "@sideway/pinpoint": "^2.0.0" + } + }, "node_modules/jose": { "version": "6.1.3", "resolved": "https://registry.npmjs.org/jose/-/jose-6.1.3.tgz", @@ -12905,6 +14114,15 @@ "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", "license": "ISC" }, + "node_modules/lucide-react": { + "version": "0.561.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.561.0.tgz", + "integrity": "sha512-Y59gMY38tl4/i0qewcqohPdEbieBy7SovpBL9IFebhc2mDd8x4PZSOsiFRkpPcOq6bj1r/mjH/Rk73gSlIJP2A==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/lz-string": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", @@ -14925,6 +16143,13 @@ "node": ">= 0.10" } }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "dev": true, + "license": "MIT" + }, "node_modules/psl": { "version": "1.15.0", "resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz", @@ -15253,6 +16478,85 @@ "node": ">=0.10.0" } }, + "node_modules/react-remove-scroll": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz", + "integrity": "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==", + "license": "MIT", + "dependencies": { + "react-remove-scroll-bar": "^2.3.7", + "react-style-singleton": "^2.2.3", + "tslib": "^2.1.0", + "use-callback-ref": "^1.3.3", + "use-sidecar": "^1.1.3" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-remove-scroll-bar": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz", + "integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==", + "license": "MIT", + "dependencies": { + "react-style-singleton": "^2.2.2", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-resizable-panels": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/react-resizable-panels/-/react-resizable-panels-2.1.9.tgz", + "integrity": "sha512-z77+X08YDIrgAes4jl8xhnUu1LNIRp4+E7cv4xHmLOxxUPO/ML7PSrE813b90vj7xvQ1lcf7g2uA9GeMZonjhQ==", + "license": "MIT", + "peerDependencies": { + "react": "^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc", + "react-dom": "^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + } + }, + "node_modules/react-style-singleton": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", + "integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==", + "license": "MIT", + "dependencies": { + "get-nonce": "^1.0.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/read": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/read/-/read-1.0.7.tgz", @@ -16406,6 +17710,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/sonner": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz", + "integrity": "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==", + "license": "MIT", + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", + "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" + } + }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", @@ -17074,6 +18388,16 @@ "node": ">=8" } }, + "node_modules/tailwind-merge": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.6.1.tgz", + "integrity": "sha512-Oo6tHdpZsGpkKG88HJ8RR1rg/RdnEkQEfMoEk2x1XRI3F1AxeU+ijRXpiVUF4UbLfcxxRGw6TbUINKYdWVsQTQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, "node_modules/tailwindcss": { "version": "3.4.18", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.18.tgz", @@ -18154,6 +19478,49 @@ "requires-port": "^1.0.0" } }, + "node_modules/use-callback-ref": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz", + "integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-sidecar": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz", + "integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==", + "license": "MIT", + "dependencies": { + "detect-node-es": "^1.1.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/use-sync-external-store": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", @@ -18517,6 +19884,26 @@ "node": ">=18" } }, + "node_modules/wait-on": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/wait-on/-/wait-on-7.2.0.tgz", + "integrity": "sha512-wCQcHkRazgjG5XoAq9jbTMLpNIjoSlZslrJ2+N9MxDsGEv1HnFoVjOCexL0ESva7Y9cu350j+DWADdk54s4AFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "axios": "^1.6.1", + "joi": "^17.11.0", + "lodash": "^4.17.21", + "minimist": "^1.2.8", + "rxjs": "^7.8.1" + }, + "bin": { + "wait-on": "bin/wait-on" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/web-streams-polyfill": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", @@ -19177,7 +20564,6 @@ "@iarna/toml": "^2.2.5", "@modelcontextprotocol/sdk": "^1.25.1", "@qwen-code/qwen-code-core": "file:../core", - "@qwen-code/web-app": "file:../web-app", "@types/update-notifier": "^6.0.8", "ansi-regex": "^6.2.2", "command-exists": "^1.2.9", @@ -23532,12 +24918,33 @@ "name": "@qwen-code/web-app", "version": "0.1.0", "dependencies": { + "@qwen-code/qwen-code-core": "file:../core", + "@qwen-code/sdk": "file:../sdk-typescript", + "@radix-ui/react-collapsible": "^1.1.12", + "@radix-ui/react-dialog": "^1.1.15", + "@radix-ui/react-dropdown-menu": "^2.1.16", + "@radix-ui/react-hover-card": "^1.1.15", + "@radix-ui/react-scroll-area": "^1.2.10", + "@radix-ui/react-select": "^2.2.6", + "@radix-ui/react-separator": "^1.1.8", + "@radix-ui/react-slot": "^1.2.4", + "@radix-ui/react-switch": "^1.2.6", + "@radix-ui/react-toggle": "^1.1.10", + "@radix-ui/react-toggle-group": "^1.1.11", + "@radix-ui/react-tooltip": "^1.2.8", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "cmdk": "^1.1.1", "express": "^4.21.0", + "lucide-react": "^0.561.0", + "nanoid": "^5.1.6", "open": "^10.1.2", + "react-resizable-panels": "^2.1.7", + "sonner": "^2.0.7", + "tailwind-merge": "^2.6.0", "ws": "^8.18.0" }, "devDependencies": { - "@qwen-code/qwen-code-core": "file:../core", "@qwen-code/webui": "file:../webui", "@types/express": "^4.17.21", "@types/react": "^19.1.8", @@ -23552,10 +24959,8 @@ "tailwindcss": "^3.4.0", "tsx": "^4.19.0", "typescript": "^5.0.0", - "vite": "^5.0.0" - }, - "peerDependencies": { - "@qwen-code/qwen-code-core": "*" + "vite": "^5.0.0", + "wait-on": "^7.2.0" } }, "packages/web-app/node_modules/@esbuild/aix-ppc64": { @@ -24014,6 +25419,24 @@ "@esbuild/win32-x64": "0.21.5" } }, + "packages/web-app/node_modules/nanoid": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.6.tgz", + "integrity": "sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.js" + }, + "engines": { + "node": "^18 || >=20" + } + }, "packages/web-app/node_modules/vite": { "version": "5.4.21", "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", diff --git a/packages/cli/package.json b/packages/cli/package.json index 11d3ceb575..14e9a460ed 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -37,7 +37,6 @@ }, "dependencies": { "@google/genai": "1.30.0", - "@qwen-code/web-app": "file:../web-app", "@iarna/toml": "^2.2.5", "@modelcontextprotocol/sdk": "^1.25.1", "@qwen-code/qwen-code-core": "file:../core", diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx index 16fea6311c..da692f8943 100644 --- a/packages/cli/src/gemini.tsx +++ b/packages/cli/src/gemini.tsx @@ -55,6 +55,197 @@ import { validateNonInteractiveAuth } from './validateNonInterActiveAuth.js'; import { showResumeSessionPicker } from './ui/components/StandaloneSessionPicker.js'; import { initializeLlmOutputLanguage } from './utils/languageUtils.js'; +/** + * Find an available port starting from the given port + */ +async function findAvailablePort(startPort: number): Promise { + const net = await import('node:net'); + + return new Promise((resolve) => { + const tryPort = (port: number) => { + const server = net.createServer(); + server.listen(port, '127.0.0.1', () => { + server.close(() => resolve(port)); + }); + server.on('error', () => { + // Port is in use, try next + tryPort(port + 1); + }); + }; + tryPort(startPort); + }); +} + +/** + * Parse web command arguments + */ +function parseWebArgs(args: string[]): { + port: number; + host: string; + open: boolean; + dev: boolean; +} { + const result = { + port: 5495, + host: '127.0.0.1', + open: true, + dev: false, + }; + + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + const nextArg = args[i + 1]; + + if ((arg === '--port' || arg === '-p') && nextArg) { + const port = parseInt(nextArg, 10); + if (!isNaN(port) && port > 0 && port < 65536) { + result.port = port; + } + i++; + } else if ((arg === '--host' || arg === '-h') && nextArg) { + result.host = nextArg; + i++; + } else if (arg === '--no-open') { + result.open = false; + } else if (arg === '--dev' || arg === '-d') { + result.dev = true; + } else if (arg === '--help') { + console.log(` +Usage: qwen web [options] + +Start the Qwen Code Web GUI server. + +Options: + -p, --port Port to listen on (default: 5495) + -h, --host Host to bind to (default: 127.0.0.1) + --no-open Don't open browser automatically + -d, --dev Run in development mode with hot reload + --help Show this help message +`); + process.exit(0); + } + } + + return result; +} + +/** + * Run the web server (qwen web subcommand) + */ +async function runWebServer(args: string[]): Promise { + const parsed = parseWebArgs(args); + + // Dev mode: use Vite dev server with HMR + if (parsed.dev) { + console.log('Starting Qwen Code Web GUI in development mode...\n'); + + const { spawn } = await import('node:child_process'); + const path = await import('node:path'); + const fs = await import('node:fs'); + const { fileURLToPath } = await import('node:url'); + + // Find web-app package directory - try multiple possible locations + const currentDir = path.dirname(fileURLToPath(import.meta.url)); + const possiblePaths = [ + path.resolve(currentDir, '../../web-app'), // From src/gemini.tsx + path.resolve(currentDir, '../../../web-app'), // From dist/src/gemini.js + path.resolve(currentDir, '../../../../packages/web-app'), // From nested dist + path.resolve(process.cwd(), 'packages/web-app'), // From project root + ]; + + let webAppDir: string | null = null; + for (const p of possiblePaths) { + if (fs.existsSync(path.join(p, 'package.json'))) { + webAppDir = p; + break; + } + } + + if (!webAppDir) { + console.error('Could not find web-app package directory.'); + console.error('Tried:', possiblePaths); + process.exit(1); + } + + console.log(` Web App directory: ${webAppDir}\n`); + console.log(' Starting Vite dev server (frontend) + backend server...\n'); + console.log(' Frontend: http://localhost:5173 (with HMR)'); + console.log(' Backend: http://localhost:5495\n'); + + const child = spawn('npm', ['run', 'dev'], { + cwd: webAppDir, + stdio: 'inherit', + shell: true, + }); + + child.on('error', (err) => { + console.error('Failed to start dev server:', err.message); + process.exit(1); + }); + + // Keep process running + await new Promise(() => {}); + return; + } + + // Production mode: use built assets + console.log('Starting Qwen Code Web GUI server...'); + + try { + // Find available port + const actualPort = await findAvailablePort(parsed.port); + + if (actualPort !== parsed.port) { + console.log( + `Port ${parsed.port} is in use, using port ${actualPort} instead.`, + ); + } + + // Dynamic import of the web-app package + const webAppModule = await import('@qwen-code/web-app'); + const { startServer } = webAppModule; + + await startServer({ + port: actualPort, + host: parsed.host, + config: null, + }); + + const url = `http://${parsed.host === '0.0.0.0' ? 'localhost' : parsed.host}:${actualPort}`; + + console.log(`\n Qwen Code Web GUI running at: ${url}\n`); + + if (parsed.host === '0.0.0.0') { + console.log( + ' Warning: Server is accessible from the network. Use with caution.\n', + ); + } + + if (parsed.open) { + try { + const open = (await import('open')).default; + await open(url); + console.log(' Browser opened.\n'); + } catch { + console.log( + ` Could not open browser automatically. Please visit ${url}\n`, + ); + } + } + + console.log(' Press Ctrl+C to stop the server.\n'); + + // Keep the process running + await new Promise(() => {}); + } catch (error) { + console.error( + 'Failed to start Web GUI server:', + error instanceof Error ? error.message : String(error), + ); + process.exit(1); + } +} + export function validateDnsResolutionOrder( order: string | undefined, ): DnsResolutionOrder { @@ -200,6 +391,14 @@ export async function startInteractiveUI( export async function main() { setupUnhandledRejectionHandler(); + + // Check for 'web' subcommand early - before any complex initialization + const args = process.argv.slice(2); + if (args[0] === 'web') { + await runWebServer(args.slice(1)); + return; + } + const settings = loadSettings(); await cleanupCheckpoints(); diff --git a/packages/cli/src/services/BuiltinCommandLoader.ts b/packages/cli/src/services/BuiltinCommandLoader.ts index b7a43bfd8d..89b742fc22 100644 --- a/packages/cli/src/services/BuiltinCommandLoader.ts +++ b/packages/cli/src/services/BuiltinCommandLoader.ts @@ -39,7 +39,6 @@ import { themeCommand } from '../ui/commands/themeCommand.js'; import { toolsCommand } from '../ui/commands/toolsCommand.js'; import { vimCommand } from '../ui/commands/vimCommand.js'; import { setupGithubCommand } from '../ui/commands/setupGithubCommand.js'; -import { webCommand } from '../ui/commands/webCommand.js'; /** * Loads the core, hard-coded slash commands that are an integral part @@ -89,7 +88,6 @@ export class BuiltinCommandLoader implements ICommandLoader { vimCommand, setupGithubCommand, terminalSetupCommand, - webCommand, ]; return allDefinitions.filter((cmd): cmd is SlashCommand => cmd !== null); diff --git a/packages/cli/src/ui/commands/webCommand.ts b/packages/cli/src/ui/commands/webCommand.ts deleted file mode 100644 index 956fcf080a..0000000000 --- a/packages/cli/src/ui/commands/webCommand.ts +++ /dev/null @@ -1,155 +0,0 @@ -/** - * @license - * Copyright 2025 Qwen Team - * SPDX-License-Identifier: Apache-2.0 - */ - -import type { - SlashCommand, - CommandContext, - MessageActionReturn, -} from './types.js'; -import { CommandKind } from './types.js'; -import { t } from '../../i18n/index.js'; -import { MessageType } from '../types.js'; - -/** - * /web command - Start the Web GUI server - */ -export const webCommand: SlashCommand = { - name: 'web', - kind: CommandKind.BUILT_IN, - get description() { - return t('Start Web GUI server in your browser'); - }, - action: async ( - context: CommandContext, - args: string, - ): Promise => { - const parsed = parseWebArgs(args); - - // Add initial message - context.ui.addItem( - { - type: MessageType.INFO, - text: t('Starting Web GUI server...'), - }, - Date.now(), - ); - - try { - // Dynamic import of the web-app package - const webAppModule = await import('@qwen-code/web-app'); - const { startServer } = webAppModule; - - const actualPort = await startServer({ - port: parsed.port, - host: parsed.host, - config: context.services.config, - }); - - const url = `http://${parsed.host === '0.0.0.0' ? 'localhost' : parsed.host}:${actualPort}`; - - context.ui.addItem( - { - type: MessageType.INFO, - text: t('Web GUI running at {{url}}', { url }), - }, - Date.now(), - ); - - if (parsed.host === '0.0.0.0') { - context.ui.addItem( - { - type: MessageType.INFO, - text: t( - 'Warning: Server is accessible from the network. Use with caution.', - ), - }, - Date.now(), - ); - } - - if (parsed.open) { - try { - const open = (await import('open')).default; - await open(url); - context.ui.addItem( - { - type: MessageType.INFO, - text: t('Browser opened'), - }, - Date.now(), - ); - } catch { - context.ui.addItem( - { - type: MessageType.INFO, - text: t( - 'Could not open browser automatically. Please visit {{url}}', - { - url, - }, - ), - }, - Date.now(), - ); - } - } - - context.ui.addItem( - { - type: MessageType.INFO, - text: t('Press Ctrl+C to stop the server'), - }, - Date.now(), - ); - } catch (error) { - return { - type: 'message', - messageType: 'error', - content: t('Failed to start Web GUI server: {{error}}', { - error: error instanceof Error ? error.message : String(error), - }), - }; - } - }, -}; - -interface WebArgs { - port: number; - host: string; - open: boolean; -} - -/** - * Parse command arguments - */ -function parseWebArgs(args: string): WebArgs { - const parts = args.trim().split(/\s+/).filter(Boolean); - const result: WebArgs = { - port: 5494, - host: '127.0.0.1', - open: true, - }; - - for (let i = 0; i < parts.length; i++) { - const part = parts[i]; - const nextPart = parts[i + 1]; - - if ((part === '--port' || part === '-p') && nextPart) { - const port = parseInt(nextPart, 10); - if (!isNaN(port) && port > 0 && port < 65536) { - result.port = port; - } - i++; - } else if ((part === '--host' || part === '-h') && nextPart) { - result.host = nextPart; - i++; - } else if (part === '--no-open') { - result.open = false; - } - } - - return result; -} diff --git a/packages/core/src/services/sessionService.test.ts b/packages/core/src/services/sessionService.test.ts index 58ff1f235d..4db8b62ae2 100644 --- a/packages/core/src/services/sessionService.test.ts +++ b/packages/core/src/services/sessionService.test.ts @@ -6,6 +6,7 @@ import fs from 'node:fs'; import path from 'node:path'; +import type { NonSharedBuffer } from 'node:buffer'; import { afterEach, beforeEach, @@ -36,6 +37,9 @@ describe('SessionService', () => { let statSyncSpy: MockInstance; let unlinkSyncSpy: MockInstance; + const asDirents = (entries: string[]) => + entries as unknown as Array>; + beforeEach(() => { vi.mocked(getProjectHash).mockReturnValue('test-project-hash'); vi.mocked(path.join).mockImplementation((...args) => args.join('/')); @@ -136,10 +140,9 @@ describe('SessionService', () => { it('should list sessions sorted by mtime descending', async () => { const now = Date.now(); - readdirSyncSpy.mockReturnValue([ - `${sessionIdA}.jsonl`, - `${sessionIdB}.jsonl`, - ] as unknown as Array>); + readdirSyncSpy.mockReturnValue( + asDirents([`${sessionIdA}.jsonl`, `${sessionIdB}.jsonl`]), + ); statSyncSpy.mockImplementation((filePath: fs.PathLike) => { const path = filePath.toString(); @@ -169,9 +172,7 @@ describe('SessionService', () => { it('should extract prompt text from first record', async () => { const now = Date.now(); - readdirSyncSpy.mockReturnValue([ - `${sessionIdA}.jsonl`, - ] as unknown as Array>); + readdirSyncSpy.mockReturnValue(asDirents([`${sessionIdA}.jsonl`])); statSyncSpy.mockReturnValue({ mtimeMs: now, @@ -193,9 +194,7 @@ describe('SessionService', () => { message: { role: 'user', parts: [{ text: longPrompt }] }, }; - readdirSyncSpy.mockReturnValue([ - `${sessionIdA}.jsonl`, - ] as unknown as Array>); + readdirSyncSpy.mockReturnValue(asDirents([`${sessionIdA}.jsonl`])); statSyncSpy.mockReturnValue({ mtimeMs: Date.now(), isFile: () => true, @@ -211,11 +210,13 @@ describe('SessionService', () => { it('should paginate with size parameter', async () => { const now = Date.now(); - readdirSyncSpy.mockReturnValue([ - `${sessionIdA}.jsonl`, - `${sessionIdB}.jsonl`, - `${sessionIdC}.jsonl`, - ] as unknown as Array>); + readdirSyncSpy.mockReturnValue( + asDirents([ + `${sessionIdA}.jsonl`, + `${sessionIdB}.jsonl`, + `${sessionIdC}.jsonl`, + ]), + ); statSyncSpy.mockImplementation((filePath: fs.PathLike) => { const path = filePath.toString(); @@ -254,11 +255,13 @@ describe('SessionService', () => { const oldMtime = now - 2000; const cursorMtime = now - 1000; - readdirSyncSpy.mockReturnValue([ - `${sessionIdA}.jsonl`, - `${sessionIdB}.jsonl`, - `${sessionIdC}.jsonl`, - ] as unknown as Array>); + readdirSyncSpy.mockReturnValue( + asDirents([ + `${sessionIdA}.jsonl`, + `${sessionIdB}.jsonl`, + `${sessionIdC}.jsonl`, + ]), + ); statSyncSpy.mockImplementation((filePath: fs.PathLike) => { const path = filePath.toString(); @@ -282,9 +285,7 @@ describe('SessionService', () => { }); it('should skip files from different projects', async () => { - readdirSyncSpy.mockReturnValue([ - `${sessionIdA}.jsonl`, - ] as unknown as Array>); + readdirSyncSpy.mockReturnValue(asDirents([`${sessionIdA}.jsonl`])); statSyncSpy.mockReturnValue({ mtimeMs: Date.now(), isFile: () => true, @@ -308,12 +309,14 @@ describe('SessionService', () => { }); it('should skip files that do not match session file pattern', async () => { - readdirSyncSpy.mockReturnValue([ - `${sessionIdA}.jsonl`, // valid - 'not-a-uuid.jsonl', // invalid pattern - 'readme.txt', // not jsonl - '.hidden.jsonl', // hidden file - ] as unknown as Array>); + readdirSyncSpy.mockReturnValue( + asDirents([ + `${sessionIdA}.jsonl`, // valid + 'not-a-uuid.jsonl', // invalid pattern + 'readme.txt', // not jsonl + '.hidden.jsonl', // hidden file + ]), + ); statSyncSpy.mockReturnValue({ mtimeMs: Date.now(), isFile: () => true, @@ -556,10 +559,9 @@ describe('SessionService', () => { it('should return the most recent session (same as getLatestSession)', async () => { const now = Date.now(); - readdirSyncSpy.mockReturnValue([ - `${sessionIdA}.jsonl`, - `${sessionIdB}.jsonl`, - ] as unknown as Array>); + readdirSyncSpy.mockReturnValue( + asDirents([`${sessionIdA}.jsonl`, `${sessionIdB}.jsonl`]), + ); statSyncSpy.mockImplementation((filePath: fs.PathLike) => { const path = filePath.toString(); diff --git a/packages/core/src/utils/shell-utils.ts b/packages/core/src/utils/shell-utils.ts index ea20ed08cf..786eb178ca 100644 --- a/packages/core/src/utils/shell-utils.ts +++ b/packages/core/src/utils/shell-utils.ts @@ -495,19 +495,25 @@ export function execCommand( args, { encoding: 'utf8', ...options }, (error, stdout, stderr) => { + const toText = (value: string | Buffer | null | undefined) => + typeof value === 'string' + ? value + : value + ? value.toString('utf8') + : ''; if (error) { if (!options.preserveOutputOnError) { reject(error); } else { resolve({ - stdout: stdout ?? '', - stderr: stderr ?? '', + stdout: toText(stdout), + stderr: toText(stderr), code: typeof error.code === 'number' ? error.code : 1, }); } return; } - resolve({ stdout: stdout ?? '', stderr: stderr ?? '', code: 0 }); + resolve({ stdout: toText(stdout), stderr: toText(stderr), code: 0 }); }, ); child.on('error', reject); diff --git a/packages/web-app/package.json b/packages/web-app/package.json index 81fc584ba5..b31669cf62 100644 --- a/packages/web-app/package.json +++ b/packages/web-app/package.json @@ -15,10 +15,11 @@ } }, "scripts": { - "dev": "concurrently \"npm run dev:server\" \"npm run dev:client\"", + "dev": "concurrently -k \"npm run dev:server\" \"npm run dev:client\"", "dev:server": "tsx watch src/server/index.ts", - "dev:client": "vite", - "build": "npm run build:client && npm run build:server", + "dev:client": "wait-on tcp:5495 && vite", + "prebuild": "node -e \"const pkg=require('./package.json');require('fs').writeFileSync('src/client/version.ts','export const APP_VERSION = \\\"' + pkg.version + '\\\";\\n');\"", + "build": "npm run prebuild && npm run build:client && npm run build:server", "build:client": "vite build", "build:server": "tsc -p tsconfig.server.json", "start": "node dist/server/index.js", @@ -27,9 +28,30 @@ }, "dependencies": { "@qwen-code/qwen-code-core": "file:../core", + "@qwen-code/sdk": "file:../sdk-typescript", + "@radix-ui/react-collapsible": "^1.1.12", + "@radix-ui/react-dialog": "^1.1.15", + "@radix-ui/react-dropdown-menu": "^2.1.16", + "@radix-ui/react-hover-card": "^1.1.15", + "@radix-ui/react-scroll-area": "^1.2.10", + "@radix-ui/react-select": "^2.2.6", + "@radix-ui/react-separator": "^1.1.8", + "@radix-ui/react-slot": "^1.2.4", + "@radix-ui/react-switch": "^1.2.6", + "@radix-ui/react-toggle": "^1.1.10", + "@radix-ui/react-toggle-group": "^1.1.11", + "@radix-ui/react-tooltip": "^1.2.8", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "cmdk": "^1.1.1", "express": "^4.21.0", - "ws": "^8.18.0", - "open": "^10.1.2" + "lucide-react": "^0.561.0", + "nanoid": "^5.1.6", + "open": "^10.1.2", + "react-resizable-panels": "^2.1.7", + "sonner": "^2.0.7", + "tailwind-merge": "^2.6.0", + "ws": "^8.18.0" }, "devDependencies": { "@qwen-code/webui": "file:../webui", @@ -46,7 +68,8 @@ "tailwindcss": "^3.4.0", "tsx": "^4.19.0", "typescript": "^5.0.0", - "vite": "^5.0.0" + "vite": "^5.0.0", + "wait-on": "^7.2.0" }, "peerDependencies": {} } diff --git a/packages/web-app/src/client/App.tsx b/packages/web-app/src/client/App.tsx index 5298ca7b3f..589231bd8d 100644 --- a/packages/web-app/src/client/App.tsx +++ b/packages/web-app/src/client/App.tsx @@ -4,18 +4,83 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { useState, useCallback } from 'react'; +import { useState, useCallback, useEffect } from 'react'; import { Sidebar } from './components/Sidebar.js'; import { ChatArea } from './components/ChatArea.js'; import { useSessions } from './hooks/useSessions.js'; import { useWebSocket } from './hooks/useWebSocket.js'; import { useMessages } from './hooks/useMessages.js'; -import type { Message, PermissionRequest } from '../shared/types.js'; +import { + ResizablePanelGroup, + ResizablePanel, + ResizableHandle, +} from './components/ui/resizable.js'; +import type { Message } from '../shared/types.js'; + +// App version - dynamically imported from generated file +import { APP_VERSION } from './version.js'; + +type Theme = 'light' | 'dark' | 'system'; + +function applyTheme(theme: Theme) { + const root = document.documentElement; + const effectiveTheme = + theme === 'system' + ? window.matchMedia('(prefers-color-scheme: dark)').matches + ? 'dark' + : 'light' + : theme; + + if (effectiveTheme === 'dark') { + root.classList.add('dark'); + } else { + root.classList.remove('dark'); + } +} export function App() { - const [currentSessionId, setCurrentSessionId] = useState(null); + // Initialize session ID from URL hash + const [currentSessionId, setCurrentSessionId] = useState( + () => { + const hash = window.location.hash.slice(1); // Remove '#' + return hash || null; + }, + ); + const [sidebarCollapsed, setSidebarCollapsed] = useState(false); + const [theme, setTheme] = useState(() => { + const saved = localStorage.getItem('qwen-code-theme') as Theme | null; + return saved || 'system'; + }); - const { sessions, createSession, refreshSessions, isLoading } = useSessions(); + // Sync session ID to URL hash + useEffect(() => { + if (currentSessionId) { + window.history.replaceState(null, '', `#${currentSessionId}`); + } else { + window.history.replaceState(null, '', window.location.pathname); + } + }, [currentSessionId]); + + // Apply theme on mount and when it changes + useEffect(() => { + applyTheme(theme); + localStorage.setItem('qwen-code-theme', theme); + }, [theme]); + + // Listen for system theme changes + useEffect(() => { + const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)'); + const handleChange = () => { + if (theme === 'system') { + applyTheme('system'); + } + }; + mediaQuery.addEventListener('change', handleChange); + return () => mediaQuery.removeEventListener('change', handleChange); + }, [theme]); + + const { sessions, createSession, deleteSession, refreshSessions, isLoading } = + useSessions(); const { messages, addMessage, setMessages, clearMessages } = useMessages(); const handleMessage = useCallback( @@ -38,6 +103,8 @@ export function App() { isStreaming, permissionRequest, respondToPermission, + sessionInfo, + usage, } = useWebSocket(currentSessionId, { onMessage: handleMessage, onHistory: handleHistory, @@ -72,35 +139,116 @@ export function App() { }, [send]); const handlePermissionResponse = useCallback( - (allow: boolean, scope: string) => { - respondToPermission(allow, scope); + (optionId: string) => { + respondToPermission(optionId); }, [respondToPermission], ); - return ( -
- {/* Sidebar */} - + const handleToggleTheme = useCallback(() => { + setTheme((current) => { + if (current === 'light') return 'dark'; + if (current === 'dark') return 'system'; + return 'light'; + }); + }, []); - {/* Main content */} - + const handleToggleSidebar = useCallback(() => { + setSidebarCollapsed((prev) => !prev); + }, []); + + const handleDeleteSession = useCallback( + async (sessionId: string) => { + const success = await deleteSession(sessionId); + if (success && currentSessionId === sessionId) { + setCurrentSessionId(null); + clearMessages(); + } + }, + [deleteSession, currentSessionId, clearMessages], + ); + + // Find current session for header display + const currentSession = sessions.find((s) => s.id === currentSessionId); + + // Get previous/next session for navigation + const currentIndex = sessions.findIndex((s) => s.id === currentSessionId); + const hasPrevSession = currentIndex > 0; + const hasNextSession = + currentIndex >= 0 && currentIndex < sessions.length - 1; + + const handlePrevSession = useCallback(() => { + if (hasPrevSession) { + const prevSession = sessions[currentIndex - 1]; + handleSelectSession(prevSession.id); + } + }, [hasPrevSession, sessions, currentIndex, handleSelectSession]); + + const handleNextSession = useCallback(() => { + if (hasNextSession) { + const nextSession = sessions[currentIndex + 1]; + handleSelectSession(nextSession.id); + } + }, [hasNextSession, sessions, currentIndex, handleSelectSession]); + + // Use CLI version if available, fallback to APP_VERSION + const displayVersion = sessionInfo?.version || APP_VERSION; + + return ( +
+ + {/* Sidebar Panel */} + + + + + + + {/* Main content Panel */} + + + +
); } diff --git a/packages/web-app/src/client/assets.d.ts b/packages/web-app/src/client/assets.d.ts new file mode 100644 index 0000000000..fc781e8809 --- /dev/null +++ b/packages/web-app/src/client/assets.d.ts @@ -0,0 +1,4 @@ +declare module '*.png' { + const src: string; + export default src; +} diff --git a/packages/web-app/src/client/assets/icon.png b/packages/web-app/src/client/assets/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..d773698ae28c64059947080b6ee849c38cefc6b0 GIT binary patch literal 53565 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 F_#fbx+id^< literal 0 HcmV?d00001 diff --git a/packages/web-app/src/client/components/ChatArea.tsx b/packages/web-app/src/client/components/ChatArea.tsx index bfd6d9647f..a74eab8ac8 100644 --- a/packages/web-app/src/client/components/ChatArea.tsx +++ b/packages/web-app/src/client/components/ChatArea.tsx @@ -4,24 +4,63 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { useState, useRef, useMemo, useCallback } from 'react'; -import { ChatViewer } from '@qwen-code/webui'; +import { useState, useRef, useMemo, useCallback, useEffect } from 'react'; +import { ChatViewer, WaitingMessage } from '@qwen-code/webui'; import type { ChatViewerHandle, ChatMessageData } from '@qwen-code/webui'; +import { + Info, + ChevronUp, + ChevronDown, + Paperclip, + Settings, + Maximize2, + Square, + Send, + X, +} from 'lucide-react'; +import { cn } from '../lib/utils.js'; +import { Button } from './ui/button.js'; +import { + Tooltip, + TooltipContent, + TooltipTrigger, + TooltipProvider, +} from './ui/tooltip.js'; import type { Message, PermissionRequest } from '../../shared/types.js'; +const qwenCodeLogo = new URL('../assets/icon.png', import.meta.url).href; + +interface UsageInfo { + inputTokens: number; + outputTokens: number; + totalTokens: number; + contextWindow: number; +} interface ChatAreaProps { sessionId: string | null; + sessionTitle?: string; + sessionTime?: string; messages: Message[]; isConnected: boolean; isStreaming: boolean; permissionRequest: PermissionRequest | null; - onSendMessage: (content: string) => void; + onSendMessage: ( + content: string, + options?: { model?: string; thinking?: boolean }, + ) => void; onCancel: () => void; - onPermissionResponse: (allow: boolean, scope: string) => void; + onPermissionResponse: (optionId: string) => void; + onPrevSession?: () => void; + onNextSession?: () => void; + theme?: 'light' | 'dark'; + usage?: UsageInfo | null; + currentModel?: string; } export function ChatArea({ sessionId, + sessionTitle, + sessionTime, messages, isConnected, isStreaming, @@ -29,40 +68,178 @@ export function ChatArea({ onSendMessage, onCancel, onPermissionResponse, + onPrevSession, + onNextSession, + theme = 'light', + usage, + currentModel, }: ChatAreaProps) { const chatViewerRef = useRef(null); + const scrollContainerRef = useRef(null); const inputRef = useRef(null); + const fileInputRef = useRef(null); + const lastSessionIdRef = useRef(null); const [inputText, setInputText] = useState(''); + const [inputExpanded, setInputExpanded] = useState(false); + + // Calculate context usage from real usage data or estimate from messages + const contextUsage = useMemo(() => { + if (usage && usage.contextWindow > 0) { + const percentage = Math.min( + 100, + Math.round((usage.totalTokens / usage.contextWindow) * 100), + ); + return percentage; + } + // Fallback: estimate from message content + const totalChars = messages.reduce((sum, msg) => { + const content = msg.message?.content; + if (typeof content === 'string') return sum + content.length; + // Also count from parts + const parts = msg.message?.parts; + if (Array.isArray(parts)) { + return ( + sum + + parts.reduce((partSum, part) => { + if (part && typeof part.text === 'string') { + return partSum + part.text.length; + } + return partSum; + }, 0) + ); + } + return sum; + }, 0); + const estimatedTokens = Math.round(totalChars / 4); + const maxTokens = 200000; + const percentage = Math.min( + 100, + Math.round((estimatedTokens / maxTokens) * 100), + ); + return percentage; + }, [messages, usage]); + + // Auto-resize textarea + useEffect(() => { + const textarea = inputRef.current; + if (textarea && !inputExpanded) { + textarea.style.height = 'auto'; + textarea.style.height = `${Math.min(textarea.scrollHeight, 200)}px`; + } + }, [inputText, inputExpanded]); + + // Scroll to bottom helper + const scrollToBottom = useCallback(() => { + requestAnimationFrame(() => { + if (scrollContainerRef.current) { + scrollContainerRef.current.scrollTop = + scrollContainerRef.current.scrollHeight; + } + }); + }, []); + + // Scroll to bottom when messages change (new message added or streaming) + useEffect(() => { + scrollToBottom(); + }, [messages, isStreaming, scrollToBottom]); + + // Scroll to bottom on initial load (for sessions loaded from URL hash) + useEffect(() => { + if (!sessionId) { + lastSessionIdRef.current = null; + return; + } + if (messages.length === 0) return; + if (lastSessionIdRef.current === sessionId) return; + lastSessionIdRef.current = sessionId; + // Small delay to ensure DOM is rendered + const timer = setTimeout(scrollToBottom, 100); + return () => clearTimeout(timer); + }, [sessionId, messages.length, scrollToBottom]); + + const handleFileSelect = useCallback(() => { + fileInputRef.current?.click(); + }, []); + + const handleFileChange = useCallback( + (e: React.ChangeEvent) => { + const files = e.target.files; + if (files && files.length > 0) { + const fileNames = Array.from(files) + .map((f) => `@${f.name}`) + .join(' '); + setInputText((prev) => prev + (prev ? ' ' : '') + fileNames); + } + if (fileInputRef.current) { + fileInputRef.current.value = ''; + } + }, + [], + ); + + const normalizeContent = ( + content: Message['message'] | undefined, + ): NonNullable['content'] => { + const raw = content?.content; + if (!Array.isArray(raw)) { + return raw; + } + return raw.filter( + (item): item is { type: string } => + typeof item === 'object' && item !== null && 'type' in item, + ) as NonNullable['content']; + }; // Convert messages to ChatViewer format - const chatMessages: ChatMessageData[] = useMemo(() => { - return messages.map((msg) => ({ - uuid: msg.uuid, - parentUuid: msg.parentUuid, - timestamp: msg.timestamp, - type: msg.type as ChatMessageData['type'], - message: msg.message - ? { - role: msg.message.role, - parts: msg.message.parts, - content: msg.message.content, - } - : undefined, - toolCall: msg.toolCall - ? { - kind: msg.toolCall.name, - name: msg.toolCall.name, - args: msg.toolCall.args, - status: msg.toolCall.status, - result: msg.toolCall.result, - } - : undefined, - })); + const chatMessages: ChatMessageData[] = useMemo( + () => + messages.map((msg) => ({ + uuid: msg.uuid, + parentUuid: msg.parentUuid, + timestamp: msg.timestamp, + type: (msg.type === 'thinking' + ? 'assistant' + : msg.type) as ChatMessageData['type'], + message: msg.message + ? { + role: + msg.type === 'thinking' + ? 'thinking' + : msg.message.role || msg.type, + parts: msg.message.parts, + content: normalizeContent(msg.message), + } + : undefined, + toolCall: msg.toolCall ?? undefined, + })), + [messages], + ); + + // Check if we have any assistant response after the last user message + const hasAssistantResponse = useMemo(() => { + if (messages.length === 0) return false; + // Find the last user message index + let lastUserIndex = -1; + for (let i = messages.length - 1; i >= 0; i--) { + if (messages[i].type === 'user') { + lastUserIndex = i; + break; + } + } + if (lastUserIndex === -1) return true; // No user message, no need to show loading + // Check if there's any assistant/thinking/tool_call after the last user message + for (let i = lastUserIndex + 1; i < messages.length; i++) { + const type = messages[i].type; + if (type === 'assistant' || type === 'thinking' || type === 'tool_call') { + return true; + } + } + return false; }, [messages]); const handleSubmit = useCallback( - (e?: React.FormEvent) => { - e?.preventDefault(); + (e?: { preventDefault?: () => void }) => { + e?.preventDefault?.(); if (!inputText.trim() || isStreaming) return; onSendMessage(inputText); setInputText(''); @@ -86,10 +263,16 @@ export function ChatArea({ // Empty state if (!sessionId) { return ( -
-
-
💬
-

Welcome to Qwen Code

+
+
+ Qwen Code +

+ Welcome to Qwen Code +

Select a session or create a new one to get started

@@ -98,134 +281,326 @@ export function ChatArea({ ); } + const displayTitle = sessionTitle || 'Session'; + const displayModel = currentModel || 'Default Model'; + return ( -
- {/* Header */} -
-
-

- Session -

- - {sessionId.slice(0, 8)}... - -
-
- {!isConnected && ( - - - Disconnected - - )} - {isConnected && ( - - - Connected - - )} -
-
- - {/* Messages */} -
- -
- - {/* Input area */} -
-
-
-