From b709ec6742657d5df865d0c70147757f69aa500d Mon Sep 17 00:00:00 2001 From: Jack Date: Tue, 20 Oct 2026 13:24:17 +0800 Subject: [PATCH] =?UTF-8?q?=EF=BB=BFremove=20the=20desktop-pet=20system?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pet feature was three loosely coupled subsystems that no core flow depended on: the Petdex catalog page, the pet install/preference store, and the pet-state socket. All three are removed. The only cross-boundary touch point was a try/catch-wrapped observer that mapped chat-run events to pet animations. Removing it leaves the /chat-run event contract unchanged: no event name, field name, field type, or emission timing changed. Removing petdexPublicRoutes also closes the unauthenticated outbound proxy at /api/hermes/petdex/asset, which shrinks the SSRF surface. --- docs/02-功能清单-0.6.29.md | 2 +- .../2026-10-20-remove-pet-system.md | 59 +++ docs/cyber-theme-plan.md | 7 +- docs/known-issues.md | 2 +- packages/client/public/icons/pet-resize.svg | 3 - packages/client/src/api/hermes/pet-state.ts | 87 ----- packages/client/src/api/hermes/petdex.ts | 22 -- packages/client/src/api/hermes/pets.ts | 52 --- .../src/components/layout/AppSidebar.vue | 9 - packages/client/src/i18n/locales/en.ts | 24 -- packages/client/src/i18n/locales/zh.ts | 24 -- packages/client/src/router/index.ts | 5 - .../client/src/stores/hermes/pet-state.ts | 74 ---- packages/client/src/stores/hermes/pets.ts | 82 ----- .../client/src/views/hermes/PetdexView.vue | 339 ------------------ .../server/src/controllers/hermes/petdex.ts | 26 -- .../server/src/controllers/hermes/pets.ts | 50 --- packages/server/src/index.ts | 5 - packages/server/src/routes/hermes/petdex.ts | 8 - packages/server/src/routes/hermes/pets.ts | 8 - packages/server/src/routes/index.ts | 5 - .../src/services/hermes/pet-state-socket.ts | 291 --------------- packages/server/src/services/hermes/petdex.ts | 137 ------- packages/server/src/services/hermes/pets.ts | 305 ---------------- .../hermes/run-chat/handle-bridge-run.ts | 11 - .../src/services/hermes/run-chat/index.ts | 22 -- tests/client/pets-store.test.ts | 58 --- tests/e2e/fixtures.ts | 5 - tests/server/petdex-service.test.ts | 58 --- tests/server/pets-service.test.ts | 77 ---- 30 files changed, 64 insertions(+), 1793 deletions(-) create mode 100644 docs/chat-chain-changes/2026-10-20-remove-pet-system.md delete mode 100644 packages/client/public/icons/pet-resize.svg delete mode 100644 packages/client/src/api/hermes/pet-state.ts delete mode 100644 packages/client/src/api/hermes/petdex.ts delete mode 100644 packages/client/src/api/hermes/pets.ts delete mode 100644 packages/client/src/stores/hermes/pet-state.ts delete mode 100644 packages/client/src/stores/hermes/pets.ts delete mode 100644 packages/client/src/views/hermes/PetdexView.vue delete mode 100644 packages/server/src/controllers/hermes/petdex.ts delete mode 100644 packages/server/src/controllers/hermes/pets.ts delete mode 100644 packages/server/src/routes/hermes/petdex.ts delete mode 100644 packages/server/src/routes/hermes/pets.ts delete mode 100644 packages/server/src/services/hermes/pet-state-socket.ts delete mode 100644 packages/server/src/services/hermes/petdex.ts delete mode 100644 packages/server/src/services/hermes/pets.ts delete mode 100644 tests/client/pets-store.test.ts delete mode 100644 tests/server/petdex-service.test.ts delete mode 100644 tests/server/pets-service.test.ts diff --git a/docs/02-功能清单-0.6.29.md b/docs/02-功能清单-0.6.29.md index 2e69716..9857373 100644 --- a/docs/02-功能清单-0.6.29.md +++ b/docs/02-功能清单-0.6.29.md @@ -124,7 +124,7 @@ ## P. 桌宠系统 -- [ ] ❌ **整组砍(2026-10-15 确认)**:桌宠状态 socket、Petdex 图鉴、pets store、PetdexView 全删(浪费资源) +- [x] ❌ **整组砍(2026-10-15 确认,2026-10-20 执行)**:桌宠状态 socket、Petdex 图鉴、pets store、PetdexView 已全删(浪费资源) ## Q. 编程代理之外的特色杂项 diff --git a/docs/chat-chain-changes/2026-10-20-remove-pet-system.md b/docs/chat-chain-changes/2026-10-20-remove-pet-system.md new file mode 100644 index 0000000..f3b4d50 --- /dev/null +++ b/docs/chat-chain-changes/2026-10-20-remove-pet-system.md @@ -0,0 +1,59 @@ +--- +date: 2026-10-20 +pr: pending +feature: Remove the desktop-pet system (pet-state socket, Petdex catalog, pets store) +impact: The optional "宠物" sidebar entry and the whole pet subsystem are gone. The only cross-boundary touch point was a try/catch-wrapped observer that mapped chat-run events to pet animations; removing it does not change any /chat-run event name, field name, field type, or emission timing. No chat behavior changed. +--- + +# Remove the desktop-pet system + +## What changed + +The pet feature was three loosely coupled subsystems that no core flow +depended on. All three are removed. + +| Area | Removed | +| --- | --- | +| Client | `views/hermes/PetdexView.vue`, `stores/hermes/pets.ts`, `stores/hermes/pet-state.ts`, `api/hermes/pets.ts`, `api/hermes/petdex.ts`, `api/hermes/pet-state.ts`, `public/icons/pet-resize.svg` (orphan) | +| Client wiring | `router/index.ts` route `hermes.petdex`, `AppSidebar.vue` nav item, `sidebar.petdex` + `petdex.*` i18n keys in `zh.ts` / `en.ts` | +| Server | `services/hermes/pets.ts`, `services/hermes/petdex.ts`, `services/hermes/pet-state-socket.ts`, `controllers/hermes/pets.ts`, `controllers/hermes/petdex.ts`, `routes/hermes/pets.ts`, `routes/hermes/petdex.ts` | +| Server wiring | `routes/index.ts` (three `app.use` registrations), `index.ts` (`PetStateSocketServer` import, field, and `init()`) | +| Chat chain | `run-chat/index.ts` and `run-chat/handle-bridge-run.ts` pet observation calls, `resolvePetEventProfile`, `observePetEvent` | +| Tests | `tests/server/pets-service.test.ts`, `tests/server/petdex-service.test.ts`, `tests/client/pets-store.test.ts`, `/api/hermes/pets/active` mock in `tests/e2e/fixtures.ts` | + +## Why it was safe + +The pet subsystem sat on a single boundary crossing: the chat-run event +stream. `run-chat/index.ts` (`emitExternalEvent`, `emitToSession`) and +`run-chat/handle-bridge-run.ts` (two `emit` closures) each called +`observeRunChatPetEvent(profile, event, tagged)` to translate a chat event +into a pet animation state. Every call was wrapped in a `try/catch` that only +logged at `debug` level, so a pet failure could never break a run. + +Removing those calls leaves the surrounding emit paths intact: the tagged +payload, the `session:` room broadcast, the direct `socket.emit` fallback, +the waiter wake-ups, and the session event buffer are all unchanged. + +Everything else was an island: the client `pets` store was consumed only by +`PetdexView`, and the client `pet-state` store had no callers at all — the +client never connected to the `/pet-state` namespace, so the server-side +`pet.state.snapshot` / `pet.state.updated` events had no consumer. + +## Not changed + +The `/chat-run` Socket.IO contract is untouched: no event name, field name, +field type, or emission timing changed. This fragment exists because the +touched files sit on the chat chain path, not because any chat behavior moved. + +## Side effect + +`routes/index.ts` registered `petdexPublicRoutes` before the auth middleware, +which exposed `/api/hermes/petdex/asset` as an unauthenticated outbound proxy +(with a `petdex.dev` host allowlist). Removing it closes that endpoint and +shrinks the SSRF surface. + +## Local data + +Persisted pet state lives under +`/profile-metadata//pets/`. Deleting +the code does not remove those files; they can be deleted manually. diff --git a/docs/cyber-theme-plan.md b/docs/cyber-theme-plan.md index d2e0c73..3670a39 100644 --- a/docs/cyber-theme-plan.md +++ b/docs/cyber-theme-plan.md @@ -66,7 +66,7 @@ CYBER 是本项目**唯一**的视觉语言。`docs/ui-baseline.md` 是它的** | --- | --- | --- | | **已落地** | `SettingsView.vue` | `cy-*` 骨架 + 令牌 | | **已落地(无骨架)** | `LoginView.vue` | 令牌密度最高(24 处),装饰位面合规 | -| **部分落地** | `ChatView` / `GroupChatView` / `GlobalAgentView` / `HistoryView` / `JourneyView` / `WorkflowView` / `LogsView` / `PerformanceView` / `PluginsView` / `McpManagerView` / `MemoryView` / `PetdexView` / `TerminalView` / `SkillsView` / `SkillsUsageView` / `DevicesView` / `KanbanView` / `FilesView` / `HermesMissingView` | 用了基础令牌,但布局仍是 Arco 原生(`a-card` / `a-tabs`) | +| **部分落地** | `ChatView` / `GroupChatView` / `GlobalAgentView` / `HistoryView` / `JourneyView` / `WorkflowView` / `LogsView` / `PerformanceView` / `PluginsView` / `McpManagerView` / `MemoryView` / `TerminalView` / `SkillsView` / `SkillsUsageView` / `DevicesView` / `KanbanView` / `FilesView` / `HermesMissingView` | 用了基础令牌,但布局仍是 Arco 原生(`a-card` / `a-tabs`) | | **未落地** | `JobsView` / `ModelsView` / `ProfilesView` / `UsageView` / `ChannelsView` / `VersionPreviewView` | style 块几乎没有色值,也没有语义色 | **CYBER 目前是「孤岛式」的**:设置页 + 聊天链是两块完整的岛,其余 20+ 页面停留在迁移前原样。 @@ -170,7 +170,7 @@ CYBER 主题**完成**的定义是下面五条同时成立: 2. 按页面分批改,每批 3-5 个页面: - **批次 A(数据展示型)**:`UsageView` / `JobsView` / `LogsView` / `ModelsView` / `ProfilesView` - **批次 B(配置型)**:`DevicesView` / `ChannelsView` / `PluginsView` / `McpManagerView` - - **批次 C(内容型)**:`FilesView` / `MemoryView` / `PetdexView` / `SkillsView` / `SkillsUsageView` + - **批次 C(内容型)**:`FilesView` / `MemoryView` / `SkillsView` / `SkillsUsageView` - **批次 D(复杂型)**:`WorkflowView` / `KanbanView` / `JourneyView` / `PerformanceView` / `HistoryView` / `HermesMissingView` / `VersionPreviewView` 3. 每批的替换模式:**Arco 结构 → `.cy-panel` 外壳**,但**保留** Arco 组件本身 (`a-table` / `a-form` / `a-select` 都不动)。Arco 组件的外观已经被 @@ -192,7 +192,7 @@ CYBER 主题**完成**的定义是下面五条同时成立: | --- | --- | --- | | A | `UsageView` / `JobsView` / `LogsView` / `ModelsView` / `ProfilesView` | 大部分已合规;`JobsView` 补 `cy-sec-t` 分节标题 + i18n key `jobs.sectionList` | | B | `DevicesView` / `ChannelsView` / `PluginsView` / `McpManagerView` | `ChannelsView` 内容区包进 `cy-panel`;`McpManagerView` 已合规(modal 内列表保留自定义边框,避免卡片套卡片) | -| C | `FilesView` / `MemoryView` / `PetdexView` / `SkillsView` / `SkillsUsageView` | 已合规;`PetdexView` 棋盘格 `rgba(127,127,127,0.08)` → `rgba(var(--text-muted-rgb), 0.08)` | +| C | `FilesView` / `MemoryView` / `SkillsView` / `SkillsUsageView` | 已合规 | | D | `WorkflowView` / `KanbanView` / `JourneyView` / `PerformanceView` / `HistoryView` / `HermesMissingView` / `VersionPreviewView` / `TerminalView` / `GlobalAgentView` | **全部已是令牌驱动**。这些是专用布局(画布 / 看板 / 终端 / 聊天流),原型的 `panel` 结构不适用,不需要套骨架类 | **批次 D 的判定依据**:「cy-* 计数为 0」是**误报**。判断标准应是 @@ -274,7 +274,6 @@ CYBER 主题**完成**的定义是下面五条同时成立: | 位置 | 问题 | 修法 | | --- | --- | --- | -| `views/hermes/PetdexView.vue` | 棋盘格 `rgba(127,127,127,0.08)` | → `rgba(var(--text-muted-rgb), 0.08)` | | `utils/hermes/kanban-status-colors.ts` | 分类色 `#c084fc` + `rgba(192,132,252,0.14)` 字面量 | 新增令牌 `--kanban-review` / `--kanban-review-rgb`;灰阶改走 `--text-muted-rgb` | | `components/hermes/chat/MessageList.vue` | 浅色主题遗留阴影 `rgba(15,23,42,0.05)` | → `var(--shadow-sm)` | diff --git a/docs/known-issues.md b/docs/known-issues.md index e1ae8f9..8e286fb 100644 --- a/docs/known-issues.md +++ b/docs/known-issues.md @@ -199,7 +199,7 @@ ### 视觉细节 -- **圆角 tag**:naive 的 `NTag round` 无 Arco 对应,改用了 `border-radius: 999px`(`PetdexView.vue`、`DevicesView.vue`) +- **圆角 tag**:naive 的 `NTag round` 无 Arco 对应,改用了 `border-radius: 999px`(`DevicesView.vue`) - **按钮尺寸**:naive 的 `size="tiny"` → Arco `size="mini"`,可能与相邻元素不齐 - **滚动条**:naive 的 `:native-scrollbar="false"` 无对应,已删除,滚动条样式会变 diff --git a/packages/client/public/icons/pet-resize.svg b/packages/client/public/icons/pet-resize.svg deleted file mode 100644 index 5fdbea1..0000000 --- a/packages/client/public/icons/pet-resize.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/packages/client/src/api/hermes/pet-state.ts b/packages/client/src/api/hermes/pet-state.ts deleted file mode 100644 index 0d2d89f..0000000 --- a/packages/client/src/api/hermes/pet-state.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { io, type Socket } from 'socket.io-client' -import { getActiveProfileName, getApiKey, getBaseUrlValue } from '@/api/client' - -export interface PetActivity { - busy?: boolean - reasoning?: boolean - toolRunning?: boolean - awaitingInput?: boolean - error?: boolean - justCompleted?: boolean - celebrate?: boolean -} - -export type PetState = 'idle' | 'run' | 'review' | 'failed' | 'wave' | 'jump' | 'waiting' - -export interface PetStateSnapshot { - profile: string - state: PetState - activity: PetActivity - updatedAt: number - sessionId?: string - runId?: string - activeTools: string[] - awaiting: Array<'approval' | 'clarify' | 'input'> -} - -let socket: Socket | null = null -let socketProfile: string | null = null - -function activeProfile(profile?: string | null): string { - return profile || getActiveProfileName() || 'default' -} - -export function connectPetStateSocket(profile?: string | null): Socket { - const nextProfile = activeProfile(profile) - if (socket && socketProfile === nextProfile) return socket - if (socket) { - socket.disconnect() - socket = null - } - - socketProfile = nextProfile - socket = io(`${getBaseUrlValue()}/pet-state`, { - auth: { token: getApiKey() }, - query: { profile: nextProfile }, - transports: ['websocket', 'polling'], - reconnection: true, - reconnectionAttempts: Infinity, - reconnectionDelay: 1000, - reconnectionDelayMax: 30000, - randomizationFactor: 0.5, - timeout: 30000, - }) - return socket -} - -export function disconnectPetStateSocket(): void { - socket?.disconnect() - socket = null - socketProfile = null -} - -export function onPetStateSnapshot( - handler: (snapshot: PetStateSnapshot) => void, - profile?: string | null, -): () => void { - const activeSocket = connectPetStateSocket(profile) - activeSocket.on('pet.state.snapshot', handler) - activeSocket.on('pet.state.updated', handler) - return () => { - activeSocket.off('pet.state.snapshot', handler) - activeSocket.off('pet.state.updated', handler) - } -} - -export function requestPetStateSnapshot(profile?: string | null): Promise { - const activeSocket = connectPetStateSocket(profile) - return new Promise((resolve, reject) => { - activeSocket.timeout(10000).emit('pet.state.get', (err: Error | null, snapshot: PetStateSnapshot) => { - if (err) { - reject(err) - return - } - resolve(snapshot) - }) - }) -} diff --git a/packages/client/src/api/hermes/petdex.ts b/packages/client/src/api/hermes/petdex.ts deleted file mode 100644 index 578aca9..0000000 --- a/packages/client/src/api/hermes/petdex.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { request } from '@/api/client' - -export interface PetdexPet { - slug: string - displayName: string - kind: string - submittedBy: string - spritesheetUrl: string - previewUrl?: string - petJsonUrl: string - zipUrl: string -} - -export interface PetdexManifest { - generatedAt: string - total: number - pets: PetdexPet[] -} - -export function fetchPetdexManifest(force = false): Promise { - return request(`/api/hermes/petdex/manifest${force ? '?force=1' : ''}`) -} diff --git a/packages/client/src/api/hermes/pets.ts b/packages/client/src/api/hermes/pets.ts deleted file mode 100644 index 432e94a..0000000 --- a/packages/client/src/api/hermes/pets.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { request } from '../client' - -export interface WebPetPosition { - x: number - y: number -} - -export interface ActivePet { - 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?: WebPetPosition - stateRows: string[] - installedAt: number - updatedAt: number -} - -export async function fetchActivePet(): Promise { - const res = await request<{ pet: ActivePet | null }>('/api/hermes/pets/active') - return res.pet -} - -export async function adoptPet(slug: string): Promise { - const res = await request<{ pet: ActivePet }>('/api/hermes/pets/adopt', { - method: 'POST', - body: JSON.stringify({ slug }), - }) - return res.pet -} - -export async function updateActivePetPreferences(input: { - scale?: number - position?: WebPetPosition - enabled?: boolean -}): Promise { - const res = await request<{ pet: ActivePet | null }>('/api/hermes/pets/active', { - method: 'PATCH', - body: JSON.stringify(input), - }) - return res.pet -} diff --git a/packages/client/src/components/layout/AppSidebar.vue b/packages/client/src/components/layout/AppSidebar.vue index a3e05f2..aea6083 100644 --- a/packages/client/src/components/layout/AppSidebar.vue +++ b/packages/client/src/components/layout/AppSidebar.vue @@ -153,15 +153,6 @@ function handleUpdateClick() { {{ t("sidebar.mcp") }} - - - - - - - - {{ t("sidebar.petdex") }} - diff --git a/packages/client/src/i18n/locales/en.ts b/packages/client/src/i18n/locales/en.ts index 0e1a94b..892128f 100644 --- a/packages/client/src/i18n/locales/en.ts +++ b/packages/client/src/i18n/locales/en.ts @@ -208,7 +208,6 @@ export default { skills: 'Skills', plugins: 'Plugins', mcp: 'MCP', - petdex: 'Pets', memory: 'Memory', logs: 'Logs', usage: 'Usage', @@ -1191,29 +1190,6 @@ export default { }, }, - petdex: { - title: 'Petdex Pets', - subtitle: 'Browse the public petdex catalog used by Hermes pets.', - refresh: 'Refresh', - adopt: 'Adopt', - active: 'Active', - adopted: 'Adopted {name}', - adoptFailed: 'Failed to adopt pet', - loadFailed: 'Failed to load petdex catalog', - searchPlaceholder: 'Search by name, slug, author, or kind', - kindFilter: 'Kind', - spritesheet: 'Spritesheet', - submittedBy: 'By {name}', - empty: 'No pets match the current filters.', - showMore: 'Show {count} more', - summary: { - total: 'Catalog total', - visible: 'Matching', - kinds: 'Kinds', - generatedAt: 'Generated at', - }, - }, - // Memory memory: { title: 'Memory', diff --git a/packages/client/src/i18n/locales/zh.ts b/packages/client/src/i18n/locales/zh.ts index 56fb3e3..9b3cd3f 100644 --- a/packages/client/src/i18n/locales/zh.ts +++ b/packages/client/src/i18n/locales/zh.ts @@ -207,7 +207,6 @@ export default { profiles: '用户', plugins: '插件', mcp: 'MCP', - petdex: '宠物', skills: '技能', memory: '记忆', logs: '日志', @@ -1191,29 +1190,6 @@ export default { }, }, - petdex: { - title: 'Petdex 宠物', - subtitle: '浏览 Hermes 桌宠使用的 petdex 公共图库。', - refresh: '刷新', - adopt: '领取', - active: '使用中', - adopted: '已领取 {name}', - adoptFailed: '领取宠物失败', - loadFailed: '加载 petdex 图库失败', - searchPlaceholder: '按名称、slug、作者或类型搜索', - kindFilter: '类型', - spritesheet: '精灵图', - submittedBy: '作者 {name}', - empty: '没有符合当前筛选的宠物。', - showMore: '再显示 {count} 个', - summary: { - total: '图库总数', - visible: '匹配数量', - kinds: '类型数', - generatedAt: '生成时间', - }, - }, - // 记忆 memory: { title: '记忆', diff --git a/packages/client/src/router/index.ts b/packages/client/src/router/index.ts index 3bccc4e..f39ddaf 100644 --- a/packages/client/src/router/index.ts +++ b/packages/client/src/router/index.ts @@ -109,11 +109,6 @@ const router = createRouter({ name: 'hermes.plugins', component: () => import('@/views/hermes/PluginsView.vue'), }, - { - path: '/hermes/petdex', - name: 'hermes.petdex', - component: () => import('@/views/hermes/PetdexView.vue'), - }, { path: '/hermes/memory', name: 'hermes.memory', diff --git a/packages/client/src/stores/hermes/pet-state.ts b/packages/client/src/stores/hermes/pet-state.ts deleted file mode 100644 index c358809..0000000 --- a/packages/client/src/stores/hermes/pet-state.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { computed, ref } from 'vue' -import { defineStore } from 'pinia' -import { - connectPetStateSocket, - disconnectPetStateSocket, - onPetStateSnapshot, - requestPetStateSnapshot, - type PetActivity, - type PetState, - type PetStateSnapshot, -} from '@/api/hermes/pet-state' - -function emptySnapshot(profile = 'default'): PetStateSnapshot { - return { - profile, - state: 'idle', - activity: {}, - updatedAt: Date.now(), - activeTools: [], - awaiting: [], - } -} - -export const usePetStateStore = defineStore('petState', () => { - const snapshot = ref(emptySnapshot()) - const connected = ref(false) - const error = ref('') - let unsubscribe: (() => void) | null = null - - const state = computed(() => snapshot.value.state) - const activity = computed(() => snapshot.value.activity) - - async function connect(profile?: string | null): Promise { - disconnect() - error.value = '' - const socket = connectPetStateSocket(profile) - socket.on('connect', () => { - connected.value = true - error.value = '' - }) - socket.on('disconnect', () => { - connected.value = false - }) - socket.on('connect_error', (err: Error) => { - connected.value = false - error.value = err.message - }) - unsubscribe = onPetStateSnapshot(next => { - snapshot.value = next - }, profile) - try { - snapshot.value = await requestPetStateSnapshot(profile) - } catch (err) { - error.value = err instanceof Error ? err.message : String(err) - } - } - - function disconnect(): void { - unsubscribe?.() - unsubscribe = null - disconnectPetStateSocket() - connected.value = false - } - - return { - activity, - connect, - connected, - disconnect, - error, - snapshot, - state, - } -}) diff --git a/packages/client/src/stores/hermes/pets.ts b/packages/client/src/stores/hermes/pets.ts deleted file mode 100644 index 4fa4751..0000000 --- a/packages/client/src/stores/hermes/pets.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { computed, ref } from 'vue' -import { defineStore } from 'pinia' -import * as petsApi from '@/api/hermes/pets' -import type { ActivePet, WebPetPosition } from '@/api/hermes/pets' - -export const usePetsStore = defineStore('pets', () => { - const activePet = ref(null) - const loading = ref(false) - const saving = ref(false) - const error = ref('') - - const hasActivePet = computed(() => !!activePet.value?.enabled) - - async function loadActivePet(): Promise { - loading.value = true - error.value = '' - try { - activePet.value = await petsApi.fetchActivePet() - return activePet.value - } catch (err: any) { - error.value = err?.message || 'Failed to load active pet' - activePet.value = null - return null - } finally { - loading.value = false - } - } - - async function adopt(slug: string): Promise { - saving.value = true - error.value = '' - try { - activePet.value = await petsApi.adoptPet(slug) - return activePet.value - } catch (err: any) { - error.value = err?.message || 'Failed to adopt pet' - throw err - } finally { - saving.value = false - } - } - - async function savePreferences(input: { scale?: number; position?: WebPetPosition; enabled?: boolean }): Promise { - if (!activePet.value) return - activePet.value = { - ...activePet.value, - ...(typeof input.scale === 'number' ? { scale: input.scale } : {}), - ...(input.position ? { position: input.position } : {}), - ...(typeof input.enabled === 'boolean' ? { enabled: input.enabled } : {}), - } - saving.value = true - try { - const saved = await petsApi.updateActivePetPreferences(input) - if (saved) activePet.value = saved - else if (input.enabled === false) activePet.value = null - } finally { - saving.value = false - } - } - - async function hideActivePet(): Promise { - await savePreferences({ enabled: false }) - } - - function clear(): void { - activePet.value = null - error.value = '' - } - - return { - activePet, - loading, - saving, - error, - hasActivePet, - loadActivePet, - adopt, - savePreferences, - hideActivePet, - clear, - } -}) diff --git a/packages/client/src/views/hermes/PetdexView.vue b/packages/client/src/views/hermes/PetdexView.vue deleted file mode 100644 index 7b001a7..0000000 --- a/packages/client/src/views/hermes/PetdexView.vue +++ /dev/null @@ -1,339 +0,0 @@ - - -
- - -
-
- -
- - -
-
- - - diff --git a/packages/server/src/controllers/hermes/petdex.ts b/packages/server/src/controllers/hermes/petdex.ts deleted file mode 100644 index d52d76f..0000000 --- a/packages/server/src/controllers/hermes/petdex.ts +++ /dev/null @@ -1,26 +0,0 @@ -import type { Context } from 'koa' -import { fetchPetdexAsset, fetchPetdexManifest } from '../../services/hermes/petdex' - -export async function manifest(ctx: Context) { - const force = ctx.query.force === '1' || ctx.query.force === 'true' - ctx.body = await fetchPetdexManifest({ force }) -} - -export async function asset(ctx: Context) { - const url = typeof ctx.query.url === 'string' ? ctx.query.url.trim() : '' - if (!url) { - ctx.status = 400 - ctx.body = { error: 'Petdex asset URL is required' } - return - } - - try { - const asset = await fetchPetdexAsset(url) - ctx.type = asset.mime - ctx.set('Cache-Control', `public, max-age=${asset.maxAgeSeconds}`) - ctx.body = asset.buffer - } catch (err) { - ctx.status = 400 - ctx.body = { error: err instanceof Error ? err.message : 'Petdex asset request failed' } - } -} diff --git a/packages/server/src/controllers/hermes/pets.ts b/packages/server/src/controllers/hermes/pets.ts deleted file mode 100644 index da9f88c..0000000 --- a/packages/server/src/controllers/hermes/pets.ts +++ /dev/null @@ -1,50 +0,0 @@ -import type { Context } from 'koa' -import { getActiveProfileName } from '../../services/hermes/hermes-profile' -import { adoptPetFromPetdex, getActivePet, updateActivePetPreferences } from '../../services/hermes/pets' - -function requestedProfile(ctx: Context): string { - return ctx.state.profile?.name || getActiveProfileName() || 'default' -} - -function errorMessage(err: unknown): string { - return err instanceof Error ? err.message : 'Pet request failed' -} - -export async function active(ctx: Context) { - ctx.body = { pet: await getActivePet(requestedProfile(ctx)) } -} - -export async function adopt(ctx: Context) { - const body = ctx.request.body as { slug?: unknown } | undefined - const slug = typeof body?.slug === 'string' ? body.slug.trim() : '' - if (!slug) { - ctx.status = 400 - ctx.body = { error: 'Pet slug is required' } - return - } - - try { - ctx.body = { pet: await adoptPetFromPetdex(requestedProfile(ctx), slug) } - } catch (err) { - const message = errorMessage(err) - ctx.status = message.includes('was not found') ? 404 : 400 - ctx.body = { error: message } - } -} - -export async function updateActive(ctx: Context) { - const body = ctx.request.body as { - scale?: unknown - position?: { x?: unknown; y?: unknown } - enabled?: unknown - } | undefined - - const pet = await updateActivePetPreferences(requestedProfile(ctx), { - scale: typeof body?.scale === 'number' ? body.scale : undefined, - enabled: typeof body?.enabled === 'boolean' ? body.enabled : undefined, - position: body?.position && typeof body.position.x === 'number' && typeof body.position.y === 'number' - ? { x: body.position.x, y: body.position.y } - : undefined, - }) - ctx.body = { pet } -} diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 1607eb9..a6ee9a2 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -27,7 +27,6 @@ import { scanLanDevices, startLanDiscoveryResponder } from './services/lan-disco import { getLanPeerSocketManager, getLanPeerSocketPath } from './services/lan-peer-socket' import { startGlobalAgentServer } from './services/global-agent/server' import { WorkflowSocketServer } from './services/workflow-socket' -import { PetStateSocketServer } from './services/hermes/pet-state-socket' import { logger } from './services/logger' import { createStaticCompressionMiddleware } from './middleware/static-compression' import { requireUserJwt, resolveUserProfile } from './middleware/user-auth' @@ -57,7 +56,6 @@ let server: any = null let servers: any[] = [] let chatRunServer: any = null let workflowSocketServer: WorkflowSocketServer | null = null -let petStateSocketServer: PetStateSocketServer | null = null let agentBridgeManager: any = null interface ListenResult { @@ -251,9 +249,6 @@ export async function bootstrap() { workflowSocketServer = new WorkflowSocketServer(groupChatServer.getIO()) workflowSocketServer.init() - petStateSocketServer = new PetStateSocketServer(groupChatServer.getIO()) - petStateSocketServer.init() - const loopbackBaseUrl = getLoopbackBaseUrl(server) startGlobalAgentServer(groupChatServer.getIO(), { localBaseUrl: loopbackBaseUrl }) console.log('[bootstrap] global agent server ready') diff --git a/packages/server/src/routes/hermes/petdex.ts b/packages/server/src/routes/hermes/petdex.ts deleted file mode 100644 index 103a41f..0000000 --- a/packages/server/src/routes/hermes/petdex.ts +++ /dev/null @@ -1,8 +0,0 @@ -import Router from '@koa/router' -import * as ctrl from '../../controllers/hermes/petdex' - -export const petdexRoutes = new Router() -export const petdexPublicRoutes = new Router() - -petdexPublicRoutes.get('/api/hermes/petdex/asset', ctrl.asset) -petdexRoutes.get('/api/hermes/petdex/manifest', ctrl.manifest) diff --git a/packages/server/src/routes/hermes/pets.ts b/packages/server/src/routes/hermes/pets.ts deleted file mode 100644 index aa00109..0000000 --- a/packages/server/src/routes/hermes/pets.ts +++ /dev/null @@ -1,8 +0,0 @@ -import Router from '@koa/router' -import * as ctrl from '../../controllers/hermes/pets' - -export const petRoutes = new Router() - -petRoutes.get('/api/hermes/pets/active', ctrl.active) -petRoutes.patch('/api/hermes/pets/active', ctrl.updateActive) -petRoutes.post('/api/hermes/pets/adopt', ctrl.adopt) diff --git a/packages/server/src/routes/index.ts b/packages/server/src/routes/index.ts index 0e7bf73..67939e7 100644 --- a/packages/server/src/routes/index.ts +++ b/packages/server/src/routes/index.ts @@ -43,8 +43,6 @@ import { journeyRoutes } from './hermes/journey' import { mcpRoutes } from './hermes/mcp' import { runtimeVersionRoutes } from './hermes/runtime-versions' import { writeGateRoutes } from './hermes/write-gate' -import { petdexPublicRoutes, petdexRoutes } from './hermes/petdex' -import { petRoutes } from './hermes/pets' /** * Register all routes on the Koa app. @@ -59,7 +57,6 @@ export function registerRoutes(app: any, authMiddleware: Array<(ctx: Context, ne app.use(devicePublicRoutes.routes()) app.use(ttsRoutes.routes()) app.use(apiDocsRoutes.routes()) - app.use(petdexPublicRoutes.routes()) // --- Auth middleware: all routes below require authentication --- authMiddleware.forEach((middleware) => app.use(middleware)) @@ -102,6 +99,4 @@ export function registerRoutes(app: any, authMiddleware: Array<(ctx: Context, ne app.use(mcpRoutes.routes()) // MCP management app.use(runtimeVersionRoutes.routes()) // Runtime and version management app.use(writeGateRoutes.routes()) // Hermes Agent write approval review - app.use(petdexRoutes.routes()) - app.use(petRoutes.routes()) } diff --git a/packages/server/src/services/hermes/pet-state-socket.ts b/packages/server/src/services/hermes/pet-state-socket.ts deleted file mode 100644 index fd772cf..0000000 --- a/packages/server/src/services/hermes/pet-state-socket.ts +++ /dev/null @@ -1,291 +0,0 @@ -import type { Server, Socket } from 'socket.io' -import { authenticateUserToken, isAuthEnabled, type AuthenticatedUser } from '../../middleware/user-auth' -import { userCanAccessProfile } from '../../db/hermes/users-store' -import { logger } from '../logger' - -const PET_STATE_NAMESPACE = '/pet-state' -const DEFAULT_PROFILE = 'default' - -export interface PetActivity { - busy?: boolean - reasoning?: boolean - toolRunning?: boolean - awaitingInput?: boolean - error?: boolean - justCompleted?: boolean - celebrate?: boolean -} - -export type PetState = 'idle' | 'run' | 'review' | 'failed' | 'wave' | 'jump' | 'waiting' - -export interface PetStateSnapshot { - profile: string - state: PetState - activity: PetActivity - updatedAt: number - sessionId?: string - runId?: string - activeTools: string[] - awaiting: Array<'approval' | 'clarify' | 'input'> -} - -export interface PetActivityUpdate { - profile?: string | null - activity: PetActivity - replace?: boolean -} - -function normalizeProfile(value: unknown): string { - const profile = typeof value === 'string' ? value.trim() : '' - return profile || DEFAULT_PROFILE -} - -export function derivePetState(activity: PetActivity): PetState { - if (activity.error) return 'failed' - if (activity.celebrate) return 'jump' - if (activity.justCompleted) return 'wave' - if (activity.awaitingInput) return 'waiting' - if (activity.toolRunning) return 'run' - if (activity.reasoning) return 'review' - if (activity.busy) return 'run' - return 'idle' -} - -function canAccessProfile(user: AuthenticatedUser | undefined, profile: string): boolean { - if (!user || user.role === 'super_admin') return true - return userCanAccessProfile(user.id, profile) -} - -class PetStateRegistry { - private snapshots = new Map() - private runProfiles = new Map() - private flashTimers = new Map>() - - get(profile: string): PetStateSnapshot { - const normalized = normalizeProfile(profile) - return this.snapshots.get(normalized) ?? { - profile: normalized, - state: 'idle', - activity: {}, - updatedAt: Date.now(), - activeTools: [], - awaiting: [], - } - } - - update(update: PetActivityUpdate): PetStateSnapshot { - const profile = normalizeProfile(update.profile) - const previous = this.get(profile) - const activity = update.replace ? { ...update.activity } : { ...previous.activity, ...update.activity } - const snapshot: PetStateSnapshot = { - ...previous, - profile, - state: derivePetState(activity), - activity, - updatedAt: Date.now(), - } - this.snapshots.set(profile, snapshot) - return snapshot - } - - applyRunChatEvent(profileInput: string | null | undefined, event: string, payload: Record): PetStateSnapshot { - const sessionId = stringValue(payload.session_id) - const runId = stringValue(payload.run_id) - const knownProfile = sessionId ? this.runProfiles.get(sessionId) : '' - const profile = normalizeProfile(profileInput || knownProfile) - if (sessionId) this.runProfiles.set(sessionId, profile) - - const previous = this.get(profile) - const tools = new Set(previous.activeTools) - const awaiting = new Set(previous.awaiting) - let activity: PetActivity = { ...previous.activity } - - switch (event) { - case 'run.queued': - case 'run.started': - activity = { busy: true } - awaiting.clear() - tools.clear() - break - case 'tool.started': - activity.busy = true - activity.toolRunning = true - tools.add(stringValue(payload.tool_name) || stringValue(payload.toolName) || stringValue(payload.name) || 'tool') - break - case 'tool.completed': - case 'tool.failed': { - const toolName = stringValue(payload.tool_name) || stringValue(payload.toolName) || stringValue(payload.name) - if (toolName) tools.delete(toolName) - else tools.clear() - activity.toolRunning = tools.size > 0 - break - } - case 'reasoning.delta': - case 'thinking.delta': - case 'reasoning.available': - case 'moa.aggregating': - activity.busy = true - activity.reasoning = true - break - case 'message.delta': - activity.busy = true - activity.reasoning = false - break - case 'approval.requested': - awaiting.add('approval') - activity.busy = false - activity.awaitingInput = true - activity.reasoning = false - activity.toolRunning = false - break - case 'clarify.requested': - awaiting.add('clarify') - activity.busy = false - activity.awaitingInput = true - activity.reasoning = false - activity.toolRunning = false - break - case 'approval.resolved': - awaiting.delete('approval') - activity.awaitingInput = awaiting.size > 0 - break - case 'clarify.resolved': - awaiting.delete('clarify') - activity.awaitingInput = awaiting.size > 0 - break - case 'run.completed': - activity = { justCompleted: true } - awaiting.clear() - tools.clear() - this.scheduleFlashClear(profile, 'justCompleted') - break - case 'run.failed': - activity = { error: true } - awaiting.clear() - tools.clear() - this.scheduleFlashClear(profile, 'error') - break - case 'abort.completed': - activity = {} - awaiting.clear() - tools.clear() - break - default: - break - } - - const snapshot: PetStateSnapshot = { - profile, - sessionId: sessionId || previous.sessionId, - runId: runId || previous.runId, - state: derivePetState(activity), - activity, - updatedAt: Date.now(), - activeTools: Array.from(tools), - awaiting: Array.from(awaiting), - } - this.snapshots.set(profile, snapshot) - return snapshot - } - - private scheduleFlashClear(profile: string, key: 'error' | 'justCompleted'): void { - const existing = this.flashTimers.get(profile) - if (existing) clearTimeout(existing) - const timer = setTimeout(() => { - const current = this.get(profile) - if (!current.activity[key]) return - const activity = { ...current.activity, [key]: false } - const snapshot: PetStateSnapshot = { - ...current, - state: derivePetState(activity), - activity, - updatedAt: Date.now(), - } - this.snapshots.set(profile, snapshot) - activeServer?.broadcast(snapshot) - }, 1600) - this.flashTimers.set(profile, timer) - } -} - -const registry = new PetStateRegistry() -let activeServer: PetStateSocketServer | null = null - -export function getPetStateSnapshot(profile?: string | null): PetStateSnapshot { - return registry.get(normalizeProfile(profile)) -} - -export function publishPetActivity(update: PetActivityUpdate): PetStateSnapshot { - const snapshot = registry.update(update) - activeServer?.broadcast(snapshot) - return snapshot -} - -export function observeRunChatPetEvent(profile: string | null | undefined, event: string, payload: Record): PetStateSnapshot { - const snapshot = registry.applyRunChatEvent(profile, event, payload) - activeServer?.broadcast(snapshot) - return snapshot -} - -function stringValue(value: unknown): string { - return typeof value === 'string' ? value.trim() : '' -} - -export class PetStateSocketServer { - private readonly nsp: ReturnType - - constructor(io: Server) { - this.nsp = io.of(PET_STATE_NAMESPACE) - activeServer = this - } - - init(): void { - this.nsp.use(this.authMiddleware.bind(this)) - this.nsp.on('connection', this.onConnection.bind(this)) - logger.info('[pet-state-socket] Socket.IO ready at /pet-state') - } - - broadcast(snapshot: PetStateSnapshot): void { - this.nsp.to(this.profileRoom(snapshot.profile)).emit('pet.state.updated', snapshot) - } - - private async authMiddleware(socket: Socket, next: (err?: Error) => void): Promise { - if (!await isAuthEnabled()) { - next() - return - } - - const token = socket.handshake.auth?.token as string | undefined - const user = await authenticateUserToken(token || '') - if (!user) { - next(new Error('Authentication failed')) - return - } - - const profile = normalizeProfile(socket.handshake.query?.profile) - if (!canAccessProfile(user, profile)) { - next(new Error('Profile access denied')) - return - } - - socket.data.user = user - socket.data.profile = profile - next() - } - - private onConnection(socket: Socket): void { - const profile = normalizeProfile(socket.data.profile) - void socket.join(this.profileRoom(profile)) - socket.emit('pet.state.snapshot', registry.get(profile)) - - socket.on('pet.state.get', (ack?: (snapshot: PetStateSnapshot) => void) => { - if (typeof ack === 'function') { - ack(registry.get(profile)) - } - }) - } - - private profileRoom(profile: string): string { - return `profile:${normalizeProfile(profile)}` - } -} diff --git a/packages/server/src/services/hermes/petdex.ts b/packages/server/src/services/hermes/petdex.ts deleted file mode 100644 index 2d72c5c..0000000 --- a/packages/server/src/services/hermes/petdex.ts +++ /dev/null @@ -1,137 +0,0 @@ -export interface PetdexPet { - slug: string - displayName: string - kind: string - submittedBy: string - spritesheetUrl: string - previewUrl?: string - petJsonUrl: string - zipUrl: string -} - -export interface PetdexManifest { - generatedAt: string - total: number - pets: PetdexPet[] -} - -const PETDEX_MANIFEST_URL = 'https://assets.petdex.dev/manifests/petdex-v1.json' -const CACHE_TTL_MS = 5 * 60 * 1000 -const FETCH_TIMEOUT_MS = 15_000 -const MAX_ASSET_BYTES = 10 * 1024 * 1024 - -let cache: { expiresAt: number; manifest: PetdexManifest } | null = null - -function asString(value: unknown): string { - return typeof value === 'string' ? value : '' -} - -function normalizePet(value: unknown): PetdexPet | null { - if (!value || typeof value !== 'object') return null - const item = value as Record - const slug = asString(item.slug).trim() - const spritesheetUrl = asString(item.spritesheetUrl).trim() - if (!slug || !spritesheetUrl) return null - return { - slug, - displayName: asString(item.displayName).trim() || slug, - kind: asString(item.kind).trim() || 'pet', - submittedBy: asString(item.submittedBy).trim(), - spritesheetUrl, - previewUrl: `/api/hermes/petdex/asset?url=${encodeURIComponent(spritesheetUrl)}`, - petJsonUrl: asString(item.petJsonUrl).trim(), - zipUrl: asString(item.zipUrl).trim(), - } -} - -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 petdex asset host') - } - return url -} - -function imageMimeFromResponse(response: Response, pathname: string): string { - const contentType = response.headers.get('content-type')?.split(';')[0]?.trim().toLowerCase() - if (contentType?.startsWith('image/')) return contentType - const lower = pathname.toLowerCase() - if (lower.endsWith('.webp')) return 'image/webp' - if (lower.endsWith('.png')) return 'image/png' - if (lower.endsWith('.jpg') || lower.endsWith('.jpeg')) return 'image/jpeg' - return 'application/octet-stream' -} - -function normalizeManifest(value: unknown): PetdexManifest { - if (!value || typeof value !== 'object') { - throw new Error('petdex manifest is not an object') - } - - const data = value as Record - const pets = Array.isArray(data.pets) ? data.pets.map(normalizePet).filter((pet): pet is PetdexPet => Boolean(pet)) : [] - if (pets.length === 0) { - throw new Error('petdex manifest has no pets') - } - - return { - generatedAt: asString(data.generatedAt), - total: typeof data.total === 'number' ? data.total : pets.length, - pets, - } -} - -export async function fetchPetdexManifest(options: { force?: boolean } = {}): Promise { - const now = Date.now() - if (!options.force && cache && cache.expiresAt > now) { - return cache.manifest - } - - const controller = new AbortController() - const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS) - try { - const response = await fetch(PETDEX_MANIFEST_URL, { - headers: { 'User-Agent': 'hermes-ui-petdex' }, - signal: controller.signal, - }) - if (!response.ok) { - throw new Error(`petdex manifest request failed: ${response.status}`) - } - const manifest = normalizeManifest(await response.json()) - cache = { expiresAt: now + CACHE_TTL_MS, manifest } - return manifest - } finally { - clearTimeout(timeout) - } -} - -export async function fetchPetdexAsset(urlValue: string): Promise<{ buffer: Buffer; mime: string; maxAgeSeconds: number }> { - 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-petdex' }, - signal: controller.signal, - }) - if (!response.ok) throw new Error(`petdex asset request failed: ${response.status}`) - - const length = Number(response.headers.get('content-length') || '0') - if (Number.isFinite(length) && length > MAX_ASSET_BYTES) { - throw new Error('petdex asset is too large') - } - - const arrayBuffer = await response.arrayBuffer() - if (arrayBuffer.byteLength > MAX_ASSET_BYTES) { - throw new Error('petdex asset is too large') - } - - return { - buffer: Buffer.from(arrayBuffer), - mime: imageMimeFromResponse(response, url.pathname), - maxAgeSeconds: 60 * 60, - } - } finally { - clearTimeout(timeout) - } -} diff --git a/packages/server/src/services/hermes/pets.ts b/packages/server/src/services/hermes/pets.ts deleted file mode 100644 index 53c9112..0000000 --- a/packages/server/src/services/hermes/pets.ts +++ /dev/null @@ -1,305 +0,0 @@ -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 { - if (!urlValue) return null - const { buffer } = await fetchBytes(urlValue, maxBytes) - return buffer.toString('utf-8') -} - -async function readJsonFile(filePath: string): Promise { - try { - return JSON.parse(await readFile(filePath, 'utf-8')) as T - } catch { - return null - } -} - -async function writeJsonFile(filePath: string, value: unknown): Promise { - 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 { - 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 { - const active = await readJsonFile(activePetPath(profile)) - if (!active?.enabled || !active.slug) return null - - const installed = await readJsonFile(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 { - const active = await readJsonFile(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 { - 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, - } -} diff --git a/packages/server/src/services/hermes/run-chat/handle-bridge-run.ts b/packages/server/src/services/hermes/run-chat/handle-bridge-run.ts index 1aa570c..cda333a 100644 --- a/packages/server/src/services/hermes/run-chat/handle-bridge-run.ts +++ b/packages/server/src/services/hermes/run-chat/handle-bridge-run.ts @@ -35,7 +35,6 @@ import { markAbortCompleted } from './abort' import { writeModelRunProfileToken } from './model-run-prompt' import type { AuthenticatedUser } from '../../../middleware/user-auth' import { ensureHermesRunWorkspace } from './workspace' -import { observeRunChatPetEvent } from '../pet-state-socket' import { completeWorkspaceRunCheckpoint, startWorkspaceRunCheckpoint } from './workspace-diff-tracker' const BRIDGE_USAGE_FLUSH_DELAY_MS = 200 @@ -462,7 +461,6 @@ export async function handleBridgeRun( } const emit = (event: string, payload: any) => { const tagged = { ...payload, session_id } - observePetEvent(profile, event, tagged) data.onEvent?.(event, tagged) nsp.to(`session:${session_id}`).emit(event, tagged) if (!data.onEvent && !nsp.adapter.rooms.get(`session:${session_id}`)?.size && socket.connected) { @@ -735,7 +733,6 @@ export async function resumeBridgeRun( const emit = (event: string, payload: any) => { const tagged = { ...payload, session_id: sessionId } - observePetEvent(profile, event, tagged) nsp.to(`session:${sessionId}`).emit(event, tagged) if (!nsp.adapter.rooms.get(`session:${sessionId}`)?.size && socket.connected) { socket.emit(event, tagged) @@ -851,14 +848,6 @@ export async function resumeBridgeRun( } } -function observePetEvent(profile: string, event: string, payload: Record): void { - try { - observeRunChatPetEvent(profile, event, payload) - } catch (err) { - logger.debug(err, '[chat-run-socket] failed to update pet state') - } -} - async function refreshFinalContextUsage(args: { sessionId: string profile: string diff --git a/packages/server/src/services/hermes/run-chat/index.ts b/packages/server/src/services/hermes/run-chat/index.ts index 79e5c77..d7c62d1 100644 --- a/packages/server/src/services/hermes/run-chat/index.ts +++ b/packages/server/src/services/hermes/run-chat/index.ts @@ -24,7 +24,6 @@ import { contentBlocksToString } from './content-blocks' import type { ChatRunSource, ContentBlock, QueuedRun, SessionState } from './types' import { authenticateUserToken, isAuthEnabled, type AuthenticatedUser } from '../../../middleware/user-auth' import { userCanAccessProfile } from '../../../db/hermes/users-store' -import { observeRunChatPetEvent } from '../pet-state-socket' export type { ContentBlock } from './types' @@ -798,8 +797,6 @@ export class ChatRunSocket { emitExternalEvent(sessionId: string, event: string, payload: any) { const tagged = { ...payload, session_id: sessionId } - const profile = this.resolvePetEventProfile(sessionId, tagged) - this.observePetEvent(profile, event, tagged) const state = this.sessionMap.get(sessionId) if (state?.isWorking) { state.events.push({ event, data: tagged }) @@ -932,8 +929,6 @@ export class ChatRunSocket { private emitToSession(socket: Socket, sessionId: string, event: string, payload: any) { const tagged = { ...payload, session_id: sessionId } - const profile = this.resolvePetEventProfile(sessionId, tagged) - this.observePetEvent(profile, event, tagged) this.nsp.to(`session:${sessionId}`).emit(event, tagged) if (!this.nsp.adapter.rooms.get(`session:${sessionId}`)?.size && socket.connected) { socket.emit(event, tagged) @@ -968,21 +963,4 @@ export class ChatRunSocket { this.sessionMap.clear() logger.info('[chat-run-socket] closed all connections and cleared state') } - - private resolvePetEventProfile(sessionId: string, payload: Record): string { - const payloadProfile = typeof payload.profile === 'string' ? payload.profile.trim() : '' - if (payloadProfile) return payloadProfile - const stateProfile = this.sessionMap.get(sessionId)?.profile - if (stateProfile) return stateProfile - const storedProfile = getSession(sessionId)?.profile - return storedProfile || 'default' - } - - private observePetEvent(profile: string, event: string, payload: Record): void { - try { - observeRunChatPetEvent(profile, event, payload) - } catch (err) { - logger.debug(err, '[chat-run-socket] failed to update pet state') - } - } } diff --git a/tests/client/pets-store.test.ts b/tests/client/pets-store.test.ts deleted file mode 100644 index 931fa32..0000000 --- a/tests/client/pets-store.test.ts +++ /dev/null @@ -1,58 +0,0 @@ -// @vitest-environment jsdom -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { createPinia, setActivePinia } from 'pinia' -import type { ActivePet } from '@/api/hermes/pets' - -const mockPetsApi = vi.hoisted(() => ({ - fetchActivePet: vi.fn(), - adoptPet: vi.fn(), - updateActivePetPreferences: vi.fn(), -})) - -vi.mock('@/api/hermes/pets', () => mockPetsApi) - -import { usePetsStore } from '@/stores/hermes/pets' - -function activePet(overrides: Partial = {}): ActivePet { - return { - enabled: true, - slug: 'desk-cat', - displayName: 'Desk Cat', - kind: 'cat', - submittedBy: 'petdex', - source: 'petdex', - mime: 'image/webp', - spritesheetDataUrl: 'data:image/webp;base64,AA==', - spritesheetRevision: 1, - frameW: 192, - frameH: 208, - framesPerState: 6, - loopMs: 1100, - scale: 0.33, - position: { x: 10, y: 20 }, - stateRows: ['idle'], - installedAt: 1, - updatedAt: 1, - ...overrides, - } -} - -describe('Pets Store', () => { - beforeEach(() => { - setActivePinia(createPinia()) - vi.clearAllMocks() - }) - - it('disables and clears the active pet when hiding it', async () => { - const store = usePetsStore() - store.activePet = activePet() - mockPetsApi.updateActivePetPreferences.mockResolvedValue(null) - - await store.hideActivePet() - - expect(mockPetsApi.updateActivePetPreferences).toHaveBeenCalledWith({ enabled: false }) - expect(store.activePet).toBeNull() - expect(store.hasActivePet).toBe(false) - expect(store.saving).toBe(false) - }) -}) diff --git a/tests/e2e/fixtures.ts b/tests/e2e/fixtures.ts index 338048a..1fa49f6 100644 --- a/tests/e2e/fixtures.ts +++ b/tests/e2e/fixtures.ts @@ -222,11 +222,6 @@ export async function mockHermesApi(page: Page, options: MockHermesApiOptions = return } - if (pathname === '/api/hermes/pets/active') { - await route.fulfill(jsonResponse({ pet: null })) - return - } - if (pathname === '/api/hermes/profiles') { await route.fulfill(jsonResponse({ profiles: [ diff --git a/tests/server/petdex-service.test.ts b/tests/server/petdex-service.test.ts deleted file mode 100644 index 1220e31..0000000 --- a/tests/server/petdex-service.test.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' - -import { fetchPetdexAsset, fetchPetdexManifest } from '../../packages/server/src/services/hermes/petdex' - -describe('petdex service', () => { - afterEach(() => { - vi.unstubAllGlobals() - }) - - it('adds local preview proxy URLs to manifest pets', async () => { - vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(JSON.stringify({ - generatedAt: '2026-06-30T00:00:00.000Z', - pets: [{ - slug: 'desk-cat', - displayName: 'Desk Cat', - kind: 'cat', - submittedBy: 'petdex', - spritesheetUrl: 'https://assets.petdex.dev/pets/desk-cat/spritesheet.webp', - }], - }), { - status: 200, - headers: { 'content-type': 'application/json' }, - }))) - - const manifest = await fetchPetdexManifest({ force: true }) - - expect(manifest.pets[0].spritesheetUrl).toBe('https://assets.petdex.dev/pets/desk-cat/spritesheet.webp') - expect(manifest.pets[0].previewUrl).toBe( - '/api/hermes/petdex/asset?url=https%3A%2F%2Fassets.petdex.dev%2Fpets%2Fdesk-cat%2Fspritesheet.webp', - ) - }) - - it('only proxies petdex https assets', async () => { - const fetchMock = vi.fn() - vi.stubGlobal('fetch', fetchMock) - - await expect(fetchPetdexAsset('https://example.com/pet.webp')).rejects.toThrow('Unsupported petdex asset host') - await expect(fetchPetdexAsset('http://assets.petdex.dev/pet.webp')).rejects.toThrow('Unsupported petdex asset host') - expect(fetchMock).not.toHaveBeenCalled() - }) - - it('fetches allowed petdex image assets with a size limit', async () => { - const body = new Uint8Array([1, 2, 3, 4]) - vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(body, { - status: 200, - headers: { - 'content-type': 'image/webp', - 'content-length': String(body.byteLength), - }, - }))) - - const asset = await fetchPetdexAsset('https://assets.petdex.dev/pets/desk-cat/spritesheet.webp') - - expect(asset.mime).toBe('image/webp') - expect([...asset.buffer]).toEqual([1, 2, 3, 4]) - expect(asset.maxAgeSeconds).toBe(3600) - }) -}) diff --git a/tests/server/pets-service.test.ts b/tests/server/pets-service.test.ts deleted file mode 100644 index df5facf..0000000 --- a/tests/server/pets-service.test.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { mkdir, mkdtemp, readFile, rm, writeFile } from 'fs/promises' -import { tmpdir } from 'os' -import { join } from 'path' -import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { getActivePet, updateActivePetPreferences } from '../../packages/server/src/services/hermes/pets' - -const originalWebUiHome = process.env.HERMES_WEB_UI_HOME - -let hermesHome = '' - -function profilePetsDir(profile: string): string { - const segment = Buffer.from(profile || 'default', 'utf-8').toString('base64url') - return join(hermesHome, 'profile-metadata', segment, 'pets') -} - -async function writeInstalledPet(profile: string, slug: string): Promise { - const petsDir = profilePetsDir(profile) - const petDir = join(petsDir, slug) - await mkdir(petDir, { recursive: true }) - await writeFile(join(petDir, 'spritesheet.webp'), Buffer.from([1, 2, 3])) - await writeFile(join(petDir, 'pet.json'), `${JSON.stringify({ - slug, - displayName: 'Desk Cat', - kind: 'cat', - submittedBy: 'petdex', - source: 'petdex', - spritesheetUrl: 'https://assets.petdex.dev/pets/desk-cat/spritesheet.webp', - petJsonUrl: '', - zipUrl: '', - spritesheetFile: 'spritesheet.webp', - mime: 'image/webp', - installedAt: 1, - updatedAt: 1, - }, null, 2)}\n`) - await writeFile(join(petsDir, 'active.json'), `${JSON.stringify({ - enabled: true, - slug, - scale: 0.42, - position: { x: 15, y: 30 }, - updatedAt: 2, - }, null, 2)}\n`) - return join(petsDir, 'active.json') -} - -describe('pets service', () => { - beforeEach(async () => { - hermesHome = await mkdtemp(join(tmpdir(), 'hermes-pets-service-')) - process.env.HERMES_WEB_UI_HOME = hermesHome - }) - - afterEach(async () => { - await rm(hermesHome, { recursive: true, force: true }) - if (originalWebUiHome === undefined) delete process.env.HERMES_WEB_UI_HOME - else process.env.HERMES_WEB_UI_HOME = originalWebUiHome - }) - - it('persists disabled active pet state and stops returning it as active', async () => { - const profile = 'default' - const activePath = await writeInstalledPet(profile, 'desk-cat') - - await expect(getActivePet(profile)).resolves.toMatchObject({ - enabled: true, - slug: 'desk-cat', - scale: 0.42, - }) - - await expect(updateActivePetPreferences(profile, { enabled: false })).resolves.toBeNull() - await expect(getActivePet(profile)).resolves.toBeNull() - - const active = JSON.parse(await readFile(activePath, 'utf-8')) - expect(active).toMatchObject({ - enabled: false, - slug: 'desk-cat', - scale: 0.42, - }) - }) -})