wip(web-app): built an initial web app using node

This commit is contained in:
yiliang114 2026-02-01 00:23:35 +08:00
parent 4b73d85e6c
commit 4bce8317aa
60 changed files with 4473 additions and 4667 deletions

View file

@ -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.
---
<!-- OPENSPEC:START -->
**角色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/<capability>/spec.md` 下创建 spec delta 文件:
- 使用 `## ADDED|MODIFIED|REMOVED Requirements` 格式
- 每个 Requirement 必须包含至少一个 `#### Scenario:`
5. 最后运行 `openspec validate <id> --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 <change-id>` 初始化(即 `openspec/changes/<change-id>/` 目录不存在):
1. 当用户在对话中直接描述需求内容时,从指令参数或对话中提取 change-id
2. 自动创建 `openspec/changes/<change-id>/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/<capability>/spec.md` - Spec Delta 文件(如适用)
**Reference**
- 使用 `openspec show <id> --json --deltas-only``openspec show <spec> --type spec` 检查详情
- 使用 `rg -n "Requirement:|Scenario:" openspec/specs` 搜索现有需求
<!-- OPENSPEC:END -->

View file

@ -1,114 +0,0 @@
---
name: /dw-2-design
id: dw-2-design
category: DW Workflow
description: Generate technical design and spec deltas.
---
<!-- OPENSPEC:START -->
**角色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/<capability>/spec.md` - 正式的 Spec Delta 文件(如适用)
<!-- OPENSPEC:END -->

View file

@ -1,83 +0,0 @@
---
name: /dw-3-task
id: dw-3-task
category: DW Workflow
description: Generate development tasks and checklist.
---
<!-- OPENSPEC:START -->
**角色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` - 精简任务清单,供工具消费
<!-- OPENSPEC:END -->

View file

@ -1,67 +0,0 @@
---
name: /dw-4-code
id: dw-4-code
category: DW Workflow
description: Implement the change and apply OpenSpec workflow.
---
<!-- OPENSPEC:START -->
**角色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 <id> --json --deltas-only` 获取 proposal 的额外上下文
- 使用 `openspec list``openspec show <item>` 获取更多信息
<!-- OPENSPEC:END -->

View file

@ -1,147 +0,0 @@
---
name: /dw-5-revise
id: dw-5-revise
category: DW Workflow
description: Revise and update existing change documents.
---
<!-- OPENSPEC:START -->
**角色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/<capability>/spec.md`
6. 在 `proposal.md` 末尾追加修订记录
**阶段 4验证完成**
1. 如果更新了 spec delta运行 `openspec validate <change-id> --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 <id> --json --deltas-only` 获取 proposal 的额外上下文
- 使用 `openspec list``openspec show <item>` 获取更多信息
<!-- OPENSPEC:END -->

View file

@ -1,80 +0,0 @@
---
name: /dw-6-archive
id: dw-6-archive
category: DW Workflow
description: Smart archive with auto spec skeleton creation.
---
<!-- OPENSPEC:START -->
**角色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 <change-id> --json --deltas-only` 获取变更的 delta 信息
2. **分析 delta**:检查每个受影响的 capability
- 检查 `openspec/specs/<capability>/spec.md` 是否存在
- 检查 delta 是否包含 MODIFIED、REMOVED 或 RENAMED 操作
3. **处理缺失 spec**:对于每个缺失 spec 且包含非 ADDED 操作的 capability
- 向用户说明将创建骨架文件
- 创建 `openspec/specs/<capability>/spec.md` 骨架文件,格式如下:
```markdown
# <capability> Specification
## Purpose
TBD - created by archiving change <change-id>. Update Purpose after archive.
## Requirements
```
4. **执行归档**:运行 `openspec archive <change-id> --yes`
5. **汇报结果**
- 如果创建了骨架文件,列出创建的文件
- 报告归档结果
---
**输入资料**
- 变更 ID从用户指令参数或对话中获取
- 可选选项:`--skip-specs``--no-validate`
---
**注意事项Notes**
- 骨架文件格式必须与 `archive.ts` 中的 `buildSpecSkeleton` 方法一致
- 如果 spec 已存在,不要修改它,直接进行标准归档
- 归档完成后,提醒用户更新骨架文件的 Purpose 部分
**输出**
执行 `openspec archive` 命令,完成归档流程
**Reference**
- 使用 `openspec show <id> --json --deltas-only` 获取 delta 信息
- 使用 `openspec archive <id> --yes` 执行归档
<!-- OPENSPEC:END -->

View file

@ -1,28 +0,0 @@
---
name: /openspec-apply
id: openspec-apply
category: OpenSpec
description: Implement an approved OpenSpec change and keep tasks in sync.
---
<!-- OPENSPEC:START -->
**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/<id>/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 <item>` when additional context is required.
**Reference**
- Use `openspec show <id> --json --deltas-only` if you need additional context from the proposal while implementing.
<!-- OPENSPEC:END -->

View file

@ -1,32 +0,0 @@
---
name: /openspec-archive
id: openspec-archive
category: OpenSpec
description: Archive a deployed OpenSpec change and update specs.
---
<!-- OPENSPEC:START -->
**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 `<ChangeId>` 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 <id>`) and stop if the change is missing, already archived, or otherwise not ready to archive.
3. Run `openspec archive <id> --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 <id>` 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.
<!-- OPENSPEC:END -->

View file

@ -1,33 +0,0 @@
---
name: /openspec-proposal
id: openspec-proposal
category: OpenSpec
description: Scaffold a new OpenSpec change and validate strictly.
---
<!-- OPENSPEC:START -->
**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/<id>/`.
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/<id>/specs/<capability>/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 <id> --strict` and resolve every issue before sharing the proposal.
**Reference**
- Use `openspec show <id> --json --deltas-only` or `openspec show <spec> --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 <keyword>`, `ls`, or direct file reads so proposals align with current implementation realities.
<!-- OPENSPEC:END -->

View file

@ -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 子进程)
```

View file

@ -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<br/>(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 (
<div className="flex h-screen">
<Sidebar sessions={sessions} onSelect={handleSelect} />
<main className="flex-1 flex flex-col">
<ChatHeader title={session.title} contextUsage={usage} />
<ChatViewer messages={messages} />
<InputForm onSubmit={handleSubmit} />
</main>
</div>
);
}
```
## 复用的现有组件
`@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)

2
.gitignore vendored
View file

@ -22,7 +22,7 @@ package-lock.json
# Editors
.idea
*.iml
# .cursor
.cursor
.qoder
# OS metadata

View file

@ -1,18 +0,0 @@
<!-- OPENSPEC:START -->
# 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.
<!-- OPENSPEC:END -->

18
QWEN.md
View file

@ -1,18 +0,0 @@
<!-- OPENSPEC:START -->
# 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.
<!-- OPENSPEC:END -->

View file

@ -122,9 +122,7 @@ export default tseslint.config(
'yargs/**',
'msw/node',
'**/generated/**',
'./styles/tailwind.css',
'./styles/App.css',
'./styles/style.css'
'**/styles/*.css'
],
},
],

View file

@ -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/<id>/`.
3. Draft spec deltas using `## ADDED|MODIFIED|REMOVED Requirements` with at least one `#### Scenario:` per requirement.
4. Run `openspec validate <id> --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 <change-id> --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 12 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 <spec-id> --type spec` (use `--json` for filters)
- Change: `openspec show <change-id> --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 <change-id> [--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/<capability>/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 arent 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/<capability>/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 <change-id> [--yes|-y] # Mark complete (add --yes for automation)
```
Remember: Specs are truth. Changes are proposals. Keep them in sync.

View file

@ -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 命令系统实现

File diff suppressed because it is too large Load diff

View file

@ -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 说明
**验收标准**: 文档完整

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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]

1437
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -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",

View file

@ -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<number> {
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> Port to listen on (default: 5495)
-h, --host <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<void> {
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();

View file

@ -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);

View file

@ -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<void | MessageActionReturn> => {
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;
}

View file

@ -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<typeof fs.statSync>;
let unlinkSyncSpy: MockInstance<typeof fs.unlinkSync>;
const asDirents = (entries: string[]) =>
entries as unknown as Array<fs.Dirent<NonSharedBuffer>>;
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<fs.Dirent<Buffer>>);
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<fs.Dirent<Buffer>>);
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<fs.Dirent<Buffer>>);
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<fs.Dirent<Buffer>>);
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<fs.Dirent<Buffer>>);
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<fs.Dirent<Buffer>>);
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<fs.Dirent<Buffer>>);
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<fs.Dirent<Buffer>>);
readdirSyncSpy.mockReturnValue(
asDirents([`${sessionIdA}.jsonl`, `${sessionIdB}.jsonl`]),
);
statSyncSpy.mockImplementation((filePath: fs.PathLike) => {
const path = filePath.toString();

View file

@ -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);

View file

@ -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": {}
}

View file

