mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-08-11 09:46:05 +00:00
wip(web-app): built an initial web app using node
This commit is contained in:
parent
4b73d85e6c
commit
4bce8317aa
60 changed files with 4473 additions and 4667 deletions
|
|
@ -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 -->
|
||||
|
|
@ -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 -->
|
||||
|
|
@ -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 -->
|
||||
|
|
@ -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 -->
|
||||
|
|
@ -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 -->
|
||||
|
|
@ -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 -->
|
||||
|
|
@ -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 -->
|
||||
|
|
@ -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 -->
|
||||
|
|
@ -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 -->
|
||||
|
|
@ -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 子进程)
|
||||
```
|
||||
|
|
@ -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
2
.gitignore
vendored
|
|
@ -22,7 +22,7 @@ package-lock.json
|
|||
# Editors
|
||||
.idea
|
||||
*.iml
|
||||
# .cursor
|
||||
.cursor
|
||||
.qoder
|
||||
|
||||
# OS metadata
|
||||
|
|
|
|||
18
AGENTS.md
18
AGENTS.md
|
|
@ -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
18
QWEN.md
|
|
@ -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 -->
|
||||
|
|
@ -122,9 +122,7 @@ export default tseslint.config(
|
|||
'yargs/**',
|
||||
'msw/node',
|
||||
'**/generated/**',
|
||||
'./styles/tailwind.css',
|
||||
'./styles/App.css',
|
||||
'./styles/style.css'
|
||||
'**/styles/*.css'
|
||||
],
|
||||
},
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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 1–2 clarifying questions before scaffolding
|
||||
|
||||
### Search Guidance
|
||||
- Enumerate specs: `openspec spec list --long` (or `--json` for scripts)
|
||||
- Enumerate changes: `openspec list` (or `openspec change list --json` - deprecated but available)
|
||||
- Show details:
|
||||
- Spec: `openspec show <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 aren’t explicitly changing the existing requirement, add a new requirement under ADDED instead.
|
||||
|
||||
Authoring a MODIFIED requirement correctly:
|
||||
1) Locate the existing requirement in `openspec/specs/<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.
|
||||
|
|
@ -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
|
|
@ -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 说明
|
||||
|
||||
**验收标准**: 文档完整
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
1437
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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": {}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
4
packages/web-app/src/client/assets.d.ts
vendored
Normal file
4
packages/web-app/src/client/assets.d.ts
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
declare module '*.png' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
BIN
packages/web-app/src/client/assets/icon.png
Normal file
BIN
packages/web-app/src/client/assets/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 52 KiB |
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
57
packages/web-app/src/client/components/ui/button.tsx
Normal file
57
packages/web-app/src/client/components/ui/button.tsx
Normal 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 };
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
42
packages/web-app/src/client/components/ui/resizable.tsx
Normal file
42
packages/web-app/src/client/components/ui/resizable.tsx
Normal 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 };
|
||||
23
packages/web-app/src/client/components/ui/sonner.tsx
Normal file
23
packages/web-app/src/client/components/ui/sonner.tsx
Normal 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 };
|
||||
26
packages/web-app/src/client/components/ui/switch.tsx
Normal file
26
packages/web-app/src/client/components/ui/switch.tsx
Normal 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 };
|
||||
39
packages/web-app/src/client/components/ui/theme-toggle.tsx
Normal file
39
packages/web-app/src/client/components/ui/theme-toggle.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
27
packages/web-app/src/client/components/ui/tooltip.tsx
Normal file
27
packages/web-app/src/client/components/ui/tooltip.tsx
Normal 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 };
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
30
packages/web-app/src/client/lib/utils.ts
Normal file
30
packages/web-app/src/client/lib/utils.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
1
packages/web-app/src/client/version.ts
Normal file
1
packages/web-app/src/client/version.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export const APP_VERSION = '0.1.0';
|
||||
|
|
@ -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) => {
|
||||
|
|
|
|||
92
packages/web-app/src/server/cli/qwen-wrapper.ts
Normal file
92
packages/web-app/src/server/cli/qwen-wrapper.ts
Normal 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);
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
46
packages/web-app/src/server/sessionManager.ts
Normal file
46
packages/web-app/src/server/sessionManager.ts
Normal 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());
|
||||
}
|
||||
|
|
@ -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
|
|
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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: [],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
"compilerOptions": {
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"noEmit": false,
|
||||
"declaration": true
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue