Phase 002: rebrand to hermes-ui and consolidate i18n to zh/en
Build / build (push) Canceled after 0s
NPM Lockfile Check / npm ci --ignore-scripts (push) Canceled after 0s
Playwright / e2e (push) Canceled after 0s

Removes the ekko / hermes-web-ui branding from the whole tree and drops
eight locale files, so the project presents one name and one language pair.

Data compatibility is deliberate, not accidental: DB filenames, the JWT
audience, and skill manifest ownership all still recognise their pre-rename
values so existing deployments under ~/.hermes-web-ui keep working without a
re-login or a re-injection.

scripts/harness-check.mjs now scans for the legacy strings and fails the
build on any new occurrence, with an explicit allow-list (and reasons) for
the compatibility paths that must keep them.
This commit is contained in:
2026-10-16 11:11:29 +08:00
parent 1e9ea89d86
commit c93e1fe1bd
70 changed files with 1305 additions and 17856 deletions
+3 -3
View File
@@ -71,8 +71,8 @@ let pendingAgentBridgeHealthRefresh: Promise<AgentBridgeHealthPayload> | null =
/**
* Whether the periodic npm-registry version check is disabled.
*
* Useful when hermes-web-ui is bundled inside a packaged distribution
* (e.g. a desktop app) where the user can't `npm install -g hermes-web-ui@latest`
* Useful when hermes-ui is bundled inside a packaged distribution
* (e.g. a desktop app) where the user can't `npm install -g hermes-ui@latest`
* to upgrade — the "update available" prompt would be misleading and
* the periodic outbound HTTP request to the npm registry is unnecessary.
*
@@ -106,7 +106,7 @@ function isNewerVersion(candidate: string, current: string): boolean {
export async function checkLatestVersion(): Promise<void> {
if (isUpdateCheckDisabled()) return
try {
const packageName = PACKAGE_INFO?.name || 'hermes-web-ui'
const packageName = PACKAGE_INFO?.name || 'hermes-ui'
const registryName = encodeURIComponent(packageName)
const res = await fetch(`https://registry.npmjs.org/${registryName}/latest`, { signal: AbortSignal.timeout(10000) })
if (res.ok) {
@@ -233,7 +233,7 @@ export async function submit(ctx: any) {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'User-Agent': 'hermes-web-ui/1.0',
'User-Agent': 'hermes-ui/1.0',
},
body: JSON.stringify({
grant_type: 'authorization_code',
@@ -318,7 +318,7 @@ export async function start(ctx: any) {
state,
nonce,
plan: 'generic',
referrer: 'hermes-web-ui',
referrer: 'hermes-ui',
}).toString()}`
sessions.set(sessionId, {
id: sessionId,
+18 -15
View File
@@ -9,8 +9,8 @@ let updateInProgress = false
const NODE_ENVIRONMENT_MISSING_CODE = 'node_environment_missing'
const DOCKER_ENVIRONMENT_CODE = 'docker_environment'
const PREVIEW_DIR_NAME = 'hermes-web-ui-pereview'
const PREVIEW_HOME_DIR_NAME = 'hermes-web-ui-pereview-home'
const PREVIEW_DIR_NAME = 'hermes-ui-preview'
const PREVIEW_HOME_DIR_NAME = 'hermes-ui-preview-home'
const PREVIEW_BACKEND_PORT = 8650
const PREVIEW_FRONTEND_PORT = 8651
const PREVIEW_AGENT_BRIDGE_PORT = 18650
@@ -133,9 +133,12 @@ function getPreviewRepoGitUrl(): string {
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]}`
const match = baseUrl.match(/^https:\/\/([^/]+)\/([^/]+)\/([^/]+)$/)
if (!match) throw new Error(`Preview API fallback only supports HTTPS repository URLs: ${baseUrl}`)
const [, host, owner, repo] = match
if (host === 'github.com') return `https://api.github.com/repos/${owner}/${repo}`
// Gitea and Forgejo expose the same tag list shape under /api/v1.
return `https://${host}/api/v1/repos/${owner}/${repo}`
}
function getPreviewGithubRepoParts(): { owner: string; repo: string } {
@@ -362,7 +365,7 @@ function getPreviewViteHostArg() {
}
function getGlobalPackageBin(root: string) {
return join(root, 'hermes-web-ui', 'bin', 'hermes-web-ui.mjs')
return join(root, 'hermes-ui', 'bin', 'hermes-ui.mjs')
}
function getCurrentNodeEnv() {
@@ -725,8 +728,8 @@ function assertPreviewPackage() {
}
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()}`)
if (pkg?.name !== 'hermes-ui') {
throw new Error(`Preview directory is not hermes-ui: ${getPreviewDir()}`)
}
}
@@ -932,7 +935,7 @@ async function downloadGithubZip(ref: string, targetDir: string, type: 'tag' | '
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' },
headers: { 'User-Agent': 'hermes-ui-preview' },
signal: AbortSignal.timeout(60_000),
})
if (!res.ok) throw new Error(`Failed to download GitHub archive: HTTP ${res.status}`)
@@ -1031,7 +1034,7 @@ function getGlobalRoot() {
function getGlobalCliScript() {
const cli = getGlobalPackageBin(getGlobalRoot())
if (!existsSync(cli)) {
throw new Error(`Updated hermes-web-ui CLI not found: ${cli}`)
throw new Error(`Updated hermes-ui CLI not found: ${cli}`)
}
return cli
}
@@ -1043,7 +1046,7 @@ function runUpdateInstall() {
console.warn('[update] failed to clean npm cache, continuing update:', err)
}
return runNpmSync(['install', '-g', 'hermes-web-ui@latest'], { timeout: 10 * 60 * 1000 })
return runNpmSync(['install', '-g', 'hermes-ui@latest'], { timeout: 10 * 60 * 1000 })
}
function spawnRestart(port: string) {
@@ -1062,7 +1065,7 @@ export async function handleUpdate(ctx: any) {
ctx.status = 409
ctx.body = {
success: false,
message: 'hermes-web-ui update is already in progress',
message: 'hermes-ui update is already in progress',
}
return
}
@@ -1073,7 +1076,7 @@ export async function handleUpdate(ctx: any) {
ctx.body = {
success: false,
code: DOCKER_ENVIRONMENT_CODE,
message: 'hermes-web-ui update is not available inside Docker. '
message: 'hermes-ui update is not available inside Docker. '
+ 'Please pull a new image and recreate the container:\n\n'
+ ' docker compose pull\n'
+ ' docker compose up -d --force-recreate',
@@ -1089,7 +1092,7 @@ export async function handleUpdate(ctx: any) {
ctx.body = {
success: true,
message: output.trim() || 'hermes-web-ui updated successfully',
message: output.trim() || 'hermes-ui updated successfully',
}
keepUpdateLockForRestart = true
@@ -1153,7 +1156,7 @@ export async function previewTags(ctx: any) {
try {
appendPreviewActionLog('load tags with GitHub API')
const res = await fetch(`${getPreviewRepoApiUrl()}/tags?per_page=100`, {
headers: { 'User-Agent': 'hermes-web-ui-preview' },
headers: { 'User-Agent': 'hermes-ui-preview' },
signal: AbortSignal.timeout(15_000),
})
if (!res.ok) {
+2
View File
@@ -12,6 +12,8 @@ const DB_DIR = isTest
: isDev
? resolve(process.cwd(), 'packages/server/data')
: config.appHome
// These filenames predate the hermes-web-ui -> hermes-ui rename. They are kept so
// existing deployments under ~/.hermes-web-ui keep finding their data.
const DB_PATH = resolve(DB_DIR, 'hermes-web-ui.db')
const JSON_PATH = resolve(DB_DIR, 'hermes-web-ui.json')
+1 -1
View File
@@ -240,7 +240,7 @@ function startLanDiscovery(): void {
}
export async function bootstrap() {
console.log(`hermes-web-ui v${APP_VERSION} starting...`)
console.log(`hermes-ui v${APP_VERSION} starting...`)
await mkdir(config.uploadDir, { recursive: true })
if (shouldCreateWebUiDataDir()) {
await mkdir(config.dataDir, { recursive: true })
+3 -3
View File
@@ -39,14 +39,14 @@ export const AI_OUTPUT_FORMAT_GUIDELINES = `
\`\`\`
[屏幕录制](/tmp/screen-recording.mp4)
[操作演示](/tmp/demo.webm)
[录屏2026-05-08 15.19.46](/Users/ekko/Desktop/录屏2026-05-08%2015.19.46.mov)
[录屏2026-05-08 15.19.46](</Users/ekko/Desktop/录屏2026-05-08 15.19.46.mov>)
[录屏2026-05-08 15.19.46](/Users/demo/Desktop/录屏2026-05-08%2015.19.46.mov)
[录屏2026-05-08 15.19.46](</Users/demo/Desktop/录屏2026-05-08 15.19.46.mov>)
[Windows 录屏](<C:/Users/Administrator/Desktop/screen recording.mov>)
\`\`\`
错误示例:
\`\`\`
[录屏2026-05-08 15.19.46](/Users/ekko/Desktop/录屏2026-05-08 15.19.46.mov)
[录屏2026-05-08 15.19.46](/Users/demo/Desktop/录屏2026-05-08 15.19.46.mov)
![桌面截图](C:\\Users\\Administrator\\Desktop\\screenshot.png)
\`\`\`
+7 -3
View File
@@ -26,7 +26,7 @@ interface JwtPayload {
username: string
role: UserRole
type: 'access'
aud: 'hermes-web-ui'
aud: string
iat: number
exp: number
}
@@ -39,7 +39,10 @@ declare module 'koa' {
}
}
const JWT_AUDIENCE = 'hermes-web-ui'
const JWT_AUDIENCE = 'hermes-ui'
// Tokens signed before the hermes-web-ui -> hermes-ui rename still carry the old
// audience. Keep accepting them so upgrades do not force a re-login.
const LEGACY_JWT_AUDIENCES = new Set(['hermes-web-ui'])
const DEFAULT_EXPIRES_SECONDS = 60 * 60 * 24 * 30
const MIN_EXPIRES_SECONDS = 1
const MAX_EXPIRES_SECONDS = 60 * 60 * 24 * 365
@@ -167,7 +170,8 @@ export function verifyUserJwt(token: string, secret: string, now = Date.now()):
try {
const payload = JSON.parse(Buffer.from(body, 'base64url').toString('utf-8')) as Partial<JwtPayload>
if (payload.type !== 'access' || payload.aud !== JWT_AUDIENCE) return null
if (payload.type !== 'access') return null
if (payload.aud !== JWT_AUDIENCE && !LEGACY_JWT_AUDIENCES.has(String(payload.aud))) return null
if (!payload.sub || !payload.username || !payload.role || !payload.exp) return null
if (Math.floor(now / 1000) >= payload.exp) return null
return payload as JwtPayload
@@ -820,9 +820,9 @@ function isDesktopRuntime(): boolean {
function candidateBundledMcpScripts(): string[] {
return [
process.env.HERMES_WEB_UI_MCP_BIN,
join(process.cwd(), 'bin/hermes-studio-mcp.mjs'),
join(__dirname, '../../bin/hermes-studio-mcp.mjs'),
join(__dirname, '../../../../../bin/hermes-studio-mcp.mjs'),
join(process.cwd(), 'bin/hermes-ui-mcp.mjs'),
join(__dirname, '../../bin/hermes-ui-mcp.mjs'),
join(__dirname, '../../../../../bin/hermes-ui-mcp.mjs'),
join(process.cwd(), 'bin/hermes-web-ui-mcp.mjs'),
join(__dirname, '../../bin/hermes-web-ui-mcp.mjs'),
join(__dirname, '../../../../../bin/hermes-web-ui-mcp.mjs'),
@@ -44,6 +44,9 @@ function shouldIncludeManagedMcpServers(): boolean {
function candidateBundledMcpScripts(): string[] {
return [
process.env.HERMES_WEB_UI_MCP_BIN,
join(process.cwd(), 'bin/hermes-ui-mcp.mjs'),
join(__dirname, '../../bin/hermes-ui-mcp.mjs'),
join(__dirname, '../../../../../bin/hermes-ui-mcp.mjs'),
join(process.cwd(), 'bin/hermes-studio-mcp.mjs'),
join(__dirname, '../../bin/hermes-studio-mcp.mjs'),
join(__dirname, '../../../../../bin/hermes-studio-mcp.mjs'),
@@ -91,7 +91,7 @@ export async function fetchPetdexManifest(options: { force?: boolean } = {}): Pr
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS)
try {
const response = await fetch(PETDEX_MANIFEST_URL, {
headers: { 'User-Agent': 'hermes-web-ui-petdex' },
headers: { 'User-Agent': 'hermes-ui-petdex' },
signal: controller.signal,
})
if (!response.ok) {
@@ -111,7 +111,7 @@ export async function fetchPetdexAsset(urlValue: string): Promise<{ buffer: Buff
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS)
try {
const response = await fetch(url, {
headers: { 'User-Agent': 'hermes-web-ui-petdex' },
headers: { 'User-Agent': 'hermes-ui-petdex' },
signal: controller.signal,
})
if (!response.ok) throw new Error(`petdex asset request failed: ${response.status}`)
+1 -1
View File
@@ -133,7 +133,7 @@ async function fetchBytes(urlValue: string, maxBytes: number): Promise<{ buffer:
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS)
try {
const response = await fetch(url, {
headers: { 'User-Agent': 'hermes-web-ui-pets' },
headers: { 'User-Agent': 'hermes-ui-pets' },
signal: controller.signal,
})
if (!response.ok) throw new Error(`Pet asset request failed: ${response.status}`)
@@ -6,7 +6,13 @@ import { detectHermesRootHome } from './hermes-path'
import { logger } from '../logger'
const MANIFEST_FILENAME = '.webui-managed-skills.json'
const MANIFEST_OWNER = 'hermes-web-ui'
const MANIFEST_OWNER = 'hermes-ui'
// Manifests written before the hermes-web-ui -> hermes-ui rename still carry the old
// owner. Recognising them keeps managed skills managed instead of re-injecting them.
const LEGACY_MANIFEST_OWNERS = new Set(['hermes-web-ui'])
function isManagedOwner(owner: string | undefined): boolean {
return owner === MANIFEST_OWNER || LEGACY_MANIFEST_OWNERS.has(String(owner))
}
const HASH_IGNORED_FILENAMES = new Set([
'.DS_Store',
'Thumbs.db',
@@ -177,7 +183,7 @@ export class HermesSkillInjector {
const currentHash = await this.hashDir(targetSkillDir)
const manifestEntry = manifest[entry.name]
const isManaged = manifestEntry?.owner === MANIFEST_OWNER
const isManaged = isManagedOwner(manifestEntry?.owner)
const isUnchangedManagedCopy = isManaged && manifestEntry?.installed_hash === currentHash
const isExistingBundledCopy = !manifestEntry && currentHash === sourceHash
@@ -5,7 +5,7 @@ import { SttNoSpeechDetectedError, type SttTranscribeInput, type SttTranscribeRe
const DEFAULT_BASE_URL = 'https://openspeech.bytedance.com/api/v3/auc/bigmodel'
const DEFAULT_RESOURCE_ID = 'volc.seedasr.auc'
const DEFAULT_USER_ID = 'hermes-web-ui'
const DEFAULT_USER_ID = 'hermes-ui'
const DONE_STATUS = '20000000'
const RUNNING_STATUSES = new Set(['20000001', '20000002'])
const STATUS_MESSAGES: Record<string, string> = {
@@ -75,9 +75,9 @@ function isDesktopRuntime(): boolean {
function candidateBundledMcpScripts(): string[] {
return [
process.env.HERMES_WEB_UI_MCP_BIN,
join(process.cwd(), 'bin/hermes-studio-mcp.mjs'),
join(__dirname, '../../bin/hermes-studio-mcp.mjs'),
join(__dirname, '../../../../../bin/hermes-studio-mcp.mjs'),
join(process.cwd(), 'bin/hermes-ui-mcp.mjs'),
join(__dirname, '../../bin/hermes-ui-mcp.mjs'),
join(__dirname, '../../../../../bin/hermes-ui-mcp.mjs'),
join(process.cwd(), 'bin/hermes-web-ui-mcp.mjs'),
join(__dirname, '../../bin/hermes-web-ui-mcp.mjs'),
join(__dirname, '../../../../../bin/hermes-web-ui-mcp.mjs'),
@@ -6,7 +6,7 @@ import { assertSafeTtsBaseUrl } from './url-safety'
const DEFAULT_BASE_URL = 'https://openspeech.bytedance.com/api/v3/tts/unidirectional'
const DEFAULT_RESOURCE_ID = 'seed-tts-2.0'
const DEFAULT_SPEAKER = 'zh_female_xiaohe_uranus_bigtts'
const DEFAULT_USER_ID = 'hermes-web-ui'
const DEFAULT_USER_ID = 'hermes-ui'
const MAX_ERROR_DETAIL_LENGTH = 500
const DEFAULT_FORMAT = 'mp3'
const DEFAULT_SAMPLE_RATE = 24000
+1 -1
View File
@@ -8,7 +8,7 @@ const MAX_LOG_SIZE = 3 * 1024 * 1024 // 3MB
const CHECK_INTERVAL = 60_000 // Check every minute
const logDir = process.env.VITEST
? resolve(tmpdir(), 'hermes-web-ui-test-logs', String(process.pid))
? resolve(tmpdir(), 'hermes-ui-test-logs', String(process.pid))
: resolve(config.appHome, 'logs')
mkdirSync(logDir, { recursive: true })
@@ -400,8 +400,8 @@ export async function downloadWebUiVersion(version: string, source: VersionDownl
if (!cleanVersion) throw new Error('Web UI version is required')
const releaseTag = `v${cleanVersion}`
const assetName = `hermes-web-ui-${cleanVersion}.tar.gz`
const manifestName = `hermes-web-ui-${cleanVersion}.json`
const assetName = `hermes-ui-${cleanVersion}.tar.gz`
const manifestName = `hermes-ui-${cleanVersion}.json`
const manifestUrl = downloadAssetUrl(manifestName, releaseTag, source)
onProgress?.({ stage: 'resolve', message: 'runtimeVersions.jobStage.resolveWebUi' })
const manifest = await fetchJson<{ asset?: { sha256?: string; size?: number } }>(manifestUrl)
@@ -424,7 +424,7 @@ export async function downloadWebUiVersion(version: string, source: VersionDownl
onProgress?.({ stage: 'extract', message: 'runtimeVersions.jobStage.extractWebUi' })
await extractTarGzip(archive, tempRoot)
const extractedRoot = join(tempRoot, 'webui')
for (const required of ['package.json', 'bin/hermes-web-ui.mjs', 'dist/server/index.js']) {
for (const required of ['package.json', 'bin/hermes-ui.mjs', 'dist/server/index.js']) {
if (!existsSync(join(extractedRoot, required))) throw new Error(`Web UI archive is missing required file: ${required}`)
}
onProgress?.({ stage: 'install', message: 'runtimeVersions.jobStage.installWebUi' })