Files
hermes-web/packages/server/src/services/hermes/pets.ts
T
toor c93e1fe1bd
Build / build (push) Canceled after 0s
NPM Lockfile Check / npm ci --ignore-scripts (push) Canceled after 0s
Playwright / e2e (push) Canceled after 0s
Phase 002: rebrand to hermes-ui and consolidate i18n to zh/en
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.
2026-10-16 11:11:29 +08:00

306 lines
9.2 KiB
TypeScript

import { existsSync } from 'fs'
import { mkdir, readFile, writeFile } from 'fs/promises'
import { dirname, join } from 'path'
import { getWebUiHome } from '../../config'
import { fetchPetdexManifest, type PetdexPet } from './petdex'
export type ActivePetState = 'idle' | 'run' | 'review' | 'failed' | 'wave' | 'jump' | 'waiting'
export interface InstalledPet {
slug: string
displayName: string
kind: string
submittedBy: string
source: 'petdex'
spritesheetUrl: string
petJsonUrl: string
zipUrl: string
spritesheetFile: string
petJsonFile?: string
mime: string
installedAt: number
updatedAt: number
}
export interface ActivePetConfig {
enabled: boolean
slug: string
scale: number
position?: {
x: number
y: number
}
updatedAt: number
}
export interface ActivePetResponse {
enabled: boolean
slug: string
displayName: string
kind: string
submittedBy: string
source: 'petdex'
mime: string
spritesheetDataUrl: string
spritesheetRevision: number
frameW: number
frameH: number
framesPerState: number
loopMs: number
scale: number
position?: {
x: number
y: number
}
stateRows: string[]
installedAt: number
updatedAt: number
}
const FRAME_W = 192
const FRAME_H = 208
const FRAMES_PER_STATE = 6
const LOOP_MS = 1100
const DEFAULT_SCALE = 0.33
const STATE_ROWS = [
'idle',
'running-right',
'running-left',
'waving',
'jumping',
'failed',
'waiting',
'running',
'review',
]
const MAX_SPRITESHEET_BYTES = 10 * 1024 * 1024
const MAX_JSON_BYTES = 512 * 1024
const FETCH_TIMEOUT_MS = 20_000
function profileMetadataRoot(): string {
return join(getWebUiHome(), 'profile-metadata')
}
function profileMetadataDir(name: string): string {
const segment = Buffer.from(name || 'default', 'utf-8').toString('base64url')
return join(profileMetadataRoot(), segment)
}
function petsRoot(profile: string): string {
return join(profileMetadataDir(profile), 'pets')
}
function activePetPath(profile: string): string {
return join(petsRoot(profile), 'active.json')
}
function petDir(profile: string, slug: string): string {
return join(petsRoot(profile), safeSlug(slug))
}
function petMetaPath(profile: string, slug: string): string {
return join(petDir(profile, slug), 'pet.json')
}
function safeSlug(slug: string): string {
const normalized = String(slug || '').trim().toLowerCase()
const safe = normalized.replace(/[^a-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '')
if (!safe) throw new Error('Pet slug is required')
return safe.slice(0, 120)
}
function assertPetdexAssetUrl(value: string): URL {
const url = new URL(value)
const host = url.hostname.toLowerCase()
if (url.protocol !== 'https:' || (host !== 'petdex.dev' && !host.endsWith('.petdex.dev'))) {
throw new Error('Unsupported pet asset host')
}
return url
}
function mimeFromResponse(response: Response, fallbackUrl: string): string {
const contentType = response.headers.get('content-type')?.split(';')[0]?.trim().toLowerCase()
if (contentType?.startsWith('image/')) return contentType
if (fallbackUrl.endsWith('.webp')) return 'image/webp'
if (fallbackUrl.endsWith('.png')) return 'image/png'
return 'application/octet-stream'
}
async function fetchBytes(urlValue: string, maxBytes: number): Promise<{ buffer: Buffer; mime: string }> {
const url = assertPetdexAssetUrl(urlValue)
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS)
try {
const response = await fetch(url, {
headers: { 'User-Agent': 'hermes-ui-pets' },
signal: controller.signal,
})
if (!response.ok) throw new Error(`Pet asset request failed: ${response.status}`)
const length = Number(response.headers.get('content-length') || '0')
if (Number.isFinite(length) && length > maxBytes) {
throw new Error('Pet asset is too large')
}
const arrayBuffer = await response.arrayBuffer()
if (arrayBuffer.byteLength > maxBytes) {
throw new Error('Pet asset is too large')
}
return {
buffer: Buffer.from(arrayBuffer),
mime: mimeFromResponse(response, url.pathname.toLowerCase()),
}
} finally {
clearTimeout(timeout)
}
}
async function fetchTextAsset(urlValue: string, maxBytes: number): Promise<string | null> {
if (!urlValue) return null
const { buffer } = await fetchBytes(urlValue, maxBytes)
return buffer.toString('utf-8')
}
async function readJsonFile<T>(filePath: string): Promise<T | null> {
try {
return JSON.parse(await readFile(filePath, 'utf-8')) as T
} catch {
return null
}
}
async function writeJsonFile(filePath: string, value: unknown): Promise<void> {
await mkdir(dirname(filePath), { recursive: true })
await writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`, { encoding: 'utf-8', mode: 0o600 })
}
function normalizeInstalledPet(pet: PetdexPet, asset: { mime: string }, now: number): InstalledPet {
return {
slug: pet.slug,
displayName: pet.displayName || pet.slug,
kind: pet.kind || 'pet',
submittedBy: pet.submittedBy || '',
source: 'petdex',
spritesheetUrl: pet.spritesheetUrl,
petJsonUrl: pet.petJsonUrl,
zipUrl: pet.zipUrl,
spritesheetFile: 'spritesheet.webp',
petJsonFile: pet.petJsonUrl ? 'petdex-pet.json' : undefined,
mime: asset.mime,
installedAt: now,
updatedAt: now,
}
}
export async function adoptPetFromPetdex(profile: string, slugInput: string): Promise<ActivePetResponse> {
const slug = safeSlug(slugInput)
const manifest = await fetchPetdexManifest()
const pet = manifest.pets.find(item => safeSlug(item.slug) === slug)
if (!pet) throw new Error(`Pet "${slugInput}" was not found in petdex`)
const targetDir = petDir(profile, pet.slug)
await mkdir(targetDir, { recursive: true })
const spritesheet = await fetchBytes(pet.spritesheetUrl, MAX_SPRITESHEET_BYTES)
await writeFile(join(targetDir, 'spritesheet.webp'), spritesheet.buffer, { mode: 0o600 })
if (pet.petJsonUrl) {
const petJson = await fetchTextAsset(pet.petJsonUrl, MAX_JSON_BYTES)
if (petJson) await writeFile(join(targetDir, 'petdex-pet.json'), petJson, { encoding: 'utf-8', mode: 0o600 })
}
const now = Date.now()
const installed = normalizeInstalledPet(pet, spritesheet, now)
const active: ActivePetConfig = {
enabled: true,
slug: installed.slug,
scale: DEFAULT_SCALE,
updatedAt: now,
}
await writeJsonFile(petMetaPath(profile, pet.slug), installed)
await writeJsonFile(activePetPath(profile), active)
const response = await buildActivePetResponse(profile, installed, active)
if (!response) throw new Error('Installed pet asset is missing')
return response
}
export async function getActivePet(profile: string): Promise<ActivePetResponse | null> {
const active = await readJsonFile<ActivePetConfig>(activePetPath(profile))
if (!active?.enabled || !active.slug) return null
const installed = await readJsonFile<InstalledPet>(petMetaPath(profile, active.slug))
if (!installed) return null
return buildActivePetResponse(profile, installed, active)
}
export async function updateActivePetPreferences(
profile: string,
input: { scale?: number; position?: { x?: number; y?: number }; enabled?: boolean },
): Promise<ActivePetResponse | null> {
const active = await readJsonFile<ActivePetConfig>(activePetPath(profile))
if (!active?.slug) return null
const next: ActivePetConfig = {
...active,
updatedAt: Date.now(),
}
if (typeof input.enabled === 'boolean') {
next.enabled = input.enabled
}
if (typeof input.scale === 'number' && Number.isFinite(input.scale)) {
next.scale = Math.min(1.2, Math.max(0.18, input.scale))
}
if (input.position && typeof input.position.x === 'number' && typeof input.position.y === 'number') {
next.position = {
x: Math.round(Math.max(0, input.position.x)),
y: Math.round(Math.max(0, input.position.y)),
}
}
await writeJsonFile(activePetPath(profile), next)
if (!next.enabled) return null
return getActivePet(profile)
}
async function buildActivePetResponse(
profile: string,
installed: InstalledPet,
active: ActivePetConfig,
): Promise<ActivePetResponse | null> {
const filePath = join(petDir(profile, installed.slug), installed.spritesheetFile || 'spritesheet.webp')
if (!existsSync(filePath)) return null
const data = await readFile(filePath)
const spritesheetRevision = installed.updatedAt || installed.installedAt || 0
const updatedAt = Math.max(active.updatedAt || 0, spritesheetRevision)
return {
enabled: active.enabled,
slug: installed.slug,
displayName: installed.displayName,
kind: installed.kind,
submittedBy: installed.submittedBy,
source: installed.source,
mime: installed.mime || 'image/webp',
spritesheetDataUrl: `data:${installed.mime || 'image/webp'};base64,${data.toString('base64')}`,
frameW: FRAME_W,
frameH: FRAME_H,
framesPerState: FRAMES_PER_STATE,
loopMs: LOOP_MS,
scale: typeof active.scale === 'number' && active.scale > 0 ? active.scale : DEFAULT_SCALE,
position: active.position && Number.isFinite(active.position.x) && Number.isFinite(active.position.y)
? { x: active.position.x, y: active.position.y }
: undefined,
stateRows: STATE_ROWS,
installedAt: installed.installedAt,
spritesheetRevision,
updatedAt,
}
}