[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
@@ -148,8 +148,9 @@ function buildWsUrl(): string {
return `${wsProtocol}//${new URL(base).host}/api/hermes/terminal${token ? `?token=${encodeURIComponent(token)}` : ""}`;
}
const host = import.meta.env.DEV
? formatHostForPort(location.hostname, 8648)
const directDevPort = import.meta.env.VITE_HERMES_DIRECT_WS_PORT;
const host = import.meta.env.DEV && directDevPort
? formatHostForPort(location.hostname, Number(directDevPort))
: location.host;
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;
});
const isSuperAdmin = computed(() => isStoredSuperAdmin());
const isVersionPreview = import.meta.env.VITE_HERMES_PREVIEW === '1';
function isNavActive(...names: string[]) {
return names.includes(selectedKey.value);
@@ -35,7 +36,7 @@ const logoPath = '/logo.png';
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) {
return t(`sidebar.group${key}${appStore.sidebarCollapsed ? "Short" : ""}`);
@@ -253,6 +254,29 @@ function openChangelog() {
</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 -->
<div class="nav-group">
<div class="nav-group-label" @click="toggleGroup('system')">