[codex] add version preview workflow (#1086)
* add version preview workflow * fix sidebar group test * fix legacy usage schema migration
This commit is contained in:
@@ -282,8 +282,8 @@ npm install
|
|||||||
npm run dev
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
- Frontend: http://localhost:5173
|
- Frontend: http://localhost:8649
|
||||||
- BFF Server: http://localhost:8648
|
- BFF Server: http://localhost:8647
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run build # outputs to dist/
|
npm run build # outputs to dist/
|
||||||
|
|||||||
+2
-2
@@ -289,8 +289,8 @@ npm install
|
|||||||
npm run dev
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
- 前端:http://localhost:5173
|
- 前端:http://localhost:8649
|
||||||
- BFF 服务器:http://localhost:8648
|
- BFF 服务器:http://localhost:8647
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run build # 构建输出到 dist/
|
npm run build # 构建输出到 dist/
|
||||||
|
|||||||
@@ -1,439 +0,0 @@
|
|||||||
# 今日改动测试用例
|
|
||||||
|
|
||||||
日期:2026-05-18
|
|
||||||
|
|
||||||
## 基础检查
|
|
||||||
|
|
||||||
### TC-001 类型检查
|
|
||||||
|
|
||||||
步骤:
|
|
||||||
1. 在项目根目录执行 `npx tsc --noEmit -p packages/server/tsconfig.json`。
|
|
||||||
2. 执行 `npx vue-tsc -b --noEmit`。
|
|
||||||
|
|
||||||
期望:
|
|
||||||
- 两个命令都通过。
|
|
||||||
- 没有新增 TypeScript 编译错误。
|
|
||||||
|
|
||||||
### TC-002 启动服务
|
|
||||||
|
|
||||||
步骤:
|
|
||||||
1. 启动本地开发服务。
|
|
||||||
2. 打开 `http://localhost:5173`。
|
|
||||||
3. 观察控制台和服务端日志。
|
|
||||||
|
|
||||||
期望:
|
|
||||||
- Vite 和 server 正常启动。
|
|
||||||
- 不出现 `ECONNREFUSED 127.0.0.1:8648` 之外的持续异常。
|
|
||||||
- 页面可以正常进入 Hermes。
|
|
||||||
|
|
||||||
## Profile 与模型
|
|
||||||
|
|
||||||
### TC-010 available-models 返回多 profile 合集
|
|
||||||
|
|
||||||
步骤:
|
|
||||||
1. 准备至少两个 profile,每个 profile 配置不同 provider/model。
|
|
||||||
2. 请求 `GET /api/hermes/available-models`。
|
|
||||||
3. 检查返回模型列表。
|
|
||||||
|
|
||||||
期望:
|
|
||||||
- 返回所有有效 profile 的 provider/model 合集。
|
|
||||||
- 需要远程拉模型的 provider 按 base URL 去重请求。
|
|
||||||
- 默认模型优先使用当前 active profile 的默认配置。
|
|
||||||
|
|
||||||
### TC-011 新建对话选择 profile 和模型
|
|
||||||
|
|
||||||
步骤:
|
|
||||||
1. 点击新建对话。
|
|
||||||
2. 在弹窗选择 profile、provider、model。
|
|
||||||
3. 发送第一条消息。
|
|
||||||
|
|
||||||
期望:
|
|
||||||
- 新建时会把选择的 profile/provider/model 带到后端。
|
|
||||||
- 不依赖前端长期 state 存储 provider/model。
|
|
||||||
- 聊天使用选择的 profile 启动。
|
|
||||||
|
|
||||||
### TC-012 Sidebar 模型切换
|
|
||||||
|
|
||||||
步骤:
|
|
||||||
1. 在 sidebar 切换当前会话模型。
|
|
||||||
2. 等待接口返回。
|
|
||||||
3. 刷新页面或重新打开会话。
|
|
||||||
|
|
||||||
期望:
|
|
||||||
- UI 不会自动跳回旧模型。
|
|
||||||
- 当前会话继续显示新模型。
|
|
||||||
- 后续请求使用新模型。
|
|
||||||
|
|
||||||
## 单聊 Bridge 与上下文压缩
|
|
||||||
|
|
||||||
### TC-020 多 profile bridge worker
|
|
||||||
|
|
||||||
步骤:
|
|
||||||
1. 使用 default profile 发起一次聊天。
|
|
||||||
2. 切换到另一个 profile 发起聊天。
|
|
||||||
3. 查看 bridge 日志。
|
|
||||||
|
|
||||||
期望:
|
|
||||||
- 不会因为切换 profile 杀掉其他 profile 的 worker。
|
|
||||||
- `chat`、`destroy` 日志中的 profile、profile_dir、config 路径匹配实际会话 profile。
|
|
||||||
|
|
||||||
### TC-021 强制上下文压缩使用会话模型
|
|
||||||
|
|
||||||
步骤:
|
|
||||||
1. 创建一个非 default profile 的会话。
|
|
||||||
2. 设置不同 provider/model/context_length。
|
|
||||||
3. 触发上下文压缩。
|
|
||||||
4. 查看日志和压缩请求。
|
|
||||||
|
|
||||||
期望:
|
|
||||||
- context_length 依据当前 session 的 profile/provider/model 获取。
|
|
||||||
- 获取顺序为 sqlite 会话信息、profile 配置、硬编码 fallback。
|
|
||||||
- 压缩请求通过 `source=api_server` 走 bridge。
|
|
||||||
- Web UI 本地数据库不写入压缩会话记录。
|
|
||||||
|
|
||||||
### TC-022 指令压缩
|
|
||||||
|
|
||||||
步骤:
|
|
||||||
1. 在单聊中执行压缩相关指令。
|
|
||||||
2. 使用非 default profile 会话重复执行。
|
|
||||||
|
|
||||||
期望:
|
|
||||||
- 指令压缩同样使用当前 session 的 profile/provider/model。
|
|
||||||
- 不固定使用 default 模型。
|
|
||||||
- 不污染正常聊天历史。
|
|
||||||
|
|
||||||
## Session 列表与历史
|
|
||||||
|
|
||||||
### TC-030 Session 列表合并
|
|
||||||
|
|
||||||
步骤:
|
|
||||||
1. 使用多个 profile 创建会话。
|
|
||||||
2. 打开会话列表。
|
|
||||||
3. 使用 profile 过滤下拉。
|
|
||||||
|
|
||||||
期望:
|
|
||||||
- 默认显示所有有效 profile 下的会话。
|
|
||||||
- 传入 profile 过滤时只显示该 profile 会话。
|
|
||||||
- 已删除 profile 的旧会话被过滤,不再进入后报错。
|
|
||||||
|
|
||||||
### TC-031 Chat 列表 profile 信息
|
|
||||||
|
|
||||||
步骤:
|
|
||||||
1. 打开普通聊天会话列表。
|
|
||||||
2. 查看每条 session item。
|
|
||||||
|
|
||||||
期望:
|
|
||||||
- 普通 chat session item 显示 profile 头像和 profile 名称。
|
|
||||||
- profile 信息位于模型和日期下方。
|
|
||||||
- history 页面不显示 profile 信息。
|
|
||||||
|
|
||||||
### TC-032 History profile 过滤
|
|
||||||
|
|
||||||
步骤:
|
|
||||||
1. 打开历史页面。
|
|
||||||
2. 查看顶部说明和 profile 下拉。
|
|
||||||
3. 切换 “只显示当前 profile”。
|
|
||||||
|
|
||||||
期望:
|
|
||||||
- 原描述文案被替换为 profile 过滤控件。
|
|
||||||
- “All Profiles” 已国际化。
|
|
||||||
- history 列表按过滤条件变化。
|
|
||||||
|
|
||||||
## 删除会话
|
|
||||||
|
|
||||||
### TC-040 单个删除同步 Hermes
|
|
||||||
|
|
||||||
步骤:
|
|
||||||
1. 创建一个 Hermes 侧存在的会话。
|
|
||||||
2. 在 Web UI session 列表删除单条会话。
|
|
||||||
3. 查看本地 DB 和 Hermes profile 侧数据。
|
|
||||||
|
|
||||||
期望:
|
|
||||||
- Web UI 本地会话被删除。
|
|
||||||
- 如果 Hermes 对应 profile 下存在该 session,也同步删除。
|
|
||||||
- profile 缺失或 Hermes 侧不存在时不报错。
|
|
||||||
|
|
||||||
### TC-041 批量删除同步 Hermes
|
|
||||||
|
|
||||||
步骤:
|
|
||||||
1. 选择多个 session,覆盖不同 profile。
|
|
||||||
2. 点击批量删除。
|
|
||||||
3. 在确认弹窗确认。
|
|
||||||
|
|
||||||
期望:
|
|
||||||
- 确认弹窗显示 loading。
|
|
||||||
- 每条会话按自己的 profile 删除 Hermes 侧数据。
|
|
||||||
- 批量删除期间 UI 不重复提交。
|
|
||||||
- 部分 Hermes 删除失败时,本地删除逻辑不被无关 profile 阻塞。
|
|
||||||
|
|
||||||
## 群聊基础
|
|
||||||
|
|
||||||
### TC-050 群聊清空消息
|
|
||||||
|
|
||||||
步骤:
|
|
||||||
1. 进入群聊房间并发送几条消息。
|
|
||||||
2. 清空群聊消息。
|
|
||||||
3. 再发起一次群聊。
|
|
||||||
|
|
||||||
期望:
|
|
||||||
- 消息被清空。
|
|
||||||
- room 生成新的 sessionId/sessionSeed。
|
|
||||||
- 后续 agent run 不复用旧 session。
|
|
||||||
|
|
||||||
### TC-051 群聊并发触发
|
|
||||||
|
|
||||||
步骤:
|
|
||||||
1. 在同一条用户消息里 @ 多个 agent。
|
|
||||||
2. 观察多个 agent 回复。
|
|
||||||
3. 在某个 agent 回复未结束时再次 @ 同一个 agent。
|
|
||||||
|
|
||||||
期望:
|
|
||||||
- 不同 agent 可以并发回复。
|
|
||||||
- 同一个 agent 串行处理。
|
|
||||||
- 同一 agent 忙时新 mention 进入该 agent 的队列,最终只处理最新一条排队消息。
|
|
||||||
|
|
||||||
### TC-052 群聊 source 使用 api_server
|
|
||||||
|
|
||||||
步骤:
|
|
||||||
1. 在群聊中 @ agent。
|
|
||||||
2. 查看服务端日志和 bridge 请求。
|
|
||||||
|
|
||||||
期望:
|
|
||||||
- 群聊 agent 调用 source 为 `api_server`。
|
|
||||||
- 不再走 cli source。
|
|
||||||
|
|
||||||
## 群聊流式与消息入库
|
|
||||||
|
|
||||||
### TC-060 群聊流式输出
|
|
||||||
|
|
||||||
步骤:
|
|
||||||
1. @ 一个 agent 并观察回复过程。
|
|
||||||
2. 刷新前查看 UI。
|
|
||||||
3. 刷新后再次查看消息。
|
|
||||||
|
|
||||||
期望:
|
|
||||||
- agent 回复流式显示。
|
|
||||||
- 流式结束前不落库空 content 占位消息。
|
|
||||||
- 刷新后不会出现空 assistant 消息。
|
|
||||||
- 完成后 loading/thinking 状态消失。
|
|
||||||
|
|
||||||
### TC-061 toolcall/toolresult 展示
|
|
||||||
|
|
||||||
步骤:
|
|
||||||
1. 让 agent 执行一个工具调用。
|
|
||||||
2. 查看群聊消息气泡。
|
|
||||||
3. 展开工具详情。
|
|
||||||
|
|
||||||
期望:
|
|
||||||
- toolcall 和 toolresult 合并成一条工具消息展示。
|
|
||||||
- 工具消息显示头像和 agent 名称。
|
|
||||||
- 工具样式与单聊一致。
|
|
||||||
- 参数和结果有截断,长内容不撑破 UI。
|
|
||||||
- `hermes_show_tool_calls` 只影响群聊自身可见性,不影响单聊常显规则。
|
|
||||||
|
|
||||||
### TC-062 toolcall 顺序
|
|
||||||
|
|
||||||
步骤:
|
|
||||||
1. 让 agent 回复中先说一句话,再调用工具,再继续回复。
|
|
||||||
2. 查看 UI 和 `group-chat-history-preview.json`。
|
|
||||||
|
|
||||||
期望:
|
|
||||||
- 工具调用前的普通文本保留在 toolcall 前面。
|
|
||||||
- toolcall/toolresult 不被错误插到最终回复下面。
|
|
||||||
- 最终 agent 回复不会丢失。
|
|
||||||
|
|
||||||
### TC-063 入库原子性
|
|
||||||
|
|
||||||
步骤:
|
|
||||||
1. 同时 @ 多个 agent。
|
|
||||||
2. 等待多个 agent 回复完成。
|
|
||||||
3. 查看 `gc_messages`。
|
|
||||||
|
|
||||||
期望:
|
|
||||||
- 每个 agent 的一次回复作为完整消息落库。
|
|
||||||
- 不出现谁先完成谁把别人的消息合并进同一条的情况。
|
|
||||||
- 工具消息和最终文本消息的归属正确。
|
|
||||||
|
|
||||||
## 群聊 History 组装
|
|
||||||
|
|
||||||
### TC-070 生成预览 JSON
|
|
||||||
|
|
||||||
步骤:
|
|
||||||
1. 在群聊产生用户消息、agent 回复、toolcall、toolresult。
|
|
||||||
2. 生成 `group-chat-history-preview.json`。
|
|
||||||
3. 检查 JSON 顺序和 role。
|
|
||||||
|
|
||||||
期望:
|
|
||||||
- 当前 agent 自己的普通回复为 `assistant`。
|
|
||||||
- 当前 agent 自己的 toolcall 为 `assistant`,内容格式为 `[Calling tool: name with arguments: ...]`。
|
|
||||||
- toolresult 为 `user`。
|
|
||||||
- 其他 agent 的回复、toolcall、toolresult 都作为 `user`。
|
|
||||||
- 每条内容只带 `[发送者]:` 前缀,不生成 `[发送者 to 目标]:`。
|
|
||||||
- 预览中的 `source`、`sourceRole`、`originalMessageId` 只用于调试,不发送给 bridge。
|
|
||||||
|
|
||||||
### TC-071 @User 清理
|
|
||||||
|
|
||||||
步骤:
|
|
||||||
1. 用户或 agent 消息中包含 `@User-dfd5fd`。
|
|
||||||
2. 生成 history preview。
|
|
||||||
|
|
||||||
期望:
|
|
||||||
- 对应内容转换为 `[发送者]: 内容`。
|
|
||||||
- body 中原始 `@User-dfd5fd` 被移除。
|
|
||||||
- history preview 中不出现 `[test to User-dfd5fd]:` 这种前缀。
|
|
||||||
|
|
||||||
### TC-072 群聊 prompt 约束
|
|
||||||
|
|
||||||
步骤:
|
|
||||||
1. 只 @ 一个 agent,让它回答普通问题。
|
|
||||||
2. 不要求它转交、邀请、询问其他成员。
|
|
||||||
|
|
||||||
期望:
|
|
||||||
- agent 不会主动 @ 其他人。
|
|
||||||
- 不会在结尾要求其他 agent 接力。
|
|
||||||
- 只有明确需要对方执行动作、提供信息、确认决策时才 @。
|
|
||||||
|
|
||||||
### TC-073 群聊 token 统计
|
|
||||||
|
|
||||||
步骤:
|
|
||||||
1. 群聊中产生多轮 user/assistant/tool 消息。
|
|
||||||
2. 请求 `GET /api/hermes/group-chat/rooms`。
|
|
||||||
3. 对比房间 `totalTokens`。
|
|
||||||
|
|
||||||
期望:
|
|
||||||
- token 估算逻辑与单聊一致,按 role/input/output/tool_calls 统计。
|
|
||||||
- 不是简单拼接 content/senderName 计算。
|
|
||||||
- snapshot 场景下统计不重复。
|
|
||||||
|
|
||||||
## 群聊附件与图片
|
|
||||||
|
|
||||||
### TC-080 用户发送图片
|
|
||||||
|
|
||||||
步骤:
|
|
||||||
1. 在群聊输入框上传或粘贴图片。
|
|
||||||
2. 输入文字并发送。
|
|
||||||
3. 查看本地 UI 和 agent 收到的内容。
|
|
||||||
|
|
||||||
期望:
|
|
||||||
- 用户消息不显示原始 JSON 数组。
|
|
||||||
- 图片以缩略图展示。
|
|
||||||
- 点击图片可以预览。
|
|
||||||
- 文本只显示 text block。
|
|
||||||
- 发送给 bridge 时图片转 base64,与单聊 ContentBlock[] 处理一致。
|
|
||||||
|
|
||||||
### TC-081 用户发送文件
|
|
||||||
|
|
||||||
步骤:
|
|
||||||
1. 在群聊发送普通文件。
|
|
||||||
2. 查看消息展示。
|
|
||||||
|
|
||||||
期望:
|
|
||||||
- 文件以文件附件样式展示。
|
|
||||||
- 不被错误当作纯文本 JSON 展示。
|
|
||||||
- 下载链接可用。
|
|
||||||
|
|
||||||
### TC-082 Windows 路径兼容
|
|
||||||
|
|
||||||
步骤:
|
|
||||||
1. 构造或上传一个路径形如 `C:\path\file.jpg` 的附件记录。
|
|
||||||
2. 查看群聊消息。
|
|
||||||
|
|
||||||
期望:
|
|
||||||
- 下载 URL 中路径被标准化为 `C:/path/file.jpg`。
|
|
||||||
- 图片和文件都可以正常展示或下载。
|
|
||||||
|
|
||||||
## 群聊语音与操作栏
|
|
||||||
|
|
||||||
### TC-090 自动播放开关
|
|
||||||
|
|
||||||
步骤:
|
|
||||||
1. 打开群聊输入框的自动播放语音开关。
|
|
||||||
2. 让 agent 回复一条完整消息。
|
|
||||||
|
|
||||||
期望:
|
|
||||||
- 回复完成后触发语音播放。
|
|
||||||
- 不在流式未完成时播放半截内容。
|
|
||||||
- 设置与单聊共用 `autoPlaySpeech` 行为。
|
|
||||||
|
|
||||||
### TC-091 手动播放语音
|
|
||||||
|
|
||||||
步骤:
|
|
||||||
1. 点击群聊 assistant 消息底部语音按钮。
|
|
||||||
2. 再次点击暂停或恢复。
|
|
||||||
|
|
||||||
期望:
|
|
||||||
- 按当前 TTS provider 播放。
|
|
||||||
- WebSpeech、OpenAI、custom、edge、mimo 路径与单聊一致。
|
|
||||||
- 播放状态按钮图标变化。
|
|
||||||
|
|
||||||
### TC-092 呼吸灯和操作栏样式
|
|
||||||
|
|
||||||
步骤:
|
|
||||||
1. 播放群聊 assistant 消息语音。
|
|
||||||
2. 对比单聊消息播放态。
|
|
||||||
|
|
||||||
期望:
|
|
||||||
- 群聊气泡出现与单聊一致的呼吸灯动画。
|
|
||||||
- 群聊底部操作栏包含语音按钮、复制按钮、时间。
|
|
||||||
- 操作栏 hover 显示,移动端常显。
|
|
||||||
- 操作栏和气泡之间有合理间距,不贴边。
|
|
||||||
|
|
||||||
### TC-093 复制消息
|
|
||||||
|
|
||||||
步骤:
|
|
||||||
1. 点击群聊消息底部复制按钮。
|
|
||||||
2. 粘贴剪贴板内容。
|
|
||||||
|
|
||||||
期望:
|
|
||||||
- 复制的是当前气泡可读文本。
|
|
||||||
- ContentBlock[] 消息只复制文本部分,不复制图片 JSON。
|
|
||||||
- tool 消息不显示普通复制按钮。
|
|
||||||
|
|
||||||
## 群聊工具可见性
|
|
||||||
|
|
||||||
### TC-100 工具显示开关
|
|
||||||
|
|
||||||
步骤:
|
|
||||||
1. 在群聊输入框切换工具调用显示开关。
|
|
||||||
2. 触发一次工具调用。
|
|
||||||
|
|
||||||
期望:
|
|
||||||
- 关闭时隐藏已完成工具消息。
|
|
||||||
- 正在运行的工具消息仍可见,避免用户误以为卡住。
|
|
||||||
- 打开后工具消息恢复显示。
|
|
||||||
|
|
||||||
## 回归检查
|
|
||||||
|
|
||||||
### TC-110 单聊不受群聊改动影响
|
|
||||||
|
|
||||||
步骤:
|
|
||||||
1. 在普通单聊发送文本、图片、工具调用消息。
|
|
||||||
2. 播放语音并复制消息。
|
|
||||||
3. 触发上下文压缩。
|
|
||||||
|
|
||||||
期望:
|
|
||||||
- 单聊工具调用仍常显。
|
|
||||||
- 单聊图片展示、预览、base64 发送正常。
|
|
||||||
- 单聊语音呼吸灯和操作栏样式不变。
|
|
||||||
- 单聊压缩仍走正确 session profile/model。
|
|
||||||
|
|
||||||
### TC-111 已删除 profile 数据
|
|
||||||
|
|
||||||
步骤:
|
|
||||||
1. 创建一个 profile 并产生聊天记录。
|
|
||||||
2. 删除该 profile。
|
|
||||||
3. 打开 session 列表和历史页面。
|
|
||||||
|
|
||||||
期望:
|
|
||||||
- 不展示不属于当前全部有效 profile 的聊天记录。
|
|
||||||
- 不会因为进入旧会话请求缺失 profile 而报错。
|
|
||||||
|
|
||||||
### TC-112 多语言文案
|
|
||||||
|
|
||||||
步骤:
|
|
||||||
1. 切换到中文、英文、日文等语言。
|
|
||||||
2. 查看 profile 过滤选项。
|
|
||||||
|
|
||||||
期望:
|
|
||||||
- `All Profiles` 或对应翻译正常显示。
|
|
||||||
- 不出现缺失 i18n key。
|
|
||||||
@@ -7,6 +7,7 @@ services:
|
|||||||
container_name: ${WEBUI_CONTAINER_NAME:-hermes-webui}
|
container_name: ${WEBUI_CONTAINER_NAME:-hermes-webui}
|
||||||
ports:
|
ports:
|
||||||
- "${PORT:-6060}:${PORT:-6060}"
|
- "${PORT:-6060}:${PORT:-6060}"
|
||||||
|
- "${PREVIEW_FRONTEND_PORT:-8651}:8651"
|
||||||
- "${XAI_OAUTH_PORT:-56121}:56121"
|
- "${XAI_OAUTH_PORT:-56121}:56121"
|
||||||
volumes:
|
volumes:
|
||||||
- ${HERMES_DATA_DIR:-./hermes_data}:/home/agent/.hermes
|
- ${HERMES_DATA_DIR:-./hermes_data}:/home/agent/.hermes
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
},
|
},
|
||||||
"env": {
|
"env": {
|
||||||
"NODE_ENV": "development",
|
"NODE_ENV": "development",
|
||||||
|
"PORT": "8647",
|
||||||
"TS_NODE_PROJECT": "packages/server/tsconfig.json",
|
"TS_NODE_PROJECT": "packages/server/tsconfig.json",
|
||||||
"HERMES_WEB_UI_STOP_GATEWAYS_ON_SHUTDOWN": "0"
|
"HERMES_WEB_UI_STOP_GATEWAYS_ON_SHUTDOWN": "0"
|
||||||
},
|
},
|
||||||
|
|||||||
+1
-1
@@ -35,7 +35,7 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "vite --host --port 8648",
|
"start": "vite --host --port 8648",
|
||||||
"dev": "concurrently \"npm run dev:server\" \"npm run dev:client\"",
|
"dev": "concurrently \"npm run dev:server\" \"npm run dev:client\"",
|
||||||
"dev:client": "vite --host",
|
"dev:client": "cross-env HERMES_WEB_UI_BACKEND_PORT=8647 vite --host --port 8649 --strictPort",
|
||||||
"dev:server": "nodemon",
|
"dev:server": "nodemon",
|
||||||
"build": "vue-tsc -b && vite build && tsc --noEmit -p packages/server/tsconfig.json && node scripts/build-server.mjs",
|
"build": "vue-tsc -b && vite build && tsc --noEmit -p packages/server/tsconfig.json && node scripts/build-server.mjs",
|
||||||
"prepare": "[ -d dist ] || npm run build",
|
"prepare": "[ -d dist ] || npm run build",
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import router from '@/router'
|
|||||||
const DEFAULT_BASE_URL = ''
|
const DEFAULT_BASE_URL = ''
|
||||||
|
|
||||||
function getBaseUrl(): string {
|
function getBaseUrl(): string {
|
||||||
|
if (import.meta.env.VITE_HERMES_PREVIEW === '1') return DEFAULT_BASE_URL
|
||||||
return localStorage.getItem('hermes_server_url') || DEFAULT_BASE_URL
|
return localStorage.getItem('hermes_server_url') || DEFAULT_BASE_URL
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -267,8 +267,9 @@ export function buildKanbanEventsWebSocketUrl(opts?: KanbanBoardOptions): string
|
|||||||
return `${websocketProtocol(base)}//${new URL(base).host}${path}`
|
return `${websocketProtocol(base)}//${new URL(base).host}${path}`
|
||||||
}
|
}
|
||||||
|
|
||||||
const host = import.meta.env.DEV
|
const directDevPort = import.meta.env.VITE_HERMES_DIRECT_WS_PORT
|
||||||
? formatHostForPort(location.hostname, 8648)
|
const host = import.meta.env.DEV && directDevPort
|
||||||
|
? formatHostForPort(location.hostname, Number(directDevPort))
|
||||||
: location.host
|
: location.host
|
||||||
return `${websocketProtocol()}//${host}${path}`
|
return `${websocketProtocol()}//${host}${path}`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,34 @@ export interface HealthResponse {
|
|||||||
node_version?: string
|
node_version?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PreviewTag {
|
||||||
|
name: string
|
||||||
|
sha: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PreviewStatus {
|
||||||
|
preview_dir: string
|
||||||
|
exists: boolean
|
||||||
|
has_package: boolean
|
||||||
|
installed: boolean
|
||||||
|
running: boolean
|
||||||
|
pid: number | null
|
||||||
|
current_tag: string
|
||||||
|
frontend_url: string
|
||||||
|
agent_bridge_endpoint: string
|
||||||
|
log_path: string
|
||||||
|
webui_home: string
|
||||||
|
action_log_path: string
|
||||||
|
dev_log_path: string
|
||||||
|
action_log: string
|
||||||
|
dev_log: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PreviewActionResponse extends PreviewStatus {
|
||||||
|
success: boolean
|
||||||
|
message?: string
|
||||||
|
}
|
||||||
|
|
||||||
// Config-based model types
|
// Config-based model types
|
||||||
export interface ModelInfo {
|
export interface ModelInfo {
|
||||||
id: string
|
id: string
|
||||||
@@ -84,6 +112,36 @@ export async function triggerUpdate(): Promise<{ success: boolean; message: stri
|
|||||||
return request<{ success: boolean; message: string }>('/api/hermes/update', { method: 'POST' })
|
return request<{ success: boolean; message: string }>('/api/hermes/update', { method: 'POST' })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function fetchPreviewStatus(): Promise<PreviewStatus> {
|
||||||
|
return request<PreviewStatus>('/api/hermes/update/preview')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchPreviewTags(): Promise<{ tags: PreviewTag[] }> {
|
||||||
|
return request<{ tags: PreviewTag[] }>('/api/hermes/update/preview/tags')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function preparePreview(tag: string): Promise<PreviewActionResponse> {
|
||||||
|
return request<PreviewActionResponse>('/api/hermes/update/preview/prepare', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ tag }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function installPreview(): Promise<PreviewActionResponse> {
|
||||||
|
return request<PreviewActionResponse>('/api/hermes/update/preview/install', { method: 'POST' })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function startPreview(tag?: string): Promise<PreviewActionResponse> {
|
||||||
|
return request<PreviewActionResponse>('/api/hermes/update/preview/start', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ tag }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function stopPreview(): Promise<PreviewActionResponse> {
|
||||||
|
return request<PreviewActionResponse>('/api/hermes/update/preview/stop', { method: 'POST' })
|
||||||
|
}
|
||||||
|
|
||||||
export async function fetchConfigModels(): Promise<ConfigModelsResponse> {
|
export async function fetchConfigModels(): Promise<ConfigModelsResponse> {
|
||||||
return request<ConfigModelsResponse>('/api/hermes/config/models')
|
return request<ConfigModelsResponse>('/api/hermes/config/models')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -148,8 +148,9 @@ function buildWsUrl(): string {
|
|||||||
return `${wsProtocol}//${new URL(base).host}/api/hermes/terminal${token ? `?token=${encodeURIComponent(token)}` : ""}`;
|
return `${wsProtocol}//${new URL(base).host}/api/hermes/terminal${token ? `?token=${encodeURIComponent(token)}` : ""}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const host = import.meta.env.DEV
|
const directDevPort = import.meta.env.VITE_HERMES_DIRECT_WS_PORT;
|
||||||
? formatHostForPort(location.hostname, 8648)
|
const host = import.meta.env.DEV && directDevPort
|
||||||
|
? formatHostForPort(location.hostname, Number(directDevPort))
|
||||||
: location.host;
|
: location.host;
|
||||||
return `${wsProtocol}//${host}/api/hermes/terminal${token ? `?token=${encodeURIComponent(token)}` : ""}`;
|
return `${wsProtocol}//${host}/api/hermes/terminal${token ? `?token=${encodeURIComponent(token)}` : ""}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,313 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, ref } from 'vue'
|
||||||
|
import { NAlert, NButton, NDescriptions, NDescriptionsItem, NSelect, NSpace, NTag, useMessage } from 'naive-ui'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import {
|
||||||
|
fetchPreviewStatus,
|
||||||
|
fetchPreviewTags,
|
||||||
|
installPreview,
|
||||||
|
preparePreview,
|
||||||
|
startPreview,
|
||||||
|
stopPreview,
|
||||||
|
type PreviewStatus,
|
||||||
|
type PreviewTag,
|
||||||
|
} from '@/api/hermes/system'
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
const message = useMessage()
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const tagsLoading = ref(false)
|
||||||
|
const actionLoading = ref('')
|
||||||
|
const tags = ref<PreviewTag[]>([])
|
||||||
|
const selectedTag = ref('')
|
||||||
|
const status = ref<PreviewStatus | null>(null)
|
||||||
|
|
||||||
|
const tagOptions = computed(() => tags.value.map(tag => ({
|
||||||
|
label: tag.name,
|
||||||
|
value: tag.name,
|
||||||
|
})))
|
||||||
|
const actionLog = computed(() => status.value?.action_log || '')
|
||||||
|
const devLog = computed(() => status.value?.dev_log || '')
|
||||||
|
|
||||||
|
function applyErrorStatus(err: any) {
|
||||||
|
const messageText = String(err?.message || '')
|
||||||
|
const jsonStart = messageText.indexOf('{')
|
||||||
|
if (jsonStart < 0) return
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(messageText.slice(jsonStart))
|
||||||
|
if (parsed && typeof parsed === 'object' && 'preview_dir' in parsed) {
|
||||||
|
status.value = parsed as PreviewStatus
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadStatus() {
|
||||||
|
status.value = await fetchPreviewStatus()
|
||||||
|
if (!selectedTag.value && status.value.current_tag) {
|
||||||
|
selectedTag.value = status.value.current_tag
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadTags() {
|
||||||
|
tagsLoading.value = true
|
||||||
|
try {
|
||||||
|
const res = await fetchPreviewTags()
|
||||||
|
tags.value = res.tags
|
||||||
|
if (!selectedTag.value && tags.value[0]) {
|
||||||
|
selectedTag.value = tags.value[0].name
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
tagsLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRefresh() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
await Promise.all([loadStatus(), loadTags()])
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runAction(action: string, fn: () => Promise<PreviewStatus & { success?: boolean; message?: string }>, successKey: string) {
|
||||||
|
actionLoading.value = action
|
||||||
|
try {
|
||||||
|
const res = await fn()
|
||||||
|
status.value = res
|
||||||
|
if (res.success === false) {
|
||||||
|
message.warning(res.message || t('githubPreview.actionFailed'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
message.success(t(successKey))
|
||||||
|
} catch (err: any) {
|
||||||
|
applyErrorStatus(err)
|
||||||
|
message.error(err?.message || t('githubPreview.actionFailed'))
|
||||||
|
} finally {
|
||||||
|
actionLoading.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireTag(): string | null {
|
||||||
|
if (!selectedTag.value) {
|
||||||
|
message.warning(t('githubPreview.selectTag'))
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return selectedTag.value
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handlePrepare() {
|
||||||
|
const tag = requireTag()
|
||||||
|
if (!tag) return
|
||||||
|
await runAction('prepare', () => preparePreview(tag), 'githubPreview.prepareSuccess')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleInstall() {
|
||||||
|
await runAction('install', async () => {
|
||||||
|
const res = await installPreview()
|
||||||
|
if (res.success !== false && !res.installed) {
|
||||||
|
return {
|
||||||
|
...res,
|
||||||
|
success: false,
|
||||||
|
message: res.message || t('githubPreview.actionFailed'),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return res
|
||||||
|
}, 'githubPreview.installSuccess')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleStart() {
|
||||||
|
await runAction('start', () => startPreview(selectedTag.value || undefined), 'githubPreview.startSuccess')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleStop() {
|
||||||
|
await runAction('stop', stopPreview, 'githubPreview.stopSuccess')
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await handleRefresh()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="github-preview-settings">
|
||||||
|
<div class="settings-section">
|
||||||
|
<div class="control-row">
|
||||||
|
<NSelect
|
||||||
|
v-model:value="selectedTag"
|
||||||
|
class="tag-select"
|
||||||
|
filterable
|
||||||
|
:loading="tagsLoading"
|
||||||
|
:options="tagOptions"
|
||||||
|
:placeholder="t('githubPreview.selectTag')"
|
||||||
|
/>
|
||||||
|
<NSpace>
|
||||||
|
<NButton type="primary" :loading="actionLoading === 'prepare'" :disabled="!selectedTag" @click="handlePrepare">
|
||||||
|
{{ t('githubPreview.prepare') }}
|
||||||
|
</NButton>
|
||||||
|
<NButton :loading="actionLoading === 'install'" :disabled="!status?.has_package" @click="handleInstall">
|
||||||
|
{{ t('githubPreview.install') }}
|
||||||
|
</NButton>
|
||||||
|
<NButton type="success" :loading="actionLoading === 'start'" :disabled="!status?.installed" @click="handleStart">
|
||||||
|
{{ t('githubPreview.start') }}
|
||||||
|
</NButton>
|
||||||
|
<NButton :loading="actionLoading === 'stop'" :disabled="!status?.running" @click="handleStop">
|
||||||
|
{{ t('githubPreview.stop') }}
|
||||||
|
</NButton>
|
||||||
|
<NButton :loading="loading || tagsLoading" @click="handleRefresh">
|
||||||
|
{{ t('githubPreview.refresh') }}
|
||||||
|
</NButton>
|
||||||
|
</NSpace>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="section-description">{{ t('githubPreview.description') }}</p>
|
||||||
|
|
||||||
|
<NAlert type="info" :bordered="false" class="preview-note">
|
||||||
|
{{ t('githubPreview.note') }}
|
||||||
|
</NAlert>
|
||||||
|
|
||||||
|
<NDescriptions v-if="status" :column="1" bordered size="small" class="status-table">
|
||||||
|
<NDescriptionsItem :label="t('githubPreview.path')">
|
||||||
|
<code>{{ status.preview_dir }}</code>
|
||||||
|
</NDescriptionsItem>
|
||||||
|
<NDescriptionsItem :label="t('githubPreview.webuiHome')">
|
||||||
|
<code>{{ status.webui_home }}</code>
|
||||||
|
</NDescriptionsItem>
|
||||||
|
<NDescriptionsItem :label="t('githubPreview.currentTag')">
|
||||||
|
{{ status.current_tag || '-' }}
|
||||||
|
</NDescriptionsItem>
|
||||||
|
<NDescriptionsItem :label="t('githubPreview.repoReady')">
|
||||||
|
<NTag size="small" :type="status.has_package ? 'success' : 'default'">
|
||||||
|
{{ status.has_package ? t('githubPreview.yes') : t('githubPreview.no') }}
|
||||||
|
</NTag>
|
||||||
|
</NDescriptionsItem>
|
||||||
|
<NDescriptionsItem :label="t('githubPreview.dependencies')">
|
||||||
|
<NTag size="small" :type="status.installed ? 'success' : 'warning'">
|
||||||
|
{{ status.installed ? t('githubPreview.yes') : t('githubPreview.no') }}
|
||||||
|
</NTag>
|
||||||
|
</NDescriptionsItem>
|
||||||
|
<NDescriptionsItem :label="t('githubPreview.running')">
|
||||||
|
<NTag size="small" :type="status.running ? 'success' : 'default'">
|
||||||
|
{{ status.running ? `PID ${status.pid}` : t('githubPreview.notRunning') }}
|
||||||
|
</NTag>
|
||||||
|
</NDescriptionsItem>
|
||||||
|
<NDescriptionsItem :label="t('githubPreview.open')">
|
||||||
|
<a :href="status.frontend_url" target="_blank" rel="noopener noreferrer">{{ status.frontend_url }}</a>
|
||||||
|
</NDescriptionsItem>
|
||||||
|
<NDescriptionsItem :label="t('githubPreview.log')">
|
||||||
|
<code>{{ status.action_log_path }}</code>
|
||||||
|
</NDescriptionsItem>
|
||||||
|
<NDescriptionsItem :label="t('githubPreview.devLog')">
|
||||||
|
<code>{{ status.dev_log_path }}</code>
|
||||||
|
</NDescriptionsItem>
|
||||||
|
</NDescriptions>
|
||||||
|
|
||||||
|
<div class="log-output">
|
||||||
|
<div class="log-output-header">{{ t('githubPreview.logOutput') }}</div>
|
||||||
|
<div class="log-box">
|
||||||
|
<div class="log-title">{{ t('githubPreview.actionLog') }}</div>
|
||||||
|
<pre>{{ actionLog || '-' }}</pre>
|
||||||
|
</div>
|
||||||
|
<div class="log-box">
|
||||||
|
<div class="log-title">{{ t('githubPreview.devLog') }}</div>
|
||||||
|
<pre>{{ devLog || '-' }}</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
@use "@/styles/variables" as *;
|
||||||
|
|
||||||
|
.github-preview-settings {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-description {
|
||||||
|
margin: 0;
|
||||||
|
color: $text-secondary;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-select {
|
||||||
|
width: 260px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-note {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-table {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-output {
|
||||||
|
width: 100%;
|
||||||
|
border: 1px solid $border-color;
|
||||||
|
border-radius: $radius-md;
|
||||||
|
background: $bg-card;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-output-header {
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-bottom: 1px solid $border-color;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: $text-primary;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-box {
|
||||||
|
border-bottom: 1px solid $border-color;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-title {
|
||||||
|
padding: 8px 14px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: $text-secondary;
|
||||||
|
background: $bg-secondary;
|
||||||
|
}
|
||||||
|
|
||||||
|
pre {
|
||||||
|
min-height: 180px;
|
||||||
|
max-height: 320px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 12px 14px;
|
||||||
|
overflow: auto;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
code {
|
||||||
|
font-size: 12px;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1100px) {
|
||||||
|
.control-row {
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -27,6 +27,7 @@ const selectedKey = computed(() => {
|
|||||||
return route.name as string;
|
return route.name as string;
|
||||||
});
|
});
|
||||||
const isSuperAdmin = computed(() => isStoredSuperAdmin());
|
const isSuperAdmin = computed(() => isStoredSuperAdmin());
|
||||||
|
const isVersionPreview = import.meta.env.VITE_HERMES_PREVIEW === '1';
|
||||||
|
|
||||||
function isNavActive(...names: string[]) {
|
function isNavActive(...names: string[]) {
|
||||||
return names.includes(selectedKey.value);
|
return names.includes(selectedKey.value);
|
||||||
@@ -35,7 +36,7 @@ const logoPath = '/logo.png';
|
|||||||
|
|
||||||
const { record: collapsedGroups, persist: persistCollapsedGroups } = usePersistentRecord('hermes.sidebar.collapsedGroups');
|
const { record: collapsedGroups, persist: persistCollapsedGroups } = usePersistentRecord('hermes.sidebar.collapsedGroups');
|
||||||
|
|
||||||
type SidebarGroupKey = "Conversation" | "Agent" | "Monitoring" | "System";
|
type SidebarGroupKey = "Conversation" | "Agent" | "Monitoring" | "Tools" | "System";
|
||||||
|
|
||||||
function groupLabel(key: SidebarGroupKey) {
|
function groupLabel(key: SidebarGroupKey) {
|
||||||
return t(`sidebar.group${key}${appStore.sidebarCollapsed ? "Short" : ""}`);
|
return t(`sidebar.group${key}${appStore.sidebarCollapsed ? "Short" : ""}`);
|
||||||
@@ -253,6 +254,29 @@ function openChangelog() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Tools -->
|
||||||
|
<div class="nav-group">
|
||||||
|
<div class="nav-group-label" @click="toggleGroup('tools')">
|
||||||
|
<span>{{ groupLabel("Tools") }}</span>
|
||||||
|
<svg class="nav-group-arrow" :class="{ collapsed: isGroupCollapsed('tools') }" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<polyline points="6 9 12 15 18 9" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div v-show="!isGroupCollapsed('tools')" class="nav-group-items">
|
||||||
|
<RouteLinkItem v-if="isSuperAdmin && !isVersionPreview" class="nav-item" :to="{ name: 'hermes.versionPreview' }" :active="selectedKey === 'hermes.versionPreview'">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z" />
|
||||||
|
<polyline points="7.5 4.21 12 6.81 16.5 4.21" />
|
||||||
|
<polyline points="7.5 19.79 7.5 14.6 3 12" />
|
||||||
|
<polyline points="21 12 16.5 14.6 16.5 19.79" />
|
||||||
|
<polyline points="3.27 6.96 12 12.01 20.73 6.96" />
|
||||||
|
<line x1="12" y1="22.08" x2="12" y2="12" />
|
||||||
|
</svg>
|
||||||
|
<span>{{ t("sidebar.versionPreview") }}</span>
|
||||||
|
</RouteLinkItem>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- System -->
|
<!-- System -->
|
||||||
<div class="nav-group">
|
<div class="nav-group">
|
||||||
<div class="nav-group-label" @click="toggleGroup('system')">
|
<div class="nav-group-label" @click="toggleGroup('system')">
|
||||||
|
|||||||
@@ -148,6 +148,8 @@ export default {
|
|||||||
noChangelog: 'Kein Anderungsprotokoll verfugbar',
|
noChangelog: 'Kein Anderungsprotokoll verfugbar',
|
||||||
kanban: 'Kanban',
|
kanban: 'Kanban',
|
||||||
groupTools: 'Werkzeuge',
|
groupTools: 'Werkzeuge',
|
||||||
|
groupToolsShort: "Tools",
|
||||||
|
versionPreview: "Versionsvorschau",
|
||||||
groupPlatform: 'Plattform',
|
groupPlatform: 'Plattform',
|
||||||
gateways: 'Gateways',
|
gateways: 'Gateways',
|
||||||
expand: 'Menü ausklappen',
|
expand: 'Menü ausklappen',
|
||||||
@@ -930,6 +932,36 @@ jobTriggered: 'Job ausgelost',
|
|||||||
saved: 'Gespeichert',
|
saved: 'Gespeichert',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
githubPreview: {
|
||||||
|
title: "Versionsvorschau",
|
||||||
|
description: "Klont den ausgewählten GitHub-Tag in den Web-UI-Vorschaubereich, installiert Abhängigkeiten und startet ihn mit den Entwicklungsports.",
|
||||||
|
refresh: "Aktualisieren",
|
||||||
|
selectTag: "Tag auswählen",
|
||||||
|
prepare: "Code vorbereiten",
|
||||||
|
install: "Abhängigkeiten installieren",
|
||||||
|
start: "Vorschau starten",
|
||||||
|
stop: "Stoppen",
|
||||||
|
note: "Der Vorschaucode wird im Web-UI-Datenverzeichnis gespeichert. Produktion bleibt auf Port 8648; die Vorschau nutzt Frontend 8651 und Backend 8650.",
|
||||||
|
path: "Vorschaupfad",
|
||||||
|
webuiHome: "Vorschau-Datenverzeichnis",
|
||||||
|
currentTag: "Aktueller Tag",
|
||||||
|
repoReady: "Repository bereit",
|
||||||
|
dependencies: "Abhängigkeiten installiert",
|
||||||
|
running: "Status",
|
||||||
|
notRunning: "Nicht gestartet",
|
||||||
|
open: "Vorschau öffnen",
|
||||||
|
log: "Pfad zum Aktionslog",
|
||||||
|
logOutput: "Logausgabe",
|
||||||
|
actionLog: "Aktionslog",
|
||||||
|
devLog: "Dev-Server-Log",
|
||||||
|
yes: "Ja",
|
||||||
|
no: "Nein",
|
||||||
|
actionFailed: "Aktion fehlgeschlagen",
|
||||||
|
prepareSuccess: "Vorschaucode ist bereit",
|
||||||
|
installSuccess: "Abhängigkeiten installiert",
|
||||||
|
startSuccess: "Vorschau gestartet",
|
||||||
|
stopSuccess: "Vorschau gestoppt",
|
||||||
|
},
|
||||||
|
|
||||||
// Platform channel settings
|
// Platform channel settings
|
||||||
platform: {
|
platform: {
|
||||||
|
|||||||
@@ -137,6 +137,8 @@ export default {
|
|||||||
groupMonitoring: 'Monitoring',
|
groupMonitoring: 'Monitoring',
|
||||||
groupMonitoringShort: 'Mon',
|
groupMonitoringShort: 'Mon',
|
||||||
groupTools: 'Tools',
|
groupTools: 'Tools',
|
||||||
|
groupToolsShort: "Tools",
|
||||||
|
versionPreview: "Version Preview",
|
||||||
settings: 'Settings',
|
settings: 'Settings',
|
||||||
connected: 'Connected',
|
connected: 'Connected',
|
||||||
disconnected: 'Disconnected',
|
disconnected: 'Disconnected',
|
||||||
@@ -1032,6 +1034,36 @@ export default {
|
|||||||
mimoStylePromptPlaceholder: 'e.g., Bright and bouncy tone, fast pace',
|
mimoStylePromptPlaceholder: 'e.g., Bright and bouncy tone, fast pace',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
githubPreview: {
|
||||||
|
title: "Version Preview",
|
||||||
|
description: "Clone a selected GitHub tag into the Web UI preview workspace, install dependencies, and run it with the development ports.",
|
||||||
|
refresh: "Refresh",
|
||||||
|
selectTag: "Select a tag",
|
||||||
|
prepare: "Prepare Code",
|
||||||
|
install: "Install Dependencies",
|
||||||
|
start: "Start Preview",
|
||||||
|
stop: "Stop",
|
||||||
|
note: "Preview code is stored under the Web UI data home. Production remains on port 8648; preview development runs on frontend 8651 and backend 8650.",
|
||||||
|
path: "Preview Path",
|
||||||
|
webuiHome: "Preview Data Home",
|
||||||
|
currentTag: "Current Tag",
|
||||||
|
repoReady: "Repository Ready",
|
||||||
|
dependencies: "Dependencies Installed",
|
||||||
|
running: "Running",
|
||||||
|
notRunning: "Not running",
|
||||||
|
open: "Open Preview",
|
||||||
|
log: "Action Log Path",
|
||||||
|
logOutput: "Log Output",
|
||||||
|
actionLog: "Action Log",
|
||||||
|
devLog: "Dev Server Log",
|
||||||
|
yes: "Yes",
|
||||||
|
no: "No",
|
||||||
|
actionFailed: "Action failed",
|
||||||
|
prepareSuccess: "Preview code is ready",
|
||||||
|
installSuccess: "Dependencies installed",
|
||||||
|
startSuccess: "Preview started",
|
||||||
|
stopSuccess: "Preview stopped",
|
||||||
|
},
|
||||||
|
|
||||||
// Platform channel settings
|
// Platform channel settings
|
||||||
platform: {
|
platform: {
|
||||||
|
|||||||
@@ -148,6 +148,8 @@ export default {
|
|||||||
noChangelog: 'No hay registro de cambios',
|
noChangelog: 'No hay registro de cambios',
|
||||||
kanban: 'Kanban',
|
kanban: 'Kanban',
|
||||||
groupTools: 'Herramientas',
|
groupTools: 'Herramientas',
|
||||||
|
groupToolsShort: "Herr.",
|
||||||
|
versionPreview: "Vista previa de versión",
|
||||||
groupPlatform: 'Plataforma',
|
groupPlatform: 'Plataforma',
|
||||||
gateways: 'Puertas de enlace',
|
gateways: 'Puertas de enlace',
|
||||||
expand: 'Expandir menú',
|
expand: 'Expandir menú',
|
||||||
@@ -930,6 +932,36 @@ jobTriggered: 'Job ejecutado',
|
|||||||
saved: 'Guardado',
|
saved: 'Guardado',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
githubPreview: {
|
||||||
|
title: "Vista previa de versión",
|
||||||
|
description: "Clona el tag de GitHub seleccionado en el espacio de vista previa de Web UI, instala dependencias y lo ejecuta con los puertos de desarrollo.",
|
||||||
|
refresh: "Actualizar",
|
||||||
|
selectTag: "Selecciona un tag",
|
||||||
|
prepare: "Preparar código",
|
||||||
|
install: "Instalar dependencias",
|
||||||
|
start: "Iniciar vista previa",
|
||||||
|
stop: "Detener",
|
||||||
|
note: "El código de vista previa se guarda bajo el directorio de datos de Web UI. Producción sigue en el puerto 8648; la vista previa usa frontend 8651 y backend 8650.",
|
||||||
|
path: "Ruta de vista previa",
|
||||||
|
webuiHome: "Datos de vista previa",
|
||||||
|
currentTag: "Tag actual",
|
||||||
|
repoReady: "Repositorio listo",
|
||||||
|
dependencies: "Dependencias instaladas",
|
||||||
|
running: "Estado",
|
||||||
|
notRunning: "No ejecutándose",
|
||||||
|
open: "Abrir vista previa",
|
||||||
|
log: "Ruta del log de acciones",
|
||||||
|
logOutput: "Salida de logs",
|
||||||
|
actionLog: "Log de acciones",
|
||||||
|
devLog: "Log del servidor dev",
|
||||||
|
yes: "Sí",
|
||||||
|
no: "No",
|
||||||
|
actionFailed: "Acción fallida",
|
||||||
|
prepareSuccess: "Código de vista previa listo",
|
||||||
|
installSuccess: "Dependencias instaladas",
|
||||||
|
startSuccess: "Vista previa iniciada",
|
||||||
|
stopSuccess: "Vista previa detenida",
|
||||||
|
},
|
||||||
|
|
||||||
// Platform channel settings
|
// Platform channel settings
|
||||||
platform: {
|
platform: {
|
||||||
|
|||||||
@@ -148,6 +148,8 @@ export default {
|
|||||||
noChangelog: 'Aucun journal disponible',
|
noChangelog: 'Aucun journal disponible',
|
||||||
kanban: 'Kanban',
|
kanban: 'Kanban',
|
||||||
groupTools: 'Outils',
|
groupTools: 'Outils',
|
||||||
|
groupToolsShort: "Outils",
|
||||||
|
versionPreview: "Aperçu de version",
|
||||||
groupPlatform: 'Plateforme',
|
groupPlatform: 'Plateforme',
|
||||||
gateways: 'Passerelles',
|
gateways: 'Passerelles',
|
||||||
expand: 'Déplier le menu',
|
expand: 'Déplier le menu',
|
||||||
@@ -930,6 +932,36 @@ jobTriggered: 'Job declenche',
|
|||||||
saved: 'Enregistré',
|
saved: 'Enregistré',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
githubPreview: {
|
||||||
|
title: "Aperçu de version",
|
||||||
|
description: "Clone le tag GitHub sélectionné dans l’espace de prévisualisation Web UI, installe les dépendances, puis lance l’application sur les ports de développement.",
|
||||||
|
refresh: "Actualiser",
|
||||||
|
selectTag: "Sélectionner un tag",
|
||||||
|
prepare: "Préparer le code",
|
||||||
|
install: "Installer les dépendances",
|
||||||
|
start: "Démarrer l’aperçu",
|
||||||
|
stop: "Arrêter",
|
||||||
|
note: "Le code de prévisualisation est stocké dans le dossier de données Web UI. La production reste sur le port 8648 ; la prévisualisation utilise le frontend 8651 et le backend 8650.",
|
||||||
|
path: "Chemin de prévisualisation",
|
||||||
|
webuiHome: "Données de prévisualisation",
|
||||||
|
currentTag: "Tag actuel",
|
||||||
|
repoReady: "Dépôt prêt",
|
||||||
|
dependencies: "Dépendances installées",
|
||||||
|
running: "État",
|
||||||
|
notRunning: "Arrêté",
|
||||||
|
open: "Ouvrir l’aperçu",
|
||||||
|
log: "Chemin du journal d’action",
|
||||||
|
logOutput: "Sortie des journaux",
|
||||||
|
actionLog: "Journal d’action",
|
||||||
|
devLog: "Journal du serveur dev",
|
||||||
|
yes: "Oui",
|
||||||
|
no: "Non",
|
||||||
|
actionFailed: "Échec de l’action",
|
||||||
|
prepareSuccess: "Code de prévisualisation prêt",
|
||||||
|
installSuccess: "Dépendances installées",
|
||||||
|
startSuccess: "Prévisualisation démarrée",
|
||||||
|
stopSuccess: "Prévisualisation arrêtée",
|
||||||
|
},
|
||||||
|
|
||||||
// Platform channel settings
|
// Platform channel settings
|
||||||
platform: {
|
platform: {
|
||||||
|
|||||||
@@ -148,6 +148,8 @@ export default {
|
|||||||
noChangelog: '更新履歴はありません',
|
noChangelog: '更新履歴はありません',
|
||||||
kanban: 'カンバン',
|
kanban: 'カンバン',
|
||||||
groupTools: 'ツール',
|
groupTools: 'ツール',
|
||||||
|
groupToolsShort: "ツール",
|
||||||
|
versionPreview: "バージョンプレビュー",
|
||||||
groupPlatform: 'プラットフォーム',
|
groupPlatform: 'プラットフォーム',
|
||||||
gateways: 'ゲートウェイ',
|
gateways: 'ゲートウェイ',
|
||||||
expand: 'メニューを展開',
|
expand: 'メニューを展開',
|
||||||
@@ -930,8 +932,37 @@ export default {
|
|||||||
saved: '保存しました',
|
saved: '保存しました',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
githubPreview: {
|
||||||
|
title: "バージョンプレビュー",
|
||||||
|
description: "選択した GitHub tag を Web UI のプレビュー作業ディレクトリへクローンし、依存関係をインストールして開発ポートで起動します。",
|
||||||
|
refresh: "更新",
|
||||||
|
selectTag: "tag を選択",
|
||||||
|
prepare: "コードを準備",
|
||||||
|
install: "依存関係をインストール",
|
||||||
|
start: "プレビューを開始",
|
||||||
|
stop: "停止",
|
||||||
|
note: "プレビューコードは Web UI データホーム配下に保存されます。本番は 8648 のまま、プレビュー開発環境はフロントエンド 8651、バックエンド 8650 で実行されます。",
|
||||||
|
path: "プレビューパス",
|
||||||
|
webuiHome: "プレビューデータホーム",
|
||||||
|
currentTag: "現在の Tag",
|
||||||
|
repoReady: "リポジトリ準備済み",
|
||||||
|
dependencies: "依存関係インストール済み",
|
||||||
|
running: "実行状態",
|
||||||
|
notRunning: "未実行",
|
||||||
|
open: "プレビューを開く",
|
||||||
|
log: "操作ログパス",
|
||||||
|
logOutput: "ログ出力",
|
||||||
|
actionLog: "操作ログ",
|
||||||
|
devLog: "開発サーバーログ",
|
||||||
|
yes: "はい",
|
||||||
|
no: "いいえ",
|
||||||
|
actionFailed: "操作に失敗しました",
|
||||||
|
prepareSuccess: "プレビューコードの準備が完了しました",
|
||||||
|
installSuccess: "依存関係をインストールしました",
|
||||||
|
startSuccess: "プレビューを起動しました",
|
||||||
|
stopSuccess: "プレビューを停止しました",
|
||||||
|
},
|
||||||
|
|
||||||
// プラットフォームチャンネル設定
|
|
||||||
platform: {
|
platform: {
|
||||||
requireMention: "メンションが必要",
|
requireMention: "メンションが必要",
|
||||||
requireMentionGroup: "グループで応答するには {'@'}メンションが必要",
|
requireMentionGroup: "グループで応答するには {'@'}メンションが必要",
|
||||||
|
|||||||
@@ -148,6 +148,8 @@ export default {
|
|||||||
noChangelog: '변경 이력이 없습니다',
|
noChangelog: '변경 이력이 없습니다',
|
||||||
kanban: '칸반',
|
kanban: '칸반',
|
||||||
groupTools: '도구',
|
groupTools: '도구',
|
||||||
|
groupToolsShort: "도구",
|
||||||
|
versionPreview: "버전 미리보기",
|
||||||
groupPlatform: '플랫폼',
|
groupPlatform: '플랫폼',
|
||||||
gateways: '게이트웨이',
|
gateways: '게이트웨이',
|
||||||
expand: '메뉴 펼치기',
|
expand: '메뉴 펼치기',
|
||||||
@@ -930,8 +932,37 @@ export default {
|
|||||||
saved: '저장됨',
|
saved: '저장됨',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
githubPreview: {
|
||||||
|
title: "버전 미리보기",
|
||||||
|
description: "선택한 GitHub tag 를 Web UI 미리보기 작업 디렉터리에 클론하고, 의존성을 설치한 뒤 개발 포트로 실행합니다.",
|
||||||
|
refresh: "새로고침",
|
||||||
|
selectTag: "tag 선택",
|
||||||
|
prepare: "코드 준비",
|
||||||
|
install: "의존성 설치",
|
||||||
|
start: "미리보기 시작",
|
||||||
|
stop: "중지",
|
||||||
|
note: "미리보기 코드는 Web UI 데이터 홈 아래에 저장됩니다. 프로덕션은 8648을 유지하고, 미리보기 개발 환경은 프론트엔드 8651, 백엔드 8650에서 실행됩니다.",
|
||||||
|
path: "미리보기 경로",
|
||||||
|
webuiHome: "미리보기 데이터 홈",
|
||||||
|
currentTag: "현재 Tag",
|
||||||
|
repoReady: "저장소 준비됨",
|
||||||
|
dependencies: "의존성 설치됨",
|
||||||
|
running: "실행 상태",
|
||||||
|
notRunning: "실행 중 아님",
|
||||||
|
open: "미리보기 열기",
|
||||||
|
log: "작업 로그 경로",
|
||||||
|
logOutput: "로그 출력",
|
||||||
|
actionLog: "작업 로그",
|
||||||
|
devLog: "개발 서버 로그",
|
||||||
|
yes: "예",
|
||||||
|
no: "아니요",
|
||||||
|
actionFailed: "작업 실패",
|
||||||
|
prepareSuccess: "미리보기 코드가 준비되었습니다",
|
||||||
|
installSuccess: "의존성이 설치되었습니다",
|
||||||
|
startSuccess: "미리보기가 시작되었습니다",
|
||||||
|
stopSuccess: "미리보기가 중지되었습니다",
|
||||||
|
},
|
||||||
|
|
||||||
// 플랫폼 채널 설정
|
|
||||||
platform: {
|
platform: {
|
||||||
requireMention: "{'@'}멘션 필요",
|
requireMention: "{'@'}멘션 필요",
|
||||||
requireMentionGroup: "그룹에서 {'@'}멘션 시에만 응답",
|
requireMentionGroup: "그룹에서 {'@'}멘션 시에만 응답",
|
||||||
|
|||||||
@@ -148,6 +148,8 @@ export default {
|
|||||||
noChangelog: 'Nenhum registro disponivel',
|
noChangelog: 'Nenhum registro disponivel',
|
||||||
kanban: 'Kanban',
|
kanban: 'Kanban',
|
||||||
groupTools: 'Ferramentas',
|
groupTools: 'Ferramentas',
|
||||||
|
groupToolsShort: "Ferr.",
|
||||||
|
versionPreview: "Prévia de versão",
|
||||||
groupPlatform: 'Plataforma',
|
groupPlatform: 'Plataforma',
|
||||||
gateways: 'Gateways',
|
gateways: 'Gateways',
|
||||||
expand: 'Expandir menu',
|
expand: 'Expandir menu',
|
||||||
@@ -930,6 +932,36 @@ jobTriggered: 'Job acionado',
|
|||||||
saved: 'Salvo',
|
saved: 'Salvo',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
githubPreview: {
|
||||||
|
title: "Prévia de versão",
|
||||||
|
description: "Clona a tag do GitHub selecionada para o workspace de prévia do Web UI, instala dependências e executa com as portas de desenvolvimento.",
|
||||||
|
refresh: "Atualizar",
|
||||||
|
selectTag: "Selecione uma tag",
|
||||||
|
prepare: "Preparar código",
|
||||||
|
install: "Instalar dependências",
|
||||||
|
start: "Iniciar prévia",
|
||||||
|
stop: "Parar",
|
||||||
|
note: "O código de prévia é armazenado no diretório de dados do Web UI. Produção permanece na porta 8648; a prévia usa frontend 8651 e backend 8650.",
|
||||||
|
path: "Caminho da prévia",
|
||||||
|
webuiHome: "Dados da prévia",
|
||||||
|
currentTag: "Tag atual",
|
||||||
|
repoReady: "Repositório pronto",
|
||||||
|
dependencies: "Dependências instaladas",
|
||||||
|
running: "Estado",
|
||||||
|
notRunning: "Não em execução",
|
||||||
|
open: "Abrir prévia",
|
||||||
|
log: "Caminho do log de ações",
|
||||||
|
logOutput: "Saída de logs",
|
||||||
|
actionLog: "Log de ações",
|
||||||
|
devLog: "Log do servidor dev",
|
||||||
|
yes: "Sim",
|
||||||
|
no: "Não",
|
||||||
|
actionFailed: "Ação falhou",
|
||||||
|
prepareSuccess: "Código de prévia pronto",
|
||||||
|
installSuccess: "Dependências instaladas",
|
||||||
|
startSuccess: "Prévia iniciada",
|
||||||
|
stopSuccess: "Prévia parada",
|
||||||
|
},
|
||||||
|
|
||||||
// Platform channel settings
|
// Platform channel settings
|
||||||
platform: {
|
platform: {
|
||||||
|
|||||||
@@ -137,6 +137,8 @@ export default {
|
|||||||
groupMonitoring: '監控',
|
groupMonitoring: '監控',
|
||||||
groupMonitoringShort: '監控',
|
groupMonitoringShort: '監控',
|
||||||
groupTools: '工具',
|
groupTools: '工具',
|
||||||
|
groupToolsShort: "工具",
|
||||||
|
versionPreview: "版本預覽",
|
||||||
settings: '設定',
|
settings: '設定',
|
||||||
connected: '已連線',
|
connected: '已連線',
|
||||||
disconnected: '未連線',
|
disconnected: '未連線',
|
||||||
@@ -1024,6 +1026,36 @@ export default {
|
|||||||
mimoStylePromptPlaceholder: '例如:用輕快上揚的語調,語速稍快',
|
mimoStylePromptPlaceholder: '例如:用輕快上揚的語調,語速稍快',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
githubPreview: {
|
||||||
|
title: "版本預覽",
|
||||||
|
description: "將選取的 GitHub tag 複製到 Web UI 預覽工作目錄,安裝依賴並以開發連接埠執行。",
|
||||||
|
refresh: "重新整理",
|
||||||
|
selectTag: "選擇 tag",
|
||||||
|
prepare: "準備程式碼",
|
||||||
|
install: "安裝依賴",
|
||||||
|
start: "開啟預覽",
|
||||||
|
stop: "停止",
|
||||||
|
note: "預覽程式碼存放在 Web UI 資料目錄下。正式環境仍使用 8648,預覽開發環境使用前端 8651、後端 8650。",
|
||||||
|
path: "預覽路徑",
|
||||||
|
webuiHome: "預覽資料目錄",
|
||||||
|
currentTag: "目前 Tag",
|
||||||
|
repoReady: "倉庫就緒",
|
||||||
|
dependencies: "依賴已安裝",
|
||||||
|
running: "執行狀態",
|
||||||
|
notRunning: "未執行",
|
||||||
|
open: "開啟預覽",
|
||||||
|
log: "操作日誌路徑",
|
||||||
|
logOutput: "日誌輸出",
|
||||||
|
actionLog: "操作日誌",
|
||||||
|
devLog: "開發服務日誌",
|
||||||
|
yes: "是",
|
||||||
|
no: "否",
|
||||||
|
actionFailed: "操作失敗",
|
||||||
|
prepareSuccess: "預覽程式碼已準備好",
|
||||||
|
installSuccess: "依賴安裝完成",
|
||||||
|
startSuccess: "預覽已啟動",
|
||||||
|
stopSuccess: "預覽已停止",
|
||||||
|
},
|
||||||
|
|
||||||
// 平台頻道設定
|
// 平台頻道設定
|
||||||
platform: {
|
platform: {
|
||||||
|
|||||||
@@ -137,6 +137,8 @@ export default {
|
|||||||
groupMonitoring: '监控',
|
groupMonitoring: '监控',
|
||||||
groupMonitoringShort: '监控',
|
groupMonitoringShort: '监控',
|
||||||
groupTools: '工具',
|
groupTools: '工具',
|
||||||
|
groupToolsShort: "工具",
|
||||||
|
versionPreview: "版本预览",
|
||||||
settings: '设置',
|
settings: '设置',
|
||||||
connected: '已连接',
|
connected: '已连接',
|
||||||
disconnected: '未连接',
|
disconnected: '未连接',
|
||||||
@@ -1024,6 +1026,36 @@ export default {
|
|||||||
mimoStylePromptPlaceholder: '例如:用轻快上扬的语调,语速稍快',
|
mimoStylePromptPlaceholder: '例如:用轻快上扬的语调,语速稍快',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
githubPreview: {
|
||||||
|
title: "版本预览",
|
||||||
|
description: "将选中的 GitHub tag 克隆到 Web UI 预览工作目录,安装依赖并以开发端口运行。",
|
||||||
|
refresh: "刷新",
|
||||||
|
selectTag: "选择 tag",
|
||||||
|
prepare: "准备代码",
|
||||||
|
install: "安装依赖",
|
||||||
|
start: "开启预览",
|
||||||
|
stop: "停止",
|
||||||
|
note: "预览代码存放在 Web UI 数据目录下。正式环境仍使用 8648,预览开发环境使用前端 8651、后端 8650。",
|
||||||
|
path: "预览路径",
|
||||||
|
webuiHome: "预览数据目录",
|
||||||
|
currentTag: "当前 Tag",
|
||||||
|
repoReady: "仓库就绪",
|
||||||
|
dependencies: "依赖已安装",
|
||||||
|
running: "运行状态",
|
||||||
|
notRunning: "未运行",
|
||||||
|
open: "打开预览",
|
||||||
|
log: "操作日志路径",
|
||||||
|
logOutput: "日志输出",
|
||||||
|
actionLog: "操作日志",
|
||||||
|
devLog: "开发服务日志",
|
||||||
|
yes: "是",
|
||||||
|
no: "否",
|
||||||
|
actionFailed: "操作失败",
|
||||||
|
prepareSuccess: "预览代码已准备好",
|
||||||
|
installSuccess: "依赖安装完成",
|
||||||
|
startSuccess: "预览已启动",
|
||||||
|
stopSuccess: "预览已停止",
|
||||||
|
},
|
||||||
|
|
||||||
// 平台频道设置
|
// 平台频道设置
|
||||||
platform: {
|
platform: {
|
||||||
|
|||||||
@@ -117,6 +117,12 @@ const router = createRouter({
|
|||||||
name: 'hermes.files',
|
name: 'hermes.files',
|
||||||
component: () => import('@/views/hermes/FilesView.vue'),
|
component: () => import('@/views/hermes/FilesView.vue'),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/hermes/version-preview',
|
||||||
|
name: 'hermes.versionPreview',
|
||||||
|
component: () => import('@/views/hermes/VersionPreviewView.vue'),
|
||||||
|
meta: { requiresSuperAdmin: true },
|
||||||
|
},
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -290,9 +290,9 @@ function buildWsUrl(): string {
|
|||||||
return `${wsProtocol}//${new URL(base).host}/api/hermes/terminal${token ? `?token=${encodeURIComponent(token)}` : ""}`;
|
return `${wsProtocol}//${new URL(base).host}/api/hermes/terminal${token ? `?token=${encodeURIComponent(token)}` : ""}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Dev mode: connect directly to backend port; Production: same host
|
const directDevPort = import.meta.env.VITE_HERMES_DIRECT_WS_PORT;
|
||||||
const host = import.meta.env.DEV
|
const host = import.meta.env.DEV && directDevPort
|
||||||
? formatHostForPort(location.hostname, 8648)
|
? formatHostForPort(location.hostname, Number(directDevPort))
|
||||||
: location.host;
|
: location.host;
|
||||||
return `${wsProtocol}//${host}/api/hermes/terminal${token ? `?token=${encodeURIComponent(token)}` : ""}`;
|
return `${wsProtocol}//${host}/api/hermes/terminal${token ? `?token=${encodeURIComponent(token)}` : ""}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import GithubPreviewSettings from '@/components/hermes/settings/GithubPreviewSettings.vue'
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="version-preview-view">
|
||||||
|
<header class="page-header">
|
||||||
|
<h2 class="header-title">{{ t('githubPreview.title') }}</h2>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="page-content">
|
||||||
|
<GithubPreviewSettings />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
@use "@/styles/variables" as *;
|
||||||
|
|
||||||
|
.version-preview-view {
|
||||||
|
height: calc(100 * var(--vh));
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-content {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,8 +1,106 @@
|
|||||||
import { execFileSync, spawn } from 'child_process'
|
import { execFileSync, spawn, type ChildProcess } from 'child_process'
|
||||||
import { existsSync } from 'fs'
|
import { appendFileSync, closeSync, existsSync, mkdirSync, openSync, readFileSync, rmSync, writeFileSync } from 'fs'
|
||||||
import { delimiter, dirname, join } from 'path'
|
import { createServer } from 'net'
|
||||||
|
import { delimiter, dirname, join, resolve } from 'path'
|
||||||
|
import { getWebUiHome } from '../config'
|
||||||
|
|
||||||
let updateInProgress = false
|
let updateInProgress = false
|
||||||
|
let previewProcess: ChildProcess | null = null
|
||||||
|
|
||||||
|
const PREVIEW_DIR_NAME = 'hermes-web-ui-pereview'
|
||||||
|
const PREVIEW_HOME_DIR_NAME = 'hermes-web-ui-pereview-home'
|
||||||
|
const PREVIEW_BACKEND_PORT = 8650
|
||||||
|
const PREVIEW_FRONTEND_PORT = 8651
|
||||||
|
const PREVIEW_AGENT_BRIDGE_PORT = 18650
|
||||||
|
const PREVIEW_FRONTEND_URL = `http://localhost:${PREVIEW_FRONTEND_PORT}`
|
||||||
|
const PREVIEW_TAG_REF_PATTERN = /^[A-Za-z0-9._/-]+$/
|
||||||
|
const PREVIEW_MAIN_REF = 'main'
|
||||||
|
|
||||||
|
interface PackageInfo {
|
||||||
|
name: string
|
||||||
|
version: string
|
||||||
|
repositoryUrl?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function readPackageInfo(): PackageInfo | null {
|
||||||
|
const candidatePaths = [
|
||||||
|
// ts-node dev: packages/server/src/controllers -> repo root
|
||||||
|
resolve(__dirname, '../../../../package.json'),
|
||||||
|
// bundled server: dist/server -> repo root/package root
|
||||||
|
resolve(__dirname, '../../package.json'),
|
||||||
|
// fallback for processes started at the repo root
|
||||||
|
resolve(process.cwd(), 'package.json'),
|
||||||
|
]
|
||||||
|
|
||||||
|
for (const packagePath of candidatePaths) {
|
||||||
|
if (!existsSync(packagePath)) continue
|
||||||
|
try {
|
||||||
|
const pkg = JSON.parse(readFileSync(packagePath, 'utf-8'))
|
||||||
|
if (pkg?.name && pkg?.version) {
|
||||||
|
const repository = typeof pkg.repository === 'string'
|
||||||
|
? pkg.repository
|
||||||
|
: typeof pkg.repository?.url === 'string'
|
||||||
|
? pkg.repository.url
|
||||||
|
: ''
|
||||||
|
return {
|
||||||
|
name: String(pkg.name),
|
||||||
|
version: String(pkg.version),
|
||||||
|
repositoryUrl: repository,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeGithubRepoUrl(raw: string): string {
|
||||||
|
return raw
|
||||||
|
.trim()
|
||||||
|
.replace(/^git\+/, '')
|
||||||
|
.replace(/^git@github\.com:/, 'https://github.com/')
|
||||||
|
.replace(/\.git$/, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPreviewRepoBaseUrl(): string {
|
||||||
|
const configured = process.env.HERMES_WEB_UI_PREVIEW_REPO?.trim()
|
||||||
|
const repository = configured || readPackageInfo()?.repositoryUrl || ''
|
||||||
|
const normalized = normalizeGithubRepoUrl(repository)
|
||||||
|
if (!normalized) throw new Error('Preview repository is not configured')
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPreviewRepoGitUrl(): string {
|
||||||
|
return `${getPreviewRepoBaseUrl()}.git`
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPreviewRepoApiUrl(): string {
|
||||||
|
const baseUrl = getPreviewRepoBaseUrl()
|
||||||
|
const match = baseUrl.match(/^https:\/\/github\.com\/([^/]+)\/([^/]+)$/)
|
||||||
|
if (!match) throw new Error(`Preview zip fallback only supports GitHub repositories: ${baseUrl}`)
|
||||||
|
return `https://api.github.com/repos/${match[1]}/${match[2]}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPreviewGithubRepoParts(): { owner: string; repo: string } {
|
||||||
|
const baseUrl = getPreviewRepoBaseUrl()
|
||||||
|
const match = baseUrl.match(/^https:\/\/github\.com\/([^/]+)\/([^/]+)$/)
|
||||||
|
if (!match) throw new Error(`Preview zip fallback only supports GitHub repositories: ${baseUrl}`)
|
||||||
|
return { owner: match[1], repo: match[2] }
|
||||||
|
}
|
||||||
|
|
||||||
|
function listPreviewTagsWithGit(): Array<{ name: string; sha: string }> {
|
||||||
|
const output = runGit(['ls-remote', '--tags', '--refs', getPreviewRepoGitUrl()])
|
||||||
|
return output
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.map(line => line.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.map(line => {
|
||||||
|
const [sha, ref] = line.split(/\s+/)
|
||||||
|
return { sha: sha || '', name: (ref || '').replace(/^refs\/tags\//, '') }
|
||||||
|
})
|
||||||
|
.filter(tag => tag.name)
|
||||||
|
.reverse()
|
||||||
|
}
|
||||||
|
|
||||||
function getNodeBinDir() {
|
function getNodeBinDir() {
|
||||||
return dirname(process.execPath)
|
return dirname(process.execPath)
|
||||||
@@ -55,20 +153,495 @@ function getCurrentNodeEnv() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function runNpm(args: string[], options: { timeout?: number } = {}) {
|
function runNpm(args: string[], options: { timeout?: number; cwd?: string; logLabel?: string; env?: NodeJS.ProcessEnv } = {}) {
|
||||||
const npmCli = getNpmCliPath()
|
const npmCli = getNpmCliPath()
|
||||||
const command = npmCli ? process.execPath : getNpmBin()
|
const command = npmCli ? process.execPath : getNpmBin()
|
||||||
const commandArgs = npmCli ? [npmCli, ...args] : args
|
const commandArgs = npmCli ? [npmCli, ...args] : args
|
||||||
|
const label = options.logLabel || ''
|
||||||
|
|
||||||
return execFileSync(command, commandArgs, {
|
if (label) appendPreviewActionLog(`${label}: ${command} ${commandArgs.join(' ')}${options.cwd ? `\ncwd: ${options.cwd}` : ''}`)
|
||||||
|
try {
|
||||||
|
const output = execFileSync(command, commandArgs, {
|
||||||
|
encoding: 'utf-8',
|
||||||
|
timeout: options.timeout,
|
||||||
|
stdio: ['pipe', 'pipe', 'pipe'],
|
||||||
|
env: {
|
||||||
|
...getCurrentNodeEnv(),
|
||||||
|
...options.env,
|
||||||
|
},
|
||||||
|
cwd: options.cwd,
|
||||||
|
windowsHide: true,
|
||||||
|
}).trim()
|
||||||
|
if (label) {
|
||||||
|
if (output) appendPreviewActionLog(`${label} output:\n${output}`)
|
||||||
|
appendPreviewActionLog(`${label} completed`)
|
||||||
|
}
|
||||||
|
return output
|
||||||
|
} catch (err: any) {
|
||||||
|
if (label) {
|
||||||
|
const stderr = err.stderr?.toString() || ''
|
||||||
|
const stdout = err.stdout?.toString() || ''
|
||||||
|
appendPreviewActionLog(`${label} failed`)
|
||||||
|
if (stdout) appendPreviewActionLog(`${label} stdout:\n${stdout}`)
|
||||||
|
if (stderr) appendPreviewActionLog(`${label} stderr:\n${stderr}`)
|
||||||
|
}
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPreviewDir() {
|
||||||
|
return join(getWebUiHome(), PREVIEW_DIR_NAME)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPreviewHomeDir() {
|
||||||
|
return join(getWebUiHome(), PREVIEW_HOME_DIR_NAME)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPreviewAgentBridgeEndpoint() {
|
||||||
|
return process.platform === 'win32'
|
||||||
|
? `tcp://127.0.0.1:${PREVIEW_AGENT_BRIDGE_PORT}`
|
||||||
|
: `ipc://${join(getPreviewHomeDir(), 'agent-bridge.sock')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPreviewPackagePath() {
|
||||||
|
return join(getPreviewDir(), 'package.json')
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPreviewLogPath() {
|
||||||
|
return join(getPreviewDir(), 'preview-dev.log')
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPreviewActionLogPath() {
|
||||||
|
return join(getPreviewDir(), 'preview-action.log')
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPreviewInstallEnv() {
|
||||||
|
return {
|
||||||
|
NODE_ENV: 'development',
|
||||||
|
npm_config_production: 'false',
|
||||||
|
npm_config_omit: '',
|
||||||
|
NPM_CONFIG_PRODUCTION: 'false',
|
||||||
|
NPM_CONFIG_OMIT: '',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function readLogTail(path: string, maxChars = 24_000): string {
|
||||||
|
if (!existsSync(path)) return ''
|
||||||
|
const raw = readFileSync(path, 'utf-8')
|
||||||
|
return raw.length > maxChars ? raw.slice(raw.length - maxChars) : raw
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCurrentPreviewTag() {
|
||||||
|
const tagPath = join(getPreviewDir(), '.preview-tag')
|
||||||
|
if (!existsSync(tagPath)) return ''
|
||||||
|
try {
|
||||||
|
return readFileSync(tagPath, 'utf-8').trim()
|
||||||
|
} catch {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendPreviewActionLog(message: string) {
|
||||||
|
mkdirSync(getPreviewDir(), { recursive: true })
|
||||||
|
appendFileSync(getPreviewActionLogPath(), `[${new Date().toISOString()}] ${message}\n`, 'utf-8')
|
||||||
|
}
|
||||||
|
|
||||||
|
function previewPayload(extra: Record<string, any> = {}) {
|
||||||
|
return {
|
||||||
|
...extra,
|
||||||
|
...getPreviewStatus(),
|
||||||
|
action_log: readLogTail(getPreviewActionLogPath()),
|
||||||
|
dev_log: readLogTail(getPreviewLogPath()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPreviewStatus() {
|
||||||
|
const previewDir = getPreviewDir()
|
||||||
|
const packagePath = getPreviewPackagePath()
|
||||||
|
const exists = existsSync(previewDir)
|
||||||
|
const hasPackage = existsSync(packagePath)
|
||||||
|
const installed = hasPackage && getMissingPreviewDependencyBins().length === 0
|
||||||
|
const running = Boolean(previewProcess?.pid && !previewProcess.killed)
|
||||||
|
const currentTag = getCurrentPreviewTag()
|
||||||
|
|
||||||
|
return {
|
||||||
|
preview_dir: previewDir,
|
||||||
|
exists,
|
||||||
|
has_package: hasPackage,
|
||||||
|
installed,
|
||||||
|
running,
|
||||||
|
pid: running ? previewProcess?.pid : null,
|
||||||
|
current_tag: currentTag,
|
||||||
|
frontend_url: PREVIEW_FRONTEND_URL,
|
||||||
|
agent_bridge_endpoint: getPreviewAgentBridgeEndpoint(),
|
||||||
|
log_path: getPreviewLogPath(),
|
||||||
|
action_log_path: getPreviewActionLogPath(),
|
||||||
|
dev_log_path: getPreviewLogPath(),
|
||||||
|
webui_home: getPreviewHomeDir(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sleep(ms: number) {
|
||||||
|
return new Promise(resolve => setTimeout(resolve, ms))
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPortAvailable(port: number): Promise<boolean> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const server = createServer()
|
||||||
|
server.once('error', () => resolve(false))
|
||||||
|
server.once('listening', () => {
|
||||||
|
server.close(() => resolve(true))
|
||||||
|
})
|
||||||
|
server.listen(port, '127.0.0.1')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function assertPreviewPortsAvailable() {
|
||||||
|
const ports = [
|
||||||
|
PREVIEW_BACKEND_PORT,
|
||||||
|
PREVIEW_FRONTEND_PORT,
|
||||||
|
...(process.platform === 'win32' ? [PREVIEW_AGENT_BRIDGE_PORT] : []),
|
||||||
|
]
|
||||||
|
const checks = await Promise.all(ports.map(port => isPortAvailable(port)))
|
||||||
|
const busy = ports.filter((_, index) => !checks[index])
|
||||||
|
|
||||||
|
if (busy.length) {
|
||||||
|
throw new Error(`Preview port(s) already in use: ${busy.join(', ')}. Stop the existing dev server and try again.`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForPreviewReady(timeoutMs = 30_000) {
|
||||||
|
const deadline = Date.now() + timeoutMs
|
||||||
|
let lastError = ''
|
||||||
|
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
if (!previewProcess || previewProcess.killed) {
|
||||||
|
throw new Error(`Preview process exited before it became ready. Check log: ${getPreviewLogPath()}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`http://127.0.0.1:${PREVIEW_FRONTEND_PORT}/`, {
|
||||||
|
signal: AbortSignal.timeout(1500),
|
||||||
|
})
|
||||||
|
if (res.ok) return
|
||||||
|
lastError = `HTTP ${res.status}`
|
||||||
|
} catch (err: any) {
|
||||||
|
lastError = err.message || String(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
await sleep(1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Preview did not become ready on port ${PREVIEW_FRONTEND_PORT}. Last error: ${lastError}. Check log: ${getPreviewLogPath()}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function openPreviewLogFile() {
|
||||||
|
mkdirSync(getPreviewDir(), { recursive: true })
|
||||||
|
writeFileSync(getPreviewLogPath(), `[preview] starting ${new Date().toISOString()}\n`, 'utf-8')
|
||||||
|
return openSync(getPreviewLogPath(), 'a')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function stopPreviewProcess() {
|
||||||
|
const child = previewProcess
|
||||||
|
if (!child?.pid || child.killed) {
|
||||||
|
previewProcess = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
appendPreviewActionLog(`stopping preview process pid=${child.pid}`)
|
||||||
|
try {
|
||||||
|
if (process.platform === 'win32') {
|
||||||
|
spawn('taskkill', ['/pid', String(child.pid), '/T', '/F'], { stdio: 'ignore', windowsHide: true })
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
process.kill(-child.pid, 'SIGTERM')
|
||||||
|
} catch {
|
||||||
|
child.kill('SIGTERM')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
child.kill()
|
||||||
|
}
|
||||||
|
|
||||||
|
previewProcess = null
|
||||||
|
await sleep(800)
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertPreviewPackage() {
|
||||||
|
const packagePath = getPreviewPackagePath()
|
||||||
|
if (!existsSync(packagePath)) {
|
||||||
|
throw new Error(`Preview package.json not found: ${packagePath}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const pkg = JSON.parse(readFileSync(packagePath, 'utf-8'))
|
||||||
|
if (pkg?.name !== 'hermes-web-ui') {
|
||||||
|
throw new Error(`Preview directory is not hermes-web-ui: ${getPreviewDir()}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPreviewBinPath(name: string) {
|
||||||
|
return join(getPreviewDir(), 'node_modules', '.bin', process.platform === 'win32' ? `${name}.cmd` : name)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPreviewNodePtyError() {
|
||||||
|
if (!existsSync(join(getPreviewDir(), 'node_modules', 'node-pty'))) {
|
||||||
|
return 'node-pty'
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
execFileSync(process.execPath, ['-e', "require('node-pty')"], {
|
||||||
|
cwd: getPreviewDir(),
|
||||||
|
encoding: 'utf-8',
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
timeout: 30_000,
|
||||||
|
windowsHide: true,
|
||||||
|
})
|
||||||
|
return ''
|
||||||
|
} catch (err: any) {
|
||||||
|
return `node-pty (${err.stderr?.toString().trim() || err.message || String(err)})`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMissingPreviewDependencyBins() {
|
||||||
|
if (!existsSync(join(getPreviewDir(), 'node_modules'))) {
|
||||||
|
return ['node_modules']
|
||||||
|
}
|
||||||
|
|
||||||
|
const missing = ['concurrently', 'vite', 'nodemon'].filter(name => !existsSync(getPreviewBinPath(name)))
|
||||||
|
const nodePtyError = getPreviewNodePtyError()
|
||||||
|
if (nodePtyError) missing.push(nodePtyError)
|
||||||
|
return missing
|
||||||
|
}
|
||||||
|
|
||||||
|
function patchFileIfExists(path: string, patcher: (source: string) => string) {
|
||||||
|
if (!existsSync(path)) return
|
||||||
|
const source = readFileSync(path, 'utf-8')
|
||||||
|
const next = patcher(source)
|
||||||
|
if (next !== source) writeFileSync(path, next, 'utf-8')
|
||||||
|
}
|
||||||
|
|
||||||
|
function patchPreviewWebSocketClient(source: string) {
|
||||||
|
return source.replace(
|
||||||
|
/const host = import\.meta\.env\.DEV\s*\?\s*formatHostForPort\(location\.hostname,\s*\d+\)\s*:\s*location\.host/g,
|
||||||
|
[
|
||||||
|
'const directDevPort = import.meta.env.VITE_HERMES_DIRECT_WS_PORT',
|
||||||
|
' const host = import.meta.env.DEV && directDevPort',
|
||||||
|
' ? formatHostForPort(location.hostname, Number(directDevPort))',
|
||||||
|
' : location.host',
|
||||||
|
].join('\n'),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function patchPreviewApiClient(source: string) {
|
||||||
|
return source.replace(
|
||||||
|
/return localStorage\.getItem\(['"]hermes_server_url['"]\) \|\| DEFAULT_BASE_URL/,
|
||||||
|
"return import.meta.env.VITE_HERMES_PREVIEW === '1' ? DEFAULT_BASE_URL : localStorage.getItem('hermes_server_url') || DEFAULT_BASE_URL",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function patchPreviewViteConfig(source: string) {
|
||||||
|
let next = source.replace(
|
||||||
|
/const BACKEND = ['"]http:\/\/127\.0\.0\.1:\d+['"]/,
|
||||||
|
[
|
||||||
|
`const BACKEND_PORT = process.env.HERMES_WEB_UI_BACKEND_PORT || '${PREVIEW_BACKEND_PORT}'`,
|
||||||
|
'const BACKEND = `http://127.0.0.1:${BACKEND_PORT}`',
|
||||||
|
].join('\n'),
|
||||||
|
)
|
||||||
|
if (!next.includes('HERMES_WEB_UI_FRONTEND_PORT')) {
|
||||||
|
next = next.replace(
|
||||||
|
/server:\s*\{/,
|
||||||
|
`server: {\n port: Number(process.env.HERMES_WEB_UI_FRONTEND_PORT || ${PREVIEW_FRONTEND_PORT}),\n strictPort: true,`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
next = next.replace(
|
||||||
|
/(changeOrigin:\s*true,)(?!\s*\n\s*ws:\s*true,)/,
|
||||||
|
'$1\n ws: true,',
|
||||||
|
)
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
function patchPreviewSidebar(source: string) {
|
||||||
|
let next = source
|
||||||
|
if (!next.includes('VITE_HERMES_PREVIEW')) {
|
||||||
|
next = next.replace(
|
||||||
|
/const isSuperAdmin = computed\(\(\) => isStoredSuperAdmin\(\)\);/,
|
||||||
|
"const isSuperAdmin = computed(() => isStoredSuperAdmin());\nconst isVersionPreview = import.meta.env.VITE_HERMES_PREVIEW === '1';",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
next = next.replace(
|
||||||
|
/<RouteLinkItem v-if="isSuperAdmin" class="nav-item" :to="\{ name: 'hermes\.versionPreview' \}"/,
|
||||||
|
'<RouteLinkItem v-if="isSuperAdmin && !isVersionPreview" class="nav-item" :to="{ name: \'hermes.versionPreview\' }"',
|
||||||
|
)
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyPreviewRuntimePatch() {
|
||||||
|
const previewDir = getPreviewDir()
|
||||||
|
const packagePath = getPreviewPackagePath()
|
||||||
|
const viteConfigPath = join(previewDir, 'vite.config.ts')
|
||||||
|
|
||||||
|
if (existsSync(packagePath)) {
|
||||||
|
const pkg = JSON.parse(readFileSync(packagePath, 'utf-8'))
|
||||||
|
pkg.scripts = {
|
||||||
|
...pkg.scripts,
|
||||||
|
'dev:client': `vite --host --port ${PREVIEW_FRONTEND_PORT} --strictPort`,
|
||||||
|
}
|
||||||
|
writeFileSync(packagePath, JSON.stringify(pkg, null, 2) + '\n', 'utf-8')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existsSync(viteConfigPath)) {
|
||||||
|
patchFileIfExists(viteConfigPath, patchPreviewViteConfig)
|
||||||
|
}
|
||||||
|
|
||||||
|
patchFileIfExists(join(previewDir, 'packages/client/src/components/hermes/chat/TerminalPanel.vue'), patchPreviewWebSocketClient)
|
||||||
|
patchFileIfExists(join(previewDir, 'packages/client/src/views/hermes/TerminalView.vue'), patchPreviewWebSocketClient)
|
||||||
|
patchFileIfExists(join(previewDir, 'packages/client/src/api/hermes/kanban.ts'), patchPreviewWebSocketClient)
|
||||||
|
patchFileIfExists(join(previewDir, 'packages/client/src/api/client.ts'), patchPreviewApiClient)
|
||||||
|
patchFileIfExists(join(previewDir, 'packages/client/src/components/layout/AppSidebar.vue'), patchPreviewSidebar)
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertTagRef(tag: unknown): string {
|
||||||
|
const value = typeof tag === 'string' ? tag.trim() : ''
|
||||||
|
if (!value) throw new Error('Tag is required')
|
||||||
|
if (!PREVIEW_TAG_REF_PATTERN.test(value) || value.includes('..')) {
|
||||||
|
throw new Error('Invalid tag')
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
function runGit(args: string[], cwd?: string) {
|
||||||
|
return execFileSync('git', args, {
|
||||||
|
cwd,
|
||||||
encoding: 'utf-8',
|
encoding: 'utf-8',
|
||||||
timeout: options.timeout,
|
timeout: 5 * 60 * 1000,
|
||||||
stdio: ['pipe', 'pipe', 'pipe'],
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
env: getCurrentNodeEnv(),
|
|
||||||
windowsHide: true,
|
windowsHide: true,
|
||||||
}).trim()
|
}).trim()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isGitAvailable() {
|
||||||
|
try {
|
||||||
|
runGit(['--version'])
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function networkErrorMessage(err: any): string {
|
||||||
|
const detail = err.stderr?.toString() || err.message || String(err)
|
||||||
|
return `Unable to connect to GitHub. Please check your network or proxy settings. ${detail}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function errorMessage(err: any): string {
|
||||||
|
return err.stderr?.toString() || err.message || String(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadGithubZip(ref: string, targetDir: string, type: 'tag' | 'branch' = 'tag') {
|
||||||
|
const { owner, repo } = getPreviewGithubRepoParts()
|
||||||
|
const refKind = type === 'branch' ? 'heads' : 'tags'
|
||||||
|
const archiveKind = process.platform === 'win32' ? 'zip' : 'tar.gz'
|
||||||
|
const url = `https://codeload.github.com/${owner}/${repo}/${archiveKind}/refs/${refKind}/${encodeURIComponent(ref)}`
|
||||||
|
appendPreviewActionLog(`download archive: ${url}`)
|
||||||
|
const res = await fetch(url, {
|
||||||
|
headers: { 'User-Agent': 'hermes-web-ui-preview' },
|
||||||
|
signal: AbortSignal.timeout(60_000),
|
||||||
|
})
|
||||||
|
if (!res.ok) throw new Error(`Failed to download GitHub archive: HTTP ${res.status}`)
|
||||||
|
|
||||||
|
const tmpRoot = `${targetDir}.download`
|
||||||
|
const archivePath = `${tmpRoot}.${archiveKind === 'zip' ? 'zip' : 'tar.gz'}`
|
||||||
|
rmSync(tmpRoot, { recursive: true, force: true })
|
||||||
|
rmSync(archivePath, { force: true })
|
||||||
|
mkdirSync(tmpRoot, { recursive: true })
|
||||||
|
const archiveBuffer = Buffer.from(await res.arrayBuffer())
|
||||||
|
writeFileSync(archivePath, archiveBuffer)
|
||||||
|
appendPreviewActionLog(`downloaded archive: ${archiveBuffer.length} bytes`)
|
||||||
|
|
||||||
|
try {
|
||||||
|
appendPreviewActionLog(`extract archive: ${archivePath}`)
|
||||||
|
if (process.platform === 'win32') {
|
||||||
|
execFileSync('powershell.exe', [
|
||||||
|
'-NoProfile',
|
||||||
|
'-Command',
|
||||||
|
`Expand-Archive -LiteralPath ${JSON.stringify(archivePath)} -DestinationPath ${JSON.stringify(tmpRoot)} -Force`,
|
||||||
|
], { stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true, timeout: 5 * 60 * 1000 })
|
||||||
|
} else {
|
||||||
|
execFileSync('tar', ['-xzf', archivePath, '-C', tmpRoot], { stdio: ['ignore', 'pipe', 'pipe'], timeout: 5 * 60 * 1000 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const entries = execFileSync(process.platform === 'win32' ? 'cmd.exe' : 'ls', process.platform === 'win32' ? ['/c', 'dir', '/b', tmpRoot] : [tmpRoot], {
|
||||||
|
encoding: 'utf-8',
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
timeout: 30_000,
|
||||||
|
windowsHide: true,
|
||||||
|
}).trim().split(/\r?\n/).filter(Boolean)
|
||||||
|
const extracted = entries.length === 1 ? join(tmpRoot, entries[0]) : tmpRoot
|
||||||
|
appendPreviewActionLog(`replace preview directory: ${targetDir}`)
|
||||||
|
rmSync(targetDir, { recursive: true, force: true })
|
||||||
|
mkdirSync(dirname(targetDir), { recursive: true })
|
||||||
|
if (process.platform !== 'win32') mkdirSync(targetDir, { recursive: true })
|
||||||
|
execFileSync(process.platform === 'win32' ? 'cmd.exe' : 'cp', process.platform === 'win32'
|
||||||
|
? ['/c', 'move', extracted, targetDir]
|
||||||
|
: ['-R', `${extracted}/.`, targetDir], {
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
timeout: 5 * 60 * 1000,
|
||||||
|
windowsHide: true,
|
||||||
|
})
|
||||||
|
appendPreviewActionLog('archive preview code ready')
|
||||||
|
} finally {
|
||||||
|
rmSync(tmpRoot, { recursive: true, force: true })
|
||||||
|
rmSync(archivePath, { force: true })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clonePreview(ref: string) {
|
||||||
|
const previewDir = getPreviewDir()
|
||||||
|
appendPreviewActionLog(`prepare preview clone for tag: ${ref}`)
|
||||||
|
rmSync(previewDir, { recursive: true, force: true })
|
||||||
|
mkdirSync(dirname(previewDir), { recursive: true })
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!isGitAvailable()) throw new Error('git is not available')
|
||||||
|
appendPreviewActionLog(`git clone --branch ${ref} --depth 1 ${getPreviewRepoGitUrl()} ${previewDir}`)
|
||||||
|
runGit(['clone', '--branch', ref, '--depth', '1', getPreviewRepoGitUrl(), previewDir])
|
||||||
|
appendPreviewActionLog('git clone completed')
|
||||||
|
} catch {
|
||||||
|
appendPreviewActionLog('git clone unavailable or failed, falling back to GitHub zip')
|
||||||
|
rmSync(previewDir, { recursive: true, force: true })
|
||||||
|
await downloadGithubZip(ref, previewDir, ref === PREVIEW_MAIN_REF ? 'branch' : 'tag')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkoutPreview(ref: string) {
|
||||||
|
const previewDir = getPreviewDir()
|
||||||
|
appendPreviewActionLog(`checkout preview tag: ${ref}`)
|
||||||
|
if (!existsSync(previewDir)) {
|
||||||
|
await clonePreview(ref)
|
||||||
|
} else if (existsSync(join(previewDir, '.git')) && isGitAvailable()) {
|
||||||
|
try {
|
||||||
|
appendPreviewActionLog('git fetch --tags --force')
|
||||||
|
runGit(['fetch', '--tags', '--force'], previewDir)
|
||||||
|
appendPreviewActionLog(`git checkout --force ${ref}`)
|
||||||
|
runGit(['checkout', '--force', ref], previewDir)
|
||||||
|
} catch (err: any) {
|
||||||
|
appendPreviewActionLog(`git checkout failed, replacing with GitHub zip: ${err.stderr?.toString() || err.message || String(err)}`)
|
||||||
|
rmSync(previewDir, { recursive: true, force: true })
|
||||||
|
await downloadGithubZip(ref, previewDir, ref === PREVIEW_MAIN_REF ? 'branch' : 'tag')
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
appendPreviewActionLog('preview directory is missing git metadata or package.json, replacing with GitHub zip')
|
||||||
|
rmSync(previewDir, { recursive: true, force: true })
|
||||||
|
await downloadGithubZip(ref, previewDir, ref === PREVIEW_MAIN_REF ? 'branch' : 'tag')
|
||||||
|
}
|
||||||
|
|
||||||
|
assertPreviewPackage()
|
||||||
|
appendPreviewActionLog('apply preview runtime port patch')
|
||||||
|
applyPreviewRuntimePatch()
|
||||||
|
writeFileSync(join(previewDir, '.preview-tag'), `${ref}\n`)
|
||||||
|
appendPreviewActionLog(`preview tag ready: ${ref}`)
|
||||||
|
}
|
||||||
|
|
||||||
function getGlobalRoot() {
|
function getGlobalRoot() {
|
||||||
return runNpm(['root', '-g'])
|
return runNpm(['root', '-g'])
|
||||||
}
|
}
|
||||||
@@ -154,3 +727,178 @@ export async function handleUpdate(ctx: any) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function previewStatus(ctx: any) {
|
||||||
|
ctx.body = previewPayload()
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function previewTags(ctx: any) {
|
||||||
|
try {
|
||||||
|
if (isGitAvailable()) {
|
||||||
|
appendPreviewActionLog('load tags with git ls-remote')
|
||||||
|
ctx.body = { tags: [{ name: PREVIEW_MAIN_REF, sha: '' }, ...listPreviewTagsWithGit()] }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
try {
|
||||||
|
appendPreviewActionLog('load tags with GitHub API')
|
||||||
|
const res = await fetch(`${getPreviewRepoApiUrl()}/tags?per_page=100`, {
|
||||||
|
headers: { 'User-Agent': 'hermes-web-ui-preview' },
|
||||||
|
signal: AbortSignal.timeout(15_000),
|
||||||
|
})
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`GitHub API HTTP ${res.status}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const tags = await res.json() as Array<{ name?: string; commit?: { sha?: string } }>
|
||||||
|
ctx.body = {
|
||||||
|
tags: [
|
||||||
|
{ name: PREVIEW_MAIN_REF, sha: '' },
|
||||||
|
...tags
|
||||||
|
.filter(tag => typeof tag.name === 'string' && tag.name.trim())
|
||||||
|
.map(tag => ({ name: tag.name, sha: tag.commit?.sha || '' })),
|
||||||
|
],
|
||||||
|
}
|
||||||
|
} catch (apiErr: any) {
|
||||||
|
appendPreviewActionLog(`load tags failed: ${apiErr.message || String(apiErr)}`)
|
||||||
|
ctx.status = 502
|
||||||
|
ctx.body = previewPayload({ error: networkErrorMessage(apiErr) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function preparePreview(ctx: any) {
|
||||||
|
try {
|
||||||
|
const tag = assertTagRef((ctx.request.body as any)?.tag)
|
||||||
|
appendPreviewActionLog(`prepare requested: ${tag}`)
|
||||||
|
await stopPreviewProcess()
|
||||||
|
await checkoutPreview(tag)
|
||||||
|
ctx.body = previewPayload({ success: true })
|
||||||
|
} catch (err: any) {
|
||||||
|
appendPreviewActionLog(`prepare failed: ${errorMessage(err)}`)
|
||||||
|
ctx.status = 500
|
||||||
|
ctx.body = previewPayload({ success: false, message: errorMessage(err) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function installPreview(ctx: any) {
|
||||||
|
try {
|
||||||
|
appendPreviewActionLog('npm install requested')
|
||||||
|
await stopPreviewProcess()
|
||||||
|
assertPreviewPackage()
|
||||||
|
const output = runNpm(['install', '--include=dev', '--ignore-scripts'], {
|
||||||
|
cwd: getPreviewDir(),
|
||||||
|
timeout: 15 * 60 * 1000,
|
||||||
|
logLabel: 'npm install --include=dev --ignore-scripts',
|
||||||
|
env: getPreviewInstallEnv(),
|
||||||
|
})
|
||||||
|
if (existsSync(join(getPreviewDir(), 'node_modules', 'node-pty'))) {
|
||||||
|
runNpm(['rebuild', 'node-pty'], {
|
||||||
|
cwd: getPreviewDir(),
|
||||||
|
timeout: 5 * 60 * 1000,
|
||||||
|
logLabel: 'npm rebuild node-pty',
|
||||||
|
env: getPreviewInstallEnv(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
appendPreviewActionLog(`verify preview dependencies in: ${getPreviewDir()}`)
|
||||||
|
const missing = getMissingPreviewDependencyBins()
|
||||||
|
if (missing.length) {
|
||||||
|
const message = `npm install completed but preview dependencies are still missing: ${missing.join(', ')}`
|
||||||
|
appendPreviewActionLog(message)
|
||||||
|
ctx.body = previewPayload({ success: false, message })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx.body = previewPayload({ success: true, message: output })
|
||||||
|
} catch (err: any) {
|
||||||
|
appendPreviewActionLog(`npm install failed: ${err.stderr?.toString() || err.message || String(err)}`)
|
||||||
|
ctx.status = 500
|
||||||
|
ctx.body = previewPayload({ success: false, message: err.stderr?.toString() || err.message || String(err) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function startPreview(ctx: any) {
|
||||||
|
try {
|
||||||
|
const tag = (ctx.request.body as any)?.tag
|
||||||
|
const requestedTag = typeof tag === 'string' && tag.trim() ? assertTagRef(tag) : ''
|
||||||
|
appendPreviewActionLog(`npm run dev requested${requestedTag ? ` for ${requestedTag}` : ''}`)
|
||||||
|
if (requestedTag && requestedTag !== getCurrentPreviewTag() && previewProcess?.pid && !previewProcess.killed) {
|
||||||
|
await stopPreviewProcess()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (requestedTag) {
|
||||||
|
const currentTag = getCurrentPreviewTag()
|
||||||
|
if (requestedTag === currentTag && existsSync(getPreviewPackagePath())) {
|
||||||
|
appendPreviewActionLog(`skip checkout, preview tag already prepared: ${requestedTag}`)
|
||||||
|
appendPreviewActionLog('apply preview runtime port patch')
|
||||||
|
applyPreviewRuntimePatch()
|
||||||
|
} else {
|
||||||
|
await checkoutPreview(requestedTag)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assertPreviewPackage()
|
||||||
|
const missingDependencies = getMissingPreviewDependencyBins()
|
||||||
|
if (missingDependencies.length) {
|
||||||
|
const message = `Preview dependencies are not installed. Missing: ${missingDependencies.join(', ')}. Run npm install first.`
|
||||||
|
appendPreviewActionLog(`start blocked: ${message}`)
|
||||||
|
ctx.body = previewPayload({ success: false, message })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (previewProcess?.pid && !previewProcess.killed) {
|
||||||
|
appendPreviewActionLog('preview is already running')
|
||||||
|
ctx.body = previewPayload({ success: true, message: 'Preview is already running' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await assertPreviewPortsAvailable()
|
||||||
|
|
||||||
|
const npmCli = getNpmCliPath()
|
||||||
|
const command = npmCli ? process.execPath : getNpmBin()
|
||||||
|
const commandArgs = npmCli ? [npmCli, 'run', 'dev'] : ['run', 'dev']
|
||||||
|
const logFd = openPreviewLogFile()
|
||||||
|
appendPreviewActionLog(`spawn preview process: ${command} ${commandArgs.join(' ')}`)
|
||||||
|
previewProcess = spawn(command, commandArgs, {
|
||||||
|
cwd: getPreviewDir(),
|
||||||
|
detached: true,
|
||||||
|
stdio: ['ignore', logFd, logFd],
|
||||||
|
windowsHide: true,
|
||||||
|
env: {
|
||||||
|
...getCurrentNodeEnv(),
|
||||||
|
NODE_ENV: 'development',
|
||||||
|
PORT: String(PREVIEW_BACKEND_PORT),
|
||||||
|
HERMES_WEB_UI_HOME: getPreviewHomeDir(),
|
||||||
|
HERMES_WEBUI_STATE_DIR: getPreviewHomeDir(),
|
||||||
|
HERMES_AGENT_BRIDGE_ENDPOINT: getPreviewAgentBridgeEndpoint(),
|
||||||
|
AUTH_TOKEN: '',
|
||||||
|
HERMES_WEB_UI_BACKEND_PORT: String(PREVIEW_BACKEND_PORT),
|
||||||
|
HERMES_WEB_UI_FRONTEND_PORT: String(PREVIEW_FRONTEND_PORT),
|
||||||
|
VITE_HERMES_PREVIEW: '1',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
closeSync(logFd)
|
||||||
|
previewProcess.on('exit', () => {
|
||||||
|
appendPreviewActionLog('preview process exited')
|
||||||
|
previewProcess = null
|
||||||
|
})
|
||||||
|
previewProcess.on('error', (err) => {
|
||||||
|
console.error('[preview] failed:', err)
|
||||||
|
previewProcess = null
|
||||||
|
})
|
||||||
|
previewProcess.unref()
|
||||||
|
|
||||||
|
await waitForPreviewReady()
|
||||||
|
|
||||||
|
appendPreviewActionLog(`preview ready: ${PREVIEW_FRONTEND_URL}`)
|
||||||
|
ctx.body = previewPayload({ success: true, message: 'Preview started' })
|
||||||
|
} catch (err: any) {
|
||||||
|
appendPreviewActionLog(`npm run dev failed: ${err.stderr?.toString() || err.message || String(err)}`)
|
||||||
|
ctx.status = 500
|
||||||
|
ctx.body = previewPayload({ success: false, message: err.message || String(err) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function stopPreview(ctx: any) {
|
||||||
|
appendPreviewActionLog('stop preview requested')
|
||||||
|
await stopPreviewProcess()
|
||||||
|
ctx.body = previewPayload({ success: true })
|
||||||
|
}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ export const USAGE_SCHEMA: Record<string, string> = {
|
|||||||
reasoning_tokens: 'INTEGER NOT NULL DEFAULT 0',
|
reasoning_tokens: 'INTEGER NOT NULL DEFAULT 0',
|
||||||
model: "TEXT NOT NULL DEFAULT ''",
|
model: "TEXT NOT NULL DEFAULT ''",
|
||||||
profile: "TEXT NOT NULL DEFAULT 'default'",
|
profile: "TEXT NOT NULL DEFAULT 'default'",
|
||||||
created_at: 'INTEGER NOT NULL',
|
created_at: 'INTEGER NOT NULL DEFAULT 0',
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
import Router from '@koa/router'
|
import Router from '@koa/router'
|
||||||
import * as ctrl from '../controllers/update'
|
import * as ctrl from '../controllers/update'
|
||||||
|
import { requireSuperAdmin } from '../middleware/user-auth'
|
||||||
|
|
||||||
export const updateRoutes = new Router()
|
export const updateRoutes = new Router()
|
||||||
|
|
||||||
updateRoutes.post('/api/hermes/update', ctrl.handleUpdate)
|
updateRoutes.post('/api/hermes/update', ctrl.handleUpdate)
|
||||||
|
updateRoutes.get('/api/hermes/update/preview', requireSuperAdmin, ctrl.previewStatus)
|
||||||
|
updateRoutes.get('/api/hermes/update/preview/tags', requireSuperAdmin, ctrl.previewTags)
|
||||||
|
updateRoutes.post('/api/hermes/update/preview/prepare', requireSuperAdmin, ctrl.preparePreview)
|
||||||
|
updateRoutes.post('/api/hermes/update/preview/install', requireSuperAdmin, ctrl.installPreview)
|
||||||
|
updateRoutes.post('/api/hermes/update/preview/start', requireSuperAdmin, ctrl.startPreview)
|
||||||
|
updateRoutes.post('/api/hermes/update/preview/stop', requireSuperAdmin, ctrl.stopPreview)
|
||||||
|
|||||||
@@ -172,6 +172,7 @@ describe('AppSidebar search entry', () => {
|
|||||||
'sidebar.groupConversationShort',
|
'sidebar.groupConversationShort',
|
||||||
'sidebar.groupAgentShort',
|
'sidebar.groupAgentShort',
|
||||||
'sidebar.groupMonitoringShort',
|
'sidebar.groupMonitoringShort',
|
||||||
|
'sidebar.groupToolsShort',
|
||||||
'sidebar.groupSystemShort',
|
'sidebar.groupSystemShort',
|
||||||
])
|
])
|
||||||
|
|
||||||
|
|||||||
@@ -162,6 +162,22 @@ describe('Database Schema Synchronization', () => {
|
|||||||
expect(row).toBeTruthy()
|
expect(row).toBeTruthy()
|
||||||
expect(row.session_id).toBe('test-1')
|
expect(row.session_id).toBe('test-1')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('adds created_at to legacy session_usage tables missing the column', async () => {
|
||||||
|
const { syncTable, USAGE_TABLE, USAGE_SCHEMA } = await import('../../packages/server/src/db/hermes/schemas')
|
||||||
|
|
||||||
|
const db = getTestDb()
|
||||||
|
db.exec(`CREATE TABLE "${USAGE_TABLE}" (id INTEGER PRIMARY KEY AUTOINCREMENT, session_id TEXT NOT NULL)`)
|
||||||
|
db.prepare(`INSERT INTO "${USAGE_TABLE}" (session_id) VALUES (?)`).run('legacy-session')
|
||||||
|
|
||||||
|
syncTable(USAGE_TABLE, USAGE_SCHEMA, { primaryKey: 'id' })
|
||||||
|
|
||||||
|
const cols = getTableColumns(db, USAGE_TABLE)
|
||||||
|
expect(cols.has('created_at')).toBe(true)
|
||||||
|
|
||||||
|
const row = db.prepare(`SELECT session_id, created_at FROM "${USAGE_TABLE}" WHERE session_id = ?`).get('legacy-session')
|
||||||
|
expect(row).toMatchObject({ session_id: 'legacy-session', created_at: 0 })
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('Schema sync with single-column primary keys', () => {
|
describe('Schema sync with single-column primary keys', () => {
|
||||||
|
|||||||
+6
-1
@@ -4,12 +4,15 @@ import type { ProxyOptions } from 'vite'
|
|||||||
import { resolve } from 'path'
|
import { resolve } from 'path'
|
||||||
import pkg from './package.json'
|
import pkg from './package.json'
|
||||||
|
|
||||||
const BACKEND = 'http://127.0.0.1:8648'
|
const FRONTEND_PORT = Number(process.env.HERMES_WEB_UI_FRONTEND_PORT || 8649)
|
||||||
|
const BACKEND_PORT = process.env.HERMES_WEB_UI_BACKEND_PORT || '8648'
|
||||||
|
const BACKEND = `http://127.0.0.1:${BACKEND_PORT}`
|
||||||
|
|
||||||
function createProxyConfig(): ProxyOptions {
|
function createProxyConfig(): ProxyOptions {
|
||||||
return {
|
return {
|
||||||
target: BACKEND,
|
target: BACKEND,
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
|
ws: true,
|
||||||
configure: (proxy) => {
|
configure: (proxy) => {
|
||||||
proxy.on('proxyReq', (proxyReq) => {
|
proxy.on('proxyReq', (proxyReq) => {
|
||||||
proxyReq.removeHeader('origin')
|
proxyReq.removeHeader('origin')
|
||||||
@@ -89,6 +92,8 @@ export default defineConfig({
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
server: {
|
server: {
|
||||||
|
port: FRONTEND_PORT,
|
||||||
|
strictPort: true,
|
||||||
proxy: {
|
proxy: {
|
||||||
'/api': createProxyConfig(),
|
'/api': createProxyConfig(),
|
||||||
'/v1': createProxyConfig(),
|
'/v1': createProxyConfig(),
|
||||||
|
|||||||
Reference in New Issue
Block a user