[codex] add version preview workflow (#1086)

* add version preview workflow

* fix sidebar group test

* fix legacy usage schema migration
This commit is contained in:
ekko
2026-05-28 12:30:49 +08:00
committed by GitHub
parent 7997bfa2b7
commit 1734bac9b4
30 changed files with 1528 additions and 464 deletions
+2 -2
View File
@@ -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
View File
@@ -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/
-439
View File
@@ -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。
+1
View File
@@ -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
+1
View File
@@ -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
View File
@@ -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",
+1
View File
@@ -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
} }
+3 -2
View File
@@ -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}`
} }
+58
View File
@@ -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')">
+32
View File
@@ -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: {
+32
View File
@@ -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: {
+32
View File
@@ -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: {
+32
View File
@@ -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 lespace de prévisualisation Web UI, installe les dépendances, puis lance lapplication 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 laperç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 laperçu",
log: "Chemin du journal daction",
logOutput: "Sortie des journaux",
actionLog: "Journal daction",
devLog: "Journal du serveur dev",
yes: "Oui",
no: "Non",
actionFailed: "Échec de laction",
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: {
+32 -1
View File
@@ -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: "グループで応答するには {'@'}メンションが必要",
+32 -1
View File
@@ -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: "그룹에서 {'@'}멘션 시에만 응답",
+32
View File
@@ -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: {
+32
View File
@@ -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: {
+32
View File
@@ -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: {
+6
View File
@@ -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>
+756 -8
View File
@@ -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 })
}
+1 -1
View File
@@ -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',
} }
// ============================================================================ // ============================================================================
+7
View File
@@ -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)
+1
View File
@@ -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',
]) ])
+16
View File
@@ -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
View File
@@ -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(),