remove the desktop-pet system
Build / build (push) Canceled after 0s
Playwright / e2e (push) Canceled after 0s

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.
This commit is contained in:
2026-10-20 13:24:17 +08:00
committed by trae-solo-agent
parent 5e1003ceb7
commit b709ec6742
30 changed files with 64 additions and 1793 deletions
@@ -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<PetStateSnapshot> {
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)
})
})
}
-22
View File
@@ -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<PetdexManifest> {
return request<PetdexManifest>(`/api/hermes/petdex/manifest${force ? '?force=1' : ''}`)
}
-52
View File
@@ -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<ActivePet | null> {
const res = await request<{ pet: ActivePet | null }>('/api/hermes/pets/active')
return res.pet
}
export async function adoptPet(slug: string): Promise<ActivePet> {
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<ActivePet | null> {
const res = await request<{ pet: ActivePet | null }>('/api/hermes/pets/active', {
method: 'PATCH',
body: JSON.stringify(input),
})
return res.pet
}
@@ -153,15 +153,6 @@ function handleUpdateClick() {
</svg>
<span>{{ t("sidebar.mcp") }}</span>
</RouteLinkItem>
<RouteLinkItem class="nav-item" :to="{ name: 'hermes.petdex' }" :active="selectedKey === 'hermes.petdex'">
<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="M12 3l7 4v6c0 4-3 7-7 8-4-1-7-4-7-8V7l7-4z" />
<path d="M9 11h.01" />
<path d="M15 11h.01" />
<path d="M9.5 15c1.6 1.1 3.4 1.1 5 0" />
</svg>
<span>{{ t("sidebar.petdex") }}</span>
</RouteLinkItem>
<RouteLinkItem class="nav-item" :to="{ name: 'hermes.memory' }" :active="selectedKey === 'hermes.memory'">
<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="M9 18h6" />
-24
View File
@@ -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',
-24
View File
@@ -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: '记忆',
-5
View File
@@ -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',
@@ -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<PetStateSnapshot>(emptySnapshot())
const connected = ref(false)
const error = ref('')
let unsubscribe: (() => void) | null = null
const state = computed<PetState>(() => snapshot.value.state)
const activity = computed<PetActivity>(() => snapshot.value.activity)
async function connect(profile?: string | null): Promise<void> {
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,
}
})
-82
View File
@@ -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<ActivePet | null>(null)
const loading = ref(false)
const saving = ref(false)
const error = ref('')
const hasActivePet = computed(() => !!activePet.value?.enabled)
async function loadActivePet(): Promise<ActivePet | null> {
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<ActivePet> {
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<void> {
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<void> {
await savePreferences({ enabled: false })
}
function clear(): void {
activePet.value = null
error.value = ''
}
return {
activePet,
loading,
saving,
error,
hasActivePet,
loadActivePet,
adopt,
savePreferences,
hideActivePet,
clear,
}
})
@@ -1,339 +0,0 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useMessage } from '@/composables/useMessage'
import { useI18n } from 'vue-i18n'
import { fetchPetdexManifest, type PetdexManifest, type PetdexPet } from '@/api/hermes/petdex'
import { usePetsStore } from '@/stores/hermes/pets'
const { t } = useI18n()
const message = useMessage()
const petsStore = usePetsStore()
const manifest = ref<PetdexManifest | null>(null)
const loading = ref(false)
const adoptingSlug = ref('')
const error = ref('')
const searchQuery = ref('')
const kindFilter = ref<string | undefined>(undefined)
const visibleLimit = ref(96)
const pets = computed(() => manifest.value?.pets ?? [])
const generatedAt = computed(() => {
const value = manifest.value?.generatedAt
if (!value) return ''
const date = new Date(value)
return Number.isNaN(date.getTime()) ? value : date.toLocaleString()
})
const kindOptions = computed(() => {
const counts = new Map<string, number>()
for (const pet of pets.value) {
counts.set(pet.kind, (counts.get(pet.kind) ?? 0) + 1)
}
return Array.from(counts.entries())
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
.map(([kind, count]) => ({
label: `${kind} (${count})`,
value: kind,
}))
})
const filteredPets = computed(() => {
const query = searchQuery.value.trim().toLowerCase()
return pets.value.filter((pet) => {
if (kindFilter.value && pet.kind !== kindFilter.value) return false
if (!query) return true
return [pet.slug, pet.displayName, pet.kind, pet.submittedBy]
.some(value => String(value || '').toLowerCase().includes(query))
})
})
const visiblePets = computed(() => filteredPets.value.slice(0, visibleLimit.value))
const canShowMore = computed(() => visiblePets.value.length < filteredPets.value.length)
async function loadManifest(force = false) {
loading.value = true
error.value = ''
try {
manifest.value = await fetchPetdexManifest(force)
visibleLimit.value = 96
} catch (err: any) {
error.value = err?.message || t('petdex.loadFailed')
} finally {
loading.value = false
}
}
function showMore() {
visibleLimit.value += 96
}
function assetLinks(pet: PetdexPet) {
return [
{ label: t('petdex.spritesheet'), href: pet.spritesheetUrl },
...(pet.petJsonUrl ? [{ label: 'pet.json', href: pet.petJsonUrl }] : []),
...(pet.zipUrl ? [{ label: 'zip', href: pet.zipUrl }] : []),
]
}
async function adopt(pet: PetdexPet) {
adoptingSlug.value = pet.slug
try {
await petsStore.adopt(pet.slug)
message.success(t('petdex.adopted', { name: pet.displayName }))
} catch (err: any) {
message.error(err?.message || t('petdex.adoptFailed'))
} finally {
adoptingSlug.value = ''
}
}
onMounted(() => {
void loadManifest()
void petsStore.loadActivePet()
})
</script>
<template>
<div class="petdex-view">
<header class="page-header">
<h2 class="header-title">{{ t('petdex.title') }}</h2>
<a-button size="small" type="text" :loading="loading" @click="loadManifest(true)">
{{ t('petdex.refresh') }}
</a-button>
</header>
<div class="petdex-content cy-view" :class="{ 'is-loading': loading && !manifest }">
<div v-if="loading && !manifest" class="loading-state">
<a-spin />
</div>
<template v-else>
<a-alert v-if="error" type="error" class="notice">
{{ error }}
</a-alert>
<div v-if="manifest" class="cy-kpis petdex-kpis">
<div class="cy-kpi acc">
<div class="k">{{ t('petdex.summary.total') }}</div>
<div class="v">{{ manifest.total }}</div>
</div>
<div class="cy-kpi ok">
<div class="k">{{ t('petdex.summary.visible') }}</div>
<div class="v">{{ filteredPets.length }}</div>
</div>
<div class="cy-kpi info">
<div class="k">{{ t('petdex.summary.kinds') }}</div>
<div class="v">{{ kindOptions.length }}</div>
</div>
<div class="cy-kpi flat">
<div class="k">{{ t('petdex.summary.generatedAt') }}</div>
<div class="v kpi-date">{{ generatedAt || '-' }}</div>
</div>
</div>
<div class="filter-row">
<a-input v-model="searchQuery" :placeholder="t('petdex.searchPlaceholder')" allow-clear />
<a-select v-model="kindFilter" :options="kindOptions" :placeholder="t('petdex.kindFilter')" allow-clear />
</div>
<div v-if="visiblePets.length" class="cy-grid4 pet-grid">
<article v-for="pet in visiblePets" :key="pet.slug" class="pet-card cy-panel">
<div class="cy-panel-h">
<h3 class="pet-name">{{ pet.displayName }}</h3>
<span class="cy-tag">{{ pet.kind }}</span>
<div class="cy-panel-actions">
<span v-if="petsStore.activePet?.slug === pet.slug" class="cy-tag ok">
{{ t('petdex.active') }}
</span>
</div>
</div>
<div class="pet-preview">
<div class="pet-frame" :style="{ backgroundImage: `url(${pet.previewUrl || pet.spritesheetUrl})` }" />
</div>
<div class="pet-body">
<div class="pet-slug cy-code">{{ pet.slug }}</div>
<div v-if="pet.submittedBy" class="pet-meta">
{{ t('petdex.submittedBy', { name: pet.submittedBy }) }}
</div>
<div class="pet-links">
<a v-for="link in assetLinks(pet)" :key="link.href" :href="link.href" target="_blank" rel="noopener noreferrer">
{{ link.label }}
</a>
</div>
<div class="pet-actions">
<a-button
size="small"
:type="petsStore.activePet?.slug === pet.slug ? 'secondary' : 'primary'"
long
:loading="adoptingSlug === pet.slug"
:disabled="!!adoptingSlug"
@click="adopt(pet)"
>
{{ petsStore.activePet?.slug === pet.slug ? t('petdex.active') : t('petdex.adopt') }}
</a-button>
</div>
</div>
</article>
</div>
<a-empty v-else :description="t('petdex.empty')" />
<div v-if="canShowMore" class="load-more">
<a-button :disabled="loading" @click="showMore">
{{ t('petdex.showMore', { count: Math.min(96, filteredPets.length - visiblePets.length) }) }}
</a-button>
</div>
</template>
</div>
</div>
</template>
<style scoped lang="scss">
@use '@/styles/variables' as *;
.petdex-view {
height: 100%;
display: flex;
flex-direction: column;
background: var(--bg-primary);
color: var(--text-primary);
}
.petdex-content {
min-height: 0;
&.is-loading {
display: grid;
place-items: center;
}
}
.loading-state {
min-height: 320px;
display: flex;
align-items: center;
justify-content: center;
}
.notice {
margin-bottom: 16px;
}
.petdex-kpis {
margin-bottom: 16px;
}
.kpi-date {
font-size: 13px;
color: $text-secondary;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.filter-row {
display: grid;
grid-template-columns: minmax(240px, 1fr) 220px;
gap: 12px;
margin-bottom: 18px;
}
.pet-grid {
gap: 14px;
}
.pet-card {
min-width: 0;
overflow: hidden;
// 网格内不吃 .cy-panel 的纵向堆叠间距。
& + .pet-card {
margin-top: 0;
}
}
.pet-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.pet-preview {
height: 132px;
display: flex;
align-items: center;
justify-content: center;
border-bottom: 1px solid $border-light;
// 透明底纹棋盘格:用 muted 三元组上色,跟着主题走而不是写死中灰
background:
linear-gradient(45deg, rgba(var(--text-muted-rgb), 0.08) 25%, transparent 25%),
linear-gradient(-45deg, rgba(var(--text-muted-rgb), 0.08) 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, rgba(var(--text-muted-rgb), 0.08) 75%),
linear-gradient(-45deg, transparent 75%, rgba(var(--text-muted-rgb), 0.08) 75%);
background-position: 0 0, 0 8px, 8px -8px, -8px 0;
background-size: 16px 16px;
}
.pet-frame {
width: 92px;
aspect-ratio: 192 / 208;
background-repeat: no-repeat;
background-position: 0 0;
background-size: 800% auto;
image-rendering: auto;
filter: drop-shadow($shadow-md);
}
.pet-body {
padding: 12px 14px 14px;
}
.pet-slug,
.pet-meta {
margin-top: 5px;
color: $text-muted;
font-size: 11.5px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.pet-slug {
display: inline-block;
max-width: 100%;
}
.pet-links {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 10px;
a {
color: $accent-primary;
font-size: 12px;
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
}
.pet-actions {
margin-top: 12px;
}
.load-more {
display: flex;
justify-content: center;
padding: 24px 0 4px;
}
@media (max-width: 720px) {
.filter-row {
grid-template-columns: 1fr;
}
}
</style>