@ -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<string | null>(null);
// Initialize session ID from URL hash
const [currentSessionId, setCurrentSessionId] = useState<string | null>(
() => {
const hash = window.location.hash.slice(1); // Remove '#'
return hash || null;
},
);
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const [theme, setTheme] = useState<Theme>(() => {
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 (
<div className="flex h-screen bg-[var(--app-primary-background)]">
{/* Sidebar */}
<Sidebar
sessions={sessions}
currentSessionId={currentSessionId}
onSelectSession={handleSelectSession}
onCreateSession={handleCreateSession}
onRefresh={refreshSessions}
isLoading={isLoading}
/>
const handleToggleTheme = useCallback(() => {
setTheme((current) => {
if (current === 'light') return 'dark';
if (current === 'dark') return 'system';
return 'light';
});
}, []);
{/* Main content */}
<ChatArea
sessionId={currentSessionId}
messages={messages}
isConnected={isConnected}
isStreaming={isStreaming}
permissionRequest={permissionRequest}
onSendMessage={handleSendMessage}
onCancel={handleCancel}
onPermissionResponse={handlePermissionResponse}
/>
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 (
<div className="h-screen bg-background">
<ResizablePanelGroup direction="horizontal" className="h-full">
{/* Sidebar Panel */}
<ResizablePanel
defaultSize={sidebarCollapsed ? 3 : 20}
minSize={sidebarCollapsed ? 3 : 15}
maxSize={sidebarCollapsed ? 3 : 30}
className="border-r border-border"
>
<Sidebar
sessions={sessions}
currentSessionId={currentSessionId}
onSelectSession={handleSelectSession}
onCreateSession={handleCreateSession}
onDeleteSession={handleDeleteSession}
onRefresh={refreshSessions}
isLoading={isLoading}
collapsed={sidebarCollapsed}
onToggleCollapse={handleToggleSidebar}
theme={theme}
onToggleTheme={handleToggleTheme}
version={displayVersion}
/>
</ResizablePanel>
<ResizableHandle />
{/* Main content Panel */}
<ResizablePanel defaultSize={80} minSize={50}>
<ChatArea
sessionId={currentSessionId}
sessionTitle={currentSession?.title}
sessionTime={currentSession?.lastUpdated}
messages={messages}
isConnected={isConnected}
isStreaming={isStreaming}
permissionRequest={permissionRequest}
onSendMessage={handleSendMessage}
onCancel={handleCancel}
onPermissionResponse={handlePermissionResponse}
onPrevSession={hasPrevSession ? handlePrevSession : undefined}
onNextSession={hasNextSession ? handleNextSession : undefined}
theme={
theme === 'system'
? window.matchMedia('(prefers-color-scheme: dark)').matches
? 'dark'
: 'light'
: theme
}
usage={usage}
currentModel={sessionInfo?.model}
/>
</ResizablePanel>
</ResizablePanelGroup>
</div>
);
}

View file

@ -0,0 +1,4 @@
declare module '*.png' {
const src: string;
export default src;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

View file

@ -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<ChatViewerHandle>(null);
const scrollContainerRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLTextAreaElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const lastSessionIdRef = useRef<string | null>(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<HTMLInputElement>) => {
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<ChatMessageData['message']>['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<ChatMessageData['message']>['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 (
<main className="flex-1 flex items-center justify-center bg-[var(--app-primary-background)]">
<div className="text-center text-[var(--app-secondary-foreground)]">
<div className="text-6xl mb-4 opacity-30">💬</div>
<h2 className="text-xl font-medium mb-2">Welcome to Qwen Code</h2>
<main className="flex-1 flex items-center justify-center bg-background h-full">
<div className="text-center text-muted-foreground">
<img
src={qwenCodeLogo}
alt="Qwen Code"
className="w-16 h-16 mx-auto mb-4 rounded-2xl"
/>
<h2 className="text-xl font-medium mb-2 text-foreground">
Welcome to Qwen Code
</h2>
<p className="text-sm">
Select a session or create a new one to get started
</p>
@ -98,134 +281,326 @@ export function ChatArea({
);
}
const displayTitle = sessionTitle || 'Session';
const displayModel = currentModel || 'Default Model';
return (
<main className="flex-1 flex flex-col bg-[var(--app-primary-background)] relative">
{/* Header */}
<header className="px-4 py-3 border-b border-[var(--app-border)] flex items-center justify-between bg-[var(--app-secondary-background)]">
<div>
<h2 className="font-medium text-[var(--app-primary-foreground)]">
Session
</h2>
<span className="text-xs text-[var(--app-secondary-foreground)]">
{sessionId.slice(0, 8)}...
</span>
</div>
<div className="flex items-center gap-3">
{!isConnected && (
<span className="text-xs text-red-500 flex items-center gap-1">
<span className="w-2 h-2 rounded-full bg-red-500"></span>
Disconnected
</span>
)}
{isConnected && (
<span className="text-xs text-green-500 flex items-center gap-1">
<span className="w-2 h-2 rounded-full bg-green-500"></span>
Connected
</span>
)}
</div>
</header>
{/* Messages */}
<div className="flex-1 overflow-hidden">
<ChatViewer
ref={chatViewerRef}
messages={chatMessages}
autoScroll={true}
theme="auto"
emptyMessage="Start a conversation..."
showEmptyIcon={true}
/>
</div>
{/* Input area */}
<div className="p-4 border-t border-[var(--app-border)] bg-[var(--app-secondary-background)]">
<form onSubmit={handleSubmit} className="flex gap-2">
<div className="flex-1 relative">
<textarea
ref={inputRef}
value={inputText}
onChange={(e) => setInputText(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Ask Qwen Code..."
disabled={!isConnected}
rows={1}
className="w-full px-4 py-3 rounded-lg bg-[var(--app-input-background)] text-[var(--app-primary-foreground)] placeholder:text-[var(--app-input-placeholder-foreground)] border border-[var(--app-border)] focus:border-[var(--app-button-background)] focus:outline-none resize-none"
style={{
minHeight: '48px',
maxHeight: '200px',
}}
/>
<TooltipProvider>
<main className="flex-1 flex flex-col bg-background relative h-full">
{/* Header */}
<header className="px-6 py-3 border-b border-border flex items-center justify-between bg-background shrink-0">
<div className="flex-1 min-w-0 mr-4">
<h2 className="font-semibold text-foreground text-base truncate">
{displayTitle}
</h2>
{sessionTime && (
<span className="text-xs text-muted-foreground">
{new Date(sessionTime).toLocaleString()}
</span>
)}
</div>
{isStreaming ? (
<button
type="button"
onClick={onCancel}
className="px-4 py-2 rounded-lg bg-red-600 text-white hover:bg-red-700 transition-colors flex items-center gap-2"
title="Stop generation"
>
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<rect x="6" y="6" width="12" height="12" rx="2" />
</svg>
Stop
</button>
) : (
<button
type="submit"
disabled={!inputText.trim() || !isConnected}
className="px-4 py-2 rounded-lg bg-[var(--app-button-background)] text-[var(--app-button-foreground)] hover:bg-[var(--app-button-hover-background)] disabled:opacity-50 disabled:cursor-not-allowed transition-colors flex items-center gap-2"
title="Send message"
>
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8"
/>
</svg>
Send
</button>
)}
</form>
<div className="mt-2 text-xs text-[var(--app-secondary-foreground)]">
Press Enter to send, Shift+Enter for new line
</div>
</div>
<div className="flex items-center gap-3">
{/* Info icon */}
<Tooltip>
<TooltipTrigger asChild>
<button
className="p-1.5 rounded-md hover:bg-accent transition-colors"
type="button"
>
<Info className="h-4 w-4 text-muted-foreground" />
</button>
</TooltipTrigger>
<TooltipContent>
<div className="text-xs">
<div className="font-medium mb-1">Session ID</div>
<code className="text-[10px] bg-muted px-1 py-0.5 rounded">
{sessionId}
</code>
</div>
</TooltipContent>
</Tooltip>
{/* Permission request modal */}
{permissionRequest && (
<div className="absolute inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-[var(--app-menu-background)] rounded-lg p-6 max-w-md w-full mx-4 shadow-xl">
<h3 className="text-lg font-medium mb-4 text-[var(--app-primary-foreground)]">
Permission Request
</h3>
<p className="text-sm text-[var(--app-secondary-foreground)] mb-4">
{permissionRequest.description ||
`Allow ${permissionRequest.operation}?`}
</p>
<div className="flex gap-3 justify-end">
<button
onClick={() => onPermissionResponse(false, '')}
className="px-4 py-2 rounded bg-[var(--app-input-background)] text-[var(--app-primary-foreground)] hover:bg-[var(--app-list-hover-background)]"
{/* Session navigation */}
<div className="flex items-center gap-0.5">
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={onPrevSession}
disabled={!onPrevSession}
className="p-1.5 rounded-md hover:bg-accent transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
type="button"
>
<ChevronUp className="h-4 w-4 text-muted-foreground" />
</button>
</TooltipTrigger>
<TooltipContent>Previous session</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={onNextSession}
disabled={!onNextSession}
className="p-1.5 rounded-md hover:bg-accent transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
type="button"
>
<ChevronDown className="h-4 w-4 text-muted-foreground" />
</button>
</TooltipTrigger>
<TooltipContent>Next session</TooltipContent>
</Tooltip>
</div>
{/* Context usage */}
<Tooltip>
<TooltipTrigger asChild>
<div className="text-xs text-muted-foreground cursor-default">
{contextUsage}% context
</div>
</TooltipTrigger>
<TooltipContent>
{usage ? (
<div className="text-xs">
<div>
Input: {usage.inputTokens.toLocaleString()} tokens
</div>
<div>
Output: {usage.outputTokens.toLocaleString()} tokens
</div>
<div>
Total: {usage.totalTokens.toLocaleString()} /{' '}
{usage.contextWindow.toLocaleString()}
</div>
</div>
) : (
'Estimated context usage'
)}
</TooltipContent>
</Tooltip>
{/* Connection status */}
<div className="flex items-center gap-1.5">
<span
className={cn(
'w-2 h-2 rounded-full',
isConnected ? 'bg-green-500' : 'bg-red-500',
)}
/>
<span
className={cn(
'text-xs font-medium',
isConnected ? 'text-green-500' : 'text-red-500',
)}
>
Deny
</button>
<button
onClick={() => onPermissionResponse(true, 'once')}
className="px-4 py-2 rounded bg-[var(--app-button-background)] text-[var(--app-button-foreground)] hover:bg-[var(--app-button-hover-background)]"
>
Allow
</button>
{isConnected ? 'Connected' : 'Disconnected'}
</span>
</div>
</div>
</header>
{/* Messages */}
<div className="flex-1 overflow-auto" ref={scrollContainerRef}>
<div className="min-h-full flex flex-col">
<div className="flex-1">
<ChatViewer
ref={chatViewerRef}
messages={chatMessages}
autoScroll={false}
theme={theme}
emptyMessage="Start a conversation..."
showEmptyIcon={true}
/>
</div>
{/* Show waiting message when streaming starts but no assistant response yet */}
{isStreaming && !hasAssistantResponse && (
<div className="px-6 py-2">
<WaitingMessage loadingMessage="Thinking..." />
</div>
)}
{/* Bottom padding for scroll space */}
<div className="h-4 shrink-0" />
</div>
</div>
)}
</main>
{/* Input area */}
<div
className={cn(
'bg-background shrink-0',
inputExpanded
? 'fixed inset-4 z-50 rounded-xl shadow-2xl flex flex-col border border-border'
: 'px-6 pb-4 pt-2',
)}
>
{/* Hidden file input */}
<input
ref={fileInputRef}
type="file"
multiple
className="hidden"
onChange={handleFileChange}
/>
{/* Expanded mode header */}
{inputExpanded && (
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
<span className="text-sm font-medium text-foreground">
Compose Message
</span>
<button
onClick={() => setInputExpanded(false)}
className="p-1.5 rounded-md hover:bg-accent transition-colors"
type="button"
>
<X className="h-4 w-4 text-muted-foreground" />
</button>
</div>
)}
<form
onSubmit={(e) => {
e.preventDefault();
handleSubmit();
}}
className={cn('flex flex-col', inputExpanded ? 'flex-1 p-4' : '')}
>
<div
className={cn(
'rounded-xl border border-input bg-background relative',
inputExpanded ? 'flex-1 flex flex-col' : '',
)}
>
<div className={cn('relative', inputExpanded ? 'flex-1' : '')}>
<textarea
ref={inputRef}
value={inputText}
onChange={(e) => setInputText(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Ask Qwen Code..."
disabled={!isConnected}
rows={inputExpanded ? undefined : 4}
className={cn(
'w-full px-4 py-3 pr-12 bg-transparent text-foreground placeholder:text-muted-foreground focus:outline-none text-sm rounded-xl resize-none',
inputExpanded ? 'h-full' : 'min-h-[120px] max-h-[300px]',
)}
/>
<div className="absolute bottom-2 right-2">
{isStreaming ? (
<Button
type="button"
onClick={onCancel}
variant="destructive"
size="icon-sm"
className="rounded-lg"
>
<Square className="h-4 w-4" />
</Button>
) : (
<Button
type="submit"
disabled={!inputText.trim() || !isConnected}
size="icon-sm"
className="rounded-lg bg-gradient-to-r from-violet-500 to-purple-600 hover:from-violet-600 hover:to-purple-700"
>
<Send className="h-4 w-4" />
</Button>
)}
</div>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => setInputExpanded(!inputExpanded)}
className="absolute top-2 right-2 p-1.5 rounded-md hover:bg-accent transition-colors"
>
{inputExpanded ? (
<X className="h-5 w-5 text-muted-foreground" />
) : (
<Maximize2 className="h-5 w-5 text-muted-foreground" />
)}
</button>
</TooltipTrigger>
<TooltipContent>
{inputExpanded ? 'Minimize' : 'Expand input'}
</TooltipContent>
</Tooltip>
</div>
</div>
<div className="flex items-center justify-between mt-2 px-1">
<div className="flex items-center gap-2">
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={handleFileSelect}
className="p-1.5 rounded-md hover:bg-accent transition-colors"
>
<Paperclip className="h-4 w-4 text-muted-foreground" />
</button>
</TooltipTrigger>
<TooltipContent>Attach files</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center gap-1.5 px-2 py-1 rounded-md cursor-default">
<Settings className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-xs text-muted-foreground">
{displayModel}
</span>
</div>
</TooltipTrigger>
<TooltipContent>Current model</TooltipContent>
</Tooltip>
{/* TODO: Enable when Coder Model supports thinking mode
<div className="flex items-center gap-1.5">
<span className="text-xs text-muted-foreground">
Thinking
</span>
<Switch
checked={thinkingEnabled}
onCheckedChange={setThinkingEnabled}
/>
</div>
*/}
</div>
<span className="text-xs text-muted-foreground">
Press Enter to send, Shift+Enter for new line
</span>
</div>
</form>
</div>
{/* Permission request modal */}
{permissionRequest && (
<div className="absolute inset-0 bg-black/40 backdrop-blur-sm flex items-center justify-center z-50">
<div className="bg-popover rounded-xl p-6 max-w-md w-full mx-4 shadow-xl border border-border">
<h3 className="text-lg font-semibold mb-3 text-foreground">
Permission Request
</h3>
<p className="text-sm text-muted-foreground mb-5">
{permissionRequest.description ||
`Allow ${permissionRequest.operation}?`}
</p>
<div className="flex gap-3 justify-end">
<Button
onClick={() => onPermissionResponse('deny')}
variant="outline"
>
Deny
</Button>
<Button
onClick={() => onPermissionResponse('allow_once')}
className="bg-gradient-to-r from-violet-500 to-purple-600 hover:from-violet-600 hover:to-purple-700"
>
Allow
</Button>
</div>
</div>
</div>
)}
</main>
</TooltipProvider>
);
}

View file

@ -4,185 +4,293 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { useState, useMemo, Fragment } from 'react';
import { groupSessionsByDate, getTimeAgo } from '@qwen-code/webui';
import type { Session } from '../../shared/types.js';
import { memo, useCallback, useMemo, useState } from 'react';
import {
Plus,
Trash2,
Search,
X,
RefreshCw,
PanelLeftOpen,
PanelLeftClose,
} from 'lucide-react';
import { cn } from '../lib/utils';
import { QwenCodeBrand } from './ui/qwen-code-brand';
import { ThemeToggle } from './ui/theme-toggle';
import {
Tooltip,
TooltipContent,
TooltipTrigger,
TooltipProvider,
} from './ui/tooltip';
import type { Session } from '../../shared/types';
type Theme = 'light' | 'dark' | 'system';
interface SidebarProps {
sessions: Session[];
currentSessionId: string | null;
onSelectSession: (id: string) => void;
onCreateSession: () => void;
onDeleteSession: (id: string) => void;
onRefresh: () => void;
isLoading: boolean;
collapsed: boolean;
onToggleCollapse: () => void;
theme: Theme;
onToggleTheme: () => void;
version: string;
}
export function Sidebar({
function formatRelativeTime(date: string | Date): string {
const now = new Date();
const d = typeof date === 'string' ? new Date(date) : date;
const diffMs = now.getTime() - d.getTime();
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMs / 3600000);
const diffDays = Math.floor(diffMs / 86400000);
if (diffMins < 1) return 'just now';
if (diffMins < 60) return `${diffMins}m`;
if (diffHours < 24) return `${diffHours}h`;
if (diffDays < 7) return `${diffDays}d`;
return d.toLocaleDateString();
}
export const Sidebar = memo(({
sessions,
currentSessionId,
onSelectSession,
onCreateSession,
onDeleteSession,
onRefresh,
isLoading,
}: SidebarProps) {
isLoading: _isLoading,
collapsed,
onToggleCollapse,
theme,
onToggleTheme,
version,
}: SidebarProps) => {
const [searchQuery, setSearchQuery] = useState('');
const [isRefreshing, setIsRefreshing] = useState(false);
const handleClearSearch = useCallback(() => {
setSearchQuery('');
}, []);
// Filter sessions by search query
const filteredSessions = useMemo(() => {
if (!searchQuery.trim()) return sessions;
const query = searchQuery.toLowerCase();
return sessions.filter((s) => s.title.toLowerCase().includes(query));
}, [sessions, searchQuery]);
// Group sessions by date
const groupedSessions = useMemo(() => {
// Convert sessions to the format expected by groupSessionsByDate
const sessionsWithDate = filteredSessions.map((s) => ({
...s,
sessionId: s.id,
lastUpdated: s.lastUpdated,
}));
return groupSessionsByDate(sessionsWithDate);
}, [filteredSessions]);
const handleRefresh = useCallback(async () => {
if (isRefreshing) return;
setIsRefreshing(true);
try {
await Promise.resolve(onRefresh());
} finally {
setTimeout(() => setIsRefreshing(false), 500);
}
}, [onRefresh, isRefreshing]);
const handleDeleteSession = useCallback(
(sessionId: string, sessionTitle: string) => {
const confirmed = window.confirm(
`Delete session "${sessionTitle || 'Untitled'}"?`,
);
if (confirmed) {
onDeleteSession(sessionId);
}
},
[onDeleteSession],
);
// Collapsed sidebar
if (collapsed) {
return (
<TooltipProvider>
<aside className="flex h-full w-12 flex-col items-center border-r border-border bg-background py-3">
{/* Logo */}
<div className="flex h-7 w-7 items-center justify-center rounded-lg bg-gradient-to-br from-violet-500 to-purple-600">
<span className="text-white font-bold text-sm">Q</span>
</div>
{/* Spacer */}
<div className="flex-1" />
{/* Footer actions */}
<div className="flex flex-col items-center gap-2">
<ThemeToggle
theme={theme}
onToggle={onToggleTheme}
className="h-8 w-8"
/>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
aria-label="Expand sidebar"
className="inline-flex h-8 w-8 cursor-pointer items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-secondary/50 hover:text-foreground"
onClick={onToggleCollapse}
>
<PanelLeftOpen className="h-4 w-4" />
</button>
</TooltipTrigger>
<TooltipContent side="right">Expand sidebar</TooltipContent>
</Tooltip>
</div>
</aside>
</TooltipProvider>
);
}
return (
<aside className="w-64 border-r border-[var(--app-border)] flex flex-col bg-[var(--app-secondary-background)]">
{/* Brand header */}
<div className="p-4 border-b border-[var(--app-border)]">
<h1 className="text-lg font-semibold text-[var(--app-primary-foreground)]">
Qwen Code
</h1>
<span className="text-xs text-[var(--app-secondary-foreground)]">
Web GUI
</span>
</div>
{/* Sessions header */}
<div className="px-4 py-2 flex items-center justify-between">
<span className="text-xs font-medium text-[var(--app-secondary-foreground)] uppercase tracking-wider">
Sessions
</span>
<div className="flex gap-1">
<button
onClick={onRefresh}
disabled={isLoading}
className="p-1.5 rounded hover:bg-[var(--app-list-hover-background)] transition-colors"
title="Refresh sessions"
>
<svg
className={`w-4 h-4 text-[var(--app-secondary-foreground)] ${isLoading ? 'animate-spin' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
/>
</svg>
</button>
<button
onClick={onCreateSession}
className="p-1.5 rounded hover:bg-[var(--app-list-hover-background)] transition-colors"
title="New session"
>
<svg
className="w-4 h-4 text-[var(--app-secondary-foreground)]"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 4v16m8-8H4"
/>
</svg>
</button>
</div>
</div>
{/* Search box */}
<div className="px-4 py-2">
<div className="flex items-center gap-2 px-2 py-1.5 rounded bg-[var(--app-input-background)]">
<svg
className="w-4 h-4 text-[var(--app-secondary-foreground)] opacity-60"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
<input
type="text"
placeholder="Search sessions..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="flex-1 bg-transparent border-none outline-none text-sm text-[var(--app-primary-foreground)] placeholder:text-[var(--app-input-placeholder-foreground)]"
/>
</div>
</div>
{/* Session list */}
<div className="flex-1 overflow-y-auto px-2 py-1">
{groupedSessions.length === 0 ? (
<div className="px-2 py-8 text-center text-sm text-[var(--app-secondary-foreground)]">
{searchQuery ? 'No matching sessions' : 'No sessions yet'}
<TooltipProvider>
<aside className="flex h-full min-h-0 flex-col">
<div className="flex min-h-0 flex-1 flex-col gap-2 overflow-hidden">
{/* Brand */}
<div className="flex items-center justify-between px-4 pt-4">
<QwenCodeBrand size="sm" showVersion={true} version={version} />
</div>
) : (
groupedSessions.map((group) => (
<Fragment key={group.label}>
<div className="px-2 py-1.5 text-xs font-medium text-[var(--app-secondary-foreground)] opacity-60 mt-2 first:mt-0">
{group.label}
</div>
{group.sessions.map((session) => {
const sessionId =
(session.id as string) || (session.sessionId as string) || '';
const title = (session.title as string) || 'Untitled';
const lastUpdated =
(session.lastUpdated as string) ||
(session.startTime as string) ||
'';
const isActive = sessionId === currentSessionId;
return (
{/* Sessions header */}
<div className="flex items-center justify-between px-3 pt-3">
<h4 className="text-sm font-medium text-muted-foreground">
Sessions
</h4>
<div className="flex items-center gap-1">
<Tooltip>
<TooltipTrigger asChild>
<button
key={sessionId}
onClick={() => onSelectSession(sessionId)}
className={`w-full text-left px-2 py-2 rounded text-sm flex justify-between items-center gap-2 transition-colors ${
isActive
? 'bg-[var(--app-list-active-background)] text-[var(--app-list-active-foreground)] font-medium'
: 'text-[var(--app-primary-foreground)] hover:bg-[var(--app-list-hover-background)]'
}`}
aria-label="Refresh sessions"
className="cursor-pointer rounded-md p-1 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:pointer-events-none disabled:opacity-60"
onClick={handleRefresh}
disabled={isRefreshing}
type="button"
>
<span className="truncate flex-1 min-w-0">{title}</span>
<span
className={`text-xs flex-shrink-0 ${isActive ? 'opacity-80' : 'opacity-50'}`}
>
{getTimeAgo(lastUpdated)}
</span>
<RefreshCw
className={cn(
'h-3.5 w-3.5',
isRefreshing && 'animate-spin',
)}
/>
</button>
);
})}
</Fragment>
))
)}
</div>
</TooltipTrigger>
<TooltipContent>Refresh sessions</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<button
aria-label="New Session"
className="cursor-pointer rounded-md p-1 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
onClick={onCreateSession}
type="button"
>
<Plus className="h-4 w-4" />
</button>
</TooltipTrigger>
<TooltipContent>New session</TooltipContent>
</Tooltip>
</div>
</div>
{/* Footer */}
<div className="p-3 border-t border-[var(--app-border)] flex items-center justify-between">
<span className="text-xs text-[var(--app-secondary-foreground)]">
{sessions.length} session{sessions.length !== 1 ? 's' : ''}
</span>
</div>
</aside>
{/* Search */}
<div className="px-2">
<div className="relative">
<Search className="absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
<input
type="text"
placeholder="Search sessions..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="h-8 w-full rounded-md border border-input bg-background pl-8 pr-8 text-xs placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring"
/>
{searchQuery && (
<button
type="button"
onClick={handleClearSearch}
className="absolute right-2 top-1/2 -translate-y-1/2 cursor-pointer rounded-sm p-0.5 text-muted-foreground hover:text-foreground"
aria-label="Clear search"
>
<X className="h-3.5 w-3.5" />
</button>
)}
</div>
</div>
{/* Session list */}
<div className="flex-1 overflow-y-auto px-2 pb-4">
{filteredSessions.length === 0 ? (
<div className="px-2 py-8 text-center text-sm text-muted-foreground">
{searchQuery ? 'No matching sessions' : 'No sessions yet'}
</div>
) : (
<ul className="space-y-1">
{filteredSessions.map((session) => {
const isActive = session.id === currentSessionId;
return (
<li key={session.id} className="group relative">
<button
className={cn(
'w-full cursor-pointer text-left rounded-lg px-3 py-2.5 transition-colors',
isActive ? 'bg-secondary' : 'hover:bg-secondary/60',
)}
onClick={() => onSelectSession(session.id)}
type="button"
>
<p className="text-sm font-medium text-foreground truncate pr-6">
{session.title || 'Untitled'}
</p>
<span className="text-[10px] text-muted-foreground mt-0.5 block">
{formatRelativeTime(session.lastUpdated)}
</span>
</button>
{/* Delete button */}
<button
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 rounded-md transition-all text-muted-foreground opacity-0 group-hover:opacity-100 hover:bg-destructive/10 hover:text-destructive"
onClick={(e) => {
e.stopPropagation();
handleDeleteSession(session.id, session.title);
}}
title="Delete session"
type="button"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</li>
);
})}
</ul>
)}
</div>
</div>
{/* Footer */}
<div className="flex items-center justify-between px-3 py-2">
<div className="flex items-center gap-2">
<ThemeToggle theme={theme} onToggle={onToggleTheme} />
<span className="text-xs text-muted-foreground">
{sessions.length} session{sessions.length !== 1 ? 's' : ''}
</span>
</div>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
aria-label="Collapse sidebar"
className="inline-flex h-8 w-8 cursor-pointer items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-secondary/50 hover:text-foreground"
onClick={onToggleCollapse}
>
<PanelLeftClose className="h-4 w-4" />
</button>
</TooltipTrigger>
<TooltipContent>Collapse sidebar</TooltipContent>
</Tooltip>
</div>
</aside>
</TooltipProvider>
);
}
});
Sidebar.displayName = 'Sidebar';

View file

@ -0,0 +1,57 @@
import * as React from 'react';
import { Slot } from '@radix-ui/react-slot';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '../../lib/utils';
const buttonVariants = cva(
'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50',
{
variants: {
variant: {
default:
'bg-primary text-primary-foreground shadow hover:bg-primary/90',
destructive:
'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90',
outline:
'border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground',
secondary:
'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {
default: 'h-9 px-4 py-2',
sm: 'h-8 rounded-md px-3 text-xs',
lg: 'h-10 rounded-md px-8',
icon: 'h-9 w-9',
'icon-sm': 'h-8 w-8',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
},
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : 'button';
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
);
},
);
Button.displayName = 'Button';
export { Button, buttonVariants };

View file

@ -0,0 +1,43 @@
import { cn } from '../../lib/utils';
const qwenCodeLogo = new URL('../../assets/icon.png', import.meta.url).href;
type QwenCodeBrandProps = {
className?: string;
size?: 'sm' | 'md';
showVersion?: boolean;
version?: string;
};
export function QwenCodeBrand({
className,
size = 'md',
showVersion = true,
version = '0.1.0',
}: QwenCodeBrandProps) {
const textSizeClass = size === 'sm' ? 'text-base' : 'text-lg';
const versionPadding = size === 'sm' ? 'text-xs' : 'text-sm';
const logoSize = size === 'sm' ? 'h-6 w-6' : 'h-7 w-7';
return (
<div className={cn('flex items-center gap-2', className)}>
<div className="flex items-center gap-2">
<img
src={qwenCodeLogo}
alt="Qwen Code"
className={cn(logoSize, 'rounded-lg')}
/>
<span className={cn(textSizeClass, 'font-semibold text-foreground')}>
Qwen Code
</span>
</div>
{showVersion && (
<span
className={cn('text-muted-foreground font-medium', versionPadding)}
>
v{version}
</span>
)}
</div>
);
}

View file

@ -0,0 +1,42 @@
import { GripVertical } from 'lucide-react';
import * as ResizablePrimitive from 'react-resizable-panels';
import { cn } from '../../lib/utils';
const ResizablePanelGroup = ({
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive.PanelGroup>) => (
<ResizablePrimitive.PanelGroup
className={cn(
'flex h-full w-full data-[panel-group-direction=vertical]:flex-col',
className,
)}
{...props}
/>
);
const ResizablePanel = ResizablePrimitive.Panel;
const ResizableHandle = ({
withHandle,
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive.PanelResizeHandle> & {
withHandle?: boolean;
}) => (
<ResizablePrimitive.PanelResizeHandle
className={cn(
'relative flex w-px items-center justify-center bg-border/50 hover:bg-border after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:-translate-y-1/2 data-[panel-group-direction=vertical]:after:translate-x-0 [&[data-panel-group-direction=vertical]>div]:rotate-90 transition-colors',
className,
)}
{...props}
>
{withHandle && (
<div className="z-10 flex h-4 w-3 items-center justify-center rounded-sm border bg-border">
<GripVertical className="h-2.5 w-2.5" />
</div>
)}
</ResizablePrimitive.PanelResizeHandle>
);
export { ResizablePanelGroup, ResizablePanel, ResizableHandle };

View file

@ -0,0 +1,23 @@
import { Toaster as Sonner } from 'sonner';
type ToasterProps = React.ComponentProps<typeof Sonner>;
const Toaster = ({ ...props }: ToasterProps) => (
<Sonner
className="toaster group"
toastOptions={{
classNames: {
toast:
'group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg',
description: 'group-[.toast]:text-muted-foreground',
actionButton:
'group-[.toast]:bg-primary group-[.toast]:text-primary-foreground',
cancelButton:
'group-[.toast]:bg-muted group-[.toast]:text-muted-foreground',
},
}}
{...props}
/>
);
export { Toaster };

View file

@ -0,0 +1,26 @@
import * as React from 'react';
import * as SwitchPrimitive from '@radix-ui/react-switch';
import { cn } from '../../lib/utils';
const Switch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitive.Root>
>(({ className, ...props }, ref) => (
<SwitchPrimitive.Root
className={cn(
'peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input',
className,
)}
{...props}
ref={ref}
>
<SwitchPrimitive.Thumb
className={cn(
'pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0',
)}
/>
</SwitchPrimitive.Root>
));
Switch.displayName = SwitchPrimitive.Root.displayName;
export { Switch };

View file

@ -0,0 +1,39 @@
import { MoonIcon, SunIcon } from 'lucide-react';
import { cn } from '../../lib/utils';
import { Button } from './button';
type Theme = 'light' | 'dark' | 'system';
type ThemeToggleProps = {
className?: string;
theme: Theme;
onToggle: () => void;
};
export function ThemeToggle({ className, theme, onToggle }: ThemeToggleProps) {
const isDark =
theme === 'dark' ||
(theme === 'system' &&
typeof window !== 'undefined' &&
window.matchMedia('(prefers-color-scheme: dark)').matches);
return (
<Button
aria-label={isDark ? 'Switch to light mode' : 'Switch to dark mode'}
className={cn(
'h-9 w-9 p-0 text-foreground hover:text-foreground hover:bg-accent/50',
'cursor-pointer',
className,
)}
onClick={onToggle}
size="icon"
variant="ghost"
>
{isDark ? (
<SunIcon className="h-4 w-4" />
) : (
<MoonIcon className="h-4 w-4" />
)}
</Button>
);
}

View file

@ -0,0 +1,27 @@
import * as React from 'react';
import * as TooltipPrimitive from '@radix-ui/react-tooltip';
import { cn } from '../../lib/utils';
const TooltipProvider = TooltipPrimitive.Provider;
const Tooltip = TooltipPrimitive.Root;
const TooltipTrigger = TooltipPrimitive.Trigger;
const TooltipContent = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
'z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-xs text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
className,
)}
{...props}
/>
));
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };

View file

@ -11,17 +11,34 @@ import type {
WSMessage,
} from '../../shared/types.js';
interface SessionInfo {
version?: string;
model?: string;
contextWindow?: number;
}
interface UsageInfo {
inputTokens: number;
outputTokens: number;
totalTokens: number;
contextWindow: number;
}
interface UseWebSocketOptions {
onMessage: (message: Message) => void;
onHistory: (messages: Message[]) => void;
onSessionInfo?: (info: SessionInfo) => void;
onUsageUpdate?: (usage: UsageInfo) => void;
}
interface UseWebSocketReturn {
isConnected: boolean;
isStreaming: boolean;
permissionRequest: PermissionRequest | null;
sessionInfo: SessionInfo | null;
usage: UsageInfo | null;
send: (message: WSMessage) => void;
respondToPermission: (allow: boolean, scope: string) => void;
respondToPermission: (optionId: string) => void;
}
export function useWebSocket(
@ -32,12 +49,14 @@ export function useWebSocket(
const [isStreaming, setIsStreaming] = useState(false);
const [permissionRequest, setPermissionRequest] =
useState<PermissionRequest | null>(null);
const [sessionInfo, setSessionInfo] = useState<SessionInfo | null>(null);
const [usage, setUsage] = useState<UsageInfo | null>(null);
const wsRef = useRef<WebSocket | null>(null);
const reconnectTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(
null,
);
const { onMessage, onHistory } = options;
const { onMessage, onHistory, onSessionInfo, onUsageUpdate } = options;
// Connect to WebSocket
useEffect(() => {
@ -45,6 +64,10 @@ export function useWebSocket(
return;
}
// Reset usage when switching sessions
setUsage(null);
setSessionInfo(null);
const connect = () => {
// Determine WebSocket URL
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
@ -96,12 +119,37 @@ export function useWebSocket(
setIsStreaming(false);
break;
case 'session_info': {
const info: SessionInfo = {
version: data.version,
model: data.model,
contextWindow: data.contextWindow,
};
setSessionInfo(info);
onSessionInfo?.(info);
break;
}
case 'usage_update': {
const usageData: UsageInfo = {
inputTokens: data.inputTokens ?? 0,
outputTokens: data.outputTokens ?? 0,
totalTokens: data.totalTokens ?? 0,
contextWindow: data.contextWindow ?? 200000,
};
setUsage(usageData);
onUsageUpdate?.(usageData);
break;
}
case 'permission_request':
setPermissionRequest({
id: data.id,
operation: data.operation,
args: data.args || {},
description: data.description,
options: Array.isArray(data.options) ? data.options : undefined,
toolCall: data.toolCall,
});
break;
@ -111,6 +159,12 @@ export function useWebSocket(
case 'error':
console.error('WebSocket error:', data.message);
// Reset streaming state on error
setIsStreaming(false);
break;
default:
console.warn('Unhandled WebSocket message type:', data.type);
break;
}
} catch (err) {
@ -121,6 +175,7 @@ export function useWebSocket(
ws.onclose = (event) => {
console.log('WebSocket closed:', event.code, event.reason);
setIsConnected(false);
setIsStreaming(false);
wsRef.current = null;
// Attempt to reconnect after delay
@ -134,6 +189,7 @@ export function useWebSocket(
ws.onerror = (error) => {
console.error('WebSocket error:', error);
setIsStreaming(false);
};
};
@ -148,7 +204,7 @@ export function useWebSocket(
wsRef.current = null;
}
};
}, [sessionId, onMessage, onHistory]);
}, [sessionId, onMessage, onHistory, onSessionInfo, onUsageUpdate]);
// Send message
const send = useCallback((message: WSMessage) => {
@ -161,11 +217,10 @@ export function useWebSocket(
// Respond to permission request
const respondToPermission = useCallback(
(allow: boolean, scope: string) => {
(optionId: string) => {
send({
type: 'permission_response',
allow,
scope,
optionId,
requestId: permissionRequest?.id,
});
setPermissionRequest(null);
@ -177,6 +232,8 @@ export function useWebSocket(
isConnected,
isStreaming,
permissionRequest,
sessionInfo,
usage,
send,
respondToPermission,
};

View file

@ -0,0 +1,30 @@
import { type ClassValue, clsx } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
export function shortenTitle(title: string, maxLen = 50): string {
if (title.length <= maxLen) return title;
return title.slice(0, maxLen - 3) + '...';
}
export function formatRelativeTime(date: Date | string): string {
const now = new Date();
const d = typeof date === 'string' ? new Date(date) : date;
const diffMs = now.getTime() - d.getTime();
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMs / 3600000);
const diffDays = Math.floor(diffMs / 86400000);
if (diffMins < 1) return 'just now';
if (diffMins < 60) return `${diffMins}m ago`;
if (diffHours < 24) return `${diffHours}h ago`;
if (diffDays < 7) return `${diffDays}d ago`;
return d.toLocaleDateString();
}
export function isMacOS(): boolean {
return typeof navigator !== 'undefined' && /Mac/.test(navigator.platform);
}

View file

@ -8,6 +8,7 @@ import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { App } from './App.js';
import './styles/global.css';
import '@qwen-code/webui/styles.css';
const root = document.getElementById('root');
if (!root) {

View file

@ -4,46 +4,101 @@
* SPDX-License-Identifier: Apache-2.0
*/
@import "@qwen-code/webui/styles.css";
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
/* App colors - matching webui theme */
--app-primary-background: #1e1e1e;
--app-secondary-background: #252526;
--app-primary-foreground: #cccccc;
--app-secondary-foreground: #858585;
--app-border: #3c3c3c;
--app-input-background: #3c3c3c;
--app-input-placeholder-foreground: #6b6b6b;
--app-list-hover-background: #2a2d2e;
--app-list-active-background: #094771;
--app-list-active-foreground: #ffffff;
--app-menu-background: #252526;
--app-menu-foreground: #cccccc;
--app-button-background: #0e639c;
--app-button-foreground: #ffffff;
--app-button-hover-background: #1177bb;
--corner-radius-small: 4px;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 240 10% 3.9%;
--card: 0 0% 100%;
--card-foreground: 240 10% 3.9%;
--popover: 0 0% 100%;
--popover-foreground: 240 10% 3.9%;
--primary: 240 5.9% 10%;
--primary-foreground: 0 0% 98%;
--secondary: 240 4.8% 95.9%;
--secondary-foreground: 240 5.9% 10%;
--muted: 240 4.8% 95.9%;
--muted-foreground: 240 3.8% 46.1%;
--accent: 240 4.8% 95.9%;
--accent-foreground: 240 5.9% 10%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 98%;
--border: 240 5.9% 90%;
--input: 240 5.9% 90%;
--ring: 240 5.9% 10%;
--radius: 0.5rem;
/* WebUI component variables - Light theme */
--app-background: #ffffff;
--app-primary-background: #ffffff;
--app-primary-foreground: #1a1a1a;
--app-secondary-foreground: #6b7280;
--app-header-background: #f9fafb;
--app-input-background: #f3f4f6;
--app-input-border: #e5e7eb;
--app-list-active-background: #e5e7eb;
--app-list-hover-background: #f3f4f6;
--app-primary-border-color: #e5e7eb;
--app-message-user-background: #e5e7eb;
--app-message-assistant-background: #f9fafb;
--corner-radius-small: 0.375rem;
--app-list-gap: 0.5rem;
--app-list-border-radius: 0.5rem;
--app-font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
--app-font-mono: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace;
--vscode-chat-font-family: var(--app-font-sans);
--vscode-chat-font-size: 13px;
}
.dark {
--background: 240 10% 3.9%;
--foreground: 0 0% 98%;
--card: 240 10% 3.9%;
--card-foreground: 0 0% 98%;
--popover: 240 10% 3.9%;
--popover-foreground: 0 0% 98%;
--primary: 0 0% 98%;
--primary-foreground: 240 5.9% 10%;
--secondary: 240 3.7% 15.9%;
--secondary-foreground: 0 0% 98%;
--muted: 240 3.7% 15.9%;
--muted-foreground: 240 5% 64.9%;
--accent: 240 3.7% 15.9%;
--accent-foreground: 0 0% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 0% 98%;
--border: 240 3.7% 15.9%;
--input: 240 3.7% 15.9%;
--ring: 240 4.9% 83.9%;
/* WebUI component variables - Dark theme */
--app-background: #0a0a0a;
--app-primary-background: #0a0a0a;
--app-primary-foreground: #fafafa;
--app-secondary-foreground: #a1a1aa;
--app-header-background: #171717;
--app-input-background: #262626;
--app-input-border: #3f3f46;
--app-list-active-background: #3f3f46;
--app-list-hover-background: #262626;
--app-primary-border-color: #3f3f46;
--app-message-user-background: #3f3f46;
--app-message-assistant-background: #171717;
}
}
/* Light theme */
@media (prefers-color-scheme: light) {
:root {
--app-primary-background: #ffffff;
--app-secondary-background: #f3f3f3;
--app-primary-foreground: #333333;
--app-secondary-foreground: #666666;
--app-border: #e0e0e0;
--app-input-background: #ffffff;
--app-input-placeholder-foreground: #999999;
--app-list-hover-background: #e8e8e8;
--app-list-active-background: #0060c0;
--app-list-active-foreground: #ffffff;
--app-menu-background: #ffffff;
--app-menu-foreground: #333333;
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
font-family:
-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue',
Arial, sans-serif;
}
}
@ -53,20 +108,13 @@ body,
height: 100%;
margin: 0;
padding: 0;
overflow: hidden;
}
body {
font-family:
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Open Sans", "Helvetica Neue",
sans-serif;
background-color: var(--app-primary-background);
color: var(--app-primary-foreground);
}
/* Scrollbar styles */
/* Scrollbar styling */
::-webkit-scrollbar {
width: 8px;
height: 8px;
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
@ -74,11 +122,117 @@ body {
}
::-webkit-scrollbar-thumb {
background: var(--app-secondary-foreground);
border-radius: 4px;
opacity: 0.5;
background: hsl(var(--muted-foreground) / 0.3);
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
opacity: 0.8;
background: hsl(var(--muted-foreground) / 0.5);
}
/* Animation keyframes */
@keyframes fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes slide-in-from-bottom {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-in {
animation-duration: 200ms;
animation-fill-mode: both;
}
.fade-in-0 {
animation-name: fade-in;
}
/* ===========================================
WebUI Theme Overrides
These must come after @qwen-code/webui/styles.css import
to properly override the default dark theme variables
=========================================== */
/* Light theme class override for webui components */
.light-theme {
--app-primary-foreground: #1f2937;
--app-secondary-foreground: #6b7280;
--app-background: #ffffff;
--app-primary-background: #ffffff;
--app-background-secondary: #f3f4f6;
--app-secondary-background: #f3f4f6;
--app-background-tertiary: #e5e7eb;
--app-foreground: #1f2937;
--app-foreground-secondary: #6b7280;
--app-foreground-muted: #9ca3af;
--app-border: #e5e7eb;
--app-primary-border-color: #e5e7eb;
--app-input-background: #ffffff;
--app-input-border: #d1d5db;
--app-input-foreground: #1f2937;
--app-input-placeholder-foreground: #9ca3af;
--app-ghost-button-hover-background: rgba(0, 0, 0, 0.05);
--app-header-background: #f9fafb;
--app-list-hover-background: rgba(0, 0, 0, 0.05);
--app-list-active-background: #3b82f6;
--app-list-active-foreground: #ffffff;
--app-menu-background: #ffffff;
--app-menu-border: #e5e7eb;
--app-menu-foreground: #1f2937;
--app-tool-background: #ffffff;
--app-code-background: #f3f4f6;
--app-message-user-background: #e5e7eb;
--app-message-assistant-background: #f9fafb;
}
/* Dark theme class override for webui components */
.dark-theme {
--app-primary-foreground: #fafafa;
--app-secondary-foreground: #a1a1aa;
--app-background: #0a0a0a;
--app-primary-background: #0a0a0a;
--app-background-secondary: #171717;
--app-secondary-background: #171717;
--app-background-tertiary: #262626;
--app-foreground: #fafafa;
--app-foreground-secondary: #a1a1aa;
--app-foreground-muted: #71717a;
--app-border: #3f3f46;
--app-primary-border-color: #3f3f46;
--app-input-background: #262626;
--app-input-border: #3f3f46;
--app-input-foreground: #fafafa;
--app-input-placeholder-foreground: #71717a;
--app-ghost-button-hover-background: rgba(255, 255, 255, 0.1);
--app-header-background: #171717;
--app-list-hover-background: rgba(255, 255, 255, 0.1);
--app-list-active-background: #3f3f46;
--app-list-active-foreground: #ffffff;
--app-menu-background: #171717;
--app-menu-border: #3f3f46;
--app-menu-foreground: #fafafa;
--app-tool-background: #171717;
--app-code-background: #262626;
--app-message-user-background: #3f3f46;
--app-message-assistant-background: #171717;
}
/* ===========================================
WaitingMessage alignment fix
=========================================== */
.waiting-message-inner.assistant-message-loading::before {
top: 13px;
}

View file

@ -0,0 +1 @@
export const APP_VERSION = '0.1.0';

View file

@ -7,16 +7,14 @@
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));
/**
* Create Express application
*/
export function createApp(config: Config | null) {
export function createApp() {
const app = express();
// JSON body parser
@ -41,12 +39,8 @@ export function createApp(config: Config | null) {
next();
});
// Store config in app locals for routes to access
app.locals.config = config;
// API routes
app.use('/api/sessions', sessionsRouter());
app.use('/api/config', configRouter());
// Health check
app.get('/healthz', (_req, res) => {

View file

@ -0,0 +1,92 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { spawn } from 'node:child_process';
import { existsSync } from 'node:fs';
import path from 'node:path';
import process from 'node:process';
import { createRequire } from 'node:module';
const RESUME_ENV = 'QWEN_CODE_RESUME_SESSION_ID';
const CLI_PATH_ENV = 'QWEN_CODE_CLI_PATH';
function isFilePath(spec: string): boolean {
return spec.includes('/') || spec.includes('\\');
}
function resolveBundledCliPath(): string | null {
const candidates: string[] = [];
try {
const require = createRequire(import.meta.url);
const packageJsonPath = require.resolve('@qwen-code/sdk/package.json');
const packageRoot = path.dirname(packageJsonPath);
candidates.push(path.join(packageRoot, 'dist', 'cli', 'cli.js'));
} catch {
// Ignore resolution errors and fall back to other candidates.
}
const cwd = process.cwd();
candidates.push(path.join(cwd, 'dist', 'cli', 'cli.js'));
candidates.push(path.join(cwd, 'dist', 'cli.js'));
candidates.push(
path.join(cwd, 'packages', 'sdk-typescript', 'dist', 'cli', 'cli.js'),
);
for (const candidate of candidates) {
if (existsSync(candidate)) {
return candidate;
}
}
return null;
}
function resolveCliCommand(): { command: string; args: string[] } {
const explicitPath = process.env[CLI_PATH_ENV];
if (explicitPath) {
if (isFilePath(explicitPath)) {
const ext = path.extname(explicitPath).toLowerCase();
if (['.js', '.mjs', '.cjs'].includes(ext)) {
return { command: process.execPath, args: [explicitPath] };
}
}
return { command: explicitPath, args: [] };
}
const bundledPath = resolveBundledCliPath();
if (bundledPath) {
return { command: process.execPath, args: [bundledPath] };
}
return { command: 'qwen', args: [] };
}
const cliArgs = process.argv.slice(2);
const resumeSessionId = process.env[RESUME_ENV];
if (resumeSessionId) {
cliArgs.push('--resume', resumeSessionId);
}
const { command, args } = resolveCliCommand();
const child = spawn(command, [...args, ...cliArgs], {
stdio: 'inherit',
env: process.env,
cwd: process.cwd(),
});
child.on('exit', (code, signal) => {
if (signal) {
process.exit(1);
} else {
process.exit(code ?? 0);
}
});
child.on('error', (error) => {
console.error('Failed to start Qwen CLI:', error);
process.exit(1);
});

View file

@ -6,15 +6,17 @@
import { createServer } from 'http';
import { WebSocketServer } from 'ws';
import type { Config } from '@qwen-code/qwen-code-core';
import { createApp } from './app.js';
import { setupWebSocket } from './websocket/handler.js';
import { findAvailablePort } from './utils/port.js';
import { pathToFileURL } from 'url';
export interface StartServerOptions {
port?: number;
host?: string;
config: Config | null;
cwd?: string;
// Deprecated, kept for API compatibility.
config?: unknown;
}
/**
@ -23,22 +25,16 @@ export interface StartServerOptions {
export async function startServer(
options: StartServerOptions,
): Promise<number> {
const { port = 5494, host = '127.0.0.1', config } = options;
const { port = 5494, host = '127.0.0.1' } = options;
// Find available port
const actualPort = await findAvailablePort(host, port);
// Create Express app
const app = createApp(config);
// Create HTTP server
const app = createApp();
const server = createServer(app);
// Create WebSocket server
const wss = new WebSocketServer({ server, path: '/ws' });
setupWebSocket(wss, config);
setupWebSocket(wss);
// Start server
return new Promise((resolve, reject) => {
server.on('error', reject);
server.listen(actualPort, host, () => {
@ -49,3 +45,25 @@ export async function startServer(
}
export { createApp } from './app.js';
async function startFromEnv(): Promise<void> {
const env = process.env as Record<string, string | undefined>;
const host = env['QWEN_CODE_WEB_HOST'] ?? env['WEB_APP_HOST'] ?? '127.0.0.1';
const port = Number(env['QWEN_CODE_WEB_PORT'] ?? env['WEB_APP_PORT']) || 5495;
await startServer({ host, port });
}
function isMainModule(): boolean {
if (!process.argv[1]) {
return false;
}
return import.meta.url === pathToFileURL(process.argv[1]).href;
}
if (isMainModule()) {
startFromEnv().catch((error) => {
console.error('Failed to start Web GUI server:', error);
process.exit(1);
});
}

View file

@ -6,36 +6,25 @@
import { Router } from 'express';
import type { Request, Response } from 'express';
import type { Config } from '@qwen-code/qwen-code-core';
import type { Session, SessionsListResponse } from '../../shared/types.js';
/**
* Get config from request app locals
*/
function getConfig(req: Request): Config | null {
return req.app.locals.config as Config | null;
}
import { SessionService } from '@qwen-code/qwen-code-core';
import { createSession, removeSession } from '../sessionManager.js';
/**
* Sessions API router
*/
export function sessionsRouter() {
const router = Router();
const cwd = process.cwd();
const sessionService = new SessionService(cwd);
/**
* GET /api/sessions - List all sessions
*/
router.get('/', async (req: Request, res: Response) => {
const config = getConfig(req);
if (!config) {
return res.status(500).json({ error: 'Configuration not available' });
}
try {
const limit = parseInt(req.query.limit as string) || 50;
const offset = parseInt(req.query.offset as string) || 0;
const limit = parseInt(req.query.limit as string, 10) || 50;
const sessionService = config.getSessionService();
const result = await sessionService.listSessions({ size: limit });
const response: SessionsListResponse = {
@ -61,14 +50,10 @@ export function sessionsRouter() {
/**
* POST /api/sessions - Create a new session
*/
router.post('/', async (req: Request, res: Response) => {
const config = getConfig(req);
if (!config) {
return res.status(500).json({ error: 'Configuration not available' });
}
router.post('/', async (_req: Request, res: Response) => {
try {
const sessionId = config.startNewSession();
const runner = await createSession(cwd);
const sessionId = runner.getSessionId();
const session: Session = {
id: sessionId,
@ -90,13 +75,7 @@ export function sessionsRouter() {
* GET /api/sessions/:id - Get session details
*/
router.get('/:id', async (req: Request, res: Response) => {
const config = getConfig(req);
if (!config) {
return res.status(500).json({ error: 'Configuration not available' });
}
try {
const sessionService = config.getSessionService();
const session = await sessionService.loadSession(req.params.id);
if (!session) {
@ -122,19 +101,15 @@ export function sessionsRouter() {
* DELETE /api/sessions/:id - Delete a session
*/
router.delete('/:id', async (req: Request, res: Response) => {
const config = getConfig(req);
if (!config) {
return res.status(500).json({ error: 'Configuration not available' });
}
try {
const sessionService = config.getSessionService();
const success = await sessionService.removeSession(req.params.id);
if (!success) {
return res.status(404).json({ error: 'Session not found' });
}
removeSession(req.params.id);
res.status(204).send();
} catch (error) {
console.error('Error deleting session:', error);

View file

@ -0,0 +1,46 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { SessionRunner } from './websocket/sessionRunner.js';
const sessionRunners = new Map<string, SessionRunner>();
export async function createSession(
cwd: string = process.cwd(),
): Promise<SessionRunner> {
const runner = await SessionRunner.createNew(cwd);
const sessionId = runner.getSessionId();
sessionRunners.set(sessionId, runner);
return runner;
}
export function getOrCreateSession(
sessionId: string,
cwd: string = process.cwd(),
): SessionRunner {
let runner = sessionRunners.get(sessionId);
if (!runner) {
runner = new SessionRunner(sessionId, cwd);
sessionRunners.set(sessionId, runner);
}
return runner;
}
export function getSession(sessionId: string): SessionRunner | undefined {
return sessionRunners.get(sessionId);
}
export function removeSession(sessionId: string): void {
const runner = sessionRunners.get(sessionId);
if (runner) {
void runner.shutdown();
sessionRunners.delete(sessionId);
}
}
export function getActiveSessions(): string[] {
return Array.from(sessionRunners.keys());
}

View file

@ -5,68 +5,49 @@
*/
import type { WebSocket, WebSocketServer } from 'ws';
import type { Config } from '@qwen-code/qwen-code-core';
import type { WSMessage } from '../../shared/types.js';
import { SessionRunner } from './sessionRunner.js';
import { SessionService } from '@qwen-code/qwen-code-core';
import {
getOrCreateSession,
getSession,
removeSession,
} from '../sessionManager.js';
interface ClientState {
sessionId: string | null;
}
// Global map of session runners
const sessionRunners = new Map<string, SessionRunner>();
/**
* Get or create a session runner for the given session ID
*/
function getOrCreateRunner(sessionId: string, config: Config | null): SessionRunner {
let runner = sessionRunners.get(sessionId);
if (!runner) {
runner = new SessionRunner(sessionId, config);
sessionRunners.set(sessionId, runner);
}
return runner;
}
/**
* Setup WebSocket server
*/
export function setupWebSocket(wss: WebSocketServer, config: Config | null) {
export function setupWebSocket(wss: WebSocketServer) {
const clientStates = new WeakMap<WebSocket, ClientState>();
wss.on('connection', (ws: WebSocket) => {
console.log('WebSocket client connected');
clientStates.set(ws, { sessionId: null });
ws.on('message', async (data) => {
try {
const message: WSMessage = JSON.parse(data.toString());
const state = clientStates.get(ws);
if (!state) return;
if (!state) {
return;
}
switch (message.type) {
case 'join_session':
await handleJoinSession(ws, state, message, config);
await handleJoinSession(ws, state, message);
break;
case 'leave_session':
handleLeaveSession(ws, state);
break;
case 'user_message':
await handleUserMessage(ws, state, message, config);
await handleUserMessage(ws, state, message);
break;
case 'cancel':
handleCancel(state);
break;
case 'permission_response':
handlePermissionResponse(state, message);
break;
default:
ws.send(
JSON.stringify({
@ -90,12 +71,11 @@ export function setupWebSocket(wss: WebSocketServer, config: Config | null) {
console.log('WebSocket client disconnected');
const state = clientStates.get(ws);
if (state?.sessionId) {
const runner = sessionRunners.get(state.sessionId);
const runner = getSession(state.sessionId);
if (runner) {
runner.removeClient(ws);
// Clean up runner if no clients left
if (runner.clientCount === 0) {
sessionRunners.delete(state.sessionId);
removeSession(state.sessionId);
}
}
}
@ -108,14 +88,10 @@ export function setupWebSocket(wss: WebSocketServer, config: Config | null) {
});
}
/**
* Handle join session request
*/
async function handleJoinSession(
ws: WebSocket,
state: ClientState,
message: WSMessage,
config: Config | null,
) {
const sessionId = message.sessionId as string;
if (!sessionId) {
@ -123,21 +99,27 @@ async function handleJoinSession(
return;
}
// Leave previous session if any
if (state.sessionId) {
const prevRunner = sessionRunners.get(state.sessionId);
const prevRunner = getSession(state.sessionId);
if (prevRunner) {
prevRunner.removeClient(ws);
}
}
state.sessionId = sessionId;
// Get or create runner and add client
const runner = getOrCreateRunner(sessionId, config);
let runner = getSession(sessionId);
if (!runner) {
const sessionService = new SessionService(process.cwd());
const exists = await sessionService.sessionExists(sessionId);
if (!exists) {
ws.send(JSON.stringify({ type: 'error', message: 'Session not found' }));
state.sessionId = null;
return;
}
runner = getOrCreateSession(sessionId);
}
runner.addClient(ws);
// Load and send history
try {
const history = await runner.getHistory();
ws.send(JSON.stringify({ type: 'history', messages: history }));
@ -155,30 +137,26 @@ async function handleJoinSession(
);
}
/**
* Handle leave session request
*/
function handleLeaveSession(ws: WebSocket, state: ClientState) {
if (state.sessionId) {
const runner = sessionRunners.get(state.sessionId);
if (runner) {
runner.removeClient(ws);
if (runner.clientCount === 0) {
sessionRunners.delete(state.sessionId);
}
}
state.sessionId = null;
if (!state.sessionId) {
return;
}
const runner = getSession(state.sessionId);
if (runner) {
runner.removeClient(ws);
if (runner.clientCount === 0) {
removeSession(state.sessionId);
}
}
state.sessionId = null;
}
/**
* Handle user message
*/
async function handleUserMessage(
ws: WebSocket,
state: ClientState,
message: WSMessage,
config: Config | null,
) {
if (!state.sessionId) {
ws.send(JSON.stringify({ type: 'error', message: 'Not in a session' }));
@ -193,34 +171,27 @@ async function handleUserMessage(
return;
}
const runner = getOrCreateRunner(state.sessionId, config);
const runner = getOrCreateSession(state.sessionId);
await runner.handleUserMessage(content.trim());
}
/**
* Handle cancel request
*/
function handleCancel(state: ClientState) {
if (state.sessionId) {
const runner = sessionRunners.get(state.sessionId);
if (runner) {
runner.cancel();
}
if (!state.sessionId) {
return;
}
const runner = getSession(state.sessionId);
runner?.cancel();
}
/**
* Handle permission response
*/
function handlePermissionResponse(state: ClientState, message: WSMessage) {
if (state.sessionId) {
const runner = sessionRunners.get(state.sessionId);
if (runner) {
runner.handlePermissionResponse({
allow: message.allow as boolean,
scope: message.scope as string,
requestId: message.requestId as string | undefined,
});
}
if (!state.sessionId) {
return;
}
const runner = getSession(state.sessionId);
runner?.handlePermissionResponse({
optionId: message.optionId as string,
requestId: message.requestId as string | undefined,
});
}

File diff suppressed because it is too large Load diff

View file

@ -30,17 +30,50 @@ export type MessageType =
*/
export interface MessagePart {
text: string;
thought?: boolean;
}
/**
* Tool call content
*/
export interface ToolCallContent {
type: 'content' | 'diff';
content?: {
type: string;
text?: string;
error?: unknown;
[key: string]: unknown;
};
path?: string;
oldText?: string | null;
newText?: string;
}
/**
* Tool call location
*/
export interface ToolCallLocation {
path: string;
line?: number | null;
}
/**
* Tool call status
*/
export type ToolCallStatus = 'pending' | 'in_progress' | 'completed' | 'failed';
/**
* Tool call data
*/
export interface ToolCallData {
name: string;
args: Record<string, unknown>;
status: 'pending' | 'running' | 'success' | 'error';
result?: unknown;
error?: string;
toolCallId: string;
kind: string;
title: string | object;
status: ToolCallStatus;
rawInput?: string | object;
content?: ToolCallContent[];
locations?: ToolCallLocation[];
timestamp?: number;
}
/**
@ -55,7 +88,7 @@ export interface Message {
message?: {
role: string;
parts?: MessagePart[];
content?: string;
content?: string | unknown[];
};
toolCall?: ToolCallData;
}
@ -63,11 +96,19 @@ export interface Message {
/**
* Permission request
*/
export interface PermissionOption {
name: string;
kind: string;
optionId: string;
}
export interface PermissionRequest {
id: string;
operation: string;
args: Record<string, unknown>;
description?: string;
options?: PermissionOption[];
toolCall?: ToolCallData;
}
/**

View file

@ -1,13 +1,57 @@
import qwenWebuiPreset from '@qwen-code/webui/tailwind.preset';
/** @type {import('tailwindcss').Config} */
export default {
darkMode: ['class'],
content: [
'./index.html',
'./src/client/**/*.{js,ts,jsx,tsx}',
'../webui/src/**/*.{js,ts,jsx,tsx}',
],
presets: [require('@qwen-code/webui/tailwind.preset')],
presets: [qwenWebuiPreset],
theme: {
extend: {},
extend: {
colors: {
border: 'hsl(var(--border))',
input: 'hsl(var(--input))',
ring: 'hsl(var(--ring))',
background: 'hsl(var(--background))',
foreground: 'hsl(var(--foreground))',
primary: {
DEFAULT: 'hsl(var(--primary))',
foreground: 'hsl(var(--primary-foreground))',
},
secondary: {
DEFAULT: 'hsl(var(--secondary))',
foreground: 'hsl(var(--secondary-foreground))',
},
destructive: {
DEFAULT: 'hsl(var(--destructive))',
foreground: 'hsl(var(--destructive-foreground))',
},
muted: {
DEFAULT: 'hsl(var(--muted))',
foreground: 'hsl(var(--muted-foreground))',
},
accent: {
DEFAULT: 'hsl(var(--accent))',
foreground: 'hsl(var(--accent-foreground))',
},
popover: {
DEFAULT: 'hsl(var(--popover))',
foreground: 'hsl(var(--popover-foreground))',
},
card: {
DEFAULT: 'hsl(var(--card))',
foreground: 'hsl(var(--card-foreground))',
},
},
borderRadius: {
lg: 'var(--radius)',
md: 'calc(var(--radius) - 2px)',
sm: 'calc(var(--radius) - 4px)',
},
},
},
plugins: [],
};

View file

@ -3,6 +3,11 @@
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"baseUrl": ".",
"paths": {
"@qwen-code/webui": ["../webui/src/index.ts"],
"@qwen-code/webui/*": ["../webui/src/*"]
},
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"jsx": "react-jsx",
"strict": true,
@ -12,7 +17,7 @@
"declaration": true,
"declarationMap": true,
"outDir": "./dist",
"rootDir": "./src",
"rootDir": "..",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true

View file

@ -3,6 +3,7 @@
"compilerOptions": {
"module": "ESNext",
"moduleResolution": "bundler",
"rootDir": "./src",
"outDir": "./dist",
"noEmit": false,
"declaration": true

View file

@ -2,23 +2,27 @@ import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import path from 'path';
const backendPort =
Number(process.env['QWEN_CODE_WEB_PORT'] ?? process.env['WEB_APP_PORT']) ||
5495;
export default defineConfig({
plugins: [react()],
root: '.',
publicDir: 'public',
build: {
outDir: 'dist/client',
emptyDirFirst: true,
emptyOutDir: true,
},
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:5494',
target: `http://localhost:${backendPort}`,
changeOrigin: true,
},
'/ws': {
target: 'ws://localhost:5494',
target: `ws://localhost:${backendPort}`,
ws: true,
},
},
@ -26,6 +30,10 @@ export default defineConfig({
resolve: {
alias: {
'@': path.resolve(__dirname, 'src/client'),
'@qwen-code/webui/styles.css': path.resolve(
__dirname,
'../webui/dist/styles.css',
),
},
},
});