Browse Source

feat: 初始化夭夭 Web 工作台

samien 2 months ago
commit
8b62378932
100 changed files with 18793 additions and 0 deletions
  1. 10 0
      .env.example
  2. 15 0
      .gitignore
  3. 58 0
      LICENSE
  4. 41 0
      README.md
  5. 175 0
      bin/hermes-yaoyao.mjs
  6. 129 0
      design-qa.md
  7. 17 0
      index.html
  8. 7580 0
      package-lock.json
  9. 76 0
      package.json
  10. 30 0
      playwright.config.ts
  11. BIN
      public/brand/AppIcon-1024.png
  12. BIN
      public/icons/apple-touch-icon.png
  13. BIN
      public/icons/favicon-32.png
  14. BIN
      public/icons/icon-192.png
  15. BIN
      public/icons/icon-512.png
  16. BIN
      public/icons/logo-64.png
  17. 21 0
      public/manifest.webmanifest
  18. 13 0
      scripts/verify-brand.mjs
  19. 65 0
      scripts/verify-lifecycle.mjs
  20. 24 0
      src/client/App.vue
  21. 59 0
      src/client/api/auth.ts
  22. 146 0
      src/client/api/client.ts
  23. 40 0
      src/client/api/files.ts
  24. 132 0
      src/client/api/groups.ts
  25. 7 0
      src/client/api/index.ts
  26. 37 0
      src/client/api/profiles.ts
  27. 321 0
      src/client/api/realtime.ts
  28. 161 0
      src/client/api/sessions.ts
  29. 19 0
      src/client/components/app/LoadingScreen.vue
  30. 188 0
      src/client/components/app/ResourceSidebar.vue
  31. 583 0
      src/client/components/app/WorkspaceShell.vue
  32. 12 0
      src/client/components/app/types.ts
  33. 116 0
      src/client/components/common/AppIcon.vue
  34. 54 0
      src/client/components/common/BrandMark.vue
  35. 33 0
      src/client/components/common/ChoiceDialog.vue
  36. 29 0
      src/client/components/common/EmptyState.vue
  37. 6 0
      src/client/components/common/types.ts
  38. 624 0
      src/client/components/composer/ComposerShell.vue
  39. 28 0
      src/client/components/composer/types.ts
  40. 67 0
      src/client/components/groups/CreateGroupDialog.vue
  41. 108 0
      src/client/components/groups/GroupManager.vue
  42. 19 0
      src/client/components/groups/types.ts
  43. 94 0
      src/client/components/library/LibraryGrid.vue
  44. 39 0
      src/client/components/library/LibrarySidebar.vue
  45. 137 0
      src/client/components/library/PreviewInspector.vue
  46. 33 0
      src/client/components/library/PreviewModal.vue
  47. 26 0
      src/client/components/library/types.ts
  48. 39 0
      src/client/components/messages/InteractionCard.vue
  49. 93 0
      src/client/components/messages/MarkdownContent.vue
  50. 243 0
      src/client/components/messages/MessageTimeline.vue
  51. 39 0
      src/client/components/messages/ToolTrace.vue
  52. 39 0
      src/client/components/messages/types.ts
  53. 47 0
      src/client/components/workspace/WorkspaceView.vue
  54. 29 0
      src/client/components/workspace/pendingComposer.ts
  55. 217 0
      src/client/components/workspace/viewModels.ts
  56. 10 0
      src/client/env.d.ts
  57. 15 0
      src/client/main.ts
  58. 16 0
      src/client/router/index.ts
  59. 17 0
      src/client/stores/app.ts
  60. 206 0
      src/client/stores/artifacts.ts
  61. 99 0
      src/client/stores/auth.ts
  62. 707 0
      src/client/stores/chat.ts
  63. 72 0
      src/client/stores/files.ts
  64. 430 0
      src/client/stores/groups.ts
  65. 7 0
      src/client/stores/index.ts
  66. 25 0
      src/client/stores/theme.ts
  67. 135 0
      src/client/styles/global.css
  68. 69 0
      src/client/styles/tokens.css
  69. 119 0
      src/client/utils/artifacts.ts
  70. 37 0
      src/client/utils/attachments.ts
  71. 43 0
      src/client/utils/cache.ts
  72. 143 0
      src/client/utils/groupProtocol.ts
  73. 24 0
      src/client/utils/id.ts
  74. 232 0
      src/client/utils/messageReducer.ts
  75. 334 0
      src/client/utils/normalize.ts
  76. 103 0
      src/client/views/ArtifactsView.vue
  77. 294 0
      src/client/views/ChatView.vue
  78. 115 0
      src/client/views/FilesView.vue
  79. 212 0
      src/client/views/GroupsView.vue
  80. 70 0
      src/client/views/LoginView.vue
  81. 164 0
      src/server/app.ts
  82. 117 0
      src/server/config.ts
  83. 13 0
      src/server/errors.ts
  84. 83 0
      src/server/index.ts
  85. 102 0
      src/server/leases.ts
  86. 624 0
      src/server/routes.ts
  87. 170 0
      src/server/security.ts
  88. 316 0
      src/server/uploads.ts
  89. 222 0
      src/server/upstream.ts
  90. 482 0
      src/server/websocket.ts
  91. 1 0
      src/shared/index.ts
  92. 426 0
      src/shared/types.ts
  93. 30 0
      tests/brand.test.ts
  94. 50 0
      tests/client/artifactStore.test.ts
  95. 64 0
      tests/client/authProtocol.test.ts
  96. 95 0
      tests/client/chatProtocol.test.ts
  97. 85 0
      tests/client/chatStore.test.ts
  98. 17 0
      tests/client/composer.test.ts
  99. 25 0
      tests/client/filesProtocol.test.ts
  100. 55 0
      tests/client/groupProtocol.test.ts

+ 10 - 0
.env.example

@@ -0,0 +1,10 @@
+HERMES_YAOYAO_HOST=127.0.0.1
+HERMES_YAOYAO_PORT=8800
+HERMES_YAOYAO_UPSTREAM=http://127.0.0.1:9119
+# Comma-separated additional DNS names. Localhost and active private IP literals are allowed automatically.
+HERMES_YAOYAO_ALLOWED_HOSTS=
+# For LAN access set HOST=0.0.0.0 and either configure TLS or explicitly enable trusted-LAN HTTP below.
+HERMES_YAOYAO_ALLOW_INSECURE_LAN=0
+# HERMES_YAOYAO_TLS_CERT=/absolute/path/to/cert.pem
+# HERMES_YAOYAO_TLS_KEY=/absolute/path/to/key.pem
+# HERMES_YAOYAO_HOME=/Users/you/.hermes-yaoyao

+ 15 - 0
.gitignore

@@ -0,0 +1,15 @@
+node_modules/
+dist/
+dist-server/
+coverage/
+playwright-report/
+test-results/
+.env
+.env.*
+!.env.example
+.DS_Store
+*.log
+*.pem
+*.key
+.hermes-yaoyao/
+.codegraph/

+ 58 - 0
LICENSE

@@ -0,0 +1,58 @@
+Business Source License 1.1
+
+Parameters
+
+Licensor:             EKKOLearnAI
+Licensed Work:        Hermes Studio, formerly known as Hermes Web UI,
+                      including the hermes-web-ui npm package and CLI,
+                      Hermes Studio desktop applications, web applications,
+                      server components, runtime integrations, firmware,
+                      release artifacts, documentation, and all associated
+                      files in this repository.
+Additional Use Grant: You may use the Licensed Work for non-commercial purposes,
+                      including personal use, education, and research.
+                      Commercial use (including but not limited to selling,
+                      licensing, SaaS hosting, or embedding in a commercial
+                      product) requires a separate commercial license from the
+                      Licensor.
+
+Change Date:          2029-05-10
+
+Change License:       Apache License 2.0
+
+Notice
+
+The Licensed Work is provided under the Business Source License 1.1 (BSL 1.1).
+You may not use the Licensed Work except in compliance with the License.
+
+You may obtain a copy of the License at
+https://mariadb.com/bsl11/
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+
+---
+
+For the purposes of this License:
+
+- "Licensed Work" refers to Hermes Studio and all source code,
+  documentation, firmware, desktop applications, web applications, server
+  components, runtime integrations, release artifacts, packages, command-line
+  tools, and associated files in this repository. This includes materials
+  distributed or identified under the current Hermes Studio name, the former
+  Hermes Web UI name, the hermes-web-ui npm package name, and the
+  hermes-studio desktop application name.
+
+- "Non-commercial use" means use that is not primarily intended for or
+  directed toward commercial advantage or monetary compensation.
+
+- The Licensor (EKKOLearnAI) and its authorized representatives are not
+  subject to the Additional Use Grant restrictions and may use the Licensed
+  Work for any purpose, including commercial purposes, without limitation.
+
+On the Change Date (2029-05-10), this License will automatically convert to
+the Apache License 2.0, at which point all users may use the Licensed Work
+under the terms of the Apache License 2.0.

+ 41 - 0
README.md

@@ -0,0 +1,41 @@
+# 夭夭 Web
+
+夭夭 Web 是一个独立的本地 Web 工作台,通过端口 `8800` 连接现有 Hermes Dashboard/Gateway `9119`,提供普通聊天、群聊、文件库和产物浏览。它不会启动、停止或监督 Hermes。
+
+## 开发
+
+```bash
+npm install
+cp .env.example .env
+npm run dev
+```
+
+默认上游是 `http://127.0.0.1:9119`,服务默认只监听本机。局域网 HTTP 只适用于可信网络,必须同时设置:
+
+```bash
+HERMES_YAOYAO_HOST=0.0.0.0
+HERMES_YAOYAO_ALLOW_INSECURE_LAN=1
+```
+
+生产局域网使用建议同时配置 `HERMES_YAOYAO_TLS_CERT` 和 `HERMES_YAOYAO_TLS_KEY`。
+
+## 构建与运行
+
+```bash
+npm run build
+npm start
+```
+
+服务管理命令:
+
+```bash
+node bin/hermes-yaoyao.mjs service install
+node bin/hermes-yaoyao.mjs service start
+node bin/hermes-yaoyao.mjs service status
+node bin/hermes-yaoyao.mjs service stop
+node bin/hermes-yaoyao.mjs service uninstall
+```
+
+执行 `npm link` 后也可以直接使用 `hermes-yaoyao` 命令。
+
+运行数据默认位于 `~/.hermes-yaoyao`。卸载 LaunchAgent 不会删除运行数据。

+ 175 - 0
bin/hermes-yaoyao.mjs

@@ -0,0 +1,175 @@
+#!/usr/bin/env node
+
+import { execFileSync } from 'node:child_process'
+import { existsSync } from 'node:fs'
+import { chmod, mkdir, readdir, stat, unlink, writeFile } from 'node:fs/promises'
+import { homedir } from 'node:os'
+import { dirname, join, resolve } from 'node:path'
+import { fileURLToPath } from 'node:url'
+
+const label = 'com.samien.hermes-yaoyao'
+const projectRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..')
+const uid = process.getuid?.() ?? 501
+const domain = `gui/${uid}`
+const launchAgents = join(homedir(), 'Library', 'LaunchAgents')
+const plistPath = join(launchAgents, `${label}.plist`)
+const dataHome = process.env.HERMES_YAOYAO_HOME || join(homedir(), '.hermes-yaoyao')
+const logDir = join(homedir(), 'Library', 'Logs')
+const serverEntry = join(projectRoot, 'dist-server', 'server', 'index.js')
+
+function run(command, args, options = {}) {
+  return execFileSync(command, args, { encoding: 'utf8', stdio: options.inherit ? 'inherit' : 'pipe' })
+}
+
+function escapeXml(value) {
+  return String(value)
+    .replaceAll('&', '&')
+    .replaceAll('<', '&lt;')
+    .replaceAll('>', '&gt;')
+    .replaceAll('"', '&quot;')
+    .replaceAll("'", '&apos;')
+}
+
+function environment() {
+  const allowed = [
+    'HERMES_YAOYAO_HOST',
+    'HERMES_YAOYAO_PORT',
+    'HERMES_YAOYAO_UPSTREAM',
+    'HERMES_YAOYAO_ALLOWED_HOSTS',
+    'HERMES_YAOYAO_HOME',
+    'HERMES_YAOYAO_TLS_CERT',
+    'HERMES_YAOYAO_TLS_KEY',
+    'HERMES_YAOYAO_ALLOW_INSECURE_LAN',
+  ]
+  return Object.fromEntries(allowed.flatMap(key => process.env[key] ? [[key, process.env[key]]] : []))
+}
+
+function plist() {
+  const node = process.execPath
+  const envEntries = {
+    PATH: `${dirname(node)}:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin`,
+    NODE_ENV: 'production',
+    HERMES_YAOYAO_HOME: dataHome,
+    HERMES_YAOYAO_HOST: '127.0.0.1',
+    HERMES_YAOYAO_PORT: '8800',
+    HERMES_YAOYAO_UPSTREAM: 'http://127.0.0.1:9119',
+    ...environment(),
+  }
+  const envXml = Object.entries(envEntries).map(([key, value]) => (
+    `      <key>${escapeXml(key)}</key>\n      <string>${escapeXml(value)}</string>`
+  )).join('\n')
+  const logPath = join(logDir, 'hermes-yaoyao.log')
+  return `<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+  <dict>
+    <key>Label</key><string>${label}</string>
+    <key>ProgramArguments</key>
+    <array><string>${escapeXml(node)}</string><string>${escapeXml(serverEntry)}</string></array>
+    <key>WorkingDirectory</key><string>${escapeXml(projectRoot)}</string>
+    <key>EnvironmentVariables</key>
+    <dict>
+${envXml}
+    </dict>
+    <key>RunAtLoad</key><true/>
+    <key>KeepAlive</key><true/>
+    <key>ThrottleInterval</key><integer>10</integer>
+    <key>ProcessType</key><string>Interactive</string>
+    <key>Umask</key><integer>63</integer>
+    <key>StandardOutPath</key><string>${escapeXml(logPath)}</string>
+    <key>StandardErrorPath</key><string>${escapeXml(logPath)}</string>
+  </dict>
+</plist>
+`
+}
+
+function loaded() {
+  try {
+    run('launchctl', ['print', `${domain}/${label}`])
+    return true
+  } catch {
+    return false
+  }
+}
+
+async function install() {
+  if (!existsSync(serverEntry)) {
+    throw new Error('未找到生产构建,请先运行 npm run build')
+  }
+  await mkdir(launchAgents, { recursive: true })
+  await mkdir(dataHome, { recursive: true, mode: 0o700 })
+  await mkdir(logDir, { recursive: true })
+  if (loaded()) run('launchctl', ['bootout', `${domain}/${label}`])
+  await writeFile(plistPath, plist(), { mode: 0o600 })
+  await chmod(plistPath, 0o600)
+  run('plutil', ['-lint', plistPath], { inherit: true })
+  run('launchctl', ['bootstrap', domain, plistPath])
+  process.stdout.write(`已安装并启动 ${label}\n`)
+}
+
+function start() {
+  if (!existsSync(plistPath)) throw new Error('服务尚未安装')
+  if (!loaded()) run('launchctl', ['bootstrap', domain, plistPath])
+  else run('launchctl', ['kickstart', '-k', `${domain}/${label}`])
+  process.stdout.write(`已启动 ${label}\n`)
+}
+
+function stop() {
+  if (loaded()) run('launchctl', ['bootout', `${domain}/${label}`])
+  process.stdout.write(`已停止 ${label};Hermes 9119 未被操作\n`)
+}
+
+async function uninstall() {
+  stop()
+  if (existsSync(plistPath)) await unlink(plistPath)
+  process.stdout.write(`已卸载 ${label};运行数据保留在 ${dataHome}\n`)
+}
+
+function status() {
+  if (!loaded()) {
+    process.stdout.write(`${label}: stopped\n`)
+    process.exitCode = 3
+    return
+  }
+  process.stdout.write(run('launchctl', ['print', `${domain}/${label}`]))
+}
+
+async function pruneUploads(args) {
+  const index = args.indexOf('--older-than')
+  const days = index >= 0 ? Number(args[index + 1]) : 30
+  if (!Number.isFinite(days) || days < 1) throw new Error('--older-than 必须是大于 0 的天数')
+  if (!args.includes('--yes')) {
+    throw new Error('这是显式清理操作,请增加 --yes')
+  }
+  const root = join(dataHome, 'uploads', 'pending')
+  if (!existsSync(root)) return
+  const cutoff = Date.now() - days * 86_400_000
+  let removed = 0
+  for (const entry of await readdir(root)) {
+    const target = join(root, entry)
+    const info = await stat(target)
+    if (info.isFile() && info.mtimeMs < cutoff) {
+      await unlink(target)
+      removed += 1
+    }
+  }
+  process.stdout.write(`已清理 ${removed} 个未提交上传文件\n`)
+}
+
+async function main() {
+  const [, , group, command, ...args] = process.argv
+  if (group === 'service') {
+    if (command === 'install') return install()
+    if (command === 'start') return start()
+    if (command === 'stop') return stop()
+    if (command === 'status') return status()
+    if (command === 'uninstall') return uninstall()
+  }
+  if (group === 'uploads' && command === 'prune') return pruneUploads(args)
+  process.stdout.write(`夭夭 Web\n\n用法:\n  hermes-yaoyao service install|start|stop|status|uninstall\n  hermes-yaoyao uploads prune --older-than 30 --yes\n`)
+}
+
+main().catch(error => {
+  process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`)
+  process.exitCode = 1
+})

+ 129 - 0
design-qa.md

@@ -0,0 +1,129 @@
+# 侧栏 Design QA
+
+## 对照目标
+
+- Source visual truth: `/tmp/hermes-yaoyao-sidebar-qa/grok-source-desktop.png`
+- Final implementation: `/tmp/hermes-yaoyao-sidebar-qa/implementation-final-1237x788.png`
+- Search state: `/tmp/hermes-yaoyao-sidebar-qa/implementation-final-search-1237x788.png`
+- Collapsed state: `/tmp/hermes-yaoyao-sidebar-qa/implementation-final-collapsed-1237x788.png`
+- Mobile drawer: `/tmp/hermes-yaoyao-sidebar-qa/implementation-final-mobile-390x844.png`
+- Full-view comparison: `/tmp/hermes-yaoyao-sidebar-qa/comparison-full.png`
+- Focused sidebar comparison: `/tmp/hermes-yaoyao-sidebar-qa/comparison-sidebar.png`
+
+## 归一化
+
+- Desktop source and implementation are both 1237 × 788 pixels, CSS viewport 1237 × 788, device scale factor 1.
+- Focused comparison crops the same left 264 × 788 region from both images and appends them side by side without scaling.
+- Mobile implementation is 390 × 844 pixels at CSS viewport 390 × 844 and device scale factor 1. The reference only defines the desktop information hierarchy, so mobile is evaluated as a responsive continuation rather than a pixel clone.
+- State: light theme, authenticated fake-9119 account, chat workspace, two history sessions.
+
+## Full-view comparison
+
+The implementation now keeps the same dominant composition as the source: a restrained light single-column sidebar separated from a large canvas, with the composer remaining the primary main-canvas interaction. Main-canvas copy and controls intentionally follow Hermes YaoYao requirements rather than cloning Grok product content.
+
+## Focused sidebar comparison
+
+- Information order matches: brand/collapse → search → new chat → feature entries → history → account area.
+- Search and new-chat rows use the same 40px full-width rhythm and low-contrast active fill.
+- History is an unboxed, compact list with pinned treatment and date buckets.
+- The footer stays pinned to the bottom. The extra Agent selector is intentional because multi-Profile switching is a product requirement.
+
+## Required fidelity surfaces
+
+- Fonts and typography: system sans-serif, 12–13px navigation hierarchy and compact metadata closely match the source density. Product name and secondary previews are intentional Hermes additions.
+- Spacing and layout rhythm: expanded width 264px, top row 58px, search/new-chat 40px, 10px horizontal inset; search input occupies the exact trigger rectangle at x=10, y=58, width=243, height=40.
+- Colors and tokens: neutral white/gray surfaces, black primary type, quiet dividers and one low-contrast selected row match the source. Existing theme tokens retain dark-theme support.
+- Image quality and assets: the canonical mobile AppIcon source is used at native aspect ratio; no substitute logo or CSS drawing is present.
+- Copy and content: “搜索 / 新建聊天 / 历史记录” match the requested structure. Hermes-only entries remain “群聊 / 文件库 / 产物”.
+
+## Interaction and responsive evidence
+
+- Search opens in place, focuses the input, and Escape clears/restores the trigger.
+- Collapsed width is 68px; search and new-chat remain available as named icon buttons, and collapsed search expands the sidebar before focusing.
+- Collapse state persists across reload.
+- The 390px drawer is 304px wide, reports `aria-hidden=false` while open, and makes the main canvas inert.
+- Browser console error/warning collection was empty for the final desktop/search/collapse/mobile flow.
+
+## Comparison history
+
+### Pass 1 — blocked
+
+- P1: search opened a second input inside the history body instead of replacing the top search row.
+- P1: collapsed mode removed search and new-chat actions.
+- P2: history lacked today/yesterday/earlier scan grouping.
+
+Fixes: positioned the live search input in the top row, synchronized close state across desktop/mobile copies, retained icon-only primary actions in collapsed mode, auto-expanded collapsed search, and added timestamp-derived history buckets.
+
+### Pass 2 — passed
+
+Post-fix screenshots confirm the search trigger and input share the exact rectangle, collapsed actions remain usable, and the history bucket appears without disturbing the fixed footer. No actionable P0/P1/P2 mismatch remains.
+
+## Follow-up polish
+
+- P3: session rows retain a secondary preview line for faster Hermes history scanning.
+
+## Account and collapse annotation pass
+
+- Source visual truth: current browser annotations supplied with the user request, targeting the duplicate Agent/account rows, desktop wordmark, and collapsed control placement.
+- Final expanded implementation: `/tmp/hermes-yaoyao-sidebar-qa/account-merged-expanded.png`
+- Final collapsed implementation: `/tmp/hermes-yaoyao-sidebar-qa/account-merged-collapsed.png`
+
+Findings resolved:
+
+- The Agent selector and account row are now one account switcher: avatar, username, active Agent, Agent menu, theme control, and logout menu live in the same footer surface.
+- Desktop rail uses only the mobile AppIcon; the `夭夭 Web` wordmark is no longer rendered there.
+- In the 68px collapsed rail, the collapse/expand control moves to the bottom directly above the signed-in account. The theme action hides in this compact state and remains available when expanded or on mobile.
+
+No new actionable P0/P1/P2 issue was observed in the expanded or collapsed browser-rendered states.
+
+## New-chat empty-state annotation pass
+
+- Source visual truth: current user-supplied mobile browser annotation requesting a large gray product logo and the copy “聊点什么”.
+- Final browser-rendered implementation: `/tmp/hermes-yaoyao-sidebar-qa/new-chat-empty.png`
+
+The ordinary chat empty state now renders the canonical mobile AppIcon at 150px mobile / 180px desktop with muted gray opacity and a single “聊点什么” prompt. Existing conversation timelines and the group-chat empty state retain their separate message-oriented presentation.
+
+## Transport-warning annotation pass
+
+The trusted-LAN plaintext banner is no longer rendered. The explicit server-side configuration gate for non-loopback HTTP remains unchanged; only the persistent canvas warning was removed as requested.
+
+## Composer dashboard-alignment pass
+
+- Source visual truth: user-supplied Dashboard composer annotations showing the reasoning slider popover, settings dropdown, and context-usage row.
+- Final reasoning implementation: `/tmp/hermes-yaoyao-sidebar-qa/composer-reasoning-popover.png`
+- Final settings implementation: `/tmp/hermes-yaoyao-sidebar-qa/composer-settings-popover.png`
+
+The ordinary-chat composer now exposes an anchored reasoning slider with the same default-to-maximum range and rainbow rail, an anchored settings popover for tool-trace visibility, and a context row showing used, maximum, remaining tokens, and progress. The initial fallback is `0 / 256.0k · 剩余 256.0k`; live 9119 usage replaces it when available.
+
+## Settings-menu annotation pass
+
+- Source visual truth: Dashboard settings dropdown annotation with “显示工具轨迹” and “语音输入设置”.
+- Final browser-rendered implementation: `/tmp/hermes-yaoyao-sidebar-qa/composer-settings-voice-row.png`
+
+The settings popover now matches the two-row structure. Tool trace is functional. Voice input remains outside the current release scope, and choosing its settings row explicitly reports that it is not yet enabled rather than exposing a deceptive microphone control.
+
+## Library preview modal pass
+
+File-library and artifact-grid selection now opens a centered modal rather than a persistent right Inspector. The shared preview surface handles images, video, audio, PDFs, Office documents, spreadsheets, text/code, links, and generic downloadable files. A real current-profile text file was opened successfully in the browser-rendered modal; the focused E2E path opens and closes a PDF modal without console errors.
+
+## Contextual navigation pass
+
+The feature strip now hides the current workspace and the artifact shortcut. In a group room it presents “对话 / 文件库”, preventing a redundant “群聊” destination while retaining direct artifact routes and source links.
+
+## Transparent conversation-header pass
+
+The normal-chat timeline header now renders as a transparent overlay with no horizontal divider or title band. An active stored session exposes a single top-right ellipsis action that opens only rename and delete controls; group-room headers retain their own management controls.
+
+## Product-scope navigation pass
+
+The chat workspace now shows only “群聊 / 文件库” as alternative destinations. The current “对话” item is omitted, and the artifact route redirects to chat with no visible product entry.
+
+## Agent-name source pass
+
+Bootstrap now enriches Hermes profiles from the YaoYao plugin `/profiles` settings endpoint. Session rows use the configured `agentName` at the right edge, falling back only when the plugin does not provide a name. A current browser run verified the plugin-configured name “竹儿” on session entries.
+
+## Pinned-session pass
+
+Pinned sessions are now rendered as their own “已置顶 N” section, with the existing pin glyph retained on each session row and the configured Agent name at the right edge. Server order remains authoritative; the client only supplies the visual grouping.
+
+final result: passed

+ 17 - 0
index.html

@@ -0,0 +1,17 @@
+<!doctype html>
+<html lang="zh-CN">
+  <head>
+    <meta charset="UTF-8" />
+    <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
+    <meta name="theme-color" content="#fafafa" />
+    <meta name="description" content="夭夭 Web:Hermes 聊天、群聊、文件库与产物工作台" />
+    <link rel="icon" type="image/png" sizes="32x32" href="/icons/favicon-32.png" />
+    <link rel="apple-touch-icon" sizes="180x180" href="/icons/apple-touch-icon.png" />
+    <link rel="manifest" href="/manifest.webmanifest" />
+    <title>夭夭 Web</title>
+  </head>
+  <body>
+    <div id="app"></div>
+    <script type="module" src="/src/client/main.ts"></script>
+  </body>
+</html>

+ 7580 - 0
package-lock.json

@@ -0,0 +1,7580 @@
+{
+  "name": "hermes-yaoyao",
+  "version": "0.1.0",
+  "lockfileVersion": 3,
+  "requires": true,
+  "packages": {
+    "": {
+      "name": "hermes-yaoyao",
+      "version": "0.1.0",
+      "license": "BSL-1.1",
+      "dependencies": {
+        "@koa/bodyparser": "^5.0.0",
+        "@koa/router": "^15.4.0",
+        "@vicons/tabler": "^0.13.0",
+        "busboy": "^1.6.0",
+        "cookie": "^1.0.2",
+        "docx-preview": "^0.3.7",
+        "dompurify": "^3.2.6",
+        "highlight.js": "^11.11.1",
+        "idb": "^8.0.3",
+        "koa": "^2.15.3",
+        "koa-send": "^5.0.1",
+        "koa-static": "^5.0.0",
+        "markdown-it": "^14.1.0",
+        "mime-types": "^3.0.1",
+        "naive-ui": "^2.44.1",
+        "pdfjs-dist": "^5.7.284",
+        "pinia": "^3.0.4",
+        "read-excel-file": "^7.0.3",
+        "vue": "^3.5.32",
+        "vue-router": "^4.6.4",
+        "ws": "^8.20.0",
+        "zod": "^4.1.12"
+      },
+      "bin": {
+        "hermes-yaoyao": "bin/hermes-yaoyao.mjs"
+      },
+      "devDependencies": {
+        "@playwright/test": "^1.60.0",
+        "@types/busboy": "^1.5.4",
+        "@types/jsdom": "^21.1.7",
+        "@types/koa": "^2.15.0",
+        "@types/koa-send": "^4.1.6",
+        "@types/koa-static": "^4.0.4",
+        "@types/markdown-it": "^14.1.2",
+        "@types/mime-types": "^2.1.4",
+        "@types/node": "^24.10.0",
+        "@types/supertest": "^6.0.3",
+        "@types/ws": "^8.18.1",
+        "@vitejs/plugin-vue": "^6.0.5",
+        "@vitest/coverage-v8": "^3.2.4",
+        "@vue/test-utils": "^2.4.6",
+        "@vue/tsconfig": "^0.9.1",
+        "cross-env": "^10.1.0",
+        "jsdom": "^27.0.1",
+        "sass": "^1.99.0",
+        "supertest": "^7.1.4",
+        "tsx": "^4.21.0",
+        "typescript": "~6.0.2",
+        "vite": "^8.0.4",
+        "vitest": "^3.2.4",
+        "vue-tsc": "^3.2.8"
+      },
+      "engines": {
+        "node": ">=24.0.0"
+      }
+    },
+    "node_modules/@acemir/cssom": {
+      "version": "0.9.31",
+      "resolved": "https://registry.npmmirror.com/@acemir/cssom/-/cssom-0.9.31.tgz",
+      "integrity": "sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@ampproject/remapping": {
+      "version": "2.3.0",
+      "resolved": "https://registry.npmmirror.com/@ampproject/remapping/-/remapping-2.3.0.tgz",
+      "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "dependencies": {
+        "@jridgewell/gen-mapping": "^0.3.5",
+        "@jridgewell/trace-mapping": "^0.3.24"
+      },
+      "engines": {
+        "node": ">=6.0.0"
+      }
+    },
+    "node_modules/@asamuzakjp/css-color": {
+      "version": "4.1.2",
+      "resolved": "https://registry.npmmirror.com/@asamuzakjp/css-color/-/css-color-4.1.2.tgz",
+      "integrity": "sha512-NfBUvBaYgKIuq6E/RBLY1m0IohzNHAYyaJGuTK79Z23uNwmz2jl1mPsC5ZxCCxylinKhT1Amn5oNTlx1wN8cQg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@csstools/css-calc": "^3.0.0",
+        "@csstools/css-color-parser": "^4.0.1",
+        "@csstools/css-parser-algorithms": "^4.0.0",
+        "@csstools/css-tokenizer": "^4.0.0",
+        "lru-cache": "^11.2.5"
+      }
+    },
+    "node_modules/@asamuzakjp/dom-selector": {
+      "version": "6.8.1",
+      "resolved": "https://registry.npmmirror.com/@asamuzakjp/dom-selector/-/dom-selector-6.8.1.tgz",
+      "integrity": "sha512-MvRz1nCqW0fsy8Qz4dnLIvhOlMzqDVBabZx6lH+YywFDdjXhMY37SmpV1XFX3JzG5GWHn63j6HX6QPr3lZXHvQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@asamuzakjp/nwsapi": "^2.3.9",
+        "bidi-js": "^1.0.3",
+        "css-tree": "^3.1.0",
+        "is-potential-custom-element-name": "^1.0.1",
+        "lru-cache": "^11.2.6"
+      }
+    },
+    "node_modules/@asamuzakjp/nwsapi": {
+      "version": "2.3.9",
+      "resolved": "https://registry.npmmirror.com/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz",
+      "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@babel/helper-string-parser": {
+      "version": "7.29.7",
+      "resolved": "https://registry.npmmirror.com/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
+      "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/helper-validator-identifier": {
+      "version": "7.29.7",
+      "resolved": "https://registry.npmmirror.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
+      "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/parser": {
+      "version": "7.29.8",
+      "resolved": "https://registry.npmmirror.com/@babel/parser/-/parser-7.29.8.tgz",
+      "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/types": "^7.29.8"
+      },
+      "bin": {
+        "parser": "bin/babel-parser.js"
+      },
+      "engines": {
+        "node": ">=6.0.0"
+      }
+    },
+    "node_modules/@babel/types": {
+      "version": "7.29.8",
+      "resolved": "https://registry.npmmirror.com/@babel/types/-/types-7.29.8.tgz",
+      "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/helper-string-parser": "^7.29.7",
+        "@babel/helper-validator-identifier": "^7.29.7"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@bcoe/v8-coverage": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmmirror.com/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz",
+      "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@css-render/plugin-bem": {
+      "version": "0.15.14",
+      "resolved": "https://registry.npmmirror.com/@css-render/plugin-bem/-/plugin-bem-0.15.14.tgz",
+      "integrity": "sha512-QK513CJ7yEQxm/P3EwsI+d+ha8kSOcjGvD6SevM41neEMxdULE+18iuQK6tEChAWMOQNQPLG/Rw3Khb69r5neg==",
+      "license": "MIT",
+      "peerDependencies": {
+        "css-render": "~0.15.14"
+      }
+    },
+    "node_modules/@css-render/vue3-ssr": {
+      "version": "0.15.14",
+      "resolved": "https://registry.npmmirror.com/@css-render/vue3-ssr/-/vue3-ssr-0.15.14.tgz",
+      "integrity": "sha512-//8027GSbxE9n3QlD73xFY6z4ZbHbvrOVB7AO6hsmrEzGbg+h2A09HboUyDgu+xsmj7JnvJD39Irt+2D0+iV8g==",
+      "license": "MIT",
+      "peerDependencies": {
+        "vue": "^3.0.11"
+      }
+    },
+    "node_modules/@csstools/color-helpers": {
+      "version": "6.1.1",
+      "resolved": "https://registry.npmmirror.com/@csstools/color-helpers/-/color-helpers-6.1.1.tgz",
+      "integrity": "sha512-gLNsunvwf3mCi5u5o46/Z/JcJMnhbHSaZ69rkgPzNM3J4s8hWwpPUQB6/tt0EDFyCiWzxANlx+2LJwpYj4zS1w==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/csstools"
+        },
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/csstools"
+        }
+      ],
+      "license": "MIT-0",
+      "engines": {
+        "node": ">=20.19.0"
+      }
+    },
+    "node_modules/@csstools/css-calc": {
+      "version": "3.3.0",
+      "resolved": "https://registry.npmmirror.com/@csstools/css-calc/-/css-calc-3.3.0.tgz",
+      "integrity": "sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/csstools"
+        },
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/csstools"
+        }
+      ],
+      "license": "MIT",
+      "engines": {
+        "node": ">=20.19.0"
+      },
+      "peerDependencies": {
+        "@csstools/css-parser-algorithms": "^4.0.0",
+        "@csstools/css-tokenizer": "^4.0.0"
+      }
+    },
+    "node_modules/@csstools/css-color-parser": {
+      "version": "4.2.0",
+      "resolved": "https://registry.npmmirror.com/@csstools/css-color-parser/-/css-color-parser-4.2.0.tgz",
+      "integrity": "sha512-5+5LEmFuY1AjXdYhmgjTJogtQnP1evJ1zrBZGUNZ0thkpwnnmKxcHdAMn/OtFjAb25zA+jKDVYVRl+5G7rjv1A==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/csstools"
+        },
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/csstools"
+        }
+      ],
+      "license": "MIT",
+      "dependencies": {
+        "@csstools/color-helpers": "^6.1.1",
+        "@csstools/css-calc": "^3.3.0"
+      },
+      "engines": {
+        "node": ">=20.19.0"
+      },
+      "peerDependencies": {
+        "@csstools/css-parser-algorithms": "^4.0.0",
+        "@csstools/css-tokenizer": "^4.0.0"
+      }
+    },
+    "node_modules/@csstools/css-parser-algorithms": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmmirror.com/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz",
+      "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/csstools"
+        },
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/csstools"
+        }
+      ],
+      "license": "MIT",
+      "engines": {
+        "node": ">=20.19.0"
+      },
+      "peerDependencies": {
+        "@csstools/css-tokenizer": "^4.0.0"
+      }
+    },
+    "node_modules/@csstools/css-syntax-patches-for-csstree": {
+      "version": "1.1.8",
+      "resolved": "https://registry.npmmirror.com/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.8.tgz",
+      "integrity": "sha512-CpMLjAvwQg3BL5S0IeqsZNMH7EQrEWi0kLKOC13ZBF0ZwERiLWlibNPJr8G1kdU3Ms/r2KiNrF81pUh2HwAHdg==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/csstools"
+        },
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/csstools"
+        }
+      ],
+      "license": "MIT-0",
+      "peerDependencies": {
+        "css-tree": "^3.2.1"
+      },
+      "peerDependenciesMeta": {
+        "css-tree": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@csstools/css-tokenizer": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmmirror.com/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz",
+      "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/csstools"
+        },
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/csstools"
+        }
+      ],
+      "license": "MIT",
+      "engines": {
+        "node": ">=20.19.0"
+      }
+    },
+    "node_modules/@emotion/hash": {
+      "version": "0.8.0",
+      "resolved": "https://registry.npmmirror.com/@emotion/hash/-/hash-0.8.0.tgz",
+      "integrity": "sha512-kBJtf7PH6aWwZ6fka3zQ0p6SBYzx4fl1LoZXE2RrnYST9Xljm7WfKJrU4g/Xr3Beg72MLrp1AWNUmuYJTL7Cow==",
+      "license": "MIT"
+    },
+    "node_modules/@epic-web/invariant": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmmirror.com/@epic-web/invariant/-/invariant-1.0.0.tgz",
+      "integrity": "sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@esbuild/aix-ppc64": {
+      "version": "0.28.2",
+      "resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz",
+      "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "aix"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/android-arm": {
+      "version": "0.28.2",
+      "resolved": "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.28.2.tgz",
+      "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/android-arm64": {
+      "version": "0.28.2",
+      "resolved": "https://registry.npmmirror.com/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz",
+      "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/android-x64": {
+      "version": "0.28.2",
+      "resolved": "https://registry.npmmirror.com/@esbuild/android-x64/-/android-x64-0.28.2.tgz",
+      "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/darwin-arm64": {
+      "version": "0.28.2",
+      "resolved": "https://registry.npmmirror.com/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz",
+      "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/darwin-x64": {
+      "version": "0.28.2",
+      "resolved": "https://registry.npmmirror.com/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz",
+      "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/freebsd-arm64": {
+      "version": "0.28.2",
+      "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz",
+      "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/freebsd-x64": {
+      "version": "0.28.2",
+      "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz",
+      "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-arm": {
+      "version": "0.28.2",
+      "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz",
+      "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-arm64": {
+      "version": "0.28.2",
+      "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz",
+      "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-ia32": {
+      "version": "0.28.2",
+      "resolved": "https://registry.npmmirror.com/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz",
+      "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==",
+      "cpu": [
+        "ia32"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-loong64": {
+      "version": "0.28.2",
+      "resolved": "https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz",
+      "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==",
+      "cpu": [
+        "loong64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-mips64el": {
+      "version": "0.28.2",
+      "resolved": "https://registry.npmmirror.com/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz",
+      "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==",
+      "cpu": [
+        "mips64el"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-ppc64": {
+      "version": "0.28.2",
+      "resolved": "https://registry.npmmirror.com/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz",
+      "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-riscv64": {
+      "version": "0.28.2",
+      "resolved": "https://registry.npmmirror.com/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz",
+      "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==",
+      "cpu": [
+        "riscv64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-s390x": {
+      "version": "0.28.2",
+      "resolved": "https://registry.npmmirror.com/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz",
+      "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==",
+      "cpu": [
+        "s390x"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-x64": {
+      "version": "0.28.2",
+      "resolved": "https://registry.npmmirror.com/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz",
+      "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/netbsd-arm64": {
+      "version": "0.28.2",
+      "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz",
+      "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "netbsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/netbsd-x64": {
+      "version": "0.28.2",
+      "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz",
+      "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "netbsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/openbsd-arm64": {
+      "version": "0.28.2",
+      "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz",
+      "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "openbsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/openbsd-x64": {
+      "version": "0.28.2",
+      "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz",
+      "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "openbsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/openharmony-arm64": {
+      "version": "0.28.2",
+      "resolved": "https://registry.npmmirror.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz",
+      "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "openharmony"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/sunos-x64": {
+      "version": "0.28.2",
+      "resolved": "https://registry.npmmirror.com/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz",
+      "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "sunos"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/win32-arm64": {
+      "version": "0.28.2",
+      "resolved": "https://registry.npmmirror.com/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz",
+      "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/win32-ia32": {
+      "version": "0.28.2",
+      "resolved": "https://registry.npmmirror.com/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz",
+      "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==",
+      "cpu": [
+        "ia32"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/win32-x64": {
+      "version": "0.28.2",
+      "resolved": "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz",
+      "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@exodus/bytes": {
+      "version": "1.15.1",
+      "resolved": "https://registry.npmmirror.com/@exodus/bytes/-/bytes-1.15.1.tgz",
+      "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+      },
+      "peerDependencies": {
+        "@noble/hashes": "^1.8.0 || ^2.0.0"
+      },
+      "peerDependenciesMeta": {
+        "@noble/hashes": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@hapi/bourne": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmmirror.com/@hapi/bourne/-/bourne-3.0.0.tgz",
+      "integrity": "sha512-Waj1cwPXJDucOib4a3bAISsKJVb15MKi9IvmTI/7ssVEm6sywXGjVJDhl6/umt1pK1ZS7PacXU3A1PmFKHEZ2w==",
+      "license": "BSD-3-Clause"
+    },
+    "node_modules/@isaacs/cliui": {
+      "version": "8.0.2",
+      "resolved": "https://registry.npmmirror.com/@isaacs/cliui/-/cliui-8.0.2.tgz",
+      "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==",
+      "dev": true,
+      "license": "ISC",
+      "dependencies": {
+        "string-width": "^5.1.2",
+        "string-width-cjs": "npm:string-width@^4.2.0",
+        "strip-ansi": "^7.0.1",
+        "strip-ansi-cjs": "npm:strip-ansi@^6.0.1",
+        "wrap-ansi": "^8.1.0",
+        "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0"
+      },
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@istanbuljs/schema": {
+      "version": "0.1.6",
+      "resolved": "https://registry.npmmirror.com/@istanbuljs/schema/-/schema-0.1.6.tgz",
+      "integrity": "sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/@jridgewell/gen-mapping": {
+      "version": "0.3.13",
+      "resolved": "https://registry.npmmirror.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
+      "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@jridgewell/sourcemap-codec": "^1.5.0",
+        "@jridgewell/trace-mapping": "^0.3.24"
+      }
+    },
+    "node_modules/@jridgewell/resolve-uri": {
+      "version": "3.1.2",
+      "resolved": "https://registry.npmmirror.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
+      "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=6.0.0"
+      }
+    },
+    "node_modules/@jridgewell/sourcemap-codec": {
+      "version": "1.5.5",
+      "resolved": "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
+      "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
+      "license": "MIT"
+    },
+    "node_modules/@jridgewell/trace-mapping": {
+      "version": "0.3.31",
+      "resolved": "https://registry.npmmirror.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
+      "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@jridgewell/resolve-uri": "^3.1.0",
+        "@jridgewell/sourcemap-codec": "^1.4.14"
+      }
+    },
+    "node_modules/@juggle/resize-observer": {
+      "version": "3.4.0",
+      "resolved": "https://registry.npmmirror.com/@juggle/resize-observer/-/resize-observer-3.4.0.tgz",
+      "integrity": "sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA==",
+      "license": "Apache-2.0"
+    },
+    "node_modules/@koa/bodyparser": {
+      "version": "5.1.2",
+      "resolved": "https://registry.npmmirror.com/@koa/bodyparser/-/bodyparser-5.1.2.tgz",
+      "integrity": "sha512-eGJm9/66iUX+LUH03Cz0e94unbSKrmSPCick4MO5UorAAomcjC5Kl+SkoZ6CSyPew3neMYjj7n+djnlGYBSJAg==",
+      "license": "MIT",
+      "dependencies": {
+        "co-body": "^6.1.0",
+        "lodash.merge": "^4.6.2",
+        "type-is": "^1.6.18"
+      },
+      "engines": {
+        "node": ">= 16"
+      },
+      "peerDependencies": {
+        "koa": "^2.14.1"
+      }
+    },
+    "node_modules/@koa/router": {
+      "version": "15.7.0",
+      "resolved": "https://registry.npmmirror.com/@koa/router/-/router-15.7.0.tgz",
+      "integrity": "sha512-WaAlk4TOl/O0rhTpOR0l052gz03syPMmI6Pe2gd7v3ubjfv5UcSGcnb0Y/J5NNC/ln+5FiUqPJTc/a15I+XqAA==",
+      "license": "MIT",
+      "dependencies": {
+        "debug": "^4.4.3",
+        "http-errors": "^2.0.1",
+        "koa-compose": "^4.1.0",
+        "path-to-regexp": "^8.4.2"
+      },
+      "engines": {
+        "node": ">= 20"
+      },
+      "peerDependencies": {
+        "koa": "^2.0.0 || ^3.0.0"
+      },
+      "peerDependenciesMeta": {
+        "koa": {
+          "optional": false
+        }
+      }
+    },
+    "node_modules/@napi-rs/canvas": {
+      "version": "0.1.100",
+      "resolved": "https://registry.npmmirror.com/@napi-rs/canvas/-/canvas-0.1.100.tgz",
+      "integrity": "sha512-xglYA6q3XO5P3BNJYxVZ1IV7DLVjp1Py6nwag88YntrS+3vKHyYcMqXVS4ZztJmwz2uGvz1FWhI/4LgbR5uQDA==",
+      "license": "MIT",
+      "optional": true,
+      "workspaces": [
+        "e2e/*"
+      ],
+      "engines": {
+        "node": ">= 10"
+      },
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/Brooooooklyn"
+      },
+      "optionalDependencies": {
+        "@napi-rs/canvas-android-arm64": "0.1.100",
+        "@napi-rs/canvas-darwin-arm64": "0.1.100",
+        "@napi-rs/canvas-darwin-x64": "0.1.100",
+        "@napi-rs/canvas-linux-arm-gnueabihf": "0.1.100",
+        "@napi-rs/canvas-linux-arm64-gnu": "0.1.100",
+        "@napi-rs/canvas-linux-arm64-musl": "0.1.100",
+        "@napi-rs/canvas-linux-riscv64-gnu": "0.1.100",
+        "@napi-rs/canvas-linux-x64-gnu": "0.1.100",
+        "@napi-rs/canvas-linux-x64-musl": "0.1.100",
+        "@napi-rs/canvas-win32-arm64-msvc": "0.1.100",
+        "@napi-rs/canvas-win32-x64-msvc": "0.1.100"
+      }
+    },
+    "node_modules/@napi-rs/canvas-android-arm64": {
+      "version": "0.1.100",
+      "resolved": "https://registry.npmmirror.com/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.100.tgz",
+      "integrity": "sha512-hjhCKhntPv9+t4ckHymdx0phYNcVW+GKQR6Lzw2zE+pOVjOplSmtx9nNNknTjbEDLcuLZqA1y8ufKg1XfgftzQ==",
+      "cpu": [
+        "arm64"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">= 10"
+      },
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/Brooooooklyn"
+      }
+    },
+    "node_modules/@napi-rs/canvas-darwin-arm64": {
+      "version": "0.1.100",
+      "resolved": "https://registry.npmmirror.com/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.100.tgz",
+      "integrity": "sha512-2PcswRaC7Ly645DGt88///zuFDhJxJYdKAs1uU3mfk1atYkXufgcgLfBpk6Tm12nCQBaNt1wpybuPZ4qOhTo8A==",
+      "cpu": [
+        "arm64"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">= 10"
+      },
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/Brooooooklyn"
+      }
+    },
+    "node_modules/@napi-rs/canvas-darwin-x64": {
+      "version": "0.1.100",
+      "resolved": "https://registry.npmmirror.com/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.100.tgz",
+      "integrity": "sha512-ePNZtj7pNIva/siZMg+HmbeozkIjqUIYdoymH8HaA3qK7LfzFN4WMBM8G6HQ9ZC+H3+Dnn5pqtiXpgLykaPOhw==",
+      "cpu": [
+        "x64"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">= 10"
+      },
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/Brooooooklyn"
+      }
+    },
+    "node_modules/@napi-rs/canvas-linux-arm-gnueabihf": {
+      "version": "0.1.100",
+      "resolved": "https://registry.npmmirror.com/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.100.tgz",
+      "integrity": "sha512-d5cDB48oWFGU8/XPhUOFAlySgb/VAu7D+s8fi55K1Pcfg8aPplHWqMgibhVLU8ky7Pyg/fuiVLz4Nf3JrSTuUA==",
+      "cpu": [
+        "arm"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 10"
+      },
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/Brooooooklyn"
+      }
+    },
+    "node_modules/@napi-rs/canvas-linux-arm64-gnu": {
+      "version": "0.1.100",
+      "resolved": "https://registry.npmmirror.com/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.100.tgz",
+      "integrity": "sha512-rDxgxRu69RvDlX/bh9o22DxLsGr8EqsNgotL9+RwQE1S0b0cqeatqsw6aW45mukm0B42DIAaAacKaYQ8cqS1nw==",
+      "cpu": [
+        "arm64"
+      ],
+      "libc": [
+        "glibc"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 10"
+      },
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/Brooooooklyn"
+      }
+    },
+    "node_modules/@napi-rs/canvas-linux-arm64-musl": {
+      "version": "0.1.100",
+      "resolved": "https://registry.npmmirror.com/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.100.tgz",
+      "integrity": "sha512-K3mDW66N+xT2/V439u1alFANiBUjdEx2gLiNYnCmUsva5jZMxWTjafBYwTzYK+EMFMHrUoabuU+T1BIP5CgbYQ==",
+      "cpu": [
+        "arm64"
+      ],
+      "libc": [
+        "musl"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 10"
+      },
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/Brooooooklyn"
+      }
+    },
+    "node_modules/@napi-rs/canvas-linux-riscv64-gnu": {
+      "version": "0.1.100",
+      "resolved": "https://registry.npmmirror.com/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.100.tgz",
+      "integrity": "sha512-mooqUBTIsccZpnoQC4NgrC1v6C1vof39etLNMnBwCY+p0gajWJvAHLGQ6g/gGyS5YrpDW+GefSN4+Cvcr08UWw==",
+      "cpu": [
+        "riscv64"
+      ],
+      "libc": [
+        "glibc"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 10"
+      },
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/Brooooooklyn"
+      }
+    },
+    "node_modules/@napi-rs/canvas-linux-x64-gnu": {
+      "version": "0.1.100",
+      "resolved": "https://registry.npmmirror.com/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.100.tgz",
+      "integrity": "sha512-1eCvkDCazm7FFhsT7DfGOdSaHgZVK3bt/dSBl5EWHOWmnz+I7j8tPseJqqD81NF+MH21jKUK4wQSDjN0mdhnTg==",
+      "cpu": [
+        "x64"
+      ],
+      "libc": [
+        "glibc"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 10"
+      },
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/Brooooooklyn"
+      }
+    },
+    "node_modules/@napi-rs/canvas-linux-x64-musl": {
+      "version": "0.1.100",
+      "resolved": "https://registry.npmmirror.com/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.100.tgz",
+      "integrity": "sha512-20arT6lnI19S68qNlii73TSEDbECNgzMz2EpldC1V3mZFuRkeujXkcebRk0LRJe9SEUAooYiLokfMViY8IX7yA==",
+      "cpu": [
+        "x64"
+      ],
+      "libc": [
+        "musl"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 10"
+      },
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/Brooooooklyn"
+      }
+    },
+    "node_modules/@napi-rs/canvas-win32-arm64-msvc": {
+      "version": "0.1.100",
+      "resolved": "https://registry.npmmirror.com/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-0.1.100.tgz",
+      "integrity": "sha512-DZFFT1wIAg37LJw37yhMRFfjATd3vTQzjZ1Yki8u2vhO6Hi5VE6BVaGQ1aaDu7xb4iMErz+9EOwjpS7xcxFeBw==",
+      "cpu": [
+        "arm64"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">= 10"
+      },
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/Brooooooklyn"
+      }
+    },
+    "node_modules/@napi-rs/canvas-win32-x64-msvc": {
+      "version": "0.1.100",
+      "resolved": "https://registry.npmmirror.com/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.100.tgz",
+      "integrity": "sha512-MyT1j3mHC2+Lu4pBi9mKyMJhtP6U7k7EldY7sj/uS5gJA65gTXt8MefJQXLJo5d/vZbuWmfxzkEUNc/urV3pHA==",
+      "cpu": [
+        "x64"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">= 10"
+      },
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/Brooooooklyn"
+      }
+    },
+    "node_modules/@napi-rs/lzma-linux-x64-gnu": {
+      "version": "1.5.1",
+      "resolved": "https://registry.npmmirror.com/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz",
+      "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "libc": [
+        "glibc"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": "^22.20 || ^24.12 || >=25"
+      }
+    },
+    "node_modules/@noble/hashes": {
+      "version": "1.8.0",
+      "resolved": "https://registry.npmmirror.com/@noble/hashes/-/hashes-1.8.0.tgz",
+      "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": "^14.21.3 || >=16"
+      },
+      "funding": {
+        "url": "https://paulmillr.com/funding/"
+      }
+    },
+    "node_modules/@one-ini/wasm": {
+      "version": "0.1.1",
+      "resolved": "https://registry.npmmirror.com/@one-ini/wasm/-/wasm-0.1.1.tgz",
+      "integrity": "sha512-XuySG1E38YScSJoMlqovLru4KTUNSjgVTIjyh7qMX6aNN5HY5Ct5LhRJdxO79JtTzKfzV/bnWpz+zquYrISsvw==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@oxc-project/types": {
+      "version": "0.144.0",
+      "resolved": "https://registry.npmmirror.com/@oxc-project/types/-/types-0.144.0.tgz",
+      "integrity": "sha512-nuhZIOLuI6TFQ32I/WnUx+SCPY7SdSKwgnFHydAuoS1+Z4BRcaP+RRJmGzl9lw+0OFF7UmaESf7KQRXaNLHypg==",
+      "dev": true,
+      "license": "MIT",
+      "funding": {
+        "url": "https://github.com/sponsors/Boshen"
+      }
+    },
+    "node_modules/@paralleldrive/cuid2": {
+      "version": "2.3.1",
+      "resolved": "https://registry.npmmirror.com/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz",
+      "integrity": "sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@noble/hashes": "^1.1.5"
+      }
+    },
+    "node_modules/@parcel/watcher": {
+      "version": "2.6.0",
+      "resolved": "https://registry.npmmirror.com/@parcel/watcher/-/watcher-2.6.0.tgz",
+      "integrity": "sha512-7FNeNl8NCE7aINx7WXiKQrPYZWC/hvrTsmk6zmxbI7LTXE7hVek/n8AfVgpe2y82zl3w0HvCHN0bVKMBoJcC0w==",
+      "dev": true,
+      "hasInstallScript": true,
+      "license": "MIT",
+      "optional": true,
+      "dependencies": {
+        "detect-libc": "^2.0.3",
+        "is-glob": "^4.0.3",
+        "node-addon-api": "^7.0.0",
+        "picomatch": "^4.0.4"
+      },
+      "engines": {
+        "node": ">= 10.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      },
+      "optionalDependencies": {
+        "@parcel/watcher-android-arm64": "2.6.0",
+        "@parcel/watcher-darwin-arm64": "2.6.0",
+        "@parcel/watcher-darwin-x64": "2.6.0",
+        "@parcel/watcher-freebsd-x64": "2.6.0",
+        "@parcel/watcher-linux-arm-glibc": "2.6.0",
+        "@parcel/watcher-linux-arm-musl": "2.6.0",
+        "@parcel/watcher-linux-arm64-glibc": "2.6.0",
+        "@parcel/watcher-linux-arm64-musl": "2.6.0",
+        "@parcel/watcher-linux-x64-glibc": "2.6.0",
+        "@parcel/watcher-linux-x64-musl": "2.6.0",
+        "@parcel/watcher-win32-arm64": "2.6.0",
+        "@parcel/watcher-win32-x64": "2.6.0"
+      }
+    },
+    "node_modules/@parcel/watcher-android-arm64": {
+      "version": "2.6.0",
+      "resolved": "https://registry.npmmirror.com/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.6.0.tgz",
+      "integrity": "sha512-trgpLSCKRC/huFjXX/Smh+0sWe4+YtKfktIToiMl59ghz7z+qkH6kMvNnUbLyRs9N11t8l4svSCs1+5B3rOAhA==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">= 10.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/@parcel/watcher-darwin-arm64": {
+      "version": "2.6.0",
+      "resolved": "https://registry.npmmirror.com/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.6.0.tgz",
+      "integrity": "sha512-Y3QV0gl7Q1zbfueunkWIERICbEojQFCgpyG7YqOGNFLsckXyI1xu9mAIUpKY9QBYzBtSkN8dBPwd3yiAO9ovMw==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">= 10.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/@parcel/watcher-darwin-x64": {
+      "version": "2.6.0",
+      "resolved": "https://registry.npmmirror.com/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.6.0.tgz",
+      "integrity": "sha512-Ohv6OpzhUfKYD7Beb8kDvG0jbIxORCYY1JRdZnaBtnjjkJxgD7ZVL0nw2sCYd0yTMKTvz3nnTnOF3cDifK+kvw==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">= 10.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/@parcel/watcher-freebsd-x64": {
+      "version": "2.6.0",
+      "resolved": "https://registry.npmmirror.com/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.6.0.tgz",
+      "integrity": "sha512-5HmXvDgs8VK+74jF9y9/2FE3/OnlcKmc56tjmSrEuZjpSZOGL+fvAu+HKJBdPs9uwoP2hE6TlSUpXZ/C5jUFmQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ],
+      "engines": {
+        "node": ">= 10.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/@parcel/watcher-linux-arm-glibc": {
+      "version": "2.6.0",
+      "resolved": "https://registry.npmmirror.com/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.6.0.tgz",
+      "integrity": "sha512-Ps/hui3A+vMbjdqlqAowK2ZL8+BO8dBjxeWXj6npTBs3jx4wWmbPpaLuqwrQrSqIVMCnpWo238bJ1U37GhQOYg==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "libc": [
+        "glibc"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 10.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/@parcel/watcher-linux-arm-musl": {
+      "version": "2.6.0",
+      "resolved": "https://registry.npmmirror.com/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.6.0.tgz",
+      "integrity": "sha512-9c6AUHgHoG+IY88MRIHupztQiQnrbqHYQjkM2btA+Bf/wQnQMuiD0Wfk1EVv3TlNT3x41uU71rn6E4xh/+zvkw==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "libc": [
+        "musl"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 10.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/@parcel/watcher-linux-arm64-glibc": {
+      "version": "2.6.0",
+      "resolved": "https://registry.npmmirror.com/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.6.0.tgz",
+      "integrity": "sha512-yHRqS2owEXe6Hic9z6Mh1ECsCd+ODVOGvZDyciqRd21+v+o+DnXMOrw50DSpIG2sb8GPEaPPmfeCAWKPJdq46g==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "libc": [
+        "glibc"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 10.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/@parcel/watcher-linux-arm64-musl": {
+      "version": "2.6.0",
+      "resolved": "https://registry.npmmirror.com/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.6.0.tgz",
+      "integrity": "sha512-WhB2e/V7rqdHHWZusBSPuy5Ei8S6lSz6FE5TKKQz5h3a0O+C+mhY7vxU9b/stqvMb8beLnPY82ZrFTLKs+SrKA==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "libc": [
+        "musl"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 10.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/@parcel/watcher-linux-x64-glibc": {
+      "version": "2.6.0",
+      "resolved": "https://registry.npmmirror.com/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.6.0.tgz",
+      "integrity": "sha512-ulGE6x6Oz6iAwg75T8YQSoguBWasniIbX+QWpaYPcCnDOpdWX3k+4xbEYPZVLxOuoJI+svJJPD3sEj8G7lrQ3A==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "libc": [
+        "glibc"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 10.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/@parcel/watcher-linux-x64-musl": {
+      "version": "2.6.0",
+      "resolved": "https://registry.npmmirror.com/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.6.0.tgz",
+      "integrity": "sha512-tkBYKt7YQrjIJWYDnto2YgO8MRkjlMTSNoRHzsXinBqbLdeOM3L32wPZJvIZxqaLMfSlS/4sUjH/6STVP/XDLw==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "libc": [
+        "musl"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 10.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/@parcel/watcher-win32-arm64": {
+      "version": "2.6.0",
+      "resolved": "https://registry.npmmirror.com/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.6.0.tgz",
+      "integrity": "sha512-gIZAP23jaHjGWasY/TY6yL7NHFClf0Ga7FN+iINvk+KN94rhm94lYZhFsbYFNcA04/onvGD9kKmiJLJB2HbNwQ==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">= 10.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/@parcel/watcher-win32-x64": {
+      "version": "2.6.0",
+      "resolved": "https://registry.npmmirror.com/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.6.0.tgz",
+      "integrity": "sha512-cA+/pXV2YkfxlIcXOQ5fSWqAzzPyD78/x5qbK/I0vUkrlYHA8TIz+MXjAbGouguKVSI4bOmkTSJ1/poVSsgt+A==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">= 10.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/@pkgjs/parseargs": {
+      "version": "0.11.0",
+      "resolved": "https://registry.npmmirror.com/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
+      "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==",
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "engines": {
+        "node": ">=14"
+      }
+    },
+    "node_modules/@playwright/test": {
+      "version": "1.62.1",
+      "resolved": "https://registry.npmmirror.com/@playwright/test/-/test-1.62.1.tgz",
+      "integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "dependencies": {
+        "playwright": "1.62.1"
+      },
+      "bin": {
+        "playwright": "cli.js"
+      },
+      "engines": {
+        "node": ">=20"
+      }
+    },
+    "node_modules/@rolldown/binding-android-arm64": {
+      "version": "1.2.4",
+      "resolved": "https://registry.npmmirror.com/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.4.tgz",
+      "integrity": "sha512-jHC2cnyKz5xU2fhECtFl8OZ83cYNt13GZQD+0uMJ/X3o+ijmd56okHhTUwxVSHPx1IRVIJEZ1/1pPzeLCU6XKA==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/binding-darwin-arm64": {
+      "version": "1.2.4",
+      "resolved": "https://registry.npmmirror.com/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.4.tgz",
+      "integrity": "sha512-Dc5mPD8F5F/FS8i01syd7FTF6yB2fVthH/TRkjwJkzUK6EpoxHtqvZQP5Zwq80/5z19TWYHIg1KOHboCgVx/aQ==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/binding-darwin-x64": {
+      "version": "1.2.4",
+      "resolved": "https://registry.npmmirror.com/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.4.tgz",
+      "integrity": "sha512-fpDm4oBo6SqLvWUYCmFhdde3U9KH2fRNNMeAnAPAIwxRL345xutL0EtEUcuoxsoazdJGv/MuDBQHlCDrtbvqOg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/binding-freebsd-x64": {
+      "version": "1.2.4",
+      "resolved": "https://registry.npmmirror.com/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.4.tgz",
+      "integrity": "sha512-rSJoreDE/HoIzoaib6MTp5jQtCTdMHKIvItAKT/ImS6Y6Ww76oUaeMyp4Vc/fAgd/ehji068IxetHXAnqUwN9A==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/binding-linux-arm-gnueabihf": {
+      "version": "1.2.4",
+      "resolved": "https://registry.npmmirror.com/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.4.tgz",
+      "integrity": "sha512-/jm8OGHgn7oGaJu3i/qZI9spUGcJ+y/lk43ttQ/iO1tOd9NissG6o97bighBCiL+BKRngmcDuR6ikfwYdJmVuQ==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/binding-linux-arm64-gnu": {
+      "version": "1.2.4",
+      "resolved": "https://registry.npmmirror.com/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.4.tgz",
+      "integrity": "sha512-tIP06BeD9EqvECBrPZ+sqdPlYrT+aYaAiu1wYziVx5elRK/ftm33JxVDy2bXGbr6J0CrtirCkR87/X5a2euEng==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "libc": [
+        "glibc"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/binding-linux-arm64-musl": {
+      "version": "1.2.4",
+      "resolved": "https://registry.npmmirror.com/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.4.tgz",
+      "integrity": "sha512-Ql1Q0EQqVThvn9VAVlwNzsUvbSFtCMGjLpRRi4pk5i7NZZ4n5ISiLMjHYtus4VQ2PvkSw24zyaCVsiS+sXPj1w==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "libc": [
+        "musl"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/binding-linux-ppc64-gnu": {
+      "version": "1.2.4",
+      "resolved": "https://registry.npmmirror.com/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.4.tgz",
+      "integrity": "sha512-GjbjXD4XXfN19D0LZNbmiCBUoDiRACsYHr0yaIbbn8aFsXjHZifcYqu/W5Er5X2X990WjHXFrxarn5chzItorQ==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "libc": [
+        "glibc"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/binding-linux-s390x-gnu": {
+      "version": "1.2.4",
+      "resolved": "https://registry.npmmirror.com/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.4.tgz",
+      "integrity": "sha512-p5WR0NOwaRmJ/B1b6IjEFLLivwEsf3PrdBIhRbhTCQisbo2SvHHpG4ELB/+FgQNnB88LTOF86upmJmbvZdQ2lw==",
+      "cpu": [
+        "s390x"
+      ],
+      "dev": true,
+      "libc": [
+        "glibc"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/binding-linux-x64-gnu": {
+      "version": "1.2.4",
+      "resolved": "https://registry.npmmirror.com/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.4.tgz",
+      "integrity": "sha512-4/GyVjmhR+Tc6HLJvwc1sOhPqAZtySiSMesOZyX6JQ5XBxoTDEMKQzvo07NIK6nTon/SivlZqvhzvuVBNQhObQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "libc": [
+        "glibc"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/binding-linux-x64-musl": {
+      "version": "1.2.4",
+      "resolved": "https://registry.npmmirror.com/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.4.tgz",
+      "integrity": "sha512-l9eeLsCNvPpmSXUej0etw/J1eqV0Jj1D5G/xG6YTijmE6dkv6E2QezgWbTfQk63v952DPqrjOCoiqxq7Bw0YUQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "libc": [
+        "musl"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/binding-openharmony-arm64": {
+      "version": "1.2.4",
+      "resolved": "https://registry.npmmirror.com/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.4.tgz",
+      "integrity": "sha512-e0F355MSTMm3+UOqtV3L24gFUp2N5m1f8L/7d56deik6va+AXdrt9F8LbzGpeWGWRbZEDq4m8NVnJDeBtf9DZg==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "openharmony"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/binding-win32-arm64-msvc": {
+      "version": "1.2.4",
+      "resolved": "https://registry.npmmirror.com/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.4.tgz",
+      "integrity": "sha512-AWLi0uBRYh6QlE7OKhiz+phZC0qwtij2QZmhmOdsLdFn64m7oMpooE9ICE3lhm9xMb4SpDo2WbHcxX1iFLFtqw==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/binding-win32-x64-msvc": {
+      "version": "1.2.4",
+      "resolved": "https://registry.npmmirror.com/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.4.tgz",
+      "integrity": "sha512-UwSDJOg3dqCAejWdxclJjCsh3Qq4vLYMDxmyHqo1btz3stK2VqgwNd3mm5tuIwzSlGIQ/1H9Hr+Zn09mrezNqQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/pluginutils": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmmirror.com/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
+      "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@rollup/rollup-android-arm-eabi": {
+      "version": "4.62.4",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.4.tgz",
+      "integrity": "sha512-RrPokAb7dmbxFoeO3TloqHyOjgye8RkBhSqmp4aJMIex4c9r46ZstPnleDQOq1t46VOVjwIuwNogIqbodV1Vvg==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ]
+    },
+    "node_modules/@rollup/rollup-android-arm64": {
+      "version": "4.62.4",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.4.tgz",
+      "integrity": "sha512-JKuJc+pnpks2pjy7L/N3v/cAkZxYlnmuZoD840ldbMI5KDbC4iO9NKwPKYdjYFCMAIIlBzYSFHxIJVYzRo2/8A==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ]
+    },
+    "node_modules/@rollup/rollup-darwin-arm64": {
+      "version": "4.62.4",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.4.tgz",
+      "integrity": "sha512-krw5uS2STmvJ02x0uTXHbqQNuz+9eZ1iw+qXk9dmW2gvV4jV7O2hEoOnuhFrpOPiel1mBFtqbxYZZtC46hXLOw==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ]
+    },
+    "node_modules/@rollup/rollup-darwin-x64": {
+      "version": "4.62.4",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.4.tgz",
+      "integrity": "sha512-wsTxtgApb4PrOsNJIm0FZ1h3WvCC+k9uxLJ4ad75hgoS4NiRes2SoJFlDAyMwiUY8IssDqGcHbXuN0sx1tfF1A==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ]
+    },
+    "node_modules/@rollup/rollup-freebsd-arm64": {
+      "version": "4.62.4",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.4.tgz",
+      "integrity": "sha512-GUOnQlyZe3yAXhWOtOMsn5Qkrv5E5mZXa0thbARWi5Ei2szlVXJFQhddZ4HbAzh8q92w5twp+CQvs/eFanz9YQ==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ]
+    },
+    "node_modules/@rollup/rollup-freebsd-x64": {
+      "version": "4.62.4",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.4.tgz",
+      "integrity": "sha512-/Y7f3QuxjzPKsjA/rfEDa3+0vXqyjmJ50Ln8dPpCmWkKTrUoWHG1cWhTqaAMLob2m2nESWuC7yGrREz019Ztqg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
+      "version": "4.62.4",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.4.tgz",
+      "integrity": "sha512-81wiiX3v7aqy+T+bT61TJ78yJjRquqFFTTbAPt08imfQQzkPIW8t6aJbkTagtCCrXMNc9D66+geqlK7ydLPNqA==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "libc": [
+        "glibc"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-arm-musleabihf": {
+      "version": "4.62.4",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.4.tgz",
+      "integrity": "sha512-9kmDIvNZqdoHOBZgNtpTBeLWYO/LVipM3H/j62P8848/l/VPEQL6N3uxU9pvP1oZAsXyC2MEnFP3ovRjo7WYNQ==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "libc": [
+        "musl"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-arm64-gnu": {
+      "version": "4.62.4",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.4.tgz",
+      "integrity": "sha512-CcnXHWnXg69g+DX5VWL3FHts3qMRN2uVEHX+BZvGLdd07/gXkn3ePjYtO1LDJvxkGKVHMclKBRa1QUTH+6toYQ==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "libc": [
+        "glibc"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-arm64-musl": {
+      "version": "4.62.4",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.4.tgz",
+      "integrity": "sha512-iFOibiHnTRuhrWLlRsOQFdZJJIa7S8OwkneJr4ocALP16u5yk6lWLINFwhHaEqBFMsKDUZofLkGos7+CPzGB3g==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "libc": [
+        "musl"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-loong64-gnu": {
+      "version": "4.62.4",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.4.tgz",
+      "integrity": "sha512-XnWYMI7euHlb5a871xPja+Gm7DRCFU+FGRrtS2sMq9N8FvqtpagUy6gD4YOemC5MRk9xbh8+jYMEJbigFQwsgA==",
+      "cpu": [
+        "loong64"
+      ],
+      "dev": true,
+      "libc": [
+        "glibc"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-loong64-musl": {
+      "version": "4.62.4",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.4.tgz",
+      "integrity": "sha512-qGDAlO0U8xedCcsdRm9oaoQY8DAx/QT7uIxJWhCdx0ceIWX783UC9QSYkdpzAe29wNiVfp24+bZdQmn49o45SQ==",
+      "cpu": [
+        "loong64"
+      ],
+      "dev": true,
+      "libc": [
+        "musl"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-ppc64-gnu": {
+      "version": "4.62.4",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.4.tgz",
+      "integrity": "sha512-ru4H6ezD7ysA5EiEK6qkkaEb4modH8CTej6kUy/gQi20u3kB3G7Zn8snXXkeJSCOFKG/rbPPtM/+9Wgas1961w==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "libc": [
+        "glibc"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-ppc64-musl": {
+      "version": "4.62.4",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.4.tgz",
+      "integrity": "sha512-2W4MO5WQVJnbJaZdvDb9rhBDuFU1nKIepPFpJUBsTh2k1YY2g+ODViaWuyOAjQ5cOP7NvrvLzt3wvHOoiAvc7w==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "libc": [
+        "musl"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-riscv64-gnu": {
+      "version": "4.62.4",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.4.tgz",
+      "integrity": "sha512-+fxjfuoAmVMCYV5QyjoIpu0cp5DOiOTeqYFk1AVaxGr+/ravWLX89XfQmptsoWcaVy/TGf2hexzbUOrCQIL1CQ==",
+      "cpu": [
+        "riscv64"
+      ],
+      "dev": true,
+      "libc": [
+        "glibc"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-riscv64-musl": {
+      "version": "4.62.4",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.4.tgz",
+      "integrity": "sha512-jTn8JfHGL4djjFxPuM06LmNUJDsst2jeVlsd9OmIH6zc5sC9K6rIuO4YajXatLUpBmBKl6b35ro1QZocLi+tcA==",
+      "cpu": [
+        "riscv64"
+      ],
+      "dev": true,
+      "libc": [
+        "musl"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-s390x-gnu": {
+      "version": "4.62.4",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.4.tgz",
+      "integrity": "sha512-oCJCJL4pXsoDcP2QZ+JVlPTIRc6266zsIaeJJsWImmF7HO0W8nb6HuSgZlMWxJwaPf8ehbSw8yo0EUw925hKsA==",
+      "cpu": [
+        "s390x"
+      ],
+      "dev": true,
+      "libc": [
+        "glibc"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-x64-gnu": {
+      "version": "4.62.4",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.4.tgz",
+      "integrity": "sha512-W69hukhZ3KKNRCaMIEzKvcFye42hh0FE1+YoYaf5+Ikacuftoco6yO/xouz0hc5d5W/s3yBro5jRiuEE/Q5vUw==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "libc": [
+        "glibc"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-x64-musl": {
+      "version": "4.62.4",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.4.tgz",
+      "integrity": "sha512-qiXbGG2jkjXhzXpsFZSR2Xpb8DN/UaxYsbb/STbuR/6fpaDgRmmaq1B/LmtF2wQFOFOSsK2jdE0RZ3a0zHn4QA==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "libc": [
+        "musl"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-openbsd-x64": {
+      "version": "4.62.4",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.4.tgz",
+      "integrity": "sha512-nWeM//hxv8mIo6jD7Hu4o48DVmV9pbV6gsKaWU+4NFyqHoPKwrkRiZGLKUhOBk8qNmDmpwFtPKg80Bo/Tn4xiQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "openbsd"
+      ]
+    },
+    "node_modules/@rollup/rollup-openharmony-arm64": {
+      "version": "4.62.4",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.4.tgz",
+      "integrity": "sha512-s62SQ/vgsRSvMwDkOEfTqfgASF0f26ZNaQuTA6Aok5lrikf89yI2W0gFHvZb2Jpgc6N8JnOKZgCK2iciO3CsxQ==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "openharmony"
+      ]
+    },
+    "node_modules/@rollup/rollup-win32-arm64-msvc": {
+      "version": "4.62.4",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.4.tgz",
+      "integrity": "sha512-J6wGf8TVGbXJq+HH+ttTvrcfNKPbuZecV6KT1B8I18BC5IURUh5kl4Yl5OEP5eFIUoI5BWxCsyYMhFsDx8kekw==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ]
+    },
+    "node_modules/@rollup/rollup-win32-ia32-msvc": {
+      "version": "4.62.4",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.4.tgz",
+      "integrity": "sha512-zmfrQd/0wu6oJs8Vq8KwY/YtsKSsLtKe/HwAP4Wqy8LhWjeT55fHRAkOhYQ12wI3ayS4Tt12d5CDRD7N96SAYQ==",
+      "cpu": [
+        "ia32"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ]
+    },
+    "node_modules/@rollup/rollup-win32-x64-gnu": {
+      "version": "4.62.4",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.4.tgz",
+      "integrity": "sha512-qPzHqdj9rfUD+w79dtE07zi/kFwKyCJqplp5K5ygeLTp7jLpAoc16OAH39HSmRC9UpozaecsleI8uAdEj6v2yw==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ]
+    },
+    "node_modules/@rollup/rollup-win32-x64-msvc": {
+      "version": "4.62.4",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.4.tgz",
+      "integrity": "sha512-zD6NdeWEByGE9QF9vCrlJ5YQB4oq9q91kPZS37Jwj5hOkvR1lTBSpsKhKDw4IJtbQ35LsTS1HD9DZYGKIshU1Q==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ]
+    },
+    "node_modules/@types/accepts": {
+      "version": "1.3.7",
+      "resolved": "https://registry.npmmirror.com/@types/accepts/-/accepts-1.3.7.tgz",
+      "integrity": "sha512-Pay9fq2lM2wXPWbteBsRAGiWH2hig4ZE2asK+mm7kUzlxRTfL961rj89I6zV/E3PcIkDqyuBEcMxFT7rccugeQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/node": "*"
+      }
+    },
+    "node_modules/@types/body-parser": {
+      "version": "1.19.6",
+      "resolved": "https://registry.npmmirror.com/@types/body-parser/-/body-parser-1.19.6.tgz",
+      "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/connect": "*",
+        "@types/node": "*"
+      }
+    },
+    "node_modules/@types/busboy": {
+      "version": "1.5.4",
+      "resolved": "https://registry.npmmirror.com/@types/busboy/-/busboy-1.5.4.tgz",
+      "integrity": "sha512-kG7WrUuAKK0NoyxfQHsVE6j1m01s6kMma64E+OZenQABMQyTJop1DumUWcLwAQ2JzpefU7PDYoRDKl8uZosFjw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/node": "*"
+      }
+    },
+    "node_modules/@types/chai": {
+      "version": "5.2.3",
+      "resolved": "https://registry.npmmirror.com/@types/chai/-/chai-5.2.3.tgz",
+      "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/deep-eql": "*",
+        "assertion-error": "^2.0.1"
+      }
+    },
+    "node_modules/@types/connect": {
+      "version": "3.4.38",
+      "resolved": "https://registry.npmmirror.com/@types/connect/-/connect-3.4.38.tgz",
+      "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/node": "*"
+      }
+    },
+    "node_modules/@types/content-disposition": {
+      "version": "0.5.9",
+      "resolved": "https://registry.npmmirror.com/@types/content-disposition/-/content-disposition-0.5.9.tgz",
+      "integrity": "sha512-8uYXI3Gw35MhiVYhG3s295oihrxRyytcRHjSjqnqZVDDy/xcGBRny7+Xj1Wgfhv5QzRtN2hB2dVRBUX9XW3UcQ==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@types/cookiejar": {
+      "version": "2.1.5",
+      "resolved": "https://registry.npmmirror.com/@types/cookiejar/-/cookiejar-2.1.5.tgz",
+      "integrity": "sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@types/cookies": {
+      "version": "0.9.2",
+      "resolved": "https://registry.npmmirror.com/@types/cookies/-/cookies-0.9.2.tgz",
+      "integrity": "sha512-1AvkDdZM2dbyFybL4fxpuNCaWyv//0AwsuUk2DWeXyM1/5ZKm6W3z6mQi24RZ4l2ucY+bkSHzbDVpySqPGuV8A==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/connect": "*",
+        "@types/express": "*",
+        "@types/keygrip": "*",
+        "@types/node": "*"
+      }
+    },
+    "node_modules/@types/deep-eql": {
+      "version": "4.0.2",
+      "resolved": "https://registry.npmmirror.com/@types/deep-eql/-/deep-eql-4.0.2.tgz",
+      "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@types/estree": {
+      "version": "1.0.9",
+      "resolved": "https://registry.npmmirror.com/@types/estree/-/estree-1.0.9.tgz",
+      "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@types/express": {
+      "version": "5.0.6",
+      "resolved": "https://registry.npmmirror.com/@types/express/-/express-5.0.6.tgz",
+      "integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/body-parser": "*",
+        "@types/express-serve-static-core": "^5.0.0",
+        "@types/serve-static": "^2"
+      }
+    },
+    "node_modules/@types/express-serve-static-core": {
+      "version": "5.1.3",
+      "resolved": "https://registry.npmmirror.com/@types/express-serve-static-core/-/express-serve-static-core-5.1.3.tgz",
+      "integrity": "sha512-dPfW8NFiOF4wOHc7+N/QSxlY9cfSsenewGbAz8C8U/MULPd/YZ27LvJUIlzaXie7e6Ove9YunJGgC9tbHD2cKw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/node": "*",
+        "@types/qs": "*",
+        "@types/range-parser": "*",
+        "@types/send": "*"
+      }
+    },
+    "node_modules/@types/http-assert": {
+      "version": "1.5.6",
+      "resolved": "https://registry.npmmirror.com/@types/http-assert/-/http-assert-1.5.6.tgz",
+      "integrity": "sha512-TTEwmtjgVbYAzZYWyeHPrrtWnfVkm8tQkP8P21uQifPgMRgjrow3XDEYqucuC8SKZJT7pUnhU/JymvjggxO9vw==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@types/http-errors": {
+      "version": "2.0.5",
+      "resolved": "https://registry.npmmirror.com/@types/http-errors/-/http-errors-2.0.5.tgz",
+      "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@types/jsdom": {
+      "version": "21.1.7",
+      "resolved": "https://registry.npmmirror.com/@types/jsdom/-/jsdom-21.1.7.tgz",
+      "integrity": "sha512-yOriVnggzrnQ3a9OKOCxaVuSug3w3/SbOj5i7VwXWZEyUNl3bLF9V3MfxGbZKuwqJOQyRfqXyROBB1CoZLFWzA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/node": "*",
+        "@types/tough-cookie": "*",
+        "parse5": "^7.0.0"
+      }
+    },
+    "node_modules/@types/keygrip": {
+      "version": "1.0.6",
+      "resolved": "https://registry.npmmirror.com/@types/keygrip/-/keygrip-1.0.6.tgz",
+      "integrity": "sha512-lZuNAY9xeJt7Bx4t4dx0rYCDqGPW8RXhQZK1td7d4H6E9zYbLoOtjBvfwdTKpsyxQI/2jv+armjX/RW+ZNpXOQ==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@types/koa": {
+      "version": "2.15.2",
+      "resolved": "https://registry.npmmirror.com/@types/koa/-/koa-2.15.2.tgz",
+      "integrity": "sha512-CB+iyjjh1uS5N6/CKwXvw0qA7USMS2WVc4Tjf660yCjhdvqzNr8gdFcIawB41zGGptOQ+d1fnpaQWIIUXYxR3w==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/accepts": "*",
+        "@types/content-disposition": "*",
+        "@types/cookies": "*",
+        "@types/http-assert": "*",
+        "@types/http-errors": "*",
+        "@types/keygrip": "*",
+        "@types/koa-compose": "*",
+        "@types/node": "*"
+      }
+    },
+    "node_modules/@types/koa-compose": {
+      "version": "3.2.9",
+      "resolved": "https://registry.npmmirror.com/@types/koa-compose/-/koa-compose-3.2.9.tgz",
+      "integrity": "sha512-BroAZ9FTvPiCy0Pi8tjD1OfJ7bgU1gQf0eR6e1Vm+JJATy9eKOG3hQMFtMciMawiSOVnLMdmUOC46s7HBhSTsA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/koa": "*"
+      }
+    },
+    "node_modules/@types/koa-send": {
+      "version": "4.1.6",
+      "resolved": "https://registry.npmmirror.com/@types/koa-send/-/koa-send-4.1.6.tgz",
+      "integrity": "sha512-vgnNGoOJkx7FrF0Jl6rbK1f8bBecqAchKpXtKuXzqIEdXTDO6dsSTjr+eZ5m7ltSjH4K/E7auNJEQCAd0McUPA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/koa": "*"
+      }
+    },
+    "node_modules/@types/koa-static": {
+      "version": "4.0.4",
+      "resolved": "https://registry.npmmirror.com/@types/koa-static/-/koa-static-4.0.4.tgz",
+      "integrity": "sha512-j1AUzzl7eJYEk9g01hNTlhmipFh8RFbOQmaMNLvLcNNAkPw0bdTs3XTa3V045XFlrWN0QYnblbDJv2RzawTn6A==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/koa": "*",
+        "@types/koa-send": "*"
+      }
+    },
+    "node_modules/@types/linkify-it": {
+      "version": "5.0.0",
+      "resolved": "https://registry.npmmirror.com/@types/linkify-it/-/linkify-it-5.0.0.tgz",
+      "integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@types/lodash": {
+      "version": "4.17.25",
+      "resolved": "https://registry.npmmirror.com/@types/lodash/-/lodash-4.17.25.tgz",
+      "integrity": "sha512-+K1NIO8I+F9/wNulfVvu23QYd0Pe9/OCqRrim4NoYIf1VoEDL90Ve4ClzpyqBLc7NpGGWRvYNCKZ1BE/Jpf8dQ==",
+      "license": "MIT"
+    },
+    "node_modules/@types/lodash-es": {
+      "version": "4.17.12",
+      "resolved": "https://registry.npmmirror.com/@types/lodash-es/-/lodash-es-4.17.12.tgz",
+      "integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==",
+      "license": "MIT",
+      "dependencies": {
+        "@types/lodash": "*"
+      }
+    },
+    "node_modules/@types/markdown-it": {
+      "version": "14.1.2",
+      "resolved": "https://registry.npmmirror.com/@types/markdown-it/-/markdown-it-14.1.2.tgz",
+      "integrity": "sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/linkify-it": "^5",
+        "@types/mdurl": "^2"
+      }
+    },
+    "node_modules/@types/mdurl": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmmirror.com/@types/mdurl/-/mdurl-2.0.0.tgz",
+      "integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@types/methods": {
+      "version": "1.1.4",
+      "resolved": "https://registry.npmmirror.com/@types/methods/-/methods-1.1.4.tgz",
+      "integrity": "sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@types/mime-types": {
+      "version": "2.1.4",
+      "resolved": "https://registry.npmmirror.com/@types/mime-types/-/mime-types-2.1.4.tgz",
+      "integrity": "sha512-lfU4b34HOri+kAY5UheuFMWPDOI+OPceBSHZKp69gEyTL/mmJ4cnU6Y/rlme3UL3GyOn6Y42hyIEw0/q8sWx5w==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@types/node": {
+      "version": "24.13.3",
+      "resolved": "https://registry.npmmirror.com/@types/node/-/node-24.13.3.tgz",
+      "integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "undici-types": "~7.18.0"
+      }
+    },
+    "node_modules/@types/qs": {
+      "version": "6.15.1",
+      "resolved": "https://registry.npmmirror.com/@types/qs/-/qs-6.15.1.tgz",
+      "integrity": "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@types/range-parser": {
+      "version": "1.2.7",
+      "resolved": "https://registry.npmmirror.com/@types/range-parser/-/range-parser-1.2.7.tgz",
+      "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@types/send": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmmirror.com/@types/send/-/send-1.2.1.tgz",
+      "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/node": "*"
+      }
+    },
+    "node_modules/@types/serve-static": {
+      "version": "2.2.0",
+      "resolved": "https://registry.npmmirror.com/@types/serve-static/-/serve-static-2.2.0.tgz",
+      "integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/http-errors": "*",
+        "@types/node": "*"
+      }
+    },
+    "node_modules/@types/superagent": {
+      "version": "8.1.11",
+      "resolved": "https://registry.npmmirror.com/@types/superagent/-/superagent-8.1.11.tgz",
+      "integrity": "sha512-KA7srSW/HENDtOw9DOqaFLgWuMqN9WgjEw62lh9dpvRaZDkhdOkazASd7X7i2eMUYLHa1U37ZttnePsH5zTDHw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/cookiejar": "^2.1.5",
+        "@types/methods": "^1.1.4",
+        "@types/node": "*",
+        "form-data": "^4.0.0"
+      }
+    },
+    "node_modules/@types/supertest": {
+      "version": "6.0.3",
+      "resolved": "https://registry.npmmirror.com/@types/supertest/-/supertest-6.0.3.tgz",
+      "integrity": "sha512-8WzXq62EXFhJ7QsH3Ocb/iKQ/Ty9ZVWnVzoTKc9tyyFRRF3a74Tk2+TLFgaFFw364Ere+npzHKEJ6ga2LzIL7w==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/methods": "^1.1.4",
+        "@types/superagent": "^8.1.0"
+      }
+    },
+    "node_modules/@types/tough-cookie": {
+      "version": "4.0.5",
+      "resolved": "https://registry.npmmirror.com/@types/tough-cookie/-/tough-cookie-4.0.5.tgz",
+      "integrity": "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@types/trusted-types": {
+      "version": "2.0.7",
+      "resolved": "https://registry.npmmirror.com/@types/trusted-types/-/trusted-types-2.0.7.tgz",
+      "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
+      "license": "MIT",
+      "optional": true
+    },
+    "node_modules/@types/ws": {
+      "version": "8.18.1",
+      "resolved": "https://registry.npmmirror.com/@types/ws/-/ws-8.18.1.tgz",
+      "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/node": "*"
+      }
+    },
+    "node_modules/@vicons/tabler": {
+      "version": "0.13.0",
+      "resolved": "https://registry.npmmirror.com/@vicons/tabler/-/tabler-0.13.0.tgz",
+      "integrity": "sha512-AykuhiqjszkIoAL/7knIFm6RDOBS1ZmQdJfQ+RNLEah0fVsxykUFCfMBSNZh8lOzC85EtdD1k5g/sv5GYk0Ohg==",
+      "license": "MIT"
+    },
+    "node_modules/@vitejs/plugin-vue": {
+      "version": "6.0.8",
+      "resolved": "https://registry.npmmirror.com/@vitejs/plugin-vue/-/plugin-vue-6.0.8.tgz",
+      "integrity": "sha512-0ZjgOg7oO6farnNGup7yvoM/YXZV84OZxHAwtflItNa/6zzQyVb5LNxyea3FEKEX2XlagIKzrlH7wwxkKgtiew==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@rolldown/pluginutils": "^1.0.1"
+      },
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      },
+      "peerDependencies": {
+        "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0",
+        "vue": "^3.2.25"
+      }
+    },
+    "node_modules/@vitest/coverage-v8": {
+      "version": "3.2.7",
+      "resolved": "https://registry.npmmirror.com/@vitest/coverage-v8/-/coverage-v8-3.2.7.tgz",
+      "integrity": "sha512-NEGWJS2XNu2PfRLQwOO3CTKj1tTETxNBdk454vDxVBhxJYhPaA/eS0nAI0c+1El1P7a60z8+i+ZrQoGESweGKg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@ampproject/remapping": "^2.3.0",
+        "@bcoe/v8-coverage": "^1.0.2",
+        "ast-v8-to-istanbul": "^0.3.3",
+        "debug": "^4.4.1",
+        "istanbul-lib-coverage": "^3.2.2",
+        "istanbul-lib-report": "^3.0.1",
+        "istanbul-lib-source-maps": "^5.0.6",
+        "istanbul-reports": "^3.1.7",
+        "magic-string": "^0.30.17",
+        "magicast": "^0.3.5",
+        "std-env": "^3.9.0",
+        "test-exclude": "^7.0.1",
+        "tinyrainbow": "^2.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/vitest"
+      },
+      "peerDependencies": {
+        "@vitest/browser": "3.2.7",
+        "vitest": "3.2.7"
+      },
+      "peerDependenciesMeta": {
+        "@vitest/browser": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@vitest/expect": {
+      "version": "3.2.7",
+      "resolved": "https://registry.npmmirror.com/@vitest/expect/-/expect-3.2.7.tgz",
+      "integrity": "sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/chai": "^5.2.2",
+        "@vitest/spy": "3.2.7",
+        "@vitest/utils": "3.2.7",
+        "chai": "^5.2.0",
+        "tinyrainbow": "^2.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/vitest"
+      }
+    },
+    "node_modules/@vitest/pretty-format": {
+      "version": "3.2.7",
+      "resolved": "https://registry.npmmirror.com/@vitest/pretty-format/-/pretty-format-3.2.7.tgz",
+      "integrity": "sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "tinyrainbow": "^2.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/vitest"
+      }
+    },
+    "node_modules/@vitest/runner": {
+      "version": "3.2.7",
+      "resolved": "https://registry.npmmirror.com/@vitest/runner/-/runner-3.2.7.tgz",
+      "integrity": "sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@vitest/utils": "3.2.7",
+        "pathe": "^2.0.3",
+        "strip-literal": "^3.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/vitest"
+      }
+    },
+    "node_modules/@vitest/snapshot": {
+      "version": "3.2.7",
+      "resolved": "https://registry.npmmirror.com/@vitest/snapshot/-/snapshot-3.2.7.tgz",
+      "integrity": "sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@vitest/pretty-format": "3.2.7",
+        "magic-string": "^0.30.17",
+        "pathe": "^2.0.3"
+      },
+      "funding": {
+        "url": "https://opencollective.com/vitest"
+      }
+    },
+    "node_modules/@vitest/spy": {
+      "version": "3.2.7",
+      "resolved": "https://registry.npmmirror.com/@vitest/spy/-/spy-3.2.7.tgz",
+      "integrity": "sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "tinyspy": "^4.0.3"
+      },
+      "funding": {
+        "url": "https://opencollective.com/vitest"
+      }
+    },
+    "node_modules/@vitest/utils": {
+      "version": "3.2.7",
+      "resolved": "https://registry.npmmirror.com/@vitest/utils/-/utils-3.2.7.tgz",
+      "integrity": "sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@vitest/pretty-format": "3.2.7",
+        "loupe": "^3.1.4",
+        "tinyrainbow": "^2.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/vitest"
+      }
+    },
+    "node_modules/@volar/language-core": {
+      "version": "2.4.28",
+      "resolved": "https://registry.npmmirror.com/@volar/language-core/-/language-core-2.4.28.tgz",
+      "integrity": "sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@volar/source-map": "2.4.28"
+      }
+    },
+    "node_modules/@volar/source-map": {
+      "version": "2.4.28",
+      "resolved": "https://registry.npmmirror.com/@volar/source-map/-/source-map-2.4.28.tgz",
+      "integrity": "sha512-yX2BDBqJkRXfKw8my8VarTyjv48QwxdJtvRgUpNE5erCsgEUdI2DsLbpa+rOQVAJYshY99szEcRDmyHbF10ggQ==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@volar/typescript": {
+      "version": "2.4.28",
+      "resolved": "https://registry.npmmirror.com/@volar/typescript/-/typescript-2.4.28.tgz",
+      "integrity": "sha512-Ja6yvWrbis2QtN4ClAKreeUZPVYMARDYZl9LMEv1iQ1QdepB6wn0jTRxA9MftYmYa4DQ4k/DaSZpFPUfxl8giw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@volar/language-core": "2.4.28",
+        "path-browserify": "^1.0.1",
+        "vscode-uri": "^3.0.8"
+      }
+    },
+    "node_modules/@vue/compiler-core": {
+      "version": "3.5.41",
+      "resolved": "https://registry.npmmirror.com/@vue/compiler-core/-/compiler-core-3.5.41.tgz",
+      "integrity": "sha512-q0Xtv/F9w2YO/7htQhtiL+Ev2WCJbe5N2hc+XfgyKkEKqWpSxknmT8QOuGdEKNdjPq0c3F7rNpFkTo3Kfrm7pg==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/parser": "^7.29.8",
+        "@vue/shared": "3.5.41",
+        "entities": "^7.0.1",
+        "estree-walker": "^2.0.2",
+        "source-map-js": "^1.2.1"
+      }
+    },
+    "node_modules/@vue/compiler-dom": {
+      "version": "3.5.41",
+      "resolved": "https://registry.npmmirror.com/@vue/compiler-dom/-/compiler-dom-3.5.41.tgz",
+      "integrity": "sha512-oKacVfNglLvGjnS6BXOlGL7EyG2h8X03pqXCjzotRZUaXGjbrTJUnVAQjrCqUnS+lyu31nwQjZY/d817GmCnfw==",
+      "license": "MIT",
+      "dependencies": {
+        "@vue/compiler-core": "3.5.41",
+        "@vue/shared": "3.5.41"
+      }
+    },
+    "node_modules/@vue/compiler-sfc": {
+      "version": "3.5.41",
+      "resolved": "https://registry.npmmirror.com/@vue/compiler-sfc/-/compiler-sfc-3.5.41.tgz",
+      "integrity": "sha512-XJhip7R2wy6vX3knCxdZN4KracFaZUef58s1KYewqluedHIJaPIVfXoYT7MF1F8nCvv6k8bWWxDC8opMkg1VTQ==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/parser": "^7.29.8",
+        "@vue/compiler-core": "3.5.41",
+        "@vue/compiler-dom": "3.5.41",
+        "@vue/compiler-ssr": "3.5.41",
+        "@vue/shared": "3.5.41",
+        "estree-walker": "^2.0.2",
+        "magic-string": "^0.30.21",
+        "postcss": "^8.5.19",
+        "source-map-js": "^1.2.1"
+      }
+    },
+    "node_modules/@vue/compiler-ssr": {
+      "version": "3.5.41",
+      "resolved": "https://registry.npmmirror.com/@vue/compiler-ssr/-/compiler-ssr-3.5.41.tgz",
+      "integrity": "sha512-U3v5OejKEGqOI0Wy0+Sz7hGuIFZHA4LSXzrNM3IMIeDyJEBBfTpX26n3SDgToRpP2bLc9FfI2j/kSgcJ8Emq5A==",
+      "license": "MIT",
+      "dependencies": {
+        "@vue/compiler-dom": "3.5.41",
+        "@vue/shared": "3.5.41"
+      }
+    },
+    "node_modules/@vue/devtools-api": {
+      "version": "7.7.10",
+      "resolved": "https://registry.npmmirror.com/@vue/devtools-api/-/devtools-api-7.7.10.tgz",
+      "integrity": "sha512-KxtEpUOOpFz/qOGRrAwA36QF7DqIA+FXgCYit9mk9wjbaZt0sXOFz81ElOZtKA4HbWHUdwNjZHBFsFFyp5BZiA==",
+      "license": "MIT",
+      "dependencies": {
+        "@vue/devtools-kit": "^7.7.10"
+      }
+    },
+    "node_modules/@vue/devtools-kit": {
+      "version": "7.7.10",
+      "resolved": "https://registry.npmmirror.com/@vue/devtools-kit/-/devtools-kit-7.7.10.tgz",
+      "integrity": "sha512-3WNi2Kq4tbpVbmhml7RiphmAt0279oh3fKNeWMQIrltfX8Q91b4i5PL8DtyNKdwmcsGrV4fg+erwWOmD05CLIw==",
+      "license": "MIT",
+      "dependencies": {
+        "@vue/devtools-shared": "^7.7.10",
+        "birpc": "^2.3.0",
+        "hookable": "^5.5.3",
+        "mitt": "^3.0.1",
+        "perfect-debounce": "^1.0.0",
+        "speakingurl": "^14.0.1",
+        "superjson": "^2.2.2"
+      }
+    },
+    "node_modules/@vue/devtools-shared": {
+      "version": "7.7.10",
+      "resolved": "https://registry.npmmirror.com/@vue/devtools-shared/-/devtools-shared-7.7.10.tgz",
+      "integrity": "sha512-wOPslzB8vTvpxwdaOcR2qAbwmuSP0L+rhpoC6Cf56V3Jip+HWb7PQQXOUPgBNQARpXsbQX/+mvi8kKucmBGRwQ==",
+      "license": "MIT",
+      "dependencies": {
+        "rfdc": "^1.4.1"
+      }
+    },
+    "node_modules/@vue/language-core": {
+      "version": "3.3.10",
+      "resolved": "https://registry.npmmirror.com/@vue/language-core/-/language-core-3.3.10.tgz",
+      "integrity": "sha512-CR7ByBbgPHqhxrioKPOcZBqttaozzLNwtkCzXQ+uF8gLPHnUe03srPnGpdtHD3zp+bq5iyVkZ1WNx7W564RPwg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@volar/language-core": "2.4.28",
+        "@vue/compiler-dom": "^3.5.0",
+        "@vue/shared": "^3.5.0",
+        "alien-signals": "^3.2.1",
+        "muggle-string": "^0.4.1",
+        "path-browserify": "^1.0.1",
+        "picomatch": "^4.0.4"
+      }
+    },
+    "node_modules/@vue/reactivity": {
+      "version": "3.5.41",
+      "resolved": "https://registry.npmmirror.com/@vue/reactivity/-/reactivity-3.5.41.tgz",
+      "integrity": "sha512-rznsqKM0np0x18EjzF8x88MpEhdNsffbvFbckLL5+oUKz1BxAImEmO7J1ArRYSyo6aQaVoBDp7jEkT91OOxydA==",
+      "license": "MIT",
+      "dependencies": {
+        "@vue/shared": "3.5.41"
+      }
+    },
+    "node_modules/@vue/runtime-core": {
+      "version": "3.5.41",
+      "resolved": "https://registry.npmmirror.com/@vue/runtime-core/-/runtime-core-3.5.41.tgz",
+      "integrity": "sha512-Vcry58hiAKwGen9Z1jUZE0feFsNArPCMOImYI8el48A9Idf6DuQYD0U05zZIF2Iad1hGhPSvcbBbAOhNr55fhg==",
+      "license": "MIT",
+      "dependencies": {
+        "@vue/reactivity": "3.5.41",
+        "@vue/shared": "3.5.41"
+      }
+    },
+    "node_modules/@vue/runtime-dom": {
+      "version": "3.5.41",
+      "resolved": "https://registry.npmmirror.com/@vue/runtime-dom/-/runtime-dom-3.5.41.tgz",
+      "integrity": "sha512-3vVBahVBS9+U6cmXBLyb8nE6/yYo4J/CGI9eVFs3KiMc0YHuudwKyShTD65jtJy/L9PUUxNAFu4cj4LiJ0UFbw==",
+      "license": "MIT",
+      "dependencies": {
+        "@vue/reactivity": "3.5.41",
+        "@vue/runtime-core": "3.5.41",
+        "@vue/shared": "3.5.41",
+        "csstype": "^3.2.3"
+      }
+    },
+    "node_modules/@vue/server-renderer": {
+      "version": "3.5.41",
+      "resolved": "https://registry.npmmirror.com/@vue/server-renderer/-/server-renderer-3.5.41.tgz",
+      "integrity": "sha512-n6hx/pNFfbD6SuyeuMVkvqox8bwf/ET9JlA/kAz/imw8sw++wkqKe2mHX5KutjPpbKE4Z56yTHszoOjGMI9igQ==",
+      "license": "MIT",
+      "dependencies": {
+        "@vue/compiler-ssr": "3.5.41",
+        "@vue/runtime-dom": "3.5.41",
+        "@vue/shared": "3.5.41"
+      }
+    },
+    "node_modules/@vue/shared": {
+      "version": "3.5.41",
+      "resolved": "https://registry.npmmirror.com/@vue/shared/-/shared-3.5.41.tgz",
+      "integrity": "sha512-IOnwSCma8j+9xJT6b8H0dEYidC80NsYmNMlZxRsukYcSoGaDBohog5hDxzeUXdFeGWFA++vWvxqOmrr96VlqMA==",
+      "license": "MIT"
+    },
+    "node_modules/@vue/test-utils": {
+      "version": "2.4.11",
+      "resolved": "https://registry.npmmirror.com/@vue/test-utils/-/test-utils-2.4.11.tgz",
+      "integrity": "sha512-GDqaqZsA6m2E5vNzej0aYiIb6BX8xV9pNSbbbXKOfEYwg7ZNblVX8suyqmUBThq8VIrgAJNxn+z72hVtUeiWHA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "js-beautify": "^1.14.9",
+        "vue-component-type-helpers": "^3.0.0"
+      },
+      "peerDependencies": {
+        "@vue/compiler-dom": "3.x",
+        "@vue/server-renderer": "3.x",
+        "vue": "3.x"
+      },
+      "peerDependenciesMeta": {
+        "@vue/server-renderer": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@vue/tsconfig": {
+      "version": "0.9.1",
+      "resolved": "https://registry.npmmirror.com/@vue/tsconfig/-/tsconfig-0.9.1.tgz",
+      "integrity": "sha512-buvjm+9NzLCJL29KY1j1991YYJ5e6275OiK+G4jtmfIb+z4POywbdm0wXusT9adVWqe0xqg70TbI7+mRx4uU9w==",
+      "dev": true,
+      "license": "MIT",
+      "peerDependencies": {
+        "typescript": ">= 5.8",
+        "vue": "^3.4.0"
+      },
+      "peerDependenciesMeta": {
+        "typescript": {
+          "optional": true
+        },
+        "vue": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@xmldom/xmldom": {
+      "version": "0.8.14",
+      "resolved": "https://registry.npmmirror.com/@xmldom/xmldom/-/xmldom-0.8.14.tgz",
+      "integrity": "sha512-T4EDRUBVZYRldYApjEJiU0e1stYWaRAX7CuSnKzrpwdZKo53zGV8/pqfzV6FfwNl9YThD2OumQYvqtvjvgG7aQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=10.0.0"
+      }
+    },
+    "node_modules/abbrev": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmmirror.com/abbrev/-/abbrev-2.0.0.tgz",
+      "integrity": "sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ==",
+      "dev": true,
+      "license": "ISC",
+      "engines": {
+        "node": "^14.17.0 || ^16.13.0 || >=18.0.0"
+      }
+    },
+    "node_modules/accepts": {
+      "version": "1.3.8",
+      "resolved": "https://registry.npmmirror.com/accepts/-/accepts-1.3.8.tgz",
+      "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
+      "license": "MIT",
+      "dependencies": {
+        "mime-types": "~2.1.34",
+        "negotiator": "0.6.3"
+      },
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/accepts/node_modules/mime-db": {
+      "version": "1.52.0",
+      "resolved": "https://registry.npmmirror.com/mime-db/-/mime-db-1.52.0.tgz",
+      "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/accepts/node_modules/mime-types": {
+      "version": "2.1.35",
+      "resolved": "https://registry.npmmirror.com/mime-types/-/mime-types-2.1.35.tgz",
+      "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
+      "license": "MIT",
+      "dependencies": {
+        "mime-db": "1.52.0"
+      },
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/agent-base": {
+      "version": "7.1.4",
+      "resolved": "https://registry.npmmirror.com/agent-base/-/agent-base-7.1.4.tgz",
+      "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">= 14"
+      }
+    },
+    "node_modules/alien-signals": {
+      "version": "3.2.1",
+      "resolved": "https://registry.npmmirror.com/alien-signals/-/alien-signals-3.2.1.tgz",
+      "integrity": "sha512-I8FjmltrfnDFoZedi5CG8DghVYNhzb/Ijluz7tCSJH0xpd0484Kowhbb1XDYOxfJpU1p5wnM2X54dA+IfGyD1g==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/ansi-regex": {
+      "version": "6.3.0",
+      "resolved": "https://registry.npmmirror.com/ansi-regex/-/ansi-regex-6.3.0.tgz",
+      "integrity": "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=12"
+      },
+      "funding": {
+        "url": "https://github.com/chalk/ansi-regex?sponsor=1"
+      }
+    },
+    "node_modules/ansi-styles": {
+      "version": "6.2.3",
+      "resolved": "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-6.2.3.tgz",
+      "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=12"
+      },
+      "funding": {
+        "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+      }
+    },
+    "node_modules/argparse": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmmirror.com/argparse/-/argparse-2.0.1.tgz",
+      "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
+      "license": "Python-2.0"
+    },
+    "node_modules/asap": {
+      "version": "2.0.6",
+      "resolved": "https://registry.npmmirror.com/asap/-/asap-2.0.6.tgz",
+      "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/assertion-error": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmmirror.com/assertion-error/-/assertion-error-2.0.1.tgz",
+      "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/ast-v8-to-istanbul": {
+      "version": "0.3.12",
+      "resolved": "https://registry.npmmirror.com/ast-v8-to-istanbul/-/ast-v8-to-istanbul-0.3.12.tgz",
+      "integrity": "sha512-BRRC8VRZY2R4Z4lFIL35MwNXmwVqBityvOIwETtsCSwvjl0IdgFsy9NhdaA6j74nUdtJJlIypeRhpDam19Wq3g==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@jridgewell/trace-mapping": "^0.3.31",
+        "estree-walker": "^3.0.3",
+        "js-tokens": "^10.0.0"
+      }
+    },
+    "node_modules/ast-v8-to-istanbul/node_modules/estree-walker": {
+      "version": "3.0.3",
+      "resolved": "https://registry.npmmirror.com/estree-walker/-/estree-walker-3.0.3.tgz",
+      "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/estree": "^1.0.0"
+      }
+    },
+    "node_modules/async-validator": {
+      "version": "4.2.5",
+      "resolved": "https://registry.npmmirror.com/async-validator/-/async-validator-4.2.5.tgz",
+      "integrity": "sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==",
+      "license": "MIT"
+    },
+    "node_modules/asynckit": {
+      "version": "0.4.0",
+      "resolved": "https://registry.npmmirror.com/asynckit/-/asynckit-0.4.0.tgz",
+      "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/balanced-match": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmmirror.com/balanced-match/-/balanced-match-1.0.2.tgz",
+      "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/bidi-js": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmmirror.com/bidi-js/-/bidi-js-1.0.3.tgz",
+      "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "require-from-string": "^2.0.2"
+      }
+    },
+    "node_modules/birpc": {
+      "version": "2.9.0",
+      "resolved": "https://registry.npmmirror.com/birpc/-/birpc-2.9.0.tgz",
+      "integrity": "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==",
+      "license": "MIT",
+      "funding": {
+        "url": "https://github.com/sponsors/antfu"
+      }
+    },
+    "node_modules/bluebird": {
+      "version": "3.7.2",
+      "resolved": "https://registry.npmmirror.com/bluebird/-/bluebird-3.7.2.tgz",
+      "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==",
+      "license": "MIT"
+    },
+    "node_modules/brace-expansion": {
+      "version": "2.1.4",
+      "resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-2.1.4.tgz",
+      "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "balanced-match": "^1.0.0"
+      }
+    },
+    "node_modules/busboy": {
+      "version": "1.6.0",
+      "resolved": "https://registry.npmmirror.com/busboy/-/busboy-1.6.0.tgz",
+      "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==",
+      "dependencies": {
+        "streamsearch": "^1.1.0"
+      },
+      "engines": {
+        "node": ">=10.16.0"
+      }
+    },
+    "node_modules/bytes": {
+      "version": "3.1.2",
+      "resolved": "https://registry.npmmirror.com/bytes/-/bytes-3.1.2.tgz",
+      "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/cac": {
+      "version": "6.7.14",
+      "resolved": "https://registry.npmmirror.com/cac/-/cac-6.7.14.tgz",
+      "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/cache-content-type": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmmirror.com/cache-content-type/-/cache-content-type-1.0.1.tgz",
+      "integrity": "sha512-IKufZ1o4Ut42YUrZSo8+qnMTrFuKkvyoLXUywKz9GJ5BrhOFGhLdkx9sG4KAnVvbY6kEcSFjLQul+DVmBm2bgA==",
+      "license": "MIT",
+      "dependencies": {
+        "mime-types": "^2.1.18",
+        "ylru": "^1.2.0"
+      },
+      "engines": {
+        "node": ">= 6.0.0"
+      }
+    },
+    "node_modules/cache-content-type/node_modules/mime-db": {
+      "version": "1.52.0",
+      "resolved": "https://registry.npmmirror.com/mime-db/-/mime-db-1.52.0.tgz",
+      "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/cache-content-type/node_modules/mime-types": {
+      "version": "2.1.35",
+      "resolved": "https://registry.npmmirror.com/mime-types/-/mime-types-2.1.35.tgz",
+      "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
+      "license": "MIT",
+      "dependencies": {
+        "mime-db": "1.52.0"
+      },
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/call-bind-apply-helpers": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmmirror.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
+      "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0",
+        "function-bind": "^1.1.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/call-bound": {
+      "version": "1.0.4",
+      "resolved": "https://registry.npmmirror.com/call-bound/-/call-bound-1.0.4.tgz",
+      "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bind-apply-helpers": "^1.0.2",
+        "get-intrinsic": "^1.3.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/chai": {
+      "version": "5.3.3",
+      "resolved": "https://registry.npmmirror.com/chai/-/chai-5.3.3.tgz",
+      "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "assertion-error": "^2.0.1",
+        "check-error": "^2.1.1",
+        "deep-eql": "^5.0.1",
+        "loupe": "^3.1.0",
+        "pathval": "^2.0.0"
+      },
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/check-error": {
+      "version": "2.1.3",
+      "resolved": "https://registry.npmmirror.com/check-error/-/check-error-2.1.3.tgz",
+      "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">= 16"
+      }
+    },
+    "node_modules/chokidar": {
+      "version": "5.0.0",
+      "resolved": "https://registry.npmmirror.com/chokidar/-/chokidar-5.0.0.tgz",
+      "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "readdirp": "^5.0.0"
+      },
+      "engines": {
+        "node": ">= 20.19.0"
+      },
+      "funding": {
+        "url": "https://paulmillr.com/funding/"
+      }
+    },
+    "node_modules/co": {
+      "version": "4.6.0",
+      "resolved": "https://registry.npmmirror.com/co/-/co-4.6.0.tgz",
+      "integrity": "sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==",
+      "license": "MIT",
+      "engines": {
+        "iojs": ">= 1.0.0",
+        "node": ">= 0.12.0"
+      }
+    },
+    "node_modules/co-body": {
+      "version": "6.2.0",
+      "resolved": "https://registry.npmmirror.com/co-body/-/co-body-6.2.0.tgz",
+      "integrity": "sha512-Kbpv2Yd1NdL1V/V4cwLVxraHDV6K8ayohr2rmH0J87Er8+zJjcTa6dAn9QMPC9CRgU8+aNajKbSf1TzDB1yKPA==",
+      "license": "MIT",
+      "dependencies": {
+        "@hapi/bourne": "^3.0.0",
+        "inflation": "^2.0.0",
+        "qs": "^6.5.2",
+        "raw-body": "^2.3.3",
+        "type-is": "^1.6.16"
+      },
+      "engines": {
+        "node": ">=8.0.0"
+      }
+    },
+    "node_modules/color-convert": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmmirror.com/color-convert/-/color-convert-2.0.1.tgz",
+      "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "color-name": "~1.1.4"
+      },
+      "engines": {
+        "node": ">=7.0.0"
+      }
+    },
+    "node_modules/color-name": {
+      "version": "1.1.4",
+      "resolved": "https://registry.npmmirror.com/color-name/-/color-name-1.1.4.tgz",
+      "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/combined-stream": {
+      "version": "1.0.8",
+      "resolved": "https://registry.npmmirror.com/combined-stream/-/combined-stream-1.0.8.tgz",
+      "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "delayed-stream": "~1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/commander": {
+      "version": "10.0.1",
+      "resolved": "https://registry.npmmirror.com/commander/-/commander-10.0.1.tgz",
+      "integrity": "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=14"
+      }
+    },
+    "node_modules/component-emitter": {
+      "version": "1.3.1",
+      "resolved": "https://registry.npmmirror.com/component-emitter/-/component-emitter-1.3.1.tgz",
+      "integrity": "sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==",
+      "dev": true,
+      "license": "MIT",
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/config-chain": {
+      "version": "1.1.13",
+      "resolved": "https://registry.npmmirror.com/config-chain/-/config-chain-1.1.13.tgz",
+      "integrity": "sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "ini": "^1.3.4",
+        "proto-list": "~1.2.1"
+      }
+    },
+    "node_modules/content-disposition": {
+      "version": "0.5.4",
+      "resolved": "https://registry.npmmirror.com/content-disposition/-/content-disposition-0.5.4.tgz",
+      "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
+      "license": "MIT",
+      "dependencies": {
+        "safe-buffer": "5.2.1"
+      },
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/content-type": {
+      "version": "1.0.5",
+      "resolved": "https://registry.npmmirror.com/content-type/-/content-type-1.0.5.tgz",
+      "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/cookie": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmmirror.com/cookie/-/cookie-1.1.1.tgz",
+      "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=18"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/cookie-signature": {
+      "version": "1.2.2",
+      "resolved": "https://registry.npmmirror.com/cookie-signature/-/cookie-signature-1.2.2.tgz",
+      "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=6.6.0"
+      }
+    },
+    "node_modules/cookiejar": {
+      "version": "2.1.4",
+      "resolved": "https://registry.npmmirror.com/cookiejar/-/cookiejar-2.1.4.tgz",
+      "integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/cookies": {
+      "version": "0.9.1",
+      "resolved": "https://registry.npmmirror.com/cookies/-/cookies-0.9.1.tgz",
+      "integrity": "sha512-TG2hpqe4ELx54QER/S3HQ9SRVnQnGBtKUz5bLQWtYAQ+o6GpgMs6sYUvaiJjVxb+UXwhRhAEP3m7LbsIZ77Hmw==",
+      "license": "MIT",
+      "dependencies": {
+        "depd": "~2.0.0",
+        "keygrip": "~1.1.0"
+      },
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/copy-anything": {
+      "version": "4.0.5",
+      "resolved": "https://registry.npmmirror.com/copy-anything/-/copy-anything-4.0.5.tgz",
+      "integrity": "sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==",
+      "license": "MIT",
+      "dependencies": {
+        "is-what": "^5.2.0"
+      },
+      "engines": {
+        "node": ">=18"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/mesqueeb"
+      }
+    },
+    "node_modules/core-util-is": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmmirror.com/core-util-is/-/core-util-is-1.0.3.tgz",
+      "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
+      "license": "MIT"
+    },
+    "node_modules/cross-env": {
+      "version": "10.1.0",
+      "resolved": "https://registry.npmmirror.com/cross-env/-/cross-env-10.1.0.tgz",
+      "integrity": "sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@epic-web/invariant": "^1.0.0",
+        "cross-spawn": "^7.0.6"
+      },
+      "bin": {
+        "cross-env": "dist/bin/cross-env.js",
+        "cross-env-shell": "dist/bin/cross-env-shell.js"
+      },
+      "engines": {
+        "node": ">=20"
+      }
+    },
+    "node_modules/cross-spawn": {
+      "version": "7.0.6",
+      "resolved": "https://registry.npmmirror.com/cross-spawn/-/cross-spawn-7.0.6.tgz",
+      "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "path-key": "^3.1.0",
+        "shebang-command": "^2.0.0",
+        "which": "^2.0.1"
+      },
+      "engines": {
+        "node": ">= 8"
+      }
+    },
+    "node_modules/css-render": {
+      "version": "0.15.14",
+      "resolved": "https://registry.npmmirror.com/css-render/-/css-render-0.15.14.tgz",
+      "integrity": "sha512-9nF4PdUle+5ta4W5SyZdLCCmFd37uVimSjg1evcTqKJCyvCEEj12WKzOSBNak6r4im4J4iYXKH1OWpUV5LBYFg==",
+      "license": "MIT",
+      "dependencies": {
+        "@emotion/hash": "~0.8.0",
+        "csstype": "~3.0.5"
+      }
+    },
+    "node_modules/css-render/node_modules/csstype": {
+      "version": "3.0.11",
+      "resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.0.11.tgz",
+      "integrity": "sha512-sa6P2wJ+CAbgyy4KFssIb/JNMLxFvKF1pCYCSXS8ZMuqZnMsrxqI2E5sPyoTpxoPU/gVZMzr2zjOfg8GIZOMsw==",
+      "license": "MIT"
+    },
+    "node_modules/css-tree": {
+      "version": "3.2.1",
+      "resolved": "https://registry.npmmirror.com/css-tree/-/css-tree-3.2.1.tgz",
+      "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "mdn-data": "2.27.1",
+        "source-map-js": "^1.2.1"
+      },
+      "engines": {
+        "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0"
+      }
+    },
+    "node_modules/cssstyle": {
+      "version": "5.3.7",
+      "resolved": "https://registry.npmmirror.com/cssstyle/-/cssstyle-5.3.7.tgz",
+      "integrity": "sha512-7D2EPVltRrsTkhpQmksIu+LxeWAIEk6wRDMJ1qljlv+CKHJM+cJLlfhWIzNA44eAsHXSNe3+vO6DW1yCYx8SuQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@asamuzakjp/css-color": "^4.1.1",
+        "@csstools/css-syntax-patches-for-csstree": "^1.0.21",
+        "css-tree": "^3.1.0",
+        "lru-cache": "^11.2.4"
+      },
+      "engines": {
+        "node": ">=20"
+      }
+    },
+    "node_modules/csstype": {
+      "version": "3.2.3",
+      "resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz",
+      "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
+      "license": "MIT"
+    },
+    "node_modules/data-urls": {
+      "version": "6.0.1",
+      "resolved": "https://registry.npmmirror.com/data-urls/-/data-urls-6.0.1.tgz",
+      "integrity": "sha512-euIQENZg6x8mj3fO6o9+fOW8MimUI4PpD/fZBhJfeioZVy9TUpM4UY7KjQNVZFlqwJ0UdzRDzkycB997HEq1BQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "whatwg-mimetype": "^5.0.0",
+        "whatwg-url": "^15.1.0"
+      },
+      "engines": {
+        "node": ">=20"
+      }
+    },
+    "node_modules/data-urls/node_modules/whatwg-mimetype": {
+      "version": "5.0.0",
+      "resolved": "https://registry.npmmirror.com/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz",
+      "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=20"
+      }
+    },
+    "node_modules/date-fns": {
+      "version": "4.4.0",
+      "resolved": "https://registry.npmmirror.com/date-fns/-/date-fns-4.4.0.tgz",
+      "integrity": "sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w==",
+      "license": "MIT",
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/kossnocorp"
+      }
+    },
+    "node_modules/date-fns-tz": {
+      "version": "3.2.0",
+      "resolved": "https://registry.npmmirror.com/date-fns-tz/-/date-fns-tz-3.2.0.tgz",
+      "integrity": "sha512-sg8HqoTEulcbbbVXeg84u5UnlsQa8GS5QXMqjjYIhS4abEVVKIUwe0/l/UhrZdKaL/W5eWZNlbTeEIiOXTcsBQ==",
+      "license": "MIT",
+      "peerDependencies": {
+        "date-fns": "^3.0.0 || ^4.0.0"
+      }
+    },
+    "node_modules/debug": {
+      "version": "4.4.3",
+      "resolved": "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz",
+      "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+      "license": "MIT",
+      "dependencies": {
+        "ms": "^2.1.3"
+      },
+      "engines": {
+        "node": ">=6.0"
+      },
+      "peerDependenciesMeta": {
+        "supports-color": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/decimal.js": {
+      "version": "10.6.0",
+      "resolved": "https://registry.npmmirror.com/decimal.js/-/decimal.js-10.6.0.tgz",
+      "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/deep-eql": {
+      "version": "5.0.2",
+      "resolved": "https://registry.npmmirror.com/deep-eql/-/deep-eql-5.0.2.tgz",
+      "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/deep-equal": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmmirror.com/deep-equal/-/deep-equal-1.0.1.tgz",
+      "integrity": "sha512-bHtC0iYvWhyaTzvV3CZgPeZQqCOBGyGsVV7v4eevpdkLHfiSrXUdBG+qAuSz4RI70sszvjQ1QSZ98An1yNwpSw==",
+      "license": "MIT"
+    },
+    "node_modules/delayed-stream": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmmirror.com/delayed-stream/-/delayed-stream-1.0.0.tgz",
+      "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.4.0"
+      }
+    },
+    "node_modules/delegates": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmmirror.com/delegates/-/delegates-1.0.0.tgz",
+      "integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==",
+      "license": "MIT"
+    },
+    "node_modules/depd": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmmirror.com/depd/-/depd-2.0.0.tgz",
+      "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/destroy": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmmirror.com/destroy/-/destroy-1.2.0.tgz",
+      "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8",
+        "npm": "1.2.8000 || >= 1.4.16"
+      }
+    },
+    "node_modules/detect-libc": {
+      "version": "2.1.2",
+      "resolved": "https://registry.npmmirror.com/detect-libc/-/detect-libc-2.1.2.tgz",
+      "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/dezalgo": {
+      "version": "1.0.4",
+      "resolved": "https://registry.npmmirror.com/dezalgo/-/dezalgo-1.0.4.tgz",
+      "integrity": "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==",
+      "dev": true,
+      "license": "ISC",
+      "dependencies": {
+        "asap": "^2.0.0",
+        "wrappy": "1"
+      }
+    },
+    "node_modules/docx-preview": {
+      "version": "0.3.7",
+      "resolved": "https://registry.npmmirror.com/docx-preview/-/docx-preview-0.3.7.tgz",
+      "integrity": "sha512-Lav69CTA/IYZPJTsKH7oYeoZjyg96N0wEJMNslGJnZJ+dMUZK85Lt5ASC79yUlD48ecWjuv+rkcmFt6EVPV0Xg==",
+      "license": "Apache-2.0",
+      "dependencies": {
+        "jszip": ">=3.0.0"
+      }
+    },
+    "node_modules/dompurify": {
+      "version": "3.4.13",
+      "resolved": "https://registry.npmmirror.com/dompurify/-/dompurify-3.4.13.tgz",
+      "integrity": "sha512-2vmYIoqjze2d+kakP8S/nS5shfsl587kzwEjcGlTdiksUVgFHnFCsLYDVj/JNqJVOQZGSYBTmuycv0PodwmnMQ==",
+      "license": "(MPL-2.0 OR Apache-2.0)",
+      "optionalDependencies": {
+        "@types/trusted-types": "^2.0.7"
+      }
+    },
+    "node_modules/dunder-proto": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmmirror.com/dunder-proto/-/dunder-proto-1.0.1.tgz",
+      "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bind-apply-helpers": "^1.0.1",
+        "es-errors": "^1.3.0",
+        "gopd": "^1.2.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/duplexer2": {
+      "version": "0.1.4",
+      "resolved": "https://registry.npmmirror.com/duplexer2/-/duplexer2-0.1.4.tgz",
+      "integrity": "sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA==",
+      "license": "BSD-3-Clause",
+      "dependencies": {
+        "readable-stream": "^2.0.2"
+      }
+    },
+    "node_modules/eastasianwidth": {
+      "version": "0.2.0",
+      "resolved": "https://registry.npmmirror.com/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
+      "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/editorconfig": {
+      "version": "1.0.7",
+      "resolved": "https://registry.npmmirror.com/editorconfig/-/editorconfig-1.0.7.tgz",
+      "integrity": "sha512-e0GOtq/aTQhVdNyDU9e02+wz9oDDM+SIOQxWME2QRjzRX5yyLAuHDE+0aE8vHb9XRC8XD37eO2u57+F09JqFhw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@one-ini/wasm": "0.1.1",
+        "commander": "^10.0.0",
+        "minimatch": "^9.0.1",
+        "semver": "^7.5.3"
+      },
+      "bin": {
+        "editorconfig": "bin/editorconfig"
+      },
+      "engines": {
+        "node": ">=14"
+      }
+    },
+    "node_modules/ee-first": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmmirror.com/ee-first/-/ee-first-1.1.1.tgz",
+      "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
+      "license": "MIT"
+    },
+    "node_modules/emoji-regex": {
+      "version": "9.2.2",
+      "resolved": "https://registry.npmmirror.com/emoji-regex/-/emoji-regex-9.2.2.tgz",
+      "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/encodeurl": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmmirror.com/encodeurl/-/encodeurl-1.0.2.tgz",
+      "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/entities": {
+      "version": "7.0.1",
+      "resolved": "https://registry.npmmirror.com/entities/-/entities-7.0.1.tgz",
+      "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
+      "license": "BSD-2-Clause",
+      "engines": {
+        "node": ">=0.12"
+      },
+      "funding": {
+        "url": "https://github.com/fb55/entities?sponsor=1"
+      }
+    },
+    "node_modules/es-define-property": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmmirror.com/es-define-property/-/es-define-property-1.0.1.tgz",
+      "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/es-errors": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmmirror.com/es-errors/-/es-errors-1.3.0.tgz",
+      "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/es-module-lexer": {
+      "version": "1.7.0",
+      "resolved": "https://registry.npmmirror.com/es-module-lexer/-/es-module-lexer-1.7.0.tgz",
+      "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/es-object-atoms": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmmirror.com/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
+      "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/es-set-tostringtag": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmmirror.com/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
+      "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0",
+        "get-intrinsic": "^1.2.6",
+        "has-tostringtag": "^1.0.2",
+        "hasown": "^2.0.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/esbuild": {
+      "version": "0.28.2",
+      "resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.28.2.tgz",
+      "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==",
+      "dev": true,
+      "hasInstallScript": true,
+      "license": "MIT",
+      "bin": {
+        "esbuild": "bin/esbuild"
+      },
+      "engines": {
+        "node": ">=18"
+      },
+      "optionalDependencies": {
+        "@esbuild/aix-ppc64": "0.28.2",
+        "@esbuild/android-arm": "0.28.2",
+        "@esbuild/android-arm64": "0.28.2",
+        "@esbuild/android-x64": "0.28.2",
+        "@esbuild/darwin-arm64": "0.28.2",
+        "@esbuild/darwin-x64": "0.28.2",
+        "@esbuild/freebsd-arm64": "0.28.2",
+        "@esbuild/freebsd-x64": "0.28.2",
+        "@esbuild/linux-arm": "0.28.2",
+        "@esbuild/linux-arm64": "0.28.2",
+        "@esbuild/linux-ia32": "0.28.2",
+        "@esbuild/linux-loong64": "0.28.2",
+        "@esbuild/linux-mips64el": "0.28.2",
+        "@esbuild/linux-ppc64": "0.28.2",
+        "@esbuild/linux-riscv64": "0.28.2",
+        "@esbuild/linux-s390x": "0.28.2",
+        "@esbuild/linux-x64": "0.28.2",
+        "@esbuild/netbsd-arm64": "0.28.2",
+        "@esbuild/netbsd-x64": "0.28.2",
+        "@esbuild/openbsd-arm64": "0.28.2",
+        "@esbuild/openbsd-x64": "0.28.2",
+        "@esbuild/openharmony-arm64": "0.28.2",
+        "@esbuild/sunos-x64": "0.28.2",
+        "@esbuild/win32-arm64": "0.28.2",
+        "@esbuild/win32-ia32": "0.28.2",
+        "@esbuild/win32-x64": "0.28.2"
+      }
+    },
+    "node_modules/escape-html": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmmirror.com/escape-html/-/escape-html-1.0.3.tgz",
+      "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
+      "license": "MIT"
+    },
+    "node_modules/estree-walker": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmmirror.com/estree-walker/-/estree-walker-2.0.2.tgz",
+      "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
+      "license": "MIT"
+    },
+    "node_modules/evtd": {
+      "version": "0.2.4",
+      "resolved": "https://registry.npmmirror.com/evtd/-/evtd-0.2.4.tgz",
+      "integrity": "sha512-qaeGN5bx63s/AXgQo8gj6fBkxge+OoLddLniox5qtLAEY5HSnuSlISXVPxnSae1dWblvTh4/HoMIB+mbMsvZzw==",
+      "license": "MIT"
+    },
+    "node_modules/expect-type": {
+      "version": "1.4.0",
+      "resolved": "https://registry.npmmirror.com/expect-type/-/expect-type-1.4.0.tgz",
+      "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "engines": {
+        "node": ">=12.0.0"
+      }
+    },
+    "node_modules/fast-safe-stringify": {
+      "version": "2.1.1",
+      "resolved": "https://registry.npmmirror.com/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz",
+      "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/fdir": {
+      "version": "6.5.0",
+      "resolved": "https://registry.npmmirror.com/fdir/-/fdir-6.5.0.tgz",
+      "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=12.0.0"
+      },
+      "peerDependencies": {
+        "picomatch": "^3 || ^4"
+      },
+      "peerDependenciesMeta": {
+        "picomatch": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/fflate": {
+      "version": "0.8.3",
+      "resolved": "https://registry.npmmirror.com/fflate/-/fflate-0.8.3.tgz",
+      "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
+      "license": "MIT"
+    },
+    "node_modules/foreground-child": {
+      "version": "3.3.1",
+      "resolved": "https://registry.npmmirror.com/foreground-child/-/foreground-child-3.3.1.tgz",
+      "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==",
+      "dev": true,
+      "license": "ISC",
+      "dependencies": {
+        "cross-spawn": "^7.0.6",
+        "signal-exit": "^4.0.1"
+      },
+      "engines": {
+        "node": ">=14"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/isaacs"
+      }
+    },
+    "node_modules/form-data": {
+      "version": "4.0.6",
+      "resolved": "https://registry.npmmirror.com/form-data/-/form-data-4.0.6.tgz",
+      "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "asynckit": "^0.4.0",
+        "combined-stream": "^1.0.8",
+        "es-set-tostringtag": "^2.1.0",
+        "hasown": "^2.0.4",
+        "mime-types": "^2.1.35"
+      },
+      "engines": {
+        "node": ">= 6"
+      }
+    },
+    "node_modules/form-data/node_modules/mime-db": {
+      "version": "1.52.0",
+      "resolved": "https://registry.npmmirror.com/mime-db/-/mime-db-1.52.0.tgz",
+      "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/form-data/node_modules/mime-types": {
+      "version": "2.1.35",
+      "resolved": "https://registry.npmmirror.com/mime-types/-/mime-types-2.1.35.tgz",
+      "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "mime-db": "1.52.0"
+      },
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/formidable": {
+      "version": "3.5.4",
+      "resolved": "https://registry.npmmirror.com/formidable/-/formidable-3.5.4.tgz",
+      "integrity": "sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@paralleldrive/cuid2": "^2.2.2",
+        "dezalgo": "^1.0.4",
+        "once": "^1.4.0"
+      },
+      "engines": {
+        "node": ">=14.0.0"
+      },
+      "funding": {
+        "url": "https://ko-fi.com/tunnckoCore/commissions"
+      }
+    },
+    "node_modules/fresh": {
+      "version": "0.5.2",
+      "resolved": "https://registry.npmmirror.com/fresh/-/fresh-0.5.2.tgz",
+      "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/fs-extra": {
+      "version": "11.3.1",
+      "resolved": "https://registry.npmmirror.com/fs-extra/-/fs-extra-11.3.1.tgz",
+      "integrity": "sha512-eXvGGwZ5CL17ZSwHWd3bbgk7UUpF6IFHtP57NYYakPvHOs8GDgDe5KJI36jIJzDkJ6eJjuzRA8eBQb6SkKue0g==",
+      "license": "MIT",
+      "dependencies": {
+        "graceful-fs": "^4.2.0",
+        "jsonfile": "^6.0.1",
+        "universalify": "^2.0.0"
+      },
+      "engines": {
+        "node": ">=14.14"
+      }
+    },
+    "node_modules/fsevents": {
+      "version": "2.3.2",
+      "resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.2.tgz",
+      "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
+      "dev": true,
+      "hasInstallScript": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+      }
+    },
+    "node_modules/function-bind": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmmirror.com/function-bind/-/function-bind-1.1.2.tgz",
+      "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
+      "license": "MIT",
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/generator-function": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmmirror.com/generator-function/-/generator-function-2.0.1.tgz",
+      "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/get-intrinsic": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmmirror.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
+      "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bind-apply-helpers": "^1.0.2",
+        "es-define-property": "^1.0.1",
+        "es-errors": "^1.3.0",
+        "es-object-atoms": "^1.1.1",
+        "function-bind": "^1.1.2",
+        "get-proto": "^1.0.1",
+        "gopd": "^1.2.0",
+        "has-symbols": "^1.1.0",
+        "hasown": "^2.0.2",
+        "math-intrinsics": "^1.1.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/get-proto": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmmirror.com/get-proto/-/get-proto-1.0.1.tgz",
+      "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
+      "license": "MIT",
+      "dependencies": {
+        "dunder-proto": "^1.0.1",
+        "es-object-atoms": "^1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/glob": {
+      "version": "10.4.5",
+      "resolved": "https://registry.npmmirror.com/glob/-/glob-10.4.5.tgz",
+      "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==",
+      "dev": true,
+      "license": "ISC",
+      "dependencies": {
+        "foreground-child": "^3.1.0",
+        "jackspeak": "^3.1.2",
+        "minimatch": "^9.0.4",
+        "minipass": "^7.1.2",
+        "package-json-from-dist": "^1.0.0",
+        "path-scurry": "^1.11.1"
+      },
+      "bin": {
+        "glob": "dist/esm/bin.mjs"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/isaacs"
+      }
+    },
+    "node_modules/gopd": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmmirror.com/gopd/-/gopd-1.2.0.tgz",
+      "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/graceful-fs": {
+      "version": "4.2.11",
+      "resolved": "https://registry.npmmirror.com/graceful-fs/-/graceful-fs-4.2.11.tgz",
+      "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
+      "license": "ISC"
+    },
+    "node_modules/has-flag": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmmirror.com/has-flag/-/has-flag-4.0.0.tgz",
+      "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/has-symbols": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmmirror.com/has-symbols/-/has-symbols-1.1.0.tgz",
+      "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/has-tostringtag": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmmirror.com/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
+      "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
+      "license": "MIT",
+      "dependencies": {
+        "has-symbols": "^1.0.3"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/hasown": {
+      "version": "2.0.4",
+      "resolved": "https://registry.npmmirror.com/hasown/-/hasown-2.0.4.tgz",
+      "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
+      "license": "MIT",
+      "dependencies": {
+        "function-bind": "^1.1.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/highlight.js": {
+      "version": "11.12.0",
+      "resolved": "https://registry.npmmirror.com/highlight.js/-/highlight.js-11.12.0.tgz",
+      "integrity": "sha512-nbfWpyRMcMrPMmDwJB+dhX/eiaPKtc2RB+0QZskqJ3WjRA/FDS0e9hZrx8EC/lbEv8gXy98FcDbNa/dspAaJMg==",
+      "license": "BSD-3-Clause",
+      "engines": {
+        "node": ">=12.0.0"
+      }
+    },
+    "node_modules/hookable": {
+      "version": "5.5.3",
+      "resolved": "https://registry.npmmirror.com/hookable/-/hookable-5.5.3.tgz",
+      "integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==",
+      "license": "MIT"
+    },
+    "node_modules/html-encoding-sniffer": {
+      "version": "6.0.0",
+      "resolved": "https://registry.npmmirror.com/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz",
+      "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@exodus/bytes": "^1.6.0"
+      },
+      "engines": {
+        "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+      }
+    },
+    "node_modules/html-escaper": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmmirror.com/html-escaper/-/html-escaper-2.0.2.tgz",
+      "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/http-assert": {
+      "version": "1.5.0",
+      "resolved": "https://registry.npmmirror.com/http-assert/-/http-assert-1.5.0.tgz",
+      "integrity": "sha512-uPpH7OKX4H25hBmU6G1jWNaqJGpTXxey+YOUizJUAgu0AjLUeC8D73hTrhvDS5D+GJN1DN1+hhc/eF/wpxtp0w==",
+      "license": "MIT",
+      "dependencies": {
+        "deep-equal": "~1.0.1",
+        "http-errors": "~1.8.0"
+      },
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/http-assert/node_modules/depd": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmmirror.com/depd/-/depd-1.1.2.tgz",
+      "integrity": "sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/http-assert/node_modules/http-errors": {
+      "version": "1.8.1",
+      "resolved": "https://registry.npmmirror.com/http-errors/-/http-errors-1.8.1.tgz",
+      "integrity": "sha512-Kpk9Sm7NmI+RHhnj6OIWDI1d6fIoFAtFt9RLaTMRlg/8w49juAStsrBgp0Dp4OdxdVbRIeKhtCUvoi/RuAhO4g==",
+      "license": "MIT",
+      "dependencies": {
+        "depd": "~1.1.2",
+        "inherits": "2.0.4",
+        "setprototypeof": "1.2.0",
+        "statuses": ">= 1.5.0 < 2",
+        "toidentifier": "1.0.1"
+      },
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/http-assert/node_modules/statuses": {
+      "version": "1.5.0",
+      "resolved": "https://registry.npmmirror.com/statuses/-/statuses-1.5.0.tgz",
+      "integrity": "sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/http-errors": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmmirror.com/http-errors/-/http-errors-2.0.1.tgz",
+      "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
+      "license": "MIT",
+      "dependencies": {
+        "depd": "~2.0.0",
+        "inherits": "~2.0.4",
+        "setprototypeof": "~1.2.0",
+        "statuses": "~2.0.2",
+        "toidentifier": "~1.0.1"
+      },
+      "engines": {
+        "node": ">= 0.8"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/http-proxy-agent": {
+      "version": "7.0.2",
+      "resolved": "https://registry.npmmirror.com/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz",
+      "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "agent-base": "^7.1.0",
+        "debug": "^4.3.4"
+      },
+      "engines": {
+        "node": ">= 14"
+      }
+    },
+    "node_modules/https-proxy-agent": {
+      "version": "7.0.6",
+      "resolved": "https://registry.npmmirror.com/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
+      "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "agent-base": "^7.1.2",
+        "debug": "4"
+      },
+      "engines": {
+        "node": ">= 14"
+      }
+    },
+    "node_modules/iconv-lite": {
+      "version": "0.4.24",
+      "resolved": "https://registry.npmmirror.com/iconv-lite/-/iconv-lite-0.4.24.tgz",
+      "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
+      "license": "MIT",
+      "dependencies": {
+        "safer-buffer": ">= 2.1.2 < 3"
+      },
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/idb": {
+      "version": "8.0.3",
+      "resolved": "https://registry.npmmirror.com/idb/-/idb-8.0.3.tgz",
+      "integrity": "sha512-LtwtVyVYO5BqRvcsKuB2iUMnHwPVByPCXFXOpuU96IZPPoPN6xjOGxZQ74pgSVVLQWtUOYgyeL4GE98BY5D3wg==",
+      "license": "ISC"
+    },
+    "node_modules/immediate": {
+      "version": "3.0.6",
+      "resolved": "https://registry.npmmirror.com/immediate/-/immediate-3.0.6.tgz",
+      "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
+      "license": "MIT"
+    },
+    "node_modules/immutable": {
+      "version": "5.1.9",
+      "resolved": "https://registry.npmmirror.com/immutable/-/immutable-5.1.9.tgz",
+      "integrity": "sha512-m8nVez3rwrgmWxtLMt1ZYXB2Lv7OKYn/disyxAlSDYAlKSlFoPPfIAmAM/M5xqL4m4C/wAPw7S2/CNaUii1Hxg==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/inflation": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmmirror.com/inflation/-/inflation-2.1.0.tgz",
+      "integrity": "sha512-t54PPJHG1Pp7VQvxyVCJ9mBbjG3Hqryges9bXoOO6GExCPa+//i/d5GSuFtpx3ALLd7lgIAur6zrIlBQyJuMlQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8.0"
+      }
+    },
+    "node_modules/inherits": {
+      "version": "2.0.4",
+      "resolved": "https://registry.npmmirror.com/inherits/-/inherits-2.0.4.tgz",
+      "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
+      "license": "ISC"
+    },
+    "node_modules/ini": {
+      "version": "1.3.8",
+      "resolved": "https://registry.npmmirror.com/ini/-/ini-1.3.8.tgz",
+      "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
+      "dev": true,
+      "license": "ISC"
+    },
+    "node_modules/is-extglob": {
+      "version": "2.1.1",
+      "resolved": "https://registry.npmmirror.com/is-extglob/-/is-extglob-2.1.1.tgz",
+      "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/is-fullwidth-code-point": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmmirror.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
+      "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/is-generator-function": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmmirror.com/is-generator-function/-/is-generator-function-1.1.2.tgz",
+      "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bound": "^1.0.4",
+        "generator-function": "^2.0.0",
+        "get-proto": "^1.0.1",
+        "has-tostringtag": "^1.0.2",
+        "safe-regex-test": "^1.1.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/is-glob": {
+      "version": "4.0.3",
+      "resolved": "https://registry.npmmirror.com/is-glob/-/is-glob-4.0.3.tgz",
+      "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "dependencies": {
+        "is-extglob": "^2.1.1"
+      },
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/is-potential-custom-element-name": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmmirror.com/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
+      "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/is-regex": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmmirror.com/is-regex/-/is-regex-1.2.1.tgz",
+      "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bound": "^1.0.2",
+        "gopd": "^1.2.0",
+        "has-tostringtag": "^1.0.2",
+        "hasown": "^2.0.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/is-what": {
+      "version": "5.5.0",
+      "resolved": "https://registry.npmmirror.com/is-what/-/is-what-5.5.0.tgz",
+      "integrity": "sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=18"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/mesqueeb"
+      }
+    },
+    "node_modules/isarray": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmmirror.com/isarray/-/isarray-1.0.0.tgz",
+      "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
+      "license": "MIT"
+    },
+    "node_modules/isexe": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmmirror.com/isexe/-/isexe-2.0.0.tgz",
+      "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
+      "dev": true,
+      "license": "ISC"
+    },
+    "node_modules/istanbul-lib-coverage": {
+      "version": "3.2.2",
+      "resolved": "https://registry.npmmirror.com/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz",
+      "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==",
+      "dev": true,
+      "license": "BSD-3-Clause",
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/istanbul-lib-report": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmmirror.com/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz",
+      "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==",
+      "dev": true,
+      "license": "BSD-3-Clause",
+      "dependencies": {
+        "istanbul-lib-coverage": "^3.0.0",
+        "make-dir": "^4.0.0",
+        "supports-color": "^7.1.0"
+      },
+      "engines": {
+        "node": ">=10"
+      }
+    },
+    "node_modules/istanbul-lib-source-maps": {
+      "version": "5.0.6",
+      "resolved": "https://registry.npmmirror.com/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz",
+      "integrity": "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==",
+      "dev": true,
+      "license": "BSD-3-Clause",
+      "dependencies": {
+        "@jridgewell/trace-mapping": "^0.3.23",
+        "debug": "^4.1.1",
+        "istanbul-lib-coverage": "^3.0.0"
+      },
+      "engines": {
+        "node": ">=10"
+      }
+    },
+    "node_modules/istanbul-reports": {
+      "version": "3.2.0",
+      "resolved": "https://registry.npmmirror.com/istanbul-reports/-/istanbul-reports-3.2.0.tgz",
+      "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==",
+      "dev": true,
+      "license": "BSD-3-Clause",
+      "dependencies": {
+        "html-escaper": "^2.0.0",
+        "istanbul-lib-report": "^3.0.0"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/jackspeak": {
+      "version": "3.4.3",
+      "resolved": "https://registry.npmmirror.com/jackspeak/-/jackspeak-3.4.3.tgz",
+      "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==",
+      "dev": true,
+      "license": "BlueOak-1.0.0",
+      "dependencies": {
+        "@isaacs/cliui": "^8.0.2"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/isaacs"
+      },
+      "optionalDependencies": {
+        "@pkgjs/parseargs": "^0.11.0"
+      }
+    },
+    "node_modules/js-beautify": {
+      "version": "1.15.4",
+      "resolved": "https://registry.npmmirror.com/js-beautify/-/js-beautify-1.15.4.tgz",
+      "integrity": "sha512-9/KXeZUKKJwqCXUdBxFJ3vPh467OCckSBmYDwSK/EtV090K+iMJ7zx2S3HLVDIWFQdqMIsZWbnaGiba18aWhaA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "config-chain": "^1.1.13",
+        "editorconfig": "^1.0.4",
+        "glob": "^10.4.2",
+        "js-cookie": "^3.0.5",
+        "nopt": "^7.2.1"
+      },
+      "bin": {
+        "css-beautify": "js/bin/css-beautify.js",
+        "html-beautify": "js/bin/html-beautify.js",
+        "js-beautify": "js/bin/js-beautify.js"
+      },
+      "engines": {
+        "node": ">=14"
+      }
+    },
+    "node_modules/js-cookie": {
+      "version": "3.0.8",
+      "resolved": "https://registry.npmmirror.com/js-cookie/-/js-cookie-3.0.8.tgz",
+      "integrity": "sha512-yeJd4aNAdYZQjaon2bpD/Gb0B/omw7HQOsynXXcOiWVCacbBcPlgn8S/d1X6blFSaHao7ozqtW7NZW19xpCtIw==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/js-tokens": {
+      "version": "10.0.0",
+      "resolved": "https://registry.npmmirror.com/js-tokens/-/js-tokens-10.0.0.tgz",
+      "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/jsdom": {
+      "version": "27.4.0",
+      "resolved": "https://registry.npmmirror.com/jsdom/-/jsdom-27.4.0.tgz",
+      "integrity": "sha512-mjzqwWRD9Y1J1KUi7W97Gja1bwOOM5Ug0EZ6UDK3xS7j7mndrkwozHtSblfomlzyB4NepioNt+B2sOSzczVgtQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@acemir/cssom": "^0.9.28",
+        "@asamuzakjp/dom-selector": "^6.7.6",
+        "@exodus/bytes": "^1.6.0",
+        "cssstyle": "^5.3.4",
+        "data-urls": "^6.0.0",
+        "decimal.js": "^10.6.0",
+        "html-encoding-sniffer": "^6.0.0",
+        "http-proxy-agent": "^7.0.2",
+        "https-proxy-agent": "^7.0.6",
+        "is-potential-custom-element-name": "^1.0.1",
+        "parse5": "^8.0.0",
+        "saxes": "^6.0.0",
+        "symbol-tree": "^3.2.4",
+        "tough-cookie": "^6.0.0",
+        "w3c-xmlserializer": "^5.0.0",
+        "webidl-conversions": "^8.0.0",
+        "whatwg-mimetype": "^4.0.0",
+        "whatwg-url": "^15.1.0",
+        "ws": "^8.18.3",
+        "xml-name-validator": "^5.0.0"
+      },
+      "engines": {
+        "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+      },
+      "peerDependencies": {
+        "canvas": "^3.0.0"
+      },
+      "peerDependenciesMeta": {
+        "canvas": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/jsdom/node_modules/entities": {
+      "version": "8.0.0",
+      "resolved": "https://registry.npmmirror.com/entities/-/entities-8.0.0.tgz",
+      "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==",
+      "dev": true,
+      "license": "BSD-2-Clause",
+      "engines": {
+        "node": ">=20.19.0"
+      },
+      "funding": {
+        "url": "https://github.com/fb55/entities?sponsor=1"
+      }
+    },
+    "node_modules/jsdom/node_modules/parse5": {
+      "version": "8.0.1",
+      "resolved": "https://registry.npmmirror.com/parse5/-/parse5-8.0.1.tgz",
+      "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "entities": "^8.0.0"
+      },
+      "funding": {
+        "url": "https://github.com/inikulin/parse5?sponsor=1"
+      }
+    },
+    "node_modules/jsonfile": {
+      "version": "6.2.1",
+      "resolved": "https://registry.npmmirror.com/jsonfile/-/jsonfile-6.2.1.tgz",
+      "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==",
+      "license": "MIT",
+      "dependencies": {
+        "universalify": "^2.0.0"
+      },
+      "optionalDependencies": {
+        "graceful-fs": "^4.1.6"
+      }
+    },
+    "node_modules/jszip": {
+      "version": "3.10.1",
+      "resolved": "https://registry.npmmirror.com/jszip/-/jszip-3.10.1.tgz",
+      "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==",
+      "license": "(MIT OR GPL-3.0-or-later)",
+      "dependencies": {
+        "lie": "~3.3.0",
+        "pako": "~1.0.2",
+        "readable-stream": "~2.3.6",
+        "setimmediate": "^1.0.5"
+      }
+    },
+    "node_modules/keygrip": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmmirror.com/keygrip/-/keygrip-1.1.0.tgz",
+      "integrity": "sha512-iYSchDJ+liQ8iwbSI2QqsQOvqv58eJCEanyJPJi+Khyu8smkcKSFUCbPwzFcL7YVtZ6eONjqRX/38caJ7QjRAQ==",
+      "license": "MIT",
+      "dependencies": {
+        "tsscmp": "1.0.6"
+      },
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/koa": {
+      "version": "2.16.4",
+      "resolved": "https://registry.npmmirror.com/koa/-/koa-2.16.4.tgz",
+      "integrity": "sha512-3An0GCLDSR34tsCO4H8Tef8Pp2ngtaZDAZnsWJYelqXUK5wyiHvGItgK/xcSkmHLSTn1Jcho1mRQs2ehRzvKKw==",
+      "license": "MIT",
+      "dependencies": {
+        "accepts": "^1.3.5",
+        "cache-content-type": "^1.0.0",
+        "content-disposition": "~0.5.2",
+        "content-type": "^1.0.4",
+        "cookies": "~0.9.0",
+        "debug": "^4.3.2",
+        "delegates": "^1.0.0",
+        "depd": "^2.0.0",
+        "destroy": "^1.0.4",
+        "encodeurl": "^1.0.2",
+        "escape-html": "^1.0.3",
+        "fresh": "~0.5.2",
+        "http-assert": "^1.3.0",
+        "http-errors": "^1.6.3",
+        "is-generator-function": "^1.0.7",
+        "koa-compose": "^4.1.0",
+        "koa-convert": "^2.0.0",
+        "on-finished": "^2.3.0",
+        "only": "~0.0.2",
+        "parseurl": "^1.3.2",
+        "statuses": "^1.5.0",
+        "type-is": "^1.6.16",
+        "vary": "^1.1.2"
+      },
+      "engines": {
+        "node": "^4.8.4 || ^6.10.1 || ^7.10.1 || >= 8.1.4"
+      }
+    },
+    "node_modules/koa-compose": {
+      "version": "4.1.0",
+      "resolved": "https://registry.npmmirror.com/koa-compose/-/koa-compose-4.1.0.tgz",
+      "integrity": "sha512-8ODW8TrDuMYvXRwra/Kh7/rJo9BtOfPc6qO8eAfC80CnCvSjSl0bkRM24X6/XBBEyj0v1nRUQ1LyOy3dbqOWXw==",
+      "license": "MIT"
+    },
+    "node_modules/koa-convert": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmmirror.com/koa-convert/-/koa-convert-2.0.0.tgz",
+      "integrity": "sha512-asOvN6bFlSnxewce2e/DK3p4tltyfC4VM7ZwuTuepI7dEQVcvpyFuBcEARu1+Hxg8DIwytce2n7jrZtRlPrARA==",
+      "license": "MIT",
+      "dependencies": {
+        "co": "^4.6.0",
+        "koa-compose": "^4.1.0"
+      },
+      "engines": {
+        "node": ">= 10"
+      }
+    },
+    "node_modules/koa-send": {
+      "version": "5.0.1",
+      "resolved": "https://registry.npmmirror.com/koa-send/-/koa-send-5.0.1.tgz",
+      "integrity": "sha512-tmcyQ/wXXuxpDxyNXv5yNNkdAMdFRqwtegBXUaowiQzUKqJehttS0x2j0eOZDQAyloAth5w6wwBImnFzkUz3pQ==",
+      "license": "MIT",
+      "dependencies": {
+        "debug": "^4.1.1",
+        "http-errors": "^1.7.3",
+        "resolve-path": "^1.4.0"
+      },
+      "engines": {
+        "node": ">= 8"
+      }
+    },
+    "node_modules/koa-send/node_modules/depd": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmmirror.com/depd/-/depd-1.1.2.tgz",
+      "integrity": "sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/koa-send/node_modules/http-errors": {
+      "version": "1.8.1",
+      "resolved": "https://registry.npmmirror.com/http-errors/-/http-errors-1.8.1.tgz",
+      "integrity": "sha512-Kpk9Sm7NmI+RHhnj6OIWDI1d6fIoFAtFt9RLaTMRlg/8w49juAStsrBgp0Dp4OdxdVbRIeKhtCUvoi/RuAhO4g==",
+      "license": "MIT",
+      "dependencies": {
+        "depd": "~1.1.2",
+        "inherits": "2.0.4",
+        "setprototypeof": "1.2.0",
+        "statuses": ">= 1.5.0 < 2",
+        "toidentifier": "1.0.1"
+      },
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/koa-send/node_modules/statuses": {
+      "version": "1.5.0",
+      "resolved": "https://registry.npmmirror.com/statuses/-/statuses-1.5.0.tgz",
+      "integrity": "sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/koa-static": {
+      "version": "5.0.0",
+      "resolved": "https://registry.npmmirror.com/koa-static/-/koa-static-5.0.0.tgz",
+      "integrity": "sha512-UqyYyH5YEXaJrf9S8E23GoJFQZXkBVJ9zYYMPGz919MSX1KuvAcycIuS0ci150HCoPf4XQVhQ84Qf8xRPWxFaQ==",
+      "license": "MIT",
+      "dependencies": {
+        "debug": "^3.1.0",
+        "koa-send": "^5.0.0"
+      },
+      "engines": {
+        "node": ">= 7.6.0"
+      }
+    },
+    "node_modules/koa-static/node_modules/debug": {
+      "version": "3.2.7",
+      "resolved": "https://registry.npmmirror.com/debug/-/debug-3.2.7.tgz",
+      "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==",
+      "license": "MIT",
+      "dependencies": {
+        "ms": "^2.1.1"
+      }
+    },
+    "node_modules/koa/node_modules/http-errors": {
+      "version": "1.8.1",
+      "resolved": "https://registry.npmmirror.com/http-errors/-/http-errors-1.8.1.tgz",
+      "integrity": "sha512-Kpk9Sm7NmI+RHhnj6OIWDI1d6fIoFAtFt9RLaTMRlg/8w49juAStsrBgp0Dp4OdxdVbRIeKhtCUvoi/RuAhO4g==",
+      "license": "MIT",
+      "dependencies": {
+        "depd": "~1.1.2",
+        "inherits": "2.0.4",
+        "setprototypeof": "1.2.0",
+        "statuses": ">= 1.5.0 < 2",
+        "toidentifier": "1.0.1"
+      },
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/koa/node_modules/http-errors/node_modules/depd": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmmirror.com/depd/-/depd-1.1.2.tgz",
+      "integrity": "sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/koa/node_modules/statuses": {
+      "version": "1.5.0",
+      "resolved": "https://registry.npmmirror.com/statuses/-/statuses-1.5.0.tgz",
+      "integrity": "sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/lie": {
+      "version": "3.3.0",
+      "resolved": "https://registry.npmmirror.com/lie/-/lie-3.3.0.tgz",
+      "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
+      "license": "MIT",
+      "dependencies": {
+        "immediate": "~3.0.5"
+      }
+    },
+    "node_modules/lightningcss": {
+      "version": "1.33.0",
+      "resolved": "https://registry.npmmirror.com/lightningcss/-/lightningcss-1.33.0.tgz",
+      "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==",
+      "dev": true,
+      "license": "MPL-2.0",
+      "dependencies": {
+        "detect-libc": "^2.0.3"
+      },
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      },
+      "optionalDependencies": {
+        "lightningcss-android-arm64": "1.33.0",
+        "lightningcss-darwin-arm64": "1.33.0",
+        "lightningcss-darwin-x64": "1.33.0",
+        "lightningcss-freebsd-x64": "1.33.0",
+        "lightningcss-linux-arm-gnueabihf": "1.33.0",
+        "lightningcss-linux-arm64-gnu": "1.33.0",
+        "lightningcss-linux-arm64-musl": "1.33.0",
+        "lightningcss-linux-x64-gnu": "1.33.0",
+        "lightningcss-linux-x64-musl": "1.33.0",
+        "lightningcss-win32-arm64-msvc": "1.33.0",
+        "lightningcss-win32-x64-msvc": "1.33.0"
+      }
+    },
+    "node_modules/lightningcss-android-arm64": {
+      "version": "1.33.0",
+      "resolved": "https://registry.npmmirror.com/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz",
+      "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/lightningcss-darwin-arm64": {
+      "version": "1.33.0",
+      "resolved": "https://registry.npmmirror.com/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz",
+      "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/lightningcss-darwin-x64": {
+      "version": "1.33.0",
+      "resolved": "https://registry.npmmirror.com/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz",
+      "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/lightningcss-freebsd-x64": {
+      "version": "1.33.0",
+      "resolved": "https://registry.npmmirror.com/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz",
+      "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/lightningcss-linux-arm-gnueabihf": {
+      "version": "1.33.0",
+      "resolved": "https://registry.npmmirror.com/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz",
+      "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/lightningcss-linux-arm64-gnu": {
+      "version": "1.33.0",
+      "resolved": "https://registry.npmmirror.com/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz",
+      "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "libc": [
+        "glibc"
+      ],
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/lightningcss-linux-arm64-musl": {
+      "version": "1.33.0",
+      "resolved": "https://registry.npmmirror.com/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz",
+      "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "libc": [
+        "musl"
+      ],
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/lightningcss-linux-x64-gnu": {
+      "version": "1.33.0",
+      "resolved": "https://registry.npmmirror.com/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz",
+      "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "libc": [
+        "glibc"
+      ],
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/lightningcss-linux-x64-musl": {
+      "version": "1.33.0",
+      "resolved": "https://registry.npmmirror.com/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz",
+      "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "libc": [
+        "musl"
+      ],
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/lightningcss-win32-arm64-msvc": {
+      "version": "1.33.0",
+      "resolved": "https://registry.npmmirror.com/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz",
+      "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/lightningcss-win32-x64-msvc": {
+      "version": "1.33.0",
+      "resolved": "https://registry.npmmirror.com/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz",
+      "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/linkify-it": {
+      "version": "5.0.2",
+      "resolved": "https://registry.npmmirror.com/linkify-it/-/linkify-it-5.0.2.tgz",
+      "integrity": "sha512-ONTm2jCMAVZjgQa/Fy1kScXsuOoF5NPTsoFBdE1KVIZ2vAh/r9+Bqo+0jINCBYnavTPQZz38QzFTme79ENoN3Q==",
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/puzrin"
+        },
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/markdown-it"
+        }
+      ],
+      "license": "MIT",
+      "dependencies": {
+        "uc.micro": "^2.0.0"
+      }
+    },
+    "node_modules/lodash": {
+      "version": "4.18.1",
+      "resolved": "https://registry.npmmirror.com/lodash/-/lodash-4.18.1.tgz",
+      "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
+      "license": "MIT"
+    },
+    "node_modules/lodash-es": {
+      "version": "4.18.1",
+      "resolved": "https://registry.npmmirror.com/lodash-es/-/lodash-es-4.18.1.tgz",
+      "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==",
+      "license": "MIT"
+    },
+    "node_modules/lodash.merge": {
+      "version": "4.6.2",
+      "resolved": "https://registry.npmmirror.com/lodash.merge/-/lodash.merge-4.6.2.tgz",
+      "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==",
+      "license": "MIT"
+    },
+    "node_modules/loupe": {
+      "version": "3.2.1",
+      "resolved": "https://registry.npmmirror.com/loupe/-/loupe-3.2.1.tgz",
+      "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/lru-cache": {
+      "version": "11.5.2",
+      "resolved": "https://registry.npmmirror.com/lru-cache/-/lru-cache-11.5.2.tgz",
+      "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==",
+      "dev": true,
+      "license": "BlueOak-1.0.0",
+      "engines": {
+        "node": "20 || >=22"
+      }
+    },
+    "node_modules/magic-string": {
+      "version": "0.30.21",
+      "resolved": "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.21.tgz",
+      "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
+      "license": "MIT",
+      "dependencies": {
+        "@jridgewell/sourcemap-codec": "^1.5.5"
+      }
+    },
+    "node_modules/magicast": {
+      "version": "0.3.5",
+      "resolved": "https://registry.npmmirror.com/magicast/-/magicast-0.3.5.tgz",
+      "integrity": "sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/parser": "^7.25.4",
+        "@babel/types": "^7.25.4",
+        "source-map-js": "^1.2.0"
+      }
+    },
+    "node_modules/make-dir": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmmirror.com/make-dir/-/make-dir-4.0.0.tgz",
+      "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "semver": "^7.5.3"
+      },
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/markdown-it": {
+      "version": "14.3.0",
+      "resolved": "https://registry.npmmirror.com/markdown-it/-/markdown-it-14.3.0.tgz",
+      "integrity": "sha512-RCEsPjR+sr0x+AuYp601tKTkgFG4YEPLCzHST3cQ/fhlJkqAkz1L2/Qbp1j9qw5SBwQHFBoW8+hoN5xssOF0Tw==",
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/puzrin"
+        },
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/markdown-it"
+        }
+      ],
+      "license": "MIT",
+      "dependencies": {
+        "argparse": "^2.0.1",
+        "entities": "^4.5.0",
+        "linkify-it": "^5.0.2",
+        "mdurl": "^2.0.0",
+        "punycode.js": "^2.3.1",
+        "uc.micro": "^2.1.0"
+      },
+      "bin": {
+        "markdown-it": "bin/markdown-it.mjs"
+      }
+    },
+    "node_modules/markdown-it/node_modules/entities": {
+      "version": "4.5.0",
+      "resolved": "https://registry.npmmirror.com/entities/-/entities-4.5.0.tgz",
+      "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
+      "license": "BSD-2-Clause",
+      "engines": {
+        "node": ">=0.12"
+      },
+      "funding": {
+        "url": "https://github.com/fb55/entities?sponsor=1"
+      }
+    },
+    "node_modules/math-intrinsics": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmmirror.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
+      "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/mdn-data": {
+      "version": "2.27.1",
+      "resolved": "https://registry.npmmirror.com/mdn-data/-/mdn-data-2.27.1.tgz",
+      "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==",
+      "dev": true,
+      "license": "CC0-1.0"
+    },
+    "node_modules/mdurl": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmmirror.com/mdurl/-/mdurl-2.1.0.tgz",
+      "integrity": "sha512-1+HBaOx0zi/dQWht8rNv9MYf9qqpqL/kxI0hXImU6Y547zM6Sni8BQibt7ifgMcYtQg41ao3Ivd6cnSM86inpg==",
+      "license": "MIT"
+    },
+    "node_modules/media-typer": {
+      "version": "0.3.0",
+      "resolved": "https://registry.npmmirror.com/media-typer/-/media-typer-0.3.0.tgz",
+      "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/methods": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmmirror.com/methods/-/methods-1.1.2.tgz",
+      "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/mime": {
+      "version": "2.6.0",
+      "resolved": "https://registry.npmmirror.com/mime/-/mime-2.6.0.tgz",
+      "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==",
+      "dev": true,
+      "license": "MIT",
+      "bin": {
+        "mime": "cli.js"
+      },
+      "engines": {
+        "node": ">=4.0.0"
+      }
+    },
+    "node_modules/mime-db": {
+      "version": "1.54.0",
+      "resolved": "https://registry.npmmirror.com/mime-db/-/mime-db-1.54.0.tgz",
+      "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/mime-types": {
+      "version": "3.0.2",
+      "resolved": "https://registry.npmmirror.com/mime-types/-/mime-types-3.0.2.tgz",
+      "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==",
+      "license": "MIT",
+      "dependencies": {
+        "mime-db": "^1.54.0"
+      },
+      "engines": {
+        "node": ">=18"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/minimatch": {
+      "version": "9.0.9",
+      "resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-9.0.9.tgz",
+      "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
+      "dev": true,
+      "license": "ISC",
+      "dependencies": {
+        "brace-expansion": "^2.0.2"
+      },
+      "engines": {
+        "node": ">=16 || 14 >=14.17"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/isaacs"
+      }
+    },
+    "node_modules/minipass": {
+      "version": "7.1.3",
+      "resolved": "https://registry.npmmirror.com/minipass/-/minipass-7.1.3.tgz",
+      "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==",
+      "dev": true,
+      "license": "BlueOak-1.0.0",
+      "engines": {
+        "node": ">=16 || 14 >=14.17"
+      }
+    },
+    "node_modules/mitt": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmmirror.com/mitt/-/mitt-3.0.1.tgz",
+      "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==",
+      "license": "MIT"
+    },
+    "node_modules/ms": {
+      "version": "2.1.3",
+      "resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz",
+      "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+      "license": "MIT"
+    },
+    "node_modules/muggle-string": {
+      "version": "0.4.1",
+      "resolved": "https://registry.npmmirror.com/muggle-string/-/muggle-string-0.4.1.tgz",
+      "integrity": "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/naive-ui": {
+      "version": "2.45.0",
+      "resolved": "https://registry.npmmirror.com/naive-ui/-/naive-ui-2.45.0.tgz",
+      "integrity": "sha512-B1I69ajv1d7wat19LHHXxHBxUrwAP2mP+FtHFzsnwzh31qQwCzA/SOJZTPSQ3To2xuTibsxN3/PDa37tZUfyEw==",
+      "license": "MIT",
+      "dependencies": {
+        "@css-render/plugin-bem": "^0.15.14",
+        "@css-render/vue3-ssr": "^0.15.14",
+        "@types/lodash": "^4.17.20",
+        "@types/lodash-es": "^4.17.12",
+        "async-validator": "^4.2.5",
+        "css-render": "^0.15.14",
+        "csstype": "^3.1.3",
+        "date-fns": "^4.1.0",
+        "date-fns-tz": "^3.2.0",
+        "evtd": "^0.2.4",
+        "highlight.js": "^11.8.0",
+        "lodash": "^4.17.21",
+        "lodash-es": "^4.17.21",
+        "seemly": "^0.3.10",
+        "treemate": "^0.3.11",
+        "vdirs": "^0.1.8",
+        "vooks": "^0.2.12",
+        "vueuc": "^0.4.65"
+      },
+      "engines": {
+        "node": ">=20"
+      },
+      "peerDependencies": {
+        "vue": "^3.0.0"
+      }
+    },
+    "node_modules/nanoid": {
+      "version": "3.3.18",
+      "resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.18.tgz",
+      "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==",
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/ai"
+        }
+      ],
+      "license": "MIT",
+      "bin": {
+        "nanoid": "bin/nanoid.cjs"
+      },
+      "engines": {
+        "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+      }
+    },
+    "node_modules/negotiator": {
+      "version": "0.6.3",
+      "resolved": "https://registry.npmmirror.com/negotiator/-/negotiator-0.6.3.tgz",
+      "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/node-addon-api": {
+      "version": "7.1.1",
+      "resolved": "https://registry.npmmirror.com/node-addon-api/-/node-addon-api-7.1.1.tgz",
+      "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==",
+      "dev": true,
+      "license": "MIT",
+      "optional": true
+    },
+    "node_modules/node-int64": {
+      "version": "0.4.0",
+      "resolved": "https://registry.npmmirror.com/node-int64/-/node-int64-0.4.0.tgz",
+      "integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==",
+      "license": "MIT"
+    },
+    "node_modules/nopt": {
+      "version": "7.2.1",
+      "resolved": "https://registry.npmmirror.com/nopt/-/nopt-7.2.1.tgz",
+      "integrity": "sha512-taM24ViiimT/XntxbPyJQzCG+p4EKOpgD3mxFwW38mGjVUrfERQOeY4EDHjdnptttfHuHQXFx+lTP08Q+mLa/w==",
+      "dev": true,
+      "license": "ISC",
+      "dependencies": {
+        "abbrev": "^2.0.0"
+      },
+      "bin": {
+        "nopt": "bin/nopt.js"
+      },
+      "engines": {
+        "node": "^14.17.0 || ^16.13.0 || >=18.0.0"
+      }
+    },
+    "node_modules/object-inspect": {
+      "version": "1.13.4",
+      "resolved": "https://registry.npmmirror.com/object-inspect/-/object-inspect-1.13.4.tgz",
+      "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/on-finished": {
+      "version": "2.4.1",
+      "resolved": "https://registry.npmmirror.com/on-finished/-/on-finished-2.4.1.tgz",
+      "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
+      "license": "MIT",
+      "dependencies": {
+        "ee-first": "1.1.1"
+      },
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/once": {
+      "version": "1.4.0",
+      "resolved": "https://registry.npmmirror.com/once/-/once-1.4.0.tgz",
+      "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
+      "dev": true,
+      "license": "ISC",
+      "dependencies": {
+        "wrappy": "1"
+      }
+    },
+    "node_modules/only": {
+      "version": "0.0.2",
+      "resolved": "https://registry.npmmirror.com/only/-/only-0.0.2.tgz",
+      "integrity": "sha512-Fvw+Jemq5fjjyWz6CpKx6w9s7xxqo3+JCyM0WXWeCSOboZ8ABkyvP8ID4CZuChA/wxSx+XSJmdOm8rGVyJ1hdQ=="
+    },
+    "node_modules/package-json-from-dist": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmmirror.com/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
+      "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==",
+      "dev": true,
+      "license": "BlueOak-1.0.0"
+    },
+    "node_modules/pako": {
+      "version": "1.0.11",
+      "resolved": "https://registry.npmmirror.com/pako/-/pako-1.0.11.tgz",
+      "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
+      "license": "(MIT AND Zlib)"
+    },
+    "node_modules/parse5": {
+      "version": "7.3.0",
+      "resolved": "https://registry.npmmirror.com/parse5/-/parse5-7.3.0.tgz",
+      "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "entities": "^6.0.0"
+      },
+      "funding": {
+        "url": "https://github.com/inikulin/parse5?sponsor=1"
+      }
+    },
+    "node_modules/parse5/node_modules/entities": {
+      "version": "6.0.1",
+      "resolved": "https://registry.npmmirror.com/entities/-/entities-6.0.1.tgz",
+      "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==",
+      "dev": true,
+      "license": "BSD-2-Clause",
+      "engines": {
+        "node": ">=0.12"
+      },
+      "funding": {
+        "url": "https://github.com/fb55/entities?sponsor=1"
+      }
+    },
+    "node_modules/parseurl": {
+      "version": "1.3.3",
+      "resolved": "https://registry.npmmirror.com/parseurl/-/parseurl-1.3.3.tgz",
+      "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/path-browserify": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmmirror.com/path-browserify/-/path-browserify-1.0.1.tgz",
+      "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/path-is-absolute": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmmirror.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
+      "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/path-key": {
+      "version": "3.1.1",
+      "resolved": "https://registry.npmmirror.com/path-key/-/path-key-3.1.1.tgz",
+      "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/path-scurry": {
+      "version": "1.11.1",
+      "resolved": "https://registry.npmmirror.com/path-scurry/-/path-scurry-1.11.1.tgz",
+      "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==",
+      "dev": true,
+      "license": "BlueOak-1.0.0",
+      "dependencies": {
+        "lru-cache": "^10.2.0",
+        "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0"
+      },
+      "engines": {
+        "node": ">=16 || 14 >=14.18"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/isaacs"
+      }
+    },
+    "node_modules/path-scurry/node_modules/lru-cache": {
+      "version": "10.4.3",
+      "resolved": "https://registry.npmmirror.com/lru-cache/-/lru-cache-10.4.3.tgz",
+      "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
+      "dev": true,
+      "license": "ISC"
+    },
+    "node_modules/path-to-regexp": {
+      "version": "8.4.2",
+      "resolved": "https://registry.npmmirror.com/path-to-regexp/-/path-to-regexp-8.4.2.tgz",
+      "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==",
+      "license": "MIT",
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/pathe": {
+      "version": "2.0.3",
+      "resolved": "https://registry.npmmirror.com/pathe/-/pathe-2.0.3.tgz",
+      "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/pathval": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmmirror.com/pathval/-/pathval-2.0.1.tgz",
+      "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">= 14.16"
+      }
+    },
+    "node_modules/pdfjs-dist": {
+      "version": "5.7.284",
+      "resolved": "https://registry.npmmirror.com/pdfjs-dist/-/pdfjs-dist-5.7.284.tgz",
+      "integrity": "sha512-h4EdYQczmGhbOlqc3PPZwxevn7ApdWPbovAuWXOB/DjIyigSnwfy2oze7c6mRcSr9XgLp3eN3EeL4DyySTPMFw==",
+      "license": "Apache-2.0",
+      "engines": {
+        "node": ">=22.13.0 || >=24"
+      },
+      "optionalDependencies": {
+        "@napi-rs/canvas": "^0.1.100"
+      }
+    },
+    "node_modules/perfect-debounce": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmmirror.com/perfect-debounce/-/perfect-debounce-1.0.0.tgz",
+      "integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==",
+      "license": "MIT"
+    },
+    "node_modules/picocolors": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz",
+      "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+      "license": "ISC"
+    },
+    "node_modules/picomatch": {
+      "version": "4.0.5",
+      "resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.5.tgz",
+      "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=12"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/jonschlinkert"
+      }
+    },
+    "node_modules/pinia": {
+      "version": "3.0.4",
+      "resolved": "https://registry.npmmirror.com/pinia/-/pinia-3.0.4.tgz",
+      "integrity": "sha512-l7pqLUFTI/+ESXn6k3nu30ZIzW5E2WZF/LaHJEpoq6ElcLD+wduZoB2kBN19du6K/4FDpPMazY2wJr+IndBtQw==",
+      "license": "MIT",
+      "dependencies": {
+        "@vue/devtools-api": "^7.7.7"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/posva"
+      },
+      "peerDependencies": {
+        "typescript": ">=4.5.0",
+        "vue": "^3.5.11"
+      },
+      "peerDependenciesMeta": {
+        "typescript": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/playwright": {
+      "version": "1.62.1",
+      "resolved": "https://registry.npmmirror.com/playwright/-/playwright-1.62.1.tgz",
+      "integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "dependencies": {
+        "playwright-core": "1.62.1"
+      },
+      "bin": {
+        "playwright": "cli.js"
+      },
+      "engines": {
+        "node": ">=20"
+      },
+      "optionalDependencies": {
+        "fsevents": "2.3.2"
+      }
+    },
+    "node_modules/playwright-core": {
+      "version": "1.62.1",
+      "resolved": "https://registry.npmmirror.com/playwright-core/-/playwright-core-1.62.1.tgz",
+      "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "bin": {
+        "playwright-core": "cli.js"
+      },
+      "engines": {
+        "node": ">=20"
+      }
+    },
+    "node_modules/postcss": {
+      "version": "8.5.26",
+      "resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.26.tgz",
+      "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==",
+      "funding": [
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/postcss/"
+        },
+        {
+          "type": "tidelift",
+          "url": "https://tidelift.com/funding/github/npm/postcss"
+        },
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/ai"
+        }
+      ],
+      "license": "MIT",
+      "dependencies": {
+        "nanoid": "^3.3.17",
+        "picocolors": "^1.1.1",
+        "source-map-js": "^1.2.1"
+      },
+      "engines": {
+        "node": "^10 || ^12 || >=14"
+      }
+    },
+    "node_modules/process-nextick-args": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmmirror.com/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
+      "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
+      "license": "MIT"
+    },
+    "node_modules/proto-list": {
+      "version": "1.2.4",
+      "resolved": "https://registry.npmmirror.com/proto-list/-/proto-list-1.2.4.tgz",
+      "integrity": "sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==",
+      "dev": true,
+      "license": "ISC"
+    },
+    "node_modules/punycode": {
+      "version": "2.3.1",
+      "resolved": "https://registry.npmmirror.com/punycode/-/punycode-2.3.1.tgz",
+      "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/punycode.js": {
+      "version": "2.3.1",
+      "resolved": "https://registry.npmmirror.com/punycode.js/-/punycode.js-2.3.1.tgz",
+      "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/qs": {
+      "version": "6.15.3",
+      "resolved": "https://registry.npmmirror.com/qs/-/qs-6.15.3.tgz",
+      "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==",
+      "license": "BSD-3-Clause",
+      "dependencies": {
+        "es-define-property": "^1.0.1",
+        "side-channel": "^1.1.1"
+      },
+      "engines": {
+        "node": ">=0.6"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/raw-body": {
+      "version": "2.5.3",
+      "resolved": "https://registry.npmmirror.com/raw-body/-/raw-body-2.5.3.tgz",
+      "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==",
+      "license": "MIT",
+      "dependencies": {
+        "bytes": "~3.1.2",
+        "http-errors": "~2.0.1",
+        "iconv-lite": "~0.4.24",
+        "unpipe": "~1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/read-excel-file": {
+      "version": "7.0.3",
+      "resolved": "https://registry.npmmirror.com/read-excel-file/-/read-excel-file-7.0.3.tgz",
+      "integrity": "sha512-S9+WgwNbVFeCRAT90sMuTDqN3Ez9Nz2tywXvdH1XSKM8SxZlrFBjmB0/IiYMBCFx71YSitOUSUDjGLXhAj1V0A==",
+      "license": "MIT",
+      "dependencies": {
+        "@xmldom/xmldom": "^0.8.11",
+        "fflate": "^0.8.2",
+        "unzipper": "^0.12.3"
+      },
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/readable-stream": {
+      "version": "2.3.8",
+      "resolved": "https://registry.npmmirror.com/readable-stream/-/readable-stream-2.3.8.tgz",
+      "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
+      "license": "MIT",
+      "dependencies": {
+        "core-util-is": "~1.0.0",
+        "inherits": "~2.0.3",
+        "isarray": "~1.0.0",
+        "process-nextick-args": "~2.0.0",
+        "safe-buffer": "~5.1.1",
+        "string_decoder": "~1.1.1",
+        "util-deprecate": "~1.0.1"
+      }
+    },
+    "node_modules/readable-stream/node_modules/safe-buffer": {
+      "version": "5.1.2",
+      "resolved": "https://registry.npmmirror.com/safe-buffer/-/safe-buffer-5.1.2.tgz",
+      "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
+      "license": "MIT"
+    },
+    "node_modules/readdirp": {
+      "version": "5.1.1",
+      "resolved": "https://registry.npmmirror.com/readdirp/-/readdirp-5.1.1.tgz",
+      "integrity": "sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">= 20.19.0"
+      },
+      "funding": {
+        "type": "individual",
+        "url": "https://paulmillr.com/funding/"
+      }
+    },
+    "node_modules/require-from-string": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmmirror.com/require-from-string/-/require-from-string-2.0.2.tgz",
+      "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/resolve-path": {
+      "version": "1.4.0",
+      "resolved": "https://registry.npmmirror.com/resolve-path/-/resolve-path-1.4.0.tgz",
+      "integrity": "sha512-i1xevIst/Qa+nA9olDxLWnLk8YZbi8R/7JPbCMcgyWaFR6bKWaexgJgEB5oc2PKMjYdrHynyz0NY+if+H98t1w==",
+      "license": "MIT",
+      "dependencies": {
+        "http-errors": "~1.6.2",
+        "path-is-absolute": "1.0.1"
+      },
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/resolve-path/node_modules/depd": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmmirror.com/depd/-/depd-1.1.2.tgz",
+      "integrity": "sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/resolve-path/node_modules/http-errors": {
+      "version": "1.6.3",
+      "resolved": "https://registry.npmmirror.com/http-errors/-/http-errors-1.6.3.tgz",
+      "integrity": "sha512-lks+lVC8dgGyh97jxvxeYTWQFvh4uw4yC12gVl63Cg30sjPX4wuGcdkICVXDAESr6OJGjqGA8Iz5mkeN6zlD7A==",
+      "license": "MIT",
+      "dependencies": {
+        "depd": "~1.1.2",
+        "inherits": "2.0.3",
+        "setprototypeof": "1.1.0",
+        "statuses": ">= 1.4.0 < 2"
+      },
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/resolve-path/node_modules/inherits": {
+      "version": "2.0.3",
+      "resolved": "https://registry.npmmirror.com/inherits/-/inherits-2.0.3.tgz",
+      "integrity": "sha512-x00IRNXNy63jwGkJmzPigoySHbaqpNuzKbBOmzK+g2OdZpQ9w+sxCN+VSB3ja7IAge2OP2qpfxTjeNcyjmW1uw==",
+      "license": "ISC"
+    },
+    "node_modules/resolve-path/node_modules/setprototypeof": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmmirror.com/setprototypeof/-/setprototypeof-1.1.0.tgz",
+      "integrity": "sha512-BvE/TwpZX4FXExxOxZyRGQQv651MSwmWKZGqvmPcRIjDqWub67kTKuIMx43cZZrS/cBBzwBcNDWoFxt2XEFIpQ==",
+      "license": "ISC"
+    },
+    "node_modules/resolve-path/node_modules/statuses": {
+      "version": "1.5.0",
+      "resolved": "https://registry.npmmirror.com/statuses/-/statuses-1.5.0.tgz",
+      "integrity": "sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/rfdc": {
+      "version": "1.4.1",
+      "resolved": "https://registry.npmmirror.com/rfdc/-/rfdc-1.4.1.tgz",
+      "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==",
+      "license": "MIT"
+    },
+    "node_modules/rolldown": {
+      "version": "1.2.4",
+      "resolved": "https://registry.npmmirror.com/rolldown/-/rolldown-1.2.4.tgz",
+      "integrity": "sha512-rSr7irW0K7QRWzjdJXqZowkcRdDtjRduh43rBltnVKd0VFq839l1lJoDvGJb6gl7+4rTTCrPWu+YfujUL8Ug7w==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@oxc-project/types": "=0.144.0",
+        "@rolldown/pluginutils": "^1.0.0"
+      },
+      "bin": {
+        "rolldown": "bin/cli.mjs"
+      },
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      },
+      "optionalDependencies": {
+        "@rolldown/binding-android-arm64": "1.2.4",
+        "@rolldown/binding-darwin-arm64": "1.2.4",
+        "@rolldown/binding-darwin-x64": "1.2.4",
+        "@rolldown/binding-freebsd-x64": "1.2.4",
+        "@rolldown/binding-linux-arm-gnueabihf": "1.2.4",
+        "@rolldown/binding-linux-arm64-gnu": "1.2.4",
+        "@rolldown/binding-linux-arm64-musl": "1.2.4",
+        "@rolldown/binding-linux-ppc64-gnu": "1.2.4",
+        "@rolldown/binding-linux-s390x-gnu": "1.2.4",
+        "@rolldown/binding-linux-x64-gnu": "1.2.4",
+        "@rolldown/binding-linux-x64-musl": "1.2.4",
+        "@rolldown/binding-openharmony-arm64": "1.2.4",
+        "@rolldown/binding-win32-arm64-msvc": "1.2.4",
+        "@rolldown/binding-win32-x64-msvc": "1.2.4"
+      }
+    },
+    "node_modules/rollup": {
+      "version": "4.62.4",
+      "resolved": "https://registry.npmmirror.com/rollup/-/rollup-4.62.4.tgz",
+      "integrity": "sha512-RXOqwaPsBGjMNMa4sQjDjHieHEZDFoj/Rdr46l2MU5DfEs16wHJPC2RPTPHWhNl+M3aI472LLqFkFKut4SblOg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/estree": "1.0.9"
+      },
+      "bin": {
+        "rollup": "dist/bin/rollup"
+      },
+      "engines": {
+        "node": ">=18.0.0",
+        "npm": ">=8.0.0"
+      },
+      "optionalDependencies": {
+        "@napi-rs/lzma-linux-x64-gnu": "1.5.1",
+        "@rollup/rollup-android-arm-eabi": "4.62.4",
+        "@rollup/rollup-android-arm64": "4.62.4",
+        "@rollup/rollup-darwin-arm64": "4.62.4",
+        "@rollup/rollup-darwin-x64": "4.62.4",
+        "@rollup/rollup-freebsd-arm64": "4.62.4",
+        "@rollup/rollup-freebsd-x64": "4.62.4",
+        "@rollup/rollup-linux-arm-gnueabihf": "4.62.4",
+        "@rollup/rollup-linux-arm-musleabihf": "4.62.4",
+        "@rollup/rollup-linux-arm64-gnu": "4.62.4",
+        "@rollup/rollup-linux-arm64-musl": "4.62.4",
+        "@rollup/rollup-linux-loong64-gnu": "4.62.4",
+        "@rollup/rollup-linux-loong64-musl": "4.62.4",
+        "@rollup/rollup-linux-ppc64-gnu": "4.62.4",
+        "@rollup/rollup-linux-ppc64-musl": "4.62.4",
+        "@rollup/rollup-linux-riscv64-gnu": "4.62.4",
+        "@rollup/rollup-linux-riscv64-musl": "4.62.4",
+        "@rollup/rollup-linux-s390x-gnu": "4.62.4",
+        "@rollup/rollup-linux-x64-gnu": "4.62.4",
+        "@rollup/rollup-linux-x64-musl": "4.62.4",
+        "@rollup/rollup-openbsd-x64": "4.62.4",
+        "@rollup/rollup-openharmony-arm64": "4.62.4",
+        "@rollup/rollup-win32-arm64-msvc": "4.62.4",
+        "@rollup/rollup-win32-ia32-msvc": "4.62.4",
+        "@rollup/rollup-win32-x64-gnu": "4.62.4",
+        "@rollup/rollup-win32-x64-msvc": "4.62.4",
+        "fsevents": "~2.3.2"
+      }
+    },
+    "node_modules/safe-buffer": {
+      "version": "5.2.1",
+      "resolved": "https://registry.npmmirror.com/safe-buffer/-/safe-buffer-5.2.1.tgz",
+      "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/feross"
+        },
+        {
+          "type": "patreon",
+          "url": "https://www.patreon.com/feross"
+        },
+        {
+          "type": "consulting",
+          "url": "https://feross.org/support"
+        }
+      ],
+      "license": "MIT"
+    },
+    "node_modules/safe-regex-test": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmmirror.com/safe-regex-test/-/safe-regex-test-1.1.0.tgz",
+      "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bound": "^1.0.2",
+        "es-errors": "^1.3.0",
+        "is-regex": "^1.2.1"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/safer-buffer": {
+      "version": "2.1.2",
+      "resolved": "https://registry.npmmirror.com/safer-buffer/-/safer-buffer-2.1.2.tgz",
+      "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
+      "license": "MIT"
+    },
+    "node_modules/sass": {
+      "version": "1.102.0",
+      "resolved": "https://registry.npmmirror.com/sass/-/sass-1.102.0.tgz",
+      "integrity": "sha512-NSOyTnaQF7rTAEOtI2fwb386vL+akyiQLBZu8Na7hXCb+umJy0GAqlcMIaqACZ6Z1VgTBS4K9PG6B3IdjHGJsw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "chokidar": "^5.0.0",
+        "immutable": "^5.1.5",
+        "source-map-js": ">=0.6.2 <2.0.0"
+      },
+      "bin": {
+        "sass": "sass.js"
+      },
+      "engines": {
+        "node": ">=20.19.0"
+      },
+      "optionalDependencies": {
+        "@parcel/watcher": "^2.4.1"
+      }
+    },
+    "node_modules/saxes": {
+      "version": "6.0.0",
+      "resolved": "https://registry.npmmirror.com/saxes/-/saxes-6.0.0.tgz",
+      "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==",
+      "dev": true,
+      "license": "ISC",
+      "dependencies": {
+        "xmlchars": "^2.2.0"
+      },
+      "engines": {
+        "node": ">=v12.22.7"
+      }
+    },
+    "node_modules/seemly": {
+      "version": "0.3.10",
+      "resolved": "https://registry.npmmirror.com/seemly/-/seemly-0.3.10.tgz",
+      "integrity": "sha512-2+SMxtG1PcsL0uyhkumlOU6Qo9TAQ/WyH7tthnPIOQB05/12jz9naq6GZ6iZ6ApVsO3rr2gsnTf3++OV63kE1Q==",
+      "license": "MIT"
+    },
+    "node_modules/semver": {
+      "version": "7.8.5",
+      "resolved": "https://registry.npmmirror.com/semver/-/semver-7.8.5.tgz",
+      "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
+      "dev": true,
+      "license": "ISC",
+      "bin": {
+        "semver": "bin/semver.js"
+      },
+      "engines": {
+        "node": ">=10"
+      }
+    },
+    "node_modules/setimmediate": {
+      "version": "1.0.5",
+      "resolved": "https://registry.npmmirror.com/setimmediate/-/setimmediate-1.0.5.tgz",
+      "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
+      "license": "MIT"
+    },
+    "node_modules/setprototypeof": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmmirror.com/setprototypeof/-/setprototypeof-1.2.0.tgz",
+      "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
+      "license": "ISC"
+    },
+    "node_modules/shebang-command": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmmirror.com/shebang-command/-/shebang-command-2.0.0.tgz",
+      "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "shebang-regex": "^3.0.0"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/shebang-regex": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmmirror.com/shebang-regex/-/shebang-regex-3.0.0.tgz",
+      "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/side-channel": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmmirror.com/side-channel/-/side-channel-1.1.1.tgz",
+      "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0",
+        "object-inspect": "^1.13.4",
+        "side-channel-list": "^1.0.1",
+        "side-channel-map": "^1.0.1",
+        "side-channel-weakmap": "^1.0.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/side-channel-list": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmmirror.com/side-channel-list/-/side-channel-list-1.0.1.tgz",
+      "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0",
+        "object-inspect": "^1.13.4"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/side-channel-map": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmmirror.com/side-channel-map/-/side-channel-map-1.0.1.tgz",
+      "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bound": "^1.0.2",
+        "es-errors": "^1.3.0",
+        "get-intrinsic": "^1.2.5",
+        "object-inspect": "^1.13.3"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/side-channel-weakmap": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmmirror.com/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
+      "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bound": "^1.0.2",
+        "es-errors": "^1.3.0",
+        "get-intrinsic": "^1.2.5",
+        "object-inspect": "^1.13.3",
+        "side-channel-map": "^1.0.1"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/siginfo": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmmirror.com/siginfo/-/siginfo-2.0.0.tgz",
+      "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
+      "dev": true,
+      "license": "ISC"
+    },
+    "node_modules/signal-exit": {
+      "version": "4.1.0",
+      "resolved": "https://registry.npmmirror.com/signal-exit/-/signal-exit-4.1.0.tgz",
+      "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
+      "dev": true,
+      "license": "ISC",
+      "engines": {
+        "node": ">=14"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/isaacs"
+      }
+    },
+    "node_modules/source-map-js": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz",
+      "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+      "license": "BSD-3-Clause",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/speakingurl": {
+      "version": "14.0.1",
+      "resolved": "https://registry.npmmirror.com/speakingurl/-/speakingurl-14.0.1.tgz",
+      "integrity": "sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==",
+      "license": "BSD-3-Clause",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/stackback": {
+      "version": "0.0.2",
+      "resolved": "https://registry.npmmirror.com/stackback/-/stackback-0.0.2.tgz",
+      "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/statuses": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmmirror.com/statuses/-/statuses-2.0.2.tgz",
+      "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/std-env": {
+      "version": "3.10.0",
+      "resolved": "https://registry.npmmirror.com/std-env/-/std-env-3.10.0.tgz",
+      "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/streamsearch": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmmirror.com/streamsearch/-/streamsearch-1.1.0.tgz",
+      "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==",
+      "engines": {
+        "node": ">=10.0.0"
+      }
+    },
+    "node_modules/string_decoder": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmmirror.com/string_decoder/-/string_decoder-1.1.1.tgz",
+      "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
+      "license": "MIT",
+      "dependencies": {
+        "safe-buffer": "~5.1.0"
+      }
+    },
+    "node_modules/string_decoder/node_modules/safe-buffer": {
+      "version": "5.1.2",
+      "resolved": "https://registry.npmmirror.com/safe-buffer/-/safe-buffer-5.1.2.tgz",
+      "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
+      "license": "MIT"
+    },
+    "node_modules/string-width": {
+      "version": "5.1.2",
+      "resolved": "https://registry.npmmirror.com/string-width/-/string-width-5.1.2.tgz",
+      "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "eastasianwidth": "^0.2.0",
+        "emoji-regex": "^9.2.2",
+        "strip-ansi": "^7.0.1"
+      },
+      "engines": {
+        "node": ">=12"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/string-width-cjs": {
+      "name": "string-width",
+      "version": "4.2.3",
+      "resolved": "https://registry.npmmirror.com/string-width/-/string-width-4.2.3.tgz",
+      "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "emoji-regex": "^8.0.0",
+        "is-fullwidth-code-point": "^3.0.0",
+        "strip-ansi": "^6.0.1"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/string-width-cjs/node_modules/ansi-regex": {
+      "version": "5.0.1",
+      "resolved": "https://registry.npmmirror.com/ansi-regex/-/ansi-regex-5.0.1.tgz",
+      "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/string-width-cjs/node_modules/emoji-regex": {
+      "version": "8.0.0",
+      "resolved": "https://registry.npmmirror.com/emoji-regex/-/emoji-regex-8.0.0.tgz",
+      "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/string-width-cjs/node_modules/strip-ansi": {
+      "version": "6.0.1",
+      "resolved": "https://registry.npmmirror.com/strip-ansi/-/strip-ansi-6.0.1.tgz",
+      "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "ansi-regex": "^5.0.1"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/strip-ansi": {
+      "version": "7.2.0",
+      "resolved": "https://registry.npmmirror.com/strip-ansi/-/strip-ansi-7.2.0.tgz",
+      "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "ansi-regex": "^6.2.2"
+      },
+      "engines": {
+        "node": ">=12"
+      },
+      "funding": {
+        "url": "https://github.com/chalk/strip-ansi?sponsor=1"
+      }
+    },
+    "node_modules/strip-ansi-cjs": {
+      "name": "strip-ansi",
+      "version": "6.0.1",
+      "resolved": "https://registry.npmmirror.com/strip-ansi/-/strip-ansi-6.0.1.tgz",
+      "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "ansi-regex": "^5.0.1"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/strip-ansi-cjs/node_modules/ansi-regex": {
+      "version": "5.0.1",
+      "resolved": "https://registry.npmmirror.com/ansi-regex/-/ansi-regex-5.0.1.tgz",
+      "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/strip-literal": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmmirror.com/strip-literal/-/strip-literal-3.1.0.tgz",
+      "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "js-tokens": "^9.0.1"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/antfu"
+      }
+    },
+    "node_modules/strip-literal/node_modules/js-tokens": {
+      "version": "9.0.1",
+      "resolved": "https://registry.npmmirror.com/js-tokens/-/js-tokens-9.0.1.tgz",
+      "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/superagent": {
+      "version": "10.3.0",
+      "resolved": "https://registry.npmmirror.com/superagent/-/superagent-10.3.0.tgz",
+      "integrity": "sha512-B+4Ik7ROgVKrQsXTV0Jwp2u+PXYLSlqtDAhYnkkD+zn3yg8s/zjA2MeGayPoY/KICrbitwneDHrjSotxKL+0XQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "component-emitter": "^1.3.1",
+        "cookiejar": "^2.1.4",
+        "debug": "^4.3.7",
+        "fast-safe-stringify": "^2.1.1",
+        "form-data": "^4.0.5",
+        "formidable": "^3.5.4",
+        "methods": "^1.1.2",
+        "mime": "2.6.0",
+        "qs": "^6.14.1"
+      },
+      "engines": {
+        "node": ">=14.18.0"
+      }
+    },
+    "node_modules/superjson": {
+      "version": "2.2.6",
+      "resolved": "https://registry.npmmirror.com/superjson/-/superjson-2.2.6.tgz",
+      "integrity": "sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==",
+      "license": "MIT",
+      "dependencies": {
+        "copy-anything": "^4"
+      },
+      "engines": {
+        "node": ">=16"
+      }
+    },
+    "node_modules/supertest": {
+      "version": "7.2.2",
+      "resolved": "https://registry.npmmirror.com/supertest/-/supertest-7.2.2.tgz",
+      "integrity": "sha512-oK8WG9diS3DlhdUkcFn4tkNIiIbBx9lI2ClF8K+b2/m8Eyv47LSawxUzZQSNKUrVb2KsqeTDCcjAAVPYaSLVTA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "cookie-signature": "^1.2.2",
+        "methods": "^1.1.2",
+        "superagent": "^10.3.0"
+      },
+      "engines": {
+        "node": ">=14.18.0"
+      }
+    },
+    "node_modules/supports-color": {
+      "version": "7.2.0",
+      "resolved": "https://registry.npmmirror.com/supports-color/-/supports-color-7.2.0.tgz",
+      "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "has-flag": "^4.0.0"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/symbol-tree": {
+      "version": "3.2.4",
+      "resolved": "https://registry.npmmirror.com/symbol-tree/-/symbol-tree-3.2.4.tgz",
+      "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/test-exclude": {
+      "version": "7.0.2",
+      "resolved": "https://registry.npmmirror.com/test-exclude/-/test-exclude-7.0.2.tgz",
+      "integrity": "sha512-u9E6A+ZDYdp7a4WnarkXPZOx8Ilz46+kby6p1yZ8zsGTz9gYa6FIS7lj2oezzNKmtdyyJNNmmXDppga5GB7kSw==",
+      "dev": true,
+      "license": "ISC",
+      "dependencies": {
+        "@istanbuljs/schema": "^0.1.2",
+        "glob": "^10.4.1",
+        "minimatch": "^10.2.2"
+      },
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/test-exclude/node_modules/balanced-match": {
+      "version": "4.0.4",
+      "resolved": "https://registry.npmmirror.com/balanced-match/-/balanced-match-4.0.4.tgz",
+      "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": "18 || 20 || >=22"
+      }
+    },
+    "node_modules/test-exclude/node_modules/brace-expansion": {
+      "version": "5.0.9",
+      "resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-5.0.9.tgz",
+      "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "balanced-match": "^4.0.2"
+      },
+      "engines": {
+        "node": "20 || >=22"
+      }
+    },
+    "node_modules/test-exclude/node_modules/minimatch": {
+      "version": "10.2.6",
+      "resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-10.2.6.tgz",
+      "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==",
+      "dev": true,
+      "license": "BlueOak-1.0.0",
+      "dependencies": {
+        "brace-expansion": "^5.0.8"
+      },
+      "engines": {
+        "node": "18 || 20 || >=22"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/isaacs"
+      }
+    },
+    "node_modules/tinybench": {
+      "version": "2.9.0",
+      "resolved": "https://registry.npmmirror.com/tinybench/-/tinybench-2.9.0.tgz",
+      "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/tinyexec": {
+      "version": "0.3.2",
+      "resolved": "https://registry.npmmirror.com/tinyexec/-/tinyexec-0.3.2.tgz",
+      "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/tinyglobby": {
+      "version": "0.2.17",
+      "resolved": "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.17.tgz",
+      "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "fdir": "^6.5.0",
+        "picomatch": "^4.0.4"
+      },
+      "engines": {
+        "node": ">=12.0.0"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/SuperchupuDev"
+      }
+    },
+    "node_modules/tinypool": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmmirror.com/tinypool/-/tinypool-1.1.1.tgz",
+      "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": "^18.0.0 || >=20.0.0"
+      }
+    },
+    "node_modules/tinyrainbow": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmmirror.com/tinyrainbow/-/tinyrainbow-2.0.0.tgz",
+      "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=14.0.0"
+      }
+    },
+    "node_modules/tinyspy": {
+      "version": "4.0.4",
+      "resolved": "https://registry.npmmirror.com/tinyspy/-/tinyspy-4.0.4.tgz",
+      "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=14.0.0"
+      }
+    },
+    "node_modules/tldts": {
+      "version": "7.4.10",
+      "resolved": "https://registry.npmmirror.com/tldts/-/tldts-7.4.10.tgz",
+      "integrity": "sha512-GgouD1B+sWwvkaEq8vXC15DjQitxbvs12oIXELpconwm+Tg3zfcEv4jgzq3vtKverDXsg3VI8aRgNL2Nra0Iog==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "tldts-core": "^7.4.10"
+      },
+      "bin": {
+        "tldts": "bin/cli.js"
+      }
+    },
+    "node_modules/tldts-core": {
+      "version": "7.4.10",
+      "resolved": "https://registry.npmmirror.com/tldts-core/-/tldts-core-7.4.10.tgz",
+      "integrity": "sha512-KnQjp53ZekKgm/r3l+u8kJGGzYgrWdP8+Mql7a4vijh2WE0IrZWspQj/TpTxDho/YxO+AnOZnIjQcCD+q6iJsw==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/toidentifier": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmmirror.com/toidentifier/-/toidentifier-1.0.1.tgz",
+      "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.6"
+      }
+    },
+    "node_modules/tough-cookie": {
+      "version": "6.0.2",
+      "resolved": "https://registry.npmmirror.com/tough-cookie/-/tough-cookie-6.0.2.tgz",
+      "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==",
+      "dev": true,
+      "license": "BSD-3-Clause",
+      "dependencies": {
+        "tldts": "^7.0.5"
+      },
+      "engines": {
+        "node": ">=16"
+      }
+    },
+    "node_modules/tr46": {
+      "version": "6.0.0",
+      "resolved": "https://registry.npmmirror.com/tr46/-/tr46-6.0.0.tgz",
+      "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "punycode": "^2.3.1"
+      },
+      "engines": {
+        "node": ">=20"
+      }
+    },
+    "node_modules/treemate": {
+      "version": "0.3.11",
+      "resolved": "https://registry.npmmirror.com/treemate/-/treemate-0.3.11.tgz",
+      "integrity": "sha512-M8RGFoKtZ8dF+iwJfAJTOH/SM4KluKOKRJpjCMhI8bG3qB74zrFoArKZ62ll0Fr3mqkMJiQOmWYkdYgDeITYQg==",
+      "license": "MIT"
+    },
+    "node_modules/tsscmp": {
+      "version": "1.0.6",
+      "resolved": "https://registry.npmmirror.com/tsscmp/-/tsscmp-1.0.6.tgz",
+      "integrity": "sha512-LxhtAkPDTkVCMQjt2h6eBVY28KCjikZqZfMcC15YBeNjkgUpdCfBu5HoiOTDu86v6smE8yOjyEktJ8hlbANHQA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.6.x"
+      }
+    },
+    "node_modules/tsx": {
+      "version": "4.23.12",
+      "resolved": "https://registry.npmmirror.com/tsx/-/tsx-4.23.12.tgz",
+      "integrity": "sha512-FDf4L4sYzKtzWYhU/Xm0AQFdTjdIxNo9ElTf2mxXM6k8YMHXzYUe4yODVaXP4V9uMFbVg8c0qyBccK2OOxb45Q==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "esbuild": "~0.28.0"
+      },
+      "bin": {
+        "tsx": "dist/cli.mjs"
+      },
+      "engines": {
+        "node": ">=18.0.0"
+      },
+      "optionalDependencies": {
+        "fsevents": "~2.3.3"
+      }
+    },
+    "node_modules/tsx/node_modules/fsevents": {
+      "version": "2.3.3",
+      "resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz",
+      "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+      "dev": true,
+      "hasInstallScript": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+      }
+    },
+    "node_modules/type-is": {
+      "version": "1.6.18",
+      "resolved": "https://registry.npmmirror.com/type-is/-/type-is-1.6.18.tgz",
+      "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
+      "license": "MIT",
+      "dependencies": {
+        "media-typer": "0.3.0",
+        "mime-types": "~2.1.24"
+      },
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/type-is/node_modules/mime-db": {
+      "version": "1.52.0",
+      "resolved": "https://registry.npmmirror.com/mime-db/-/mime-db-1.52.0.tgz",
+      "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/type-is/node_modules/mime-types": {
+      "version": "2.1.35",
+      "resolved": "https://registry.npmmirror.com/mime-types/-/mime-types-2.1.35.tgz",
+      "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
+      "license": "MIT",
+      "dependencies": {
+        "mime-db": "1.52.0"
+      },
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/typescript": {
+      "version": "6.0.3",
+      "resolved": "https://registry.npmmirror.com/typescript/-/typescript-6.0.3.tgz",
+      "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
+      "devOptional": true,
+      "license": "Apache-2.0",
+      "bin": {
+        "tsc": "bin/tsc",
+        "tsserver": "bin/tsserver"
+      },
+      "engines": {
+        "node": ">=14.17"
+      }
+    },
+    "node_modules/uc.micro": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmmirror.com/uc.micro/-/uc.micro-2.1.0.tgz",
+      "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==",
+      "license": "MIT"
+    },
+    "node_modules/undici-types": {
+      "version": "7.18.2",
+      "resolved": "https://registry.npmmirror.com/undici-types/-/undici-types-7.18.2.tgz",
+      "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/universalify": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmmirror.com/universalify/-/universalify-2.0.1.tgz",
+      "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 10.0.0"
+      }
+    },
+    "node_modules/unpipe": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmmirror.com/unpipe/-/unpipe-1.0.0.tgz",
+      "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/unzipper": {
+      "version": "0.12.5",
+      "resolved": "https://registry.npmmirror.com/unzipper/-/unzipper-0.12.5.tgz",
+      "integrity": "sha512-tXYOi9R57Uj/2Z25SOs5RRSzq886MBQj2gY8dPL+xl/kv6s6SvByoKfAtvfVeEuhntWDgjd2o9p2lb4TVPAz0A==",
+      "license": "MIT",
+      "dependencies": {
+        "bluebird": "~3.7.2",
+        "duplexer2": "~0.1.4",
+        "fs-extra": "11.3.1",
+        "graceful-fs": "^4.2.2",
+        "node-int64": "^0.4.0"
+      }
+    },
+    "node_modules/util-deprecate": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmmirror.com/util-deprecate/-/util-deprecate-1.0.2.tgz",
+      "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
+      "license": "MIT"
+    },
+    "node_modules/vary": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmmirror.com/vary/-/vary-1.1.2.tgz",
+      "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/vdirs": {
+      "version": "0.1.8",
+      "resolved": "https://registry.npmmirror.com/vdirs/-/vdirs-0.1.8.tgz",
+      "integrity": "sha512-H9V1zGRLQZg9b+GdMk8MXDN2Lva0zx72MPahDKc30v+DtwKjfyOSXWRIX4t2mhDubM1H09gPhWeth/BJWPHGUw==",
+      "license": "MIT",
+      "dependencies": {
+        "evtd": "^0.2.2"
+      },
+      "peerDependencies": {
+        "vue": "^3.0.11"
+      }
+    },
+    "node_modules/vite": {
+      "version": "8.2.1",
+      "resolved": "https://registry.npmmirror.com/vite/-/vite-8.2.1.tgz",
+      "integrity": "sha512-EU/eS7BH3XROHh2YnBefjM6DBKA6ZeMZEYQbj7NLWg5wHYlhB8B/Mayd5XsgWq+NFYccDOTemRpdETWR6Ka/lw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "lightningcss": "^1.33.0",
+        "picomatch": "^4.0.5",
+        "postcss": "^8.5.25",
+        "rolldown": "~1.2.1",
+        "tinyglobby": "^0.2.17"
+      },
+      "bin": {
+        "vite": "bin/vite.js"
+      },
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      },
+      "funding": {
+        "url": "https://github.com/vitejs/vite?sponsor=1"
+      },
+      "optionalDependencies": {
+        "fsevents": "~2.3.3"
+      },
+      "peerDependencies": {
+        "@types/node": "^20.19.0 || >=22.12.0",
+        "@vitejs/devtools": "^0.4.0",
+        "esbuild": "^0.27.0 || ^0.28.0",
+        "jiti": ">=1.21.0",
+        "less": "^4.0.0",
+        "sass": "^1.70.0",
+        "sass-embedded": "^1.70.0",
+        "stylus": ">=0.54.8",
+        "sugarss": "^5.0.0",
+        "terser": "^5.16.0",
+        "tsx": "^4.8.1",
+        "yaml": "^2.4.2"
+      },
+      "peerDependenciesMeta": {
+        "@types/node": {
+          "optional": true
+        },
+        "@vitejs/devtools": {
+          "optional": true
+        },
+        "esbuild": {
+          "optional": true
+        },
+        "jiti": {
+          "optional": true
+        },
+        "less": {
+          "optional": true
+        },
+        "sass": {
+          "optional": true
+        },
+        "sass-embedded": {
+          "optional": true
+        },
+        "stylus": {
+          "optional": true
+        },
+        "sugarss": {
+          "optional": true
+        },
+        "terser": {
+          "optional": true
+        },
+        "tsx": {
+          "optional": true
+        },
+        "yaml": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/vite-node": {
+      "version": "3.2.4",
+      "resolved": "https://registry.npmmirror.com/vite-node/-/vite-node-3.2.4.tgz",
+      "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "cac": "^6.7.14",
+        "debug": "^4.4.1",
+        "es-module-lexer": "^1.7.0",
+        "pathe": "^2.0.3",
+        "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0"
+      },
+      "bin": {
+        "vite-node": "vite-node.mjs"
+      },
+      "engines": {
+        "node": "^18.0.0 || ^20.0.0 || >=22.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/vitest"
+      }
+    },
+    "node_modules/vite-node/node_modules/fsevents": {
+      "version": "2.3.3",
+      "resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz",
+      "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+      "dev": true,
+      "hasInstallScript": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+      }
+    },
+    "node_modules/vite-node/node_modules/vite": {
+      "version": "7.3.6",
+      "resolved": "https://registry.npmmirror.com/vite/-/vite-7.3.6.tgz",
+      "integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "esbuild": "^0.27.0 || ^0.28.0",
+        "fdir": "^6.5.0",
+        "picomatch": "^4.0.3",
+        "postcss": "^8.5.6",
+        "rollup": "^4.43.0",
+        "tinyglobby": "^0.2.15"
+      },
+      "bin": {
+        "vite": "bin/vite.js"
+      },
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      },
+      "funding": {
+        "url": "https://github.com/vitejs/vite?sponsor=1"
+      },
+      "optionalDependencies": {
+        "fsevents": "~2.3.3"
+      },
+      "peerDependencies": {
+        "@types/node": "^20.19.0 || >=22.12.0",
+        "jiti": ">=1.21.0",
+        "less": "^4.0.0",
+        "lightningcss": "^1.21.0",
+        "sass": "^1.70.0",
+        "sass-embedded": "^1.70.0",
+        "stylus": ">=0.54.8",
+        "sugarss": "^5.0.0",
+        "terser": "^5.16.0",
+        "tsx": "^4.8.1",
+        "yaml": "^2.4.2"
+      },
+      "peerDependenciesMeta": {
+        "@types/node": {
+          "optional": true
+        },
+        "jiti": {
+          "optional": true
+        },
+        "less": {
+          "optional": true
+        },
+        "lightningcss": {
+          "optional": true
+        },
+        "sass": {
+          "optional": true
+        },
+        "sass-embedded": {
+          "optional": true
+        },
+        "stylus": {
+          "optional": true
+        },
+        "sugarss": {
+          "optional": true
+        },
+        "terser": {
+          "optional": true
+        },
+        "tsx": {
+          "optional": true
+        },
+        "yaml": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/vite/node_modules/fsevents": {
+      "version": "2.3.3",
+      "resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz",
+      "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+      "dev": true,
+      "hasInstallScript": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+      }
+    },
+    "node_modules/vitest": {
+      "version": "3.2.7",
+      "resolved": "https://registry.npmmirror.com/vitest/-/vitest-3.2.7.tgz",
+      "integrity": "sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/chai": "^5.2.2",
+        "@vitest/expect": "3.2.7",
+        "@vitest/mocker": "3.2.7",
+        "@vitest/pretty-format": "^3.2.7",
+        "@vitest/runner": "3.2.7",
+        "@vitest/snapshot": "3.2.7",
+        "@vitest/spy": "3.2.7",
+        "@vitest/utils": "3.2.7",
+        "chai": "^5.2.0",
+        "debug": "^4.4.1",
+        "expect-type": "^1.2.1",
+        "magic-string": "^0.30.17",
+        "pathe": "^2.0.3",
+        "picomatch": "^4.0.2",
+        "std-env": "^3.9.0",
+        "tinybench": "^2.9.0",
+        "tinyexec": "^0.3.2",
+        "tinyglobby": "^0.2.14",
+        "tinypool": "^1.1.1",
+        "tinyrainbow": "^2.0.0",
+        "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0",
+        "vite-node": "3.2.4",
+        "why-is-node-running": "^2.3.0"
+      },
+      "bin": {
+        "vitest": "vitest.mjs"
+      },
+      "engines": {
+        "node": "^18.0.0 || ^20.0.0 || >=22.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/vitest"
+      },
+      "peerDependencies": {
+        "@edge-runtime/vm": "*",
+        "@types/debug": "^4.1.12",
+        "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0",
+        "@vitest/browser": "3.2.7",
+        "@vitest/ui": "3.2.7",
+        "happy-dom": "*",
+        "jsdom": "*"
+      },
+      "peerDependenciesMeta": {
+        "@edge-runtime/vm": {
+          "optional": true
+        },
+        "@types/debug": {
+          "optional": true
+        },
+        "@types/node": {
+          "optional": true
+        },
+        "@vitest/browser": {
+          "optional": true
+        },
+        "@vitest/ui": {
+          "optional": true
+        },
+        "happy-dom": {
+          "optional": true
+        },
+        "jsdom": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/vitest/node_modules/@vitest/mocker": {
+      "version": "3.2.7",
+      "resolved": "https://registry.npmmirror.com/@vitest/mocker/-/mocker-3.2.7.tgz",
+      "integrity": "sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@vitest/spy": "3.2.7",
+        "estree-walker": "^3.0.3",
+        "magic-string": "^0.30.17"
+      },
+      "funding": {
+        "url": "https://opencollective.com/vitest"
+      },
+      "peerDependencies": {
+        "msw": "^2.4.9",
+        "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0"
+      },
+      "peerDependenciesMeta": {
+        "msw": {
+          "optional": true
+        },
+        "vite": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/vitest/node_modules/estree-walker": {
+      "version": "3.0.3",
+      "resolved": "https://registry.npmmirror.com/estree-walker/-/estree-walker-3.0.3.tgz",
+      "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/estree": "^1.0.0"
+      }
+    },
+    "node_modules/vitest/node_modules/fsevents": {
+      "version": "2.3.3",
+      "resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz",
+      "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+      "dev": true,
+      "hasInstallScript": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+      }
+    },
+    "node_modules/vitest/node_modules/vite": {
+      "version": "7.3.6",
+      "resolved": "https://registry.npmmirror.com/vite/-/vite-7.3.6.tgz",
+      "integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "esbuild": "^0.27.0 || ^0.28.0",
+        "fdir": "^6.5.0",
+        "picomatch": "^4.0.3",
+        "postcss": "^8.5.6",
+        "rollup": "^4.43.0",
+        "tinyglobby": "^0.2.15"
+      },
+      "bin": {
+        "vite": "bin/vite.js"
+      },
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      },
+      "funding": {
+        "url": "https://github.com/vitejs/vite?sponsor=1"
+      },
+      "optionalDependencies": {
+        "fsevents": "~2.3.3"
+      },
+      "peerDependencies": {
+        "@types/node": "^20.19.0 || >=22.12.0",
+        "jiti": ">=1.21.0",
+        "less": "^4.0.0",
+        "lightningcss": "^1.21.0",
+        "sass": "^1.70.0",
+        "sass-embedded": "^1.70.0",
+        "stylus": ">=0.54.8",
+        "sugarss": "^5.0.0",
+        "terser": "^5.16.0",
+        "tsx": "^4.8.1",
+        "yaml": "^2.4.2"
+      },
+      "peerDependenciesMeta": {
+        "@types/node": {
+          "optional": true
+        },
+        "jiti": {
+          "optional": true
+        },
+        "less": {
+          "optional": true
+        },
+        "lightningcss": {
+          "optional": true
+        },
+        "sass": {
+          "optional": true
+        },
+        "sass-embedded": {
+          "optional": true
+        },
+        "stylus": {
+          "optional": true
+        },
+        "sugarss": {
+          "optional": true
+        },
+        "terser": {
+          "optional": true
+        },
+        "tsx": {
+          "optional": true
+        },
+        "yaml": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/vooks": {
+      "version": "0.2.12",
+      "resolved": "https://registry.npmmirror.com/vooks/-/vooks-0.2.12.tgz",
+      "integrity": "sha512-iox0I3RZzxtKlcgYaStQYKEzWWGAduMmq+jS7OrNdQo1FgGfPMubGL3uGHOU9n97NIvfFDBGnpSvkWyb/NSn/Q==",
+      "license": "MIT",
+      "dependencies": {
+        "evtd": "^0.2.2"
+      },
+      "peerDependencies": {
+        "vue": "^3.0.0"
+      }
+    },
+    "node_modules/vscode-uri": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmmirror.com/vscode-uri/-/vscode-uri-3.1.0.tgz",
+      "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/vue": {
+      "version": "3.5.41",
+      "resolved": "https://registry.npmmirror.com/vue/-/vue-3.5.41.tgz",
+      "integrity": "sha512-2laE0p+aK+/AOPG/XL/WepOs/GlK755LJ1XECi9kDUrz1FKNw8rb2Xzlw9JS1rqEV55nb0ttsKxVlTCcd+R5cg==",
+      "license": "MIT",
+      "dependencies": {
+        "@vue/compiler-dom": "3.5.41",
+        "@vue/compiler-sfc": "3.5.41",
+        "@vue/runtime-dom": "3.5.41",
+        "@vue/server-renderer": "3.5.41",
+        "@vue/shared": "3.5.41"
+      },
+      "peerDependencies": {
+        "typescript": "*"
+      },
+      "peerDependenciesMeta": {
+        "typescript": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/vue-component-type-helpers": {
+      "version": "3.3.10",
+      "resolved": "https://registry.npmmirror.com/vue-component-type-helpers/-/vue-component-type-helpers-3.3.10.tgz",
+      "integrity": "sha512-t7IQivQ3oD4D01b7s7a9AWzHAcr3DGBIa/1jZREsFQJcFgSL92gqUkqNiHTYgzTt7QDuJa0I9wCeDwEtZICoBQ==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/vue-router": {
+      "version": "4.6.4",
+      "resolved": "https://registry.npmmirror.com/vue-router/-/vue-router-4.6.4.tgz",
+      "integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==",
+      "license": "MIT",
+      "dependencies": {
+        "@vue/devtools-api": "^6.6.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/posva"
+      },
+      "peerDependencies": {
+        "vue": "^3.5.0"
+      }
+    },
+    "node_modules/vue-router/node_modules/@vue/devtools-api": {
+      "version": "6.6.4",
+      "resolved": "https://registry.npmmirror.com/@vue/devtools-api/-/devtools-api-6.6.4.tgz",
+      "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==",
+      "license": "MIT"
+    },
+    "node_modules/vue-tsc": {
+      "version": "3.3.10",
+      "resolved": "https://registry.npmmirror.com/vue-tsc/-/vue-tsc-3.3.10.tgz",
+      "integrity": "sha512-YaDVxcW+CGtaOt3pZahMG5jYPx0hsUTxyEoPOTSMebcGUXP9lIBabQ14vfKMORb2CqqK5CxsNo/d1d+4IQwiKg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@volar/typescript": "2.4.28",
+        "@vue/language-core": "3.3.10"
+      },
+      "bin": {
+        "vue-tsc": "bin/vue-tsc.js"
+      },
+      "peerDependencies": {
+        "typescript": ">=5.0.0"
+      }
+    },
+    "node_modules/vueuc": {
+      "version": "0.4.65",
+      "resolved": "https://registry.npmmirror.com/vueuc/-/vueuc-0.4.65.tgz",
+      "integrity": "sha512-lXuMl+8gsBmruudfxnMF9HW4be8rFziylXFu1VHVNbLVhRTXXV4njvpRuJapD/8q+oFEMSfQMH16E/85VoWRyQ==",
+      "license": "MIT",
+      "dependencies": {
+        "@css-render/vue3-ssr": "^0.15.10",
+        "@juggle/resize-observer": "^3.3.1",
+        "css-render": "^0.15.10",
+        "evtd": "^0.2.4",
+        "seemly": "^0.3.6",
+        "vdirs": "^0.1.4",
+        "vooks": "^0.2.4"
+      },
+      "peerDependencies": {
+        "vue": "^3.0.11"
+      }
+    },
+    "node_modules/w3c-xmlserializer": {
+      "version": "5.0.0",
+      "resolved": "https://registry.npmmirror.com/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
+      "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "xml-name-validator": "^5.0.0"
+      },
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/webidl-conversions": {
+      "version": "8.0.1",
+      "resolved": "https://registry.npmmirror.com/webidl-conversions/-/webidl-conversions-8.0.1.tgz",
+      "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==",
+      "dev": true,
+      "license": "BSD-2-Clause",
+      "engines": {
+        "node": ">=20"
+      }
+    },
+    "node_modules/whatwg-mimetype": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmmirror.com/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz",
+      "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/whatwg-url": {
+      "version": "15.1.0",
+      "resolved": "https://registry.npmmirror.com/whatwg-url/-/whatwg-url-15.1.0.tgz",
+      "integrity": "sha512-2ytDk0kiEj/yu90JOAp44PVPUkO9+jVhyf+SybKlRHSDlvOOZhdPIrr7xTH64l4WixO2cP+wQIcgujkGBPPz6g==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "tr46": "^6.0.0",
+        "webidl-conversions": "^8.0.0"
+      },
+      "engines": {
+        "node": ">=20"
+      }
+    },
+    "node_modules/which": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmmirror.com/which/-/which-2.0.2.tgz",
+      "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
+      "dev": true,
+      "license": "ISC",
+      "dependencies": {
+        "isexe": "^2.0.0"
+      },
+      "bin": {
+        "node-which": "bin/node-which"
+      },
+      "engines": {
+        "node": ">= 8"
+      }
+    },
+    "node_modules/why-is-node-running": {
+      "version": "2.3.0",
+      "resolved": "https://registry.npmmirror.com/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
+      "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "siginfo": "^2.0.0",
+        "stackback": "0.0.2"
+      },
+      "bin": {
+        "why-is-node-running": "cli.js"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/wrap-ansi": {
+      "version": "8.1.0",
+      "resolved": "https://registry.npmmirror.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz",
+      "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "ansi-styles": "^6.1.0",
+        "string-width": "^5.0.1",
+        "strip-ansi": "^7.0.1"
+      },
+      "engines": {
+        "node": ">=12"
+      },
+      "funding": {
+        "url": "https://github.com/chalk/wrap-ansi?sponsor=1"
+      }
+    },
+    "node_modules/wrap-ansi-cjs": {
+      "name": "wrap-ansi",
+      "version": "7.0.0",
+      "resolved": "https://registry.npmmirror.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
+      "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "ansi-styles": "^4.0.0",
+        "string-width": "^4.1.0",
+        "strip-ansi": "^6.0.0"
+      },
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/chalk/wrap-ansi?sponsor=1"
+      }
+    },
+    "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": {
+      "version": "5.0.1",
+      "resolved": "https://registry.npmmirror.com/ansi-regex/-/ansi-regex-5.0.1.tgz",
+      "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/wrap-ansi-cjs/node_modules/ansi-styles": {
+      "version": "4.3.0",
+      "resolved": "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-4.3.0.tgz",
+      "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "color-convert": "^2.0.1"
+      },
+      "engines": {
+        "node": ">=8"
+      },
+      "funding": {
+        "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+      }
+    },
+    "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": {
+      "version": "8.0.0",
+      "resolved": "https://registry.npmmirror.com/emoji-regex/-/emoji-regex-8.0.0.tgz",
+      "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/wrap-ansi-cjs/node_modules/string-width": {
+      "version": "4.2.3",
+      "resolved": "https://registry.npmmirror.com/string-width/-/string-width-4.2.3.tgz",
+      "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "emoji-regex": "^8.0.0",
+        "is-fullwidth-code-point": "^3.0.0",
+        "strip-ansi": "^6.0.1"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": {
+      "version": "6.0.1",
+      "resolved": "https://registry.npmmirror.com/strip-ansi/-/strip-ansi-6.0.1.tgz",
+      "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "ansi-regex": "^5.0.1"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/wrappy": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmmirror.com/wrappy/-/wrappy-1.0.2.tgz",
+      "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
+      "dev": true,
+      "license": "ISC"
+    },
+    "node_modules/ws": {
+      "version": "8.21.3",
+      "resolved": "https://registry.npmmirror.com/ws/-/ws-8.21.3.tgz",
+      "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=10.0.0"
+      },
+      "peerDependencies": {
+        "bufferutil": "^4.0.1",
+        "utf-8-validate": ">=5.0.2"
+      },
+      "peerDependenciesMeta": {
+        "bufferutil": {
+          "optional": true
+        },
+        "utf-8-validate": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/xml-name-validator": {
+      "version": "5.0.0",
+      "resolved": "https://registry.npmmirror.com/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
+      "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/xmlchars": {
+      "version": "2.2.0",
+      "resolved": "https://registry.npmmirror.com/xmlchars/-/xmlchars-2.2.0.tgz",
+      "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/ylru": {
+      "version": "1.4.0",
+      "resolved": "https://registry.npmmirror.com/ylru/-/ylru-1.4.0.tgz",
+      "integrity": "sha512-2OQsPNEmBCvXuFlIni/a+Rn+R2pHW9INm0BxXJ4hVDA8TirqMj+J/Rp9ItLatT/5pZqWwefVrTQcHpixsxnVlA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 4.0.0"
+      }
+    },
+    "node_modules/zod": {
+      "version": "4.4.3",
+      "resolved": "https://registry.npmmirror.com/zod/-/zod-4.4.3.tgz",
+      "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
+      "license": "MIT",
+      "funding": {
+        "url": "https://github.com/sponsors/colinhacks"
+      }
+    }
+  }
+}

+ 76 - 0
package.json

@@ -0,0 +1,76 @@
+{
+  "name": "hermes-yaoyao",
+  "version": "0.1.0",
+  "private": true,
+  "license": "BSL-1.1",
+  "description": "夭夭 Web:直连 Hermes 9119 的聊天、群聊、文件库与产物工作台",
+  "type": "module",
+  "engines": {
+    "node": ">=24.0.0"
+  },
+  "bin": {
+    "hermes-yaoyao": "./bin/hermes-yaoyao.mjs"
+  },
+  "scripts": {
+    "dev": "cross-env NODE_ENV=development tsx watch src/server/index.ts",
+    "build": "npm run brand:verify && vue-tsc -b && vite build && tsc -p tsconfig.server.json",
+    "start": "cross-env NODE_ENV=production node dist-server/server/index.js",
+    "typecheck": "vue-tsc -b && tsc --noEmit -p tsconfig.server.json",
+    "test": "vitest run",
+    "test:watch": "vitest",
+    "test:e2e": "npm run build && playwright test",
+    "test:live": "cross-env HERMES_YAOYAO_LIVE_TEST=1 vitest run tests/live",
+    "brand:verify": "node scripts/verify-brand.mjs",
+    "verify:lifecycle": "node scripts/verify-lifecycle.mjs"
+  },
+  "dependencies": {
+    "@koa/bodyparser": "^5.0.0",
+    "@koa/router": "^15.4.0",
+    "@vicons/tabler": "^0.13.0",
+    "busboy": "^1.6.0",
+    "cookie": "^1.0.2",
+    "docx-preview": "^0.3.7",
+    "dompurify": "^3.2.6",
+    "highlight.js": "^11.11.1",
+    "idb": "^8.0.3",
+    "koa": "^2.15.3",
+    "koa-send": "^5.0.1",
+    "koa-static": "^5.0.0",
+    "markdown-it": "^14.1.0",
+    "mime-types": "^3.0.1",
+    "naive-ui": "^2.44.1",
+    "pdfjs-dist": "^5.7.284",
+    "pinia": "^3.0.4",
+    "read-excel-file": "^7.0.3",
+    "vue": "^3.5.32",
+    "vue-router": "^4.6.4",
+    "ws": "^8.20.0",
+    "zod": "^4.1.12"
+  },
+  "devDependencies": {
+    "@playwright/test": "^1.60.0",
+    "@types/busboy": "^1.5.4",
+    "@types/jsdom": "^21.1.7",
+    "@types/koa": "^2.15.0",
+    "@types/koa-send": "^4.1.6",
+    "@types/koa-static": "^4.0.4",
+    "@types/markdown-it": "^14.1.2",
+    "@types/mime-types": "^2.1.4",
+    "@types/node": "^24.10.0",
+    "@types/supertest": "^6.0.3",
+    "@types/ws": "^8.18.1",
+    "@vitejs/plugin-vue": "^6.0.5",
+    "@vitest/coverage-v8": "^3.2.4",
+    "@vue/test-utils": "^2.4.6",
+    "@vue/tsconfig": "^0.9.1",
+    "cross-env": "^10.1.0",
+    "jsdom": "^27.0.1",
+    "sass": "^1.99.0",
+    "supertest": "^7.1.4",
+    "tsx": "^4.21.0",
+    "typescript": "~6.0.2",
+    "vite": "^8.0.4",
+    "vitest": "^3.2.4",
+    "vue-tsc": "^3.2.8"
+  }
+}

+ 30 - 0
playwright.config.ts

@@ -0,0 +1,30 @@
+import { defineConfig, devices } from '@playwright/test'
+
+export default defineConfig({
+  testDir: './tests/e2e',
+  timeout: 30_000,
+  fullyParallel: false,
+  workers: 1,
+  reporter: 'list',
+  use: {
+    baseURL: 'http://127.0.0.1:18801',
+    viewport: { width: 1280, height: 720 },
+    trace: 'retain-on-failure',
+    screenshot: 'only-on-failure',
+  },
+  projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],
+  webServer: [
+    {
+      command: 'node tests/fixtures/fake-hermes.mjs',
+      port: 19119,
+      reuseExistingServer: true,
+      timeout: 15_000,
+    },
+    {
+      command: 'cross-env NODE_ENV=production HERMES_YAOYAO_HOST=127.0.0.1 HERMES_YAOYAO_PORT=18801 HERMES_YAOYAO_UPSTREAM=http://127.0.0.1:19119 node dist-server/server/index.js',
+      url: 'http://127.0.0.1:18801/healthz',
+      reuseExistingServer: true,
+      timeout: 15_000,
+    },
+  ],
+})

BIN
public/brand/AppIcon-1024.png


BIN
public/icons/apple-touch-icon.png


BIN
public/icons/favicon-32.png


BIN
public/icons/icon-192.png


BIN
public/icons/icon-512.png


BIN
public/icons/logo-64.png


+ 21 - 0
public/manifest.webmanifest

@@ -0,0 +1,21 @@
+{
+  "name": "夭夭 Web",
+  "short_name": "夭夭",
+  "description": "Hermes 聊天、群聊、文件库与产物工作台",
+  "start_url": "/",
+  "display": "standalone",
+  "background_color": "#fafafa",
+  "theme_color": "#fafafa",
+  "icons": [
+    {
+      "src": "/icons/icon-192.png",
+      "sizes": "192x192",
+      "type": "image/png"
+    },
+    {
+      "src": "/icons/icon-512.png",
+      "sizes": "512x512",
+      "type": "image/png"
+    }
+  ]
+}

+ 13 - 0
scripts/verify-brand.mjs

@@ -0,0 +1,13 @@
+import { createHash } from 'node:crypto'
+import { readFile } from 'node:fs/promises'
+import { fileURLToPath } from 'node:url'
+
+const expected = 'e19c03450c62238aeb70ad735f612d4bfc61964e1473b1be4bdca1d9230bd086'
+const source = fileURLToPath(new URL('../public/brand/AppIcon-1024.png', import.meta.url))
+const digest = createHash('sha256').update(await readFile(source)).digest('hex')
+
+if (digest !== expected) {
+  throw new Error(`Brand source hash mismatch: expected ${expected}, received ${digest}`)
+}
+
+process.stdout.write(`Brand source verified: ${digest}\n`)

+ 65 - 0
scripts/verify-lifecycle.mjs

@@ -0,0 +1,65 @@
+import { spawn, spawnSync } from 'node:child_process'
+import { setTimeout as delay } from 'node:timers/promises'
+
+const port = Number(process.env.HERMES_YAOYAO_VERIFY_PORT || 18800)
+const entry = new URL('../dist-server/server/index.js', import.meta.url)
+
+function listenerPids(targetPort) {
+  const result = spawnSync('lsof', ['-nP', `-iTCP:${targetPort}`, '-sTCP:LISTEN', '-t'], {
+    encoding: 'utf8',
+  })
+  return new Set(result.stdout.split(/\s+/).map(value => value.trim()).filter(Boolean))
+}
+
+async function waitForHealth() {
+  const deadline = Date.now() + 15_000
+  while (Date.now() < deadline) {
+    try {
+      const response = await fetch(`http://127.0.0.1:${port}/healthz`, {
+        headers: { Host: `127.0.0.1:${port}` },
+      })
+      if (response.ok) return
+    } catch {
+      // Server may still be materializing the production bundle.
+    }
+    await delay(150)
+  }
+  throw new Error('Timed out waiting for the verification server')
+}
+
+const gatewayBefore = listenerPids(9119)
+const child = spawn(process.execPath, [entry.pathname], {
+  cwd: new URL('..', import.meta.url),
+  env: {
+    ...process.env,
+    NODE_ENV: 'production',
+    HERMES_YAOYAO_HOST: '127.0.0.1',
+    HERMES_YAOYAO_PORT: String(port),
+    HERMES_YAOYAO_UPSTREAM: process.env.HERMES_YAOYAO_UPSTREAM || 'http://127.0.0.1:9119',
+  },
+  stdio: ['ignore', 'pipe', 'pipe'],
+})
+
+let stderr = ''
+child.stderr.on('data', chunk => { stderr += chunk.toString() })
+
+try {
+  await waitForHealth()
+  const webPids = listenerPids(port)
+  if (webPids.size !== 1) throw new Error(`Expected one ${port} listener, received ${[...webPids].join(', ') || 'none'}`)
+} finally {
+  child.kill('SIGTERM')
+  await Promise.race([
+    new Promise(resolve => child.once('exit', resolve)),
+    delay(5_000).then(() => child.kill('SIGKILL')),
+  ])
+}
+
+const gatewayAfter = listenerPids(9119)
+if ([...gatewayBefore].join(',') !== [...gatewayAfter].join(',')) {
+  throw new Error(`9119 listener changed: before=${[...gatewayBefore]} after=${[...gatewayAfter]}`)
+}
+if (child.exitCode !== 0 && child.signalCode !== 'SIGTERM') {
+  throw new Error(`Verification server failed: ${stderr.trim()}`)
+}
+process.stdout.write(`Lifecycle verified: one ${port} listener; 9119 unchanged (${[...gatewayAfter].join(',') || 'no listener'}).\n`)

+ 24 - 0
src/client/App.vue

@@ -0,0 +1,24 @@
+<script setup lang="ts">
+import { onMounted, watch } from 'vue'
+import LoadingScreen from '@/components/app/LoadingScreen.vue'
+import LoginView from '@/views/LoginView.vue'
+import { useAuthStore } from '@/stores/auth'
+import { useThemeStore } from '@/stores/theme'
+
+const auth = useAuthStore()
+const theme = useThemeStore()
+
+watch(() => theme.resolvedTheme, value => {
+  document.documentElement.classList.toggle('dark', value === 'dark')
+  document.documentElement.dataset.theme = value
+  document.querySelector('meta[name="theme-color"]')?.setAttribute('content', value === 'dark' ? '#181817' : '#f8f8f6')
+}, { immediate: true })
+
+onMounted(() => auth.bootstrap())
+</script>
+
+<template>
+  <LoadingScreen v-if="auth.status === 'checking'" />
+  <LoginView v-else-if="!auth.isAuthenticated" />
+  <RouterView v-else />
+</template>

+ 59 - 0
src/client/api/auth.ts

@@ -0,0 +1,59 @@
+import type { BootstrapResponse, CurrentUser, JsonValue, Profile } from '@shared/types'
+import { apiRequest, setApiCsrfToken, unwrapData } from './client'
+import { bool, normalizeProfile, normalizeUser, record, string, values } from '@/utils/normalize'
+
+function normalizeBootstrap(payload: unknown): BootstrapResponse {
+  const root = record(unwrapData(payload as never))
+  const status = record(root.status)
+  const authRequired = bool(root.authRequired ?? root.auth_required ?? status.authRequired ?? status.auth_required)
+  const rawUser = root.user ?? root.identity
+  const profiles = values(root.profiles).map(normalizeProfile)
+  const csrfToken = string(root.csrfToken ?? root.csrf_token ?? root.csrf)
+  const response: BootstrapResponse = {
+    status: string(status.state ?? root.state) || undefined,
+    authRequired,
+    profiles,
+    csrfToken,
+    insecureLan: bool(root.insecureLan ?? root.insecure_lan),
+    groupUploadsEnabled: bool(root.groupUploadsEnabled ?? root.group_uploads_enabled),
+  }
+  if (rawUser) response.user = normalizeUser(rawUser)
+  setApiCsrfToken(csrfToken)
+  return response
+}
+
+export async function bootstrap(): Promise<BootstrapResponse> {
+  return normalizeBootstrap(await apiRequest<unknown>('/api/app/bootstrap', { csrf: false }))
+}
+
+export interface LoginInput {
+  username: string
+  password: string
+  provider?: string
+}
+
+export async function login(input: LoginInput): Promise<BootstrapResponse> {
+  const payload = await apiRequest<unknown>('/api/app/login', {
+    method: 'POST',
+    body: { ...input, next: '/' } as JsonValue,
+    notifyUnauthorized: false,
+  })
+  const response = normalizeBootstrap(payload)
+  if (!response.user && response.authRequired) return bootstrap()
+  return response
+}
+
+export async function logout(): Promise<void> {
+  await apiRequest('/api/app/logout', { method: 'POST', body: {}, notifyUnauthorized: false })
+  setApiCsrfToken('')
+}
+
+export async function fetchProfiles(): Promise<Profile[]> {
+  const payload = record(unwrapData(await apiRequest<unknown>('/api/app/profiles')))
+  return values(payload.profiles ?? payload.items ?? payload).map(normalizeProfile)
+}
+
+export async function fetchCurrentUser(): Promise<CurrentUser | undefined> {
+  const response = await bootstrap()
+  return response.user
+}

+ 146 - 0
src/client/api/client.ts

@@ -0,0 +1,146 @@
+import type { JsonValue } from '@shared/types'
+
+export class ApiError extends Error {
+  readonly status: number
+  readonly code?: string
+  readonly details?: JsonValue
+
+  constructor(message: string, status = 0, code?: string, details?: JsonValue) {
+    super(message)
+    this.name = 'ApiError'
+    this.status = status
+    this.code = code
+    this.details = details
+  }
+}
+
+let csrfToken = ''
+const unauthorizedListeners = new Set<() => void>()
+const securityChannel = typeof window !== 'undefined' && typeof window.BroadcastChannel !== 'undefined'
+  ? new window.BroadcastChannel('hermes-yaoyao-security')
+  : undefined
+securityChannel?.addEventListener('message', event => {
+  if (typeof event.data === 'string') csrfToken = event.data
+})
+
+export function setApiCsrfToken(token?: string | null): void {
+  csrfToken = token?.trim() ?? ''
+  securityChannel?.postMessage(csrfToken)
+}
+
+export function clearApiSecurityContext(): void {
+  csrfToken = ''
+}
+
+export function onApiUnauthorized(listener: () => void): () => void {
+  unauthorizedListeners.add(listener)
+  return () => unauthorizedListeners.delete(listener)
+}
+
+export interface ApiRequestOptions extends Omit<RequestInit, 'body'> {
+  body?: JsonValue | FormData | Blob
+  csrf?: boolean
+  timeoutMs?: number
+  notifyUnauthorized?: boolean
+}
+
+function isBodyInit(value: ApiRequestOptions['body']): value is FormData | Blob {
+  return (typeof FormData !== 'undefined' && value instanceof FormData)
+    || (typeof Blob !== 'undefined' && value instanceof Blob)
+}
+
+function errorMessage(payload: unknown, fallback: string): string {
+  if (payload && typeof payload === 'object') {
+    const record = payload as Record<string, unknown>
+    const nested = record.error && typeof record.error === 'object'
+      ? record.error as Record<string, unknown>
+      : undefined
+    for (const candidate of [record.message, record.error, nested?.message, record.detail]) {
+      if (typeof candidate === 'string' && candidate.trim()) return candidate.trim()
+    }
+  }
+  if (typeof payload === 'string' && payload.trim()) return payload.trim()
+  return fallback
+}
+
+export async function apiRequest<T>(path: string, options: ApiRequestOptions = {}): Promise<T> {
+  if (!path.startsWith('/api/app/')) throw new ApiError('客户端仅允许访问 /api/app 接口', 0, 'INVALID_PATH')
+
+  const method = (options.method ?? 'GET').toUpperCase()
+  const headers = new Headers(options.headers)
+  headers.set('Accept', 'application/json')
+  if (options.csrf !== false && !['GET', 'HEAD', 'OPTIONS'].includes(method)) {
+    if (!csrfToken) throw new ApiError('安全令牌尚未就绪,请刷新后重试', 0, 'CSRF_MISSING')
+    headers.set('X-CSRF-Token', csrfToken)
+  }
+
+  let body: BodyInit | undefined
+  if (options.body !== undefined) {
+    if (isBodyInit(options.body)) {
+      body = options.body
+    } else {
+      headers.set('Content-Type', 'application/json')
+      body = JSON.stringify(options.body)
+    }
+  }
+
+  const controller = new AbortController()
+  const timeout = window.setTimeout(() => controller.abort(), options.timeoutMs ?? 30_000)
+  const abort = () => controller.abort(options.signal?.reason)
+  options.signal?.addEventListener('abort', abort, { once: true })
+  try {
+    const response = await fetch(path, {
+      ...options,
+      method,
+      headers,
+      body,
+      credentials: 'include',
+      cache: 'no-store',
+      signal: controller.signal,
+    })
+    const contentType = response.headers.get('content-type') ?? ''
+    const payload = response.status === 204
+      ? undefined
+      : contentType.includes('application/json')
+        ? await response.json().catch(() => undefined)
+        : await response.text().catch(() => undefined)
+    if (!response.ok) {
+      const record = payload && typeof payload === 'object' ? payload as Record<string, unknown> : undefined
+      const code = typeof record?.code === 'string' ? record.code : undefined
+      if ((response.status === 401 || response.status === 403)
+        && code !== 'invalid_csrf'
+        && options.notifyUnauthorized !== false) {
+        for (const listener of unauthorizedListeners) listener()
+      }
+      throw new ApiError(
+        errorMessage(payload, `请求失败(${response.status})`),
+        response.status,
+        code,
+        payload as JsonValue,
+      )
+    }
+    return payload as T
+  } catch (error) {
+    if (error instanceof ApiError) throw error
+    if (controller.signal.aborted) throw new ApiError('请求已取消或超时', 0, 'REQUEST_ABORTED')
+    throw new ApiError(error instanceof Error ? error.message : '网络请求失败', 0, 'NETWORK_ERROR')
+  } finally {
+    window.clearTimeout(timeout)
+    options.signal?.removeEventListener('abort', abort)
+  }
+}
+
+export function apiUrl(path: string, query: Record<string, string | number | boolean | null | undefined> = {}): string {
+  const params = new URLSearchParams()
+  for (const [key, value] of Object.entries(query)) {
+    if (value !== undefined && value !== null && value !== '') params.set(key, String(value))
+  }
+  const encoded = params.toString()
+  return encoded ? `${path}?${encoded}` : path
+}
+
+export function unwrapData<T>(payload: T | { data: T }): T {
+  return payload && typeof payload === 'object' && 'data' in payload
+    ? (payload as { data: T }).data
+    : payload as T
+}

+ 40 - 0
src/client/api/files.ts

@@ -0,0 +1,40 @@
+import type { FileKind, FileLibraryPage } from '@shared/types'
+import { apiRequest, apiUrl, unwrapData } from './client'
+import { normalizeFile, number, record, values } from '@/utils/normalize'
+
+export interface FileQuery {
+  search?: string
+  kind?: FileKind | 'all'
+  profile?: string
+  conversationOnly?: boolean
+  cursor?: string
+  limit?: number
+}
+
+export async function getFiles(query: FileQuery = {}): Promise<FileLibraryPage> {
+  const serverKind = query.kind === 'image' || query.kind === 'video' ? query.kind : undefined
+  const payload = unwrapData(await apiRequest<unknown>(apiUrl('/api/app/files', {
+    search: query.search,
+    kind: serverKind,
+    profile: query.profile,
+    conversationOnly: query.conversationOnly || undefined,
+    cursor: query.cursor,
+    limit: Math.max(1, Math.min(100, query.limit ?? 50)),
+  })))
+  const source = record(payload)
+  const responseProfile = typeof source.profile === 'string' ? source.profile : query.profile
+  return {
+    items: values(source.items ?? source.files ?? payload)
+      .map(item => normalizeFile(item, responseProfile)).filter(file => file.id),
+    nextCursor: typeof source.nextCursor === 'string' ? source.nextCursor : typeof source.next_cursor === 'string' ? source.next_cursor : null,
+    total: number(source.total) || undefined,
+  }
+}
+
+export function fileDownloadUrl(id: string, profile?: string): string {
+  return apiUrl(`/api/app/files/${encodeURIComponent(id)}/download`, { profile })
+}
+
+export function filePreviewUrl(id: string, profile?: string): string {
+  return apiUrl(`/api/app/files/${encodeURIComponent(id)}/preview`, { profile })
+}

+ 132 - 0
src/client/api/groups.ts

@@ -0,0 +1,132 @@
+import type {
+  GroupAgent, GroupCapabilities, GroupInteraction, GroupMessagePage, GroupRoomDetail, GroupRoomPage, JsonValue, UploadReference,
+} from '@shared/types'
+import { apiRequest, apiUrl, unwrapData } from './client'
+import {
+  normalizeGroupAgent, normalizeGroupCapabilities, normalizeGroupInteraction, normalizeGroupMessage,
+  normalizeGroupRoom, normalizeGroupRoomDetail, record, values,
+} from '@/utils/normalize'
+import { createUuid } from '@/utils/id'
+
+const BASE = '/api/app/groups'
+
+function requestId(): string { return createUuid() }
+
+export async function getGroupCapabilities(): Promise<GroupCapabilities> {
+  return normalizeGroupCapabilities(unwrapData(await apiRequest<unknown>(`${BASE}/capabilities`)))
+}
+
+export async function getGroupRooms(cursor?: string, limit = 50): Promise<GroupRoomPage> {
+  const payload = unwrapData(await apiRequest<unknown>(apiUrl(`${BASE}/rooms`, { cursor, limit: Math.max(1, Math.min(100, limit)) })))
+  const source = record(payload)
+  return {
+    items: values(source.items ?? source.rooms ?? payload).map(normalizeGroupRoom).filter(room => room.id),
+    nextCursor: typeof source.nextCursor === 'string' ? source.nextCursor : typeof source.next_cursor === 'string' ? source.next_cursor : null,
+  }
+}
+
+export async function getGroupRoom(roomId: string): Promise<GroupRoomDetail> {
+  return normalizeGroupRoomDetail(unwrapData(await apiRequest<unknown>(`${BASE}/rooms/${encodeURIComponent(roomId)}`)))
+}
+
+export interface AgentSeed { profile: string; displayName: string; description?: string; replyWithoutMention?: boolean }
+
+export async function createGroupRoom(input: { name: string; cwd?: string; agents: AgentSeed[]; maxReplyRounds?: number }): Promise<GroupRoomDetail> {
+  return normalizeGroupRoomDetail(unwrapData(await apiRequest<unknown>(`${BASE}/rooms`, {
+    method: 'POST', body: { requestId: requestId(), cwd: '', maxReplyRounds: 3, ...input } as unknown as JsonValue,
+  })))
+}
+
+export async function updateGroupRoom(roomId: string, input: { name?: string; cwd?: string; maxReplyRounds?: number }): Promise<GroupRoomDetail> {
+  return normalizeGroupRoomDetail(unwrapData(await apiRequest<unknown>(`${BASE}/rooms/${encodeURIComponent(roomId)}`, {
+    method: 'PATCH', body: { requestId: requestId(), ...input } as JsonValue,
+  })))
+}
+
+export async function archiveGroupRoom(roomId: string): Promise<GroupRoomDetail> {
+  return normalizeGroupRoomDetail(unwrapData(await apiRequest<unknown>(`${BASE}/rooms/${encodeURIComponent(roomId)}`, {
+    method: 'DELETE', body: { requestId: requestId() },
+  })))
+}
+
+export async function addGroupAgent(roomId: string, input: AgentSeed): Promise<GroupAgent> {
+  return normalizeGroupAgent(unwrapData(await apiRequest<unknown>(`${BASE}/rooms/${encodeURIComponent(roomId)}/agents`, {
+    method: 'POST', body: { requestId: requestId(), description: '', replyWithoutMention: false, ...input } as JsonValue,
+  })))
+}
+
+export async function updateGroupAgent(roomId: string, agentId: string, input: Partial<Omit<AgentSeed, 'profile'>> & { enabled?: boolean }): Promise<GroupAgent> {
+  return normalizeGroupAgent(unwrapData(await apiRequest<unknown>(`${BASE}/rooms/${encodeURIComponent(roomId)}/agents/${encodeURIComponent(agentId)}`, {
+    method: 'PATCH', body: { requestId: requestId(), ...input } as JsonValue,
+  })))
+}
+
+export async function removeGroupAgent(roomId: string, agentId: string): Promise<GroupAgent> {
+  return normalizeGroupAgent(unwrapData(await apiRequest<unknown>(`${BASE}/rooms/${encodeURIComponent(roomId)}/agents/${encodeURIComponent(agentId)}`, {
+    method: 'DELETE', body: { requestId: requestId() },
+  })))
+}
+
+export async function interruptGroupAgent(roomId: string, agentId: string): Promise<unknown> {
+  return unwrapData(await apiRequest(`${BASE}/rooms/${encodeURIComponent(roomId)}/agents/${encodeURIComponent(agentId)}/interrupt`, {
+    method: 'POST', body: { requestId: requestId() },
+  }))
+}
+
+export async function getGroupMessages(roomId: string, options: { beforeSeq?: number; afterSeq?: number; limit?: number } = {}): Promise<GroupMessagePage> {
+  if (options.beforeSeq && options.afterSeq) throw new Error('beforeSeq 与 afterSeq 不能同时使用')
+  const payload = unwrapData(await apiRequest<unknown>(apiUrl(`${BASE}/rooms/${encodeURIComponent(roomId)}/messages`, {
+    beforeSeq: options.beforeSeq, afterSeq: options.afterSeq, limit: Math.max(1, Math.min(100, options.limit ?? 100)),
+  })))
+  const source = record(payload)
+  return { items: values(source.items ?? source.messages ?? payload).map(normalizeGroupMessage).filter(message => message.id) }
+}
+
+export async function sendGroupMessage(
+  roomId: string,
+  content: string,
+  mentionAgentIds: string[],
+  clientMessageId: string,
+  uploadIds: string[] = [],
+  stableRequestId = requestId(),
+): Promise<{ message: ReturnType<typeof normalizeGroupMessage>; runs: unknown[] }> {
+  const payload = record(unwrapData(await apiRequest<unknown>(`${BASE}/rooms/${encodeURIComponent(roomId)}/messages`, {
+    method: 'POST', body: { requestId: stableRequestId, clientMessageId, content, mentionAgentIds, uploadIds } as JsonValue,
+  })))
+  return { message: normalizeGroupMessage(payload.message), runs: values(payload.runs) }
+}
+
+export async function approveGroupInteraction(roomId: string, interactionId: string, choice: 'once' | 'session' | 'always' | 'deny'): Promise<GroupInteraction | unknown> {
+  const payload = unwrapData(await apiRequest<unknown>(`${BASE}/rooms/${encodeURIComponent(roomId)}/interactions/${encodeURIComponent(interactionId)}/approval`, {
+    method: 'POST', body: { requestId: requestId(), choice, permanent: choice === 'always' },
+  }))
+  const source = record(payload)
+  return source.interaction ? normalizeGroupInteraction(source.interaction) : payload
+}
+
+export async function clarifyGroupInteraction(roomId: string, interactionId: string, response: string): Promise<GroupInteraction | unknown> {
+  const payload = unwrapData(await apiRequest<unknown>(`${BASE}/rooms/${encodeURIComponent(roomId)}/interactions/${encodeURIComponent(interactionId)}/clarification`, {
+    method: 'POST', body: { requestId: requestId(), response },
+  }))
+  const source = record(payload)
+  return source.interaction ? normalizeGroupInteraction(source.interaction) : payload
+}
+
+export async function uploadGroupFiles(roomId: string, files: File[]): Promise<UploadReference[]> {
+  const form = new FormData()
+  form.set('roomId', roomId)
+  for (const file of files) form.append('files', file, file.name)
+  const payload = unwrapData(await apiRequest<unknown>('/api/app/group-uploads', {
+    method: 'POST', body: form, timeoutMs: 120_000,
+  }))
+  const source = record(payload)
+  return values(source.files ?? source.items ?? source.uploads ?? payload).map(value => {
+    const item = record(value)
+    return {
+      id: String(item.id ?? ''), name: String(item.name ?? item.filename ?? ''),
+      mimeType: String(item.mimeType ?? item.mime_type ?? 'application/octet-stream'),
+      size: Number(item.size ?? 0), markdown: typeof item.markdown === 'string' ? item.markdown : undefined,
+      refText: typeof item.refText === 'string' ? item.refText : typeof item.ref_text === 'string' ? item.ref_text : undefined,
+    }
+  }).filter(item => item.id && item.name)
+}

+ 7 - 0
src/client/api/index.ts

@@ -0,0 +1,7 @@
+export * from './auth'
+export * from './client'
+export * from './files'
+export * from './groups'
+export * from './profiles'
+export * from './realtime'
+export * from './sessions'

+ 37 - 0
src/client/api/profiles.ts

@@ -0,0 +1,37 @@
+import type { ModelOption, Profile } from '@shared/types'
+import { apiRequest, unwrapData } from './client'
+import { normalizeModel, normalizeProfile, record, values } from '@/utils/normalize'
+
+export async function getProfiles(): Promise<Profile[]> {
+  const payload = unwrapData(await apiRequest<unknown>('/api/app/profiles'))
+  const source = record(payload)
+  return values(source.profiles ?? source.items ?? payload).map(normalizeProfile)
+}
+
+export async function getModels(profile?: string): Promise<ModelOption[]> {
+  const suffix = profile ? `?profile=${encodeURIComponent(profile)}` : ''
+  const payload = unwrapData(await apiRequest<unknown>(`/api/app/models${suffix}`))
+  const source = record(payload)
+  if (Array.isArray(source.providers)) {
+    const currentModel = typeof source.model === 'string' ? source.model : ''
+    const currentProvider = typeof source.provider === 'string' ? source.provider : ''
+    return source.providers.flatMap(rawProvider => {
+      const provider = record(rawProvider)
+      const slug = typeof provider.slug === 'string' ? provider.slug : typeof provider.name === 'string' ? provider.name : ''
+      const capabilities = record(provider.capabilities)
+      return values(provider.models).flatMap(rawModel => {
+        const model = typeof rawModel === 'string' ? rawModel : String(record(rawModel).id ?? record(rawModel).model ?? '')
+        if (!model || !slug) return []
+        const modelCapabilities = record(capabilities[model])
+        return [{
+          id: model,
+          name: model,
+          provider: slug,
+          supportsReasoning: typeof modelCapabilities.reasoning === 'boolean' ? modelCapabilities.reasoning : undefined,
+          isDefault: model === currentModel && slug === currentProvider,
+        } satisfies ModelOption]
+      })
+    })
+  }
+  return values(source.models ?? source.items ?? payload).map(normalizeModel).filter(model => model.id)
+}

+ 321 - 0
src/client/api/realtime.ts

@@ -0,0 +1,321 @@
+import type { GroupSocketEnvelope, JsonValue, RealtimeConnectionState, RpcEventFrame, RpcRequestFrame } from '@shared/types'
+import { apiRequest, ApiError, unwrapData } from './client'
+import { createId } from '@/utils/id'
+import { number, record, string } from '@/utils/normalize'
+
+export class RpcError extends Error {
+  readonly code?: number | string
+  readonly data?: JsonValue
+
+  constructor(message: string, code?: number | string, data?: JsonValue) {
+    super(message)
+    this.name = 'RpcError'
+    this.code = code
+    this.data = data
+  }
+}
+
+interface LeaseResponse { lease?: string; token?: string; id?: string; expiresAt?: string }
+
+export async function createRealtimeLease(channel: 'chat' | 'groups', anchor?: { epoch: string; cursor: number }): Promise<string> {
+  const payload = unwrapData(await apiRequest<LeaseResponse>('/api/app/realtime-leases', {
+    method: 'POST', body: { channel, ...(anchor ?? {}) }, timeoutMs: 15_000,
+  }))
+  const lease = payload.lease ?? payload.token ?? payload.id
+  if (!lease?.trim()) throw new ApiError('实时连接租约无效', 0, 'INVALID_LEASE')
+  return lease.trim()
+}
+
+function webSocketURL(path: string, query: Record<string, string | number | undefined>): string {
+  const url = new URL(path, window.location.origin)
+  url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
+  for (const [key, value] of Object.entries(query)) if (value !== undefined) url.searchParams.set(key, String(value))
+  return url.toString()
+}
+
+type StateListener = (state: RealtimeConnectionState, reason?: string) => void
+type RpcEventListener = (event: RpcEventFrame['params']) => void
+
+interface PendingRequest {
+  resolve(value: JsonValue): void
+  reject(reason: unknown): void
+  timer: number
+  method: string
+}
+
+export class ChatRpcSocket {
+  private socket?: WebSocket
+  private generation = 0
+  private pending = new Map<string, PendingRequest>()
+  private eventListeners = new Set<RpcEventListener>()
+  private stateListeners = new Set<StateListener>()
+  private manuallyClosed = false
+  state: RealtimeConnectionState = 'idle'
+
+  onEvent(listener: RpcEventListener): () => void {
+    this.eventListeners.add(listener)
+    return () => this.eventListeners.delete(listener)
+  }
+
+  onState(listener: StateListener): () => void {
+    this.stateListeners.add(listener)
+    return () => this.stateListeners.delete(listener)
+  }
+
+  async connect(): Promise<void> {
+    this.close()
+    this.manuallyClosed = false
+    const generation = ++this.generation
+    this.publishState('leasing')
+    const lease = await createRealtimeLease('chat')
+    if (generation !== this.generation || this.manuallyClosed) return
+    this.publishState('connecting')
+    await new Promise<void>((resolve, reject) => {
+      const socket = new WebSocket(webSocketURL('/ws/chat', { lease }))
+      this.socket = socket
+      let settled = false
+      const readyTimer = window.setTimeout(() => {
+        if (settled) return
+        settled = true
+        socket.close(1011, 'gateway ready timeout')
+        reject(new ApiError('等待 Hermes 实时握手超时', 0, 'GATEWAY_READY_TIMEOUT'))
+      }, 15_000)
+      socket.addEventListener('open', () => {
+        if (generation !== this.generation) return socket.close(1000)
+        this.publishState('connected')
+      }, { once: true })
+      socket.addEventListener('message', event => {
+        void this.handleMessage(event.data, generation).then(ready => {
+          if (!ready || settled) return
+          settled = true
+          window.clearTimeout(readyTimer)
+          resolve()
+        })
+      })
+      socket.addEventListener('error', () => {
+        if (!settled) {
+          settled = true
+          window.clearTimeout(readyTimer)
+          reject(new ApiError('聊天实时连接失败', 0, 'WEBSOCKET_ERROR'))
+        }
+      })
+      socket.addEventListener('close', event => {
+        if (!settled) {
+          settled = true
+          window.clearTimeout(readyTimer)
+          reject(new ApiError(event.reason || '聊天实时连接在握手前关闭', 0, 'WEBSOCKET_CLOSED'))
+        }
+        this.handleClose(event, generation)
+      })
+    })
+  }
+
+  close(): void {
+    this.manuallyClosed = true
+    this.generation += 1
+    this.socket?.close(1000, 'client close')
+    this.socket = undefined
+    this.rejectAll(new ApiError('实时连接已关闭', 0, 'WEBSOCKET_CLOSED'))
+    this.publishState('disconnected')
+  }
+
+  async request(method: string, params: Record<string, JsonValue> = {}, timeoutMs = 120_000): Promise<JsonValue> {
+    const socket = this.socket
+    if (!socket || socket.readyState !== WebSocket.OPEN) throw new ApiError('聊天实时连接尚未就绪', 0, 'WEBSOCKET_NOT_READY')
+    const id = createId('rpc')
+    const frame: RpcRequestFrame = { jsonrpc: '2.0', id, method, params }
+    return new Promise<JsonValue>((resolve, reject) => {
+      const timer = window.setTimeout(() => {
+        this.pending.delete(id)
+        reject(new ApiError(`实时请求超时:${method}`, 0, 'RPC_TIMEOUT'))
+        // A timed-out request has an unknown receipt and the transport may be half-open.
+        this.socket?.close(1011, 'rpc timeout')
+      }, timeoutMs)
+      this.pending.set(id, { resolve, reject, timer, method })
+      try {
+        socket.send(JSON.stringify(frame))
+      } catch (error) {
+        window.clearTimeout(timer)
+        this.pending.delete(id)
+        reject(error)
+      }
+    })
+  }
+
+  private async handleMessage(data: unknown, generation: number): Promise<boolean> {
+    if (generation !== this.generation) return false
+    let text: string
+    if (typeof data === 'string') text = data
+    else if (data instanceof Blob) text = await data.text()
+    else if (data instanceof ArrayBuffer) text = new TextDecoder().decode(data)
+    else return false
+    let frame: unknown
+    try { frame = JSON.parse(text) } catch { return false }
+    const source = record(frame)
+    const id = typeof source.id === 'string' || typeof source.id === 'number' ? String(source.id) : ''
+    if (id && this.pending.has(id)) {
+      const pending = this.pending.get(id)!
+      this.pending.delete(id)
+      window.clearTimeout(pending.timer)
+      const error = record(source.error)
+      if (source.error) pending.reject(new RpcError(string(error.message, 'Hermes JSON-RPC 请求失败'), error.code as number | string, error.data as JsonValue))
+      else pending.resolve((source.result ?? null) as JsonValue)
+      return false
+    }
+    if (source.method !== 'event') return false
+    const params = record(source.params)
+    const type = string(params.type)
+    if (!type) return false
+    const event: RpcEventFrame['params'] = {
+      type,
+      session_id: string(params.session_id) || undefined,
+      profile: string(params.profile) || undefined,
+      payload: (params.payload ?? null) as JsonValue,
+    }
+    if (type === 'gateway.ready') this.publishState('ready')
+    for (const listener of this.eventListeners) listener(event)
+    return type === 'gateway.ready'
+  }
+
+  private handleClose(event: CloseEvent, generation: number): void {
+    if (generation !== this.generation) return
+    this.socket = undefined
+    this.rejectAll(new ApiError('聊天实时连接已断开', 0, 'WEBSOCKET_CLOSED'))
+    this.publishState(this.manuallyClosed ? 'disconnected' : 'failed', event.reason || `连接关闭(${event.code})`)
+  }
+
+  private rejectAll(error: Error): void {
+    for (const pending of this.pending.values()) {
+      window.clearTimeout(pending.timer)
+      pending.reject(error)
+    }
+    this.pending.clear()
+  }
+
+  private publishState(state: RealtimeConnectionState, reason?: string): void {
+    this.state = state
+    for (const listener of this.stateListeners) listener(state, reason)
+  }
+}
+
+export interface GroupEventSocketOptions {
+  epoch: string
+  cursor: number
+  onEnvelope(envelope: GroupSocketEnvelope): void
+  onState?(state: RealtimeConnectionState, reason?: string): void
+  onReset(reason: string): void
+}
+
+export class GroupEventSocket {
+  private socket?: WebSocket
+  private generation = 0
+  private manuallyClosed = false
+  private epoch = ''
+  private cursor = 0
+  state: RealtimeConnectionState = 'idle'
+
+  async connect(options: GroupEventSocketOptions): Promise<void> {
+    this.close()
+    this.manuallyClosed = false
+    this.epoch = options.epoch
+    this.cursor = options.cursor
+    const generation = ++this.generation
+    this.publish(options, 'leasing')
+    const lease = await createRealtimeLease('groups', { epoch: this.epoch, cursor: this.cursor })
+    if (generation !== this.generation || this.manuallyClosed) return
+    this.publish(options, 'connecting')
+    await new Promise<void>((resolve, reject) => {
+      const socket = new WebSocket(webSocketURL('/ws/groups', { lease }))
+      this.socket = socket
+      let settled = false
+      socket.addEventListener('open', () => {
+        if (generation !== this.generation) return socket.close(1000)
+        settled = true
+        resolve()
+      }, { once: true })
+      socket.addEventListener('message', event => { void this.handleMessage(event.data, generation, options) })
+      socket.addEventListener('error', () => {
+        if (!settled) {
+          settled = true
+          reject(new ApiError('群聊事件连接失败', 0, 'GROUP_WEBSOCKET_ERROR'))
+        }
+      })
+      socket.addEventListener('close', event => {
+        if (generation !== this.generation) return
+        this.socket = undefined
+        this.publish(options, this.manuallyClosed ? 'disconnected' : 'failed', event.reason || `连接关闭(${event.code})`)
+      })
+    })
+  }
+
+  close(): void {
+    this.manuallyClosed = true
+    this.generation += 1
+    this.socket?.close(1000, 'client close')
+    this.socket = undefined
+    this.state = 'disconnected'
+  }
+
+  private async handleMessage(data: unknown, generation: number, options: GroupEventSocketOptions): Promise<void> {
+    if (generation !== this.generation) return
+    let text: string
+    if (typeof data === 'string') text = data
+    else if (data instanceof Blob) text = await data.text()
+    else if (data instanceof ArrayBuffer) text = new TextDecoder().decode(data)
+    else return
+    let decoded: unknown
+    try { decoded = JSON.parse(text) } catch {
+      this.needsReset(options, 'malformed_frame')
+      return
+    }
+    const source = record(decoded)
+    const envelope: GroupSocketEnvelope = {
+      type: string(source.type) as GroupSocketEnvelope['type'],
+      epoch: string(source.epoch) || undefined,
+      cursor: source.cursor == null ? undefined : number(source.cursor, -1),
+      heartbeatSeconds: source.heartbeatSeconds == null && source.heartbeat_seconds == null ? undefined : number(source.heartbeatSeconds ?? source.heartbeat_seconds),
+      roomId: string(source.roomId ?? source.room_id) || undefined,
+      event: string(source.event) || undefined,
+      payload: (source.payload ?? null) as JsonValue,
+      reason: string(source.reason) || undefined,
+    }
+    if (!['group.ready', 'group.event', 'group.heartbeat', 'group.reset_required'].includes(envelope.type)) {
+      this.needsReset(options, 'unknown_envelope')
+      return
+    }
+    if (envelope.epoch && envelope.epoch !== this.epoch) {
+      this.needsReset(options, 'epoch_mismatch')
+      return
+    }
+    if (envelope.type === 'group.ready') {
+      if (envelope.cursor !== this.cursor) return this.needsReset(options, 'ready_cursor_mismatch')
+      this.publish(options, 'ready')
+      options.onEnvelope(envelope)
+      return
+    }
+    if (envelope.type === 'group.event') {
+      if (envelope.cursor == null || envelope.cursor < 0) return this.needsReset(options, 'invalid_cursor')
+      if (envelope.cursor <= this.cursor) return
+      if (envelope.cursor !== this.cursor + 1) return this.needsReset(options, 'cursor_gap')
+      this.cursor = envelope.cursor
+      options.onEnvelope(envelope)
+      return
+    }
+    if (envelope.type === 'group.reset_required') {
+      this.needsReset(options, envelope.reason || 'server_reset')
+      return
+    }
+    if (envelope.cursor != null && envelope.cursor !== this.cursor) this.needsReset(options, 'heartbeat_cursor_mismatch')
+  }
+
+  private needsReset(options: GroupEventSocketOptions, reason: string): void {
+    this.publish(options, 'needs-reset', reason)
+    this.socket?.close(1000, 'reset required')
+    options.onReset(reason)
+  }
+
+  private publish(options: GroupEventSocketOptions, state: RealtimeConnectionState, reason?: string): void {
+    this.state = state
+    options.onState?.(state, reason)
+  }
+}

+ 161 - 0
src/client/api/sessions.ts

@@ -0,0 +1,161 @@
+import type { ChatMessage, JsonValue, SessionSummary } from '@shared/types'
+import { apiRequest, apiUrl, unwrapData } from './client'
+import { normalizeChatMessage, normalizeSession, number, record, values } from '@/utils/normalize'
+
+const EXCLUDED_SOURCES = 'cron,ios_group'
+
+export interface SessionPage {
+  items: SessionSummary[]
+  nextCursor?: string | null
+  total?: number
+}
+
+export interface MessagePage {
+  session?: SessionSummary
+  messages: ChatMessage[]
+  total: number
+  returned: number
+  hasMore: boolean
+  offset: number
+  limit: number
+}
+
+export async function getSessions(profile?: string, cursor?: string, limit = 100): Promise<SessionPage> {
+  const offset = Math.max(0, Number.parseInt(cursor ?? '0', 10) || 0)
+  const payload = unwrapData(await apiRequest<unknown>(apiUrl('/api/app/sessions', {
+    profile,
+    offset,
+    limit: Math.max(1, Math.min(200, limit)),
+    order: 'recent',
+    archived: 'exclude',
+    exclude: EXCLUDED_SOURCES,
+  })))
+  const source = record(payload)
+  const rawItems = values(source.items ?? source.sessions ?? payload)
+  const items = rawItems.map(item => normalizeSession(item, profile)).filter(session => session.id)
+    .filter(session => !['cron', 'ios_group'].includes(session.source))
+  const total = number(source.total)
+  const nextOffset = offset + items.length
+  return {
+    items,
+    nextCursor: typeof source.nextCursor === 'string'
+      ? source.nextCursor
+      : typeof source.next_cursor === 'string'
+        ? source.next_cursor
+        : total > nextOffset && items.length ? String(nextOffset) : null,
+    total: total || undefined,
+  }
+}
+
+export async function searchSessions(query: string, profile?: string, limit = 100): Promise<SessionSummary[]> {
+  const payload = unwrapData(await apiRequest<unknown>(apiUrl('/api/app/sessions/search', {
+    q: query,
+    profile,
+    limit: Math.max(1, Math.min(200, limit)),
+    exclude: EXCLUDED_SOURCES,
+  })))
+  const source = record(payload)
+  return values(source.items ?? source.sessions ?? source.results ?? payload).map(item => normalizeSession(item, profile))
+    .filter(session => session.id && !['cron', 'ios_group'].includes(session.source))
+}
+
+export async function getSession(id: string, profile?: string): Promise<SessionSummary> {
+  const payload = unwrapData(await apiRequest<unknown>(apiUrl(`/api/app/sessions/${encodeURIComponent(id)}`, { profile })))
+  const source = record(payload)
+  return normalizeSession(source.session ?? payload, profile)
+}
+
+export async function getMessages(
+  sessionId: string,
+  offset: number,
+  limit = 150,
+  profile?: string,
+): Promise<MessagePage> {
+  const safeOffset = Math.max(0, Math.trunc(offset))
+  const safeLimit = Math.max(1, Math.min(500, Math.trunc(limit)))
+  const url = apiUrl(`/api/app/sessions/${encodeURIComponent(sessionId)}/messages`, {
+    offset: safeOffset,
+    limit: safeLimit,
+    order: 'latest',
+    include_compacted: true,
+    profile,
+  })
+  const payload = unwrapData(await apiRequest<unknown>(url))
+  const source = record(payload)
+  const pagination = record(source.pagination)
+  const messages = values(source.messages ?? source.items ?? payload).map(message => normalizeChatMessage(message, sessionId, profile))
+  const returned = Math.max(number(pagination.returned), messages.length)
+  const total = Math.max(number(pagination.total ?? source.total), safeOffset + returned)
+  const hasMore = typeof pagination.hasMore === 'boolean'
+    ? pagination.hasMore
+    : typeof pagination.has_more === 'boolean'
+      ? pagination.has_more
+      : safeOffset + returned < total || returned >= safeLimit
+  return {
+    session: source.session ? normalizeSession(source.session, profile) : undefined,
+    messages,
+    total,
+    returned,
+    hasMore,
+    offset: safeOffset,
+    limit: safeLimit,
+  }
+}
+
+export async function createSession(profile: string, title?: string): Promise<SessionSummary> {
+  const payload = unwrapData(await apiRequest<unknown>('/api/app/sessions', {
+    method: 'POST',
+    body: { profile, title: title || null, source: 'web' } as JsonValue,
+  }))
+  const source = record(payload)
+  return normalizeSession(source.session ?? payload, profile)
+}
+
+export async function updateSession(
+  id: string,
+  patch: { title?: string; archived?: boolean; pinned?: boolean },
+  profile?: string,
+): Promise<SessionSummary> {
+  await apiRequest<unknown>(apiUrl(`/api/app/sessions/${encodeURIComponent(id)}`, { profile }), {
+    method: 'PATCH', body: patch as JsonValue,
+  })
+  return getSession(id, profile)
+}
+
+export async function deleteSession(id: string, profile?: string): Promise<void> {
+  await apiRequest(apiUrl(`/api/app/sessions/${encodeURIComponent(id)}`, { profile }), { method: 'DELETE' })
+}
+
+export async function getSessionUnread(profile?: string): Promise<Record<string, number>> {
+  const payload = unwrapData(await apiRequest<unknown>(apiUrl('/api/app/sessions/unread', { profile })))
+  const source = record(payload)
+  const sessionRows = values(source.sessions ?? source.items)
+  if (sessionRows.length) {
+    const result: Record<string, number> = {}
+    for (const value of sessionRows) {
+      const item = record(value)
+      const id = typeof item.sessionId === 'string'
+        ? item.sessionId
+        : typeof item.session_id === 'string' ? item.session_id : ''
+      if (id) result[id] = Math.max(0, number(item.unreadCount ?? item.unread_count ?? item.count))
+    }
+    return result
+  }
+  const raw = record(source.items ?? source.unread ?? source.counts ?? payload)
+  const result: Record<string, number> = {}
+  for (const [sessionId, value] of Object.entries(raw)) {
+    if (typeof value === 'number' || typeof value === 'string') result[sessionId] = Math.max(0, number(value))
+    else {
+      const item = record(value)
+      const id = typeof item.sessionId === 'string' ? item.sessionId : typeof item.session_id === 'string' ? item.session_id : sessionId
+      result[id] = Math.max(0, number(item.unreadCount ?? item.unread_count ?? item.count))
+    }
+  }
+  return result
+}
+
+export async function markSessionRead(sessionId: string, readMessageCount: number, profile?: string): Promise<void> {
+  await apiRequest(apiUrl(`/api/app/sessions/unread/${encodeURIComponent(sessionId)}`, { profile }), {
+    method: 'PATCH', body: { readMessageCount: Math.max(0, Math.trunc(readMessageCount)) },
+  })
+}

+ 19 - 0
src/client/components/app/LoadingScreen.vue

@@ -0,0 +1,19 @@
+<script setup lang="ts">
+import BrandMark from '@/components/common/BrandMark.vue'
+
+withDefaults(defineProps<{ message?: string }>(), { message: '正在恢复会话…' })
+</script>
+
+<template>
+  <main class="loading-screen" aria-live="polite">
+    <BrandMark :size="48" label />
+    <span class="loading-line"><i />{{ message }}</span>
+  </main>
+</template>
+
+<style scoped>
+.loading-screen { display: grid; place-content: center; justify-items: center; width: 100%; height: 100%; background: var(--canvas); }
+.loading-line { display: flex; align-items: center; gap: 8px; margin-top: 22px; color: var(--text-muted); font-size: 11px; }
+.loading-line i { width: 5px; height: 5px; border-radius: 50%; background: var(--text-primary); animation: blink 1.15s ease-in-out infinite; }
+@keyframes blink { 50% { opacity: .2; transform: scale(.72); } }
+</style>

+ 188 - 0
src/client/components/app/ResourceSidebar.vue

@@ -0,0 +1,188 @@
+<script setup lang="ts">
+import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
+import AppIcon from '@/components/common/AppIcon.vue'
+import type { SidebarItem } from './types'
+
+const SIDEBAR_SEARCH_EVENT = 'hermes-yaoyao:sidebar-search'
+const SIDEBAR_SEARCH_CLOSE_EVENT = 'hermes-yaoyao:sidebar-search-close'
+
+const props = withDefaults(defineProps<{
+  items: SidebarItem[]
+  activeId?: string
+  loading?: boolean
+  searchable?: boolean
+  search?: string
+  searchPlaceholder?: string
+  emptyTitle?: string
+  emptyDescription?: string
+}>(), {
+  activeId: '',
+  loading: false,
+  searchable: true,
+  search: '',
+  searchPlaceholder: '搜索',
+  emptyTitle: '暂无内容',
+  emptyDescription: '新建一项开始使用。',
+})
+
+const emit = defineEmits<{
+  select: [id: string]
+  search: [value: string]
+  more: [id: string]
+}>()
+
+const searchInput = ref<HTMLInputElement | null>(null)
+const searchOpen = ref(Boolean(props.search))
+const sectionCounts = computed(() => props.items.reduce<Record<string, number>>((counts, item) => {
+  if (item.section) counts[item.section] = (counts[item.section] ?? 0) + 1
+  return counts
+}, {}))
+const sidebarRows = computed(() => props.items.map((item, index) => ({
+  item,
+  section: item.section && item.section !== props.items[index - 1]?.section ? item.section : '',
+  sectionLabel: item.section === '已置顶' ? `已置顶 ${sectionCounts.value[item.section] ?? 0}` : item.section || '',
+})))
+
+async function focusSearch() {
+  if (!props.searchable) return
+  searchOpen.value = true
+  await nextTick()
+  const input = searchInput.value
+  if (!input || input.offsetParent === null) return
+  input.focus()
+  input.select()
+}
+
+function handleSearchInput(event: Event) {
+  const value = (event.target as HTMLInputElement).value
+  emit('search', value)
+  if (!value) searchOpen.value = false
+}
+
+function closeSearch(clear = false) {
+  if (clear) emit('search', '')
+  searchOpen.value = false
+  document.dispatchEvent(new CustomEvent(SIDEBAR_SEARCH_CLOSE_EVENT))
+}
+
+function handleSearchRequest() { void focusSearch() }
+function handleSearchCloseRequest() { searchOpen.value = false }
+
+watch(() => props.search, value => {
+  if (value) searchOpen.value = true
+})
+
+onMounted(() => {
+  document.addEventListener(SIDEBAR_SEARCH_EVENT, handleSearchRequest)
+  document.addEventListener(SIDEBAR_SEARCH_CLOSE_EVENT, handleSearchCloseRequest)
+})
+onBeforeUnmount(() => {
+  document.removeEventListener(SIDEBAR_SEARCH_EVENT, handleSearchRequest)
+  document.removeEventListener(SIDEBAR_SEARCH_CLOSE_EVENT, handleSearchCloseRequest)
+})
+
+defineExpose({ focusSearch })
+</script>
+
+<template>
+  <div class="resource-sidebar">
+    <Transition name="search-reveal">
+      <label v-if="searchable && searchOpen" class="sidebar-search">
+        <AppIcon name="search" :size="15" />
+        <input
+          ref="searchInput"
+          :value="search"
+          type="search"
+          :placeholder="searchPlaceholder"
+          @input="handleSearchInput"
+          @keydown.esc.prevent="closeSearch(true)"
+          @blur="!search && closeSearch()"
+        />
+      </label>
+    </Transition>
+    <div class="sidebar-list" role="listbox" aria-label="资源列表">
+      <div v-if="loading && !items.length" class="sidebar-loading" aria-label="正在加载">
+        <span v-for="n in 7" :key="n" class="sidebar-skeleton" :style="{ opacity: 1 - n * .08 }" />
+      </div>
+      <template v-else>
+        <template v-for="row in sidebarRows" :key="row.item.id">
+          <div v-if="row.section" class="sidebar-section-label" :class="{ 'sidebar-section-label--pinned': row.section === '已置顶' }" role="presentation"><span>{{ row.sectionLabel }}</span></div>
+          <div
+            class="sidebar-item"
+            :class="{ active: row.item.id === activeId || row.item.active }"
+            role="option"
+            tabindex="0"
+            :aria-selected="row.item.id === activeId || row.item.active"
+            @click="emit('select', row.item.id)"
+            @keydown.enter.prevent="emit('select', row.item.id)"
+            @keydown.space.prevent="emit('select', row.item.id)"
+          >
+            <span class="sidebar-item__icon" :class="{ 'sidebar-item__icon--avatar': !row.item.icon }">
+              <AppIcon v-if="row.item.icon" :name="row.item.icon" :size="15" />
+              <template v-else>{{ row.item.title.slice(0, 1).toUpperCase() }}</template>
+              <span v-if="row.item.status" class="presence" :class="`presence--${row.item.status}`" />
+            </span>
+            <span class="sidebar-item__copy">
+              <span class="sidebar-item__row">
+                <strong><AppIcon v-if="row.item.pinned" name="pin" :size="10" />{{ row.item.title }}</strong>
+                <small v-if="row.item.meta">{{ row.item.meta }}</small>
+              </span>
+              <span v-if="row.item.subtitle" class="sidebar-item__row sidebar-item__row--secondary">
+                <span>{{ row.item.subtitle }}</span>
+                <b v-if="row.item.unread">{{ row.item.unread > 99 ? '99+' : row.item.unread }}</b>
+              </span>
+            </span>
+            <button class="sidebar-item__more" type="button" aria-label="更多操作" @click.stop="emit('more', row.item.id)">
+              <AppIcon name="dots" :size="16" />
+            </button>
+          </div>
+        </template>
+      </template>
+      <div v-if="!loading && !items.length" class="sidebar-empty">
+        <span><AppIcon name="chat" :size="20" /></span>
+        <strong>{{ emptyTitle }}</strong>
+        <p>{{ emptyDescription }}</p>
+      </div>
+    </div>
+  </div>
+</template>
+
+<style scoped>
+.resource-sidebar { display: flex; height: 100%; min-height: 0; flex-direction: column; }
+.sidebar-search { display: flex; align-items: center; gap: 8px; min-height: 35px; margin: 0 11px 10px; padding: 0 10px; border: 1px solid var(--line); border-radius: 10px; color: var(--text-muted); background: var(--surface-soft); }
+.sidebar-search:focus-within { border-color: var(--line-strong); background: var(--surface-raised); box-shadow: 0 0 0 3px var(--focus-ring); }
+.sidebar-search input { min-width: 0; flex: 1; border: 0; outline: 0; background: transparent; color: var(--text-primary); font-size: 12px; }
+.sidebar-search input::placeholder { color: var(--text-muted); }
+.search-reveal-enter-active, .search-reveal-leave-active { transition: opacity 120ms ease, transform 120ms ease; }
+.search-reveal-enter-from, .search-reveal-leave-to { opacity: 0; transform: translateY(-3px); }
+.sidebar-list { min-height: 0; flex: 1; overflow: auto; padding: 0 7px 14px; scrollbar-gutter: stable; }
+.sidebar-section-label { display: flex; align-items: center; gap: 8px; min-height: 25px; padding: 7px 8px 3px; color: var(--text-muted); font-size: 9px; font-weight: 560; }
+.sidebar-section-label::after { height: 1px; flex: 1; background: var(--line); content: ''; }
+.sidebar-section-label--pinned { min-height: 28px; padding-top: 9px; color: var(--text-secondary); font-size: 10px; font-weight: 650; }
+.sidebar-section-label--pinned::after { background: transparent; }
+.sidebar-item { position: relative; display: flex; align-items: center; gap: 7px; width: 100%; min-height: 42px; padding: 4px 7px; border: 0; border-radius: 8px; background: transparent; color: var(--text-primary); cursor: pointer; text-align: left; transition: background-color 120ms ease, color 120ms ease; }
+.sidebar-item:hover { background: var(--surface-soft); }
+.sidebar-item.active { background: var(--surface-hover); }
+.sidebar-item__icon { position: relative; display: grid; place-items: center; width: 23px; height: 23px; flex: 0 0 23px; border: 0; border-radius: 7px; color: var(--text-muted); background: transparent; }
+.sidebar-item.active .sidebar-item__icon, .sidebar-item:hover .sidebar-item__icon { color: var(--text-secondary); }
+.sidebar-item__icon--avatar { background: var(--surface-hover); color: var(--text-secondary); font-size: 9px; font-weight: 700; }
+.presence { position: absolute; right: -2px; bottom: -2px; width: 8px; height: 8px; border: 2px solid var(--surface); border-radius: 50%; background: var(--text-muted); }
+.presence--online { background: var(--success); }.presence--working { background: var(--warning); }.presence--offline { background: var(--text-muted); }
+.sidebar-item__copy { display: flex; min-width: 0; flex: 1; flex-direction: column; gap: 1px; }
+.sidebar-item__row { display: flex; align-items: center; justify-content: space-between; gap: 8px; min-width: 0; }
+.sidebar-item__row strong, .sidebar-item__row span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.sidebar-item__row strong { display: flex; min-width: 0; align-items: center; gap: 4px; font-size: 11.5px; font-weight: 570; }
+.sidebar-item__row strong :deep(.app-icon) { color: var(--text-muted); }
+.sidebar-item__row small { flex: 0 0 auto; color: var(--text-muted); font-size: 8.5px; font-variant-numeric: tabular-nums; }
+.sidebar-item__row--secondary { color: var(--text-muted); font-size: 9.5px; line-height: 13px; }
+.sidebar-item__row b { display: grid; place-items: center; min-width: 16px; height: 16px; padding: 0 4px; border-radius: 999px; background: var(--accent); color: var(--text-on-solid); font-size: 8px; }
+.sidebar-item__more { display: none; position: absolute; right: 5px; top: 7px; place-items: center; width: 28px; height: 28px; padding: 0; border: 0; border-radius: 7px; background: var(--surface-raised); color: var(--text-secondary); cursor: pointer; box-shadow: 0 1px 5px rgba(0,0,0,.07); }
+.sidebar-item:hover .sidebar-item__more, .sidebar-item:focus-within .sidebar-item__more { display: grid; }
+.sidebar-loading { display: flex; flex-direction: column; gap: 4px; padding: 2px; }
+.sidebar-skeleton { height: 38px; border-radius: 8px; background: linear-gradient(90deg, var(--surface-soft), var(--surface-hover), var(--surface-soft)); background-size: 200% 100%; animation: shimmer 1.5s linear infinite; }
+@keyframes shimmer { to { background-position: -200% 0; } }
+.sidebar-empty { display: grid; place-items: center; padding: 45px 22px; color: var(--text-muted); text-align: center; }
+.sidebar-empty > span { display: grid; place-items: center; width: 38px; height: 38px; margin-bottom: 10px; border-radius: 12px; background: var(--surface-soft); }
+.sidebar-empty strong { color: var(--text-secondary); font-size: 12px; }.sidebar-empty p { margin: 5px 0 0; font-size: 10px; line-height: 1.5; }
+@media (prefers-reduced-motion: reduce) { .sidebar-item, .search-reveal-enter-active, .search-reveal-leave-active { transition: none; } }
+</style>

+ 583 - 0
src/client/components/app/WorkspaceShell.vue

@@ -0,0 +1,583 @@
+<script setup lang="ts">
+import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
+import { useRoute, useRouter } from 'vue-router'
+import AppIcon from '@/components/common/AppIcon.vue'
+import BrandMark from '@/components/common/BrandMark.vue'
+
+type NavItem = {
+  key: 'chat' | 'groups' | 'files'
+  label: string
+  path: string
+  icon: 'chat' | 'groups' | 'files'
+}
+
+const SIDEBAR_COLLAPSED_KEY = 'hermes-yaoyao:sidebar-collapsed'
+const SIDEBAR_SEARCH_EVENT = 'hermes-yaoyao:sidebar-search'
+const SIDEBAR_SEARCH_CLOSE_EVENT = 'hermes-yaoyao:sidebar-search-close'
+
+const props = withDefaults(defineProps<{
+  userName?: string
+  profileName?: string
+  profiles?: string[]
+  theme?: 'light' | 'dark'
+  insecureTransport?: boolean
+  sidebarTitle?: string
+  sidebarSubtitle?: string
+  inspectorOpen?: boolean
+}>(), {
+  userName: '',
+  profileName: '',
+  profiles: () => [],
+  theme: 'light',
+  insecureTransport: false,
+  sidebarTitle: '',
+  sidebarSubtitle: '',
+  inspectorOpen: false,
+})
+
+const emit = defineEmits<{
+  logout: []
+  toggleTheme: []
+  selectProfile: [profile: string]
+  closeInspector: []
+}>()
+
+const route = useRoute()
+const router = useRouter()
+const mobileDrawerOpen = ref(false)
+const profileMenuOpen = ref(false)
+const sidebarCollapsed = ref(false)
+const sidebarSearchOpen = ref(false)
+const desktopSidebarContext = ref<HTMLElement | null>(null)
+const mobileSidebarContext = ref<HTMLElement | null>(null)
+
+const navItems: NavItem[] = [
+  { key: 'chat', label: '对话', path: '/chat', icon: 'chat' },
+  { key: 'groups', label: '群聊', path: '/groups', icon: 'groups' },
+  { key: 'files', label: '文件库', path: '/files', icon: 'files' },
+]
+
+// The active workspace is already represented by its main canvas and sidebar
+// context. Keep this strip focused on destinations the user can switch to.
+const featureNavItems = computed(() => navItems.filter(item => item.key !== activeNav.value.key))
+
+const activeNav = computed(() => {
+  const item = navItems.find(entry => route.path.startsWith(entry.path))
+  return item ?? navItems[0]
+})
+
+const contextHeading = computed(() => ({
+  chat: '历史记录',
+  groups: '群聊记录',
+  files: '文件筛选',
+})[activeNav.value.key])
+
+const hasPrimaryAction = computed(() => activeNav.value.key === 'chat' || activeNav.value.key === 'groups')
+
+async function navigate(path: string) {
+  mobileDrawerOpen.value = false
+  await router.push(path)
+}
+
+function closeMenus(event: MouseEvent) {
+  const target = event.target as HTMLElement
+  if (!target.closest('.sidebar-account-switcher')) profileMenuOpen.value = false
+}
+
+function toggleSidebar() {
+  sidebarCollapsed.value = !sidebarCollapsed.value
+  try { localStorage.setItem(SIDEBAR_COLLAPSED_KEY, sidebarCollapsed.value ? '1' : '0') } catch { /* optional persistence */ }
+}
+
+async function openSidebarSearch(host: HTMLElement | null) {
+  if (sidebarCollapsed.value) {
+    sidebarCollapsed.value = false
+    try { localStorage.setItem(SIDEBAR_COLLAPSED_KEY, '0') } catch { /* optional persistence */ }
+    await nextTick()
+  }
+  sidebarSearchOpen.value = true
+  document.dispatchEvent(new CustomEvent(SIDEBAR_SEARCH_EVENT, { detail: { section: activeNav.value.key } }))
+  await nextTick()
+  host?.querySelector<HTMLInputElement>('input[type="search"]')?.focus()
+}
+
+async function closeSidebarSearch(host: HTMLElement | null, clear = false, restoreFocus = false) {
+  const input = host?.querySelector<HTMLInputElement>('input[type="search"]')
+  if (clear && input?.value) {
+    input.value = ''
+    input.dispatchEvent(new Event('input', { bubbles: true }))
+  }
+  sidebarSearchOpen.value = false
+  document.dispatchEvent(new CustomEvent(SIDEBAR_SEARCH_CLOSE_EVENT))
+  input?.blur()
+  if (restoreFocus) {
+    await nextTick()
+    host?.closest('aside')?.querySelector<HTMLButtonElement>('.sidebar-search-trigger')?.focus()
+  }
+}
+
+function handleSidebarInput(event: Event) {
+  const input = event.target as HTMLInputElement
+  if (input.matches('input[type="search"]') && !input.value) void closeSidebarSearch(input.closest('.sidebar-context'))
+}
+
+function handleSidebarFocusout(event: FocusEvent) {
+  const input = event.target as HTMLInputElement
+  if (input.matches('input[type="search"]') && !input.value) void closeSidebarSearch(input.closest('.sidebar-context'))
+}
+
+function handleSidebarSearchClosed() { sidebarSearchOpen.value = false }
+
+watch(() => route.fullPath, () => { mobileDrawerOpen.value = false })
+watch(() => activeNav.value.key, () => {
+  sidebarSearchOpen.value = false
+  profileMenuOpen.value = false
+})
+
+onMounted(() => {
+  try { sidebarCollapsed.value = localStorage.getItem(SIDEBAR_COLLAPSED_KEY) === '1' } catch { /* optional persistence */ }
+  document.addEventListener('mousedown', closeMenus)
+  document.addEventListener(SIDEBAR_SEARCH_CLOSE_EVENT, handleSidebarSearchClosed)
+})
+onBeforeUnmount(() => {
+  document.removeEventListener('mousedown', closeMenus)
+  document.removeEventListener(SIDEBAR_SEARCH_CLOSE_EVENT, handleSidebarSearchClosed)
+})
+</script>
+
+<template>
+  <div class="workspace-shell" :class="{ 'workspace-shell--collapsed': sidebarCollapsed }">
+    <header class="mobile-header">
+      <button class="icon-button" type="button" aria-label="打开导航" @click="mobileDrawerOpen = true">
+        <AppIcon name="menu" :size="20" />
+      </button>
+      <button class="mobile-brand" type="button" aria-label="返回对话" @click="navigate('/chat')">
+        <BrandMark :size="28" label compact />
+      </button>
+      <button class="icon-button" type="button" :aria-label="theme === 'dark' ? '切换浅色主题' : '切换深色主题'" @click="emit('toggleTheme')">
+        <AppIcon :name="theme === 'dark' ? 'sun' : 'moon'" />
+      </button>
+    </header>
+
+    <aside
+      class="desktop-sidebar rail"
+      :class="{ 'desktop-sidebar--collapsed': sidebarCollapsed }"
+      aria-label="主导航"
+      :inert="mobileDrawerOpen"
+    >
+      <div class="sidebar-brand-row">
+        <button class="rail__brand sidebar-brand" type="button" aria-label="返回对话" title="夭夭 Web" @click="navigate('/chat')">
+          <BrandMark :size="sidebarCollapsed ? 26 : 32" :label="false" compact />
+        </button>
+      </div>
+      <button
+        class="sidebar-collapse"
+        :class="{ 'sidebar-collapse--collapsed': sidebarCollapsed }"
+        type="button"
+        :aria-label="sidebarCollapsed ? '展开侧边栏' : '折叠侧边栏'"
+        :title="sidebarCollapsed ? '展开侧边栏' : '折叠侧边栏'"
+        @click="toggleSidebar"
+      >
+        <AppIcon name="chevron-left" :size="18" />
+      </button>
+
+      <button
+        class="sidebar-search-trigger"
+        :class="{ 'sidebar-search-trigger--searching': sidebarSearchOpen }"
+        type="button"
+        aria-label="搜索"
+        title="搜索"
+        :aria-expanded="sidebarSearchOpen"
+        :aria-hidden="sidebarSearchOpen ? 'true' : undefined"
+        :tabindex="sidebarSearchOpen ? -1 : 0"
+        @click="openSidebarSearch(desktopSidebarContext)"
+      >
+        <AppIcon name="search" :size="18" />
+        <span>搜索</span>
+      </button>
+
+      <div v-if="hasPrimaryAction" class="sidebar-primary-action">
+        <slot name="sidebar-action" />
+      </div>
+
+      <nav class="sidebar-feature-nav" aria-label="功能入口">
+        <button
+          v-for="item in featureNavItems"
+          :key="item.key"
+          type="button"
+          :class="{ active: activeNav.key === item.key }"
+          :aria-current="activeNav.key === item.key ? 'page' : undefined"
+          :title="item.label"
+          @click="navigate(item.path)"
+        >
+          <AppIcon :name="item.icon" :size="18" />
+          <span>{{ item.label }}</span>
+        </button>
+      </nav>
+
+      <section
+        ref="desktopSidebarContext"
+        class="sidebar-context"
+        :class="{ 'sidebar-context--searching': sidebarSearchOpen }"
+        @keydown.esc.capture="closeSidebarSearch(desktopSidebarContext, true, true)"
+        @input.capture="handleSidebarInput"
+        @focusout.capture="handleSidebarFocusout"
+      >
+        <header class="sidebar-context__heading">
+          <strong>{{ contextHeading }}</strong>
+          <span v-if="sidebarSubtitle">{{ sidebarSubtitle }}</span>
+        </header>
+        <div class="sidebar-context__body">
+          <slot name="sidebar" />
+        </div>
+      </section>
+
+      <div class="sidebar-footer">
+        <div class="sidebar-account-switcher">
+          <button class="sidebar-account-switcher__main" type="button" :title="`切换 Agent:${profileName || profiles[0] || '未选择'}`" @click="profileMenuOpen = !profileMenuOpen">
+            <span class="sidebar-account__avatar">{{ (userName || '夭').slice(0, 1).toUpperCase() }}</span>
+            <span class="account-copy">
+              <strong>{{ userName || 'Hermes 用户' }}</strong>
+              <span>{{ profileName || profiles[0] || '未选择 Agent' }}</span>
+            </span>
+            <AppIcon class="sidebar-account-switcher__chevron" name="chevron-down" :size="14" />
+          </button>
+          <button class="sidebar-account__utility" type="button" :title="theme === 'dark' ? '切换浅色主题' : '切换深色主题'" @click="emit('toggleTheme')">
+            <AppIcon :name="theme === 'dark' ? 'sun' : 'moon'" :size="17" />
+          </button>
+          <Transition name="menu-fade">
+            <div v-if="profileMenuOpen" class="profile-menu">
+              <button
+                v-for="profile in profiles"
+                :key="profile"
+                type="button"
+                :class="{ active: profile === profileName }"
+                @click="emit('selectProfile', profile); profileMenuOpen = false"
+              >
+                <span>{{ profile.slice(0, 1).toUpperCase() }}</span>
+                <strong>{{ profile }}</strong>
+                <AppIcon v-if="profile === profileName" name="check" :size="15" />
+              </button>
+              <button class="profile-menu__logout" type="button" @click="profileMenuOpen = false; emit('logout')">
+                <AppIcon name="logout" :size="15" /><strong>退出登录</strong>
+              </button>
+            </div>
+          </Transition>
+        </div>
+      </div>
+    </aside>
+
+    <Transition name="drawer-fade">
+      <button v-if="mobileDrawerOpen" class="drawer-scrim" type="button" aria-label="关闭导航" @click="mobileDrawerOpen = false" />
+    </Transition>
+    <aside
+      class="mobile-drawer"
+      :class="{ 'mobile-drawer--open': mobileDrawerOpen }"
+      aria-label="移动导航"
+      :aria-hidden="!mobileDrawerOpen"
+      :inert="!mobileDrawerOpen"
+    >
+      <div class="mobile-drawer__header">
+        <button class="sidebar-brand" type="button" aria-label="返回对话" @click="navigate('/chat')">
+          <BrandMark :size="32" label compact />
+        </button>
+        <button class="icon-button" type="button" aria-label="关闭导航" @click="mobileDrawerOpen = false">
+          <AppIcon name="close" />
+        </button>
+      </div>
+
+      <button
+        class="sidebar-search-trigger"
+        :class="{ 'sidebar-search-trigger--searching': sidebarSearchOpen }"
+        type="button"
+        aria-label="搜索"
+        title="搜索"
+        :aria-expanded="sidebarSearchOpen"
+        :aria-hidden="sidebarSearchOpen ? 'true' : undefined"
+        :tabindex="sidebarSearchOpen ? -1 : 0"
+        @click="openSidebarSearch(mobileSidebarContext)"
+      >
+        <AppIcon name="search" :size="18" />
+        <span>搜索</span>
+      </button>
+
+      <div v-if="hasPrimaryAction" class="sidebar-primary-action">
+        <slot name="sidebar-action" />
+      </div>
+
+      <nav class="sidebar-feature-nav" aria-label="功能入口">
+        <button
+          v-for="item in featureNavItems"
+          :key="item.key"
+          type="button"
+          :class="{ active: activeNav.key === item.key }"
+          :aria-current="activeNav.key === item.key ? 'page' : undefined"
+          @click="navigate(item.path)"
+        >
+          <AppIcon :name="item.icon" :size="18" />
+          <span>{{ item.label }}</span>
+        </button>
+      </nav>
+
+      <section
+        ref="mobileSidebarContext"
+        class="sidebar-context mobile-drawer__context"
+        :class="{ 'sidebar-context--searching': sidebarSearchOpen }"
+        @keydown.esc.capture="closeSidebarSearch(mobileSidebarContext, true, true)"
+        @input.capture="handleSidebarInput"
+        @focusout.capture="handleSidebarFocusout"
+      >
+        <header class="sidebar-context__heading">
+          <strong>{{ contextHeading }}</strong>
+          <span v-if="sidebarSubtitle">{{ sidebarSubtitle }}</span>
+        </header>
+        <div class="sidebar-context__body">
+          <slot name="mobile-sidebar" />
+        </div>
+      </section>
+
+      <div class="sidebar-footer mobile-drawer__footer">
+        <div class="sidebar-account-switcher">
+          <button class="sidebar-account-switcher__main" type="button" :title="`切换 Agent:${profileName || profiles[0] || '未选择'}`" @click="profileMenuOpen = !profileMenuOpen">
+            <span class="sidebar-account__avatar">{{ (userName || '夭').slice(0, 1).toUpperCase() }}</span>
+            <span class="account-copy">
+              <strong>{{ userName || 'Hermes 用户' }}</strong>
+              <span>{{ profileName || profiles[0] || '未选择 Agent' }}</span>
+            </span>
+            <AppIcon class="sidebar-account-switcher__chevron" name="chevron-down" :size="14" />
+          </button>
+          <button class="sidebar-account__utility" type="button" :title="theme === 'dark' ? '切换浅色主题' : '切换深色主题'" @click="emit('toggleTheme')">
+            <AppIcon :name="theme === 'dark' ? 'sun' : 'moon'" :size="17" />
+          </button>
+          <Transition name="menu-fade">
+            <div v-if="profileMenuOpen" class="profile-menu">
+              <button
+                v-for="profile in profiles"
+                :key="profile"
+                type="button"
+                :class="{ active: profile === profileName }"
+                @click="emit('selectProfile', profile); profileMenuOpen = false"
+              >
+                <span>{{ profile.slice(0, 1).toUpperCase() }}</span>
+                <strong>{{ profile }}</strong>
+                <AppIcon v-if="profile === profileName" name="check" :size="15" />
+              </button>
+              <button class="profile-menu__logout" type="button" @click="profileMenuOpen = false; emit('logout')">
+                <AppIcon name="logout" :size="15" /><strong>退出登录</strong>
+              </button>
+            </div>
+          </Transition>
+        </div>
+      </div>
+    </aside>
+
+    <main class="workspace-main" :inert="mobileDrawerOpen">
+      <slot />
+    </main>
+
+    <Transition name="inspector-slide">
+      <aside v-if="inspectorOpen" class="workspace-inspector">
+        <div class="workspace-inspector__close">
+          <button class="icon-button" type="button" aria-label="关闭预览" @click="emit('closeInspector')">
+            <AppIcon name="close" />
+          </button>
+        </div>
+        <slot name="inspector" />
+      </aside>
+    </Transition>
+  </div>
+</template>
+
+<style scoped>
+.workspace-shell {
+  display: grid;
+  grid-template-columns: 264px minmax(0, 1fr) auto;
+  width: 100%;
+  height: 100%;
+  min-width: 0;
+  overflow: hidden;
+  background: var(--canvas);
+  transition: grid-template-columns 180ms var(--ease-out);
+}
+
+.workspace-shell--collapsed { grid-template-columns: 68px minmax(0, 1fr) auto; }
+.mobile-header, .mobile-drawer, .drawer-scrim { display: none; }
+
+.desktop-sidebar {
+  --sidebar-search-top: 58px;
+  position: relative;
+  z-index: 20;
+  display: flex;
+  min-width: 0;
+  min-height: 0;
+  flex-direction: column;
+  overflow: hidden;
+  border-right: 1px solid var(--line);
+  background: var(--surface);
+  color: var(--text-primary);
+}
+
+.sidebar-brand-row { display: flex; min-height: 58px; align-items: center; padding: 9px 11px 7px; }
+.sidebar-brand { display: flex; min-width: 0; align-items: center; padding: 0; border: 0; border-radius: 10px; background: transparent; cursor: pointer; }
+.sidebar-brand:hover { background: var(--surface-hover); }
+.sidebar-collapse, .sidebar-account__utility {
+  display: grid;
+  width: 34px;
+  height: 34px;
+  flex: 0 0 34px;
+  place-items: center;
+  padding: 0;
+  border: 0;
+  border-radius: 9px;
+  background: transparent;
+  color: var(--text-muted);
+  cursor: pointer;
+}
+.sidebar-collapse:hover, .sidebar-account__utility:hover { background: var(--surface-hover); color: var(--text-primary); }
+.sidebar-collapse { position: absolute; top: 12px; right: 11px; transition: transform 180ms var(--ease-out), color 140ms ease, background-color 140ms ease; }
+.sidebar-collapse--collapsed { transform: rotate(180deg); }
+
+.sidebar-search-trigger,
+.sidebar-feature-nav button {
+  display: flex;
+  width: calc(100% - 20px);
+  min-height: 40px;
+  align-items: center;
+  gap: 11px;
+  margin-inline: 10px;
+  padding: 0 11px;
+  border: 0;
+  border-radius: 10px;
+  background: transparent;
+  color: var(--text-primary);
+  cursor: pointer;
+  text-align: left;
+  font-size: 13px;
+  font-weight: 580;
+}
+.sidebar-search-trigger:hover,
+.sidebar-search-trigger[aria-expanded="true"],
+.sidebar-feature-nav button:hover,
+.sidebar-feature-nav button.active { background: var(--surface-soft); }
+.sidebar-search-trigger[aria-expanded="true"] { box-shadow: 0 0 0 3px var(--focus-ring); }
+.sidebar-search-trigger--searching { visibility: hidden; pointer-events: none; }
+
+.desktop-sidebar .sidebar-context--searching :deep(.sidebar-search),
+.desktop-sidebar .sidebar-context--searching :deep(.library-search),
+.mobile-drawer .sidebar-context--searching :deep(.sidebar-search),
+.mobile-drawer .sidebar-context--searching :deep(.library-search) {
+  position: absolute;
+  z-index: 40;
+  top: var(--sidebar-search-top);
+  left: 10px;
+  width: calc(100% - 20px);
+  min-height: 40px;
+  margin: 0;
+  background: var(--surface);
+}
+
+.sidebar-primary-action { margin: 5px 10px; }
+.sidebar-primary-action :deep(button) {
+  display: flex;
+  width: 100%;
+  min-height: 40px;
+  align-items: center;
+  justify-content: flex-start;
+  gap: 11px;
+  padding: 0 11px;
+  border: 0;
+  border-radius: 10px;
+  background: var(--surface-soft);
+  color: var(--text-primary);
+  cursor: pointer;
+  font-size: 13px;
+  font-weight: 650;
+}
+.sidebar-primary-action :deep(button:hover) { background: var(--surface-hover); }
+.sidebar-primary-action :deep(button > span) { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+
+.sidebar-feature-nav { display: flex; flex-direction: column; gap: 1px; padding: 0 0 9px; }
+.sidebar-feature-nav button { margin-block: 0; }
+.sidebar-feature-nav button.active { font-weight: 680; }
+
+.sidebar-context { display: flex; min-height: 0; flex: 1; flex-direction: column; overflow: hidden; }
+.sidebar-context__heading { display: flex; min-height: 35px; align-items: center; justify-content: space-between; gap: 8px; padding: 6px 20px 5px 21px; }
+.sidebar-context__heading strong { font-size: 12px; font-weight: 680; }
+.sidebar-context__heading span { overflow: hidden; color: var(--text-muted); font-size: 9px; text-overflow: ellipsis; white-space: nowrap; }
+.sidebar-context__body { min-height: 0; flex: 1; overflow: hidden; }
+.sidebar-context:not(.sidebar-context--searching) :deep(.library-search) { display: none; }
+.sidebar-context__body :deep(.library-search) { margin-bottom: 10px; }
+.sidebar-context__body :deep(.sidebar-list) { padding-inline: 10px; }
+.sidebar-context__body :deep(.sidebar-item) { min-height: 42px; padding: 5px 9px; }
+.sidebar-context__body :deep(.sidebar-item__icon) { width: 25px; height: 25px; flex-basis: 25px; border: 0; border-radius: 7px; background: transparent; }
+.sidebar-context__body :deep(.sidebar-item__icon--avatar) { background: var(--surface-soft); color: var(--text-secondary); }
+.sidebar-context__body :deep(.sidebar-item__row strong) { font-size: 12px; }
+.sidebar-context__body :deep(.library-sidebar) { padding-top: 1px; }
+.sidebar-context__body :deep(.library-sidebar section) { margin-top: 2px; }
+
+.sidebar-footer { flex: 0 0 auto; padding: 7px 10px 10px; background: var(--surface); }
+.sidebar-account-switcher { position: relative; z-index: 30; display: flex; min-height: 46px; align-items: center; gap: 6px; padding: 4px 7px; border-top: 1px solid var(--line); }
+.sidebar-account-switcher__main { display: flex; min-width: 0; min-height: 38px; flex: 1; align-items: center; gap: 9px; padding: 4px 1px; border: 0; border-radius: 8px; background: transparent; color: var(--text-primary); cursor: pointer; text-align: left; }
+.sidebar-account-switcher__main:hover { background: var(--surface-soft); }
+.sidebar-account__avatar { display: grid; width: 30px; height: 30px; flex: 0 0 30px; place-items: center; border-radius: 50%; background: #c91e55; color: #fff; font-size: 12px; font-weight: 700; }
+.sidebar-account-switcher__chevron { flex: 0 0 auto; color: var(--text-muted); }
+.profile-menu { position: absolute; right: 0; bottom: calc(100% + 6px); left: 0; padding: 5px; border: 1px solid var(--line); border-radius: 12px; background: var(--surface-raised); box-shadow: var(--shadow-float); }
+.profile-menu button { display: flex; width: 100%; min-height: 38px; align-items: center; gap: 9px; padding: 5px 8px; border: 0; border-radius: 8px; background: transparent; cursor: pointer; text-align: left; }
+.profile-menu button:hover, .profile-menu button.active { background: var(--surface-hover); }
+.profile-menu button > span { display: grid; width: 25px; height: 25px; place-items: center; border-radius: 8px; background: var(--surface-soft); color: var(--text-secondary); font-size: 10px; }
+.profile-menu button strong { flex: 1; overflow: hidden; font-size: 12px; text-overflow: ellipsis; }
+.profile-menu__logout { margin-top: 4px; border-top: 1px solid var(--line) !important; color: var(--danger); }
+.account-copy { display: flex; min-width: 0; flex: 1; flex-direction: column; }
+.account-copy strong, .account-copy span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.account-copy strong { font-size: 12px; font-weight: 650; }
+.account-copy span { color: var(--text-muted); font-size: 10px; }
+
+.desktop-sidebar--collapsed .sidebar-brand-row { padding-inline: 21px; }
+.desktop-sidebar--collapsed .sidebar-brand { width: 26px; flex: 0 0 26px; overflow: hidden; }
+.desktop-sidebar--collapsed .sidebar-collapse { position: static; width: 28px; height: 28px; flex: 0 0 28px; order: 1; align-self: center; margin-top: auto; margin-bottom: 5px; }
+.desktop-sidebar--collapsed .sidebar-context { display: none; }
+.desktop-sidebar--collapsed .sidebar-search-trigger { width: 42px; justify-content: center; margin-inline: auto; padding: 0; }
+.desktop-sidebar--collapsed .sidebar-search-trigger span { display: none; }
+.desktop-sidebar--collapsed .sidebar-primary-action { width: 42px; margin-inline: auto; }
+.desktop-sidebar--collapsed .sidebar-primary-action :deep(button) { width: 42px; justify-content: center; padding: 0; }
+.desktop-sidebar--collapsed .sidebar-primary-action :deep(button > span) { display: none; }
+.desktop-sidebar--collapsed .sidebar-feature-nav { align-items: center; padding: 8px 0; }
+.desktop-sidebar--collapsed .sidebar-feature-nav button { width: 42px; justify-content: center; margin-inline: 0; padding: 0; }
+.desktop-sidebar--collapsed .sidebar-feature-nav button span,
+.desktop-sidebar--collapsed .sidebar-account-switcher__chevron,
+.desktop-sidebar--collapsed .account-copy { display: none; }
+.desktop-sidebar--collapsed .sidebar-footer { display: flex; order: 2; margin-top: 0; flex-direction: column; align-items: center; padding: 0 8px 8px; }
+.desktop-sidebar--collapsed .sidebar-account-switcher { width: 42px; flex-direction: column-reverse; gap: 3px; padding: 0; border-top: 0; }
+.desktop-sidebar--collapsed .sidebar-account-switcher__main { width: 42px; min-height: 42px; flex: 0 0 42px; justify-content: center; padding: 0; }
+.desktop-sidebar--collapsed .sidebar-account__utility { display: none; }
+.desktop-sidebar--collapsed .profile-menu { right: auto; bottom: 0; left: calc(100% + 8px); width: 220px; }
+
+.workspace-main { position: relative; display: flex; min-width: 0; min-height: 0; flex-direction: column; overflow: hidden; background: var(--canvas); }
+.workspace-inspector { position: relative; width: min(390px, 34vw); min-width: 310px; min-height: 0; overflow: hidden; border-left: 1px solid var(--line); background: var(--surface); }
+.workspace-inspector__close { position: absolute; z-index: 5; top: 10px; right: 10px; }
+
+.menu-fade-enter-active, .menu-fade-leave-active { transition: opacity 120ms ease, transform 120ms var(--ease-out); }
+.menu-fade-enter-from, .menu-fade-leave-to { opacity: 0; transform: translateY(4px); }
+.inspector-slide-enter-active, .inspector-slide-leave-active { overflow: hidden; transition: width 180ms var(--ease-out), opacity 150ms ease; }
+.inspector-slide-enter-from, .inspector-slide-leave-to { width: 0; min-width: 0; opacity: 0; }
+
+@media (max-width: 900px) {
+  .workspace-shell, .workspace-shell--collapsed { grid-template-columns: minmax(0, 1fr); grid-template-rows: 52px minmax(0, 1fr); }
+  .mobile-header { display: flex; z-index: 24; grid-row: 1; align-items: center; justify-content: space-between; padding: max(5px, env(safe-area-inset-top)) 10px 5px; border-bottom: 1px solid var(--line); background: color-mix(in srgb, var(--surface) 94%, transparent); backdrop-filter: blur(18px); }
+  .mobile-brand { padding: 0; border: 0; background: transparent; cursor: pointer; }
+  .desktop-sidebar { display: none; }
+  .workspace-main { grid-row: 2; }
+  .drawer-scrim { display: block; position: fixed; z-index: 50; inset: 0; padding: 0; border: 0; background: var(--scrim); backdrop-filter: blur(2px); }
+  .mobile-drawer { --sidebar-search-top: calc(max(9px, env(safe-area-inset-top)) + 49px); display: flex; position: fixed; z-index: 51; inset: 0 auto 0 0; width: min(304px, 88vw); min-height: 0; flex-direction: column; overflow: hidden; padding: max(9px, env(safe-area-inset-top)) 0 max(9px, env(safe-area-inset-bottom)); background: var(--surface); box-shadow: var(--shadow-float); transform: translateX(-102%); transition: transform 190ms var(--ease-out); }
+  .mobile-drawer--open { transform: translateX(0); }
+  .mobile-drawer__header { display: flex; min-height: 49px; align-items: center; justify-content: space-between; padding: 0 14px 5px 12px; }
+  .mobile-drawer__context { margin-top: 2px; }
+  .mobile-drawer__context .sidebar-context__body :deep(.resource-sidebar) { min-height: 0; flex: 1; }
+  .mobile-drawer__footer { padding-inline: 10px; }
+  .workspace-inspector { position: fixed; z-index: 45; inset: 52px 0 0 auto; width: min(440px, 100vw); min-width: 0; box-shadow: var(--shadow-float); }
+  .inspector-slide-enter-from, .inspector-slide-leave-to { width: min(440px, 100vw); transform: translateX(100%); }
+}
+
+.drawer-fade-enter-active, .drawer-fade-leave-active { transition: opacity 160ms ease; }
+.drawer-fade-enter-from, .drawer-fade-leave-to { opacity: 0; }
+</style>

+ 12 - 0
src/client/components/app/types.ts

@@ -0,0 +1,12 @@
+export type SidebarItem = {
+  id: string
+  title: string
+  subtitle?: string
+  meta?: string
+  section?: string
+  active?: boolean
+  unread?: number
+  pinned?: boolean
+  status?: 'online' | 'working' | 'offline'
+  icon?: 'chat' | 'groups' | 'file' | 'image' | 'video' | 'audio' | 'link' | 'artifacts'
+}

+ 116 - 0
src/client/components/common/AppIcon.vue

@@ -0,0 +1,116 @@
+<script setup lang="ts">
+type IconName =
+  | 'chat'
+  | 'groups'
+  | 'files'
+  | 'artifacts'
+  | 'plus'
+  | 'search'
+  | 'dots'
+  | 'sun'
+  | 'moon'
+  | 'logout'
+  | 'menu'
+  | 'close'
+  | 'paperclip'
+  | 'brain'
+  | 'settings'
+  | 'model'
+  | 'arrow-up'
+  | 'stop'
+  | 'tools'
+  | 'users'
+  | 'chevron-down'
+  | 'chevron-left'
+  | 'download'
+  | 'external'
+  | 'image'
+  | 'video'
+  | 'audio'
+  | 'file'
+  | 'link'
+  | 'refresh'
+  | 'quote'
+  | 'check'
+  | 'alert'
+  | 'copy'
+  | 'branch'
+  | 'archive'
+  | 'trash'
+  | 'edit'
+  | 'panel'
+  | 'pin'
+
+withDefaults(defineProps<{ name: IconName; size?: number }>(), { size: 18 })
+</script>
+
+<template>
+  <svg
+    class="app-icon"
+    :width="size"
+    :height="size"
+    viewBox="0 0 24 24"
+    fill="none"
+    stroke="currentColor"
+    stroke-width="1.75"
+    stroke-linecap="round"
+    stroke-linejoin="round"
+    aria-hidden="true"
+  >
+    <template v-if="name === 'chat'">
+      <path d="M8 18h-3a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v11a2 2 0 0 1-2 2h-5l-6 4z" />
+      <path d="M8 9h8M8 13h5" />
+    </template>
+    <template v-else-if="name === 'groups'">
+      <path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
+      <circle cx="9" cy="7" r="4" />
+      <path d="M22 21v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75" />
+    </template>
+    <template v-else-if="name === 'files'">
+      <path d="M4 4a2 2 0 0 1 2-2h5l2 3h5a2 2 0 0 1 2 2v11a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2z" />
+    </template>
+    <template v-else-if="name === 'artifacts'">
+      <path d="M12 3 3 8l9 5 9-5zM3 12l9 5 9-5M3 16l9 5 9-5" />
+    </template>
+    <template v-else-if="name === 'plus'"><path d="M12 5v14M5 12h14" /></template>
+    <template v-else-if="name === 'search'"><circle cx="11" cy="11" r="7" /><path d="m20 20-4-4" /></template>
+    <template v-else-if="name === 'dots'"><circle cx="5" cy="12" r="1" fill="currentColor" stroke="none" /><circle cx="12" cy="12" r="1" fill="currentColor" stroke="none" /><circle cx="19" cy="12" r="1" fill="currentColor" stroke="none" /></template>
+    <template v-else-if="name === 'sun'"><circle cx="12" cy="12" r="4" /><path d="M12 2v2M12 20v2M4.93 4.93l1.42 1.42M17.65 17.65l1.42 1.42M2 12h2M20 12h2M4.93 19.07l1.42-1.42M17.65 6.35l1.42-1.42" /></template>
+    <template v-else-if="name === 'moon'"><path d="M20.5 14.2A8.5 8.5 0 0 1 9.8 3.5 8.5 8.5 0 1 0 20.5 14.2z" /></template>
+    <template v-else-if="name === 'logout'"><path d="M10 17l5-5-5-5M15 12H3M21 19V5a2 2 0 0 0-2-2h-6" /></template>
+    <template v-else-if="name === 'menu'"><path d="M4 7h16M4 12h16M4 17h16" /></template>
+    <template v-else-if="name === 'close'"><path d="m6 6 12 12M18 6 6 18" /></template>
+    <template v-else-if="name === 'paperclip'"><path d="m21.4 11.6-8.9 8.9a6 6 0 0 1-8.5-8.5l9.5-9.5a4 4 0 1 1 5.7 5.7l-9.6 9.5a2 2 0 0 1-2.8-2.8l8.9-8.9" /></template>
+    <template v-else-if="name === 'brain'"><path d="M9.5 2A2.5 2.5 0 0 1 12 4.5v15a2.5 2.5 0 0 1-5-.1 2.5 2.5 0 0 1-3-3 3 3 0 0 1 0-5.8A2.5 2.5 0 0 1 7 6a2.5 2.5 0 0 1 2.5-4Z" /><path d="M14.5 2A2.5 2.5 0 0 0 12 4.5v15a2.5 2.5 0 0 0 5-.1 2.5 2.5 0 0 0 3-3 3 3 0 0 0 0-5.8A2.5 2.5 0 0 0 17 6a2.5 2.5 0 0 0-2.5-4Z" /></template>
+    <template v-else-if="name === 'settings'"><circle cx="12" cy="12" r="3" /><path d="M19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1-2.8 2.8-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5v.2h-4v-.2a1.7 1.7 0 0 0-1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1-2.8-2.8.1-.1A1.7 1.7 0 0 0 4.6 15a1.7 1.7 0 0 0-1.5-1H3v-4h.1A1.7 1.7 0 0 0 4.6 9a1.7 1.7 0 0 0-.3-1.8l-.1-.1 2.8-2.8.1.1a1.7 1.7 0 0 0 1.8.3 1.7 1.7 0 0 0 1-1.5V3h4v.2a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1 2.8 2.8-.1.1A1.7 1.7 0 0 0 19.4 9a1.7 1.7 0 0 0 1.5 1h.1v4h-.1a1.7 1.7 0 0 0-1.5 1Z" /></template>
+    <template v-else-if="name === 'model'"><circle cx="12" cy="12" r="3" /><path d="M12 2v4M12 18v4M2 12h4M18 12h4M5 5l3 3M16 16l3 3M5 19l3-3M16 8l3-3" /></template>
+    <template v-else-if="name === 'arrow-up'"><path d="M12 19V5m-7 7 7-7 7 7" stroke-width="2.2" /></template>
+    <template v-else-if="name === 'stop'"><rect x="6" y="6" width="12" height="12" rx="2.5" fill="currentColor" stroke="none" /></template>
+    <template v-else-if="name === 'tools'"><path d="M14.7 6.3a4 4 0 0 0-5-5L12 3.6 8.4 7.2 6.1 4.9a4 4 0 0 0 5 5l-7.4 7.4a2.1 2.1 0 0 0 3 3l7.4-7.4a4 4 0 0 0 5-5l-2.3 2.3-3.6-3.6z" /></template>
+    <template v-else-if="name === 'users'"><circle cx="9" cy="8" r="3" /><path d="M3 21v-2a6 6 0 0 1 12 0v2M16 3a3 3 0 0 1 0 6M18 14a5 5 0 0 1 3 5v2" /></template>
+    <template v-else-if="name === 'chevron-down'"><path d="m6 9 6 6 6-6" /></template>
+    <template v-else-if="name === 'chevron-left'"><path d="m15 18-6-6 6-6" /></template>
+    <template v-else-if="name === 'download'"><path d="M12 3v12m-5-5 5 5 5-5M5 21h14" /></template>
+    <template v-else-if="name === 'external'"><path d="M15 3h6v6M10 14 21 3M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" /></template>
+    <template v-else-if="name === 'image'"><rect x="3" y="3" width="18" height="18" rx="2" /><circle cx="8.5" cy="8.5" r="1.5" /><path d="m21 15-5-5L5 21" /></template>
+    <template v-else-if="name === 'video'"><rect x="2" y="5" width="15" height="14" rx="2" /><path d="m17 10 5-3v10l-5-3z" /></template>
+    <template v-else-if="name === 'audio'"><path d="M9 18V5l11-2v13M9 9l11-2" /><circle cx="6" cy="18" r="3" /><circle cx="17" cy="16" r="3" /></template>
+    <template v-else-if="name === 'file'"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" /><path d="M14 2v6h6" /></template>
+    <template v-else-if="name === 'link'"><path d="M10 13a5 5 0 0 0 7.1.1l2-2a5 5 0 0 0-7.1-7.1l-1.1 1.1M14 11a5 5 0 0 0-7.1-.1l-2 2A5 5 0 0 0 12 20l1.1-1.1" /></template>
+    <template v-else-if="name === 'refresh'"><path d="M20 7v5h-5M4 17v-5h5" /><path d="M5.1 9a8 8 0 0 1 13-3l2 2M18.9 15a8 8 0 0 1-13 3l-2-2" /></template>
+    <template v-else-if="name === 'quote'"><path d="M3 21c3 0 7-1 7-8V5H4v8h3c0 3-1 4-4 5zM14 21c3 0 7-1 7-8V5h-6v8h3c0 3-1 4-4 5z" /></template>
+    <template v-else-if="name === 'check'"><path d="m5 12 4 4L19 6" /></template>
+    <template v-else-if="name === 'alert'"><path d="M12 3 2 21h20zM12 9v4M12 17h.01" /></template>
+    <template v-else-if="name === 'copy'"><rect x="9" y="9" width="11" height="11" rx="2" /><path d="M15 9V6a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v7a2 2 0 0 0 2 2h3" /></template>
+    <template v-else-if="name === 'branch'"><circle cx="6" cy="5" r="2" /><circle cx="18" cy="6" r="2" /><circle cx="6" cy="19" r="2" /><path d="M6 7v10M8 7c6 0 4 6 8 6h2" /></template>
+    <template v-else-if="name === 'archive'"><path d="M3 6h18M5 6v14h14V6M3 3h18v3H3zM9 10h6" /></template>
+    <template v-else-if="name === 'trash'"><path d="M3 6h18M8 6V4h8v2M19 6l-1 15H6L5 6M10 11v5M14 11v5" /></template>
+    <template v-else-if="name === 'edit'"><path d="M12 20h9M16.5 3.5a2.1 2.1 0 0 1 3 3L8 18l-4 1 1-4z" /></template>
+    <template v-else-if="name === 'panel'"><rect x="3" y="3" width="18" height="18" rx="2" /><path d="M15 3v18" /></template>
+    <template v-else-if="name === 'pin'"><path d="m14 4 6 6-3 1-4 4-1 5-2-2-2-2 5-1 4-4zM4 20l5-5" /></template>
+  </svg>
+</template>
+
+<style scoped>
+.app-icon { display: block; flex: 0 0 auto; }
+</style>

+ 54 - 0
src/client/components/common/BrandMark.vue

@@ -0,0 +1,54 @@
+<script setup lang="ts">
+withDefaults(defineProps<{
+  size?: number
+  label?: boolean
+  compact?: boolean
+}>(), {
+  size: 32,
+  label: false,
+  compact: false,
+})
+</script>
+
+<template>
+  <span class="brand-mark" :class="{ 'brand-mark--compact': compact }">
+    <span class="brand-mark__plate" :style="{ width: `${size}px`, height: `${size}px` }">
+      <img src="/brand/AppIcon-1024.png" alt="" aria-hidden="true" />
+    </span>
+    <span v-if="label" class="brand-mark__name">夭夭 Web</span>
+  </span>
+</template>
+
+<style scoped>
+.brand-mark {
+  display: inline-flex;
+  align-items: center;
+  gap: 12px;
+  color: var(--text-primary);
+}
+
+.brand-mark__plate {
+  display: inline-grid;
+  place-items: center;
+  flex: 0 0 auto;
+  overflow: hidden;
+  border-radius: 24%;
+  background: #f1f1ef;
+}
+
+.brand-mark__plate img {
+  display: block;
+  width: 100%;
+  height: 100%;
+  object-fit: contain;
+}
+
+.brand-mark__name {
+  font-size: 20px;
+  font-weight: 650;
+  letter-spacing: -.035em;
+}
+
+.brand-mark--compact { gap: 8px; }
+.brand-mark--compact .brand-mark__name { font-size: 16px; }
+</style>

+ 33 - 0
src/client/components/common/ChoiceDialog.vue

@@ -0,0 +1,33 @@
+<script setup lang="ts">
+import AppIcon from '@/components/common/AppIcon.vue'
+import type { ChoiceOption } from './types'
+
+defineProps<{ open: boolean; title: string; options: ChoiceOption[]; selectedId?: string }>()
+const emit = defineEmits<{ close: []; select: [id: string] }>()
+</script>
+
+<template>
+  <Teleport to="body">
+    <Transition name="choice-fade">
+      <div v-if="open" class="choice-layer" @mousedown.self="emit('close')">
+        <section class="choice-dialog" role="dialog" aria-modal="true" :aria-label="title">
+          <header><h2>{{ title }}</h2><button class="icon-button" type="button" aria-label="关闭" @click="emit('close')"><AppIcon name="close" /></button></header>
+          <div class="choice-list">
+            <button v-for="option in options" :key="option.id" type="button" :class="{ selected: option.id === selectedId }" :disabled="option.disabled" @click="emit('select', option.id)">
+              <span><strong>{{ option.label }}</strong><small v-if="option.description">{{ option.description }}</small></span><AppIcon v-if="option.id === selectedId" name="check" :size="16" />
+            </button>
+          </div>
+        </section>
+      </div>
+    </Transition>
+  </Teleport>
+</template>
+
+<style scoped>
+.choice-layer { position: fixed; z-index: 210; inset: 0; display: grid; place-items: center; padding: 18px; background: var(--scrim); backdrop-filter: blur(4px); }
+.choice-dialog { width: min(420px, 100%); max-height: min(620px, calc(100vh - 36px)); padding: 13px; overflow: auto; border: 1px solid var(--line); border-radius: 16px; background: var(--surface-raised); box-shadow: var(--shadow-float); }
+header { display: flex; min-height: 43px; align-items: center; justify-content: space-between; padding: 0 3px 7px 8px; } h2 { margin: 0; font-size: 14px; letter-spacing: -.02em; }
+.choice-list { display: flex; flex-direction: column; gap: 3px; }.choice-list button { display: flex; width: 100%; min-height: 48px; align-items: center; gap: 10px; padding: 7px 9px; border: 0; border-radius: 10px; background: transparent; color: var(--text-primary); cursor: pointer; text-align: left; }.choice-list button:hover, .choice-list button.selected { background: var(--surface-soft); }.choice-list button:disabled { cursor: not-allowed; opacity: .35; }.choice-list button > span { display: flex; min-width: 0; flex: 1; flex-direction: column; }.choice-list strong { font-size: 11px; }.choice-list small { margin-top: 3px; color: var(--text-muted); font-size: 9px; }
+.choice-fade-enter-active, .choice-fade-leave-active { transition: opacity 130ms ease; }.choice-fade-enter-active .choice-dialog, .choice-fade-leave-active .choice-dialog { transition: transform 160ms var(--ease-out); }.choice-fade-enter-from, .choice-fade-leave-to { opacity: 0; }.choice-fade-enter-from .choice-dialog, .choice-fade-leave-to .choice-dialog { transform: translateY(7px) scale(.99); }
+@media (max-width: 600px) { .choice-layer { place-items: end center; padding: 0; }.choice-dialog { width: 100%; max-height: 72vh; border-radius: 17px 17px 0 0; padding-bottom: max(13px, env(safe-area-inset-bottom)); } }
+</style>

+ 29 - 0
src/client/components/common/EmptyState.vue

@@ -0,0 +1,29 @@
+<script setup lang="ts">
+import AppIcon from '@/components/common/AppIcon.vue'
+
+type EmptyIcon = 'chat' | 'groups' | 'files' | 'artifacts' | 'search' | 'alert'
+
+withDefaults(defineProps<{
+  icon?: EmptyIcon
+  title: string
+  description?: string
+  actionLabel?: string
+}>(), { icon: 'chat', description: '', actionLabel: '' })
+
+const emit = defineEmits<{ action: [] }>()
+</script>
+
+<template>
+  <div class="empty-state">
+    <div class="empty-state__mark"><AppIcon :name="icon" :size="23" /></div>
+    <h2>{{ title }}</h2>
+    <p v-if="description">{{ description }}</p>
+    <button v-if="actionLabel" class="solid-button" type="button" @click="emit('action')">{{ actionLabel }}</button>
+  </div>
+</template>
+
+<style scoped>
+.empty-state { display: flex; width: min(380px, calc(100% - 32px)); margin: auto; flex-direction: column; align-items: center; padding: 40px 20px; text-align: center; }
+.empty-state__mark { display: grid; place-items: center; width: 48px; height: 48px; margin-bottom: 17px; border: 1px solid var(--line); border-radius: 15px; background: var(--surface-raised); color: var(--text-secondary); box-shadow: 0 12px 28px rgba(0,0,0,.05); }
+h2 { margin: 0; font-size: 18px; font-weight: 630; letter-spacing: -.025em; } p { max-width: 310px; margin: 8px 0 20px; color: var(--text-muted); font-size: 12px; line-height: 1.65; }
+</style>

+ 6 - 0
src/client/components/common/types.ts

@@ -0,0 +1,6 @@
+export type ChoiceOption = {
+  id: string
+  label: string
+  description?: string
+  disabled?: boolean
+}

+ 624 - 0
src/client/components/composer/ComposerShell.vue

@@ -0,0 +1,624 @@
+<script setup lang="ts">
+import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
+import AppIcon from '@/components/common/AppIcon.vue'
+import type { ComposerAttachment, ComposerOption, ComposerReference, ComposerSubmit } from './types'
+
+const props = withDefaults(defineProps<{
+  mode?: 'chat' | 'group'
+  draftKey?: string
+  placeholder?: string
+  disabled?: boolean
+  streaming?: boolean
+  sending?: boolean
+  modelLabel?: string
+  reasoningEffort?: string
+  reasoningValue?: string
+  reasoningOptions?: ComposerOption[]
+  contextUsed?: number
+  contextLimit?: number
+  queueMode?: boolean
+  toolTraceVisible?: boolean
+  reference?: ComposerReference | null
+  mentionOptions?: ComposerOption[]
+  slashCommands?: ComposerOption[]
+  attachmentsEnabled?: boolean
+}>(), {
+  mode: 'chat',
+  draftKey: '',
+  placeholder: '输入消息,Enter 发送,Shift + Enter 换行',
+  disabled: false,
+  streaming: false,
+  sending: false,
+  modelLabel: '选择模型',
+  reasoningEffort: '默认',
+  reasoningValue: '',
+  reasoningOptions: () => [],
+  contextUsed: 0,
+  contextLimit: 0,
+  queueMode: false,
+  toolTraceVisible: true,
+  reference: null,
+  mentionOptions: () => [],
+  slashCommands: () => [],
+  attachmentsEnabled: true,
+})
+
+const emit = defineEmits<{
+  send: [payload: ComposerSubmit]
+  stop: []
+  modelClick: []
+  reasoningChange: [value: string]
+  settingsClick: []
+  queueToggle: []
+  toolTraceToggle: []
+  clearReference: []
+  error: [message: string]
+}>()
+
+const MAX_FILE_SIZE = 25 * 1024 * 1024
+const MAX_FILES = 8
+const text = ref('')
+const textarea = ref<HTMLTextAreaElement | null>(null)
+const fileInput = ref<HTMLInputElement | null>(null)
+const attachments = ref<ComposerAttachment[]>([])
+const isComposing = ref(false)
+const dragDepth = ref(0)
+const dragOver = computed(() => dragDepth.value > 0)
+const manualHeight = ref<number | null>(null)
+const menuIndex = ref(0)
+const menuKind = ref<'slash' | 'mention' | null>(null)
+const menuQuery = ref('')
+const chosenMentionIds = ref<string[]>([])
+const reasoningOpen = ref(false)
+const settingsOpen = ref(false)
+const localError = ref('')
+let errorTimer: number | undefined
+
+const canSubmit = computed(() => !props.disabled && !props.sending && (text.value.trim().length > 0 || attachments.value.length > 0))
+// A running group still accepts new room messages. Only an ordinary session
+// turns its primary send affordance into an interrupt control.
+const showStop = computed(() => props.mode === 'chat' && props.streaming && !canSubmit.value)
+const compactModel = computed(() => props.modelLabel.split('/').filter(Boolean).at(-1) || props.modelLabel)
+const contextPercent = computed(() => props.contextLimit > 0 ? Math.min(100, Math.round(props.contextUsed / props.contextLimit * 100)) : 0)
+const hasContext = computed(() => props.contextLimit > 0)
+const remainingContextTokens = computed(() => Math.max(0, props.contextLimit - props.contextUsed))
+const reasoningOptionIndex = computed(() => Math.max(0, props.reasoningOptions.findIndex(option => option.id === props.reasoningValue)))
+const reasoningLabel = computed(() => props.reasoningOptions[reasoningOptionIndex.value]?.label || props.reasoningEffort)
+
+const menuOptions = computed(() => {
+  const source = menuKind.value === 'slash' ? props.slashCommands : props.mentionOptions
+  const query = menuQuery.value.trim().toLocaleLowerCase()
+  return source.filter(option => {
+    if (!query) return true
+    return option.label.toLocaleLowerCase().includes(query) || option.detail?.toLocaleLowerCase().includes(query)
+  }).slice(0, 10)
+})
+
+function draftStorageKey(key = props.draftKey) {
+  return key ? `hermes-yaoyao:composer:${props.mode}:${key}` : ''
+}
+
+function restoreDraft() {
+  const key = draftStorageKey()
+  text.value = key ? localStorage.getItem(key) || '' : ''
+  nextTick(autoSize)
+}
+
+function persistDraft(value: string) {
+  const key = draftStorageKey()
+  if (!key) return
+  if (value) localStorage.setItem(key, value)
+  else localStorage.removeItem(key)
+}
+
+function showError(message: string) {
+  localError.value = message
+  emit('error', message)
+  window.clearTimeout(errorTimer)
+  errorTimer = window.setTimeout(() => { localError.value = '' }, 4500)
+}
+
+function formatSize(bytes: number) {
+  if (bytes < 1024) return `${bytes} B`
+  if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
+  return `${(bytes / 1024 / 1024).toFixed(1)} MB`
+}
+
+function isImage(type: string) { return type.startsWith('image/') }
+
+function addFiles(files: File[]) {
+  if (!props.attachmentsEnabled) {
+    showError('当前上游不支持群聊附件')
+    return
+  }
+  const available = MAX_FILES - attachments.value.length
+  if (available <= 0) return showError(`最多添加 ${MAX_FILES} 个文件`)
+  const accepted: ComposerAttachment[] = []
+  for (const file of files.slice(0, available)) {
+    if (file.size > MAX_FILE_SIZE) {
+      showError(`${file.name} 超过 25 MiB`)
+      continue
+    }
+    accepted.push({
+      id: `${file.name}:${file.size}:${file.lastModified}:${crypto.randomUUID()}`,
+      file,
+      name: file.name,
+      size: file.size,
+      type: file.type || 'application/octet-stream',
+      previewUrl: isImage(file.type) ? URL.createObjectURL(file) : undefined,
+    })
+  }
+  attachments.value.push(...accepted)
+  if (files.length > available) showError(`最多添加 ${MAX_FILES} 个文件`)
+}
+
+function removeAttachment(id: string) {
+  const index = attachments.value.findIndex(item => item.id === id)
+  if (index < 0) return
+  const [removed] = attachments.value.splice(index, 1)
+  if (removed?.previewUrl) URL.revokeObjectURL(removed.previewUrl)
+}
+
+function clearAttachments() {
+  attachments.value.forEach(item => item.previewUrl && URL.revokeObjectURL(item.previewUrl))
+  attachments.value = []
+  if (fileInput.value) fileInput.value.value = ''
+}
+
+function onFiles(event: Event) {
+  const input = event.target as HTMLInputElement
+  addFiles(Array.from(input.files ?? []))
+  input.value = ''
+}
+
+function onPaste(event: ClipboardEvent) {
+  const files = Array.from(event.clipboardData?.files ?? [])
+  if (files.length) addFiles(files)
+}
+
+function onDragEnter(event: DragEvent) {
+  if (!event.dataTransfer?.types.includes('Files')) return
+  event.preventDefault()
+  dragDepth.value += 1
+}
+
+function onDragOver(event: DragEvent) {
+  if (!event.dataTransfer?.types.includes('Files')) return
+  event.preventDefault()
+  if (event.dataTransfer) event.dataTransfer.dropEffect = 'copy'
+}
+
+function onDragLeave(event: DragEvent) {
+  event.preventDefault()
+  dragDepth.value = Math.max(0, dragDepth.value - 1)
+}
+
+function onDrop(event: DragEvent) {
+  event.preventDefault()
+  dragDepth.value = 0
+  addFiles(Array.from(event.dataTransfer?.files ?? []))
+}
+
+function autoSize() {
+  const el = textarea.value
+  if (!el || manualHeight.value !== null) return
+  el.style.height = 'auto'
+  el.style.height = `${Math.min(400, Math.max(30, el.scrollHeight))}px`
+}
+
+function resetHeight() {
+  manualHeight.value = null
+  if (textarea.value) textarea.value.style.height = 'auto'
+  nextTick(autoSize)
+}
+
+function startResize(event: PointerEvent) {
+  const el = textarea.value
+  if (!el) return
+  event.preventDefault()
+  const startY = event.clientY
+  const startHeight = el.clientHeight
+  const pointerId = event.pointerId
+  const handle = event.currentTarget as HTMLElement
+  handle.setPointerCapture(pointerId)
+
+  const move = (moveEvent: PointerEvent) => {
+    manualHeight.value = Math.min(400, Math.max(30, startHeight - (moveEvent.clientY - startY)))
+  }
+  const up = () => {
+    handle.removeEventListener('pointermove', move)
+    handle.removeEventListener('pointerup', up)
+    handle.removeEventListener('pointercancel', up)
+  }
+  handle.addEventListener('pointermove', move)
+  handle.addEventListener('pointerup', up)
+  handle.addEventListener('pointercancel', up)
+}
+
+function updateMenu() {
+  const el = textarea.value
+  if (!el || isComposing.value) return
+  const before = text.value.slice(0, el.selectionStart)
+  if (before.startsWith('/') && !before.includes(' ') && !before.includes('\n') && props.slashCommands.length) {
+    menuKind.value = 'slash'
+    menuQuery.value = before.slice(1)
+    menuIndex.value = 0
+    return
+  }
+  if (props.mode === 'group' && props.mentionOptions.length) {
+    const match = before.match(/(?:^|\s)@([^\s@]*)$/u)
+    if (match && !/https?:\/\/\S*$/u.test(before) && !/\S+@\S*$/u.test(before)) {
+      menuKind.value = 'mention'
+      menuQuery.value = match[1] ?? ''
+      menuIndex.value = 0
+      return
+    }
+  }
+  menuKind.value = null
+}
+
+function selectOption(option: ComposerOption) {
+  if (option.disabled) return
+  const el = textarea.value
+  if (!el) return
+  const cursor = el.selectionStart
+  const before = text.value.slice(0, cursor)
+  const after = text.value.slice(cursor)
+  if (menuKind.value === 'slash') {
+    const replacement = option.insertText ?? `/${option.label} `
+    text.value = replacement + after
+    nextTick(() => el.setSelectionRange(replacement.length, replacement.length))
+  } else {
+    const match = before.match(/(?:^|\s)@([^\s@]*)$/u)
+    const start = match ? cursor - match[0].trimStart().length : cursor
+    const prefix = text.value.slice(0, start)
+    const replacement = `@${option.label} `
+    text.value = prefix + replacement + after
+    if (option.id !== 'all' && !chosenMentionIds.value.includes(option.id)) chosenMentionIds.value.push(option.id)
+    nextTick(() => {
+      const pos = prefix.length + replacement.length
+      el.setSelectionRange(pos, pos)
+    })
+  }
+  menuKind.value = null
+  nextTick(() => { el.focus(); autoSize() })
+}
+
+function handleKeydown(event: KeyboardEvent) {
+  if (menuKind.value && menuOptions.value.length) {
+    if (event.key === 'ArrowDown') {
+      event.preventDefault(); menuIndex.value = (menuIndex.value + 1) % menuOptions.value.length; return
+    }
+    if (event.key === 'ArrowUp') {
+      event.preventDefault(); menuIndex.value = (menuIndex.value - 1 + menuOptions.value.length) % menuOptions.value.length; return
+    }
+    if (event.key === 'Escape') { event.preventDefault(); menuKind.value = null; return }
+    if (event.key === 'Tab' || (event.key === 'Enter' && !event.shiftKey)) {
+      event.preventDefault()
+      const option = menuOptions.value[menuIndex.value]
+      if (option) selectOption(option)
+      return
+    }
+  }
+  if (event.key !== 'Enter' || event.shiftKey) return
+  if (isComposing.value || event.isComposing || event.keyCode === 229) return
+  event.preventDefault()
+  submit()
+}
+
+function endComposition() {
+  requestAnimationFrame(() => {
+    isComposing.value = false
+    updateMenu()
+  })
+}
+
+function submit() {
+  if (!canSubmit.value) return
+  const payload: ComposerSubmit = {
+    text: text.value.trim(),
+    files: attachments.value.map(item => item.file),
+    mentionIds: [...chosenMentionIds.value],
+  }
+  emit('send', payload)
+  nextTick(() => textarea.value?.focus())
+}
+
+function clearAfterSend() {
+  text.value = ''
+  chosenMentionIds.value = []
+  persistDraft('')
+  clearAttachments()
+  resetHeight()
+  nextTick(() => textarea.value?.focus())
+}
+
+function onWrapperMouseDown(event: MouseEvent) {
+  const target = event.target as HTMLElement
+  if (target.closest('button, input, textarea, [role="option"], .composer-resize')) return
+  textarea.value?.focus()
+}
+
+function formatTokens(value: number) {
+  if (value >= 1_048_576) return `${(value / 1_048_576).toFixed(1)}m`
+  if (value >= 1024) return `${(value / 1024).toFixed(1)}k`
+  return `${value}`
+}
+
+function selectReasoningByIndex(event: Event) {
+  const index = Number((event.target as HTMLInputElement).value)
+  const option = props.reasoningOptions[index]
+  if (option) emit('reasoningChange', option.id)
+}
+
+function onDocumentPointer(event: PointerEvent) {
+  const target = event.target as HTMLElement
+  if (!target.closest('.composer-shell')) {
+    menuKind.value = null
+    reasoningOpen.value = false
+    settingsOpen.value = false
+  }
+}
+
+watch(text, value => { persistDraft(value); nextTick(autoSize) })
+watch(() => props.draftKey, restoreDraft)
+
+onMounted(() => {
+  restoreDraft()
+  document.addEventListener('pointerdown', onDocumentPointer)
+})
+
+onBeforeUnmount(() => {
+  clearAttachments()
+  window.clearTimeout(errorTimer)
+  document.removeEventListener('pointerdown', onDocumentPointer)
+})
+
+defineExpose({
+  attachFiles: addFiles,
+  focus: () => textarea.value?.focus(),
+  clearAfterSend,
+})
+</script>
+
+<template>
+  <div class="composer-area">
+    <div v-if="attachments.length" class="composer-attachments" aria-label="附件">
+      <div v-for="attachment in attachments" :key="attachment.id" class="composer-attachment" :class="{ image: isImage(attachment.type) }">
+        <img v-if="attachment.previewUrl" :src="attachment.previewUrl" :alt="attachment.name" />
+        <AppIcon v-else name="file" :size="19" />
+        <span><strong>{{ attachment.name }}</strong><small>{{ formatSize(attachment.size) }}</small></span>
+        <button type="button" :aria-label="`移除 ${attachment.name}`" @click="removeAttachment(attachment.id)"><AppIcon name="close" :size="12" /></button>
+      </div>
+    </div>
+
+    <div v-if="reference" class="composer-reference">
+      <AppIcon name="quote" :size="14" />
+      <span><small>{{ reference.author ? `回复 ${reference.author}` : '回复消息' }}</small><strong>{{ reference.content.replace(/\s+/g, ' ').trim() }}</strong></span>
+      <button type="button" aria-label="取消引用" @click="emit('clearReference')"><AppIcon name="close" :size="13" /></button>
+    </div>
+
+    <div
+      class="composer-shell"
+      :class="{ 'composer-shell--drag': dragOver, 'composer-shell--disabled': disabled }"
+      @mousedown="onWrapperMouseDown"
+      @dragenter="onDragEnter"
+      @dragover="onDragOver"
+      @dragleave="onDragLeave"
+      @drop="onDrop"
+    >
+      <input ref="fileInput" class="composer-file-input" type="file" multiple @change="onFiles" />
+      <div class="composer-resize" title="拖动调整高度;双击复位" @pointerdown="startResize" @dblclick="resetHeight" />
+
+      <div v-if="hasContext" class="composer-context" :class="{ warning: contextPercent > 80 }">
+        <span>{{ formatTokens(contextUsed) }} / {{ formatTokens(contextLimit) }} · 剩余 {{ formatTokens(remainingContextTokens) }}</span>
+        <i><b :style="{ width: `${contextPercent}%` }" /></i>
+      </div>
+
+      <textarea
+        ref="textarea"
+        v-model="text"
+        class="composer-textarea"
+        :style="manualHeight !== null ? { height: `${manualHeight}px` } : undefined"
+        rows="1"
+        :disabled="disabled"
+        :placeholder="placeholder"
+        @input="updateMenu"
+        @click="updateMenu"
+        @keydown="handleKeydown"
+        @paste="onPaste"
+        @compositionstart="isComposing = true"
+        @compositionend="endComposition"
+      />
+
+      <div class="composer-toolbar">
+        <div class="composer-tools">
+          <button class="composer-tool composer-tool--icon" type="button" :disabled="disabled || !attachmentsEnabled" :title="attachmentsEnabled ? '添加附件' : '当前上游不支持附件'" aria-label="添加附件" @click="fileInput?.click()">
+            <AppIcon name="paperclip" :size="16" />
+          </button>
+          <div v-if="mode === 'chat'" class="composer-popover-anchor">
+            <button class="composer-tool" type="button" :class="{ active: reasoningOpen }" :disabled="disabled || !reasoningOptions.length" :title="`推理强度:${reasoningLabel}`" :aria-label="`推理强度:${reasoningLabel}`" :aria-expanded="reasoningOpen" @click="reasoningOpen = !reasoningOpen; settingsOpen = false">
+              <AppIcon name="brain" :size="16" /><span>{{ reasoningLabel }}</span><AppIcon name="chevron-down" :size="11" />
+            </button>
+            <Transition name="composer-menu">
+              <div v-if="reasoningOpen" class="composer-popover composer-popover--reasoning" role="dialog" aria-label="推理强度">
+                <div class="composer-popover__heading"><span>推理强度</span><strong>{{ reasoningLabel }}</strong></div>
+                <input class="reasoning-slider" type="range" :min="0" :max="Math.max(0, reasoningOptions.length - 1)" step="1" :value="reasoningOptionIndex" @input="selectReasoningByIndex" />
+                <div class="composer-popover__range"><span>{{ reasoningOptions[0]?.label || '默认' }}</span><span>{{ reasoningOptions.at(-1)?.label || '最大' }}</span></div>
+              </div>
+            </Transition>
+          </div>
+          <div v-if="mode === 'chat'" class="composer-popover-anchor">
+            <button class="composer-tool" type="button" :class="{ active: settingsOpen }" :disabled="disabled" title="设置" aria-label="设置" :aria-expanded="settingsOpen" @click="settingsOpen = !settingsOpen; reasoningOpen = false">
+              <AppIcon name="settings" :size="16" /><span>设置</span><AppIcon name="chevron-down" :size="11" />
+            </button>
+            <Transition name="composer-menu">
+              <div v-if="settingsOpen" class="composer-popover composer-popover--settings" role="dialog" aria-label="输入设置">
+                <button class="composer-setting-row" type="button" role="switch" :aria-checked="toolTraceVisible" @click="emit('settingsClick')">
+                  <span><AppIcon name="tools" :size="15" />显示工具轨迹</span>
+                  <AppIcon v-if="toolTraceVisible" name="check" :size="16" />
+                </button>
+                <button class="composer-setting-row" type="button" title="首版暂未启用语音输入" @click="showError('语音输入尚未启用')">
+                  <span>语音输入设置</span>
+                </button>
+              </div>
+            </Transition>
+          </div>
+          <button v-else class="composer-tool" type="button" :class="{ active: toolTraceVisible }" :disabled="disabled" title="显示工具调用" @click="emit('toolTraceToggle')">
+            <AppIcon name="tools" :size="16" /><span>工具</span>
+          </button>
+          <button v-if="mode === 'chat'" class="composer-tool composer-tool--model" type="button" :disabled="disabled" :title="modelLabel" @click="emit('modelClick')">
+            <AppIcon name="model" :size="15" /><span>{{ compactModel }}</span><AppIcon name="chevron-down" :size="11" />
+          </button>
+          <span v-else class="composer-mention-hint"><b>@</b><span>提及 Agent</span></span>
+        </div>
+        <div class="composer-actions">
+          <button v-if="mode === 'chat' && streaming && canSubmit" class="queue-toggle" :class="{ active: queueMode }" type="button" :title="queueMode ? '消息将排队发送' : '消息将 Steer 当前会话'" @click="emit('queueToggle')">
+            {{ queueMode ? '排队' : 'Steer' }}
+          </button>
+          <span v-if="sending" class="composer-sending" aria-label="正在发送" />
+          <span class="composer-send-hitbox">
+            <button
+              class="composer-send"
+              :class="{ 'composer-send--stop': showStop }"
+              type="button"
+              :disabled="showStop ? false : !canSubmit"
+              :aria-label="showStop ? '停止生成' : '发送消息'"
+              @click="showStop ? emit('stop') : submit()"
+            >
+              <AppIcon :name="showStop ? 'stop' : 'arrow-up'" :size="showStop ? 17 : 17" />
+            </button>
+          </span>
+        </div>
+      </div>
+
+      <Transition name="composer-menu">
+        <div v-if="menuKind && menuOptions.length" class="composer-menu" role="listbox">
+          <div class="composer-menu__label">{{ menuKind === 'mention' ? '提及 Agent' : '命令' }}</div>
+          <button
+            v-for="(option, index) in menuOptions"
+            :key="option.id"
+            type="button"
+            role="option"
+            :aria-selected="index === menuIndex"
+            :disabled="option.disabled"
+            :class="{ active: index === menuIndex }"
+            @mousedown.prevent="selectOption(option)"
+            @mouseenter="menuIndex = index"
+          >
+            <span class="composer-menu__avatar">{{ menuKind === 'mention' ? option.label.slice(0, 1).toUpperCase() : '/' }}</span>
+            <span><strong>{{ menuKind === 'mention' ? `@${option.label}` : `/${option.label}` }}</strong><small v-if="option.detail">{{ option.detail }}</small></span>
+            <em v-if="option.disabled">不可用</em>
+          </button>
+        </div>
+      </Transition>
+
+      <div v-if="dragOver" class="composer-drop-hint"><AppIcon name="paperclip" :size="20" />松开以添加附件</div>
+    </div>
+    <Transition name="composer-menu"><p v-if="localError" class="composer-error" role="alert">{{ localError }}</p></Transition>
+  </div>
+</template>
+
+<style scoped>
+.composer-area { position: relative; z-index: 12; flex: 0 0 auto; padding: 12px max(24px, calc((100% - 760px) / 2)) max(18px, env(safe-area-inset-bottom)); background: var(--conversation-canvas); }
+.composer-shell { position: relative; display: flex; width: 100%; min-height: 72px; max-width: 760px; margin-inline: auto; flex-direction: column; align-items: stretch; gap: 8px; padding: 13px 14px 9px; border: 1px solid var(--input-border-color); border-radius: 18px; background: var(--chat-composer-bg); box-shadow: 0 8px 24px rgba(41,36,39,.06); cursor: text; transition: border-color 140ms ease, box-shadow 150ms ease, background-color 140ms ease; }
+.dark .composer-shell { box-shadow: 0 8px 28px rgba(0,0,0,.32); }
+.composer-shell:hover:not(:focus-within) { border-color: var(--input-border-hover-color); }
+.composer-shell:focus-within { border-color: var(--input-border-focus-color); box-shadow: 0 10px 32px rgba(0,0,0,.11); }
+.dark .composer-shell:focus-within { box-shadow: 0 12px 34px rgba(0,0,0,.4); }
+.composer-shell--drag { border-color: var(--text-primary) !important; background: color-mix(in srgb, var(--chat-composer-bg) 90%, var(--text-primary)); }
+.composer-shell--disabled { opacity: .68; }
+.composer-file-input { display: none; }
+.composer-resize { position: absolute; z-index: 4; top: -5px; left: 9px; right: 9px; height: 10px; border-radius: 999px; cursor: row-resize; touch-action: none; }
+.composer-resize:hover::after { content: ''; position: absolute; top: 4px; left: 38%; right: 38%; height: 2px; border-radius: 2px; background: var(--line-strong); }
+.composer-textarea { display: block; width: 100%; min-height: 30px; max-height: 400px; flex: 1 1 auto; padding: 0; border: 0; outline: 0; resize: none; overflow-y: auto; background: none; color: var(--text-primary); font-family: var(--font-ui); font-size: 14px; line-height: 1.5; }
+.composer-textarea::placeholder { overflow: hidden; color: var(--input-placeholder-color); opacity: 1; text-overflow: ellipsis; white-space: nowrap; }
+.composer-textarea:disabled { cursor: not-allowed; }
+.composer-context { position: absolute; z-index: 1; top: 8px; right: 14px; display: flex; align-items: center; gap: 6px; max-width: calc(100% - 28px); color: var(--text-muted); font-size: 10px; pointer-events: none; }
+.composer-context span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.composer-context.warning { color: var(--warning); }
+.composer-context i { display: block; width: 60px; height: 4px; flex: 0 0 60px; overflow: hidden; border-radius: 3px; background: var(--surface-hover); }
+.composer-context b { display: block; height: 100%; border-radius: inherit; background: currentColor; transition: width 220ms ease; }
+.composer-context + .composer-textarea { padding-top: 9px; }
+.composer-toolbar { display: flex; min-height: 32px; margin-top: auto; align-items: center; justify-content: space-between; gap: 12px; }
+.composer-tools, .composer-actions { display: flex; min-width: 0; align-items: center; gap: 5px; }
+.composer-tools { flex: 1; }
+.composer-actions { flex: 0 0 auto; gap: 7px; }
+.composer-popover-anchor { position: relative; display: flex; flex: 0 0 auto; }
+.composer-tool { display: inline-flex; min-width: 28px; height: 28px; max-width: 175px; align-items: center; justify-content: center; gap: 4px; padding: 0 6px; border: 0; border-radius: 999px; background: transparent; color: var(--text-secondary); cursor: pointer; font-size: 11px; white-space: nowrap; }
+.composer-tool:hover, .composer-tool.active { background: var(--surface-hover); color: var(--text-primary); }
+.composer-tool:disabled { cursor: not-allowed; opacity: .36; }
+.composer-tool span { min-width: 0; overflow: hidden; text-overflow: ellipsis; }
+.composer-tool--icon { width: 28px; padding: 0; }
+.composer-tool--model { min-width: 0; }
+.composer-mention-hint { display: inline-flex; align-items: center; gap: 4px; color: var(--text-muted); font-size: 11px; }
+.composer-mention-hint b { display: grid; place-items: center; width: 21px; height: 21px; border-radius: 7px; background: var(--surface-soft); color: var(--text-secondary); font-size: 12px; }
+.composer-popover { position: absolute; z-index: 30; bottom: calc(100% + 10px); left: 0; width: min(320px, calc(100vw - 48px)); padding: 12px; border: 1px solid var(--line); border-radius: 13px; background: var(--surface-raised); box-shadow: var(--shadow-float); }
+.composer-popover--reasoning { --reasoning-accent: #e8a735; }
+.composer-popover__heading, .composer-popover__range { display: flex; align-items: center; justify-content: space-between; gap: 16px; }
+.composer-popover__heading { margin-bottom: 12px; color: var(--text-secondary); font-size: 12px; }
+.composer-popover__heading strong { color: var(--reasoning-accent); font-weight: 650; }
+.composer-popover__range { margin-top: 8px; color: var(--text-muted); font-size: 9px; }
+.reasoning-slider { display: block; width: 100%; height: 10px; margin: 0; appearance: none; -webkit-appearance: none; border-radius: 99px; background: linear-gradient(90deg, #38bdf8 0%, #22d3ee 20%, #34d399 40%, #facc15 62%, #fb923c 82%, #ef4444 100%); cursor: pointer; }
+.reasoning-slider::-webkit-slider-thumb { width: 22px; height: 22px; appearance: none; -webkit-appearance: none; border: 2px solid #fff; border-radius: 50%; background: #f8fafc; box-shadow: 0 2px 8px rgba(24,18,44,.28); }
+.reasoning-slider::-moz-range-thumb { width: 18px; height: 18px; border: 2px solid #fff; border-radius: 50%; background: #f8fafc; box-shadow: 0 2px 8px rgba(24,18,44,.28); }
+.composer-popover--settings { width: 220px; padding: 6px; }
+.composer-setting-row { display: flex; width: 100%; min-height: 38px; align-items: center; justify-content: space-between; gap: 8px; padding: 6px 8px; border: 0; border-radius: 8px; background: transparent; color: var(--text-secondary); cursor: pointer; font-size: 11px; }
+.composer-setting-row:hover { background: var(--surface-hover); color: var(--text-primary); }
+.composer-setting-row > span { display: inline-flex; align-items: center; gap: 7px; }
+.queue-toggle { height: 26px; padding: 0 7px; border: 0; border-radius: 8px; background: transparent; color: var(--text-muted); cursor: pointer; font-size: 10px; }
+.queue-toggle:hover, .queue-toggle.active { background: var(--surface-hover); color: var(--text-primary); }
+.composer-send-hitbox { display: grid; place-items: center; width: 34px; height: 34px; }
+.composer-send { display: grid; place-items: center; width: 34px; min-width: 34px; height: 34px; padding: 0; border: 0; border-radius: 50%; background: var(--accent); color: var(--text-on-solid); cursor: pointer; box-shadow: 0 4px 12px rgba(41,36,39,.18); transition: transform 150ms ease, box-shadow 150ms ease; }
+.composer-send:not(:disabled):hover { transform: translateY(-1px); box-shadow: 0 7px 16px rgba(41,36,39,.22); }
+.composer-send:disabled { cursor: not-allowed; background: #9f9f9f; color: #fff; }
+.composer-send--stop { background: var(--accent); }
+.composer-sending { width: 5px; height: 5px; border-radius: 50%; background: var(--warning); animation: composer-pulse 1s ease-in-out infinite; }
+@keyframes composer-pulse { 50% { opacity: .25; transform: scale(.7); } }
+.composer-attachments { display: flex; width: 100%; max-width: 760px; margin: 0 auto 9px; flex-wrap: wrap; gap: 7px; }
+.composer-attachment { position: relative; display: flex; max-width: 210px; min-height: 48px; align-items: center; gap: 8px; padding: 6px 28px 6px 9px; overflow: hidden; border: 1px solid var(--line); border-radius: 10px; background: var(--surface-raised); color: var(--text-secondary); }
+.composer-attachment.image { width: 64px; height: 64px; padding: 0; }
+.composer-attachment img { width: 100%; height: 100%; object-fit: cover; }
+.composer-attachment > span { display: flex; min-width: 0; flex-direction: column; }
+.composer-attachment strong, .composer-attachment small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.composer-attachment strong { color: var(--text-primary); font-size: 10px; font-weight: 560; }.composer-attachment small { margin-top: 3px; color: var(--text-muted); font-size: 9px; }
+.composer-attachment > button { position: absolute; top: 4px; right: 4px; display: grid; place-items: center; width: 20px; height: 20px; padding: 0; border: 0; border-radius: 50%; background: rgba(0,0,0,.52); color: #fff; cursor: pointer; }
+.composer-reference { display: flex; width: calc(100% - 16px); max-width: 744px; min-height: 36px; margin: 0 auto 8px; align-items: center; gap: 9px; padding: 5px 7px 5px 10px; border-left: 2px solid var(--text-muted); border-radius: 7px; background: var(--surface-soft); color: var(--text-muted); }
+.composer-reference > span { display: flex; min-width: 0; flex: 1; flex-direction: column; }
+.composer-reference small { font-size: 9px; }.composer-reference strong { overflow: hidden; color: var(--text-secondary); font-size: 10px; font-weight: 500; text-overflow: ellipsis; white-space: nowrap; }
+.composer-reference button { display: grid; place-items: center; width: 26px; height: 26px; padding: 0; border: 0; border-radius: 7px; background: transparent; color: var(--text-muted); cursor: pointer; }
+.composer-reference button:hover { background: var(--surface-hover); color: var(--text-primary); }
+.composer-menu { position: absolute; z-index: 20; left: 12px; right: 12px; bottom: calc(100% + 8px); max-height: 280px; padding: 5px; overflow-y: auto; border: 1px solid var(--line); border-radius: 12px; background: var(--surface-raised); box-shadow: var(--shadow-float); }
+.composer-menu__label { padding: 5px 8px 7px; color: var(--text-muted); font-size: 9px; font-weight: 650; letter-spacing: .08em; text-transform: uppercase; }
+.composer-menu button { display: flex; width: 100%; min-height: 42px; align-items: center; gap: 9px; padding: 5px 8px; border: 0; border-radius: 8px; background: transparent; color: var(--text-primary); cursor: pointer; text-align: left; }
+.composer-menu button.active, .composer-menu button:hover { background: var(--surface-hover); }
+.composer-menu button:disabled { cursor: not-allowed; opacity: .48; }
+.composer-menu__avatar { display: grid; place-items: center; width: 28px; height: 28px; border-radius: 8px; background: var(--surface-soft); color: var(--text-secondary); font-size: 11px; }
+.composer-menu button > span:nth-child(2) { display: flex; min-width: 0; flex: 1; flex-direction: column; }.composer-menu strong { font-size: 11px; }.composer-menu small { overflow: hidden; color: var(--text-muted); font-size: 9px; text-overflow: ellipsis; white-space: nowrap; }.composer-menu em { color: var(--text-muted); font-size: 9px; font-style: normal; }
+.composer-drop-hint { position: absolute; z-index: 25; inset: 6px; display: flex; align-items: center; justify-content: center; gap: 8px; border: 1px dashed var(--line-strong); border-radius: 13px; background: color-mix(in srgb, var(--chat-composer-bg) 90%, transparent); color: var(--text-secondary); font-size: 12px; backdrop-filter: blur(5px); pointer-events: none; }
+.composer-error { position: absolute; right: max(24px, calc((100% - 760px) / 2)); bottom: calc(100% - 8px); max-width: min(420px, calc(100% - 32px)); margin: 0; padding: 7px 10px; border-radius: 8px; background: var(--danger); color: #fff; box-shadow: var(--shadow-float); font-size: 10px; }
+.composer-menu-enter-active, .composer-menu-leave-active { transition: opacity 120ms ease, transform 120ms var(--ease-out); }.composer-menu-enter-from, .composer-menu-leave-to { opacity: 0; transform: translateY(4px); }
+
+@media (max-width: 768px) {
+  .composer-area { padding: 8px 12px max(12px, env(safe-area-inset-bottom)); }
+  .composer-shell { min-height: 118px; }
+  .composer-textarea { font-size: 16px; }
+  .composer-textarea::placeholder { font-size: 13px; line-height: 1.35; }
+  .composer-tool span, .composer-tool > :deep(svg:last-child), .composer-mention-hint span { display: none; }
+  .composer-tool { width: 30px; min-width: 30px; padding: 0; }
+  .composer-tools { gap: 3px; }
+  .composer-toolbar { align-items: flex-end; gap: 7px; }
+  .composer-send-hitbox { width: 44px; height: 44px; margin: -5px; }
+  .composer-send { width: 34px; min-width: 34px; height: 34px; }
+  .composer-menu { left: 6px; right: 6px; }
+  .composer-context { font-size: 8px; }
+  .composer-context i { display: none; }
+  .composer-popover { width: min(220px, calc(100vw - 96px)); }
+  .composer-error { right: 12px; }
+}
+
+@media (max-width: 360px) {
+  .queue-toggle { display: none; }
+  .composer-toolbar { gap: 3px; }
+}
+</style>

+ 28 - 0
src/client/components/composer/types.ts

@@ -0,0 +1,28 @@
+export type ComposerAttachment = {
+  id: string
+  file: File
+  name: string
+  size: number
+  type: string
+  previewUrl?: string
+}
+
+export type ComposerReference = {
+  id: string
+  content: string
+  author?: string
+}
+
+export type ComposerOption = {
+  id: string
+  label: string
+  detail?: string
+  disabled?: boolean
+  insertText?: string
+}
+
+export type ComposerSubmit = {
+  text: string
+  files: File[]
+  mentionIds: string[]
+}

+ 67 - 0
src/client/components/groups/CreateGroupDialog.vue

@@ -0,0 +1,67 @@
+<script setup lang="ts">
+import { computed, ref, watch } from 'vue'
+import AppIcon from '@/components/common/AppIcon.vue'
+
+const props = withDefaults(defineProps<{ open: boolean; profiles: string[]; busy?: boolean }>(), { busy: false })
+const emit = defineEmits<{ close: []; create: [payload: { name: string; profiles: string[]; autoReply: boolean; replyRounds: number }] }>()
+
+const name = ref('')
+const selected = ref<string[]>([])
+const autoReply = ref(true)
+const replyRounds = ref(3)
+const valid = computed(() => name.value.trim().length > 0 && selected.value.length >= 1 && selected.value.length <= 8)
+
+watch(() => props.open, open => {
+  if (!open) return
+  name.value = ''
+  selected.value = props.profiles.slice(0, 1)
+  autoReply.value = true
+  replyRounds.value = 3
+})
+
+function toggle(profile: string) {
+  if (selected.value.includes(profile)) selected.value = selected.value.filter(item => item !== profile)
+  else if (selected.value.length < 8) selected.value.push(profile)
+}
+
+function create() {
+  if (!valid.value) return
+  emit('create', { name: name.value.trim(), profiles: selected.value, autoReply: autoReply.value, replyRounds: replyRounds.value })
+}
+</script>
+
+<template>
+  <Teleport to="body">
+    <Transition name="dialog-fade">
+      <div v-if="open" class="dialog-layer" role="presentation" @mousedown.self="emit('close')">
+        <section class="create-dialog" role="dialog" aria-modal="true" aria-labelledby="create-group-title">
+          <header><div><small>9119 群聊</small><h2 id="create-group-title">新建群聊</h2></div><button class="icon-button" type="button" aria-label="关闭" @click="emit('close')"><AppIcon name="close" /></button></header>
+          <label class="field"><span>群聊名称</span><input v-model="name" maxlength="80" autofocus placeholder="例如:产品评审" /></label>
+          <div class="agent-picker">
+            <div class="agent-picker__heading"><span>选择 Agent</span><small>{{ selected.length }}/8</small></div>
+            <button v-for="profile in profiles" :key="profile" type="button" :class="{ selected: selected.includes(profile) }" :disabled="!selected.includes(profile) && selected.length >= 8" @click="toggle(profile)">
+              <span>{{ profile.slice(0, 1).toUpperCase() }}</span><strong>{{ profile }}</strong><AppIcon v-if="selected.includes(profile)" name="check" :size="15" />
+            </button>
+            <p v-if="!profiles.length">当前没有可用 Agent,请先在 Hermes 配置 Profile。</p>
+          </div>
+          <div class="dialog-settings">
+            <label><input v-model="autoReply" type="checkbox" />启用自动回复</label>
+            <label>最多轮数 <input v-model.number="replyRounds" type="number" min="1" max="12" /></label>
+          </div>
+          <footer><button class="quiet-button" type="button" @click="emit('close')">取消</button><button class="solid-button" type="button" :disabled="!valid || busy" @click="create">{{ busy ? '创建中…' : '创建群聊' }}</button></footer>
+        </section>
+      </div>
+    </Transition>
+  </Teleport>
+</template>
+
+<style scoped>
+.dialog-layer { position: fixed; z-index: 200; inset: 0; display: grid; place-items: center; padding: 18px; background: var(--scrim); backdrop-filter: blur(5px); }
+.create-dialog { width: min(470px, 100%); max-height: min(680px, calc(100vh - 36px)); padding: 18px; overflow-y: auto; border: 1px solid var(--line); border-radius: 17px; background: var(--surface-raised); box-shadow: var(--shadow-float); }
+header { display: flex; align-items: flex-start; justify-content: space-between; margin-bottom: 18px; } header small { color: var(--text-muted); font-size: 9px; letter-spacing: .06em; } h2 { margin: 3px 0 0; font-size: 19px; letter-spacing: -.03em; }
+.field { display: flex; flex-direction: column; gap: 6px; color: var(--text-secondary); font-size: 10px; }.field input { height: 38px; padding: 0 10px; border: 1px solid var(--line); border-radius: 10px; outline: none; background: var(--surface-soft); color: var(--text-primary); }.field input:focus { border-color: var(--line-strong); box-shadow: 0 0 0 3px var(--focus-ring); }
+.agent-picker { margin-top: 17px; }.agent-picker__heading { display: flex; justify-content: space-between; margin-bottom: 7px; color: var(--text-secondary); font-size: 10px; }.agent-picker__heading small { color: var(--text-muted); }.agent-picker > button { display: flex; width: 100%; min-height: 42px; align-items: center; gap: 9px; padding: 5px 8px; border: 0; border-radius: 9px; background: transparent; color: var(--text-primary); cursor: pointer; text-align: left; }.agent-picker > button:hover, .agent-picker > button.selected { background: var(--surface-soft); }.agent-picker > button:disabled { opacity: .35; }.agent-picker > button > span { display: grid; place-items: center; width: 28px; height: 28px; border-radius: 8px; background: var(--accent); color: var(--text-on-solid); font-size: 10px; }.agent-picker > button strong { flex: 1; font-size: 11px; }.agent-picker p { color: var(--text-muted); font-size: 10px; }
+.dialog-settings { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-top: 15px; padding: 11px; border-radius: 10px; background: var(--surface-soft); }.dialog-settings label { display: flex; align-items: center; gap: 6px; color: var(--text-secondary); font-size: 10px; }.dialog-settings input[type="checkbox"] { accent-color: var(--accent); }.dialog-settings input[type="number"] { width: 51px; padding: 4px; border: 1px solid var(--line); border-radius: 7px; background: var(--surface-raised); color: var(--text-primary); text-align: center; }
+footer { display: flex; justify-content: flex-end; gap: 8px; margin-top: 20px; }
+.dialog-fade-enter-active, .dialog-fade-leave-active { transition: opacity 140ms ease; }.dialog-fade-enter-active .create-dialog, .dialog-fade-leave-active .create-dialog { transition: transform 170ms var(--ease-out); }.dialog-fade-enter-from, .dialog-fade-leave-to { opacity: 0; }.dialog-fade-enter-from .create-dialog, .dialog-fade-leave-to .create-dialog { transform: translateY(8px) scale(.985); }
+</style>

+ 108 - 0
src/client/components/groups/GroupManager.vue

@@ -0,0 +1,108 @@
+<script setup lang="ts">
+import { reactive, watch } from 'vue'
+import AppIcon from '@/components/common/AppIcon.vue'
+import type { UiAgent, UiRoom } from './types'
+
+const props = defineProps<{
+  room: UiRoom
+  agents: UiAgent[]
+  availableProfiles?: string[]
+  busy?: boolean
+}>()
+
+const emit = defineEmits<{
+  updateRoom: [patch: Partial<UiRoom>]
+  addAgent: [profile: string]
+  updateAgent: [id: string, patch: Partial<UiAgent>]
+  removeAgent: [id: string]
+  interruptAgent: [id: string]
+  archiveRoom: []
+  close: []
+}>()
+
+const form = reactive({ name: props.room.name, description: props.room.description || '', autoReply: props.room.autoReply ?? true, replyRounds: props.room.replyRounds ?? 3 })
+
+watch(() => props.room, room => {
+  form.name = room.name
+  form.description = room.description || ''
+  form.autoReply = room.autoReply ?? true
+  form.replyRounds = room.replyRounds ?? 3
+}, { deep: true })
+
+function save() {
+  emit('updateRoom', {
+    name: form.name.trim() || props.room.name,
+    description: form.description.trim(),
+    autoReply: form.autoReply,
+    replyRounds: Math.min(12, Math.max(1, Number(form.replyRounds) || 1)),
+  })
+}
+</script>
+
+<template>
+  <div class="group-manager">
+    <header>
+      <span><small>群聊管理</small><strong>{{ room.name }}</strong></span>
+      <button class="icon-button" type="button" aria-label="关闭群聊管理" @click="emit('close')"><AppIcon name="close" /></button>
+    </header>
+
+    <section>
+      <h3>房间</h3>
+      <label><span>名称</span><input v-model="form.name" maxlength="80" @change="save" /></label>
+      <label><span>说明</span><textarea v-model="form.description" rows="2" maxlength="240" @change="save" /></label>
+      <div class="setting-row">
+        <span><strong>自动回复</strong><small>Agent 根据新消息自动协作</small></span>
+        <button class="switch" :class="{ active: form.autoReply }" type="button" role="switch" :aria-checked="form.autoReply" @click="form.autoReply = !form.autoReply; save()"><i /></button>
+      </div>
+      <label class="rounds"><span>最多回复轮数</span><input v-model.number="form.replyRounds" type="number" min="1" max="12" @change="save" /></label>
+    </section>
+
+    <section>
+      <div class="section-heading"><h3>成员 <em>{{ agents.length }}/8</em></h3></div>
+      <div class="agent-list">
+        <article v-for="agent in agents" :key="agent.id">
+          <span class="agent-avatar">{{ agent.name.slice(0, 1).toUpperCase() }}<i :class="`status-${agent.status || 'idle'}`" /></span>
+          <span class="agent-copy"><strong>{{ agent.name }}</strong><small>{{ agent.profile || agent.id }} · {{ { idle: '空闲', working: '正在回复', offline: '离线', error: '异常' }[agent.status || 'idle'] }}</small></span>
+          <button v-if="agent.status === 'working'" class="agent-action" type="button" title="中断" @click="emit('interruptAgent', agent.id)"><AppIcon name="stop" :size="13" /></button>
+          <button class="agent-action danger" type="button" title="移除" :disabled="agents.length <= 1" @click="emit('removeAgent', agent.id)"><AppIcon name="trash" :size="14" /></button>
+          <div class="agent-settings">
+            <label><input type="checkbox" :checked="agent.enabled !== false" @change="emit('updateAgent', agent.id, { enabled: ($event.target as HTMLInputElement).checked })" />启用</label>
+            <label><input type="checkbox" :checked="agent.autoReply !== false" @change="emit('updateAgent', agent.id, { autoReply: ($event.target as HTMLInputElement).checked })" />自动回复</label>
+          </div>
+        </article>
+      </div>
+
+      <div v-if="agents.length < 8 && availableProfiles?.length" class="add-agent">
+        <select aria-label="选择要添加的 Agent" @change="($event.target as HTMLSelectElement).value && emit('addAgent', ($event.target as HTMLSelectElement).value); ($event.target as HTMLSelectElement).value = ''">
+          <option value="">添加 Agent…</option>
+          <option v-for="profile in availableProfiles" :key="profile" :value="profile">{{ profile }}</option>
+        </select>
+      </div>
+    </section>
+
+    <section class="danger-zone">
+      <div><strong>归档群聊</strong><p>归档后不再出现在活跃列表,历史消息仍会保留。</p></div>
+      <button class="quiet-button" type="button" :disabled="busy" @click="emit('archiveRoom')"><AppIcon name="archive" :size="14" />归档</button>
+    </section>
+  </div>
+</template>
+
+<style scoped>
+.group-manager { height: 100%; overflow-y: auto; padding: 0 17px 24px; }
+header { display: flex; position: sticky; z-index: 3; top: 0; min-height: 62px; align-items: center; justify-content: space-between; background: var(--surface); }
+header > span { display: flex; flex-direction: column; } header small { color: var(--text-muted); font-size: 9px; } header strong { margin-top: 2px; font-size: 13px; font-weight: 620; }
+section { padding: 17px 0; border-top: 1px solid var(--line); } section:first-of-type { border-top: 0; }
+h3 { margin: 0 0 13px; color: var(--text-secondary); font-size: 10px; font-weight: 650; letter-spacing: .06em; text-transform: uppercase; } h3 em { color: var(--text-muted); font-style: normal; font-weight: 500; }
+label { display: flex; flex-direction: column; gap: 5px; margin: 0 0 11px; color: var(--text-muted); font-size: 10px; }
+input, textarea, select { width: 100%; padding: 8px 9px; border: 1px solid var(--line); border-radius: 9px; outline: 0; resize: vertical; background: var(--surface-soft); color: var(--text-primary); font-size: 11px; } input:focus, textarea:focus, select:focus { border-color: var(--line-strong); box-shadow: 0 0 0 3px var(--focus-ring); }
+.setting-row { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin: 13px 0; }.setting-row > span { display: flex; flex-direction: column; }.setting-row strong { font-size: 11px; }.setting-row small { margin-top: 2px; color: var(--text-muted); font-size: 9px; }
+.switch { position: relative; width: 34px; height: 20px; padding: 0; border: 0; border-radius: 999px; background: var(--surface-hover); cursor: pointer; }.switch i { position: absolute; top: 3px; left: 3px; width: 14px; height: 14px; border-radius: 50%; background: var(--text-muted); transition: transform 140ms ease, background 140ms ease; }.switch.active { background: var(--accent); }.switch.active i { background: var(--text-on-solid); transform: translateX(14px); }
+.rounds { flex-direction: row; align-items: center; justify-content: space-between; }.rounds input { width: 62px; text-align: center; }
+.agent-list { display: flex; flex-direction: column; gap: 6px; }.agent-list article { display: grid; grid-template-columns: 32px minmax(0,1fr) 28px 28px; align-items: center; gap: 7px; padding: 8px; border-radius: 10px; background: var(--surface-soft); }
+.agent-avatar { position: relative; display: grid; place-items: center; width: 32px; height: 32px; border-radius: 9px; background: var(--accent); color: var(--text-on-solid); font-size: 10px; font-weight: 700; }.agent-avatar i { position: absolute; right: -2px; bottom: -2px; width: 8px; height: 8px; border: 2px solid var(--surface-soft); border-radius: 50%; background: var(--text-muted); }.agent-avatar .status-working { background: var(--warning); }.agent-avatar .status-idle { background: var(--success); }.agent-avatar .status-error { background: var(--danger); }
+.agent-copy { display: flex; min-width: 0; flex-direction: column; }.agent-copy strong, .agent-copy small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.agent-copy strong { font-size: 11px; }.agent-copy small { margin-top: 2px; color: var(--text-muted); font-size: 9px; }
+.agent-action { display: grid; place-items: center; width: 28px; height: 28px; padding: 0; border: 0; border-radius: 7px; background: transparent; color: var(--text-muted); cursor: pointer; }.agent-action:hover { background: var(--surface-hover); color: var(--text-primary); }.agent-action.danger:hover { color: var(--danger); }.agent-action:disabled { cursor: not-allowed; opacity: .25; }
+.agent-settings { display: flex; grid-column: 2 / -1; align-items: center; gap: 15px; padding-top: 2px; }.agent-settings label { display: flex; flex-direction: row; align-items: center; gap: 5px; margin: 0; }.agent-settings input { width: auto; margin: 0; accent-color: var(--accent); }
+.add-agent { margin-top: 8px; }.add-agent select { cursor: pointer; }
+.danger-zone { display: flex; align-items: center; justify-content: space-between; gap: 12px; }.danger-zone strong { font-size: 11px; }.danger-zone p { margin: 3px 0 0; color: var(--text-muted); font-size: 9px; line-height: 1.5; }.danger-zone .quiet-button { display: flex; flex: 0 0 auto; gap: 6px; color: var(--danger); font-size: 10px; }
+</style>

+ 19 - 0
src/client/components/groups/types.ts

@@ -0,0 +1,19 @@
+export type UiAgent = {
+  id: string
+  name: string
+  profile?: string
+  enabled?: boolean
+  status?: 'idle' | 'working' | 'offline' | 'error'
+  autoReply?: boolean
+  replyRounds?: number
+}
+
+export type UiRoom = {
+  id: string
+  name: string
+  description?: string
+  archived?: boolean
+  memberIds?: string[]
+  autoReply?: boolean
+  replyRounds?: number
+}

+ 94 - 0
src/client/components/library/LibraryGrid.vue

@@ -0,0 +1,94 @@
+<script setup lang="ts">
+import AppIcon from '@/components/common/AppIcon.vue'
+import EmptyState from '@/components/common/EmptyState.vue'
+import type { UiLibraryItem } from './types'
+
+withDefaults(defineProps<{
+  items: UiLibraryItem[]
+  selectedId?: string
+  loading?: boolean
+  hasMore?: boolean
+  emptyTitle?: string
+  emptyDescription?: string
+  kind?: 'files' | 'artifacts'
+}>(), {
+  selectedId: '', loading: false, hasMore: false, emptyTitle: '暂无文件', emptyDescription: '这里会显示来自 Hermes 的文件。', kind: 'files',
+})
+
+const emit = defineEmits<{
+  select: [item: UiLibraryItem]
+  loadMore: []
+  addToComposer: [item: UiLibraryItem]
+  source: [item: UiLibraryItem]
+}>()
+
+function iconFor(item: UiLibraryItem) {
+  if (item.kind === 'document' || item.kind === 'pdf' || item.kind === 'code' || item.kind === 'text') return 'file'
+  return item.kind
+}
+
+function formatSize(size?: number) {
+  if (size === undefined) return ''
+  if (size < 1024) return `${size} B`
+  if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`
+  return `${(size / 1024 / 1024).toFixed(1)} MB`
+}
+
+function formatDate(value?: string | number | Date) {
+  if (!value) return ''
+  const date = value instanceof Date ? value : new Date(value)
+  if (Number.isNaN(date.getTime())) return ''
+  return new Intl.DateTimeFormat('zh-CN', { month: 'short', day: 'numeric' }).format(date)
+}
+</script>
+
+<template>
+  <div class="library-scroll">
+    <div v-if="loading && !items.length" class="library-skeleton">
+      <i v-for="n in 10" :key="n" :style="{ opacity: .95 - n * .04 }" />
+    </div>
+    <EmptyState v-else-if="!items.length" :icon="kind === 'files' ? 'files' : 'artifacts'" :title="emptyTitle" :description="emptyDescription" />
+    <div v-else class="library-grid">
+      <article
+        v-for="item in items"
+        :key="item.id"
+        :class="{ selected: item.id === selectedId }"
+        tabindex="0"
+        @click="emit('select', item)"
+        @keydown.enter="emit('select', item)"
+      >
+        <div class="library-thumb" :class="`library-thumb--${item.kind}`">
+          <img v-if="item.kind === 'image' && item.previewUrl" :src="item.previewUrl" :alt="item.name" loading="lazy" />
+          <video v-else-if="item.kind === 'video' && item.previewUrl" :src="item.previewUrl" muted preload="metadata" />
+          <span v-else><AppIcon :name="iconFor(item)" :size="26" /><em>{{ item.name.split('.').at(-1)?.slice(0, 5).toUpperCase() }}</em></span>
+          <div class="library-thumb__actions">
+            <button type="button" title="加入输入框" aria-label="加入输入框" @click.stop="emit('addToComposer', item)"><AppIcon name="plus" :size="14" /></button>
+            <button v-if="item.sourceSessionId" type="button" title="查看来源" aria-label="查看来源" @click.stop="emit('source', item)"><AppIcon name="external" :size="14" /></button>
+          </div>
+        </div>
+        <div class="library-copy">
+          <strong :title="item.name">{{ item.title || item.name }}</strong>
+          <span><small>{{ item.sourceLabel || formatSize(item.size) || item.kind }}</small><time>{{ formatDate(item.updatedAt || item.createdAt) }}</time></span>
+        </div>
+      </article>
+    </div>
+    <button v-if="hasMore" class="load-more" type="button" :disabled="loading" @click="emit('loadMore')">{{ loading ? '加载中…' : '加载更多' }}</button>
+  </div>
+</template>
+
+<style scoped>
+.library-scroll { min-height: 0; flex: 1; overflow-y: auto; padding: 22px 24px 40px; }
+.library-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(164px, 1fr)); gap: 20px 14px; }
+article { min-width: 0; border-radius: 12px; cursor: pointer; outline: 0; } article:focus-visible .library-thumb, article.selected .library-thumb { border-color: var(--line-strong); box-shadow: 0 0 0 3px var(--focus-ring); }
+.library-thumb { position: relative; display: grid; aspect-ratio: 1.18; place-items: center; overflow: hidden; border: 1px solid var(--line); border-radius: 12px; background: var(--surface-raised); color: var(--text-muted); transition: border-color 140ms ease, transform 160ms var(--ease-out), box-shadow 140ms ease; }
+article:hover .library-thumb { border-color: var(--line-strong); transform: translateY(-2px); box-shadow: 0 12px 26px rgba(0,0,0,.07); }
+.library-thumb img, .library-thumb video { width: 100%; height: 100%; object-fit: cover; }
+.library-thumb > span { display: flex; flex-direction: column; align-items: center; gap: 9px; }.library-thumb > span em { max-width: 74px; overflow: hidden; color: var(--text-muted); font-size: 8px; font-style: normal; letter-spacing: .1em; text-overflow: ellipsis; }
+.library-thumb--code, .library-thumb--text { background: linear-gradient(135deg, var(--surface-soft), var(--surface-raised)); }.library-thumb--link { border-style: dashed; }
+.library-thumb__actions { position: absolute; top: 6px; right: 6px; display: flex; gap: 4px; opacity: 0; transform: translateY(-3px); transition: opacity 120ms ease, transform 140ms var(--ease-out); }.library-thumb:hover .library-thumb__actions, article:focus-within .library-thumb__actions { opacity: 1; transform: translateY(0); }.library-thumb__actions button { display: grid; place-items: center; width: 28px; height: 28px; padding: 0; border: 1px solid rgba(255,255,255,.32); border-radius: 8px; background: rgba(25,25,24,.72); color: #fff; cursor: pointer; backdrop-filter: blur(8px); }
+.library-copy { padding: 8px 3px 0; }.library-copy strong { display: block; overflow: hidden; font-size: 11px; font-weight: 580; text-overflow: ellipsis; white-space: nowrap; }.library-copy > span { display: flex; margin-top: 4px; justify-content: space-between; gap: 7px; color: var(--text-muted); font-size: 9px; }.library-copy small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.library-copy time { flex: 0 0 auto; }
+.load-more { display: block; margin: 28px auto 0; padding: 7px 12px; border: 1px solid var(--line); border-radius: 9px; background: transparent; color: var(--text-secondary); cursor: pointer; font-size: 10px; }.load-more:hover { background: var(--surface-hover); }
+.library-skeleton { display: grid; grid-template-columns: repeat(auto-fill, minmax(164px, 1fr)); gap: 20px 14px; }.library-skeleton i { aspect-ratio: 1.18; border-radius: 12px; background: linear-gradient(90deg, var(--surface-soft), var(--surface-hover), var(--surface-soft)); background-size: 200% 100%; animation: shimmer 1.5s linear infinite; }
+@keyframes shimmer { to { background-position: -200% 0; } }
+@media (max-width: 600px) { .library-scroll { padding: 16px 12px 30px; }.library-grid, .library-skeleton { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px 10px; }.library-thumb__actions { opacity: 1; transform: none; }.library-copy strong { font-size: 10px; } }
+</style>

+ 39 - 0
src/client/components/library/LibrarySidebar.vue

@@ -0,0 +1,39 @@
+<script setup lang="ts">
+import AppIcon from '@/components/common/AppIcon.vue'
+import type { LibraryFilterOption } from './types'
+
+withDefaults(defineProps<{
+  search: string
+  filter: string
+  options: LibraryFilterOption[]
+  loading?: boolean
+  error?: string
+  progress?: string
+}>(), { loading: false, error: '', progress: '' })
+
+const emit = defineEmits<{ search: [value: string]; filter: [id: string]; refresh: [] }>()
+</script>
+
+<template>
+  <div class="library-sidebar">
+    <label class="library-search"><AppIcon name="search" :size="15" /><input :value="search" type="search" placeholder="搜索名称或来源" @input="emit('search', ($event.target as HTMLInputElement).value)" /></label>
+    <section>
+      <h3>类型</h3>
+      <button v-for="option in options" :key="option.id" type="button" :class="{ active: option.id === filter }" @click="emit('filter', option.id)">
+        <AppIcon :name="option.icon" :size="15" /><span>{{ option.label }}</span><small v-if="option.count !== undefined">{{ option.count }}</small>
+      </button>
+    </section>
+    <div class="library-sync">
+      <p v-if="error" class="error"><AppIcon name="alert" :size="13" />{{ error }}</p>
+      <p v-else-if="progress">{{ progress }}</p>
+      <button class="quiet-button" type="button" :disabled="loading" @click="emit('refresh')"><AppIcon name="refresh" :size="14" />{{ loading ? '同步中…' : '刷新' }}</button>
+    </div>
+  </div>
+</template>
+
+<style scoped>
+.library-sidebar { display: flex; height: 100%; min-height: 0; flex-direction: column; padding: 0 11px 13px; }
+.library-search { display: flex; min-height: 35px; align-items: center; gap: 8px; padding: 0 10px; border: 1px solid var(--line); border-radius: 10px; background: var(--surface-soft); color: var(--text-muted); }.library-search:focus-within { border-color: var(--line-strong); background: var(--surface-raised); box-shadow: 0 0 0 3px var(--focus-ring); }.library-search input { min-width: 0; flex: 1; border: 0; outline: 0; background: transparent; color: var(--text-primary); font-size: 12px; }
+section { margin-top: 18px; } h3 { margin: 0 7px 7px; color: var(--text-muted); font-size: 9px; font-weight: 650; letter-spacing: .07em; text-transform: uppercase; } section button { display: flex; width: 100%; min-height: 39px; align-items: center; gap: 9px; padding: 0 9px; border: 0; border-radius: 9px; background: transparent; color: var(--text-secondary); cursor: pointer; text-align: left; } section button:hover, section button.active { background: var(--surface-hover); color: var(--text-primary); } section button.active { font-weight: 610; } section button span { flex: 1; font-size: 11px; } section button small { color: var(--text-muted); font-size: 9px; }
+.library-sync { margin-top: auto; padding: 12px 4px 0; border-top: 1px solid var(--line); }.library-sync p { display: flex; align-items: flex-start; gap: 5px; margin: 0 0 8px; color: var(--text-muted); font-size: 9px; line-height: 1.5; }.library-sync p.error { color: var(--danger); }.library-sync .quiet-button { display: flex; width: 100%; gap: 6px; font-size: 10px; }
+</style>

+ 137 - 0
src/client/components/library/PreviewInspector.vue

@@ -0,0 +1,137 @@
+<script setup lang="ts">
+import { computed, nextTick, ref, watch } from 'vue'
+import AppIcon from '@/components/common/AppIcon.vue'
+import MarkdownContent from '@/components/messages/MarkdownContent.vue'
+import type { UiLibraryItem } from './types'
+
+const props = defineProps<{ item: UiLibraryItem }>()
+const emit = defineEmits<{ close: []; addToComposer: [item: UiLibraryItem]; source: [item: UiLibraryItem] }>()
+
+const canInline = computed(() => !!props.item.previewUrl)
+const isTextual = computed(() => ['text', 'code'].includes(props.item.kind))
+const sourceUrl = computed(() => props.item.previewUrl || props.item.downloadUrl || '')
+const officeRoot = ref<HTMLElement | null>(null)
+const pdfRoot = ref<HTMLElement | null>(null)
+const spreadsheetRows = ref<unknown[][]>([])
+const previewText = ref('')
+const previewLoading = ref(false)
+const previewError = ref('')
+const extension = computed(() => props.item.name.split('.').at(-1)?.toLocaleLowerCase() || '')
+const isWord = computed(() => extension.value === 'docx' || /wordprocessingml/.test(props.item.mimeType || ''))
+const isSpreadsheet = computed(() => ['xlsx', 'xls'].includes(extension.value) || /spreadsheetml|excel/.test(props.item.mimeType || ''))
+const isPdf = computed(() => props.item.kind === 'pdf' || extension.value === 'pdf' || props.item.mimeType === 'application/pdf')
+let previewGeneration = 0
+
+const PDF_WORKER_URL = new URL('pdfjs-dist/build/pdf.worker.min.mjs', import.meta.url).href
+
+async function loadRichPreview() {
+  const generation = ++previewGeneration
+  spreadsheetRows.value = []
+  previewText.value = props.item.textContent || ''
+  previewError.value = ''
+  if (!sourceUrl.value) return
+  if (!isWord.value && !isSpreadsheet.value && !isTextual.value && !isPdf.value) return
+  previewLoading.value = true
+  try {
+    const response = await fetch(sourceUrl.value, { credentials: 'same-origin' })
+    if (!response.ok) throw new Error(`预览读取失败(${response.status})`)
+    const blob = await response.blob()
+    if (generation !== previewGeneration) return
+    if (isPdf.value) {
+      const pdfjs = await import('pdfjs-dist')
+      pdfjs.GlobalWorkerOptions.workerSrc = PDF_WORKER_URL
+      await nextTick()
+      if (!pdfRoot.value) return
+      pdfRoot.value.replaceChildren()
+      const pdf = await pdfjs.getDocument({ data: await blob.arrayBuffer() }).promise
+      for (let index = 1; index <= Math.min(pdf.numPages, 24); index += 1) {
+        if (generation !== previewGeneration || !pdfRoot.value) return
+        const page = await pdf.getPage(index)
+        const base = page.getViewport({ scale: 1 })
+        const scale = Math.min(1.45, Math.max(.55, (pdfRoot.value.clientWidth - 24) / base.width))
+        const viewport = page.getViewport({ scale })
+        const pixelRatio = Math.min(2, window.devicePixelRatio || 1)
+        const wrapper = document.createElement('div')
+        wrapper.className = 'pdf-page'
+        const canvas = document.createElement('canvas')
+        canvas.width = Math.floor(viewport.width * pixelRatio)
+        canvas.height = Math.floor(viewport.height * pixelRatio)
+        canvas.style.width = `${viewport.width}px`
+        canvas.style.height = `${viewport.height}px`
+        wrapper.appendChild(canvas)
+        pdfRoot.value.appendChild(wrapper)
+        const context = canvas.getContext('2d')
+        if (context) await page.render({ canvas, canvasContext: context, viewport, transform: pixelRatio === 1 ? undefined : [pixelRatio, 0, 0, pixelRatio, 0, 0] }).promise
+      }
+    } else if (isWord.value) {
+      const { renderAsync } = await import('docx-preview')
+      await nextTick()
+      if (!officeRoot.value) return
+      officeRoot.value.replaceChildren()
+      await renderAsync(blob, officeRoot.value, undefined, { inWrapper: true, breakPages: true, ignoreWidth: false, ignoreHeight: false })
+    } else if (isSpreadsheet.value) {
+      const { default: readXlsxFile } = await import('read-excel-file/browser')
+      spreadsheetRows.value = await readXlsxFile(blob) as unknown[][]
+    } else if (isTextual.value && !previewText.value) {
+      previewText.value = await blob.text()
+    }
+  } catch (error) {
+    previewError.value = error instanceof Error ? error.message : '无法生成预览'
+  } finally {
+    previewLoading.value = false
+  }
+}
+
+watch(() => props.item.id, loadRichPreview, { immediate: true, flush: 'post' })
+
+function formatSize(size?: number) {
+  if (size === undefined) return '未知大小'
+  if (size < 1024) return `${size} B`
+  if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`
+  return `${(size / 1024 / 1024).toFixed(1)} MB`
+}
+</script>
+
+<template>
+  <div class="preview-inspector">
+    <header>
+      <button class="icon-button preview-back" type="button" aria-label="关闭预览" @click="emit('close')"><AppIcon name="chevron-left" /></button>
+      <div><small>预览</small><strong :title="item.name">{{ item.title || item.name }}</strong></div>
+    </header>
+
+    <div class="preview-stage" :class="`preview-stage--${item.kind}`">
+      <img v-if="item.kind === 'image' && canInline" :src="item.previewUrl" :alt="item.name" />
+      <video v-else-if="item.kind === 'video' && canInline" :src="item.previewUrl" controls playsinline />
+      <audio v-else-if="item.kind === 'audio' && canInline" :src="item.previewUrl" controls />
+      <div v-else-if="isPdf && canInline" ref="pdfRoot" class="preview-pdf" />
+      <div v-else-if="isWord" ref="officeRoot" class="preview-office" />
+      <div v-else-if="isSpreadsheet && spreadsheetRows.length" class="preview-sheet"><table><tbody><tr v-for="(row, rowIndex) in spreadsheetRows.slice(0, 200)" :key="rowIndex"><td v-for="(cell, cellIndex) in row.slice(0, 50)" :key="cellIndex">{{ cell }}</td></tr></tbody></table></div>
+      <div v-else-if="isTextual && previewText" class="preview-text"><pre v-if="item.kind === 'code'">{{ previewText }}</pre><MarkdownContent v-else :content="previewText" /></div>
+      <a v-else-if="item.kind === 'link' && (item.previewUrl || item.downloadUrl)" class="preview-link" :href="item.previewUrl || item.downloadUrl" target="_blank" rel="noopener noreferrer"><span><AppIcon name="link" :size="27" /></span><strong>{{ item.title || item.name }}</strong><small>在新窗口中打开</small></a>
+      <div v-else class="preview-unavailable"><span><AppIcon :name="previewError ? 'alert' : 'file'" :size="28" /></span><strong>{{ previewLoading ? '正在生成预览…' : previewError || '此格式暂不支持内嵌预览' }}</strong><p>{{ previewError ? '仍可下载文件后使用系统应用查看。' : '可下载文件,使用系统应用查看。' }}</p></div>
+    </div>
+
+    <section class="preview-meta">
+      <dl><div><dt>文件名</dt><dd>{{ item.name }}</dd></div><div><dt>类型</dt><dd>{{ item.mimeType || item.kind }}</dd></div><div><dt>大小</dt><dd>{{ formatSize(item.size) }}</dd></div><div v-if="item.sourceLabel"><dt>来源</dt><dd>{{ item.sourceLabel }}</dd></div></dl>
+      <div class="preview-actions">
+        <a v-if="item.downloadUrl || item.previewUrl" class="quiet-button" :href="item.downloadUrl || item.previewUrl" download><AppIcon name="download" :size="14" />下载</a>
+        <button class="solid-button" type="button" @click="emit('addToComposer', item)"><AppIcon name="plus" :size="14" />加入输入框</button>
+      </div>
+      <button v-if="item.sourceSessionId" class="source-link" type="button" @click="emit('source', item)"><AppIcon name="external" :size="13" />跳转到来源消息</button>
+    </section>
+  </div>
+</template>
+
+<style scoped>
+.preview-inspector { display: flex; height: 100%; flex-direction: column; overflow-y: auto; }
+header { display: flex; min-height: 62px; align-items: center; gap: 7px; padding: 9px 42px 9px 11px; border-bottom: 1px solid var(--line); } header > div { display: flex; min-width: 0; flex: 1; flex-direction: column; }.preview-back { display: none; } header small { color: var(--text-muted); font-size: 9px; } header strong { margin-top: 2px; overflow: hidden; font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }
+.preview-stage { display: grid; min-height: 280px; flex: 1 0 auto; place-items: center; overflow: hidden; background: var(--surface-soft); }.preview-stage img, .preview-stage video { display: block; width: 100%; height: auto; max-height: 58vh; object-fit: contain; }.preview-stage audio { width: calc(100% - 34px); }.preview-stage object { width: 100%; height: min(62vh, 660px); border: 0; }.preview-text { width: 100%; height: min(62vh, 660px); padding: 16px; overflow: auto; background: var(--surface-raised); }.preview-text pre { margin: 0; color: var(--text-secondary); font: 10px/1.65 var(--font-code); white-space: pre-wrap; overflow-wrap: anywhere; }
+.preview-office { width: 100%; height: min(62vh, 660px); overflow: auto; background: #e6e6e4; color: #111; }.preview-office :deep(.docx-wrapper) { padding: 18px 0; background: #e6e6e4; }.preview-office :deep(.docx) { margin-bottom: 14px; box-shadow: 0 4px 18px rgba(0,0,0,.12); }
+.preview-pdf { display: flex; width: 100%; height: min(62vh, 660px); align-items: center; flex-direction: column; gap: 12px; padding: 12px; overflow: auto; background: #dadad7; }.preview-pdf :deep(.pdf-page) { flex: 0 0 auto; overflow: hidden; background: #fff; box-shadow: 0 3px 14px rgba(0,0,0,.15); }.preview-pdf :deep(canvas) { display: block; }
+.preview-sheet { width: 100%; height: min(62vh, 660px); overflow: auto; background: var(--surface-raised); }.preview-sheet table { min-width: 100%; border-collapse: collapse; font-size: 9px; }.preview-sheet td { min-width: 72px; max-width: 240px; padding: 6px 8px; overflow: hidden; border: 1px solid var(--line); color: var(--text-secondary); text-overflow: ellipsis; white-space: nowrap; }.preview-sheet tr:first-child td { position: sticky; top: 0; background: var(--surface-soft); color: var(--text-primary); font-weight: 600; }
+.preview-link { display: flex; width: calc(100% - 34px); flex-direction: column; align-items: center; color: var(--text-secondary); text-decoration: none; text-align: center; }.preview-link > span, .preview-unavailable > span { display: grid; place-items: center; width: 52px; height: 52px; margin-bottom: 12px; border: 1px solid var(--line); border-radius: 16px; background: var(--surface-raised); }.preview-link strong { max-width: 100%; overflow: hidden; color: var(--text-primary); font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }.preview-link small { margin-top: 5px; color: var(--text-muted); font-size: 9px; }
+.preview-unavailable { display: flex; padding: 30px; flex-direction: column; align-items: center; color: var(--text-secondary); text-align: center; }.preview-unavailable strong { font-size: 11px; }.preview-unavailable p { margin: 5px 0 0; color: var(--text-muted); font-size: 9px; }
+.preview-meta { padding: 15px; border-top: 1px solid var(--line); } dl { display: flex; margin: 0; flex-direction: column; gap: 7px; } dl div { display: grid; grid-template-columns: 55px minmax(0, 1fr); gap: 8px; font-size: 9px; } dt { color: var(--text-muted); } dd { margin: 0; overflow: hidden; color: var(--text-secondary); text-overflow: ellipsis; white-space: nowrap; }
+.preview-actions { display: flex; gap: 7px; margin-top: 15px; }.preview-actions > * { display: flex; flex: 1; gap: 6px; font-size: 10px; text-decoration: none; }.source-link { display: flex; align-items: center; gap: 5px; margin: 11px auto 0; padding: 4px; border: 0; background: transparent; color: var(--text-muted); cursor: pointer; font-size: 9px; }.source-link:hover { color: var(--text-primary); }
+@media (max-width: 900px) { .preview-back { display: grid; } header { padding-right: 12px; } }
+</style>

+ 33 - 0
src/client/components/library/PreviewModal.vue

@@ -0,0 +1,33 @@
+<script setup lang="ts">
+import PreviewInspector from './PreviewInspector.vue'
+import type { UiLibraryItem } from './types'
+
+defineProps<{ item: UiLibraryItem }>()
+const emit = defineEmits<{ close: []; addToComposer: [item: UiLibraryItem]; source: [item: UiLibraryItem] }>()
+</script>
+
+<template>
+  <Teleport to="body">
+    <Transition name="preview-modal">
+      <div class="preview-modal-layer" @mousedown.self="emit('close')">
+        <section class="preview-modal" role="dialog" aria-modal="true" :aria-label="`预览 ${item.title || item.name}`">
+          <PreviewInspector :item="item" @close="emit('close')" @add-to-composer="emit('addToComposer', $event)" @source="emit('source', $event)" />
+        </section>
+      </div>
+    </Transition>
+  </Teleport>
+</template>
+
+<style scoped>
+.preview-modal-layer { position: fixed; z-index: 90; inset: 0; display: grid; place-items: center; padding: 28px; background: var(--scrim); backdrop-filter: blur(4px); }
+.preview-modal { display: flex; width: min(960px, calc(100vw - 56px)); max-height: min(820px, calc(100vh - 56px)); overflow: hidden; border: 1px solid var(--line); border-radius: 16px; background: var(--surface); box-shadow: 0 24px 70px rgba(0,0,0,.28); }
+.preview-modal :deep(.preview-inspector) { width: 100%; }
+.preview-modal :deep(.preview-back) { display: grid; }
+.preview-modal :deep(.preview-stage) { min-height: min(58vh, 560px); }
+.preview-modal-enter-active, .preview-modal-leave-active { transition: opacity 160ms ease; }
+.preview-modal-enter-active .preview-modal, .preview-modal-leave-active .preview-modal { transition: transform 180ms var(--ease-out), opacity 150ms ease; }
+.preview-modal-enter-from, .preview-modal-leave-to { opacity: 0; }
+.preview-modal-enter-from .preview-modal, .preview-modal-leave-to .preview-modal { opacity: 0; transform: translateY(8px) scale(.985); }
+@media (max-width: 600px) { .preview-modal-layer { padding: 0; }.preview-modal { width: 100vw; max-height: 100vh; min-height: 100vh; border: 0; border-radius: 0; }.preview-modal :deep(.preview-stage) { min-height: 46vh; } }
+@media (prefers-reduced-motion: reduce) { .preview-modal-enter-active, .preview-modal-leave-active, .preview-modal-enter-active .preview-modal, .preview-modal-leave-active .preview-modal { transition: none; } }
+</style>

+ 26 - 0
src/client/components/library/types.ts

@@ -0,0 +1,26 @@
+export type LibraryKind = 'image' | 'video' | 'audio' | 'pdf' | 'document' | 'code' | 'text' | 'file' | 'link'
+
+export type LibraryFilterOption = {
+  id: string
+  label: string
+  count?: number
+  icon: 'files' | 'image' | 'video' | 'audio' | 'file' | 'link' | 'artifacts'
+}
+
+export type UiLibraryItem = {
+  id: string
+  name: string
+  kind: LibraryKind
+  mimeType?: string
+  size?: number
+  createdAt?: string | number | Date
+  updatedAt?: string | number | Date
+  previewUrl?: string
+  downloadUrl?: string
+  sourceLabel?: string
+  sourceSessionId?: string
+  sourceMessageId?: string
+  sourceProfile?: string
+  textContent?: string
+  title?: string
+}

+ 39 - 0
src/client/components/messages/InteractionCard.vue

@@ -0,0 +1,39 @@
+<script setup lang="ts">
+import { ref } from 'vue'
+import AppIcon from '@/components/common/AppIcon.vue'
+import type { UiInteraction } from './types'
+
+defineProps<{ interaction: UiInteraction; busy?: boolean }>()
+const emit = defineEmits<{ approve: [approved: boolean]; clarify: [text: string] }>()
+const answer = ref('')
+</script>
+
+<template>
+  <section class="interaction-card" aria-live="polite">
+    <div class="interaction-card__heading">
+      <span><AppIcon :name="interaction.kind === 'approval' ? 'check' : 'chat'" :size="16" /></span>
+      <div><small>{{ interaction.kind === 'approval' ? '需要审批' : '需要补充信息' }}</small><strong>{{ interaction.title || interaction.prompt }}</strong></div>
+    </div>
+    <p v-if="interaction.title">{{ interaction.prompt }}</p>
+    <details v-if="interaction.detail"><summary>查看详情</summary><pre>{{ interaction.detail }}</pre></details>
+    <div v-if="interaction.kind === 'approval'" class="interaction-card__actions">
+      <button class="quiet-button" type="button" :disabled="busy" @click="emit('approve', false)">拒绝</button>
+      <button class="solid-button" type="button" :disabled="busy" @click="emit('approve', true)">允许</button>
+    </div>
+    <form v-else class="interaction-card__answer" @submit.prevent="answer.trim() && emit('clarify', answer.trim())">
+      <div v-if="interaction.options?.length" class="interaction-card__options">
+        <button v-for="option in interaction.options" :key="option" type="button" @click="answer = option">{{ option }}</button>
+      </div>
+      <textarea v-model="answer" rows="2" placeholder="输入回复" />
+      <button class="solid-button" type="submit" :disabled="busy || !answer.trim()">提交</button>
+    </form>
+  </section>
+</template>
+
+<style scoped>
+.interaction-card { width: min(560px, 100%); margin: 16px auto 6px; padding: 14px; border: 1px solid var(--line-strong); border-radius: 13px; background: var(--surface-raised); box-shadow: 0 12px 32px rgba(0,0,0,.06); }
+.interaction-card__heading { display: flex; align-items: flex-start; gap: 10px; }.interaction-card__heading > span { display: grid; place-items: center; width: 30px; height: 30px; border-radius: 9px; background: var(--accent); color: var(--text-on-solid); }.interaction-card__heading div { display: flex; flex: 1; flex-direction: column; }.interaction-card__heading small { color: var(--warning); font-size: 9px; font-weight: 650; letter-spacing: .07em; }.interaction-card__heading strong { margin-top: 2px; font-size: 12px; line-height: 1.45; }
+p { margin: 10px 0 0 40px; color: var(--text-secondary); font-size: 11px; line-height: 1.6; } details { margin: 9px 0 0 40px; color: var(--text-muted); font-size: 10px; } summary { cursor: pointer; } pre { max-height: 180px; overflow: auto; font: 9px/1.5 var(--font-code); white-space: pre-wrap; }
+.interaction-card__actions { display: flex; justify-content: flex-end; gap: 7px; margin-top: 13px; }.interaction-card__answer { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 8px; margin-top: 12px; }.interaction-card__answer textarea { width: 100%; resize: vertical; padding: 8px 9px; border: 1px solid var(--line); border-radius: 9px; outline: 0; background: var(--surface-soft); color: var(--text-primary); font-size: 12px; }.interaction-card__answer textarea:focus { border-color: var(--line-strong); }
+.interaction-card__options { display: flex; width: 100%; flex-wrap: wrap; gap: 5px; }.interaction-card__options button { padding: 5px 8px; border: 1px solid var(--line); border-radius: 8px; background: transparent; color: var(--text-secondary); cursor: pointer; font-size: 10px; }.interaction-card__options button:hover { background: var(--surface-hover); color: var(--text-primary); }
+</style>

+ 93 - 0
src/client/components/messages/MarkdownContent.vue

@@ -0,0 +1,93 @@
+<script setup lang="ts">
+import DOMPurify from 'dompurify'
+import MarkdownIt from 'markdown-it'
+import { computed, onMounted, onUpdated, ref } from 'vue'
+import AppIcon from '@/components/common/AppIcon.vue'
+
+const props = withDefaults(defineProps<{
+  content: string
+  streaming?: boolean
+}>(), { streaming: false })
+
+const root = ref<HTMLElement | null>(null)
+const copied = ref('')
+
+const md = new MarkdownIt({
+  html: false,
+  breaks: true,
+  linkify: true,
+  typographer: false,
+})
+
+const defaultLinkOpen = md.renderer.rules.link_open ?? ((tokens, index, options, _env, self) => self.renderToken(tokens, index, options))
+md.renderer.rules.link_open = (tokens, index, options, env, self) => {
+  const token = tokens[index]
+  token.attrSet('target', '_blank')
+  token.attrSet('rel', 'noopener noreferrer')
+  return defaultLinkOpen(tokens, index, options, env, self)
+}
+
+const rendered = computed(() => DOMPurify.sanitize(md.render(props.content || ''), {
+  USE_PROFILES: { html: true },
+  FORBID_TAGS: ['style', 'svg', 'math', 'form', 'input', 'button', 'iframe', 'object', 'embed'],
+  FORBID_ATTR: ['style', 'onerror', 'onclick', 'onload'],
+  ALLOW_UNKNOWN_PROTOCOLS: false,
+}))
+
+function decorateCodeBlocks() {
+  if (!root.value) return
+  root.value.querySelectorAll('pre').forEach((pre, index) => {
+    if (pre.querySelector(':scope > .code-copy')) return
+    const button = document.createElement('button')
+    button.type = 'button'
+    button.className = 'code-copy'
+    button.textContent = '复制'
+    button.setAttribute('aria-label', '复制代码')
+    button.addEventListener('click', async () => {
+      const code = pre.querySelector('code')?.textContent ?? ''
+      await navigator.clipboard.writeText(code)
+      const key = `${index}:${code.length}`
+      copied.value = key
+      button.textContent = '已复制'
+      window.setTimeout(() => { if (copied.value === key) { copied.value = ''; button.textContent = '复制' } }, 1400)
+    })
+    pre.appendChild(button)
+  })
+}
+
+function onClick(event: MouseEvent) {
+  const link = (event.target as HTMLElement).closest('a')
+  if (!link) return
+  const href = link.getAttribute('href') || ''
+  if (/^(javascript|data|vbscript):/i.test(href)) event.preventDefault()
+}
+
+onMounted(decorateCodeBlocks)
+onUpdated(decorateCodeBlocks)
+</script>
+
+<template>
+  <div ref="root" class="markdown" :class="{ 'markdown--streaming': streaming }" @click="onClick" v-html="rendered" />
+  <AppIcon v-if="streaming" class="stream-caret" name="arrow-up" :size="0" />
+</template>
+
+<style scoped>
+.markdown { min-width: 0; color: inherit; font-size: 13px; line-height: 1.68; overflow-wrap: anywhere; }
+.markdown :deep(p) { margin: 0 0 .72em; }.markdown :deep(p:last-child) { margin-bottom: 0; }
+.markdown :deep(h1), .markdown :deep(h2), .markdown :deep(h3), .markdown :deep(h4) { margin: 1.2em 0 .5em; color: var(--text-primary); line-height: 1.3; letter-spacing: -.02em; }
+.markdown :deep(h1) { font-size: 1.45em; }.markdown :deep(h2) { font-size: 1.28em; }.markdown :deep(h3) { font-size: 1.13em; }
+.markdown :deep(ul), .markdown :deep(ol) { margin: .6em 0; padding-left: 1.55em; }.markdown :deep(li) { margin: .22em 0; }
+.markdown :deep(blockquote) { margin: .8em 0; padding: .1em 0 .1em 1em; border-left: 2px solid var(--line-strong); color: var(--text-secondary); }
+.markdown :deep(a) { color: var(--text-primary); text-decoration: underline; text-decoration-color: var(--line-strong); text-underline-offset: 3px; }.markdown :deep(a:hover) { text-decoration-color: currentColor; }
+.markdown :deep(code) { padding: .15em .35em; border-radius: 5px; background: var(--surface-soft); color: var(--text-primary); font-family: var(--font-code); font-size: .9em; }
+.markdown :deep(pre) { position: relative; margin: .85em 0; padding: 13px 14px; overflow: auto; border: 1px solid var(--line); border-radius: 11px; background: color-mix(in srgb, var(--surface-soft) 78%, var(--surface)); }
+.markdown :deep(pre code) { padding: 0; background: transparent; font-size: 11px; line-height: 1.6; white-space: pre; }
+.markdown :deep(.code-copy) { position: sticky; float: right; top: 0; margin: -7px -8px 4px 8px; padding: 3px 6px; border: 1px solid var(--line); border-radius: 6px; background: var(--surface-raised); color: var(--text-muted); cursor: pointer; font: 9px var(--font-ui); }
+.markdown :deep(table) { display: block; max-width: 100%; margin: .9em 0; overflow-x: auto; border-collapse: collapse; }.markdown :deep(th), .markdown :deep(td) { padding: 6px 9px; border: 1px solid var(--line); text-align: left; }.markdown :deep(th) { background: var(--surface-soft); font-weight: 600; }
+.markdown :deep(hr) { margin: 1.3em 0; border: 0; border-top: 1px solid var(--line); }
+.markdown :deep(img) { display: block; width: auto; max-width: min(100%, 560px); max-height: 360px; margin: .7em 0; border: 1px solid var(--line); border-radius: 10px; background: var(--surface-soft); object-fit: contain; }
+.markdown--streaming :deep(p:last-child)::after { content: ''; display: inline-block; width: 5px; height: 14px; margin-left: 3px; border-radius: 1px; background: currentColor; vertical-align: -2px; animation: caret 1s step-end infinite; }
+.stream-caret { display: none; }
+@keyframes caret { 50% { opacity: 0; } }
+@media (max-width: 600px) { .markdown :deep(img) { max-height: 280px; } }
+</style>

+ 243 - 0
src/client/components/messages/MessageTimeline.vue

@@ -0,0 +1,243 @@
+<script setup lang="ts">
+import { nextTick, onMounted, ref, watch } from 'vue'
+import AppIcon from '@/components/common/AppIcon.vue'
+import EmptyState from '@/components/common/EmptyState.vue'
+import InteractionCard from './InteractionCard.vue'
+import MarkdownContent from './MarkdownContent.vue'
+import ToolTrace from './ToolTrace.vue'
+import type { UiInteraction, UiMessage } from './types'
+
+const props = withDefaults(defineProps<{
+  messages: UiMessage[]
+  title?: string
+  subtitle?: string
+  emptyTitle?: string
+  emptyDescription?: string
+  emptyLogo?: boolean
+  transparentHeader?: boolean
+  forkSourceTitle?: string
+  loading?: boolean
+  loadingOlder?: boolean
+  hasOlder?: boolean
+  connected?: boolean
+  synced?: boolean
+  showTools?: boolean
+  interaction?: UiInteraction | null
+}>(), {
+  title: '',
+  subtitle: '',
+  emptyTitle: '开始一段新对话',
+  emptyDescription: '从下方输入框发送消息,或选择左侧的历史会话。',
+  emptyLogo: false,
+  transparentHeader: false,
+  forkSourceTitle: '',
+  loading: false,
+  loadingOlder: false,
+  hasOlder: false,
+  connected: false,
+  synced: false,
+  showTools: true,
+  interaction: null,
+})
+
+const emit = defineEmits<{
+  loadOlder: []
+  quote: [message: UiMessage]
+  branch: [message: UiMessage]
+  preview: [attachment: NonNullable<UiMessage['attachments']>[number]]
+  approve: [approved: boolean]
+  clarify: [text: string]
+}>()
+
+const scroller = ref<HTMLElement | null>(null)
+const pinnedToBottom = ref(true)
+const showJump = ref(false)
+
+function formatTime(value?: string | number | Date) {
+  if (!value) return ''
+  const date = value instanceof Date ? value : new Date(value)
+  if (Number.isNaN(date.getTime())) return ''
+  return new Intl.DateTimeFormat('zh-CN', { hour: '2-digit', minute: '2-digit' }).format(date)
+}
+
+function onScroll() {
+  const el = scroller.value
+  if (!el) return
+  const distance = el.scrollHeight - el.scrollTop - el.clientHeight
+  pinnedToBottom.value = distance < 100
+  showJump.value = distance > 240
+}
+
+function scrollToBottom(behavior: ScrollBehavior = 'smooth') {
+  const el = scroller.value
+  if (!el) return
+  el.scrollTo({ top: el.scrollHeight, behavior })
+}
+
+function scrollToMessage(id: string): boolean {
+  const root = scroller.value
+  if (!root) return false
+  const message = root.querySelector<HTMLElement>(`[data-message-id="${CSS.escape(id)}"]`)
+  if (!message) return false
+  message.scrollIntoView({ block: 'center', behavior: 'smooth' })
+  message.classList.remove('message--revealed')
+  requestAnimationFrame(() => message.classList.add('message--revealed'))
+  window.setTimeout(() => message.classList.remove('message--revealed'), 1800)
+  return true
+}
+
+async function copyMessage(message: UiMessage) {
+  await navigator.clipboard.writeText(message.content)
+}
+
+watch(() => props.messages.length, async () => {
+  if (!pinnedToBottom.value) return
+  await nextTick()
+  scrollToBottom('auto')
+})
+
+watch(() => props.messages.at(-1)?.content, async () => {
+  if (!pinnedToBottom.value) return
+  await nextTick()
+  scrollToBottom('auto')
+})
+
+onMounted(() => nextTick(() => scrollToBottom('auto')))
+
+defineExpose({ scrollToMessage, scrollToBottom })
+</script>
+
+<template>
+  <section class="timeline-frame" :class="{ 'timeline-frame--transparent': transparentHeader }">
+    <header v-if="title || transparentHeader" class="timeline-header" :class="{ 'timeline-header--transparent': transparentHeader }">
+      <div v-if="!transparentHeader"><h2>{{ title }}</h2><p v-if="subtitle">{{ subtitle }}</p></div>
+      <div v-if="!transparentHeader" class="timeline-state" :title="connected ? (synced ? '实时连接与历史均已同步' : '实时连接已就绪,历史同步中') : '实时连接未就绪'">
+        <span class="status-dot" :class="connected ? (synced ? 'status-dot--online' : 'status-dot--working') : ''" />
+        {{ connected ? (synced ? '已同步' : '同步中') : '离线' }}
+      </div>
+      <slot name="header-actions" />
+    </header>
+
+    <div ref="scroller" class="timeline" @scroll.passive="onScroll">
+      <div v-if="loading && !messages.length" class="message-skeletons" aria-label="正在加载消息">
+        <div v-for="n in 4" :key="n" :class="{ user: n % 2 === 0 }"><i /><span /></div>
+      </div>
+      <div v-else-if="!messages.length && emptyLogo" class="new-chat-empty">
+        <img class="new-chat-empty__logo" src="/brand/AppIcon-1024.png" alt="" aria-hidden="true" />
+        <p>聊点什么</p>
+      </div>
+      <EmptyState v-else-if="!messages.length" icon="chat" :title="emptyTitle" :description="emptyDescription" />
+      <div v-else class="message-stack">
+        <div v-if="forkSourceTitle" class="fork-divider" role="separator">
+          <span><AppIcon name="branch" :size="13" />FORK 来源 <strong>{{ forkSourceTitle }}</strong></span>
+        </div>
+        <button v-if="hasOlder" class="load-older" type="button" :disabled="loadingOlder" @click="emit('loadOlder')">
+          {{ loadingOlder ? '正在加载…' : '加载更早消息' }}
+        </button>
+
+        <article
+          v-for="message in messages"
+          :key="message.id"
+          :data-message-id="message.id"
+          class="message"
+          :class="[`message--${message.role}`, { 'message--failed': message.status === 'failed' }]"
+        >
+          <div v-if="message.role !== 'user'" class="message__avatar">{{ (message.author || message.profile || (message.role === 'assistant' ? '夭' : '系')).slice(0, 1).toUpperCase() }}</div>
+          <div class="message__body">
+            <div class="message__meta">
+              <strong>{{ message.role === 'user' ? '你' : message.author || message.profile || (message.role === 'assistant' ? 'Agent' : '系统') }}</strong>
+              <time>{{ formatTime(message.createdAt) }}</time>
+              <span v-if="message.status && !['settled', 'streaming'].includes(message.status)">{{ { preparing: '准备中', attached: '附件已就绪', pending: '等待回执', accepted: '已接收', streaming: '生成中', settled: '已完成', failed: '发送失败', 'unknown-receipt': '回执未知' }[message.status] }}</span>
+            </div>
+
+            <details v-if="message.reasoning" class="message__reasoning">
+              <summary><AppIcon name="brain" :size="13" />思考过程 · {{ message.reasoning.length }} 字</summary>
+              <MarkdownContent :content="message.reasoning" />
+            </details>
+
+            <div class="message__content">
+              <MarkdownContent :content="message.content" :streaming="message.status === 'streaming'" />
+            </div>
+
+            <div v-if="message.attachments?.length" class="message__attachments">
+              <button v-for="attachment in message.attachments" :key="attachment.id" type="button" @click="emit('preview', attachment)">
+                <img v-if="attachment.kind === 'image' && attachment.url" :src="attachment.url" :alt="attachment.name" />
+                <span v-else><AppIcon :name="attachment.kind || 'file'" :size="17" /></span>
+                <strong>{{ attachment.name }}</strong>
+              </button>
+            </div>
+
+            <div v-if="showTools && message.tools?.length" class="message__tools">
+              <ToolTrace v-for="tool in message.tools" :key="tool.id" :tool="tool" />
+            </div>
+
+            <p v-if="message.error" class="message__error"><AppIcon name="alert" :size="13" />{{ message.error }}</p>
+
+            <div class="message__actions">
+              <button type="button" title="复制" aria-label="复制消息" @click="copyMessage(message)"><AppIcon name="copy" :size="13" /></button>
+              <button type="button" title="引用" aria-label="引用消息" @click="emit('quote', message)"><AppIcon name="quote" :size="13" /></button>
+              <button v-if="message.role === 'assistant'" type="button" title="从这里分支" aria-label="从这里分支" @click="emit('branch', message)"><AppIcon name="branch" :size="13" /></button>
+            </div>
+          </div>
+        </article>
+
+        <InteractionCard
+          v-if="interaction"
+          :interaction="interaction"
+          @approve="emit('approve', $event)"
+          @clarify="emit('clarify', $event)"
+        />
+      </div>
+    </div>
+
+    <Transition name="view-fade">
+      <button v-if="showJump" class="jump-bottom" type="button" @click="scrollToBottom()">
+        <AppIcon name="chevron-down" :size="15" />回到底部
+      </button>
+    </Transition>
+  </section>
+</template>
+
+<style scoped>
+.timeline-frame { position: relative; display: flex; min-width: 0; min-height: 0; flex: 1; flex-direction: column; }
+.timeline-header { display: flex; z-index: 6; min-height: 62px; align-items: center; gap: 13px; padding: 10px 18px; border-bottom: 1px solid var(--line); background: color-mix(in srgb, var(--canvas) 92%, transparent); backdrop-filter: blur(16px); }
+.timeline-header--transparent { position: absolute; top: 0; right: 0; left: 0; min-height: 0; justify-content: flex-end; padding: 11px 14px; border: 0; background: transparent; backdrop-filter: none; pointer-events: none; }
+.timeline-header--transparent > * { pointer-events: auto; }
+.timeline-header > div:first-child { min-width: 0; flex: 1; }.timeline-header h2 { overflow: hidden; margin: 0; font-size: 14px; font-weight: 630; letter-spacing: -.02em; text-overflow: ellipsis; white-space: nowrap; }.timeline-header p { overflow: hidden; margin: 3px 0 0; color: var(--text-muted); font-size: 9px; text-overflow: ellipsis; white-space: nowrap; }
+.timeline-state { display: flex; align-items: center; gap: 6px; color: var(--text-muted); font-size: 9px; }
+.timeline { min-height: 0; flex: 1; overflow-y: auto; overscroll-behavior: contain; background: var(--conversation-canvas); }
+.message-stack { width: min(780px, 100%); min-height: 100%; margin: 0 auto; padding: 24px 24px 36px; }
+.timeline-frame--transparent .message-stack { padding-top: 58px; }
+.load-older { display: block; margin: 0 auto 18px; padding: 5px 9px; border: 1px solid var(--line); border-radius: 8px; background: transparent; color: var(--text-muted); cursor: pointer; font-size: 9px; }.load-older:hover { background: var(--surface-soft); color: var(--text-secondary); }
+.message { position: relative; display: flex; max-width: 100%; gap: 10px; margin: 0 0 21px; }
+.message__avatar { display: grid; place-items: center; width: 27px; height: 27px; flex: 0 0 27px; border-radius: 9px; background: var(--accent); color: var(--text-on-solid); font-size: 10px; font-weight: 700; }
+.message__body { position: relative; min-width: 0; max-width: min(680px, calc(100% - 37px)); }
+.message__meta { display: flex; min-height: 20px; align-items: center; gap: 7px; color: var(--text-muted); font-size: 9px; }.message__meta strong { color: var(--text-primary); font-size: 11px; font-weight: 600; }.message__meta span { color: var(--warning); }
+.message__content { color: var(--text-primary); }
+.message--user { justify-content: flex-end; margin-top: 27px; }
+.message--user .message__body { max-width: min(610px, 76%); padding: 11px 15px; border-radius: 20px 20px 6px 20px; background: #eceff3; }
+.dark .message--user .message__body { background: #2d323a; }
+.message--user .message__meta { display: none; }
+.message--system { justify-content: center; }.message--system .message__avatar, .message--system .message__meta { display: none; }.message--system .message__body { max-width: 82%; padding: 6px 10px; border: 1px solid var(--line); border-radius: 9px; color: var(--text-muted); text-align: center; }
+.message__reasoning { max-width: 420px; margin: 3px 0 10px; padding: 0 0 8px; border: 0; border-bottom: 1px dashed var(--line-strong); color: var(--text-secondary); font-size: 11px; }.message__reasoning summary { display: flex; align-items: center; gap: 5px; list-style: none; color: var(--text-muted); cursor: pointer; font-size: 9px; }.message__reasoning summary::-webkit-details-marker { display: none; }.message__reasoning summary::before { content: '›'; color: var(--text-muted); font-size: 14px; line-height: 1; transition: transform 120ms ease; }.message__reasoning[open] summary::before { transform: rotate(90deg); }.message__reasoning :deep(.markdown) { margin-top: 8px; }
+.fork-divider { display: flex; align-items: center; gap: 12px; margin: 8px 0 28px; color: var(--text-muted); font-size: 10px; }.fork-divider::before, .fork-divider::after { height: 1px; flex: 1; background: var(--line); content: ''; }.fork-divider > span { display: inline-flex; align-items: center; gap: 6px; padding: 5px 10px; border: 1px solid var(--line); border-radius: 999px; background: var(--surface-raised); white-space: nowrap; }.fork-divider strong { max-width: 180px; overflow: hidden; color: var(--text-secondary); text-overflow: ellipsis; }
+.message__attachments { display: flex; margin-top: 8px; flex-wrap: wrap; gap: 7px; }.message__attachments button { display: flex; min-width: 90px; max-width: 210px; min-height: 42px; align-items: center; gap: 7px; padding: 5px 8px; overflow: hidden; border: 1px solid var(--line); border-radius: 9px; background: var(--surface); color: var(--text-secondary); cursor: pointer; }.message__attachments button:hover { border-color: var(--line-strong); }.message__attachments img { width: 48px; height: 48px; margin: -5px 0 -5px -8px; object-fit: cover; }.message__attachments button > span { display: grid; place-items: center; width: 25px; height: 25px; border-radius: 7px; background: var(--surface-soft); }.message__attachments strong { min-width: 0; overflow: hidden; font-size: 10px; font-weight: 520; text-overflow: ellipsis; white-space: nowrap; }
+.message__tools { margin-top: 9px; }.message__error { display: flex; align-items: center; gap: 5px; margin: 7px 0 0; color: var(--danger); font-size: 9px; }
+.message__actions { display: flex; position: absolute; left: -3px; top: 100%; gap: 2px; padding-top: 2px; opacity: 0; transition: opacity 120ms ease; }.message:hover .message__actions, .message:focus-within .message__actions { opacity: 1; }.message--user .message__actions { right: 0; left: auto; }.message__actions button { display: grid; place-items: center; width: 24px; height: 24px; padding: 0; border: 0; border-radius: 7px; background: transparent; color: var(--text-muted); cursor: pointer; }.message__actions button:hover { background: var(--surface-soft); color: var(--text-primary); }
+.message--failed .message__body { border-color: color-mix(in srgb, var(--danger) 35%, var(--line)); }
+.message--revealed .message__body { animation: reveal-message 1.8s ease; }
+@keyframes reveal-message { 0%, 28% { box-shadow: 0 0 0 5px var(--focus-ring); } 100% { box-shadow: 0 0 0 0 transparent; } }
+.jump-bottom { display: flex; position: absolute; z-index: 8; bottom: 8px; left: 50%; align-items: center; gap: 5px; padding: 6px 10px; border: 1px solid var(--line); border-radius: 999px; background: var(--surface-raised); color: var(--text-secondary); cursor: pointer; font-size: 9px; box-shadow: 0 6px 18px rgba(0,0,0,.1); transform: translateX(-50%); }
+.new-chat-empty { display: flex; min-height: 100%; align-items: center; justify-content: center; gap: 18px; padding: 12vh 24px; box-sizing: border-box; flex-direction: column; color: var(--text-muted); text-align: center; }
+.new-chat-empty__logo { display: block; width: min(180px, 34vw); height: auto; opacity: .18; filter: grayscale(1); }
+.new-chat-empty p { margin: 0; color: var(--text-secondary); font-size: 17px; font-weight: 520; letter-spacing: .02em; }
+.message-skeletons { width: min(760px, 100%); margin: 0 auto; padding: 35px 24px; }.message-skeletons div { display: flex; gap: 10px; margin: 0 0 25px; }.message-skeletons div.user { justify-content: flex-end; }.message-skeletons i { width: 27px; height: 27px; border-radius: 9px; background: var(--surface-hover); }.message-skeletons span { width: min(480px, 70%); height: 64px; border-radius: 12px; background: linear-gradient(90deg, var(--surface-soft), var(--surface-hover), var(--surface-soft)); background-size: 200% 100%; animation: shimmer 1.5s linear infinite; }.message-skeletons .user i { display: none; }.message-skeletons .user span { width: 44%; height: 46px; }
+@keyframes shimmer { to { background-position: -200% 0; } }
+
+@media (max-width: 768px) {
+  .timeline-header { min-height: 54px; padding: 8px 12px; }.timeline-state { font-size: 0; }.timeline-state .status-dot { width: 7px; height: 7px; }
+  .new-chat-empty { gap: 15px; padding-block: 9vh; }.new-chat-empty__logo { width: min(150px, 42vw); }.new-chat-empty p { font-size: 16px; }
+  .message-stack { padding: 18px 14px 30px; }.message { margin-bottom: 18px; }.message__body { max-width: calc(100% - 37px); }.message--user .message__body { max-width: 88%; }.message__content :deep(.markdown) { font-size: 13px; }
+  .message__actions { opacity: .65; }.message__meta time { display: none; }
+}
+</style>

+ 39 - 0
src/client/components/messages/ToolTrace.vue

@@ -0,0 +1,39 @@
+<script setup lang="ts">
+import { computed, ref } from 'vue'
+import AppIcon from '@/components/common/AppIcon.vue'
+import type { UiToolCall } from './types'
+
+const props = defineProps<{ tool: UiToolCall }>()
+const open = ref(props.tool.status === 'error')
+const statusLabel = computed(() => ({ running: '运行中', success: '完成', error: '失败', pending: '等待' })[props.tool.status])
+const detail = computed(() => {
+  const value = props.tool.output ?? props.tool.input
+  if (typeof value === 'string') return value
+  try { return JSON.stringify(value, null, 2) } catch { return String(value ?? '') }
+})
+</script>
+
+<template>
+  <div class="tool-trace" :class="`tool-trace--${tool.status}`">
+    <button type="button" @click="open = !open">
+      <span class="tool-trace__icon"><AppIcon name="tools" :size="14" /></span>
+      <span class="tool-trace__copy"><strong>{{ tool.name }}</strong><small>{{ statusLabel }}<template v-if="tool.durationMs"> · {{ tool.durationMs }} ms</template></small></span>
+      <span v-if="tool.status === 'running'" class="tool-trace__pulse" />
+      <AppIcon name="chevron-down" :size="13" :class="{ rotated: open }" />
+    </button>
+    <pre v-if="open && detail">{{ detail }}</pre>
+  </div>
+</template>
+
+<style scoped>
+.tool-trace { margin: 7px 0; border: 1px solid var(--line); border-radius: 10px; background: var(--surface); overflow: hidden; }
+.tool-trace > button { display: flex; width: 100%; min-height: 42px; align-items: center; gap: 9px; padding: 6px 9px; border: 0; background: transparent; color: var(--text-secondary); cursor: pointer; text-align: left; }
+.tool-trace > button:hover { background: var(--surface-soft); }
+.tool-trace__icon { display: grid; place-items: center; width: 26px; height: 26px; border-radius: 7px; background: var(--surface-soft); }
+.tool-trace__copy { display: flex; min-width: 0; flex: 1; flex-direction: column; }.tool-trace__copy strong { color: var(--text-primary); font-size: 10px; font-weight: 580; }.tool-trace__copy small { margin-top: 2px; color: var(--text-muted); font-size: 9px; }
+.tool-trace__pulse { width: 5px; height: 5px; border-radius: 50%; background: var(--warning); animation: pulse 1s ease infinite; }
+.rotated { transform: rotate(180deg); }.app-icon { transition: transform 140ms ease; }
+pre { max-height: 260px; margin: 0; padding: 10px 12px; overflow: auto; border-top: 1px solid var(--line); background: var(--surface-soft); color: var(--text-secondary); font: 10px/1.55 var(--font-code); white-space: pre-wrap; overflow-wrap: anywhere; }
+.tool-trace--error { border-color: color-mix(in srgb, var(--danger) 30%, var(--line)); }
+@keyframes pulse { 50% { opacity: .3; } }
+</style>

+ 39 - 0
src/client/components/messages/types.ts

@@ -0,0 +1,39 @@
+export type UiToolCall = {
+  id: string
+  name: string
+  status: 'running' | 'success' | 'error' | 'pending'
+  input?: unknown
+  output?: unknown
+  durationMs?: number
+}
+
+export type UiMessageAttachment = {
+  id: string
+  name: string
+  kind?: 'image' | 'video' | 'audio' | 'file'
+  url?: string
+  size?: number
+}
+
+export type UiMessage = {
+  id: string
+  role: 'user' | 'assistant' | 'system' | 'tool'
+  author?: string
+  content: string
+  reasoning?: string
+  createdAt?: string | number | Date
+  status?: 'preparing' | 'attached' | 'pending' | 'accepted' | 'streaming' | 'settled' | 'failed' | 'unknown-receipt'
+  error?: string
+  attachments?: UiMessageAttachment[]
+  tools?: UiToolCall[]
+  profile?: string
+}
+
+export type UiInteraction = {
+  id: string
+  kind: 'approval' | 'clarification'
+  title?: string
+  prompt: string
+  options?: string[]
+  detail?: string
+}

+ 47 - 0
src/client/components/workspace/WorkspaceView.vue

@@ -0,0 +1,47 @@
+<script setup lang="ts">
+import { computed } from 'vue'
+import WorkspaceShell from '@/components/app/WorkspaceShell.vue'
+import { useAuthStore } from '@/stores/auth'
+import { useThemeStore } from '@/stores/theme'
+
+withDefaults(defineProps<{
+  sidebarTitle: string
+  sidebarSubtitle?: string
+  inspectorOpen?: boolean
+}>(), { sidebarSubtitle: '', inspectorOpen: false })
+
+const emit = defineEmits<{ closeInspector: [] }>()
+const auth = useAuthStore()
+const theme = useThemeStore()
+
+const userName = computed(() => auth.user?.displayName || auth.user?.username || '')
+const profileName = computed(() => auth.activeProfileName)
+const profileNames = computed(() => auth.profiles.map(profile => profile.agentName || profile.displayName || profile.name))
+const profileNameMap = computed(() => new Map(auth.profiles.map(profile => [profile.agentName || profile.displayName || profile.name, profile.name])))
+
+async function logout() { await auth.logout() }
+function selectProfile(label: string) { auth.selectProfile(profileNameMap.value.get(label) || label) }
+</script>
+
+<template>
+  <WorkspaceShell
+    :user-name="userName"
+    :profile-name="auth.activeProfile?.agentName || auth.activeProfile?.displayName || profileName"
+    :profiles="profileNames"
+    :theme="theme.resolvedTheme"
+    :insecure-transport="auth.insecureLan"
+    :sidebar-title="sidebarTitle"
+    :sidebar-subtitle="sidebarSubtitle"
+    :inspector-open="inspectorOpen"
+    @logout="logout"
+    @toggle-theme="theme.toggle"
+    @select-profile="selectProfile"
+    @close-inspector="emit('closeInspector')"
+  >
+    <template #sidebar-action><slot name="sidebar-action" /></template>
+    <template #sidebar><slot name="sidebar" /></template>
+    <template #mobile-sidebar><slot name="sidebar" /></template>
+    <template #default><slot /></template>
+    <template #inspector><slot name="inspector" /></template>
+  </WorkspaceShell>
+</template>

+ 29 - 0
src/client/components/workspace/pendingComposer.ts

@@ -0,0 +1,29 @@
+import type { UiLibraryItem } from '@/components/library/types'
+
+const KEY = 'hermes-yaoyao:pending-composer-item'
+
+type PendingItem = { name: string; url: string; mimeType?: string }
+
+export function queueLibraryItemForComposer(item: UiLibraryItem): boolean {
+  const url = item.downloadUrl || item.previewUrl
+  if (!url) return false
+  const pending: PendingItem = { name: item.name, url, mimeType: item.mimeType }
+  sessionStorage.setItem(KEY, JSON.stringify(pending))
+  return true
+}
+
+export async function consumeLibraryItemForComposer(): Promise<File | null> {
+  const raw = sessionStorage.getItem(KEY)
+  if (!raw) return null
+  sessionStorage.removeItem(KEY)
+  try {
+    const pending = JSON.parse(raw) as PendingItem
+    if (!pending.url || !pending.name) return null
+    const response = await fetch(pending.url, { credentials: 'same-origin' })
+    if (!response.ok) throw new Error(`下载文件失败(${response.status})`)
+    const blob = await response.blob()
+    return new File([blob], pending.name, { type: pending.mimeType || blob.type || 'application/octet-stream' })
+  } catch {
+    return null
+  }
+}

+ 217 - 0
src/client/components/workspace/viewModels.ts

@@ -0,0 +1,217 @@
+import type {
+  ApprovalRequest,
+  ChatMessage,
+  ClarificationRequest,
+  ConversationArtifact,
+  FileLibraryItem,
+  GroupAgent,
+  GroupInteraction,
+  GroupMessage,
+  GroupRoomDetail,
+  GroupRoomSummary,
+  SessionSummary,
+  ToolCall,
+} from '@shared/types'
+import type { SidebarItem } from '@/components/app/types'
+import type { UiAgent, UiRoom } from '@/components/groups/types'
+import type { UiLibraryItem } from '@/components/library/types'
+import type { UiInteraction, UiMessage, UiMessageAttachment, UiToolCall } from '@/components/messages/types'
+
+function record(value: unknown): Record<string, unknown> {
+  return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : {}
+}
+
+function string(value: unknown, fallback = ''): string {
+  return typeof value === 'string' ? value : fallback
+}
+
+function formatRelative(value: number): string {
+  const ms = value < 10_000_000_000 ? value * 1000 : value
+  const diff = Date.now() - ms
+  if (diff < 60_000) return '刚刚'
+  if (diff < 3_600_000) return `${Math.max(1, Math.floor(diff / 60_000))} 分钟`
+  if (diff < 86_400_000) return `${Math.max(1, Math.floor(diff / 3_600_000))} 小时`
+  const date = new Date(ms)
+  return `${date.getMonth() + 1}/${date.getDate()}`
+}
+
+function historySection(value: number, pinned = false): string | undefined {
+  if (pinned) return '已置顶'
+  const ms = value < 10_000_000_000 ? value * 1000 : value
+  const today = new Date()
+  today.setHours(0, 0, 0, 0)
+  const yesterday = new Date(today)
+  yesterday.setDate(yesterday.getDate() - 1)
+  if (ms >= today.getTime()) return '今天'
+  if (ms >= yesterday.getTime()) return '昨天'
+  return '更早'
+}
+
+export function sessionSidebarItem(session: SessionSummary, unread = 0, agentName = ''): SidebarItem {
+  return {
+    id: session.id,
+    title: session.title || '未命名会话',
+    subtitle: session.preview || `${session.messageCount} 条消息`,
+    meta: agentName || session.agent || formatRelative(session.updatedAt),
+    section: historySection(session.updatedAt, session.pinned),
+    icon: 'chat',
+    pinned: session.pinned,
+    unread,
+  }
+}
+
+function toolToUi(tool: ToolCall): UiToolCall {
+  const status: UiToolCall['status'] = tool.status === 'completed' ? 'success' : tool.status === 'failed' ? 'error' : tool.status
+  return {
+    id: tool.id,
+    name: tool.name,
+    status,
+    input: tool.arguments,
+    output: tool.error || tool.result || tool.preview,
+    durationMs: tool.durationSeconds ? Math.round(tool.durationSeconds * 1000) : undefined,
+  }
+}
+
+export function chatMessageToUi(message: ChatMessage): UiMessage {
+  const attachments: UiMessageAttachment[] | undefined = message.attachments?.map(attachment => ({
+    id: attachment.id,
+    name: attachment.name,
+    kind: attachment.kind === 'image' ? 'image' : 'file',
+    url: attachment.url,
+    size: attachment.size,
+  }))
+  return {
+    id: message.id,
+    role: message.role,
+    author: message.role === 'assistant' ? message.profile : undefined,
+    content: message.content,
+    reasoning: message.reasoning,
+    createdAt: message.timestamp < 10_000_000_000 ? message.timestamp * 1000 : message.timestamp,
+    status: message.isStreaming ? 'streaming' : message.stage,
+    error: message.error,
+    attachments,
+    tools: message.toolCalls?.map(toolToUi),
+    profile: message.profile,
+  }
+}
+
+export function chatInteraction(approval?: ApprovalRequest, clarification?: ClarificationRequest): UiInteraction | null {
+  if (approval) return {
+    id: approval.id,
+    kind: 'approval',
+    title: approval.toolName ? `允许 ${approval.toolName}` : '允许这项操作',
+    prompt: approval.message || 'Agent 请求执行一项需要确认的操作。',
+    options: approval.choices,
+    detail: JSON.stringify(approval.payload, null, 2),
+  }
+  if (clarification) return {
+    id: clarification.id,
+    kind: 'clarification',
+    prompt: clarification.question,
+    options: clarification.choices,
+    detail: JSON.stringify(clarification.payload, null, 2),
+  }
+  return null
+}
+
+export function roomSidebarItem(room: GroupRoomSummary): SidebarItem {
+  return {
+    id: room.id,
+    title: room.name || '未命名群聊',
+    subtitle: room.lastMessage?.content || `${room.agentCount} 个 Agent`,
+    meta: formatRelative(room.updatedAt),
+    section: historySection(room.updatedAt),
+    unread: room.unreadCount,
+    icon: 'groups',
+  }
+}
+
+export function groupMessageToUi(message: GroupMessage): UiMessage {
+  const tools = (message.toolState || []).map((entry, index) => {
+    const value = record(entry)
+    const rawStatus = string(value.status, 'success')
+    const status: UiToolCall['status'] = rawStatus === 'completed' ? 'success' : rawStatus === 'failed' ? 'error' : ['running', 'pending', 'success', 'error'].includes(rawStatus) ? rawStatus as UiToolCall['status'] : 'success'
+    return { id: string(value.id, `${message.id}:tool:${index}`), name: string(value.name ?? value.tool, '工具调用'), status, input: value.input ?? value.arguments, output: value.output ?? value.result }
+  })
+  return {
+    id: message.id,
+    role: message.senderKind === 'human' ? 'user' : message.senderKind === 'agent' ? 'assistant' : 'system',
+    author: message.senderName,
+    content: message.content,
+    reasoning: message.reasoning,
+    createdAt: message.createdAt < 10_000_000_000 ? message.createdAt * 1000 : message.createdAt,
+    status: message.status === 'completed' ? 'settled' : message.status === 'queued' ? 'pending' : message.status === 'streaming' ? 'streaming' : message.status === 'failed' ? 'failed' : message.status === 'unknown' ? 'unknown-receipt' : 'settled',
+    error: message.error,
+    tools,
+  }
+}
+
+export function groupInteraction(interaction?: GroupInteraction): UiInteraction | null {
+  if (!interaction || interaction.status !== 'pending') return null
+  const payload = record(interaction.payload)
+  const choices = Array.isArray(payload.choices) ? payload.choices.filter((choice): choice is string => typeof choice === 'string') : undefined
+  return {
+    id: interaction.id,
+    kind: interaction.kind === 'clarification' ? 'clarification' : 'approval',
+    title: string(payload.title || payload.tool_name),
+    prompt: string(payload.question || payload.message || payload.prompt, interaction.kind === 'clarification' ? 'Agent 需要补充信息' : 'Agent 请求执行操作'),
+    options: choices,
+    detail: JSON.stringify(payload, null, 2),
+  }
+}
+
+export function agentToUi(agent: GroupAgent): UiAgent {
+  return {
+    id: agent.id,
+    name: agent.displayName || agent.profile,
+    profile: agent.profile,
+    enabled: agent.enabled,
+    autoReply: agent.replyWithoutMention,
+    status: agent.status === 'running' || agent.status === 'queued' ? 'working' : agent.status === 'unknown' ? 'offline' : 'idle',
+  }
+}
+
+export function roomToUi(room: GroupRoomDetail): UiRoom {
+  return { id: room.id, name: room.name, archived: room.archived, memberIds: room.agents.map(agent => agent.id), replyRounds: room.maxReplyRounds }
+}
+
+export function fileToUi(item: FileLibraryItem): UiLibraryItem {
+  const kind: UiLibraryItem['kind'] = item.kind === 'document'
+    ? item.mimeType === 'application/pdf' ? 'pdf' : /text|json|xml|javascript|typescript|css|html/.test(item.mimeType) ? 'text' : 'document'
+    : item.kind === 'other' ? 'file' : item.kind
+  const origin = item.origins.at(-1)
+  return {
+    id: item.id,
+    name: item.name,
+    kind,
+    mimeType: item.mimeType,
+    size: item.size,
+    updatedAt: item.modifiedAt < 10_000_000_000 ? item.modifiedAt * 1000 : item.modifiedAt,
+    previewUrl: item.previewUrl,
+    downloadUrl: item.downloadUrl,
+    sourceLabel: origin?.sessionTitle || origin?.authorName || origin?.profile,
+    sourceSessionId: origin?.sessionId,
+    sourceMessageId: origin?.messageId,
+    sourceProfile: origin?.profile,
+  }
+}
+
+export function artifactToUi(item: ConversationArtifact): UiLibraryItem {
+  const raw = item.attachment
+  const kind: UiLibraryItem['kind'] = item.kind === 'link' ? 'link' : item.kind === 'image' ? 'image' : raw?.mimeType === 'application/pdf' ? 'pdf' : 'file'
+  return {
+    id: item.id,
+    name: item.label || item.value.split('/').at(-1) || '产物',
+    title: item.label,
+    kind,
+    mimeType: item.mimeType || raw?.mimeType,
+    size: raw?.size,
+    createdAt: item.timestamp < 10_000_000_000 ? item.timestamp * 1000 : item.timestamp,
+    previewUrl: raw?.url || item.value,
+    downloadUrl: raw?.url || (item.kind === 'file' ? item.value : undefined),
+    sourceLabel: item.sessionTitle,
+    sourceSessionId: item.sessionId,
+    sourceMessageId: item.messageId,
+    sourceProfile: item.profile,
+  }
+}

+ 10 - 0
src/client/env.d.ts

@@ -0,0 +1,10 @@
+/// <reference types="vite/client" />
+
+interface ImportMetaEnv {
+  readonly DEV: boolean
+  readonly PROD: boolean
+}
+
+interface ImportMeta {
+  readonly env: ImportMetaEnv
+}

+ 15 - 0
src/client/main.ts

@@ -0,0 +1,15 @@
+import { createApp } from 'vue'
+import { createPinia } from 'pinia'
+import App from './App.vue'
+import router from './router'
+import './styles/global.css'
+
+const app = createApp(App)
+app.config.errorHandler = (error, _instance, info) => {
+  console.error(`Vue render error (${info})`, error)
+}
+router.onError((error) => {
+  console.error('Router navigation error', error)
+})
+
+app.use(createPinia()).use(router).mount('#app')

+ 16 - 0
src/client/router/index.ts

@@ -0,0 +1,16 @@
+import { createRouter, createWebHistory } from 'vue-router'
+
+const router = createRouter({
+  history: createWebHistory(),
+  routes: [
+    { path: '/', redirect: '/chat' },
+    { path: '/chat/:sessionId?', name: 'chat', component: () => import('@/views/ChatView.vue') },
+    { path: '/groups/:roomId?', name: 'groups', component: () => import('@/views/GroupsView.vue') },
+    { path: '/files', name: 'files', component: () => import('@/views/FilesView.vue') },
+    { path: '/artifacts', redirect: '/chat' },
+    { path: '/:pathMatch(.*)*', redirect: '/chat' },
+  ],
+  scrollBehavior: () => ({ top: 0 }),
+})
+
+export default router

+ 17 - 0
src/client/stores/app.ts

@@ -0,0 +1,17 @@
+import { ref } from 'vue'
+import { defineStore } from 'pinia'
+
+export type AppSection = 'chat' | 'groups' | 'files' | 'artifacts'
+
+export const useAppStore = defineStore('app', () => {
+  const section = ref<AppSection>('chat')
+  const mobileDrawerOpen = ref(false)
+  const inspectorOpen = ref(false)
+
+  function navigate(next: AppSection): void {
+    section.value = next
+    mobileDrawerOpen.value = false
+  }
+
+  return { section, mobileDrawerOpen, inspectorOpen, navigate }
+})

+ 206 - 0
src/client/stores/artifacts.ts

@@ -0,0 +1,206 @@
+import { computed, ref } from 'vue'
+import { defineStore } from 'pinia'
+import type { ArtifactKind, ChatMessage, ConversationArtifact, FileLibraryItem, SessionSummary } from '@shared/types'
+import { getMessages, getSessions } from '@/api/sessions'
+import { filePreviewUrl, getFiles } from '@/api/files'
+import { ARTIFACT_EXTRACTOR_VERSION, extractArtifacts } from '@/utils/artifacts'
+import { ScopedCache } from '@/utils/cache'
+import { mergeChatMessages } from '@/utils/messageReducer'
+import { useAuthStore } from './auth'
+
+interface CachedArtifacts {
+  extractorVersion: number
+  fingerprint: string
+  artifacts: ConversationArtifact[]
+}
+
+const cache = new ScopedCache<CachedArtifacts>('artifacts-v1')
+
+function fingerprint(session: SessionSummary): string {
+  return `${session.messageCount}:${session.toolCallCount}:${session.updatedAt}`
+}
+
+async function parallelMap<T, R>(items: T[], limit: number, worker: (item: T) => Promise<R>): Promise<R[]> {
+  const result = new Array<R>(items.length)
+  let cursor = 0
+  await Promise.all(Array.from({ length: Math.min(limit, items.length) }, async () => {
+    while (cursor < items.length) {
+      const index = cursor++
+      result[index] = await worker(items[index])
+    }
+  }))
+  return result
+}
+
+export const useArtifactsStore = defineStore('artifacts', () => {
+  const auth = useAuthStore()
+  const artifacts = ref<ConversationArtifact[]>([])
+  const isLoading = ref(false)
+  const error = ref<string>()
+  const kindFilter = ref<ArtifactKind | 'all'>('all')
+  const searchQuery = ref('')
+  const profile = ref<string>()
+  const loadedSessionCount = ref(0)
+  const totalSessionCount = ref(0)
+  const failedSessionCount = ref(0)
+
+  const filteredArtifacts = computed(() => {
+    const query = searchQuery.value.trim().toLowerCase()
+    return artifacts.value.filter(artifact => {
+      if (kindFilter.value !== 'all' && artifact.kind !== kindFilter.value) return false
+      return !query || [artifact.label, artifact.value, artifact.sessionTitle, artifact.profile]
+        .some(value => value?.toLowerCase().includes(query))
+    })
+  })
+
+  function scope(selectedProfile?: string): string {
+    return `${auth.user?.id ?? 'anonymous'}:${selectedProfile ?? 'all'}`
+  }
+
+  async function allSessions(selectedProfile?: string): Promise<SessionSummary[]> {
+    const sessions: SessionSummary[] = []
+    const visited = new Set<string>()
+    let cursor: string | undefined
+    do {
+      const key = cursor ?? '<first-page>'
+      if (visited.has(key)) break
+      visited.add(key)
+      const page = await getSessions(selectedProfile, cursor, 100)
+      sessions.push(...page.items)
+      cursor = page.nextCursor ?? undefined
+    } while (cursor && sessions.length < 2_000)
+    return sessions
+  }
+
+  async function allMessages(session: SessionSummary): Promise<ChatMessage[]> {
+    let messages: ChatMessage[] = []
+    let offset = 0
+    const visited = new Set<number>()
+    while (!visited.has(offset) && visited.size < 10_000) {
+      visited.add(offset)
+      const page = await getMessages(session.id, offset, 500, session.profile)
+      messages = mergeChatMessages(messages, page.messages, 'prepend')
+      offset += page.returned
+      if (!page.hasMore || page.returned === 0) break
+    }
+    return messages
+  }
+
+  async function allFiles(selectedProfile?: string): Promise<FileLibraryItem[]> {
+    const files: FileLibraryItem[] = []
+    const profiles = selectedProfile
+      ? [selectedProfile]
+      : auth.profiles.map(item => item.name)
+    for (const profileName of profiles.length ? profiles : [undefined]) {
+      const visited = new Set<string>()
+      let cursor: string | undefined
+      do {
+        const key = cursor ?? '<first-page>'
+        if (visited.has(key)) break
+        visited.add(key)
+        const page = await getFiles({ profile: profileName, cursor, limit: 100 })
+        files.push(...page.items)
+        cursor = page.nextCursor ?? undefined
+      } while (cursor && files.length < 5_000)
+    }
+    return files
+  }
+
+  function resolvedPath(value: string): string {
+    if (!value.startsWith('file://')) return value
+    try { return decodeURIComponent(new URL(value).pathname) } catch { return value }
+  }
+
+  function enrichPreview(items: ConversationArtifact[], files: FileLibraryItem[]): ConversationArtifact[] {
+    const byPath = new Map<string, FileLibraryItem>()
+    for (const file of files) {
+      const profiles = new Set(file.origins.map(origin => origin.profile).filter(Boolean))
+      if (!profiles.size) profiles.add('')
+      for (const profileName of profiles) byPath.set(`${profileName}:${resolvedPath(file.path)}`, file)
+      for (const origin of file.origins) {
+        const profileName = origin.profile ?? ''
+        if (origin.originalPath) byPath.set(`${profileName}:${resolvedPath(origin.originalPath)}`, file)
+        if (origin.referencePath) byPath.set(`${profileName}:${resolvedPath(origin.referencePath)}`, file)
+      }
+    }
+    return items.map(artifact => {
+      if (artifact.kind === 'link') return artifact
+      const path = resolvedPath(artifact.value)
+      const file = byPath.get(`${artifact.profile ?? ''}:${path}`) ?? byPath.get(`:${path}`)
+      if (!file) return artifact
+      const kind = file.mimeType.startsWith('image/') ? 'image' : file.mimeType === 'application/pdf' ? 'pdf' : 'file'
+      return {
+        ...artifact,
+        mimeType: file.mimeType,
+        attachment: {
+          ...artifact.attachment,
+          id: `file-library:${file.id}`,
+          name: file.name,
+          mimeType: file.mimeType,
+          size: file.size,
+          path: file.path,
+          url: filePreviewUrl(file.id, file.origins.find(origin => origin.profile)?.profile),
+          kind,
+        },
+      }
+    })
+  }
+
+  async function load(force = false): Promise<void> {
+    if (isLoading.value) return
+    isLoading.value = true
+    artifacts.value = []
+    error.value = undefined
+    loadedSessionCount.value = 0
+    failedSessionCount.value = 0
+    const selectedProfile = profile.value || undefined
+    try {
+      const [sessions, files] = await Promise.all([
+        allSessions(selectedProfile),
+        allFiles(selectedProfile).catch(() => [] as FileLibraryItem[]),
+      ])
+      totalSessionCount.value = sessions.length
+      const batches = await parallelMap(sessions, 4, async session => {
+        const key = session.id
+        const cached = force ? undefined : await cache.get(scope(selectedProfile), key)
+        if (cached?.extractorVersion === ARTIFACT_EXTRACTOR_VERSION && cached.fingerprint === fingerprint(session)) {
+          loadedSessionCount.value += 1
+          return cached.artifacts
+        }
+        try {
+          const extracted = extractArtifacts(session, await allMessages(session))
+          await cache.set(scope(selectedProfile), key, {
+            extractorVersion: ARTIFACT_EXTRACTOR_VERSION,
+            fingerprint: fingerprint(session),
+            artifacts: extracted,
+          })
+          loadedSessionCount.value += 1
+          return extracted
+        } catch {
+          failedSessionCount.value += 1
+          loadedSessionCount.value += 1
+          return cached?.artifacts ?? []
+        }
+      })
+      artifacts.value = enrichPreview(batches.flat(), files).sort((a, b) => b.timestamp - a.timestamp)
+      if (failedSessionCount.value) error.value = `${failedSessionCount.value} 个会话暂时无法读取,已保留其可用缓存`
+    } catch (cause) {
+      error.value = cause instanceof Error ? cause.message : '产物库加载失败'
+      throw cause
+    } finally { isLoading.value = false }
+  }
+
+  const refresh = () => load(true)
+  function clear(): void {
+    artifacts.value = []
+    loadedSessionCount.value = 0
+    totalSessionCount.value = 0
+    failedSessionCount.value = 0
+    error.value = undefined
+  }
+
+  return {
+    artifacts, filteredArtifacts, isLoading, error, kindFilter, searchQuery, profile,
+    loadedSessionCount, totalSessionCount, failedSessionCount, load, refresh, clear,
+  }
+})

+ 99 - 0
src/client/stores/auth.ts

@@ -0,0 +1,99 @@
+import { computed, ref } from 'vue'
+import { defineStore } from 'pinia'
+import type { AuthStatus, BootstrapResponse, CurrentUser, Profile } from '@shared/types'
+import * as authApi from '@/api/auth'
+import { ApiError, clearApiSecurityContext, onApiUnauthorized } from '@/api/client'
+
+function message(error: unknown): string {
+  return error instanceof Error ? error.message : '认证请求失败'
+}
+
+export const useAuthStore = defineStore('auth', () => {
+  const status = ref<AuthStatus>('checking')
+  const user = ref<CurrentUser>()
+  const profiles = ref<Profile[]>([])
+  const activeProfileName = ref('')
+  const csrfToken = ref('')
+  const error = ref<string>()
+  const authRequired = ref(true)
+  const insecureLan = ref(false)
+  const groupUploadsEnabled = ref(false)
+
+  const activeProfile = computed(() => profiles.value.find(profile => profile.name === activeProfileName.value)
+    ?? profiles.value.find(profile => profile.isDefault)
+    ?? profiles.value[0])
+  const isAuthenticated = computed(() => status.value === 'authenticated')
+
+  function publish(response: BootstrapResponse): void {
+    authRequired.value = response.authRequired
+    csrfToken.value = response.csrfToken
+    insecureLan.value = Boolean(response.insecureLan)
+    groupUploadsEnabled.value = Boolean(response.groupUploadsEnabled)
+    profiles.value = response.profiles
+    user.value = response.user ?? (!response.authRequired
+      ? { id: 'local', username: '本机 Hermes', role: 'local' }
+      : undefined)
+    if (!profiles.value.some(profile => profile.name === activeProfileName.value)) {
+      activeProfileName.value = profiles.value.find(profile => profile.isDefault)?.name ?? profiles.value[0]?.name ?? ''
+    }
+    status.value = user.value ? 'authenticated' : 'anonymous'
+    error.value = undefined
+  }
+
+  function expire(): void {
+    if (status.value === 'anonymous' || status.value === 'checking') return
+    status.value = 'expired'
+    user.value = undefined
+    profiles.value = []
+    activeProfileName.value = ''
+    csrfToken.value = ''
+    clearApiSecurityContext()
+  }
+
+  onApiUnauthorized(expire)
+
+  async function bootstrap(): Promise<void> {
+    status.value = 'checking'
+    error.value = undefined
+    try { publish(await authApi.bootstrap()) }
+    catch (cause) {
+      if (cause instanceof ApiError && [401, 403].includes(cause.status)) status.value = 'anonymous'
+      else status.value = 'error'
+      error.value = message(cause)
+      user.value = undefined
+      profiles.value = []
+    }
+  }
+
+  async function login(input: authApi.LoginInput): Promise<void> {
+    status.value = 'authenticating'
+    error.value = undefined
+    try { publish(await authApi.login(input)) }
+    catch (cause) {
+      status.value = 'anonymous'
+      error.value = message(cause)
+      throw cause
+    }
+  }
+
+  async function logout(): Promise<void> {
+    try { await authApi.logout() } catch { /* local state still signs out */ }
+    status.value = 'anonymous'
+    user.value = undefined
+    profiles.value = []
+    activeProfileName.value = ''
+    csrfToken.value = ''
+    clearApiSecurityContext()
+    try { publish(await authApi.bootstrap()) } catch { status.value = 'anonymous' }
+  }
+
+  function selectProfile(name: string): void {
+    if (!profiles.value.some(profile => profile.name === name)) throw new Error(`未知 Profile:${name}`)
+    activeProfileName.value = name
+  }
+
+  return {
+    status, user, profiles, activeProfileName, activeProfile, csrfToken, error, authRequired, insecureLan, groupUploadsEnabled,
+    isAuthenticated, bootstrap, login, logout, selectProfile, expire,
+  }
+})

+ 707 - 0
src/client/stores/chat.ts

@@ -0,0 +1,707 @@
+import { computed, reactive, ref, watch } from 'vue'
+import { defineStore } from 'pinia'
+import type {
+  ChatAttachment, ChatMessage, ChatRouteState, JsonValue, ModelOption, RealtimeConnectionState, SessionSummary,
+} from '@shared/types'
+import {
+  deleteSession as deleteSessionApi, getMessages, getSessions, getSessionUnread, markSessionRead, updateSession,
+} from '@/api/sessions'
+import { getModels } from '@/api/profiles'
+import { ChatRpcSocket, RpcError } from '@/api/realtime'
+import { ApiError } from '@/api/client'
+import { encodeAttachment } from '@/utils/attachments'
+import { ScopedCache } from '@/utils/cache'
+import { createId, routeKey } from '@/utils/id'
+import { applyChatEvent, mergeChatMessages } from '@/utils/messageReducer'
+import { bool, normalizeChatMessage, number, record, string, values } from '@/utils/normalize'
+import { useAuthStore } from './auth'
+
+interface CachedHistory { messages: ChatMessage[]; total: number; savedAt: number }
+const historyCache = new ScopedCache<CachedHistory>('chat-history-v1')
+
+function emptyRoute(profile: string, sessionId: string): ChatRouteState {
+  return {
+    route: { profile, sessionId }, messages: [], historySynced: false, hasMoreBefore: false,
+    loadedMessageCount: 0, messageTotal: 0, isLoadingHistory: false, isStreaming: false,
+    isQueued: false, generation: 0,
+  }
+}
+
+function errorMessage(cause: unknown): string {
+  return cause instanceof Error ? cause.message : '聊天请求失败'
+}
+
+function resultRecord(value: JsonValue): Record<string, unknown> { return record(value) }
+
+export const useChatStore = defineStore('chat', () => {
+  const auth = useAuthStore()
+  const sessions = ref<SessionSummary[]>([])
+  const activeSessionId = ref<string>()
+  const activeProfileName = ref<string>()
+  const routes = reactive<Record<string, ChatRouteState>>({})
+  const connectionState = ref<RealtimeConnectionState>('idle')
+  const historySynced = computed(() => activeRouteState.value?.historySynced ?? false)
+  const isLoading = ref(false)
+  const isSending = ref(false)
+  const error = ref<string>()
+  const models = ref<ModelOption[]>([])
+  const unreadCounts = ref<Record<string, number>>({})
+  const selectedModel = ref<ModelOption>()
+  const reasoningEffort = ref<string>()
+  const fastMode = ref<boolean>()
+  const socket = new ChatRpcSocket()
+  const runtimeRoutes = new Map<string, string>()
+  const desiredModelRoutes = new Set<string>()
+  const pendingModelConfirmations = new Map<string, { routeKey: string; model: ModelOption }>()
+  let connectPromise: Promise<void> | undefined
+  let reconnectTimer: number | undefined
+  let reconnectAttempt = 0
+  let sessionLoadGeneration = 0
+  let modelLoadGeneration = 0
+  let unreadLoadGeneration = 0
+  let reconnectResumePromise: Promise<unknown> | undefined
+  let hasConnectedOnce = false
+  const runtimePromises = new Map<string, Promise<ChatRouteState>>()
+
+  const activeRouteState = computed(() => {
+    if (!activeSessionId.value || !activeProfileName.value) return undefined
+    return routes[routeKey(activeProfileName.value, activeSessionId.value)]
+  })
+  const activeSession = computed(() => sessions.value.find(session => session.id === activeSessionId.value
+    && (!activeProfileName.value || session.profile === activeProfileName.value)))
+  const messages = computed(() => activeRouteState.value?.messages ?? [])
+  const hasMoreBefore = computed(() => activeRouteState.value?.hasMoreBefore ?? false)
+  const isStreaming = computed(() => activeRouteState.value?.isStreaming ?? false)
+  const isQueued = computed(() => activeRouteState.value?.isQueued ?? false)
+  const contextUsage = computed(() => activeRouteState.value?.usage)
+  const pendingApproval = computed(() => activeRouteState.value?.pendingApproval)
+  const pendingClarification = computed(() => activeRouteState.value?.pendingClarification)
+
+  function ensureRoute(profile: string, sessionId: string): ChatRouteState {
+    const key = routeKey(profile, sessionId)
+    routes[key] ??= emptyRoute(profile, sessionId)
+    return routes[key]
+  }
+
+  function cacheScope(profile: string): string {
+    return `${auth.user?.id ?? 'anonymous'}:${profile}`
+  }
+
+  function scheduleReconnect(): void {
+    if (!auth.isAuthenticated || reconnectTimer !== undefined) return
+    const delay = Math.min(15_000, 500 * 2 ** Math.min(reconnectAttempt, 5))
+    reconnectAttempt += 1
+    connectionState.value = 'reconnecting'
+    reconnectTimer = window.setTimeout(() => {
+      reconnectTimer = undefined
+      void connect().catch(() => scheduleReconnect())
+    }, delay)
+  }
+
+  socket.onState((state, reason) => {
+    connectionState.value = state
+    if (state === 'ready') {
+      reconnectAttempt = 0
+      const shouldResume = hasConnectedOnce
+      hasConnectedOnce = true
+      const active = activeRouteState.value
+      if (shouldResume && active && !active.runtimeSessionId && !reconnectResumePromise) {
+        reconnectResumePromise = ensureRuntime(active)
+          .catch(cause => { active.error = errorMessage(cause) })
+          .finally(() => { reconnectResumePromise = undefined })
+      }
+    }
+    if (state === 'failed') {
+      runtimeRoutes.clear()
+      for (const route of Object.values(routes)) route.runtimeSessionId = undefined
+      error.value = reason || '聊天实时连接已断开'
+      scheduleReconnect()
+    }
+  })
+
+  function migrateRoute(oldKey: string, storedSessionId: string, runtimeSessionId: string): ChatRouteState {
+    const oldState = routes[oldKey]
+    if (!oldState) throw new Error('会话状态已经失效')
+    const newKey = routeKey(oldState.route.profile, storedSessionId)
+    const target = routes[newKey]
+    const migrated: ChatRouteState = {
+      ...(target ?? oldState),
+      route: { profile: oldState.route.profile, sessionId: storedSessionId },
+      messages: target ? mergeChatMessages(target.messages, oldState.messages) : oldState.messages,
+      runtimeSessionId,
+    }
+    routes[newKey] = migrated
+    if (newKey !== oldKey) delete routes[oldKey]
+    if (desiredModelRoutes.delete(oldKey)) desiredModelRoutes.add(newKey)
+    runtimeRoutes.set(runtimeSessionId, newKey)
+    if (activeSessionId.value === oldState.route.sessionId && activeProfileName.value === oldState.route.profile) {
+      activeSessionId.value = storedSessionId
+    }
+    const draftIndex = sessions.value.findIndex(session => session.id === oldState.route.sessionId && session.profile === oldState.route.profile)
+    const now = Date.now() / 1000
+    const summary: SessionSummary = draftIndex >= 0
+      ? { ...sessions.value[draftIndex], id: storedSessionId, updatedAt: now }
+      : { id: storedSessionId, profile: oldState.route.profile, source: 'web', title: '新会话', messageCount: 0, toolCallCount: 0, startedAt: now, updatedAt: now }
+    if (draftIndex >= 0) sessions.value.splice(draftIndex, 1, summary)
+    else sessions.value.unshift(summary)
+    return migrated
+  }
+
+  socket.onEvent(event => {
+    if (event.type === 'gateway.ready') return
+    const payload = record(event.payload)
+    const runtimeId = string(event.session_id ?? payload.session_id ?? payload.sessionId)
+    let key = runtimeRoutes.get(runtimeId)
+    if (!key) key = Object.keys(routes).find(candidate => routes[candidate].runtimeSessionId === runtimeId
+      || routes[candidate].route.sessionId === runtimeId)
+    if (!key) return
+    let state = routes[key]
+    if (event.type === 'session.info') {
+      const storedId = string(payload.stored_session_id ?? payload.storedSessionId ?? payload.session_key)
+      if (storedId && storedId !== state.route.sessionId) {
+        state = migrateRoute(key, storedId, runtimeId)
+        key = routeKey(state.route.profile, storedId)
+      }
+    }
+    const attributedPayload = { ...payload, session_id: state.route.sessionId } as JsonValue
+    routes[key] = applyChatEvent(state, {
+      ...event, session_id: state.route.sessionId, profile: state.route.profile, payload: attributedPayload,
+    })
+  })
+
+  async function connect(): Promise<void> {
+    if (['connected', 'ready'].includes(connectionState.value)) return
+    if (connectPromise) return connectPromise
+    if (reconnectTimer !== undefined) {
+      window.clearTimeout(reconnectTimer)
+      reconnectTimer = undefined
+    }
+    connectPromise = socket.connect().catch(cause => {
+      connectionState.value = 'failed'
+      error.value = errorMessage(cause)
+      throw cause
+    }).finally(() => { connectPromise = undefined })
+    return connectPromise
+  }
+
+  function disconnect(): void {
+    if (reconnectTimer !== undefined) window.clearTimeout(reconnectTimer)
+    reconnectTimer = undefined
+    hasConnectedOnce = false
+    socket.close()
+    runtimeRoutes.clear()
+    runtimePromises.clear()
+    desiredModelRoutes.clear()
+    pendingModelConfirmations.clear()
+    for (const state of Object.values(routes)) state.runtimeSessionId = undefined
+  }
+
+  function switchProfile(profile: string): void {
+    sessionLoadGeneration += 1
+    modelLoadGeneration += 1
+    unreadLoadGeneration += 1
+    disconnect()
+    activeSessionId.value = undefined
+    activeProfileName.value = profile
+    error.value = undefined
+  }
+
+  function clearAccountState(): void {
+    sessions.value = []
+    activeSessionId.value = undefined
+    activeProfileName.value = undefined
+    runtimeRoutes.clear()
+    for (const key of Object.keys(routes)) delete routes[key]
+    unreadCounts.value = {}
+    error.value = undefined
+  }
+
+  watch(() => auth.status, status => {
+    if (status !== 'authenticated') {
+      disconnect()
+      clearAccountState()
+    }
+  })
+
+  async function loadSessions(profile?: string): Promise<void> {
+    const requestedProfile = profile || undefined
+    const loadGeneration = ++sessionLoadGeneration
+    isLoading.value = true
+    error.value = undefined
+    try {
+      const loaded: SessionSummary[] = []
+      const seen = new Set<string>()
+      let cursor: string | undefined
+      do {
+        const key = cursor ?? '<first-page>'
+        if (seen.has(key)) break
+        seen.add(key)
+        const page = await getSessions(requestedProfile, cursor, 100)
+        loaded.push(...page.items)
+        cursor = page.nextCursor ?? undefined
+      } while (cursor && loaded.length < 1_000)
+      if (loadGeneration !== sessionLoadGeneration
+        || (requestedProfile && auth.activeProfile?.name !== requestedProfile)) return
+      // 9119 owns both the list order and pin placement. Do not re-sort with a
+      // browser timestamp: it can move a conversation before the server has
+      // accepted or persisted the operation.
+      sessions.value = loaded
+    } catch (cause) {
+      if (loadGeneration === sessionLoadGeneration) error.value = errorMessage(cause)
+      throw cause
+    } finally {
+      if (loadGeneration === sessionLoadGeneration) isLoading.value = false
+    }
+  }
+
+  async function loadUnread(profile?: string): Promise<void> {
+    const loadGeneration = ++unreadLoadGeneration
+    const loaded = await getSessionUnread(profile)
+    if (loadGeneration === unreadLoadGeneration && (!profile || auth.activeProfile?.name === profile)) {
+      unreadCounts.value = loaded
+    }
+  }
+
+  async function markRead(sessionId = activeSessionId.value, profile = activeProfileName.value): Promise<void> {
+    if (!sessionId) return
+    const state = profile ? routes[routeKey(profile, sessionId)] : undefined
+    const summaryCount = sessions.value.find(session => session.id === sessionId)?.messageCount ?? 0
+    const count = Math.max(state?.messageTotal ?? 0, summaryCount)
+    await markSessionRead(sessionId, count, profile)
+    unreadCounts.value = { ...unreadCounts.value, [sessionId]: 0 }
+  }
+
+  async function loadHistory(state: ChatRouteState): Promise<void> {
+    const generation = ++state.generation
+    state.isLoadingHistory = true
+    state.error = undefined
+    const scope = cacheScope(state.route.profile)
+    const cached = await historyCache.get(scope, state.route.sessionId)
+    if (generation !== state.generation) return
+    if (cached?.messages.length) {
+      state.messages = mergeChatMessages(state.messages, cached.messages, 'snapshot')
+      state.messageTotal = cached.total
+      state.loadedMessageCount = cached.messages.length
+    }
+    try {
+      const page = await getMessages(state.route.sessionId, 0, 150, state.route.profile)
+      if (generation !== state.generation) return
+      state.messages = mergeChatMessages(state.messages, page.messages, 'snapshot')
+      state.messageTotal = page.total
+      state.loadedMessageCount = page.returned
+      state.hasMoreBefore = page.hasMore
+      state.historySynced = true
+      if (page.session) {
+        const index = sessions.value.findIndex(session => session.id === page.session!.id && session.profile === page.session!.profile)
+        if (index >= 0) sessions.value.splice(index, 1, page.session)
+      }
+      await historyCache.set(scope, state.route.sessionId, { messages: state.messages, total: page.total, savedAt: Date.now() })
+    } catch (cause) {
+      if (generation === state.generation) state.error = errorMessage(cause)
+      throw cause
+    } finally {
+      if (generation === state.generation) state.isLoadingHistory = false
+    }
+  }
+
+  async function selectSession(sessionId: string, profile?: string): Promise<void> {
+    const session = sessions.value.find(item => item.id === sessionId && (!profile || item.profile === profile))
+    const selectedProfile = profile ?? session?.profile ?? auth.activeProfile?.name ?? 'default'
+    activeSessionId.value = sessionId
+    activeProfileName.value = selectedProfile
+    const state = ensureRoute(selectedProfile, sessionId)
+    await Promise.allSettled([connect(), state.historySynced ? Promise.resolve() : loadHistory(state)])
+    if (connectionState.value === 'connected' || connectionState.value === 'ready') {
+      try { await ensureRuntime(state) } catch (cause) { state.error = errorMessage(cause) }
+    }
+  }
+
+  async function loadOlder(): Promise<void> {
+    const state = activeRouteState.value
+    if (!state || state.isLoadingHistory || !state.hasMoreBefore) return
+    const generation = state.generation
+    state.isLoadingHistory = true
+    try {
+      const page = await getMessages(state.route.sessionId, state.loadedMessageCount, 150, state.route.profile)
+      if (generation !== state.generation) return
+      state.messages = mergeChatMessages(state.messages, page.messages, 'prepend')
+      state.loadedMessageCount += page.returned
+      state.messageTotal = Math.max(state.messageTotal, page.total)
+      state.hasMoreBefore = page.hasMore
+      await historyCache.set(cacheScope(state.route.profile), state.route.sessionId, {
+        messages: state.messages, total: state.messageTotal, savedAt: Date.now(),
+      })
+    } catch (cause) { state.error = errorMessage(cause) }
+    finally { if (generation === state.generation) state.isLoadingHistory = false }
+  }
+
+  function createSession(profile = auth.activeProfile?.name ?? 'default'): string {
+    const id = `draft-${createId('session')}`
+    const now = Date.now() / 1000
+    sessions.value.unshift({ id, profile, source: 'web', title: '新会话', messageCount: 0, toolCallCount: 0, startedAt: now, updatedAt: now })
+    ensureRoute(profile, id)
+    activeSessionId.value = id
+    activeProfileName.value = profile
+    return id
+  }
+
+  async function performEnsureRuntime(initialState: ChatRouteState): Promise<ChatRouteState> {
+    if (initialState.runtimeSessionId) return initialState
+    await connect()
+    const initialKey = routeKey(initialState.route.profile, initialState.route.sessionId)
+    if (initialState.route.sessionId.startsWith('draft-')) {
+      const params: Record<string, JsonValue> = {
+        profile: initialState.route.profile, source: 'web', close_on_disconnect: false,
+      }
+      if (reasoningEffort.value) params.reasoning_effort = reasoningEffort.value
+      if (fastMode.value !== undefined) params.fast = fastMode.value
+      const result = resultRecord(await socket.request('session.create', params))
+      const runtimeId = string(result.session_id ?? result.sessionId)
+      const storedId = string(result.stored_session_id ?? result.storedSessionId ?? result.session_key)
+      if (!runtimeId || !storedId) throw new Error('Hermes 未返回新会话标识')
+      return migrateRoute(initialKey, storedId, runtimeId)
+    }
+    const result = resultRecord(await socket.request('session.resume', {
+      session_id: initialState.route.sessionId,
+      profile: initialState.route.profile,
+      source: 'web',
+      omit_messages: true,
+    }))
+    const runtimeId = string(result.session_id ?? result.sessionId)
+    if (!runtimeId) throw new Error('Hermes 未返回运行会话标识')
+    const storedId = string(result.stored_session_id ?? result.storedSessionId ?? result.session_key, initialState.route.sessionId)
+    const migrated = migrateRoute(initialKey, storedId, runtimeId)
+    const resumedMessages = values(result.messages)
+      .map(value => normalizeChatMessage(value, storedId, migrated.route.profile))
+    if (resumedMessages.length) migrated.messages = mergeChatMessages(migrated.messages, resumedMessages, 'snapshot')
+    const inflight = record(result.inflight ?? result.active_run)
+    const assistant = string(inflight.assistant)
+    const inflightError = string(inflight.error)
+    migrated.isStreaming = bool(result.running) || bool(inflight.streaming)
+    const queued = record(result.queued)
+    const queuedUser = string(queued.user)
+    migrated.isQueued = Boolean(queuedUser)
+    if (assistant || inflightError) {
+      migrated.messages = mergeChatMessages(migrated.messages, [{
+        id: `resume:${runtimeId}`,
+        sessionId: storedId,
+        profile: migrated.route.profile,
+        role: 'assistant',
+        content: assistant,
+        timestamp: number(result.turn_started_at, Date.now() / 1000),
+        stage: inflightError ? 'failed' : migrated.isStreaming ? 'streaming' : 'settled',
+        isStreaming: migrated.isStreaming,
+        error: inflightError || undefined,
+      }])
+    }
+    values(inflight.corrections).forEach((correction, index) => {
+      const content = string(correction)
+      if (!content) return
+      migrated.messages = mergeChatMessages(migrated.messages, [{
+        id: `resume-correction:${runtimeId}:${index}`,
+        sessionId: storedId,
+        profile: migrated.route.profile,
+        role: 'user',
+        content,
+        timestamp: number(result.turn_started_at, Date.now() / 1000) + (index + 1) / 1000,
+        stage: 'accepted',
+      }])
+    })
+    if (queuedUser) {
+      migrated.messages = mergeChatMessages(migrated.messages, [{
+        id: `resume-queued:${runtimeId}`,
+        sessionId: storedId,
+        profile: migrated.route.profile,
+        role: 'user',
+        content: queuedUser,
+        timestamp: Date.now() / 1000,
+        stage: 'accepted',
+      }])
+    }
+    const approval = record(result.pending_approval)
+    const approvalID = string(approval.request_id ?? approval.requestId ?? approval.id)
+    if (approvalID) migrated.pendingApproval = {
+      id: approvalID,
+      sessionId: storedId,
+      message: string(approval.message ?? approval.prompt) || undefined,
+      toolName: string(approval.tool_name ?? approval.tool) || undefined,
+      choices: Array.isArray(approval.choices) ? approval.choices.map(String) : undefined,
+      payload: approval as Record<string, JsonValue>,
+    }
+    const clarification = record(result.pending_clarify)
+    const clarificationID = string(clarification.request_id ?? clarification.requestId ?? clarification.id)
+    if (clarificationID) migrated.pendingClarification = {
+      id: clarificationID,
+      sessionId: storedId,
+      question: string(clarification.question ?? clarification.message ?? clarification.prompt, '需要补充信息'),
+      choices: Array.isArray(clarification.choices) ? clarification.choices.map(String) : undefined,
+      payload: clarification as Record<string, JsonValue>,
+    }
+    return migrated
+  }
+
+  async function ensureRuntime(initialState: ChatRouteState): Promise<ChatRouteState> {
+    if (initialState.runtimeSessionId) return initialState
+    const key = routeKey(initialState.route.profile, initialState.route.sessionId)
+    const existing = runtimePromises.get(key)
+    if (existing) return existing
+    let operation: Promise<ChatRouteState>
+    operation = performEnsureRuntime(initialState).finally(() => {
+      if (runtimePromises.get(key) === operation) runtimePromises.delete(key)
+    })
+    runtimePromises.set(key, operation)
+    return operation
+  }
+
+  function updateDelivery(state: ChatRouteState, clientMessageId: string, patch: Partial<ChatMessage>): void {
+    state.messages = state.messages.map(message => message.clientMessageId === clientMessageId || message.id === clientMessageId
+      ? { ...message, ...patch }
+      : message)
+  }
+
+  async function send(
+    text: string,
+    files: File[] = [],
+    mode: 'submit' | 'queue' | 'steer' = 'submit',
+  ): Promise<void> {
+    let state = activeRouteState.value
+    if (!state) {
+      createSession()
+      state = activeRouteState.value!
+    }
+    const trimmed = text.trim()
+    if (!trimmed && !files.length) return
+    const clientMessageId = createId('message')
+    const attachments: ChatAttachment[] = files.map((file, index) => ({
+      id: `${clientMessageId}:${index}`, name: file.name, mimeType: file.type || 'application/octet-stream',
+      size: file.size, kind: file.type.startsWith('image/') ? 'image' : file.type === 'application/pdf' ? 'pdf' : 'file',
+    }))
+    const optimistic: ChatMessage = {
+      id: clientMessageId, clientMessageId, sessionId: state.route.sessionId, profile: state.route.profile,
+      role: 'user', content: trimmed, timestamp: Date.now() / 1000, stage: 'preparing', attachments,
+    }
+    state.messages = mergeChatMessages(state.messages, [optimistic])
+    isSending.value = true
+    error.value = undefined
+    let submitted = false
+    try {
+      state = await ensureRuntime(state)
+      const runtimeId = state.runtimeSessionId!
+      if (!await applyDesiredModel(state)) throw new Error('请先确认模型切换,再重新发送消息')
+      if (reasoningEffort.value) {
+        await socket.request('config.set', {
+          session_id: runtimeId, key: 'reasoning', value: reasoningEffort.value, scope: 'session',
+        })
+      }
+      if (fastMode.value !== undefined) {
+        await socket.request('config.set', {
+          session_id: runtimeId, key: 'fast', value: fastMode.value ? 'fast' : 'normal', scope: 'session',
+        })
+      }
+      const parts = trimmed ? [trimmed] : []
+      for (const file of files) {
+        const encoded = await encodeAttachment(file)
+        if (encoded.kind === 'image') {
+          await socket.request('image.attach_bytes', {
+            session_id: runtimeId, content_base64: encoded.base64, filename: encoded.name, ext: encoded.extension,
+          })
+          parts.push(`[用户附加图片:${encoded.name}]`)
+        } else if (encoded.kind === 'pdf') {
+          await socket.request('pdf.attach', { session_id: runtimeId, content_base64: encoded.base64, filename: encoded.name })
+          parts.push(`[用户附加 PDF:${encoded.name}]`)
+        } else {
+          const result = resultRecord(await socket.request('file.attach', {
+            session_id: runtimeId, name: encoded.name, data_url: encoded.dataUrl,
+          }))
+          parts.push(`[用户附加文件:${encoded.name}]${string(result.ref_text) ? `\n${string(result.ref_text)}` : ''}`)
+        }
+      }
+      updateDelivery(state, clientMessageId, { stage: files.length ? 'attached' : 'pending', sessionId: state.route.sessionId })
+      updateDelivery(state, clientMessageId, { stage: 'pending' })
+      submitted = true
+      const effectiveMode = mode === 'steer' && files.length ? 'queue' : mode
+      const method = effectiveMode === 'steer' ? 'session.steer' : 'prompt.submit'
+      const result = resultRecord(await socket.request(method, {
+        session_id: runtimeId,
+        text: parts.join('\n\n'),
+        ...(effectiveMode === 'queue' ? { queued: true } : {}),
+      }))
+      const status = string(result.status, 'accepted').toLowerCase()
+      if (effectiveMode === 'steer' && !['accepted', 'queued', 'steered', 'streaming'].includes(status)) {
+        throw new RpcError(string(result.error ?? result.message, 'Hermes 拒绝了 Steer 请求'), status)
+      }
+      updateDelivery(state, clientMessageId, { stage: 'accepted' })
+      const running = ['running', 'started', 'streaming'].includes(status)
+      if (effectiveMode === 'steer') {
+        // A steer receipt acknowledges injection into the current run. It is
+        // not evidence of a new run, including Hermes's `status: queued`.
+        state.isQueued = false
+        if (running) state.isStreaming = true
+      } else {
+        state.isQueued = status === 'queued'
+        if (running) {
+          state.isQueued = false
+          state.isStreaming = true
+        }
+      }
+      // The optimistic row remains local, but the sidebar is refreshed only
+      // after a 9119 receipt and keeps the order returned by the server.
+      void loadSessions(state.route.profile).catch(() => undefined)
+    } catch (cause) {
+      const explicitRejection = cause instanceof RpcError
+      const stage = submitted && !explicitRejection ? 'unknown-receipt' : 'failed'
+      updateDelivery(state, clientMessageId, { stage, error: errorMessage(cause) })
+      state.error = errorMessage(cause)
+      throw cause
+    } finally { isSending.value = false }
+  }
+
+  async function interrupt(): Promise<void> {
+    const state = activeRouteState.value
+    if (!state) return
+    const current = await ensureRuntime(state)
+    await socket.request('session.interrupt', { session_id: current.runtimeSessionId! }, 15_000)
+    current.isStreaming = false
+    current.isQueued = false
+  }
+
+  async function respondToApproval(requestId: string, approved: boolean | 'once' | 'session' | 'always' | 'deny'): Promise<void> {
+    const state = activeRouteState.value
+    if (!state) return
+    const choice = typeof approved === 'boolean' ? approved ? 'once' : 'deny' : approved
+    const modelConfirmation = pendingModelConfirmations.get(requestId)
+    if (modelConfirmation) {
+      pendingModelConfirmations.delete(requestId)
+      const target = routes[modelConfirmation.routeKey]
+      if (!target) throw new Error('模型确认请求已失效')
+      const modelState = await ensureRuntime(target)
+      if (choice !== 'deny') {
+        await configureModel(modelState, modelConfirmation.model, true)
+      }
+      modelState.pendingApproval = undefined
+      return
+    }
+    const current = await ensureRuntime(state)
+    await socket.request('approval.respond', { session_id: current.runtimeSessionId!, choice })
+    if (current.pendingApproval?.id === requestId) current.pendingApproval = undefined
+  }
+
+  async function respondToClarification(requestId: string, response: string): Promise<void> {
+    const state = activeRouteState.value
+    if (!state) return
+    const current = await ensureRuntime(state)
+    await socket.request('clarify.respond', { session_id: current.runtimeSessionId!, request_id: requestId, answer: response })
+    if (current.pendingClarification?.id === requestId) current.pendingClarification = undefined
+  }
+
+  async function branchSession(): Promise<string | undefined> {
+    const state = activeRouteState.value
+    if (!state) return
+    const current = await ensureRuntime(state)
+    const result = resultRecord(await socket.request('session.branch', { session_id: current.runtimeSessionId! }))
+    const runtimeId = string(result.session_id ?? result.sessionId)
+    const storedId = string(result.stored_session_id ?? result.storedSessionId)
+    if (!runtimeId || !storedId) throw new Error('Hermes 未返回分支会话标识')
+    const next = emptyRoute(current.route.profile, storedId)
+    next.runtimeSessionId = runtimeId
+    routes[routeKey(current.route.profile, storedId)] = next
+    runtimeRoutes.set(runtimeId, routeKey(current.route.profile, storedId))
+    activeSessionId.value = storedId
+    activeProfileName.value = current.route.profile
+    // The branch was created by 9119, but its position remains server-owned.
+    void loadSessions(current.route.profile).catch(() => undefined)
+    return storedId
+  }
+
+  async function renameSession(id: string, title: string, profile?: string): Promise<void> {
+    await updateSession(id, { title }, profile)
+    await loadSessions(profile)
+  }
+
+  async function setSessionPinned(id: string, pinned: boolean, profile?: string): Promise<void> {
+    await updateSession(id, { pinned }, profile)
+    await loadSessions(profile)
+  }
+
+  async function removeSession(id: string, profile?: string): Promise<void> {
+    if (!id.startsWith('draft-')) await deleteSessionApi(id, profile)
+    sessions.value = sessions.value.filter(session => session.id !== id || (profile && session.profile !== profile))
+    if (activeSessionId.value === id && (!profile || activeProfileName.value === profile)) {
+      activeSessionId.value = undefined
+      activeProfileName.value = undefined
+    }
+  }
+
+  async function loadModels(profile = auth.activeProfile?.name): Promise<void> {
+    const loadGeneration = ++modelLoadGeneration
+    const loaded = await getModels(profile)
+    if (loadGeneration !== modelLoadGeneration || (profile && auth.activeProfile?.name !== profile)) return
+    models.value = loaded
+    selectedModel.value = models.value.find(model => model.isDefault) ?? models.value[0]
+  }
+
+  async function configureModel(state: ChatRouteState, model: ModelOption, confirmsExpensiveModel: boolean): Promise<boolean> {
+    const result = resultRecord(await socket.request('config.set', {
+      session_id: state.runtimeSessionId!,
+      key: 'model',
+      value: `${model.id} --provider ${model.provider} --session`,
+      ...(confirmsExpensiveModel ? { confirm_expensive_model: true } : {}),
+    }))
+    if (bool(result.confirm_required ?? result.confirmRequired)) {
+      const requestId = createId('expensive-model')
+      pendingModelConfirmations.set(requestId, { routeKey: routeKey(state.route.profile, state.route.sessionId), model })
+      state.pendingApproval = {
+        id: requestId,
+        sessionId: state.route.sessionId,
+        message: string(result.confirm_message ?? result.confirmMessage ?? result.warning, '此模型可能产生较高费用,请确认后使用。'),
+        choices: ['once', 'deny'],
+        payload: result as Record<string, JsonValue>,
+      }
+      return false
+    }
+    return true
+  }
+
+  async function applyDesiredModel(state: ChatRouteState): Promise<boolean> {
+    const key = routeKey(state.route.profile, state.route.sessionId)
+    if (!desiredModelRoutes.has(key) || !selectedModel.value || !state.runtimeSessionId) return true
+    const applied = await configureModel(state, selectedModel.value, false)
+    if (applied) desiredModelRoutes.delete(key)
+    return applied
+  }
+
+  async function setModel(model: ModelOption): Promise<void> {
+    selectedModel.value = model
+    const state = activeRouteState.value
+    if (!state) return
+    const key = routeKey(state.route.profile, state.route.sessionId)
+    desiredModelRoutes.add(key)
+    if (!state.runtimeSessionId) return
+    if (await configureModel(state, model, false)) desiredModelRoutes.delete(key)
+  }
+
+  async function refreshContextUsage(): Promise<void> {
+    const state = activeRouteState.value
+    if (!state) return
+    const current = await ensureRuntime(state)
+    const usage = resultRecord(await socket.request('session.usage', { session_id: current.runtimeSessionId! }))
+    current.usage = {
+      inputTokens: number(usage.input_tokens ?? usage.input) || undefined,
+      outputTokens: number(usage.output_tokens ?? usage.output) || undefined,
+      totalTokens: number(usage.total_tokens ?? usage.total) || undefined,
+      contextTokens: number(usage.context_tokens ?? usage.context_used ?? usage.used) || undefined,
+      contextLimit: number(usage.context_limit ?? usage.context_max ?? usage.limit) || undefined,
+      percentUsed: number(usage.percent_used ?? usage.context_percent) || undefined,
+      raw: usage as JsonValue,
+    }
+  }
+
+  return {
+    sessions, activeSessionId, activeProfileName, activeSession, routes, activeRouteState, messages,
+    connectionState, historySynced, hasMoreBefore, isLoading, isSending, isStreaming, isQueued,
+    error, models, selectedModel, reasoningEffort, fastMode, contextUsage, pendingApproval, pendingClarification, unreadCounts,
+    loadSessions, selectSession, loadOlder, createSession, connect, disconnect, send, interrupt,
+    respondToApproval, respondToClarification, branchSession, renameSession, setSessionPinned, removeSession,
+    loadModels, setModel, refreshContextUsage, loadUnread, markRead, switchProfile,
+  }
+})

+ 72 - 0
src/client/stores/files.ts

@@ -0,0 +1,72 @@
+import { computed, ref, shallowRef } from 'vue'
+import { defineStore } from 'pinia'
+import type { FileKind, FileLibraryItem } from '@shared/types'
+import { getFiles } from '@/api/files'
+import { useAuthStore } from './auth'
+
+export const useFilesStore = defineStore('files', () => {
+  const auth = useAuthStore()
+  const items = shallowRef<FileLibraryItem[]>([])
+  const nextCursor = ref<string>()
+  const hasMore = ref(true)
+  const isLoading = ref(false)
+  const error = ref<string>()
+  const query = ref('')
+  const kindFilter = ref<FileKind | 'all'>('all')
+  const profile = ref<string>()
+  const visitedCursors = new Set<string>()
+  let loadGeneration = 0
+
+  const filteredItems = computed<FileLibraryItem[]>(() => items.value.filter(item => kindFilter.value === 'all' || item.kind === kindFilter.value))
+
+  async function load(reset = true): Promise<void> {
+    if (isLoading.value && !reset) return
+    const generation = ++loadGeneration
+    if (reset) {
+      items.value = []
+      nextCursor.value = undefined
+      hasMore.value = true
+      visitedCursors.clear()
+    } else if (!hasMore.value) return
+    isLoading.value = true
+    error.value = undefined
+    try {
+      const existing = new Map(items.value.map(item => [item.id, item]))
+      let cursor = reset ? undefined : nextCursor.value
+      let pages = 0
+      while (pages < 20) {
+        const cursorKey = cursor ?? '<first-page>'
+        if (visitedCursors.has(cursorKey)) {
+          hasMore.value = false
+          error.value = '文件分页返回了重复游标,已停止加载'
+          break
+        }
+        visitedCursors.add(cursorKey)
+        const page = await getFiles({
+          search: query.value.trim() || undefined,
+          kind: kindFilter.value,
+          profile: profile.value ?? auth.activeProfile?.name,
+          cursor,
+          limit: 50,
+        })
+        if (generation !== loadGeneration) return
+        for (const item of page.items) existing.set(item.id, item)
+        pages += 1
+        cursor = page.nextCursor ?? undefined
+        nextCursor.value = cursor
+        hasMore.value = Boolean(cursor)
+        const matching = [...existing.values()].filter(item => kindFilter.value === 'all' || item.kind === kindFilter.value).length
+        if (!cursor || kindFilter.value === 'all' || matching >= 50) break
+      }
+      items.value = [...existing.values()]
+    } catch (cause) {
+      if (generation === loadGeneration) error.value = cause instanceof Error ? cause.message : '文件库加载失败'
+    } finally {
+      if (generation === loadGeneration) isLoading.value = false
+    }
+  }
+
+  const reset = () => load(true)
+  const loadMore = () => load(false)
+  return { items, filteredItems, nextCursor, hasMore, isLoading, error, query, kindFilter, profile, load, reset, loadMore }
+})

+ 430 - 0
src/client/stores/groups.ts

@@ -0,0 +1,430 @@
+import { computed, ref, shallowRef, watch } from 'vue'
+import { defineStore } from 'pinia'
+import type {
+  GroupAgent, GroupCapabilities, GroupMessage, GroupMessagePage, GroupRoomDetail, GroupRoomSummary, GroupSocketEnvelope, RealtimeConnectionState,
+} from '@shared/types'
+import {
+  addGroupAgent, approveGroupInteraction as approveApi, archiveGroupRoom as archiveApi,
+  clarifyGroupInteraction as clarifyApi, createGroupRoom as createApi, getGroupCapabilities,
+  getGroupMessages, getGroupRoom, getGroupRooms, interruptGroupAgent as interruptApi,
+  removeGroupAgent as removeAgentApi, sendGroupMessage as sendApi, updateGroupAgent as updateAgentApi,
+  updateGroupRoom as updateRoomApi, uploadGroupFiles, type AgentSeed,
+} from '@/api/groups'
+import { GroupEventSocket } from '@/api/realtime'
+import { ScopedCache } from '@/utils/cache'
+import {
+  applyGroupEnvelope, snapshotGroupRoom, upsertGroupMessage, type GroupProtocolState,
+} from '@/utils/groupProtocol'
+import { createUuid } from '@/utils/id'
+import { ApiError } from '@/api/client'
+import { useAuthStore } from './auth'
+
+type GroupAvailability = 'checking' | 'available' | 'unsupported' | 'unavailable'
+
+interface CachedGroups { state: GroupProtocolState; savedAt: number }
+const cache = new ScopedCache<CachedGroups>('groups-v2')
+
+function initialProtocol(): GroupProtocolState {
+  return { epoch: '', cursor: 0, rooms: [], roomDetails: {}, messagesByRoom: {} }
+}
+
+function errorMessage(cause: unknown): string { return cause instanceof Error ? cause.message : '群聊请求失败' }
+
+export const useGroupsStore = defineStore('groups', () => {
+  const auth = useAuthStore()
+  const availability = ref<GroupAvailability>('checking')
+  const capabilities = ref<GroupCapabilities>()
+  const protocol = shallowRef<GroupProtocolState>(initialProtocol())
+  const selectedRoomId = ref<string>()
+  const connectionState = ref<RealtimeConnectionState>('idle')
+  const isLoading = ref(false)
+  const isSending = ref(false)
+  const error = ref<string>()
+  const hasOlderByRoom = ref<Record<string, boolean>>({})
+  const socket = new GroupEventSocket()
+  let generation = 0
+  let rebuildPromise: Promise<void> | undefined
+  let reconnectTimer: number | undefined
+  let reconnectAttempt = 0
+  let selectionGeneration = 0
+  const loadingRoomEvents = new Map<string, GroupSocketEnvelope[]>()
+
+  const rooms = computed(() => protocol.value.rooms)
+  const selectedRoom = computed(() => selectedRoomId.value ? protocol.value.roomDetails[selectedRoomId.value] : undefined)
+  const messages = computed(() => selectedRoomId.value ? protocol.value.messagesByRoom[selectedRoomId.value] ?? [] : [])
+  const agents = computed(() => selectedRoom.value?.agents ?? [])
+  const pendingInteractions = computed(() => selectedRoom.value?.pendingInteractions ?? [])
+  const hasMoreBefore = computed(() => selectedRoomId.value ? hasOlderByRoom.value[selectedRoomId.value] ?? false : false)
+
+  function scope(): string { return `${auth.user?.id ?? 'anonymous'}:${auth.activeProfile?.name ?? 'all'}` }
+
+  async function saveCache(): Promise<void> {
+    await cache.set(scope(), 'snapshot', { state: protocol.value, savedAt: Date.now() })
+  }
+
+  async function loadRooms(): Promise<GroupRoomSummary[]> {
+    const loaded: GroupRoomSummary[] = []
+    const seen = new Set<string>()
+    let cursor: string | undefined
+    do {
+      const key = cursor ?? '<first-page>'
+      if (seen.has(key)) throw new Error('群聊房间分页返回了重复游标')
+      seen.add(key)
+      const page = await getGroupRooms(cursor, 50)
+      loaded.push(...page.items)
+      cursor = page.nextCursor ?? undefined
+    } while (cursor && loaded.length < 2_000)
+    return loaded.sort((a, b) => b.updatedAt - a.updatedAt)
+  }
+
+  async function connectEvents(anchor: GroupCapabilities, expectedGeneration: number): Promise<void> {
+    await socket.connect({
+      epoch: anchor.journalEpoch,
+      cursor: anchor.latestCursor,
+      onState(state, reason) {
+        if (expectedGeneration !== generation) return
+        connectionState.value = state
+        if (state === 'ready') reconnectAttempt = 0
+        if (reason && state === 'failed') {
+          error.value = reason
+          scheduleReconnect(expectedGeneration)
+        }
+      },
+      onEnvelope(envelope) {
+        if (expectedGeneration !== generation) return
+        if (envelope.roomId && loadingRoomEvents.has(envelope.roomId)) {
+          loadingRoomEvents.get(envelope.roomId)!.push(envelope)
+        }
+        protocol.value = applyGroupEnvelope(protocol.value, envelope)
+        void saveCache()
+      },
+      onReset(reason) {
+        if (expectedGeneration !== generation) return
+        error.value = `群聊事件需要重新同步:${reason}`
+        void rebuildSnapshot()
+      },
+    })
+  }
+
+  function scheduleReconnect(expectedGeneration: number): void {
+    if (reconnectTimer !== undefined || expectedGeneration !== generation || !auth.isAuthenticated) return
+    connectionState.value = 'reconnecting'
+    const delay = Math.min(15_000, 500 * 2 ** Math.min(reconnectAttempt, 5))
+    reconnectAttempt += 1
+    reconnectTimer = window.setTimeout(() => {
+      reconnectTimer = undefined
+      if (expectedGeneration !== generation || !capabilities.value) return
+      const anchor: GroupCapabilities = {
+        ...capabilities.value,
+        journalEpoch: protocol.value.epoch,
+        latestCursor: protocol.value.cursor,
+      }
+      void connectEvents(anchor, expectedGeneration).catch(() => scheduleReconnect(expectedGeneration))
+    }, delay)
+  }
+
+  async function rebuildSnapshot(): Promise<void> {
+    if (rebuildPromise) return rebuildPromise
+    const expectedGeneration = ++generation
+    const operation = (async () => {
+      isLoading.value = true
+      error.value = undefined
+      socket.close()
+      if (reconnectTimer !== undefined) window.clearTimeout(reconnectTimer)
+      reconnectTimer = undefined
+      try {
+        const anchor = await getGroupCapabilities()
+        if (anchor.protocolVersion !== 2) {
+          availability.value = 'unsupported'
+          throw new Error(`群聊协议版本不兼容:需要 v2,服务器返回 v${anchor.protocolVersion || 'unknown'}`)
+        }
+        const loadedRooms = await loadRooms()
+        if (expectedGeneration !== generation) return
+        capabilities.value = anchor
+        protocol.value = {
+          epoch: anchor.journalEpoch,
+          cursor: anchor.latestCursor,
+          rooms: loadedRooms,
+          roomDetails: {},
+          messagesByRoom: {},
+        }
+        const restoreId = selectedRoomId.value && loadedRooms.some(room => room.id === selectedRoomId.value)
+          ? selectedRoomId.value
+          : loadedRooms[0]?.id
+        if (restoreId) await loadRoomSnapshot(restoreId, expectedGeneration)
+        if (expectedGeneration !== generation) return
+        availability.value = 'available'
+        await saveCache()
+        await connectEvents(anchor, expectedGeneration)
+      } catch (cause) {
+        if (expectedGeneration !== generation) return
+        if (availability.value !== 'unsupported') availability.value = 'unavailable'
+        error.value = errorMessage(cause)
+        throw cause
+      } finally {
+        if (expectedGeneration === generation) isLoading.value = false
+      }
+    })()
+    let wrapped: Promise<void>
+    wrapped = operation.finally(() => {
+      if (rebuildPromise === wrapped) rebuildPromise = undefined
+    })
+    rebuildPromise = wrapped
+    return wrapped
+  }
+
+  async function start(): Promise<void> {
+    availability.value = 'checking'
+    protocol.value = initialProtocol()
+    const cached = await cache.get(scope(), 'snapshot')
+    if (cached?.state.epoch) {
+      protocol.value = cached.state
+      selectedRoomId.value ??= cached.state.rooms[0]?.id
+    }
+    await rebuildSnapshot()
+  }
+
+  function stop(): void {
+    generation += 1
+    rebuildPromise = undefined
+    socket.close()
+    if (reconnectTimer !== undefined) window.clearTimeout(reconnectTimer)
+    reconnectTimer = undefined
+    connectionState.value = 'disconnected'
+  }
+
+  watch(() => auth.status, status => {
+    if (status !== 'authenticated') {
+      stop()
+      protocol.value = initialProtocol()
+      selectedRoomId.value = undefined
+      capabilities.value = undefined
+      hasOlderByRoom.value = {}
+    }
+  })
+
+  async function loadRoomSnapshot(
+    roomId: string,
+    expectedGeneration = generation,
+    expectedSelection = selectionGeneration,
+  ): Promise<void> {
+    const startingCursor = protocol.value.cursor
+    loadingRoomEvents.set(roomId, [])
+    let detail: GroupRoomDetail
+    let page: GroupMessagePage
+    try {
+      ;[detail, page] = await Promise.all([getGroupRoom(roomId), getGroupMessages(roomId, { limit: 100 })])
+    } catch (cause) {
+      loadingRoomEvents.delete(roomId)
+      throw cause
+    }
+    if (expectedGeneration !== generation || expectedSelection !== selectionGeneration) {
+      loadingRoomEvents.delete(roomId)
+      return
+    }
+    selectedRoomId.value = roomId
+    let merged = page.items.sort((a, b) => a.seq - b.seq)
+    // Socket events can land while the REST baseline is in flight. Replay the
+    // already-published live rows last so a stale snapshot never erases or
+    // rolls back a streaming upsert.
+    for (const live of protocol.value.messagesByRoom[roomId] ?? []) {
+      merged = upsertGroupMessage(merged, live)
+    }
+    const liveDetail = protocol.value.cursor > startingCursor
+      ? protocol.value.roomDetails[roomId]
+      : undefined
+    protocol.value = snapshotGroupRoom(protocol.value, liveDetail ?? detail, merged)
+    for (const envelope of loadingRoomEvents.get(roomId) ?? []) {
+      const durableCursor = protocol.value.cursor
+      const replayBase = envelope.cursor == null
+        ? protocol.value
+        : { ...protocol.value, cursor: Math.max(0, envelope.cursor - 1) }
+      const replayed = applyGroupEnvelope(replayBase, envelope)
+      protocol.value = { ...replayed, cursor: Math.max(durableCursor, replayed.cursor) }
+    }
+    loadingRoomEvents.delete(roomId)
+    hasOlderByRoom.value = { ...hasOlderByRoom.value, [roomId]: page.items.length >= 100 }
+  }
+
+  async function selectRoom(roomId: string): Promise<void> {
+    const expectedSelection = ++selectionGeneration
+    selectedRoomId.value = roomId
+    if (!protocol.value.roomDetails[roomId]) {
+      isLoading.value = true
+      try { await loadRoomSnapshot(roomId, generation, expectedSelection) }
+      catch (cause) { error.value = errorMessage(cause); throw cause }
+      finally { isLoading.value = false }
+    }
+  }
+
+  async function refresh(): Promise<void> { await rebuildSnapshot() }
+
+  async function createRoom(input: { name: string; cwd?: string; agents: AgentSeed[]; maxReplyRounds?: number }): Promise<GroupRoomDetail> {
+    const detail = await createApi(input)
+    protocol.value = snapshotGroupRoom(protocol.value, detail, [])
+    selectedRoomId.value = detail.id
+    await saveCache()
+    return detail
+  }
+
+  async function updateRoom(roomId: string, input: { name?: string; cwd?: string; maxReplyRounds?: number }): Promise<GroupRoomDetail> {
+    const detail = await updateRoomApi(roomId, input)
+    protocol.value = snapshotGroupRoom(protocol.value, detail, protocol.value.messagesByRoom[roomId] ?? [])
+    await saveCache()
+    return detail
+  }
+
+  async function archiveRoom(roomId: string): Promise<void> {
+    const detail = await archiveApi(roomId)
+    protocol.value = snapshotGroupRoom(protocol.value, detail, protocol.value.messagesByRoom[roomId] ?? [])
+    if (selectedRoomId.value === roomId) selectedRoomId.value = protocol.value.rooms.find(room => !room.archived && room.id !== roomId)?.id
+    await saveCache()
+  }
+
+  function publishAgent(roomId: string, agent: GroupAgent): void {
+    const detail = protocol.value.roomDetails[roomId]
+    if (!detail) return
+    const index = detail.agents.findIndex(item => item.id === agent.id)
+    const agents = [...detail.agents]
+    if (index < 0) agents.push(agent)
+    else agents[index] = agent
+    protocol.value = {
+      ...protocol.value,
+      roomDetails: { ...protocol.value.roomDetails, [roomId]: { ...detail, agents } },
+    }
+  }
+
+  async function addAgent(roomId: string, input: AgentSeed): Promise<GroupAgent> {
+    const agent = await addGroupAgent(roomId, input)
+    publishAgent(roomId, agent)
+    return agent
+  }
+
+  async function updateAgent(roomId: string, agentId: string, input: Partial<Omit<AgentSeed, 'profile'>> & { enabled?: boolean }): Promise<GroupAgent> {
+    const agent = await updateAgentApi(roomId, agentId, input)
+    publishAgent(roomId, agent)
+    return agent
+  }
+
+  async function removeAgent(roomId: string, agentId: string): Promise<void> {
+    await removeAgentApi(roomId, agentId)
+    const detail = protocol.value.roomDetails[roomId]
+    if (detail) protocol.value = {
+      ...protocol.value,
+      roomDetails: { ...protocol.value.roomDetails, [roomId]: { ...detail, agents: detail.agents.filter(agent => agent.id !== agentId) } },
+    }
+  }
+
+  async function sendMessage(content: string, mentionAgentIds: string[] = [], files: File[] = []): Promise<void> {
+    const roomId = selectedRoomId.value
+    if (!roomId) return
+    const text = content.trim()
+    let uploadIds: string[] = []
+    let optimisticText = text
+    if (files.length) {
+      if (!auth.groupUploadsEnabled) throw new Error('当前上游不支持群聊附件')
+      const uploaded = await uploadGroupFiles(roomId, files)
+      uploadIds = uploaded.map(file => file.id)
+      optimisticText = [text, ...uploaded.map(file => `📎 ${file.name}`)].filter(Boolean).join('\n\n')
+    }
+    if (!text && !uploadIds.length) return
+    const clientMessageId = createUuid()
+    const stableRequestId = createUuid()
+    const now = Date.now() / 1000
+    const optimistic: GroupMessage = {
+      seq: Math.max(0, ...messages.value.map(message => message.seq)) + 1,
+      id: clientMessageId,
+      roomId,
+      senderKind: 'human',
+      senderId: auth.user?.id ?? 'me',
+      senderName: auth.user?.displayName ?? auth.user?.username ?? '我',
+      rootMessageId: clientMessageId,
+      clientMessageId,
+      content: optimisticText,
+      reasoning: '',
+      toolState: [],
+      status: 'queued',
+      error: '',
+      createdAt: now,
+      updatedAt: now,
+    }
+    protocol.value = {
+      ...protocol.value,
+      messagesByRoom: {
+        ...protocol.value.messagesByRoom,
+        [roomId]: upsertGroupMessage(protocol.value.messagesByRoom[roomId] ?? [], optimistic),
+      },
+    }
+    isSending.value = true
+    try {
+      const enabled = new Set((protocol.value.roomDetails[roomId]?.agents ?? []).filter(agent => agent.enabled).map(agent => agent.id))
+      const resolvedMentions = mentionAgentIds.includes('all')
+        ? [...enabled]
+        : [...new Set(mentionAgentIds.filter(id => enabled.has(id)))]
+      const response = await sendApi(roomId, text, resolvedMentions, clientMessageId, uploadIds, stableRequestId)
+      protocol.value = {
+        ...protocol.value,
+        messagesByRoom: {
+          ...protocol.value.messagesByRoom,
+          [roomId]: upsertGroupMessage(protocol.value.messagesByRoom[roomId] ?? [], response.message),
+        },
+      }
+    } catch (cause) {
+      const explicitRejection = cause instanceof ApiError && cause.status >= 400 && cause.status < 500
+      const failed = {
+        ...optimistic,
+        status: explicitRejection ? 'failed' as const : 'unknown' as const,
+        error: explicitRejection ? errorMessage(cause) : '送达状态未知;刷新房间后再决定是否重试',
+        updatedAt: Date.now() / 1000,
+      }
+      protocol.value = {
+        ...protocol.value,
+        messagesByRoom: { ...protocol.value.messagesByRoom, [roomId]: upsertGroupMessage(protocol.value.messagesByRoom[roomId] ?? [], failed) },
+      }
+      throw cause
+    } finally { isSending.value = false }
+  }
+
+  async function loadOlder(): Promise<void> {
+    const roomId = selectedRoomId.value
+    const oldest = messages.value.find(message => message.seq > 0)?.seq
+    if (!roomId || !oldest) return
+    const page = await getGroupMessages(roomId, { beforeSeq: oldest, limit: 100 })
+    let merged = protocol.value.messagesByRoom[roomId] ?? []
+    for (const message of page.items) merged = upsertGroupMessage(merged, message)
+    protocol.value = { ...protocol.value, messagesByRoom: { ...protocol.value.messagesByRoom, [roomId]: merged } }
+    hasOlderByRoom.value = { ...hasOlderByRoom.value, [roomId]: page.items.length >= 100 }
+  }
+
+  async function interruptAgent(agentId: string, roomId = selectedRoomId.value): Promise<void> {
+    if (!roomId) return
+    await interruptApi(roomId, agentId)
+  }
+
+  async function approveInteraction(interactionId: string, choice: 'once' | 'session' | 'always' | 'deny', roomId = selectedRoomId.value): Promise<void> {
+    if (!roomId) return
+    await approveApi(roomId, interactionId, choice)
+    const detail = protocol.value.roomDetails[roomId]
+    if (detail) protocol.value = {
+      ...protocol.value,
+      roomDetails: { ...protocol.value.roomDetails, [roomId]: { ...detail, pendingInteractions: detail.pendingInteractions.filter(item => item.id !== interactionId) } },
+    }
+  }
+
+  async function clarifyInteraction(interactionId: string, response: string, roomId = selectedRoomId.value): Promise<void> {
+    if (!roomId) return
+    await clarifyApi(roomId, interactionId, response)
+    const detail = protocol.value.roomDetails[roomId]
+    if (detail) protocol.value = {
+      ...protocol.value,
+      roomDetails: { ...protocol.value.roomDetails, [roomId]: { ...detail, pendingInteractions: detail.pendingInteractions.filter(item => item.id !== interactionId) } },
+    }
+  }
+
+  return {
+    availability, capabilities, rooms, selectedRoomId, selectedRoom, messages, agents, pendingInteractions,
+    connectionState, isLoading, isSending, error, hasMoreBefore,
+    start, stop, refresh, selectRoom, createRoom, updateRoom, archiveRoom, addAgent, updateAgent,
+    removeAgent, sendMessage, loadOlder, interruptAgent, approveInteraction, clarifyInteraction,
+  }
+})

+ 7 - 0
src/client/stores/index.ts

@@ -0,0 +1,7 @@
+export * from './app'
+export * from './artifacts'
+export * from './auth'
+export * from './chat'
+export * from './files'
+export * from './groups'
+export * from './theme'

+ 25 - 0
src/client/stores/theme.ts

@@ -0,0 +1,25 @@
+import { computed, onBeforeUnmount, ref } from 'vue'
+import { defineStore } from 'pinia'
+
+export type ThemePreference = 'light' | 'dark' | 'system'
+
+export const useThemeStore = defineStore('theme', () => {
+  const saved = localStorage.getItem('hermes-yaoyao:theme')
+  const theme = ref<ThemePreference>(saved === 'light' || saved === 'dark' ? saved : 'system')
+  const media = window.matchMedia('(prefers-color-scheme: dark)')
+  const systemDark = ref(media.matches)
+  const onChange = (event: MediaQueryListEvent) => { systemDark.value = event.matches }
+  media.addEventListener('change', onChange)
+  onBeforeUnmount(() => media.removeEventListener('change', onChange))
+
+  const resolvedTheme = computed<'light' | 'dark'>(() => theme.value === 'system' ? systemDark.value ? 'dark' : 'light' : theme.value)
+
+  function setTheme(value: ThemePreference): void {
+    theme.value = value
+    if (value === 'system') localStorage.removeItem('hermes-yaoyao:theme')
+    else localStorage.setItem('hermes-yaoyao:theme', value)
+  }
+
+  function toggle(): void { setTheme(resolvedTheme.value === 'dark' ? 'light' : 'dark') }
+  return { theme, resolvedTheme, setTheme, toggle }
+})

+ 135 - 0
src/client/styles/global.css

@@ -0,0 +1,135 @@
+@import './tokens.css';
+
+*, *::before, *::after { box-sizing: border-box; }
+
+html, body, #app {
+  width: 100%;
+  min-width: 320px;
+  height: 100%;
+  margin: 0;
+}
+
+html { background: var(--canvas); }
+
+body {
+  overflow: hidden;
+  background: var(--canvas);
+  color: var(--text-primary);
+  font-family: var(--font-ui);
+  font-size: 14px;
+  -webkit-font-smoothing: antialiased;
+  text-rendering: optimizeLegibility;
+}
+
+button, input, textarea, select { font: inherit; }
+button { color: inherit; }
+a { color: inherit; }
+
+button:focus-visible,
+a:focus-visible,
+input:focus-visible,
+textarea:focus-visible,
+[tabindex]:focus-visible {
+  outline: 2px solid var(--text-primary);
+  outline-offset: 2px;
+}
+
+::selection {
+  background: rgba(var(--accent-rgb), .16);
+}
+
+::-webkit-scrollbar { width: 10px; height: 10px; }
+::-webkit-scrollbar-track { background: transparent; }
+::-webkit-scrollbar-thumb {
+  min-height: 32px;
+  border: 3px solid transparent;
+  border-radius: 999px;
+  background: color-mix(in srgb, var(--text-muted) 48%, transparent);
+  background-clip: padding-box;
+}
+
+.sr-only {
+  position: absolute !important;
+  width: 1px !important;
+  height: 1px !important;
+  padding: 0 !important;
+  margin: -1px !important;
+  overflow: hidden !important;
+  clip: rect(0, 0, 0, 0) !important;
+  white-space: nowrap !important;
+  border: 0 !important;
+}
+
+.icon-button {
+  display: inline-grid;
+  place-items: center;
+  width: 34px;
+  height: 34px;
+  padding: 0;
+  border: 0;
+  border-radius: 9px;
+  background: transparent;
+  color: var(--text-secondary);
+  cursor: pointer;
+  transition: color 140ms ease, background-color 140ms ease, transform 140ms ease;
+}
+
+.icon-button:hover { color: var(--text-primary); background: var(--surface-hover); }
+.icon-button:active { transform: scale(.96); }
+.icon-button:disabled { cursor: not-allowed; opacity: .38; }
+
+.solid-button,
+.quiet-button {
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  min-height: 36px;
+  padding: 0 15px;
+  border-radius: 10px;
+  cursor: pointer;
+  font-weight: 580;
+  transition: transform 140ms ease, background-color 140ms ease, border-color 140ms ease;
+}
+
+.solid-button {
+  border: 1px solid var(--accent);
+  background: var(--accent);
+  color: var(--text-on-solid);
+}
+
+.solid-button:hover { transform: translateY(-1px); }
+.solid-button:disabled { cursor: not-allowed; opacity: .45; transform: none; }
+
+.quiet-button {
+  border: 1px solid var(--line);
+  background: transparent;
+  color: var(--text-secondary);
+}
+
+.quiet-button:hover { border-color: var(--line-strong); background: var(--surface-hover); color: var(--text-primary); }
+
+.status-dot {
+  display: inline-block;
+  width: 6px;
+  height: 6px;
+  border-radius: 50%;
+  background: var(--text-muted);
+}
+.status-dot--online { background: var(--success); box-shadow: 0 0 0 3px color-mix(in srgb, var(--success) 15%, transparent); }
+.status-dot--working { background: var(--warning); animation: status-pulse 1.5s ease-in-out infinite; }
+
+@keyframes status-pulse { 50% { opacity: .42; } }
+
+.view-fade-enter-active,
+.view-fade-leave-active { transition: opacity 140ms ease, transform 160ms var(--ease-out); }
+.view-fade-enter-from { opacity: 0; transform: translateY(4px); }
+.view-fade-leave-to { opacity: 0; transform: translateY(-2px); }
+
+@media (prefers-reduced-motion: reduce) {
+  *, *::before, *::after {
+    scroll-behavior: auto !important;
+    animation-duration: .001ms !important;
+    animation-iteration-count: 1 !important;
+    transition-duration: .001ms !important;
+  }
+}

+ 69 - 0
src/client/styles/tokens.css

@@ -0,0 +1,69 @@
+:root {
+  color-scheme: light;
+  --font-ui: Inter, ui-sans-serif, -apple-system, BlinkMacSystemFont, "SF Pro Text", "PingFang SC", "Microsoft YaHei", sans-serif;
+  --font-code: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
+  --canvas: #f8f8f6;
+  --conversation-canvas: #f5f8fc;
+  --surface: #fdfdfc;
+  --surface-raised: #ffffff;
+  --surface-soft: #f2f2ef;
+  --surface-hover: #ecece8;
+  --rail-bg: #151515;
+  --rail-text: rgba(255, 255, 255, .58);
+  --rail-active: #ffffff;
+  --text-primary: #1f1f1d;
+  --text-secondary: #656560;
+  --text-muted: #6f6f69;
+  --text-on-solid: #ffffff;
+  --line: rgba(25, 25, 22, .105);
+  --line-strong: rgba(25, 25, 22, .19);
+  --accent: #20201e;
+  --accent-rgb: 32, 32, 30;
+  --danger: #b33d33;
+  --success: #377a54;
+  --warning: #9b6b1f;
+  --focus-ring: rgba(31, 31, 29, .16);
+  --chat-composer-bg: #ffffff;
+  --input-border-color: rgba(31, 31, 29, .12);
+  --input-border-hover-color: rgba(31, 31, 29, .24);
+  --input-border-focus-color: rgba(31, 31, 29, .44);
+  --input-placeholder-color: #72726c;
+  --scrim: rgba(18, 18, 17, .32);
+  --shadow-float: 0 18px 55px rgba(20, 20, 18, .13);
+  --radius-sm: 9px;
+  --radius-md: 13px;
+  --radius-lg: 18px;
+  --ease-out: cubic-bezier(.2, .8, .2, 1);
+}
+
+:root.dark {
+  color-scheme: dark;
+  --canvas: #181817;
+  --conversation-canvas: #171a20;
+  --surface: #1d1d1c;
+  --surface-raised: #242423;
+  --surface-soft: #282827;
+  --surface-hover: #30302e;
+  --rail-bg: #0d0d0d;
+  --rail-text: rgba(255, 255, 255, .54);
+  --rail-active: #ffffff;
+  --text-primary: #efefec;
+  --text-secondary: #b2b2ad;
+  --text-muted: #a0a09a;
+  --text-on-solid: #151514;
+  --line: rgba(255, 255, 250, .09);
+  --line-strong: rgba(255, 255, 250, .17);
+  --accent: #efefec;
+  --accent-rgb: 239, 239, 236;
+  --danger: #ed786f;
+  --success: #77b88e;
+  --warning: #d9ab60;
+  --focus-ring: rgba(255, 255, 255, .12);
+  --chat-composer-bg: #242423;
+  --input-border-color: rgba(255, 255, 250, .1);
+  --input-border-hover-color: rgba(255, 255, 250, .2);
+  --input-border-focus-color: rgba(255, 255, 250, .38);
+  --input-placeholder-color: #a0a09a;
+  --scrim: rgba(0, 0, 0, .56);
+  --shadow-float: 0 18px 55px rgba(0, 0, 0, .34);
+}

+ 119 - 0
src/client/utils/artifacts.ts

@@ -0,0 +1,119 @@
+import type { ChatMessage, ConversationArtifact, JsonValue, SessionSummary } from '@shared/types'
+
+export const ARTIFACT_EXTRACTOR_VERSION = 2
+
+const markdownImage = /!\[([^\]]*)\]\(([^)\s]+)\)/gi
+const markdownLink = /(?<!!)\[([^\]]+)\]\(([^)\s]+)\)/gi
+const rawUrl = /https?:\/\/[^\s<>"')]+/gi
+const localPath = /(?:^|[\s(:"'`])((?:\/|~\/|\.\.?\/)[^\s"'`<>]+(?:\.[a-z0-9]{1,8})?)/gim
+const imageExtensions = new Set(['avif', 'bmp', 'gif', 'heic', 'heif', 'jpeg', 'jpg', 'png', 'svg', 'tif', 'tiff', 'webp'])
+const fileExtensions = new Set([...imageExtensions, 'pdf', 'txt', 'json', 'md', 'csv', 'zip', 'tar', 'gz', 'mp3', 'wav', '3gp', 'avi', 'm4v', 'mkv', 'mov', 'mp4', 'mpeg', 'mpg', 'webm', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx'])
+const keyHints = ['path', 'file', 'url', 'image', 'artifact', 'output', 'download', 'result', 'target']
+
+interface Candidate { value: string; label?: string; mimeType?: string; attachment?: ConversationArtifact['attachment'] }
+
+function extension(value: string): string {
+  try {
+    const path = new URL(value, window.location.origin).pathname
+    return path.split('.').pop()?.toLowerCase() ?? ''
+  } catch { return value.split(/[?#]/)[0].split('.').pop()?.toLowerCase() ?? '' }
+}
+
+function normalize(value: string): string {
+  return value.trim().replace(/[),.;]+$/g, '')
+}
+
+function looksLikePathOrUrl(value: string): boolean {
+  return ['http://', 'https://', 'file://', 'data:image/', '/', './', '../', '~/'].some(prefix => value.startsWith(prefix))
+}
+
+function looksLikeArtifact(value: string): boolean {
+  if (/^(https?:\/\/|data:image\/)/i.test(value)) return true
+  if (looksLikePathOrUrl(value) && fileExtensions.has(extension(value))) return true
+  return value.startsWith('/') && value.includes('.')
+}
+
+function kind(value: string, mimeType?: string): ConversationArtifact['kind'] {
+  if (mimeType?.startsWith('image/') || value.startsWith('data:image/') || imageExtensions.has(extension(value))) return 'image'
+  if (/^(\/|\.\.?\/|~\/|file:\/\/)/.test(value)) return 'file'
+  return 'link'
+}
+
+function label(value: string, artifactKind: ConversationArtifact['kind'], suggested?: string): string {
+  const clean = suggested?.trim()
+  if (artifactKind === 'link' && clean && clean !== value) return clean
+  if (artifactKind === 'link') {
+    try { return new URL(value).hostname.replace(/^www\./, '') || value } catch { return value }
+  }
+  if (value.startsWith('data:image/')) return '生成图片'
+  try { return decodeURIComponent(new URL(value, window.location.origin).pathname.split('/').pop() || value) } catch { return value.split('/').pop() || value }
+}
+
+function textCandidates(text: string): Candidate[] {
+  const result: Candidate[] = []
+  for (const match of text.matchAll(markdownImage)) result.push({ value: match[2] })
+  for (const match of text.matchAll(markdownLink)) result.push({ value: match[2], label: match[1] })
+  for (const match of text.matchAll(rawUrl)) result.push({ value: match[0] })
+  for (const match of text.matchAll(localPath)) result.push({ value: match[1] })
+  return result
+}
+
+function collectStringValues(value: JsonValue, keyPath: string, output: Candidate[]): void {
+  if (typeof value === 'string') {
+    const normalized = normalize(value)
+    if ((keyHints.some(hint => keyPath.toLowerCase().includes(hint)) || looksLikePathOrUrl(normalized)) && looksLikeArtifact(normalized)) {
+      output.push({ value: normalized })
+    }
+    return
+  }
+  if (Array.isArray(value)) return value.forEach((child, index) => collectStringValues(child, `${keyPath}.${index}`, output))
+  if (value && typeof value === 'object') {
+    for (const key of Object.keys(value).sort()) collectStringValues(value[key], `${keyPath}.${key}`, output)
+  }
+}
+
+function candidates(message: ChatMessage): Candidate[] {
+  const result = textCandidates(message.content)
+  for (const attachment of message.attachments ?? []) {
+    const value = attachment.path || attachment.url
+    if (value) result.push({ value, mimeType: attachment.mimeType, attachment })
+  }
+  for (const call of message.toolCalls ?? []) {
+    if (call.arguments !== undefined) collectStringValues(call.arguments, 'tool.arguments', result)
+    if (call.result !== undefined) collectStringValues(call.result, 'tool.result', result)
+    if (call.preview) result.push(...textCandidates(call.preview))
+  }
+  if (message.role === 'tool') {
+    try { collectStringValues(JSON.parse(message.content) as JsonValue, 'tool.result', result) } catch { /* plain tool text is already scanned */ }
+  }
+  return result
+}
+
+/** Extracts assistant/tool artifacts and intentionally ignores user rows. */
+export function extractArtifacts(session: SessionSummary, messages: ChatMessage[]): ConversationArtifact[] {
+  const found = new Map<string, ConversationArtifact>()
+  for (const message of messages) {
+    if (message.role !== 'assistant' && message.role !== 'tool') continue
+    for (const candidate of candidates(message)) {
+      const value = normalize(candidate.value)
+      if (!value || !looksLikeArtifact(value)) continue
+      const id = `${ARTIFACT_EXTRACTOR_VERSION}:${session.profile ?? ''}:${session.id}:${message.id}:${value}`
+      if (found.has(id)) continue
+      const artifactKind = kind(value, candidate.mimeType)
+      found.set(id, {
+        id,
+        kind: artifactKind,
+        value,
+        label: label(value, artifactKind, candidate.label),
+        sessionId: session.id,
+        sessionTitle: session.title,
+        profile: session.profile,
+        messageId: message.id,
+        timestamp: message.timestamp || session.updatedAt,
+        mimeType: candidate.mimeType,
+        attachment: candidate.attachment,
+      })
+    }
+  }
+  return [...found.values()]
+}

+ 37 - 0
src/client/utils/attachments.ts

@@ -0,0 +1,37 @@
+export const MAX_ATTACHMENT_BYTES = 25 * 1024 * 1024
+
+export interface EncodedAttachment {
+  name: string
+  mimeType: string
+  size: number
+  base64: string
+  dataUrl: string
+  extension: string
+  kind: 'image' | 'pdf' | 'file'
+}
+
+function readAsDataURL(file: File): Promise<string> {
+  return new Promise((resolve, reject) => {
+    const reader = new FileReader()
+    reader.onerror = () => reject(reader.error ?? new Error(`无法读取 ${file.name}`))
+    reader.onload = () => resolve(String(reader.result ?? ''))
+    reader.readAsDataURL(file)
+  })
+}
+
+export async function encodeAttachment(file: File): Promise<EncodedAttachment> {
+  if (file.size > MAX_ATTACHMENT_BYTES) throw new Error(`${file.name} 超过 25 MiB 限制`)
+  const dataUrl = await readAsDataURL(file)
+  const separator = dataUrl.indexOf(',')
+  if (separator < 0) throw new Error(`${file.name} 编码失败`)
+  const mimeType = file.type || 'application/octet-stream'
+  return {
+    name: file.name,
+    mimeType,
+    size: file.size,
+    base64: dataUrl.slice(separator + 1),
+    dataUrl,
+    extension: file.name.includes('.') ? file.name.split('.').pop()?.toLowerCase() ?? '' : '',
+    kind: mimeType.startsWith('image/') ? 'image' : mimeType === 'application/pdf' || file.name.toLowerCase().endsWith('.pdf') ? 'pdf' : 'file',
+  }
+}

+ 43 - 0
src/client/utils/cache.ts

@@ -0,0 +1,43 @@
+import { openDB, type IDBPDatabase } from 'idb'
+
+const memory = new Map<string, unknown>()
+let databasePromise: Promise<IDBPDatabase | null> | undefined
+
+async function database(): Promise<IDBPDatabase | null> {
+  if (typeof indexedDB === 'undefined') return null
+  databasePromise ??= openDB('hermes-yaoyao-cache', 1, {
+    upgrade(database) {
+      if (!database.objectStoreNames.contains('records')) database.createObjectStore('records')
+    },
+  }).catch(() => null)
+  return databasePromise
+}
+
+export class ScopedCache<T> {
+  constructor(private readonly namespace: string, private readonly maximumBytes = 16 * 1024 * 1024) {}
+
+  private key(scope: string, key: string): string { return `${this.namespace}:${scope}:${key}` }
+
+  async get(scope: string, key: string): Promise<T | undefined> {
+    const storageKey = this.key(scope, key)
+    const db = await database()
+    if (!db) return memory.get(storageKey) as T | undefined
+    try { return await db.get('records', storageKey) as T | undefined } catch { return memory.get(storageKey) as T | undefined }
+  }
+
+  async set(scope: string, key: string, value: T): Promise<void> {
+    const serialized = JSON.stringify(value)
+    if (new Blob([serialized]).size > this.maximumBytes) return
+    const storageKey = this.key(scope, key)
+    memory.set(storageKey, value)
+    const db = await database()
+    if (db) await db.put('records', value, storageKey).catch(() => undefined)
+  }
+
+  async delete(scope: string, key: string): Promise<void> {
+    const storageKey = this.key(scope, key)
+    memory.delete(storageKey)
+    const db = await database()
+    if (db) await db.delete('records', storageKey).catch(() => undefined)
+  }
+}

+ 143 - 0
src/client/utils/groupProtocol.ts

@@ -0,0 +1,143 @@
+import type {
+  GroupAgent, GroupInteraction, GroupMessage, GroupRoomDetail, GroupRoomSummary, GroupSocketEnvelope, JsonValue,
+} from '@shared/types'
+import {
+  normalizeGroupAgent, normalizeGroupInteraction, normalizeGroupMessage, normalizeGroupRoom, normalizeGroupRoomDetail, record, string,
+} from './normalize'
+
+export interface GroupProtocolState {
+  epoch: string
+  cursor: number
+  rooms: GroupRoomSummary[]
+  roomDetails: Record<string, GroupRoomDetail>
+  messagesByRoom: Record<string, GroupMessage[]>
+}
+
+export type GroupEnvelopeVerdict = 'ready' | 'apply' | 'ignore' | 'heartbeat' | 'reset'
+
+export function classifyGroupEnvelope(epoch: string, cursor: number, envelope: GroupSocketEnvelope): GroupEnvelopeVerdict {
+  if (envelope.epoch && envelope.epoch !== epoch) return 'reset'
+  if (envelope.type === 'group.reset_required') return 'reset'
+  if (envelope.type === 'group.ready') return envelope.cursor === cursor ? 'ready' : 'reset'
+  if (envelope.type === 'group.heartbeat') return envelope.cursor == null || envelope.cursor === cursor ? 'heartbeat' : 'reset'
+  if (envelope.type !== 'group.event' || envelope.cursor == null || envelope.cursor < 0) return 'reset'
+  if (envelope.cursor <= cursor) return 'ignore'
+  return envelope.cursor === cursor + 1 ? 'apply' : 'reset'
+}
+
+function upsert<T extends { id: string }>(items: T[], incoming: T): T[] {
+  const index = items.findIndex(item => item.id === incoming.id)
+  if (index < 0) return [...items, incoming]
+  const result = [...items]
+  result[index] = { ...items[index], ...incoming }
+  return result
+}
+
+export function upsertGroupMessage(items: GroupMessage[], incoming: GroupMessage): GroupMessage[] {
+  const index = items.findIndex(item => item.id === incoming.id
+    || Boolean(item.clientMessageId && incoming.clientMessageId && item.clientMessageId === incoming.clientMessageId))
+  if (index < 0) return [...items, incoming].sort((a, b) => a.seq - b.seq)
+  const result = [...items]
+  result[index] = { ...items[index], ...incoming }
+  return result.sort((a, b) => a.seq - b.seq)
+}
+
+function eventRoomId(envelope: GroupSocketEnvelope): string {
+  return envelope.roomId || string(record(envelope.payload).roomId ?? record(envelope.payload).room_id)
+}
+
+export function applyGroupEnvelope(state: GroupProtocolState, envelope: GroupSocketEnvelope): GroupProtocolState {
+  if (classifyGroupEnvelope(state.epoch, state.cursor, envelope) !== 'apply') return state
+  const roomId = eventRoomId(envelope)
+  const payload = record(envelope.payload)
+  const next: GroupProtocolState = {
+    ...state,
+    cursor: envelope.cursor!,
+    rooms: [...state.rooms],
+    roomDetails: { ...state.roomDetails },
+    messagesByRoom: { ...state.messagesByRoom },
+  }
+  switch (envelope.event) {
+    case 'room.created':
+    case 'room.updated': {
+      const room = normalizeGroupRoom(payload)
+      if (room.id) next.rooms = upsert(next.rooms, room).sort((a, b) => b.updatedAt - a.updatedAt)
+      if (roomId && next.roomDetails[roomId] && envelope.event === 'room.updated') {
+        next.roomDetails[roomId] = { ...next.roomDetails[roomId], ...room }
+      }
+      break
+    }
+    case 'room.deleted':
+      next.rooms = next.rooms.map(room => room.id === roomId ? { ...room, archived: true } : room)
+      if (next.roomDetails[roomId]) next.roomDetails[roomId] = { ...next.roomDetails[roomId], archived: true }
+      break
+    case 'agent.created':
+    case 'agent.updated': {
+      const agent = normalizeGroupAgent(payload)
+      const detail = next.roomDetails[agent.roomId || roomId]
+      if (detail) next.roomDetails[detail.id] = { ...detail, agents: upsert(detail.agents, agent) }
+      break
+    }
+    case 'agent.deleted': {
+      const agent = normalizeGroupAgent(payload)
+      const detail = next.roomDetails[agent.roomId || roomId]
+      if (detail) next.roomDetails[detail.id] = { ...detail, agents: detail.agents.filter(item => item.id !== agent.id) }
+      break
+    }
+    case 'agent.status': {
+      const agentId = string(payload.agentId ?? payload.agent_id)
+      const status = string(payload.status, 'unknown') as GroupAgent['status']
+      const detail = next.roomDetails[roomId]
+      if (detail) next.roomDetails[roomId] = {
+        ...detail,
+        agents: detail.agents.map(agent => agent.id === agentId ? { ...agent, status } : agent),
+      }
+      break
+    }
+    case 'message.upsert': {
+      const message = normalizeGroupMessage(payload)
+      if (!message.id || !message.roomId) break
+      next.messagesByRoom[message.roomId] = upsertGroupMessage(next.messagesByRoom[message.roomId] ?? [], message)
+      next.rooms = next.rooms.map(room => room.id === message.roomId
+        ? { ...room, lastMessage: message, updatedAt: Math.max(room.updatedAt, message.updatedAt) }
+        : room)
+      break
+    }
+    case 'interaction.requested':
+    case 'interaction.resolved': {
+      const interaction = normalizeGroupInteraction(payload)
+      const detail = next.roomDetails[interaction.roomId || roomId]
+      if (!detail) break
+      const interactions = upsert(detail.pendingInteractions, interaction)
+      next.roomDetails[detail.id] = {
+        ...detail,
+        pendingInteractions: interactions.filter(item => item.status === 'pending'),
+      }
+      break
+    }
+    case 'run.updated': {
+      const run = payload as unknown as GroupRoomDetail['runs'][number]
+      const detail = next.roomDetails[roomId]
+      if (detail && string((run as unknown as Record<string, JsonValue>).id)) {
+        next.roomDetails[roomId] = { ...detail, runs: upsert(detail.runs, run) }
+      }
+      break
+    }
+  }
+  return next
+}
+
+export function snapshotGroupRoom(
+  state: GroupProtocolState,
+  roomValue: unknown,
+  messages: GroupMessage[],
+): GroupProtocolState {
+  const room = normalizeGroupRoomDetail(roomValue)
+  return {
+    ...state,
+    rooms: upsert(state.rooms, normalizeGroupRoom(roomValue)),
+    roomDetails: { ...state.roomDetails, [room.id]: room },
+    messagesByRoom: { ...state.messagesByRoom, [room.id]: messages },
+    cursor: Math.max(state.cursor, room.latestCursor),
+  }
+}

+ 24 - 0
src/client/utils/id.ts

@@ -0,0 +1,24 @@
+let fallbackCounter = 0
+
+export function createId(prefix = 'web'): string {
+  const uuid = globalThis.crypto?.randomUUID?.()
+  if (uuid) return uuid.toLowerCase()
+  fallbackCounter += 1
+  return `${prefix}-${Date.now().toString(36)}-${fallbackCounter.toString(36)}-${Math.random().toString(36).slice(2, 10)}`
+}
+
+export function createUuid(): string {
+  const native = globalThis.crypto?.randomUUID?.()
+  if (native) return native.toLowerCase()
+  const bytes = new Uint8Array(16)
+  if (globalThis.crypto?.getRandomValues) globalThis.crypto.getRandomValues(bytes)
+  else for (let index = 0; index < bytes.length; index += 1) bytes[index] = Math.floor(Math.random() * 256)
+  bytes[6] = (bytes[6] & 0x0f) | 0x40
+  bytes[8] = (bytes[8] & 0x3f) | 0x80
+  const hex = [...bytes].map(value => value.toString(16).padStart(2, '0')).join('')
+  return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`
+}
+
+export function routeKey(profile: string, sessionId: string): string {
+  return `${encodeURIComponent(profile)}::${encodeURIComponent(sessionId)}`
+}

+ 232 - 0
src/client/utils/messageReducer.ts

@@ -0,0 +1,232 @@
+import type {
+  ApprovalRequest, ChatMessage, ChatRouteState, ChatUsage, ClarificationRequest, JsonValue, RpcEventFrame, ToolCall,
+} from '@shared/types'
+import { createId } from './id'
+import { bool, number, record, string } from './normalize'
+
+type MergePosition = 'append' | 'prepend' | 'snapshot'
+
+function sameMessage(left: ChatMessage, right: ChatMessage): boolean {
+  if (left.id && right.id && left.id === right.id) return true
+  return Boolean(left.clientMessageId && right.clientMessageId && left.clientMessageId === right.clientMessageId)
+}
+
+function mergeOne(previous: ChatMessage, incoming: ChatMessage): ChatMessage {
+  return {
+    ...previous,
+    ...incoming,
+    id: incoming.serverMessageId || incoming.stage === 'settled' ? incoming.id : previous.id,
+    clientMessageId: incoming.clientMessageId ?? previous.clientMessageId,
+    serverMessageId: incoming.serverMessageId ?? previous.serverMessageId,
+    content: incoming.content || previous.content,
+    reasoning: incoming.reasoning ?? previous.reasoning,
+    attachments: incoming.attachments?.length ? incoming.attachments : previous.attachments,
+    toolCalls: incoming.toolCalls?.length ? incoming.toolCalls : previous.toolCalls,
+  }
+}
+
+/** Merge only by server/client identity. Repeated text is always retained. */
+export function mergeChatMessages(existing: ChatMessage[], incoming: ChatMessage[], position: MergePosition = 'append'): ChatMessage[] {
+  const base = position === 'prepend' || position === 'snapshot' ? [...incoming, ...existing] : [...existing, ...incoming]
+  const result: ChatMessage[] = []
+  for (const message of base) {
+    const index = result.findIndex(candidate => sameMessage(candidate, message))
+    if (index < 0) result.push(message)
+    else result[index] = mergeOne(result[index], message)
+  }
+  return result
+}
+
+function payloadRecord(event: RpcEventFrame['params']): Record<string, unknown> {
+  return record(event.payload)
+}
+
+function eventSessionId(event: RpcEventFrame['params'], fallback: string): string {
+  const payload = payloadRecord(event)
+  return string(event.session_id ?? payload.session_id ?? payload.sessionId, fallback)
+}
+
+function eventMessageId(payload: Record<string, unknown>, fallback: string): string {
+  return string(payload.message_id ?? payload.messageId ?? payload.id ?? payload.run_id ?? payload.runId, fallback)
+}
+
+function lastStreamingAssistant(messages: ChatMessage[]): ChatMessage | undefined {
+  return [...messages].reverse().find(message => message.role === 'assistant' && message.isStreaming)
+}
+
+function toolFromEvent(payload: Record<string, unknown>, status: ToolCall['status']): ToolCall {
+  return {
+    id: string(payload.tool_call_id ?? payload.toolCallId ?? payload.tool_id ?? payload.id, createId('tool')),
+    name: string(payload.name ?? payload.tool_name, '工具'),
+    status,
+    arguments: (payload.arguments ?? payload.args ?? payload.context) as JsonValue | undefined,
+    result: (payload.result ?? payload.output) as JsonValue | undefined,
+    preview: string(payload.preview ?? payload.summary ?? payload.args_text) || undefined,
+    error: string(payload.error) || undefined,
+    durationSeconds: number(payload.duration_seconds ?? payload.duration_s) || undefined,
+  }
+}
+
+function normalizeUsage(payload: Record<string, unknown>): ChatUsage | undefined {
+  const raw = record(payload.usage ?? payload)
+  if (!Object.keys(raw).length) return undefined
+  return {
+    inputTokens: number(raw.input_tokens ?? raw.input ?? raw.prompt) || undefined,
+    outputTokens: number(raw.output_tokens ?? raw.output ?? raw.completion) || undefined,
+    totalTokens: number(raw.total_tokens ?? raw.total) || undefined,
+    contextTokens: number(raw.context_tokens ?? raw.context_used ?? raw.current_tokens ?? raw.used) || undefined,
+    contextLimit: number(raw.context_limit ?? raw.context_max ?? raw.max_tokens ?? raw.limit) || undefined,
+    percentUsed: number(raw.percent_used ?? raw.context_percent ?? raw.percentage) || undefined,
+    raw: raw as JsonValue,
+  }
+}
+
+function updateStreamingMessage(
+  state: ChatRouteState,
+  payload: Record<string, unknown>,
+  mode: 'start' | 'delta' | 'reasoning' | 'complete' | 'failed',
+): ChatMessage[] {
+  const current = lastStreamingAssistant(state.messages)
+  const fallbackId = current?.id ?? `stream:${state.route.profile}:${state.route.sessionId}`
+  const id = eventMessageId(payload, fallbackId)
+  const existing = state.messages.find(message => message.id === id) ?? current
+  const delta = string(payload.delta ?? payload.text_delta ?? payload.content_delta)
+  const full = string(payload.output ?? payload.text ?? payload.content)
+  let content = existing?.content ?? ''
+  let reasoning = existing?.reasoning
+  if (mode === 'delta') content += delta || full
+  if (mode === 'complete') content = bool(payload.authoritative_output) && full ? full : full || content
+  if (mode === 'reasoning') reasoning = `${reasoning ?? ''}${delta || full}`
+  const message: ChatMessage = {
+    id,
+    serverMessageId: id.startsWith('stream:') ? undefined : id,
+    sessionId: state.route.sessionId,
+    profile: state.route.profile,
+    role: 'assistant',
+    content,
+    reasoning,
+    timestamp: number(payload.timestamp ?? payload.created_at, existing?.timestamp ?? Date.now() / 1000),
+    stage: mode === 'complete' ? 'settled' : mode === 'failed' ? 'failed' : 'streaming',
+    isStreaming: !['complete', 'failed'].includes(mode),
+    error: mode === 'failed' ? string(payload.error ?? payload.message, '运行失败') : undefined,
+    toolCalls: existing?.toolCalls,
+    raw: payload as JsonValue,
+  }
+  return mergeChatMessages(state.messages, [message])
+}
+
+export function applyChatEvent(state: ChatRouteState, event: RpcEventFrame['params']): ChatRouteState {
+  if (event.type === 'gateway.ready') return state
+  const payload = payloadRecord(event)
+  if (eventSessionId(event, state.route.sessionId) !== state.route.sessionId) return state
+  const next: ChatRouteState = { ...state, error: undefined }
+  switch (event.type) {
+    case 'message.start':
+    case 'run.started':
+      next.messages = updateStreamingMessage(state, payload, 'start')
+      next.isStreaming = true
+      next.isQueued = false
+      break
+    case 'message.delta':
+    case 'content.delta':
+    case 'assistant.delta':
+      next.messages = updateStreamingMessage(state, payload, 'delta')
+      next.isStreaming = true
+      break
+    case 'reasoning.delta':
+      next.messages = updateStreamingMessage(state, payload, 'reasoning')
+      next.isStreaming = true
+      break
+    case 'thinking.delta':
+    case 'status.update':
+      next.liveStatus = string(payload.text ?? payload.message ?? payload.status) || undefined
+      break
+    case 'message.complete':
+    case 'run.completed': {
+      const terminalStatus = string(payload.status).trim().toLowerCase()
+      const failed = terminalStatus === 'error' || terminalStatus === 'failed' || Boolean(payload.error)
+      next.messages = updateStreamingMessage(state, payload, failed ? 'failed' : 'complete')
+      next.isStreaming = false
+      next.isQueued = false
+      next.liveStatus = undefined
+      next.usage = normalizeUsage(payload) ?? next.usage
+      if (failed) next.error = string(payload.error ?? payload.text ?? payload.message, '运行失败')
+      break
+    }
+    case 'error':
+    case 'run.failed':
+      next.messages = updateStreamingMessage(state, payload, 'failed')
+      next.isStreaming = false
+      next.isQueued = false
+      next.error = string(payload.error ?? payload.message, '运行失败')
+      break
+    case 'run.queued':
+      next.isQueued = true
+      break
+    case 'run.steered':
+    case 'submission.acknowledged':
+      next.isQueued = false
+      break
+    case 'session.usage':
+    case 'usage.update':
+    case 'context.update':
+      next.usage = normalizeUsage(payload)
+      break
+    case 'tool.start':
+    case 'tool.progress':
+    case 'tool.started':
+    case 'tool.generating': {
+      const current = lastStreamingAssistant(next.messages)
+      if (!current) break
+      const tool = toolFromEvent(payload, 'running')
+      const tools = [...(current.toolCalls ?? [])]
+      const index = tools.findIndex(item => item.id === tool.id)
+      if (index >= 0) tools[index] = { ...tools[index], ...tool }
+      else tools.push(tool)
+      next.messages = mergeChatMessages(next.messages, [{ ...current, toolCalls: tools }])
+      break
+    }
+    case 'tool.complete':
+    case 'tool.completed':
+    case 'tool.failed': {
+      const current = lastStreamingAssistant(next.messages) ?? [...next.messages].reverse().find(message => message.role === 'assistant')
+      if (!current) break
+      const tool = toolFromEvent(payload, event.type === 'tool.failed' || payload.error ? 'failed' : 'completed')
+      const tools = [...(current.toolCalls ?? [])]
+      const index = tools.findIndex(item => item.id === tool.id)
+      if (index >= 0) tools[index] = { ...tools[index], ...tool }
+      else tools.push(tool)
+      next.messages = mergeChatMessages(next.messages, [{ ...current, toolCalls: tools }])
+      break
+    }
+    case 'approval.request':
+    case 'approval.requested': {
+      const id = string(payload.request_id ?? payload.requestId ?? payload.id)
+      if (id) next.pendingApproval = {
+        id, sessionId: state.route.sessionId, message: string(payload.message ?? payload.prompt) || undefined,
+        toolName: string(payload.tool_name ?? payload.tool) || undefined,
+        choices: Array.isArray(payload.choices) ? payload.choices.map(String) : undefined,
+        payload: payload as Record<string, JsonValue>,
+      } satisfies ApprovalRequest
+      break
+    }
+    case 'approval.resolved':
+      next.pendingApproval = undefined
+      break
+    case 'clarify.request':
+    case 'clarify.requested': {
+      const id = string(payload.request_id ?? payload.requestId ?? payload.id)
+      if (id) next.pendingClarification = {
+        id, sessionId: state.route.sessionId, question: string(payload.question ?? payload.message ?? payload.prompt, '需要补充信息'),
+        choices: Array.isArray(payload.choices) ? payload.choices.map(String) : undefined,
+        payload: payload as Record<string, JsonValue>,
+      } satisfies ClarificationRequest
+      break
+    }
+    case 'clarify.expire':
+    case 'clarify.resolved':
+      next.pendingClarification = undefined
+      break
+  }
+  return next
+}

+ 334 - 0
src/client/utils/normalize.ts

@@ -0,0 +1,334 @@
+import type {
+  ChatAttachment,
+  ChatMessage,
+  CurrentUser,
+  FileKind,
+  FileLibraryItem,
+  GroupAgent,
+  GroupCapabilities,
+  GroupInteraction,
+  GroupMessage,
+  GroupRoomDetail,
+  GroupRoomSummary,
+  JsonValue,
+  ModelOption,
+  Profile,
+  SessionSummary,
+  ToolCall,
+} from '@shared/types'
+
+export type UnknownRecord = Record<string, unknown>
+
+export function record(value: unknown): UnknownRecord {
+  return value && typeof value === 'object' && !Array.isArray(value) ? value as UnknownRecord : {}
+}
+
+export function string(value: unknown, fallback = ''): string {
+  if (typeof value === 'string') return value
+  if (typeof value === 'number' && Number.isFinite(value)) return String(value)
+  return fallback
+}
+
+export function number(value: unknown, fallback = 0): number {
+  const parsed = typeof value === 'number' ? value : Number(value)
+  return Number.isFinite(parsed) ? parsed : fallback
+}
+
+export function bool(value: unknown, fallback = false): boolean {
+  if (typeof value === 'boolean') return value
+  if (value === 1 || value === '1' || value === 'true') return true
+  if (value === 0 || value === '0' || value === 'false') return false
+  return fallback
+}
+
+export function values(value: unknown): unknown[] {
+  return Array.isArray(value) ? value : []
+}
+
+function pick(source: UnknownRecord, ...keys: string[]): unknown {
+  for (const key of keys) if (source[key] !== undefined && source[key] !== null) return source[key]
+  return undefined
+}
+
+export function normalizeUser(value: unknown): CurrentUser {
+  const source = record(value)
+  const id = string(pick(source, 'id', 'user_id', 'userId')) || string(pick(source, 'email', 'username')) || 'local'
+  return {
+    id,
+    username: string(pick(source, 'username', 'display_name', 'displayName', 'email', 'user_id', 'userId'), id),
+    displayName: string(pick(source, 'display_name', 'displayName')) || undefined,
+    email: string(source.email) || undefined,
+    provider: string(source.provider) || undefined,
+    role: string(source.role) || undefined,
+  }
+}
+
+export function normalizeProfile(value: unknown): Profile {
+  const source = record(value)
+  const name = string(pick(source, 'name', 'profile', 'id'), 'default')
+  return {
+    name,
+    agentName: string(pick(source, 'agentName', 'agent_name')) || undefined,
+    displayName: string(pick(source, 'display_name', 'displayName', 'alias', 'description')) || undefined,
+    description: string(source.description) || undefined,
+    isDefault: bool(pick(source, 'is_default', 'isDefault', 'active')),
+    model: string(source.model) || undefined,
+    provider: string(source.provider) || undefined,
+    gatewayRunning: source.gateway_running === undefined && source.gatewayRunning === undefined
+      ? undefined
+      : bool(pick(source, 'gateway_running', 'gatewayRunning')),
+    avatar: string(source.avatar) || undefined,
+  }
+}
+
+export function normalizeModel(value: unknown): ModelOption {
+  const source = record(value)
+  const provider = string(source.provider)
+  const id = string(pick(source, 'id', 'model', 'value'))
+  return {
+    id,
+    name: string(pick(source, 'name', 'display_name', 'displayName'), id),
+    provider,
+    supportsReasoning: source.supports_reasoning === undefined && source.supportsReasoning === undefined
+      ? undefined
+      : bool(pick(source, 'supports_reasoning', 'supportsReasoning')),
+    reasoningEfforts: values(pick(source, 'reasoning_efforts', 'reasoningEfforts')).map(value => string(value)).filter(Boolean),
+    isDefault: bool(pick(source, 'is_default', 'isDefault')),
+  }
+}
+
+export function normalizeSession(value: unknown, fallbackProfile?: string): SessionSummary {
+  const source = record(value)
+  const startedAt = number(pick(source, 'started_at', 'startedAt', 'created_at', 'createdAt'))
+  const updatedAt = number(pick(source, 'last_active', 'lastActive', 'updated_at', 'updatedAt'), startedAt)
+  const id = string(pick(source, 'id', 'session_id', 'sessionId', 'stored_session_id', 'storedSessionId'))
+  return {
+    id,
+    profile: string(source.profile, fallbackProfile) || undefined,
+    source: string(source.source, 'cli'),
+    title: string(source.title, '未命名会话'),
+    preview: string(source.preview) || undefined,
+    model: string(source.model) || undefined,
+    provider: string(source.provider) || undefined,
+    agent: string(source.agent) || undefined,
+    messageCount: number(pick(source, 'message_count', 'messageCount')),
+    toolCallCount: number(pick(source, 'tool_call_count', 'toolCallCount')),
+    inputTokens: number(pick(source, 'input_tokens', 'inputTokens')) || undefined,
+    outputTokens: number(pick(source, 'output_tokens', 'outputTokens')) || undefined,
+    startedAt,
+    updatedAt,
+    endedAt: pick(source, 'ended_at', 'endedAt') == null ? null : number(pick(source, 'ended_at', 'endedAt')),
+    parentSessionId: string(pick(source, 'parent_session_id', 'parentSessionId')) || null,
+    forkPointMessageId: string(pick(source, 'fork_point_message_id', 'forkPointMessageId')) || null,
+    workspace: string(source.workspace) || null,
+    archived: bool(pick(source, 'is_archived', 'isArchived', 'archived')),
+    pinned: bool(pick(source, 'is_pinned', 'isPinned', 'pinned')),
+  }
+}
+
+function normalizeToolCall(value: unknown, index: number): ToolCall {
+  const source = record(value)
+  const error = string(source.error)
+  const result = pick(source, 'result', 'output') as JsonValue | undefined
+  return {
+    id: string(pick(source, 'id', 'tool_call_id', 'toolCallId', 'tool_id'), `tool-${index}`),
+    name: string(pick(source, 'name', 'tool_name', 'toolName'), '工具'),
+    status: error ? 'failed' : result !== undefined ? 'completed' : 'running',
+    arguments: pick(source, 'arguments', 'args', 'context') as JsonValue | undefined,
+    result,
+    preview: string(pick(source, 'preview', 'summary', 'args_text')) || undefined,
+    error: error || undefined,
+    durationSeconds: number(pick(source, 'duration_seconds', 'durationSeconds', 'duration_s')) || undefined,
+  }
+}
+
+export function normalizeAttachment(value: unknown, index = 0): ChatAttachment {
+  const source = record(value)
+  const mimeType = string(pick(source, 'mime_type', 'mimeType', 'media_type', 'mediaType'), 'application/octet-stream')
+  const name = string(pick(source, 'name', 'filename'), `附件 ${index + 1}`)
+  return {
+    id: string(source.id, `${name}:${index}`),
+    name,
+    mimeType,
+    size: number(source.size),
+    path: string(source.path) || undefined,
+    url: string(source.url) || undefined,
+    kind: mimeType.startsWith('image/') ? 'image' : mimeType === 'application/pdf' ? 'pdf' : 'file',
+  }
+}
+
+export function normalizeChatMessage(value: unknown, sessionId: string, fallbackProfile?: string): ChatMessage {
+  const source = record(value)
+  const contentValue = pick(source, 'content', 'text', 'output')
+  let content = typeof contentValue === 'string' ? contentValue : ''
+  let attachments: ChatAttachment[] = values(pick(source, 'attachments', 'files')).map(normalizeAttachment)
+  if (Array.isArray(contentValue)) {
+    const textBlocks: string[] = []
+    const attachmentBlocks: ChatAttachment[] = []
+    contentValue.forEach((blockValue, index) => {
+      const block = record(blockValue)
+      const type = string(block.type)
+      if (type === 'text') textBlocks.push(string(block.text))
+      if (type === 'image' || type === 'file') attachmentBlocks.push(normalizeAttachment(block, index))
+    })
+    content = textBlocks.filter(Boolean).join('\n\n')
+    attachments = [...attachments, ...attachmentBlocks]
+  }
+  const roleValue = string(source.role, 'assistant')
+  const role = ['user', 'assistant', 'tool', 'system'].includes(roleValue) ? roleValue as ChatMessage['role'] : 'system'
+  const id = string(pick(source, 'id', 'message_id', 'messageId')) || `history-${sessionId}-${number(source.timestamp)}-${Math.random()}`
+  const status = string(pick(source, 'status', 'finish_reason', 'finishReason'))
+  return {
+    id,
+    serverMessageId: id,
+    clientMessageId: string(pick(source, 'client_message_id', 'clientMessageId')) || undefined,
+    sessionId,
+    profile: string(source.profile, fallbackProfile) || undefined,
+    role,
+    content,
+    reasoning: string(pick(source, 'reasoning', 'thinking')) || undefined,
+    timestamp: number(pick(source, 'timestamp', 'created_at', 'createdAt', 'updated_at', 'updatedAt'), Date.now() / 1000),
+    sequence: number(pick(source, 'seq', 'sequence')) || undefined,
+    stage: status === 'error' || source.error ? 'failed' : 'settled',
+    error: string(source.error) || undefined,
+    attachments: attachments.length ? attachments : undefined,
+    toolCalls: values(pick(source, 'tool_calls', 'toolCalls', 'tools')).map(normalizeToolCall),
+    raw: value as JsonValue,
+  }
+}
+
+export function normalizeGroupCapabilities(value: unknown): GroupCapabilities {
+  const source = record(value)
+  const limits = record(source.limits)
+  return {
+    protocolVersion: number(pick(source, 'protocolVersion', 'protocol_version')),
+    journalEpoch: string(pick(source, 'journalEpoch', 'journal_epoch')),
+    latestCursor: number(pick(source, 'latestCursor', 'latest_cursor')),
+    limits: {
+      maxAgentsPerRoom: number(pick(limits, 'maxAgentsPerRoom', 'max_agents_per_room'), 8),
+      maxMessageBytes: number(pick(limits, 'maxMessageBytes', 'max_message_bytes'), 64 * 1024),
+      maxToolStateBytes: number(pick(limits, 'maxToolStateBytes', 'max_tool_state_bytes')) || undefined,
+      maxInteractionPayloadBytes: number(pick(limits, 'maxInteractionPayloadBytes', 'max_interaction_payload_bytes')) || undefined,
+      maxMessagePageSize: number(pick(limits, 'maxMessagePageSize', 'max_message_page_size'), 100),
+      maxEventBatchSize: number(pick(limits, 'maxEventBatchSize', 'max_event_batch_size')) || undefined,
+      maxAgentDepth: number(pick(limits, 'maxAgentDepth', 'max_agent_depth')) || undefined,
+      maxRoomConcurrency: number(pick(limits, 'maxRoomConcurrency', 'max_room_concurrency')) || undefined,
+      maxPluginConcurrency: number(pick(limits, 'maxPluginConcurrency', 'max_plugin_concurrency')) || undefined,
+      defaultMaxReplyRounds: number(pick(limits, 'defaultMaxReplyRounds', 'default_max_reply_rounds'), 3),
+      unlimitedReplyRoundsValue: number(pick(limits, 'unlimitedReplyRoundsValue', 'unlimited_reply_rounds_value'), -1),
+      maxAgentDisplayNameLength: number(pick(limits, 'maxAgentDisplayNameLength', 'max_agent_display_name_length'), 100),
+    },
+    eventTypes: values(pick(source, 'eventTypes', 'event_types')).map(value => string(value)).filter(Boolean),
+  }
+}
+
+export function normalizeGroupAgent(value: unknown): GroupAgent {
+  const source = record(value)
+  const status = string(source.status, 'unknown') as GroupAgent['status']
+  return {
+    id: string(source.id), roomId: string(pick(source, 'roomId', 'room_id')),
+    profile: string(source.profile), displayName: string(pick(source, 'displayName', 'display_name'), string(source.profile)),
+    description: string(source.description), storedSessionId: string(pick(source, 'storedSessionId', 'stored_session_id')) || null,
+    lastContextMessageSeq: number(pick(source, 'lastContextMessageSeq', 'last_context_message_seq')),
+    enabled: bool(source.enabled, true), replyWithoutMention: bool(pick(source, 'replyWithoutMention', 'reply_without_mention')),
+    createdAt: number(pick(source, 'createdAt', 'created_at')), updatedAt: number(pick(source, 'updatedAt', 'updated_at')),
+    status: ['idle', 'queued', 'running', 'awaiting_input'].includes(status) ? status : 'unknown',
+  }
+}
+
+export function normalizeGroupMessage(value: unknown): GroupMessage {
+  const source = record(value)
+  const senderKind = string(pick(source, 'senderKind', 'sender_kind'), 'unknown') as GroupMessage['senderKind']
+  const status = string(source.status, 'unknown') as GroupMessage['status']
+  return {
+    seq: number(source.seq), id: string(source.id), roomId: string(pick(source, 'roomId', 'room_id')),
+    senderKind: ['human', 'agent', 'system'].includes(senderKind) ? senderKind : 'unknown',
+    senderId: string(pick(source, 'senderId', 'sender_id')), senderName: string(pick(source, 'senderName', 'sender_name')),
+    rootMessageId: string(pick(source, 'rootMessageId', 'root_message_id')), replyToMessageId: string(pick(source, 'replyToMessageId', 'reply_to_message_id')) || null,
+    clientMessageId: string(pick(source, 'clientMessageId', 'client_message_id')) || null,
+    content: string(source.content), reasoning: string(source.reasoning), toolState: values(pick(source, 'toolState', 'tool_state')) as JsonValue[],
+    status: ['queued', 'streaming', 'completed', 'failed', 'interrupted'].includes(status) ? status : 'unknown',
+    error: string(source.error), createdAt: number(pick(source, 'createdAt', 'created_at')), updatedAt: number(pick(source, 'updatedAt', 'updated_at')),
+  }
+}
+
+export function normalizeGroupInteraction(value: unknown): GroupInteraction {
+  const source = record(value)
+  return {
+    id: string(source.id), roomId: string(pick(source, 'roomId', 'room_id')), agentId: string(pick(source, 'agentId', 'agent_id')),
+    runId: string(pick(source, 'runId', 'run_id')), kind: string(source.kind, 'unknown') as GroupInteraction['kind'],
+    payload: (source.payload ?? null) as JsonValue, status: string(source.status, 'unknown') as GroupInteraction['status'],
+    createdAt: number(pick(source, 'createdAt', 'created_at')), resolvedAt: pick(source, 'resolvedAt', 'resolved_at') == null ? null : number(pick(source, 'resolvedAt', 'resolved_at')),
+  }
+}
+
+export function normalizeGroupRoom(value: unknown): GroupRoomSummary {
+  const source = record(value)
+  const rawAgents = source.agents
+  return {
+    id: string(source.id), name: string(source.name, '未命名群聊'), cwd: string(source.cwd),
+    createdAt: number(pick(source, 'createdAt', 'created_at')), updatedAt: number(pick(source, 'updatedAt', 'updated_at')),
+    archived: bool(source.archived), agentCount: Array.isArray(rawAgents) ? rawAgents.length : number(pick(source, 'agentCount', 'agent_count')),
+    lastMessage: source.lastMessage || source.last_message ? normalizeGroupMessage(pick(source, 'lastMessage', 'last_message')) : null,
+    unreadCount: number(pick(source, 'unreadCount', 'unread_count')), maxReplyRounds: number(pick(source, 'maxReplyRounds', 'max_reply_rounds'), 3),
+  }
+}
+
+export function normalizeGroupRoomDetail(value: unknown): GroupRoomDetail {
+  const source = record(value)
+  const summary = normalizeGroupRoom(source)
+  return {
+    id: summary.id, name: summary.name, cwd: summary.cwd, createdAt: summary.createdAt, updatedAt: summary.updatedAt,
+    archived: summary.archived, maxReplyRounds: summary.maxReplyRounds,
+    agents: values(source.agents).map(normalizeGroupAgent), runs: values(source.runs).map(run => record(run) as unknown as GroupRoomDetail['runs'][number]),
+    pendingInteractions: values(pick(source, 'pendingInteractions', 'pending_interactions')).map(normalizeGroupInteraction),
+    latestCursor: number(pick(source, 'latestCursor', 'latest_cursor')),
+  }
+}
+
+export function inferFileKind(mimeType: string, name: string): FileKind {
+  const mime = mimeType.toLowerCase().split(';')[0]
+  if (mime.startsWith('image/')) return 'image'
+  if (mime.startsWith('video/')) return 'video'
+  if (mime.startsWith('audio/')) return 'audio'
+  if (mime.startsWith('text/') || /\.(pdf|docx?|xlsx?|pptx?|md|txt|csv|json|rtf|pages|numbers|key)$/i.test(name)) return 'document'
+  return 'other'
+}
+
+export function normalizeFile(value: unknown, fallbackProfile?: string): FileLibraryItem {
+  const source = record(value)
+  const id = string(pick(source, 'id', 'fileId', 'file_id'))
+  const mimeType = string(pick(source, 'mimeType', 'mime_type'), 'application/octet-stream')
+  const name = string(source.name)
+  const origins = values(source.origins).map(item => {
+    const origin = record(item)
+    return {
+      id: origin.id as number | string | undefined,
+      profile: string(origin.profile) || fallbackProfile || undefined,
+      sessionId: string(pick(origin, 'sessionId', 'session_id')) || undefined,
+      sessionTitle: string(pick(origin, 'sessionTitle', 'session_title')) || undefined,
+      messageId: string(pick(origin, 'messageId', 'message_id')) || undefined,
+      authorKind: string(pick(origin, 'authorKind', 'author_kind')) as FileLibraryItem['origins'][number]['authorKind'] || undefined,
+      authorName: string(pick(origin, 'authorName', 'author_name')) || undefined,
+      observedAt: number(pick(origin, 'observedAt', 'observed_at')) || undefined,
+      originalPath: string(pick(origin, 'originalPath', 'original_path')) || undefined,
+      referencePath: string(pick(origin, 'referencePath', 'reference_path')) || undefined,
+    }
+  })
+  const profile = origins.find(origin => origin.profile)?.profile
+  const profileQuery = profile ? `?profile=${encodeURIComponent(profile)}` : ''
+  return {
+    id, path: string(source.path), name, extension: string(pick(source, 'extension', 'extensionName', 'extension_name')),
+    mimeType, size: number(source.size), modifiedAt: number(pick(source, 'modifiedAt', 'modified_at')),
+    exists: bool(source.exists, true), availability: string(source.availability) as FileLibraryItem['availability'] || undefined,
+    archiveStatus: string(pick(source, 'archiveStatus', 'archive_status')) || undefined,
+    firstSeenAt: number(pick(source, 'firstSeenAt', 'first_seen_at')) || undefined,
+    lastSeenAt: number(pick(source, 'lastSeenAt', 'last_seen_at')) || undefined,
+    messageTimestamp: pick(source, 'messageTimestamp', 'message_timestamp') == null ? null : number(pick(source, 'messageTimestamp', 'message_timestamp')),
+    origins, kind: inferFileKind(mimeType, name),
+    previewUrl: string(pick(source, 'previewUrl', 'preview_url'))
+      || `/api/app/files/${encodeURIComponent(id)}/preview${profileQuery}`,
+    downloadUrl: string(pick(source, 'downloadUrl', 'download_url'))
+      || `/api/app/files/${encodeURIComponent(id)}/download${profileQuery}`,
+  }
+}

+ 103 - 0
src/client/views/ArtifactsView.vue

@@ -0,0 +1,103 @@
+<script setup lang="ts">
+import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
+import { useRouter } from 'vue-router'
+import type { ArtifactKind } from '@shared/types'
+import AppIcon from '@/components/common/AppIcon.vue'
+import LibraryGrid from '@/components/library/LibraryGrid.vue'
+import LibrarySidebar from '@/components/library/LibrarySidebar.vue'
+import PreviewModal from '@/components/library/PreviewModal.vue'
+import type { LibraryFilterOption, UiLibraryItem } from '@/components/library/types'
+import WorkspaceView from '@/components/workspace/WorkspaceView.vue'
+import { queueLibraryItemForComposer } from '@/components/workspace/pendingComposer'
+import { artifactToUi } from '@/components/workspace/viewModels'
+import { useArtifactsStore } from '@/stores/artifacts'
+import { useAuthStore } from '@/stores/auth'
+
+const artifacts = useArtifactsStore()
+const auth = useAuthStore()
+const router = useRouter()
+const selected = ref<UiLibraryItem | null>(null)
+let searchTimer: number | undefined
+
+const items = computed(() => artifacts.filteredArtifacts.map(artifactToUi))
+const options = computed<LibraryFilterOption[]>(() => {
+  const count = (kind: string) => artifacts.artifacts.filter(item => item.kind === kind).length
+  return [
+    { id: 'all', label: '全部产物', count: artifacts.artifacts.length, icon: 'artifacts' },
+    { id: 'image', label: '图片', count: count('image'), icon: 'image' },
+    { id: 'file', label: '文件', count: count('file'), icon: 'file' },
+    { id: 'link', label: '链接', count: count('link'), icon: 'link' },
+  ]
+})
+const progress = computed(() => artifacts.isLoading
+  ? `正在读取会话 ${artifacts.loadedSessionCount}/${artifacts.totalSessionCount || '…'}`
+  : artifacts.failedSessionCount ? `${artifacts.failedSessionCount} 个会话暂不可读` : artifacts.artifacts.length ? '已完成增量索引' : '')
+
+function updateSearch(value: string) {
+  window.clearTimeout(searchTimer)
+  searchTimer = window.setTimeout(() => { artifacts.searchQuery = value }, 120)
+}
+
+function updateFilter(value: string) { artifacts.kindFilter = value as ArtifactKind | 'all' }
+
+async function addToComposer(item: UiLibraryItem) {
+  if (!queueLibraryItemForComposer(item)) return
+  await router.push('/chat')
+}
+
+async function openSource(item: UiLibraryItem) {
+  if (!item.sourceSessionId) return
+  if (item.sourceProfile && auth.profiles.some(profile => profile.name === item.sourceProfile)) {
+    auth.selectProfile(item.sourceProfile)
+  }
+  await router.push({
+    path: `/chat/${encodeURIComponent(item.sourceSessionId)}`,
+    query: {
+      ...(item.sourceMessageId ? { message: item.sourceMessageId } : {}),
+      ...(item.sourceProfile ? { profile: item.sourceProfile } : {}),
+    },
+  })
+}
+
+onMounted(() => artifacts.load())
+onBeforeUnmount(() => window.clearTimeout(searchTimer))
+</script>
+
+<template>
+  <WorkspaceView sidebar-title="产物" :sidebar-subtitle="`${artifacts.artifacts.length} 项内容`">
+    <template #sidebar>
+      <LibrarySidebar
+        :search="artifacts.searchQuery"
+        :filter="artifacts.kindFilter"
+        :options="options"
+        :loading="artifacts.isLoading"
+        :error="artifacts.error"
+        :progress="progress"
+        @search="updateSearch"
+        @filter="updateFilter"
+        @refresh="artifacts.refresh"
+      />
+    </template>
+    <section class="library-workspace">
+      <header class="library-header"><div><h2>产物</h2><p>从普通会话的 Agent 与工具消息中提取</p></div><button class="quiet-button" type="button" :disabled="artifacts.isLoading" @click="artifacts.refresh"><AppIcon name="refresh" :size="14" />刷新索引</button></header>
+      <LibraryGrid
+        kind="artifacts"
+        :items="items"
+        :selected-id="selected?.id"
+        :loading="artifacts.isLoading"
+        empty-title="还没有产物"
+        empty-description="Agent 生成的图片、文件和链接会自动整理到这里。"
+        @select="selected = $event"
+        @add-to-composer="addToComposer"
+        @source="openSource"
+      />
+    </section>
+  </WorkspaceView>
+  <PreviewModal v-if="selected" :item="selected" @close="selected = null" @add-to-composer="addToComposer" @source="openSource" />
+</template>
+
+<style scoped>
+.library-workspace { display: flex; min-width: 0; min-height: 0; flex: 1; flex-direction: column; }
+.library-header { display: flex; min-height: 62px; align-items: center; justify-content: space-between; gap: 12px; padding: 10px 20px; border-bottom: 1px solid var(--line); }.library-header h2 { margin: 0; font-size: 14px; letter-spacing: -.02em; }.library-header p { margin: 3px 0 0; color: var(--text-muted); font-size: 9px; }.library-header .quiet-button { display: flex; gap: 6px; font-size: 10px; }
+@media (max-width: 600px) { .library-header { min-height: 54px; padding: 8px 13px; }.library-header .quiet-button { width: 34px; min-width: 34px; padding: 0; font-size: 0; } }
+</style>

+ 294 - 0
src/client/views/ChatView.vue

@@ -0,0 +1,294 @@
+<script setup lang="ts">
+import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
+import { useRoute, useRouter } from 'vue-router'
+import AppIcon from '@/components/common/AppIcon.vue'
+import ChoiceDialog from '@/components/common/ChoiceDialog.vue'
+import type { ChoiceOption } from '@/components/common/types'
+import ComposerShell from '@/components/composer/ComposerShell.vue'
+import type { ComposerOption, ComposerReference, ComposerSubmit } from '@/components/composer/types'
+import PreviewInspector from '@/components/library/PreviewInspector.vue'
+import type { UiLibraryItem } from '@/components/library/types'
+import MessageTimeline from '@/components/messages/MessageTimeline.vue'
+import type { UiMessage } from '@/components/messages/types'
+import ResourceSidebar from '@/components/app/ResourceSidebar.vue'
+import WorkspaceView from '@/components/workspace/WorkspaceView.vue'
+import { chatInteraction, chatMessageToUi, sessionSidebarItem } from '@/components/workspace/viewModels'
+import { consumeLibraryItemForComposer } from '@/components/workspace/pendingComposer'
+import { useAuthStore } from '@/stores/auth'
+import { useChatStore } from '@/stores/chat'
+
+const auth = useAuthStore()
+const chat = useChatStore()
+const route = useRoute()
+const router = useRouter()
+const search = ref('')
+const quoted = ref<UiMessage | null>(null)
+const preview = ref<UiLibraryItem | null>(null)
+const modelDialog = ref(false)
+const showTools = ref(true)
+const queueMode = ref(false)
+const actionSessionId = ref('')
+const renaming = ref(false)
+const renameValue = ref('')
+const composer = ref<InstanceType<typeof ComposerShell> | null>(null)
+const timeline = ref<InstanceType<typeof MessageTimeline> | null>(null)
+
+const sessions = computed(() => [...chat.sessions]
+  .filter(session => !['cron', 'ios_group'].includes(session.source))
+  .filter(session => !search.value.trim() || `${session.title} ${session.preview || ''}`.toLocaleLowerCase().includes(search.value.trim().toLocaleLowerCase())))
+const agentNames = computed(() => new Map(auth.profiles.map(profile => [
+  profile.name,
+  profile.agentName || profile.displayName || profile.name,
+])))
+const sidebarItems = computed(() => sessions.value.map(session => sessionSidebarItem(
+  session,
+  chat.unreadCounts[session.id] ?? 0,
+  agentNames.value.get(session.profile || auth.activeProfile?.name || ''),
+)))
+const messages = computed(() => chat.messages.map(chatMessageToUi))
+const activeSession = computed(() => chat.activeSession)
+const connected = computed(() => ['connected', 'ready'].includes(chat.connectionState))
+const interaction = computed(() => chatInteraction(chat.pendingApproval, chat.pendingClarification))
+const reference = computed<ComposerReference | null>(() => quoted.value ? { id: quoted.value.id, content: quoted.value.content, author: quoted.value.author } : null)
+const forkSourceTitle = computed(() => {
+  const parentId = activeSession.value?.parentSessionId
+  if (!parentId) return ''
+  return chat.sessions.find(session => session.id === parentId && session.profile === activeSession.value?.profile)?.title || parentId
+})
+const modelOptions = computed<ChoiceOption[]>(() => chat.models.map(model => ({ id: `${model.provider}:${model.id}`, label: model.name || model.id, description: model.provider })))
+const selectedModelId = computed(() => chat.selectedModel ? `${chat.selectedModel.provider}:${chat.selectedModel.id}` : '')
+const reasoningOptions: ChoiceOption[] = [
+  { id: '', label: '默认', description: '使用模型或 Profile 的默认强度' },
+  { id: 'none', label: '关闭', description: '不使用额外推理' },
+  { id: 'low', label: '低', description: '更快的轻量推理' },
+  { id: 'medium', label: '中', description: '速度与深度平衡' },
+  { id: 'high', label: '高', description: '更深入地分析复杂问题' },
+  { id: 'xhigh', label: '极高', description: '适用于最复杂的任务' },
+]
+const reasoningComposerOptions = computed<ComposerOption[]>(() => reasoningOptions.map(option => ({
+  id: option.id,
+  label: option.label,
+  detail: option.description,
+})))
+const reasoningLabel = computed(() => reasoningOptions.find(option => option.id === (chat.reasoningEffort || ''))?.label || '默认')
+
+async function refreshSessions() {
+  await Promise.all([chat.loadSessions(auth.activeProfile?.name), chat.loadUnread(auth.activeProfile?.name)])
+  const requestedId = typeof route.params.sessionId === 'string' ? route.params.sessionId : ''
+  if (requestedId && chat.activeSessionId !== requestedId) await chat.selectSession(requestedId, auth.activeProfile?.name)
+}
+
+async function chooseSession(id: string) {
+  const session = chat.sessions.find(item => item.id === id)
+  await router.push(`/chat/${encodeURIComponent(id)}`)
+  await chat.selectSession(id, session?.profile)
+  await chat.markRead(id, session?.profile)
+  quoted.value = null
+}
+
+async function createSession() {
+  const id = chat.createSession(auth.activeProfile?.name)
+  await router.push(`/chat/${encodeURIComponent(id)}`)
+}
+
+async function send(payload: ComposerSubmit) {
+  try {
+    await chat.send(payload.text, payload.files, queueMode.value ? 'queue' : chat.isStreaming ? 'steer' : 'submit')
+    composer.value?.clearAfterSend()
+  } catch { /* store exposes the error and the composer keeps its draft */ }
+}
+
+function openAttachment(attachment: NonNullable<UiMessage['attachments']>[number]) {
+  preview.value = { id: attachment.id, name: attachment.name, kind: attachment.kind || 'file', size: attachment.size, previewUrl: attachment.url, downloadUrl: attachment.url }
+}
+
+async function selectModel(id: string) {
+  const [provider, ...rest] = id.split(':')
+  const model = chat.models.find(item => item.provider === provider && item.id === rest.join(':'))
+  if (model) await chat.setModel(model)
+  modelDialog.value = false
+}
+
+function selectReasoning(id: string) {
+  chat.reasoningEffort = id || undefined
+}
+
+function openSessionActions(id: string) {
+  actionSessionId.value = id
+  const session = chat.sessions.find(item => item.id === id)
+  renameValue.value = session?.title || ''
+  renaming.value = false
+}
+
+async function saveRename() {
+  const session = chat.sessions.find(item => item.id === actionSessionId.value)
+  if (!session || !renameValue.value.trim()) return
+  await chat.renameSession(session.id, renameValue.value.trim(), session.profile)
+  actionSessionId.value = ''
+}
+
+async function deleteSession() {
+  const session = chat.sessions.find(item => item.id === actionSessionId.value)
+  if (!session) return
+  await chat.removeSession(session.id, session.profile)
+  actionSessionId.value = ''
+  if (route.params.sessionId === session.id) await router.replace('/chat')
+}
+
+async function branch() {
+  const id = await chat.branchSession()
+  if (id) await router.push(`/chat/${encodeURIComponent(id)}`)
+}
+
+async function revealSourceMessage() {
+  const messageId = typeof route.query.message === 'string' ? route.query.message : ''
+  if (!messageId) return
+  await nextTick()
+  for (let attempt = 0; attempt < 10; attempt += 1) {
+    if (timeline.value?.scrollToMessage(messageId)) return
+    if (!chat.hasMoreBefore) return
+    await chat.loadOlder()
+    await nextTick()
+  }
+}
+
+onMounted(async () => {
+  await Promise.allSettled([refreshSessions(), chat.loadModels(auth.activeProfile?.name), chat.connect()])
+  await revealSourceMessage()
+  const pendingFile = await consumeLibraryItemForComposer()
+  if (pendingFile) composer.value?.attachFiles([pendingFile])
+})
+
+onBeforeUnmount(() => chat.disconnect())
+
+watch(() => auth.activeProfile?.name, async (profile, previous) => {
+  if (!profile || profile === previous) return
+  chat.switchProfile(profile)
+  await router.replace('/chat')
+  await Promise.allSettled([chat.loadSessions(profile), chat.loadModels(profile), chat.loadUnread(profile), chat.connect()])
+})
+
+watch(() => chat.activeSessionId, async id => {
+  if (!id || route.params.sessionId === id) return
+  await router.replace(`/chat/${encodeURIComponent(id)}`)
+})
+</script>
+
+<template>
+  <WorkspaceView sidebar-title="对话" :sidebar-subtitle="`${sessions.length} 个会话`" :inspector-open="!!preview" @close-inspector="preview = null">
+    <template #sidebar-action>
+      <button class="sidebar-primary-action" type="button" title="新建聊天" aria-label="新建聊天" @click="createSession">
+        <AppIcon name="edit" :size="17" />
+        <span>新建聊天</span>
+      </button>
+    </template>
+    <template #sidebar>
+      <ResourceSidebar
+        :items="sidebarItems"
+        :active-id="chat.activeSessionId"
+        :loading="chat.isLoading"
+        :search="search"
+        search-placeholder="搜索会话"
+        empty-title="还没有会话"
+        empty-description="新建会话后,从输入框开始聊天。"
+        @search="search = $event"
+        @select="chooseSession"
+        @more="openSessionActions"
+      />
+    </template>
+
+    <div class="chat-workspace">
+      <MessageTimeline
+        ref="timeline"
+        :messages="messages"
+        :title="activeSession?.title || '新会话'"
+        :subtitle="activeSession ? `${activeSession.profile || auth.activeProfile?.name || ''} · ${activeSession.model || chat.selectedModel?.name || '默认模型'}` : '选择会话或直接开始输入'"
+        :loading="chat.activeRouteState?.isLoadingHistory || chat.isLoading"
+        :loading-older="chat.activeRouteState?.isLoadingHistory"
+        :has-older="chat.hasMoreBefore"
+        :connected="connected"
+        :synced="chat.historySynced"
+        :show-tools="showTools"
+        :interaction="interaction"
+        empty-logo
+        transparent-header
+        :fork-source-title="forkSourceTitle"
+        @load-older="chat.loadOlder"
+        @quote="quoted = $event"
+        @branch="branch"
+        @preview="openAttachment"
+        @approve="interaction && chat.respondToApproval(interaction.id, $event)"
+        @clarify="interaction && chat.respondToClarification(interaction.id, $event)"
+      >
+        <template #header-actions>
+          <button v-if="activeSession" class="icon-button header-session-menu" type="button" title="会话操作" aria-label="会话操作" @click="openSessionActions(activeSession.id)"><AppIcon name="dots" :size="18" /></button>
+        </template>
+      </MessageTimeline>
+      <p v-if="chat.error" class="chat-error" role="alert"><AppIcon name="alert" :size="13" />{{ chat.error }}</p>
+      <ComposerShell
+        ref="composer"
+        mode="chat"
+        :draft-key="`${auth.user?.id || 'local'}:${auth.activeProfile?.name || 'default'}:${chat.activeSessionId || 'new'}`"
+        :disabled="chat.connectionState === 'failed'"
+        :streaming="chat.isStreaming"
+        :sending="chat.isSending"
+        :model-label="chat.selectedModel?.name || '选择模型'"
+        :reasoning-effort="reasoningLabel"
+        :reasoning-value="chat.reasoningEffort || ''"
+        :reasoning-options="reasoningComposerOptions"
+        :context-used="chat.contextUsage?.contextTokens || chat.contextUsage?.totalTokens || 0"
+        :context-limit="chat.contextUsage?.contextLimit || 262144"
+        :queue-mode="queueMode || chat.isQueued"
+        :tool-trace-visible="showTools"
+        :reference="reference"
+        :slash-commands="[
+          { id: 'fork', label: 'fork', detail: '从当前会话创建分支' },
+          { id: 'compact', label: 'compact', detail: '压缩当前会话上下文' },
+          { id: 'model', label: 'model', detail: '切换模型' },
+        ]"
+        @send="send"
+        @stop="chat.interrupt"
+        @model-click="modelDialog = true"
+        @reasoning-change="selectReasoning"
+        @settings-click="showTools = !showTools"
+        @queue-toggle="queueMode = !queueMode"
+        @clear-reference="quoted = null"
+      />
+    </div>
+
+    <template #inspector><PreviewInspector v-if="preview" :item="preview" @close="preview = null" @add-to-composer="preview = null" /></template>
+  </WorkspaceView>
+
+  <ChoiceDialog :open="modelDialog" title="选择模型" :options="modelOptions" :selected-id="selectedModelId" @close="modelDialog = false" @select="selectModel" />
+
+  <Teleport to="body">
+    <Transition name="choice-fade">
+      <div v-if="actionSessionId" class="session-action-layer" @mousedown.self="actionSessionId = ''">
+        <section class="session-actions" role="dialog" aria-modal="true" aria-label="会话操作">
+          <header><strong>会话操作</strong><button class="icon-button" type="button" aria-label="关闭" @click="actionSessionId = ''"><AppIcon name="close" /></button></header>
+          <template v-if="renaming">
+            <label>会话名称<input v-model="renameValue" maxlength="120" autofocus @keydown.enter="saveRename" /></label>
+            <div><button class="quiet-button" type="button" @click="renaming = false">取消</button><button class="solid-button" type="button" @click="saveRename">保存</button></div>
+          </template>
+          <template v-else>
+            <button class="action-row" type="button" @click="renaming = true"><AppIcon name="edit" :size="15" />重命名</button>
+            <button class="action-row danger" type="button" @click="deleteSession"><AppIcon name="trash" :size="15" />删除会话</button>
+          </template>
+        </section>
+      </div>
+    </Transition>
+  </Teleport>
+</template>
+
+<style scoped>
+.chat-workspace { display: flex; min-width: 0; min-height: 0; flex: 1; flex-direction: column; }
+.sidebar-primary-action { display: flex; width: 100%; min-height: 40px; align-items: center; gap: 10px; padding: 0 11px; border: 0; border-radius: 9px; background: transparent; color: var(--text-primary); cursor: pointer; font-size: 12px; font-weight: 610; text-align: left; transition: background-color 120ms ease; }
+.sidebar-primary-action:hover, .sidebar-primary-action:focus-visible { background: var(--surface-hover); outline: 0; }
+.sidebar-primary-action:focus-visible { box-shadow: inset 0 0 0 1px var(--line-strong); }
+.header-session-menu { color: var(--text-muted); background: transparent; }.header-session-menu:hover { color: var(--text-primary); background: var(--surface-soft); }
+.chat-error { display: flex; width: min(760px, calc(100% - 32px)); margin: 0 auto 4px; align-items: center; gap: 6px; color: var(--danger); font-size: 9px; }
+.session-action-layer { position: fixed; z-index: 205; inset: 0; display: grid; place-items: center; padding: 18px; background: var(--scrim); backdrop-filter: blur(4px); }
+.session-actions { width: min(340px, 100%); padding: 12px; border: 1px solid var(--line); border-radius: 15px; background: var(--surface-raised); box-shadow: var(--shadow-float); }.session-actions header { display: flex; min-height: 40px; align-items: center; justify-content: space-between; padding: 0 2px 7px 7px; }.session-actions header strong { font-size: 13px; }.action-row { display: flex; width: 100%; min-height: 42px; align-items: center; gap: 9px; padding: 0 9px; border: 0; border-radius: 9px; background: transparent; color: var(--text-secondary); cursor: pointer; text-align: left; }.action-row:hover { background: var(--surface-soft); color: var(--text-primary); }.action-row.danger { color: var(--danger); }.session-actions label { display: flex; flex-direction: column; gap: 6px; color: var(--text-muted); font-size: 10px; }.session-actions input { height: 38px; padding: 0 9px; border: 1px solid var(--line); border-radius: 9px; outline: 0; background: var(--surface-soft); color: var(--text-primary); }.session-actions input:focus { border-color: var(--line-strong); }.session-actions > div { display: flex; justify-content: flex-end; gap: 7px; margin-top: 13px; }
+@media (max-width: 600px) { .session-action-layer { place-items: end center; padding: 0; }.session-actions { width: 100%; border-radius: 17px 17px 0 0; padding-bottom: max(12px, env(safe-area-inset-bottom)); } }
+@media (prefers-reduced-motion: reduce) { .sidebar-primary-action { transition: none; } }
+</style>

+ 115 - 0
src/client/views/FilesView.vue

@@ -0,0 +1,115 @@
+<script setup lang="ts">
+import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
+import { useRouter } from 'vue-router'
+import type { FileKind } from '@shared/types'
+import AppIcon from '@/components/common/AppIcon.vue'
+import LibraryGrid from '@/components/library/LibraryGrid.vue'
+import LibrarySidebar from '@/components/library/LibrarySidebar.vue'
+import PreviewModal from '@/components/library/PreviewModal.vue'
+import type { LibraryFilterOption, UiLibraryItem } from '@/components/library/types'
+import WorkspaceView from '@/components/workspace/WorkspaceView.vue'
+import { queueLibraryItemForComposer } from '@/components/workspace/pendingComposer'
+import { fileToUi } from '@/components/workspace/viewModels'
+import { useFilesStore } from '@/stores/files'
+import { useAuthStore } from '@/stores/auth'
+
+const files = useFilesStore()
+const auth = useAuthStore()
+const router = useRouter()
+const selected = ref<UiLibraryItem | null>(null)
+let searchTimer: number | undefined
+
+const items = computed(() => files.filteredItems.map(fileToUi))
+const options = computed<LibraryFilterOption[]>(() => {
+  const count = (kind: string) => files.items.filter(item => item.kind === kind).length
+  return [
+    { id: 'all', label: '全部文件', count: files.items.length, icon: 'files' },
+    { id: 'image', label: '图片', count: count('image'), icon: 'image' },
+    { id: 'video', label: '视频', count: count('video'), icon: 'video' },
+    { id: 'audio', label: '音频', count: count('audio'), icon: 'audio' },
+    { id: 'document', label: '文档', count: count('document'), icon: 'file' },
+    { id: 'other', label: '其他', count: count('other'), icon: 'file' },
+  ]
+})
+
+function updateSearch(value: string) {
+  files.query = value
+  window.clearTimeout(searchTimer)
+  searchTimer = window.setTimeout(() => void files.load(true), 260)
+}
+
+function updateFilter(value: string) {
+  files.kindFilter = value as FileKind | 'all'
+  void files.load(true)
+}
+
+async function addToComposer(item: UiLibraryItem) {
+  if (!queueLibraryItemForComposer(item)) return
+  await router.push('/chat')
+}
+
+async function openSource(item: UiLibraryItem) {
+  if (!item.sourceSessionId) return
+  if (item.sourceProfile && auth.profiles.some(profile => profile.name === item.sourceProfile)) {
+    auth.selectProfile(item.sourceProfile)
+  }
+  await router.push({
+    path: `/chat/${encodeURIComponent(item.sourceSessionId)}`,
+    query: {
+      ...(item.sourceMessageId ? { message: item.sourceMessageId } : {}),
+      ...(item.sourceProfile ? { profile: item.sourceProfile } : {}),
+    },
+  })
+}
+
+onMounted(() => files.load(true))
+onBeforeUnmount(() => window.clearTimeout(searchTimer))
+watch(() => files.items, () => {
+  if (selected.value && !files.items.some(item => item.id === selected.value?.id)) selected.value = null
+})
+watch(() => auth.activeProfile?.name, profile => {
+  selected.value = null
+  files.profile = profile
+  void files.load(true)
+})
+</script>
+
+<template>
+  <WorkspaceView sidebar-title="文件库" :sidebar-subtitle="`${files.items.length} 个文件`">
+    <template #sidebar>
+      <LibrarySidebar
+        :search="files.query"
+        :filter="files.kindFilter"
+        :options="options"
+        :loading="files.isLoading"
+        :error="files.error"
+        :progress="files.nextCursor ? '还有更多文件可加载' : files.items.length ? '已同步到最新' : ''"
+        @search="updateSearch"
+        @filter="updateFilter"
+        @refresh="files.load(true)"
+      />
+    </template>
+    <section class="library-workspace">
+      <header class="library-header"><div><h2>文件库</h2><p>9119 保存的文件与附件</p></div><span><AppIcon name="files" :size="15" />{{ items.length }}</span></header>
+      <LibraryGrid
+        :items="items"
+        :selected-id="selected?.id"
+        :loading="files.isLoading"
+        :has-more="files.hasMore"
+        empty-title="文件库还是空的"
+        empty-description="聊天中的图片、文档和附件会出现在这里。"
+        @select="selected = $event"
+        @load-more="files.loadMore"
+        @add-to-composer="addToComposer"
+        @source="openSource"
+      />
+    </section>
+  </WorkspaceView>
+  <PreviewModal v-if="selected" :item="selected" @close="selected = null" @add-to-composer="addToComposer" @source="openSource" />
+</template>
+
+<style scoped>
+.library-workspace { display: flex; min-width: 0; min-height: 0; flex: 1; flex-direction: column; }
+.library-header { display: flex; min-height: 62px; align-items: center; justify-content: space-between; gap: 12px; padding: 10px 20px; border-bottom: 1px solid var(--line); }.library-header h2 { margin: 0; font-size: 14px; letter-spacing: -.02em; }.library-header p { margin: 3px 0 0; color: var(--text-muted); font-size: 9px; }.library-header > span { display: flex; align-items: center; gap: 6px; color: var(--text-muted); font-size: 10px; }
+@media (max-width: 600px) { .library-header { min-height: 54px; padding: 8px 13px; } }
+</style>

+ 212 - 0
src/client/views/GroupsView.vue

@@ -0,0 +1,212 @@
+<script setup lang="ts">
+import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
+import { useRoute, useRouter } from 'vue-router'
+import AppIcon from '@/components/common/AppIcon.vue'
+import EmptyState from '@/components/common/EmptyState.vue'
+import ComposerShell from '@/components/composer/ComposerShell.vue'
+import type { ComposerOption, ComposerReference, ComposerSubmit } from '@/components/composer/types'
+import CreateGroupDialog from '@/components/groups/CreateGroupDialog.vue'
+import GroupManager from '@/components/groups/GroupManager.vue'
+import MessageTimeline from '@/components/messages/MessageTimeline.vue'
+import type { UiMessage } from '@/components/messages/types'
+import ResourceSidebar from '@/components/app/ResourceSidebar.vue'
+import WorkspaceView from '@/components/workspace/WorkspaceView.vue'
+import { agentToUi, groupInteraction, groupMessageToUi, roomSidebarItem, roomToUi } from '@/components/workspace/viewModels'
+import { useAuthStore } from '@/stores/auth'
+import { useGroupsStore } from '@/stores/groups'
+
+const auth = useAuthStore()
+const groups = useGroupsStore()
+const route = useRoute()
+const router = useRouter()
+const search = ref('')
+const createOpen = ref(false)
+const managerOpen = ref(false)
+const showTools = ref(true)
+const quoted = ref<UiMessage | null>(null)
+const composer = ref<InstanceType<typeof ComposerShell> | null>(null)
+
+const filteredRooms = computed(() => groups.rooms
+  .filter(room => !room.archived)
+  .filter(room => !search.value.trim() || `${room.name} ${room.lastMessage?.content || ''}`.toLocaleLowerCase().includes(search.value.trim().toLocaleLowerCase())))
+const sidebarItems = computed(() => filteredRooms.value.map(roomSidebarItem))
+const messages = computed(() => groups.messages.map(groupMessageToUi))
+const agents = computed(() => groups.agents.map(agentToUi))
+const connected = computed(() => ['connected', 'ready'].includes(groups.connectionState))
+const activeInteraction = computed(() => groupInteraction(groups.pendingInteractions[0]))
+const room = computed(() => groups.selectedRoom ? roomToUi(groups.selectedRoom) : null)
+const profiles = computed(() => auth.profiles.map(profile => profile.name))
+const availableProfiles = computed(() => profiles.value.filter(profile => !groups.agents.some(agent => agent.profile === profile)))
+const uploadsEnabled = computed(() => auth.groupUploadsEnabled)
+const reference = computed<ComposerReference | null>(() => quoted.value ? { id: quoted.value.id, author: quoted.value.author, content: quoted.value.content } : null)
+const mentionOptions = computed<ComposerOption[]>(() => [
+  { id: 'all', label: '所有人', detail: '通知房间内全部 Agent' },
+  ...groups.agents.map(agent => ({ id: agent.id, label: agent.displayName || agent.profile, detail: agent.profile, disabled: !agent.enabled })),
+])
+
+async function selectRoom(id: string) {
+  await router.push(`/groups/${encodeURIComponent(id)}`)
+  await groups.selectRoom(id)
+  quoted.value = null
+}
+
+async function createRoom(payload: { name: string; profiles: string[]; autoReply: boolean; replyRounds: number }) {
+  const detail = await groups.createRoom({
+    name: payload.name,
+    agents: payload.profiles.map(profile => ({ profile, displayName: auth.profiles.find(item => item.name === profile)?.agentName || auth.profiles.find(item => item.name === profile)?.displayName || profile, replyWithoutMention: payload.autoReply })),
+    maxReplyRounds: payload.replyRounds,
+  })
+  createOpen.value = false
+  await router.push(`/groups/${encodeURIComponent(detail.id)}`)
+}
+
+async function send(payload: ComposerSubmit) {
+  const prefix = quoted.value ? `> ${quoted.value.content.replace(/\n/g, '\n> ')}\n\n` : ''
+  try {
+    await groups.sendMessage(`${prefix}${payload.text}`, payload.mentionIds, payload.files)
+    quoted.value = null
+    composer.value?.clearAfterSend()
+  } catch { /* store publishes the error */ }
+}
+
+async function updateRoom(patch: { name?: string; replyRounds?: number }) {
+  if (!groups.selectedRoom) return
+  await groups.updateRoom(groups.selectedRoom.id, { name: patch.name, maxReplyRounds: patch.replyRounds })
+}
+
+async function addAgent(profile: string) {
+  if (!groups.selectedRoom) return
+  await groups.addAgent(groups.selectedRoom.id, { profile, displayName: auth.profiles.find(item => item.name === profile)?.agentName || auth.profiles.find(item => item.name === profile)?.displayName || profile, replyWithoutMention: true })
+}
+
+async function updateAgent(id: string, patch: { enabled?: boolean; autoReply?: boolean }) {
+  if (!groups.selectedRoom) return
+  await groups.updateAgent(groups.selectedRoom.id, id, { enabled: patch.enabled, replyWithoutMention: patch.autoReply })
+}
+
+async function archiveRoom() {
+  if (!groups.selectedRoom) return
+  await groups.archiveRoom(groups.selectedRoom.id)
+  managerOpen.value = false
+  await router.replace(groups.selectedRoomId ? `/groups/${encodeURIComponent(groups.selectedRoomId)}` : '/groups')
+}
+
+onMounted(async () => {
+  try {
+    await groups.start()
+    const requested = typeof route.params.roomId === 'string' ? route.params.roomId : ''
+    if (requested && requested !== groups.selectedRoomId) await groups.selectRoom(requested)
+    else if (groups.selectedRoomId) await router.replace(`/groups/${encodeURIComponent(groups.selectedRoomId)}`)
+  } catch { /* availability state renders the error */ }
+})
+
+onBeforeUnmount(() => groups.stop())
+
+watch(() => route.params.roomId, async roomId => {
+  if (typeof roomId === 'string' && roomId && roomId !== groups.selectedRoomId) await groups.selectRoom(roomId)
+})
+</script>
+
+<template>
+  <WorkspaceView sidebar-title="群聊" :sidebar-subtitle="groups.availability === 'available' ? `${filteredRooms.length} 个活跃房间` : '9119 群聊 v2'" :inspector-open="managerOpen && !!room" @close-inspector="managerOpen = false">
+    <template #sidebar-action>
+      <button class="sidebar-primary-action" type="button" :disabled="groups.availability !== 'available'" title="新建群聊" aria-label="新建群聊" @click="createOpen = true">
+        <AppIcon name="edit" :size="17" />
+        <span>新建群聊</span>
+      </button>
+    </template>
+    <template #sidebar>
+      <ResourceSidebar
+        :items="sidebarItems"
+        :active-id="groups.selectedRoomId"
+        :loading="groups.isLoading"
+        :search="search"
+        search-placeholder="搜索群聊"
+        empty-title="还没有群聊"
+        empty-description="新建群聊,邀请 1–8 个 Agent 一起协作。"
+        @search="search = $event"
+        @select="selectRoom"
+        @more="id => { selectRoom(id); managerOpen = true }"
+      />
+    </template>
+
+    <div v-if="groups.availability === 'unsupported' || groups.availability === 'unavailable'" class="groups-unavailable">
+      <EmptyState icon="alert" :title="groups.availability === 'unsupported' ? '群聊协议版本不兼容' : '群聊服务暂不可用'" :description="groups.error || '请确认 9119 已安装 YaoYao 群聊 protocol v2 插件。'" action-label="重新检查" @action="groups.refresh" />
+    </div>
+    <div v-else class="groups-workspace">
+      <MessageTimeline
+        :messages="messages"
+        :title="groups.selectedRoom?.name || '群聊'"
+        :subtitle="groups.selectedRoom ? `${groups.agents.length} 个 Agent · 最多 ${groups.selectedRoom.maxReplyRounds} 轮回复` : '选择或新建一个群聊'"
+        :loading="groups.isLoading"
+        :has-older="groups.hasMoreBefore"
+        :connected="connected"
+        :synced="!!groups.selectedRoom"
+        :show-tools="showTools"
+        :interaction="activeInteraction"
+        empty-title="让多个 Agent 一起工作"
+        empty-description="使用 @ 提及指定 Agent,或直接发送消息触发已启用自动回复的成员。"
+        @load-older="groups.loadOlder"
+        @quote="quoted = $event"
+        @approve="activeInteraction && groups.approveInteraction(activeInteraction.id, $event ? 'once' : 'deny')"
+        @clarify="activeInteraction && groups.clarifyInteraction(activeInteraction.id, $event)"
+      >
+        <template #header-actions>
+          <div class="group-header-actions">
+            <div class="group-avatars" aria-label="群聊成员"><span v-for="agent in agents.slice(0, 4)" :key="agent.id" :title="agent.name">{{ agent.name.slice(0, 1).toUpperCase() }}</span><em v-if="agents.length > 4">+{{ agents.length - 4 }}</em></div>
+            <button class="icon-button" type="button" title="管理群聊" aria-label="管理群聊" :disabled="!groups.selectedRoom" @click="managerOpen = true"><AppIcon name="dots" /></button>
+          </div>
+        </template>
+      </MessageTimeline>
+      <p v-if="groups.error" class="group-error" role="alert"><AppIcon name="alert" :size="13" />{{ groups.error }}</p>
+      <ComposerShell
+        ref="composer"
+        mode="group"
+        :draft-key="`${auth.user?.id || 'local'}:${groups.selectedRoomId || 'new'}`"
+        :disabled="!groups.selectedRoom || groups.availability !== 'available'"
+        :streaming="groups.agents.some(agent => ['queued', 'running'].includes(agent.status))"
+        :sending="groups.isSending"
+        :tool-trace-visible="showTools"
+        :reference="reference"
+        :mention-options="mentionOptions"
+        :attachments-enabled="uploadsEnabled"
+        placeholder="发消息给群聊,输入 @ 提及 Agent"
+        @send="send"
+        @stop="groups.agents.filter(agent => ['queued', 'running'].includes(agent.status)).forEach(agent => groups.interruptAgent(agent.id))"
+        @tool-trace-toggle="showTools = !showTools"
+        @clear-reference="quoted = null"
+      />
+    </div>
+
+    <template #inspector>
+      <GroupManager
+        v-if="room && groups.selectedRoom"
+        :room="room"
+        :agents="agents"
+        :available-profiles="availableProfiles"
+        :busy="groups.isLoading"
+        @close="managerOpen = false"
+        @update-room="updateRoom"
+        @add-agent="addAgent"
+        @update-agent="updateAgent"
+        @remove-agent="groups.removeAgent(groups.selectedRoom.id, $event)"
+        @interrupt-agent="groups.interruptAgent($event, groups.selectedRoom.id)"
+        @archive-room="archiveRoom"
+      />
+    </template>
+  </WorkspaceView>
+
+  <CreateGroupDialog :open="createOpen" :profiles="profiles" :busy="groups.isLoading" @close="createOpen = false" @create="createRoom" />
+</template>
+
+<style scoped>
+.groups-workspace, .groups-unavailable { display: flex; min-width: 0; min-height: 0; flex: 1; flex-direction: column; background: var(--conversation-canvas); }.groups-unavailable { overflow: auto; }
+.sidebar-primary-action { display: flex; width: 100%; min-height: 40px; align-items: center; gap: 10px; padding: 0 11px; border: 0; border-radius: 9px; background: transparent; color: var(--text-primary); cursor: pointer; font-size: 12px; font-weight: 610; text-align: left; transition: background-color 120ms ease; }
+.sidebar-primary-action:hover, .sidebar-primary-action:focus-visible { background: var(--surface-hover); outline: 0; }
+.sidebar-primary-action:focus-visible { box-shadow: inset 0 0 0 1px var(--line-strong); }
+.sidebar-primary-action:disabled { cursor: not-allowed; opacity: .35; }
+.group-header-actions { display: flex; align-items: center; gap: 8px; }.group-avatars { display: flex; align-items: center; }.group-avatars span, .group-avatars em { display: grid; width: 24px; height: 24px; margin-left: -5px; place-items: center; border: 2px solid var(--canvas); border-radius: 8px; background: var(--accent); color: var(--text-on-solid); font-size: 8px; font-style: normal; font-weight: 650; }.group-avatars span:first-child { margin-left: 0; }.group-avatars em { background: var(--surface-hover); color: var(--text-secondary); }
+.group-error { display: flex; width: min(760px, calc(100% - 32px)); margin: 0 auto 4px; align-items: center; gap: 6px; color: var(--danger); font-size: 9px; }
+@media (max-width: 480px) { .group-avatars { display: none; } }
+@media (prefers-reduced-motion: reduce) { .sidebar-primary-action { transition: none; } }
+</style>

+ 70 - 0
src/client/views/LoginView.vue

@@ -0,0 +1,70 @@
+<script setup lang="ts">
+import { computed, nextTick, ref } from 'vue'
+import BrandMark from '@/components/common/BrandMark.vue'
+import AppIcon from '@/components/common/AppIcon.vue'
+import { useAuthStore } from '@/stores/auth'
+import { useThemeStore } from '@/stores/theme'
+
+const auth = useAuthStore()
+const theme = useThemeStore()
+const username = ref('')
+const password = ref('')
+const provider = ref('')
+const localError = ref('')
+const submitting = computed(() => auth.status === 'authenticating')
+
+async function login() {
+  if (!username.value.trim() || !password.value) return
+  localError.value = ''
+  try {
+    await auth.login({ username: username.value.trim(), password: password.value, provider: provider.value || undefined })
+  } catch (error) {
+    localError.value = error instanceof Error ? error.message : '登录失败,请检查账号和密码'
+    password.value = ''
+    await nextTick()
+    document.querySelector<HTMLInputElement>('#password')?.focus()
+  }
+}
+</script>
+
+<template>
+  <main class="login-view">
+    <button class="theme-button icon-button" type="button" :aria-label="theme.resolvedTheme === 'dark' ? '切换浅色主题' : '切换深色主题'" @click="theme.toggle">
+      <AppIcon :name="theme.resolvedTheme === 'dark' ? 'sun' : 'moon'" />
+    </button>
+    <div class="login-atmosphere" aria-hidden="true"><i /><i /><i /></div>
+    <section class="login-panel" aria-labelledby="login-title">
+      <BrandMark :size="72" label />
+      <div class="login-copy">
+        <h1 id="login-title">登录 Hermes</h1>
+        <p>使用 9119 Gateway 的现有账号继续。</p>
+      </div>
+      <form @submit.prevent="login">
+        <label><span>账号</span><input v-model="username" name="username" autocomplete="username" autofocus required placeholder="用户名" /></label>
+        <label><span>密码</span><input id="password" v-model="password" name="password" type="password" autocomplete="current-password" required placeholder="密码" /></label>
+        <label class="provider"><span>认证方式 <small>可选</small></span><input v-model="provider" name="provider" autocomplete="off" placeholder="使用服务器默认设置" /></label>
+        <p v-if="localError || auth.error" class="login-error" role="alert"><AppIcon name="alert" :size="14" />{{ localError || auth.error }}</p>
+        <button class="login-submit solid-button" type="submit" :disabled="submitting || !username.trim() || !password">
+          <span>{{ submitting ? '正在登录…' : '登录' }}</span><AppIcon v-if="!submitting" name="arrow-up" :size="15" />
+        </button>
+      </form>
+      <p class="login-note"><span class="status-dot" />凭据只发送到当前 8800 服务,不会写入浏览器存储。</p>
+    </section>
+  </main>
+</template>
+
+<style scoped>
+.login-view { position: relative; display: grid; width: 100%; height: 100%; place-items: center; overflow: hidden; padding: 24px; background: var(--canvas); }
+.theme-button { position: absolute; z-index: 3; top: max(15px, env(safe-area-inset-top)); right: 16px; }
+.login-atmosphere { position: absolute; inset: 0; pointer-events: none; opacity: .75; }.login-atmosphere i { position: absolute; display: block; border: 1px solid var(--line); border-radius: 50%; }.login-atmosphere i:nth-child(1) { width: 520px; height: 520px; top: -360px; left: -180px; }.login-atmosphere i:nth-child(2) { width: 310px; height: 310px; right: -190px; bottom: -170px; }.login-atmosphere i:nth-child(3) { width: 3px; height: 3px; top: 22%; right: 18%; border: 0; background: var(--text-muted); box-shadow: -80px 160px 0 var(--line-strong), 90px 270px 0 var(--line-strong), -900px 340px 0 var(--line-strong); }
+.login-panel { position: relative; z-index: 2; width: min(368px, 100%); animation: login-enter 360ms var(--ease-out) both; }.login-panel :deep(.brand-mark) { display: flex; flex-direction: column; align-items: flex-start; gap: 16px; }.login-panel :deep(.brand-mark__name) { font-size: 25px; }
+.login-copy { margin: 38px 0 22px; }.login-copy h1 { margin: 0; font-size: 18px; font-weight: 630; letter-spacing: -.03em; }.login-copy p { margin: 6px 0 0; color: var(--text-muted); font-size: 11px; }
+form { display: flex; flex-direction: column; gap: 13px; } label { display: flex; flex-direction: column; gap: 6px; color: var(--text-secondary); font-size: 10px; } label span { display: flex; justify-content: space-between; } label small { color: var(--text-muted); font-weight: 400; }
+input { width: 100%; height: 43px; padding: 0 11px; border: 1px solid var(--line); border-radius: 11px; outline: 0; background: var(--surface-raised); color: var(--text-primary); font-size: 13px; transition: border-color 140ms ease, box-shadow 140ms ease; } input::placeholder { color: var(--text-muted); } input:focus { border-color: var(--line-strong); box-shadow: 0 0 0 3px var(--focus-ring); }
+.provider { margin-top: 1px; }.provider input { height: 37px; background: var(--surface-soft); font-size: 11px; }
+.login-error { display: flex; align-items: flex-start; gap: 7px; margin: 0; padding: 8px 9px; border-radius: 9px; background: color-mix(in srgb, var(--danger) 9%, transparent); color: var(--danger); font-size: 10px; line-height: 1.5; }
+.login-submit { justify-content: space-between; width: 100%; height: 43px; margin-top: 3px; padding: 0 12px 0 15px; }.login-submit .app-icon { transform: rotate(45deg); }
+.login-note { display: flex; align-items: center; justify-content: center; gap: 6px; margin: 20px 0 0; color: var(--text-muted); font-size: 9px; line-height: 1.5; text-align: center; }.login-note .status-dot { width: 5px; height: 5px; background: var(--success); }
+@keyframes login-enter { from { opacity: 0; transform: translateY(10px); } }
+@media (max-width: 480px) { .login-view { place-items: start center; padding: max(74px, calc(env(safe-area-inset-top) + 58px)) 22px 24px; overflow-y: auto; }.login-panel :deep(.brand-mark__plate) { width: 64px !important; height: 64px !important; }.login-copy { margin-top: 30px; } }
+</style>

+ 164 - 0
src/server/app.ts

@@ -0,0 +1,164 @@
+import { randomBytes } from 'node:crypto'
+import { readFileSync } from 'node:fs'
+import { createServer as createHttpServer, type Server as HttpServer } from 'node:http'
+import { createServer as createHttpsServer } from 'node:https'
+import { bodyParser } from '@koa/bodyparser'
+import Koa from 'koa'
+import type { ServerConfig } from './config.js'
+import { loadServerConfig } from './config.js'
+import { errorMessage, HttpError } from './errors.js'
+import { RealtimeLeaseStore } from './leases.js'
+import { createApiRouter } from './routes.js'
+import {
+  applySecurityHeaders,
+  CsrfProtection,
+  isAllowedHostHeader,
+  isExactOrigin,
+} from './security.js'
+import { UpstreamHttpError, UpstreamClient } from './upstream.js'
+import { UploadStore } from './uploads.js'
+import { installWebSocketRelay } from './websocket.js'
+
+export interface ApplicationOptions {
+  config?: ServerConfig
+  fetchImpl?: typeof fetch
+  csrfSecret?: Buffer
+  leases?: RealtimeLeaseStore
+  uploads?: UploadStore
+}
+
+export interface ApplicationRuntime {
+  app: Koa
+  config: ServerConfig
+  csrf: CsrfProtection
+  leases: RealtimeLeaseStore
+  upstream: UpstreamClient
+  uploads: UploadStore
+  close(): void
+}
+
+function isMutation(method: string): boolean {
+  return !['GET', 'HEAD', 'OPTIONS'].includes(method)
+}
+
+export function createApplication(options: ApplicationOptions = {}): ApplicationRuntime {
+  const config = options.config ?? loadServerConfig()
+  const app = new Koa()
+  app.proxy = false
+  const csrf = new CsrfProtection(options.csrfSecret ?? randomBytes(32), Boolean(config.tlsCert))
+  const leases = options.leases ?? new RealtimeLeaseStore()
+  const upstream = new UpstreamClient(config.upstream, options.fetchImpl, Boolean(config.tlsCert))
+  const uploads = options.uploads ?? new UploadStore(config.home)
+  try {
+    uploads.cleanupUncommitted()
+  } catch {
+    // A locked cleanup should not prevent startup; upload operations will
+    // still surface the underlying storage error when used.
+  }
+
+  app.use(async (ctx, next) => {
+    try {
+      await next()
+    } catch (error) {
+      if (error instanceof UpstreamHttpError) {
+        ctx.status = error.response.status
+        ctx.type = 'application/json; charset=utf-8'
+        ctx.body = { error: `Hermes returned HTTP ${error.response.status}`, code: error.code }
+        return
+      }
+      const status = error instanceof HttpError
+        ? error.status
+        : typeof (error as { status?: unknown })?.status === 'number'
+          ? Number((error as { status: number }).status)
+          : 500
+      const expose = status < 500 || error instanceof HttpError
+      ctx.status = status
+      ctx.type = 'application/json; charset=utf-8'
+      ctx.body = {
+        error: expose ? errorMessage(error) : 'Internal server error',
+        code: error instanceof HttpError ? error.code : 'internal_error',
+      }
+      if (status >= 500 && !config.production) ctx.app.emit('error', error, ctx)
+    }
+  })
+  app.use(async (ctx, next) => {
+    applySecurityHeaders(ctx, Boolean(config.tlsCert))
+    if (!isAllowedHostHeader(ctx.get('host'), config)) {
+      throw new HttpError(421, 'Request Host is not allowed', 'invalid_host')
+    }
+    await next()
+  })
+  app.use(async (ctx, next) => {
+    if (ctx.path.startsWith('/api/app/') && isMutation(ctx.method)) {
+      const secure = ctx.secure || Boolean(config.tlsCert)
+      if (!isExactOrigin(ctx.get('origin'), ctx.get('host'), secure)) {
+        throw new HttpError(403, 'Request Origin is not allowed', 'invalid_origin')
+      }
+      if (!csrf.verify(ctx.get('cookie'), ctx.get('x-csrf-token'))) {
+        throw new HttpError(403, 'CSRF token is invalid or missing', 'invalid_csrf')
+      }
+    }
+    await next()
+  })
+  app.use(bodyParser({
+    encoding: 'utf-8',
+    enableTypes: ['json'],
+    jsonLimit: '2mb',
+    onError: (_error, ctx) => {
+      throw new HttpError(ctx.status === 413 ? 413 : 400, 'Invalid JSON request body', 'invalid_json_body')
+    },
+  }))
+
+  const router = createApiRouter({ config, csrf, upstream, leases, uploads })
+  app.use(router.routes())
+  app.use(router.allowedMethods({ throw: false }))
+  app.use(async (ctx, next) => {
+    if (ctx.path === '/api' || ctx.path.startsWith('/api/') || ctx.path.startsWith('/ws/')) {
+      ctx.status = 404
+      ctx.type = 'application/json; charset=utf-8'
+      ctx.body = { error: 'Route not found', code: 'not_found' }
+      return
+    }
+    await next()
+  })
+
+  return {
+    app,
+    config,
+    csrf,
+    leases,
+    upstream,
+    uploads,
+    close: () => uploads.close(),
+  }
+}
+
+export interface NodeServerRuntime {
+  server: HttpServer
+  close(): Promise<void>
+}
+
+export function createNodeServer(runtime: ApplicationRuntime): NodeServerRuntime {
+  const { config } = runtime
+  const server: HttpServer = config.tlsCert && config.tlsKey
+    ? createHttpsServer({
+        cert: readFileSync(config.tlsCert),
+        key: readFileSync(config.tlsKey),
+      }, runtime.app.callback())
+    : createHttpServer(runtime.app.callback())
+  const removeWebSockets = installWebSocketRelay(server, config, runtime.leases)
+  return {
+    server,
+    close: async () => {
+      removeWebSockets()
+      await new Promise<void>((resolve, reject) => {
+        if (!server.listening) {
+          resolve()
+          return
+        }
+        server.close((error) => error ? reject(error) : resolve())
+      })
+      runtime.close()
+    },
+  }
+}

+ 117 - 0
src/server/config.ts

@@ -0,0 +1,117 @@
+import { isIP } from 'node:net'
+import { homedir } from 'node:os'
+import { resolve } from 'node:path'
+
+export interface ServerConfig {
+  host: string
+  port: number
+  upstream: URL
+  allowedHosts: ReadonlySet<string>
+  home: string
+  tlsCert?: string
+  tlsKey?: string
+  allowInsecureLan: boolean
+  insecureLan: boolean
+  production: boolean
+}
+
+function flag(value: string | undefined): boolean {
+  return value === '1' || value?.toLowerCase() === 'true'
+}
+
+function parsePort(value: string | undefined): number {
+  const port = Number(value ?? '8800')
+  if (!Number.isInteger(port) || port < 1 || port > 65_535) {
+    throw new Error('HERMES_YAOYAO_PORT must be an integer between 1 and 65535')
+  }
+  return port
+}
+
+function parseUpstream(value: string | undefined): URL {
+  const upstream = new URL(value ?? 'http://127.0.0.1:9119')
+  if (!['http:', 'https:'].includes(upstream.protocol)) {
+    throw new Error('HERMES_YAOYAO_UPSTREAM must use http or https')
+  }
+  if (upstream.username || upstream.password || upstream.search || upstream.hash) {
+    throw new Error('HERMES_YAOYAO_UPSTREAM must not contain credentials, query, or fragment')
+  }
+  upstream.pathname = upstream.pathname.replace(/\/+$/, '') || '/'
+  return upstream
+}
+
+function normalizeConfiguredHost(raw: string): string {
+  const value = raw.trim().toLowerCase()
+  if (!value) return ''
+  if (value.startsWith('[') && value.endsWith(']')) return value.slice(1, -1)
+  return value.replace(/\.$/, '')
+}
+
+export function isLoopbackHost(host: string): boolean {
+  const normalized = normalizeConfiguredHost(host)
+  if (normalized === 'localhost' || normalized === '::1') return true
+  if (isIP(normalized) === 4) return normalized.split('.')[0] === '127'
+  return false
+}
+
+export function isPrivateHost(host: string): boolean {
+  const normalized = normalizeConfiguredHost(host)
+  if (isLoopbackHost(normalized)) return true
+  if (isIP(normalized) === 4) {
+    const parts = normalized.split('.').map(Number)
+    return parts[0] === 10
+      || (parts[0] === 100 && parts[1]! >= 64 && parts[1]! <= 127)
+      || (parts[0] === 169 && parts[1] === 254)
+      || (parts[0] === 172 && parts[1]! >= 16 && parts[1]! <= 31)
+      || (parts[0] === 192 && parts[1] === 168)
+  }
+  if (isIP(normalized) === 6) {
+    return normalized.startsWith('fc')
+      || normalized.startsWith('fd')
+      || /^fe[89ab]/.test(normalized)
+  }
+  return false
+}
+
+export function isLoopbackUpstream(upstream: URL): boolean {
+  return isLoopbackHost(upstream.hostname)
+}
+
+export function loadServerConfig(
+  env: NodeJS.ProcessEnv = process.env,
+): ServerConfig {
+  const host = env.HERMES_YAOYAO_HOST?.trim() || '0.0.0.0'
+  const port = parsePort(env.HERMES_YAOYAO_PORT)
+  const upstream = parseUpstream(env.HERMES_YAOYAO_UPSTREAM)
+  const home = resolve(env.HERMES_YAOYAO_HOME?.trim() || `${homedir()}/.hermes-yaoyao`)
+  const tlsCert = env.HERMES_YAOYAO_TLS_CERT?.trim() || undefined
+  const tlsKey = env.HERMES_YAOYAO_TLS_KEY?.trim() || undefined
+  if (Boolean(tlsCert) !== Boolean(tlsKey)) {
+    throw new Error('HERMES_YAOYAO_TLS_CERT and HERMES_YAOYAO_TLS_KEY must be set together')
+  }
+  const allowInsecureLan = flag(env.HERMES_YAOYAO_ALLOW_INSECURE_LAN)
+  const production = env.NODE_ENV === 'production'
+  const insecureLan = !tlsCert && !isLoopbackHost(host)
+  if (production && insecureLan && !allowInsecureLan) {
+    throw new Error(
+      'Plain HTTP on a non-loopback host requires HERMES_YAOYAO_ALLOW_INSECURE_LAN=1',
+    )
+  }
+  const allowedHosts = new Set(
+    (env.HERMES_YAOYAO_ALLOWED_HOSTS ?? '')
+      .split(',')
+      .map(normalizeConfiguredHost)
+      .filter(Boolean),
+  )
+  return {
+    host,
+    port,
+    upstream,
+    allowedHosts,
+    home,
+    tlsCert,
+    tlsKey,
+    allowInsecureLan,
+    insecureLan,
+    production,
+  }
+}

+ 13 - 0
src/server/errors.ts

@@ -0,0 +1,13 @@
+export class HttpError extends Error {
+  constructor(
+    readonly status: number,
+    message: string,
+    readonly code = 'request_failed',
+  ) {
+    super(message)
+  }
+}
+
+export function errorMessage(value: unknown): string {
+  return value instanceof Error ? value.message : 'Unexpected server error'
+}

+ 83 - 0
src/server/index.ts

@@ -0,0 +1,83 @@
+import { existsSync } from 'node:fs'
+import { resolve } from 'node:path'
+import send from 'koa-send'
+import serve from 'koa-static'
+import { createApplication, createNodeServer } from './app.js'
+
+const runtime = createApplication()
+const nodeRuntime = createNodeServer(runtime)
+let closeFrontend = async (): Promise<void> => undefined
+
+if (runtime.config.production) {
+  const dist = resolve(process.cwd(), 'dist')
+  runtime.app.use(serve(dist, { index: 'index.html' }))
+  runtime.app.use(async (ctx) => {
+    if (ctx.method !== 'GET' || !ctx.accepts('html') || !existsSync(resolve(dist, 'index.html'))) {
+      ctx.status = 404
+      return
+    }
+    await send(ctx, 'index.html', { root: dist })
+  })
+} else {
+  const { createServer } = await import('vite')
+  const vite = await createServer({
+    server: {
+      middlewareMode: true,
+      ws: { server: nodeRuntime.server },
+    },
+    appType: 'spa',
+  })
+  closeFrontend = () => vite.close()
+  runtime.app.use(async (ctx) => {
+    const handled = await new Promise<boolean>((resolvePromise, reject) => {
+      const finish = () => {
+        cleanup()
+        resolvePromise(true)
+      }
+      const cleanup = () => {
+        ctx.res.off('finish', finish)
+        ctx.res.off('close', finish)
+      }
+      ctx.res.once('finish', finish)
+      ctx.res.once('close', finish)
+      vite.middlewares(ctx.req, ctx.res, (error?: unknown) => {
+        cleanup()
+        if (error) reject(error)
+        else resolvePromise(false)
+      })
+    })
+    if (handled) ctx.respond = false
+    else ctx.status = 404
+  })
+}
+
+nodeRuntime.server.once('error', (error: NodeJS.ErrnoException) => {
+  if (error.code === 'EADDRINUSE') {
+    console.error(`夭夭 Web could not start: ${runtime.config.host}:${runtime.config.port} is already in use`)
+  } else {
+    console.error(`夭夭 Web could not start: ${error.message}`)
+  }
+  process.exitCode = 1
+})
+nodeRuntime.server.listen(runtime.config.port, runtime.config.host, () => {
+  const protocol = runtime.config.tlsCert ? 'https' : 'http'
+  console.log(`夭夭 Web listening on ${protocol}://${runtime.config.host}:${runtime.config.port}`)
+  if (runtime.config.insecureLan) {
+    console.warn('Warning: trusted-LAN HTTP mode is enabled; credentials are not encrypted in transit.')
+  }
+})
+
+let closing = false
+async function shutdown(): Promise<void> {
+  if (closing) return
+  closing = true
+  try {
+    await closeFrontend()
+    await nodeRuntime.close()
+  } finally {
+    process.exitCode = 0
+  }
+}
+
+process.once('SIGINT', () => { void shutdown() })
+process.once('SIGTERM', () => { void shutdown() })

+ 102 - 0
src/server/leases.ts

@@ -0,0 +1,102 @@
+import { randomBytes } from 'node:crypto'
+import { HttpError } from './errors.js'
+
+export type RealtimeChannel = 'chat' | 'groups'
+export type UpstreamCredential = { name: 'ticket' | 'token'; value: string }
+
+export interface RealtimeLease {
+  id: string
+  channel: RealtimeChannel
+  credential: UpstreamCredential
+  origin: string
+  accountKeys: ReadonlySet<string>
+  epoch?: string
+  cursor?: number
+  expiresAt: number
+}
+
+export interface IssueLeaseInput {
+  channel: RealtimeChannel
+  credential: UpstreamCredential
+  origin: string
+  accountKeys: Iterable<string>
+  epoch?: string
+  cursor?: number
+}
+
+export class RealtimeLeaseStore {
+  readonly #leases = new Map<string, RealtimeLease>()
+
+  constructor(
+    readonly ttlMs = 20_000,
+    readonly now: () => number = Date.now,
+  ) {}
+
+  issue(input: IssueLeaseInput): RealtimeLease {
+    this.prune()
+    const accountKeys = new Set([...input.accountKeys].filter(Boolean))
+    if (accountKeys.size === 0) throw new HttpError(401, 'Missing account scope', 'missing_account')
+    const activeForAccount = [...this.#leases.values()].filter((lease) =>
+      [...accountKeys].some((key) => lease.accountKeys.has(key)),
+    ).length
+    if (activeForAccount >= 16) {
+      throw new HttpError(429, 'Too many pending realtime leases', 'lease_limit')
+    }
+    const id = randomBytes(32).toString('base64url')
+    const lease: RealtimeLease = {
+      id,
+      channel: input.channel,
+      credential: input.credential,
+      origin: input.origin,
+      accountKeys,
+      epoch: input.epoch,
+      cursor: input.cursor,
+      expiresAt: this.now() + this.ttlMs,
+    }
+    this.#leases.set(id, lease)
+    return lease
+  }
+
+  consume(
+    id: string,
+    channel: RealtimeChannel,
+    origin: string,
+    accountKey: string,
+  ): RealtimeLease {
+    this.prune()
+    const lease = this.#leases.get(id)
+    if (!lease || lease.channel !== channel || lease.origin !== origin || !lease.accountKeys.has(accountKey)) {
+      throw new HttpError(401, 'Realtime lease is invalid or expired', 'invalid_lease')
+    }
+    this.#leases.delete(id)
+    return lease
+  }
+
+  prune(): void {
+    const now = this.now()
+    for (const [id, lease] of this.#leases) {
+      if (lease.expiresAt <= now) this.#leases.delete(id)
+    }
+  }
+
+  get size(): number {
+    this.prune()
+    return this.#leases.size
+  }
+}
+
+export function canonicalEpoch(value: unknown): string {
+  if (typeof value !== 'string'
+    || !/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(value)) {
+    throw new HttpError(400, 'epoch must be a canonical lowercase UUID', 'invalid_epoch')
+  }
+  return value
+}
+
+export function groupCursor(value: unknown): number {
+  const cursor = typeof value === 'number' ? value : Number(value ?? 0)
+  if (!Number.isSafeInteger(cursor) || cursor < 0) {
+    throw new HttpError(400, 'cursor must be a non-negative integer', 'invalid_cursor')
+  }
+  return cursor
+}

+ 624 - 0
src/server/routes.ts

@@ -0,0 +1,624 @@
+import type Koa from 'koa'
+import Router from '@koa/router'
+import { parse } from 'cookie'
+import type { ServerConfig } from './config.js'
+import { isLoopbackUpstream } from './config.js'
+import { HttpError } from './errors.js'
+import { canonicalEpoch, groupCursor, type RealtimeChannel, RealtimeLeaseStore } from './leases.js'
+import { accountKeyFromCookieHeader, CsrfProtection, expectedRequestOrigin, requestAccountKey } from './security.js'
+import {
+  applyUpstreamCookies,
+  CookieJar,
+  decodeUpstreamError,
+  sendUpstreamResponse,
+  type UpstreamResponse,
+  UpstreamClient,
+} from './upstream.js'
+import { receiveGroupUploads, uploadMarkdown, UploadStore } from './uploads.js'
+
+type JsonObject = Record<string, unknown>
+
+export interface RouteDependencies {
+  config: ServerConfig
+  csrf: CsrfProtection
+  upstream: UpstreamClient
+  leases: RealtimeLeaseStore
+  uploads: UploadStore
+}
+
+function body(ctx: Koa.Context): JsonObject {
+  const value = (ctx.request as Koa.Request & { body?: unknown }).body
+  if (!value || typeof value !== 'object' || Array.isArray(value)) {
+    throw new HttpError(400, 'A JSON object body is required', 'invalid_json_body')
+  }
+  return value as JsonObject
+}
+
+function parseJson(response: UpstreamResponse): JsonObject {
+  try {
+    const value = JSON.parse(response.body.toString('utf8')) as unknown
+    if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('not an object')
+    return value as JsonObject
+  } catch {
+    throw new HttpError(502, 'Hermes returned invalid JSON', 'invalid_upstream_json')
+  }
+}
+
+function requireSuccess(response: UpstreamResponse): JsonObject {
+  if (response.status < 200 || response.status >= 300) {
+    const decoded = decodeUpstreamError(response)
+    throw new HttpError(response.status, decoded.error, decoded.code)
+  }
+  return parseJson(response)
+}
+
+function searchFrom(ctx: Koa.Context, allowed: readonly string[]): URLSearchParams {
+  const incoming = new URLSearchParams(ctx.querystring)
+  const outgoing = new URLSearchParams()
+  for (const name of allowed) {
+    for (const value of incoming.getAll(name)) outgoing.append(name, value)
+  }
+  return outgoing
+}
+
+function safeIdentifier(value: string, label = 'identifier'): string {
+  const normalized = value.trim()
+  if (!normalized || normalized === '.' || normalized === '..'
+    || normalized.length > 256 || /[\u0000-\u001f\u007f/\\]/.test(normalized)) {
+    throw new HttpError(400, `Invalid ${label}`, 'invalid_identifier')
+  }
+  return normalized
+}
+
+function canonicalUUID(value: string, label: string): string {
+  const normalized = value.toLowerCase()
+  if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(normalized)) {
+    throw new HttpError(400, `${label} must be a UUID`, 'invalid_identifier')
+  }
+  return normalized
+}
+
+function normalizedProfiles(value: JsonObject): unknown[] {
+  const profiles = Array.isArray(value.profiles) ? value.profiles : []
+  return profiles.map((entry) => {
+    if (entry && typeof entry === 'object' && !Array.isArray(entry)) {
+      const object = entry as JsonObject
+      return object.profile && typeof object.profile === 'object' ? object.profile : object
+    }
+    return entry
+  })
+}
+
+function profilesWithAgentNames(profiles: unknown[], pluginProfiles: JsonObject | undefined): unknown[] {
+  const items = Array.isArray(pluginProfiles?.profiles) ? pluginProfiles.profiles : []
+  const names = new Map<string, string>()
+  for (const entry of items) {
+    if (!entry || typeof entry !== 'object' || Array.isArray(entry)) continue
+    const profile = entry as JsonObject
+    const name = typeof profile.name === 'string' ? profile.name : ''
+    const agentName = typeof profile.agentName === 'string' ? profile.agentName.trim() : ''
+    if (name && agentName) names.set(name, agentName)
+  }
+  return profiles.map(entry => {
+    if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return entry
+    const profile = entry as JsonObject
+    const name = typeof profile.name === 'string' ? profile.name : typeof profile.profile === 'string' ? profile.profile : ''
+    const agentName = names.get(name)
+    return agentName ? { ...profile, agentName } : profile
+  })
+}
+
+function authRequired(status: JsonObject): boolean {
+  return status.auth_required === true || status.authRequired === true
+}
+
+function publicStatus(status: JsonObject): JsonObject {
+  return {
+    state: typeof status.overall === 'string' ? status.overall : 'unknown',
+    version: typeof status.version === 'string' ? status.version : undefined,
+    gatewayRunning: status.gateway_running === true || status.gatewayRunning === true,
+    gatewayState: typeof status.gateway_state === 'string'
+      ? status.gateway_state
+      : typeof status.gatewayState === 'string' ? status.gatewayState : undefined,
+  }
+}
+
+async function withJar<T>(ctx: Koa.Context, run: (jar: CookieJar) => Promise<T>): Promise<T> {
+  const jar = new CookieJar(ctx.get('cookie'))
+  try {
+    return await run(jar)
+  } finally {
+    applyUpstreamCookies(ctx, jar)
+  }
+}
+
+function json(ctx: Koa.Context, status: number, value: unknown): void {
+  ctx.status = status
+  ctx.type = 'application/json; charset=utf-8'
+  ctx.body = value
+}
+
+async function proxy(
+  ctx: Koa.Context,
+  upstream: UpstreamClient,
+  path: string,
+  options: {
+    search?: URLSearchParams
+    method?: string
+    requestBody?: unknown
+    requestHeaders?: Record<string, string>
+    maxResponseBytes?: number
+  } = {},
+): Promise<void> {
+  await withJar(ctx, async (jar) => {
+    const response = await upstream.request(path, jar, {
+      method: options.method,
+      search: options.search,
+      body: options.requestBody,
+      headers: options.requestHeaders,
+      maxResponseBytes: options.maxResponseBytes,
+    })
+    sendUpstreamResponse(ctx, response, jar)
+  })
+}
+
+async function bootstrap(
+  ctx: Koa.Context,
+  dependencies: RouteDependencies,
+  jar: CookieJar,
+  rotateCsrf = false,
+): Promise<void> {
+  const statusResponse = await dependencies.upstream.request('/api/status', jar)
+  if (statusResponse.status < 200 || statusResponse.status >= 300) {
+    sendUpstreamResponse(ctx, statusResponse, jar)
+    return
+  }
+  const status = parseJson(statusResponse)
+  const required = authRequired(status)
+  const identityResponse = await dependencies.upstream.request('/api/auth/me', jar)
+  if (required && [401, 403].includes(identityResponse.status)) {
+    json(ctx, 200, {
+      status: publicStatus(status),
+      authRequired: required,
+      authenticated: false,
+      profiles: [],
+      csrfToken: dependencies.csrf.issue(ctx, rotateCsrf),
+      insecureLan: dependencies.config.insecureLan,
+      groupUploadsEnabled: isLoopbackUpstream(dependencies.config.upstream),
+    })
+    return
+  }
+  if (identityResponse.status < 200 || identityResponse.status >= 300) {
+    // An auth-disabled local Gateway may not expose an identity route.
+    if (required) {
+      sendUpstreamResponse(ctx, identityResponse, jar)
+      return
+    }
+  }
+  const profilesResponse = await dependencies.upstream.request('/api/profiles', jar)
+  if (profilesResponse.status < 200 || profilesResponse.status >= 300) {
+    sendUpstreamResponse(ctx, profilesResponse, jar)
+    return
+  }
+  const identity = identityResponse.status >= 200 && identityResponse.status < 300
+    ? parseJson(identityResponse)
+    : { user_id: 'local', display_name: '本机 Hermes', provider: 'local' }
+  const pluginProfilesResponse = await dependencies.upstream.request('/api/plugins/yaoyao/profiles', jar)
+  const profiles = profilesWithAgentNames(
+    normalizedProfiles(parseJson(profilesResponse)),
+    pluginProfilesResponse.status >= 200 && pluginProfilesResponse.status < 300 ? parseJson(pluginProfilesResponse) : undefined,
+  )
+  json(ctx, 200, {
+    status: publicStatus(status),
+    authRequired: required,
+    authenticated: true,
+    user: identity.user && typeof identity.user === 'object' ? identity.user : identity,
+    profiles,
+    csrfToken: dependencies.csrf.issue(ctx, rotateCsrf),
+    insecureLan: dependencies.config.insecureLan,
+    groupUploadsEnabled: isLoopbackUpstream(dependencies.config.upstream),
+  })
+}
+
+async function login(ctx: Koa.Context, dependencies: RouteDependencies, jar: CookieJar): Promise<void> {
+  const request = body(ctx)
+  const username = typeof request.username === 'string' ? request.username.trim() : ''
+  const password = typeof request.password === 'string' ? request.password : ''
+  if (!username || !password || username.length > 320 || password.length > 4_096) {
+    throw new HttpError(400, 'Username and password are required', 'invalid_credentials')
+  }
+
+  const providersResponse = await dependencies.upstream.request('/api/auth/providers', jar)
+  const providersBody = requireSuccess(providersResponse)
+  const providers = Array.isArray(providersBody.providers) ? providersBody.providers : []
+  const passwordProviders = providers.filter((entry): entry is JsonObject =>
+    Boolean(entry && typeof entry === 'object' && !Array.isArray(entry)
+      && (entry as JsonObject).supports_password === true),
+  )
+  const requestedProvider = typeof request.provider === 'string' ? request.provider.trim() : ''
+  const provider = requestedProvider
+    ? passwordProviders.find((entry) => entry.name === requestedProvider)
+    : passwordProviders.find((entry) => String(entry.name).toLowerCase() === 'basic')
+      ?? (passwordProviders.length === 1 ? passwordProviders[0] : undefined)
+  if (!provider || typeof provider.name !== 'string') {
+    throw new HttpError(403, 'Hermes does not offer the requested password provider', 'password_login_unavailable')
+  }
+
+  const response = await dependencies.upstream.request('/auth/password-login', jar, {
+    method: 'POST',
+    body: { provider: provider.name, username, password, next: '' },
+    clientAddress: ctx.req.socket.remoteAddress,
+  })
+  const result = requireSuccess(response)
+  if (result.ok !== true) throw new HttpError(401, 'Hermes rejected the login', 'login_failed')
+  await bootstrap(ctx, dependencies, jar, true)
+}
+
+async function requireGatewayAuthentication(
+  ctx: Koa.Context,
+  dependencies: RouteDependencies,
+  jar: CookieJar,
+): Promise<void> {
+  const status = requireSuccess(await dependencies.upstream.request('/api/status', jar))
+  if (!authRequired(status)) return
+  const identity = await dependencies.upstream.request('/api/auth/me', jar, {
+    clientAddress: ctx.req.socket.remoteAddress,
+  })
+  if (identity.status === 401 || identity.status === 403) {
+    throw new HttpError(401, 'Hermes authentication is required', 'authentication_required')
+  }
+  requireSuccess(identity)
+}
+
+async function issueLease(
+  ctx: Koa.Context,
+  dependencies: RouteDependencies,
+  jar: CookieJar,
+): Promise<void> {
+  const request = body(ctx)
+  const channel = request.channel
+  if (channel !== 'chat' && channel !== 'groups') {
+    throw new HttpError(400, 'channel must be chat or groups', 'invalid_channel')
+  }
+  const status = requireSuccess(await dependencies.upstream.request('/api/status', jar))
+  if (channel === 'groups') {
+    const capabilities = requireSuccess(await dependencies.upstream.request(
+      '/api/plugins/yaoyao/v1/capabilities',
+      jar,
+    ))
+    const protocolVersion = Number(capabilities.protocolVersion ?? capabilities.protocol_version)
+    if (protocolVersion !== 2) {
+      throw new HttpError(409, 'Hermes group chat protocol v2 is required', 'unsupported_group_protocol')
+    }
+  }
+  let credential: { name: 'ticket' | 'token'; value: string }
+  if (authRequired(status)) {
+    const ticketResponse = requireSuccess(await dependencies.upstream.request('/api/auth/ws-ticket', jar, {
+      method: 'POST',
+    }))
+    if (typeof ticketResponse.ticket !== 'string' || !ticketResponse.ticket.trim()) {
+      throw new HttpError(502, 'Hermes returned an empty WebSocket ticket', 'invalid_ticket')
+    }
+    credential = { name: 'ticket', value: ticketResponse.ticket.trim() }
+  } else {
+    const root = await dependencies.upstream.request('/', jar, { maxResponseBytes: 2 * 1_024 * 1_024 })
+    const match = root.body.toString('utf8').match(/window\.__HERMES_SESSION_TOKEN__="([^"]+)"/)
+    if (!match?.[1]) throw new HttpError(401, 'Hermes did not provide a local session token', 'missing_session_token')
+    credential = { name: 'token', value: match[1] }
+  }
+
+  const origin = expectedRequestOrigin(ctx.host, ctx.secure || Boolean(dependencies.config.tlsCert))
+  if (!origin) throw new HttpError(400, 'Invalid request host', 'invalid_host')
+  const accountKeys = new Set([
+    requestAccountKey(ctx.req),
+    accountKeyFromCookieHeader(jar.header, ctx.req.socket.remoteAddress),
+  ])
+  const lease = dependencies.leases.issue({
+    channel: channel as RealtimeChannel,
+    credential,
+    origin,
+    accountKeys,
+    epoch: channel === 'groups' ? canonicalEpoch(request.epoch) : undefined,
+    cursor: channel === 'groups' ? groupCursor(request.cursor) : undefined,
+  })
+  json(ctx, 201, { lease: lease.id, channel: lease.channel, expiresAt: lease.expiresAt })
+}
+
+async function searchSessions(ctx: Koa.Context, dependencies: RouteDependencies): Promise<void> {
+  await withJar(ctx, async (jar) => {
+    const query = searchFrom(ctx, ['q', 'limit', 'source', 'profile'])
+    const text = query.get('q')?.trim()
+    if (!text) throw new HttpError(400, 'q is required', 'missing_query')
+    const limit = Math.max(1, Math.min(100, Number(query.get('limit') ?? '50') || 50))
+    query.set('limit', String(limit))
+    if (!query.get('source')) query.set('exclude_sources', 'cron,ios_group')
+    if (query.get('profile')) {
+      const response = await dependencies.upstream.request('/api/sessions/search', jar, { search: query })
+      sendUpstreamResponse(ctx, response, jar)
+      return
+    }
+
+    const profilesResponse = await dependencies.upstream.request('/api/profiles', jar)
+    const profileObjects = normalizedProfiles(requireSuccess(profilesResponse))
+    const profiles = profileObjects.flatMap((entry) => {
+      if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return []
+      const name = (entry as JsonObject).name
+      return typeof name === 'string' && name.trim() ? [name.trim()] : []
+    })
+    const results: JsonObject[] = []
+    for (const profile of profiles) {
+      const scoped = new URLSearchParams(query)
+      scoped.set('profile', profile)
+      const response = await dependencies.upstream.request('/api/sessions/search', jar, { search: scoped })
+      const value = requireSuccess(response)
+      for (const raw of Array.isArray(value.results) ? value.results : []) {
+        if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
+          results.push({ profile, ...(raw as JsonObject) })
+        }
+      }
+    }
+    results.sort((left, right) => {
+      const leftRank = Number(left.rank ?? left.last_active ?? left.started_at ?? 0)
+      const rightRank = Number(right.rank ?? right.last_active ?? right.started_at ?? 0)
+      return rightRank - leftRank
+    })
+    json(ctx, 200, { results: results.slice(0, limit) })
+  })
+}
+
+function groupPath(ctx: Koa.Context, suffix = ''): string {
+  const roomID = canonicalUUID(ctx.params.roomID, 'room ID')
+  return `/api/plugins/yaoyao/v1/rooms/${roomID}${suffix}`
+}
+
+export function createApiRouter(dependencies: RouteDependencies): Router {
+  const router = new Router()
+
+  router.get('/healthz', (ctx) => {
+    ctx.set('Cache-Control', 'no-store')
+    json(ctx, 200, { ok: true })
+  })
+  router.get('/readyz', async (ctx) => {
+    ctx.set('Cache-Control', 'no-store')
+    try {
+      const response = await dependencies.upstream.request(
+        '/api/status',
+        new CookieJar(''),
+        { maxResponseBytes: 2 * 1_024 * 1_024 },
+      )
+      const reachable = response.status >= 200 && response.status < 500
+      json(ctx, reachable ? 200 : 503, {
+        ok: reachable,
+        upstream: reachable ? 'reachable' : 'unavailable',
+      })
+    } catch {
+      json(ctx, 503, { ok: false, upstream: 'unavailable' })
+    }
+  })
+
+  router.get('/api/app/bootstrap', async (ctx) => {
+    await withJar(ctx, (jar) => bootstrap(ctx, dependencies, jar))
+  })
+  router.post('/api/app/login', async (ctx) => {
+    await withJar(ctx, (jar) => login(ctx, dependencies, jar))
+  })
+  router.post('/api/app/logout', async (ctx) => {
+    await withJar(ctx, async (jar) => {
+      const response = await dependencies.upstream.request('/auth/logout', jar, { method: 'POST' })
+      const nextCsrf = dependencies.csrf.issue(ctx, true)
+      if (response.status >= 400 && ![401, 403].includes(response.status)) {
+        sendUpstreamResponse(ctx, response, jar)
+      } else {
+        json(ctx, 200, { ok: true, csrfToken: nextCsrf })
+      }
+    })
+  })
+  router.post('/api/app/realtime-leases', async (ctx) => {
+    await withJar(ctx, (jar) => issueLease(ctx, dependencies, jar))
+  })
+
+  router.get('/api/app/profiles', async (ctx) => {
+    await proxy(ctx, dependencies.upstream, '/api/profiles')
+  })
+  router.get('/api/app/models', async (ctx) => {
+    const search = searchFrom(ctx, ['profile'])
+    search.set('explicit_only', 'true')
+    await proxy(ctx, dependencies.upstream, '/api/model/options', { search })
+  })
+  router.get('/api/app/sessions/search', async (ctx) => {
+    await searchSessions(ctx, dependencies)
+  })
+  router.get('/api/app/sessions/unread', async (ctx) => {
+    await proxy(ctx, dependencies.upstream, '/api/session-unread', {
+      search: searchFrom(ctx, ['profile']),
+    })
+  })
+  router.patch('/api/app/sessions/unread/:sessionID', async (ctx) => {
+    const id = safeIdentifier(ctx.params.sessionID, 'session ID')
+    await proxy(ctx, dependencies.upstream, `/api/session-unread/${encodeURIComponent(id)}`, {
+      method: 'PATCH',
+      search: searchFrom(ctx, ['profile']),
+      requestBody: body(ctx),
+    })
+  })
+  router.get('/api/app/sessions', async (ctx) => {
+    const search = searchFrom(ctx, ['limit', 'offset', 'order', 'archived', 'profile', 'source'])
+    search.set('exclude_sources', 'cron,ios_group')
+    const path = search.get('profile') ? '/api/sessions' : '/api/profiles/sessions'
+    await proxy(ctx, dependencies.upstream, path, { search })
+  })
+  router.get('/api/app/sessions/:sessionID/messages', async (ctx) => {
+    const id = safeIdentifier(ctx.params.sessionID, 'session ID')
+    const incoming = searchFrom(ctx, ['offset', 'limit', 'profile'])
+    const offset = Math.max(0, Number(incoming.get('offset') ?? '0') || 0)
+    const limit = Math.max(1, Math.min(500, Number(incoming.get('limit') ?? '100') || 100))
+    incoming.set('offset', String(offset))
+    incoming.set('limit', String(limit))
+    incoming.set('order', 'latest')
+    incoming.set('include_compacted', 'true')
+    await proxy(ctx, dependencies.upstream, `/api/sessions/${encodeURIComponent(id)}/messages`, {
+      search: incoming,
+    })
+  })
+  router.get('/api/app/sessions/:sessionID', async (ctx) => {
+    const id = safeIdentifier(ctx.params.sessionID, 'session ID')
+    await proxy(ctx, dependencies.upstream, `/api/sessions/${encodeURIComponent(id)}`, {
+      search: searchFrom(ctx, ['profile']),
+    })
+  })
+  router.patch('/api/app/sessions/:sessionID', async (ctx) => {
+    const id = safeIdentifier(ctx.params.sessionID, 'session ID')
+    const search = searchFrom(ctx, ['profile'])
+    const request = body(ctx)
+    const profile = search.get('profile')
+    if (profile) request.profile = profile
+    await proxy(ctx, dependencies.upstream, `/api/sessions/${encodeURIComponent(id)}`, {
+      method: 'PATCH',
+      search,
+      requestBody: request,
+    })
+  })
+  router.delete('/api/app/sessions/:sessionID', async (ctx) => {
+    const id = safeIdentifier(ctx.params.sessionID, 'session ID')
+    await proxy(ctx, dependencies.upstream, `/api/sessions/${encodeURIComponent(id)}`, {
+      method: 'DELETE',
+      search: searchFrom(ctx, ['profile']),
+    })
+  })
+
+  router.get('/api/app/groups/capabilities', async (ctx) => {
+    await proxy(ctx, dependencies.upstream, '/api/plugins/yaoyao/v1/capabilities')
+  })
+  router.get('/api/app/groups/rooms', async (ctx) => {
+    await proxy(ctx, dependencies.upstream, '/api/plugins/yaoyao/v1/rooms', {
+      search: searchFrom(ctx, ['limit', 'cursor']),
+    })
+  })
+  router.post('/api/app/groups/rooms', async (ctx) => {
+    await proxy(ctx, dependencies.upstream, '/api/plugins/yaoyao/v1/rooms', {
+      method: 'POST', requestBody: body(ctx),
+    })
+  })
+  router.get('/api/app/groups/rooms/:roomID', async (ctx) => {
+    await proxy(ctx, dependencies.upstream, groupPath(ctx))
+  })
+  for (const method of ['patch', 'delete'] as const) {
+    router[method]('/api/app/groups/rooms/:roomID', async (ctx) => {
+      await proxy(ctx, dependencies.upstream, groupPath(ctx), {
+        method: method.toUpperCase(), requestBody: body(ctx),
+      })
+    })
+  }
+  router.post('/api/app/groups/rooms/:roomID/agents', async (ctx) => {
+    await proxy(ctx, dependencies.upstream, groupPath(ctx, '/agents'), {
+      method: 'POST', requestBody: body(ctx),
+    })
+  })
+  for (const method of ['patch', 'delete'] as const) {
+    router[method]('/api/app/groups/rooms/:roomID/agents/:agentID', async (ctx) => {
+      const agentID = canonicalUUID(ctx.params.agentID, 'agent ID')
+      await proxy(ctx, dependencies.upstream, groupPath(ctx, `/agents/${agentID}`), {
+        method: method.toUpperCase(), requestBody: body(ctx),
+      })
+    })
+  }
+  router.post('/api/app/groups/rooms/:roomID/agents/:agentID/interrupt', async (ctx) => {
+    const agentID = canonicalUUID(ctx.params.agentID, 'agent ID')
+    await proxy(ctx, dependencies.upstream, groupPath(ctx, `/agents/${agentID}/interrupt`), {
+      method: 'POST', requestBody: body(ctx),
+    })
+  })
+  router.get('/api/app/groups/rooms/:roomID/messages', async (ctx) => {
+    await proxy(ctx, dependencies.upstream, groupPath(ctx, '/messages'), {
+      search: searchFrom(ctx, ['beforeSeq', 'afterSeq', 'limit']),
+    })
+  })
+  router.post('/api/app/groups/rooms/:roomID/messages', async (ctx) => {
+    const request = body(ctx)
+    const uploadIds = Array.isArray(request.uploadIds)
+      ? request.uploadIds.map((value) => String(value))
+      : []
+    const upstreamBody = { ...request }
+    delete upstreamBody.uploadIds
+    if (uploadIds.length) {
+      if (!isLoopbackUpstream(dependencies.config.upstream)) {
+        throw new HttpError(409, 'Group attachments require a loopback Hermes upstream', 'remote_upload_disabled')
+      }
+      const accountKey = requestAccountKey(ctx.req)
+      const records = dependencies.uploads.records(uploadIds, accountKey)
+      const content = typeof upstreamBody.content === 'string' ? upstreamBody.content.trim() : ''
+      upstreamBody.content = [content, uploadMarkdown(records)].filter(Boolean).join('\n\n')
+      await withJar(ctx, async (jar) => {
+        const response = await dependencies.upstream.request(groupPath(ctx, '/messages'), jar, {
+          method: 'POST', body: upstreamBody,
+        })
+        sendUpstreamResponse(ctx, response, jar)
+        if (response.status >= 200 && response.status < 300) {
+          dependencies.uploads.markReferenced(uploadIds, accountKey)
+        }
+      })
+      return
+    }
+    await proxy(ctx, dependencies.upstream, groupPath(ctx, '/messages'), {
+      method: 'POST', requestBody: upstreamBody,
+    })
+  })
+  for (const action of ['approval', 'clarification'] as const) {
+    router.post(`/api/app/groups/rooms/:roomID/interactions/:interactionID/${action}`, async (ctx) => {
+      const interactionID = safeIdentifier(ctx.params.interactionID, 'interaction ID')
+      await proxy(
+        ctx,
+        dependencies.upstream,
+        groupPath(ctx, `/interactions/${encodeURIComponent(interactionID)}/${action}`),
+        { method: 'POST', requestBody: body(ctx) },
+      )
+    })
+  }
+
+  router.post('/api/app/group-uploads', async (ctx) => {
+    if (!isLoopbackUpstream(dependencies.config.upstream)) {
+      throw new HttpError(409, 'Group attachments require a loopback Hermes upstream', 'remote_upload_disabled')
+    }
+    await withJar(ctx, async (jar) => {
+      await requireGatewayAuthentication(ctx, dependencies, jar)
+      const accountKey = accountKeyFromCookieHeader(jar.header, ctx.req.socket.remoteAddress)
+      const files = await receiveGroupUploads(ctx.req, dependencies.uploads, accountKey)
+      json(ctx, 201, { files })
+    })
+  })
+
+  router.get('/api/app/files', async (ctx) => {
+    await proxy(ctx, dependencies.upstream, '/api/plugins/yaoyao/files', {
+      search: searchFrom(ctx, ['limit', 'cursor', 'search', 'profile', 'kind', 'sender', 'session_id']),
+    })
+  })
+  for (const action of ['download', 'preview'] as const) {
+    router.get(`/api/app/files/:fileID/${action}`, async (ctx) => {
+      const id = safeIdentifier(ctx.params.fileID, 'file ID')
+      await proxy(ctx, dependencies.upstream, `/api/plugins/yaoyao/${encodeURIComponent(id)}/download`, {
+        search: searchFrom(ctx, ['profile']),
+        requestHeaders: ctx.get('range') ? { range: ctx.get('range') } : undefined,
+        maxResponseBytes: 256 * 1_024 * 1_024,
+      })
+      if (action === 'preview') {
+        const type = ctx.response.get('content-type').split(';', 1)[0]?.trim().toLowerCase()
+        const activeContent = type === 'text/html'
+          || type === 'application/xhtml+xml'
+          || type === 'image/svg+xml'
+          || Boolean(type?.endsWith('/xml') || type?.endsWith('+xml'))
+        if (activeContent) {
+          ctx.type = 'application/octet-stream'
+          ctx.set('Content-Disposition', `attachment; filename="file-${id}"`)
+        } else {
+          ctx.remove('Content-Disposition')
+        }
+      }
+    })
+  }
+
+  return router
+}
+
+export function incomingCookieNames(ctx: Koa.Context): string[] {
+  return Object.keys(parse(ctx.get('cookie')))
+}

+ 170 - 0
src/server/security.ts

@@ -0,0 +1,170 @@
+import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto'
+import type { IncomingMessage } from 'node:http'
+import type Koa from 'koa'
+import { parse, serialize } from 'cookie'
+import type { ServerConfig } from './config.js'
+import { isPrivateHost } from './config.js'
+
+export const CSRF_COOKIE = 'hermes_yaoyao_csrf'
+
+function splitHostPort(value: string): { hostname: string; port?: string } | null {
+  const trimmed = value.trim().toLowerCase()
+  if (!trimmed || /[\s/@\\]/.test(trimmed)) return null
+  if (trimmed.startsWith('[')) {
+    const end = trimmed.indexOf(']')
+    if (end < 0) return null
+    const hostname = trimmed.slice(1, end)
+    const suffix = trimmed.slice(end + 1)
+    if (suffix && !/^:\d{1,5}$/.test(suffix)) return null
+    return { hostname, port: suffix ? suffix.slice(1) : undefined }
+  }
+  const colonCount = (trimmed.match(/:/g) ?? []).length
+  if (colonCount > 1) return { hostname: trimmed }
+  const [hostname, port] = trimmed.split(':')
+  if (!hostname || (port && !/^\d{1,5}$/.test(port))) return null
+  return { hostname: hostname.replace(/\.$/, ''), port }
+}
+
+export function isAllowedHostHeader(value: string | undefined, config: ServerConfig): boolean {
+  if (!value) return false
+  const parsed = splitHostPort(value)
+  if (!parsed) return false
+  if (parsed.port !== String(config.port)) return false
+  return isPrivateHost(parsed.hostname) || config.allowedHosts.has(parsed.hostname)
+}
+
+function canonicalOrigin(origin: string): string | null {
+  try {
+    const url = new URL(origin)
+    if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password) return null
+    if (url.pathname !== '/' || url.search || url.hash) return null
+    return `${url.protocol}//${url.host.toLowerCase()}`
+  } catch {
+    return null
+  }
+}
+
+export function expectedRequestOrigin(host: string, secure: boolean): string | null {
+  if (!splitHostPort(host)) return null
+  return `${secure ? 'https' : 'http'}://${host.toLowerCase()}`
+}
+
+export function isExactOrigin(
+  origin: string | undefined,
+  host: string | undefined,
+  secure: boolean,
+): boolean {
+  if (!origin || !host) return false
+  const expected = expectedRequestOrigin(host, secure)
+  return expected !== null && canonicalOrigin(origin) === expected
+}
+
+export function appendSetCookies(ctx: Koa.Context, values: readonly string[]): void {
+  if (values.length === 0) return
+  const existing = ctx.response.headers['set-cookie']
+  const current = Array.isArray(existing) ? existing : existing ? [String(existing)] : []
+  ctx.set('Set-Cookie', [...current, ...values])
+}
+
+export class CsrfProtection {
+  readonly #secret: Buffer
+  readonly #secure: boolean
+
+  constructor(secret: Buffer = randomBytes(32), secure = false) {
+    this.#secret = secret
+    this.#secure = secure
+  }
+
+  issue(ctx: Koa.Context, rotate = false): string {
+    const currentCookie = typeof ctx.get === 'function' ? ctx.get('cookie') : undefined
+    if (!rotate) {
+      const current = this.#read(currentCookie)
+      if (current) return current
+    }
+    const token = randomBytes(24).toString('base64url')
+    const signed = `${token}.${this.#sign(token)}`
+    appendSetCookies(ctx, [serialize(CSRF_COOKIE, signed, {
+      httpOnly: true,
+      secure: this.#secure,
+      sameSite: 'strict',
+      path: '/',
+      maxAge: 60 * 60 * 8,
+    })])
+    return token
+  }
+
+  verify(cookieHeader: string | undefined, headerToken: string | undefined): boolean {
+    if (!headerToken || headerToken.length > 256) return false
+    const token = this.#read(cookieHeader)
+    if (!token || token !== headerToken) return false
+    return true
+  }
+
+  #read(cookieHeader: string | undefined): string | undefined {
+    if (!cookieHeader) return undefined
+    const signed = parse(cookieHeader)[CSRF_COOKIE]
+    if (!signed) return undefined
+    const separator = signed.lastIndexOf('.')
+    if (separator <= 0) return undefined
+    const token = signed.slice(0, separator)
+    const signature = signed.slice(separator + 1)
+    const expected = this.#sign(token)
+    const actualBuffer = Buffer.from(signature)
+    const expectedBuffer = Buffer.from(expected)
+    if (actualBuffer.length !== expectedBuffer.length
+      || !timingSafeEqual(actualBuffer, expectedBuffer)) return undefined
+    return token
+  }
+
+  clear(ctx: Koa.Context): void {
+    appendSetCookies(ctx, [serialize(CSRF_COOKIE, '', {
+      httpOnly: true,
+      secure: this.#secure,
+      sameSite: 'strict',
+      path: '/',
+      maxAge: 0,
+    })])
+  }
+
+  #sign(token: string): string {
+    return createHmac('sha256', this.#secret).update(token).digest('base64url')
+  }
+}
+
+export function accountKeyFromCookieHeader(cookieHeader: string | undefined, fallback = ''): string {
+  const cookies = parse(cookieHeader ?? '')
+  delete cookies[CSRF_COOKIE]
+  const stable = Object.entries(cookies)
+    .sort(([left], [right]) => left.localeCompare(right))
+    .map(([name, value]) => `${name}=${value}`)
+    .join(';')
+  return createHmac('sha256', 'hermes-yaoyao-account-scope')
+    .update(stable || `anonymous:${fallback}`)
+    .digest('base64url')
+}
+
+export function requestAccountKey(request: IncomingMessage): string {
+  return accountKeyFromCookieHeader(request.headers.cookie, request.socket.remoteAddress)
+}
+
+export function applySecurityHeaders(ctx: Koa.Context, tls: boolean): void {
+  const socketOrigin = `${tls ? 'wss' : 'ws'}://${ctx.host}`
+  ctx.set('Content-Security-Policy', [
+    "default-src 'self'",
+    "base-uri 'none'",
+    "frame-ancestors 'none'",
+    "form-action 'self'",
+    "object-src 'none'",
+    "script-src 'self'",
+    "style-src 'self' 'unsafe-inline'",
+    "img-src 'self' data: blob:",
+    "media-src 'self' blob:",
+    `connect-src 'self' ${socketOrigin}`,
+  ].join('; '))
+  ctx.set('Cross-Origin-Opener-Policy', 'same-origin')
+  ctx.set('Referrer-Policy', 'no-referrer')
+  ctx.set('X-Content-Type-Options', 'nosniff')
+  ctx.set('X-Frame-Options', 'DENY')
+  ctx.set('Permissions-Policy', 'camera=(), geolocation=(), microphone=()')
+  if (tls) ctx.set('Strict-Transport-Security', 'max-age=31536000')
+}

+ 316 - 0
src/server/uploads.ts

@@ -0,0 +1,316 @@
+import { randomUUID } from 'node:crypto'
+import { createWriteStream, mkdirSync, renameSync, rmSync, chmodSync, lstatSync } from 'node:fs'
+import { open } from 'node:fs/promises'
+import type { IncomingMessage } from 'node:http'
+import { basename, extname, join, resolve, sep } from 'node:path'
+import { DatabaseSync } from 'node:sqlite'
+import Busboy from 'busboy'
+import { HttpError } from './errors.js'
+
+export const MAX_UPLOAD_FILE_BYTES = 25 * 1_024 * 1_024
+export const MAX_UPLOAD_REQUEST_BYTES = 50 * 1_024 * 1_024
+export const MAX_UPLOAD_FILES = 8
+export const MAX_UPLOAD_QUOTA_BYTES = 2 * 1_024 * 1_024 * 1_024
+
+export interface UploadReference {
+  id: string
+  name: string
+  mimeType: string
+  size: number
+}
+
+export interface UploadRecord extends UploadReference {
+  path: string
+  accountKey: string
+  referenced: boolean
+}
+
+interface StagedUpload extends UploadRecord {
+  tempPath: string
+}
+
+function cleanName(value: string): string {
+  const safe = basename(value)
+    .replace(/[\u0000-\u001f\u007f]/g, '')
+    .trim()
+    .slice(0, 240)
+  return safe || 'attachment'
+}
+
+function safeExtension(name: string): string {
+  const extension = extname(name).toLowerCase()
+  return /^\.[a-z0-9]{1,10}$/.test(extension) ? extension : ''
+}
+
+function within(root: string, path: string): boolean {
+  const normalizedRoot = resolve(root)
+  const normalizedPath = resolve(path)
+  return normalizedPath.startsWith(`${normalizedRoot}${sep}`)
+}
+
+export class UploadStore {
+  readonly uploadRoot: string
+  readonly #database: DatabaseSync
+
+  constructor(
+    home: string,
+    readonly quotaBytes = MAX_UPLOAD_QUOTA_BYTES,
+  ) {
+    mkdirSync(home, { recursive: true, mode: 0o700 })
+    chmodSync(home, 0o700)
+    this.uploadRoot = join(home, 'uploads')
+    mkdirSync(this.uploadRoot, { recursive: true, mode: 0o700 })
+    chmodSync(this.uploadRoot, 0o700)
+    this.#database = new DatabaseSync(join(home, 'uploads.sqlite3'))
+    this.#database.exec(`
+      PRAGMA journal_mode = WAL;
+      PRAGMA foreign_keys = ON;
+      CREATE TABLE IF NOT EXISTS uploads (
+        id TEXT PRIMARY KEY,
+        account_key TEXT NOT NULL,
+        name TEXT NOT NULL,
+        mime_type TEXT NOT NULL,
+        size INTEGER NOT NULL CHECK(size >= 0),
+        path TEXT NOT NULL UNIQUE,
+        referenced INTEGER NOT NULL DEFAULT 0,
+        created_at INTEGER NOT NULL
+      );
+      CREATE INDEX IF NOT EXISTS uploads_account_created
+        ON uploads(account_key, created_at);
+    `)
+  }
+
+  close(): void {
+    this.#database.close()
+  }
+
+  commit(staged: readonly StagedUpload[]): UploadReference[] {
+    if (staged.length === 0) throw new HttpError(400, 'At least one file is required', 'missing_files')
+    const addedBytes = staged.reduce((sum, file) => sum + file.size, 0)
+    const usage = this.#database.prepare(
+      'SELECT COALESCE(SUM(size), 0) AS bytes FROM uploads',
+    ).get() as { bytes: number }
+    if (Number(usage.bytes) + addedBytes > this.quotaBytes) {
+      this.discard(staged)
+      throw new HttpError(413, 'Upload quota exceeded', 'upload_quota_exceeded')
+    }
+
+    const insert = this.#database.prepare(`
+      INSERT INTO uploads(id, account_key, name, mime_type, size, path, referenced, created_at)
+      VALUES (?, ?, ?, ?, ?, ?, 0, ?)
+    `)
+    const moved: string[] = []
+    this.#database.exec('BEGIN IMMEDIATE')
+    try {
+      for (const file of staged) {
+        if (!within(this.uploadRoot, file.path) || !within(this.uploadRoot, file.tempPath)) {
+          throw new HttpError(500, 'Invalid upload destination')
+        }
+        renameSync(file.tempPath, file.path)
+        chmodSync(file.path, 0o600)
+        moved.push(file.path)
+        insert.run(
+          file.id,
+          file.accountKey,
+          file.name,
+          file.mimeType,
+          file.size,
+          file.path,
+          Date.now(),
+        )
+      }
+      this.#database.exec('COMMIT')
+    } catch (error) {
+      this.#database.exec('ROLLBACK')
+      this.discard(staged)
+      for (const path of moved) rmSync(path, { force: true })
+      throw error
+    }
+    return staged.map(({ id, name, mimeType, size }) => ({ id, name, mimeType, size }))
+  }
+
+  records(ids: readonly string[], accountKey: string): UploadRecord[] {
+    if (ids.length > MAX_UPLOAD_FILES) {
+      throw new HttpError(400, `At most ${MAX_UPLOAD_FILES} uploads may be referenced`, 'too_many_uploads')
+    }
+    const unique = [...new Set(ids)]
+    const lookup = this.#database.prepare(`
+      SELECT id, account_key, name, mime_type, size, path, referenced
+      FROM uploads WHERE id = ? AND account_key = ?
+    `)
+    const records = unique.map((id) => {
+      if (!/^[0-9a-f-]{36}$/.test(id)) {
+        throw new HttpError(400, 'Invalid upload reference', 'invalid_upload')
+      }
+      const row = lookup.get(id, accountKey) as {
+        id: string
+        account_key: string
+        name: string
+        mime_type: string
+        size: number
+        path: string
+        referenced: number
+      } | undefined
+      if (!row || !within(this.uploadRoot, row.path)) {
+        throw new HttpError(404, 'Upload reference not found', 'upload_not_found')
+      }
+      const stat = lstatSync(row.path)
+      if (!stat.isFile() || stat.isSymbolicLink()) {
+        throw new HttpError(409, 'Upload reference is no longer safe', 'unsafe_upload')
+      }
+      return {
+        id: row.id,
+        accountKey: row.account_key,
+        name: row.name,
+        mimeType: row.mime_type,
+        size: Number(row.size),
+        path: row.path,
+        referenced: Boolean(row.referenced),
+      }
+    })
+    return records
+  }
+
+  markReferenced(ids: readonly string[], accountKey: string): void {
+    const statement = this.#database.prepare(
+      'UPDATE uploads SET referenced = 1 WHERE id = ? AND account_key = ?',
+    )
+    this.#database.exec('BEGIN IMMEDIATE')
+    try {
+      for (const id of new Set(ids)) statement.run(id, accountKey)
+      this.#database.exec('COMMIT')
+    } catch (error) {
+      this.#database.exec('ROLLBACK')
+      throw error
+    }
+  }
+
+  cleanupUncommitted(maxAgeMs = 24 * 60 * 60 * 1_000): number {
+    const rows = this.#database.prepare(
+      'SELECT id, path FROM uploads WHERE referenced = 0 AND created_at < ?',
+    ).all(Date.now() - maxAgeMs) as Array<{ id: string; path: string }>
+    const remove = this.#database.prepare('DELETE FROM uploads WHERE id = ? AND referenced = 0')
+    let count = 0
+    this.#database.exec('BEGIN IMMEDIATE')
+    try {
+      for (const row of rows) {
+        if (within(this.uploadRoot, row.path)) rmSync(row.path, { force: true })
+        remove.run(row.id)
+        count += 1
+      }
+      this.#database.exec('COMMIT')
+    } catch (error) {
+      this.#database.exec('ROLLBACK')
+      throw error
+    }
+    return count
+  }
+
+  discard(staged: readonly Pick<StagedUpload, 'tempPath' | 'path'>[]): void {
+    for (const file of staged) {
+      if (within(this.uploadRoot, file.tempPath)) rmSync(file.tempPath, { force: true })
+      if (within(this.uploadRoot, file.path)) rmSync(file.path, { force: true })
+    }
+  }
+}
+
+export async function receiveGroupUploads(
+  request: IncomingMessage,
+  store: UploadStore,
+  accountKey: string,
+): Promise<UploadReference[]> {
+  const contentType = request.headers['content-type']
+  if (!contentType?.toLowerCase().startsWith('multipart/form-data')) {
+    throw new HttpError(415, 'Expected multipart/form-data', 'invalid_content_type')
+  }
+  const declared = Number(request.headers['content-length'] ?? '0')
+  if (Number.isFinite(declared) && declared > MAX_UPLOAD_REQUEST_BYTES + 128 * 1_024) {
+    throw new HttpError(413, 'Upload request is too large', 'upload_request_too_large')
+  }
+
+  const busboy = Busboy({
+    headers: request.headers,
+    limits: {
+      files: MAX_UPLOAD_FILES,
+      fileSize: MAX_UPLOAD_FILE_BYTES,
+      fields: 8,
+      parts: MAX_UPLOAD_FILES + 8,
+    },
+  })
+  const staged: StagedUpload[] = []
+  const tasks: Promise<void>[] = []
+  let total = 0
+  let parserError: Error | undefined
+
+  busboy.on('file', (_field, stream, info) => {
+    const id = randomUUID()
+    const name = cleanName(info.filename)
+    const destination = join(store.uploadRoot, `${id}${safeExtension(name)}`)
+    const tempPath = join(store.uploadRoot, `.${id}.part`)
+    const stagedFile: StagedUpload = {
+      id,
+      accountKey,
+      name,
+      mimeType: info.mimeType || 'application/octet-stream',
+      size: 0,
+      path: destination,
+      tempPath,
+      referenced: false,
+    }
+    staged.push(stagedFile)
+    let limited = false
+    stream.once('limit', () => { limited = true })
+    tasks.push((async () => {
+      const handle = await open(tempPath, 'wx', 0o600)
+      const output = createWriteStream(tempPath, { fd: handle.fd, autoClose: false })
+      try {
+        for await (const chunk of stream) {
+          const data = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk as Uint8Array)
+          total += data.byteLength
+          stagedFile.size += data.byteLength
+          if (total > MAX_UPLOAD_REQUEST_BYTES) {
+            throw new HttpError(413, 'Combined uploads exceed 50 MiB', 'upload_request_too_large')
+          }
+          if (!output.write(data)) await new Promise<void>((resolve) => output.once('drain', resolve))
+        }
+        if (limited || stagedFile.size > MAX_UPLOAD_FILE_BYTES) {
+          throw new HttpError(413, `${name} exceeds 25 MiB`, 'upload_file_too_large')
+        }
+      } finally {
+        await new Promise<void>((resolve, reject) => {
+          output.end((error?: Error | null) => error ? reject(error) : resolve())
+        })
+        await handle.close().catch(() => undefined)
+      }
+    })())
+  })
+  busboy.once('filesLimit', () => {
+    parserError = new HttpError(413, `At most ${MAX_UPLOAD_FILES} files are allowed`, 'too_many_uploads')
+  })
+  busboy.once('partsLimit', () => {
+    parserError = new HttpError(413, 'Multipart request has too many parts', 'too_many_parts')
+  })
+
+  try {
+    await new Promise<void>((resolvePromise, reject) => {
+      busboy.once('error', reject)
+      busboy.once('finish', resolvePromise)
+      request.pipe(busboy)
+    })
+    await Promise.all(tasks)
+    if (parserError) throw parserError
+    return store.commit(staged)
+  } catch (error) {
+    await Promise.allSettled(tasks)
+    store.discard(staged)
+    throw error
+  }
+}
+
+function escapeMarkdown(value: string): string {
+  return value.replace(/[\\\[\]]/g, '\\$&')
+}
+
+export function uploadMarkdown(records: readonly UploadRecord[]): string {
+  return records.map((record) => `[${escapeMarkdown(record.name)}](<${record.path}>)`).join('\n')
+}

+ 222 - 0
src/server/upstream.ts

@@ -0,0 +1,222 @@
+import type Koa from 'koa'
+import { parse } from 'cookie'
+import { HttpError } from './errors.js'
+import { appendSetCookies, CSRF_COOKIE } from './security.js'
+
+const RESPONSE_HEADER_ALLOWLIST = [
+  'accept-ranges',
+  'cache-control',
+  'content-disposition',
+  'content-range',
+  'content-type',
+  'etag',
+  'last-modified',
+] as const
+
+function headerSetCookies(headers: Headers): string[] {
+  if (typeof headers.getSetCookie === 'function') return headers.getSetCookie()
+  const combined = headers.get('set-cookie')
+  if (!combined) return []
+  // This fallback is only for fetch shims without getSetCookie. It avoids
+  // splitting the comma in an Expires attribute.
+  return combined.split(/,(?=\s*[^;,\s]+=)/g).map((value) => value.trim())
+}
+
+function cookiePair(value: string): [string, string] | null {
+  const pair = value.split(';', 1)[0]?.trim()
+  const separator = pair?.indexOf('=') ?? -1
+  if (!pair || separator <= 0) return null
+  const name = pair.slice(0, separator).trim()
+  const cookieValue = pair.slice(separator + 1).trim()
+  if (!/^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/.test(name)) return null
+  return [name, cookieValue]
+}
+
+function browserCookie(value: string, publicSecure: boolean): string | null {
+  const pair = cookiePair(value)
+  if (!pair || pair[0] === CSRF_COOKIE) return null
+  const attributes = value.split(';').slice(1).map((part) => part.trim())
+  const kept = attributes.filter((attribute) => {
+    const name = attribute.split('=', 1)[0]?.toLowerCase()
+    return name !== 'domain' && name !== 'path' && name !== 'secure'
+  })
+  return [`${pair[0]}=${pair[1]}`, 'Path=/', ...kept, ...(publicSecure ? ['Secure'] : [])].join('; ')
+}
+
+export class CookieJar {
+  readonly #values = new Map<string, string>()
+  readonly #browserCookies = new Map<string, string>()
+
+  constructor(cookieHeader?: string) {
+    for (const [name, value] of Object.entries(parse(cookieHeader ?? ''))) {
+      if (name !== CSRF_COOKIE && value !== undefined) this.#values.set(name, value)
+    }
+  }
+
+  absorb(headers: Headers, publicSecure = false): void {
+    for (const raw of headerSetCookies(headers)) {
+      const pair = cookiePair(raw)
+      const sanitized = browserCookie(raw, publicSecure)
+      if (!pair || !sanitized) continue
+      const [name, value] = pair
+      if (!value || /(?:^|;)\s*max-age=0(?:;|$)/i.test(raw)) this.#values.delete(name)
+      else this.#values.set(name, value)
+      this.#browserCookies.set(name, sanitized)
+    }
+  }
+
+  get header(): string | undefined {
+    if (this.#values.size === 0) return undefined
+    return [...this.#values.entries()].map(([name, value]) => `${name}=${value}`).join('; ')
+  }
+
+  get browserCookies(): string[] {
+    return [...this.#browserCookies.values()]
+  }
+}
+
+export interface UpstreamResponse {
+  status: number
+  headers: Headers
+  body: Buffer
+}
+
+export class UpstreamHttpError extends HttpError {
+  constructor(readonly response: UpstreamResponse) {
+    super(response.status, `Hermes returned HTTP ${response.status}`, 'upstream_http_error')
+  }
+}
+
+export interface UpstreamRequestOptions {
+  method?: string
+  search?: URLSearchParams
+  body?: unknown
+  rawBody?: BodyInit
+  headers?: Record<string, string>
+  maxResponseBytes?: number
+  clientAddress?: string
+}
+
+export class UpstreamClient {
+  constructor(
+    readonly baseURL: URL,
+    readonly fetchImpl: typeof fetch = fetch,
+    readonly publicSecure = false,
+  ) {}
+
+  async request(
+    path: string,
+    jar: CookieJar,
+    options: UpstreamRequestOptions = {},
+  ): Promise<UpstreamResponse> {
+    if (!path.startsWith('/') || path.startsWith('//') || path.includes('\\')) {
+      throw new HttpError(500, 'Invalid internal upstream path')
+    }
+    const url = new URL(this.baseURL)
+    const prefix = this.baseURL.pathname === '/' ? '' : this.baseURL.pathname.replace(/\/$/, '')
+    const expectedPath = `${prefix}${path}`
+    url.pathname = expectedPath
+    if (url.pathname !== expectedPath) {
+      throw new HttpError(500, 'Upstream path normalization escaped its allowlisted route')
+    }
+    url.search = options.search?.toString() ?? ''
+    url.hash = ''
+
+    const headers = new Headers({
+      accept: 'application/json',
+      origin: this.baseURL.origin,
+      'x-forwarded-host': this.baseURL.host,
+      'x-forwarded-proto': this.publicSecure ? 'https' : 'http',
+      ...options.headers,
+    })
+    if (options.clientAddress) {
+      const clientAddress = options.clientAddress.replace(/^::ffff:/, '')
+      if (/^[0-9a-f:.]+$/i.test(clientAddress)) headers.set('x-forwarded-for', clientAddress)
+    }
+    if (jar.header) headers.set('cookie', jar.header)
+    let body: BodyInit | undefined
+    if (options.rawBody !== undefined) {
+      body = options.rawBody
+    } else if (options.body !== undefined) {
+      headers.set('content-type', 'application/json')
+      body = JSON.stringify(options.body)
+    }
+
+    let response: Response
+    let timeout: ReturnType<typeof setTimeout> | undefined
+    try {
+      response = await Promise.race([
+        this.fetchImpl(url, {
+          method: options.method ?? 'GET',
+          headers,
+          body,
+          redirect: 'manual',
+        }),
+        new Promise<never>((_resolve, reject) => {
+          timeout = setTimeout(() => reject(new Error('request timed out')), 30_000)
+          timeout.unref()
+        }),
+      ])
+    } catch (error) {
+      const message = error instanceof Error ? error.message : 'connection failed'
+      throw new HttpError(502, `Unable to reach Hermes: ${message}`, 'upstream_unavailable')
+    } finally {
+      if (timeout) clearTimeout(timeout)
+    }
+    jar.absorb(response.headers, this.publicSecure)
+    const declaredLength = Number(response.headers.get('content-length') ?? '0')
+    const limit = options.maxResponseBytes ?? 64 * 1_024 * 1_024
+    if (Number.isFinite(declaredLength) && declaredLength > limit) {
+      await response.body?.cancel()
+      throw new HttpError(502, 'Hermes response exceeded the configured limit', 'upstream_too_large')
+    }
+    const bodyBuffer = Buffer.from(await response.arrayBuffer())
+    if (bodyBuffer.byteLength > limit) {
+      throw new HttpError(502, 'Hermes response exceeded the configured limit', 'upstream_too_large')
+    }
+    return { status: response.status, headers: response.headers, body: bodyBuffer }
+  }
+
+  async json<T>(
+    path: string,
+    jar: CookieJar,
+    options: UpstreamRequestOptions = {},
+  ): Promise<T> {
+    const response = await this.request(path, jar, options)
+    if (response.status < 200 || response.status >= 300) throw new UpstreamHttpError(response)
+    try {
+      return JSON.parse(response.body.toString('utf8')) as T
+    } catch {
+      throw new HttpError(502, 'Hermes returned invalid JSON', 'invalid_upstream_json')
+    }
+  }
+}
+
+export function applyUpstreamCookies(ctx: Koa.Context, jar: CookieJar): void {
+  appendSetCookies(ctx, jar.browserCookies)
+}
+
+export function sendUpstreamResponse(
+  ctx: Koa.Context,
+  response: UpstreamResponse,
+  _jar: CookieJar,
+): void {
+  ctx.status = response.status
+  for (const name of RESPONSE_HEADER_ALLOWLIST) {
+    const value = response.headers.get(name)
+    if (value) ctx.set(name, value)
+  }
+  ctx.body = response.status === 204 || ctx.method === 'HEAD' ? null : response.body
+}
+
+export function decodeUpstreamError(response: UpstreamResponse): { error: string; code: string } {
+  try {
+    const value = JSON.parse(response.body.toString('utf8')) as Record<string, unknown>
+    const message = typeof value.error === 'string'
+      ? value.error
+      : typeof value.message === 'string' ? value.message : `Hermes returned HTTP ${response.status}`
+    return { error: message, code: 'upstream_rejected' }
+  } catch {
+    return { error: `Hermes returned HTTP ${response.status}`, code: 'upstream_rejected' }
+  }
+}

+ 482 - 0
src/server/websocket.ts

@@ -0,0 +1,482 @@
+import type { Server as HttpServer } from 'node:http'
+import type { Duplex } from 'node:stream'
+import WebSocket, { WebSocketServer, type RawData } from 'ws'
+import type { ServerConfig } from './config.js'
+import { HttpError } from './errors.js'
+import type { RealtimeChannel, RealtimeLeaseStore } from './leases.js'
+import { isAllowedHostHeader, isExactOrigin, requestAccountKey } from './security.js'
+
+const CHAT_MAX_PAYLOAD = 36 * 1_024 * 1_024
+const GROUP_MAX_PAYLOAD = 2 * 1_024 * 1_024
+const MAX_BUFFERED_BYTES = 8 * 1_024 * 1_024
+const CHAT_METHODS = new Set([
+  'approval.respond',
+  'clarify.respond',
+  'config.set',
+  'file.attach',
+  'image.attach_bytes',
+  'pdf.attach',
+  'prompt.submit',
+  'session.branch',
+  'session.close',
+  'session.context_breakdown',
+  'session.create',
+  'session.interrupt',
+  'session.resume',
+  'session.steer',
+  'session.usage',
+])
+const SESSION_ID_REQUIRED_METHODS = new Set([
+  'config.set',
+  'file.attach',
+  'image.attach_bytes',
+  'pdf.attach',
+  'prompt.submit',
+  'session.branch',
+  'session.close',
+  'session.context_breakdown',
+  'session.interrupt',
+  'session.resume',
+  'session.steer',
+  'session.usage',
+])
+
+function rejectUpgrade(socket: Duplex, status: number, message: string): void {
+  if (socket.destroyed) return
+  const safe = message.replace(/[\r\n]/g, ' ').slice(0, 120)
+  socket.end(
+    `HTTP/1.1 ${status} ${status === 401 ? 'Unauthorized' : 'Bad Request'}\r\n`
+      + 'Connection: close\r\n'
+      + 'Content-Type: text/plain; charset=utf-8\r\n'
+      + `Content-Length: ${Buffer.byteLength(safe)}\r\n\r\n${safe}`,
+  )
+}
+
+function safeCloseCode(code: number): number {
+  if ((code >= 1000 && code <= 1014 && ![1004, 1005, 1006].includes(code))
+    || (code >= 3000 && code <= 4999)) return code
+  return 1011
+}
+
+function safeCloseReason(value: string): string {
+  let reason = value
+  while (Buffer.byteLength(reason, 'utf8') > 123) reason = reason.slice(0, -1)
+  return reason
+}
+
+function safeString(value: unknown, label: string, required = false): string | undefined {
+  if (value === undefined && !required) return undefined
+  if (typeof value !== 'string') throw new HttpError(400, `${label} must be a string`)
+  const normalized = value.trim()
+  if ((!normalized && required) || normalized.length > 256 || /[\u0000-\u001f\u007f]/.test(normalized)) {
+    throw new HttpError(400, `${label} is invalid`)
+  }
+  return normalized
+}
+
+function normalizedConfigParams(params: Record<string, unknown>): Record<string, unknown> {
+  const sessionID = safeString(params.session_id, 'session_id', true)!
+  const key = safeString(params.key, 'config key', true)
+  if (key === 'model') {
+    const value = typeof params.value === 'string' ? params.value.trim() : ''
+    if (!/^[A-Za-z0-9][A-Za-z0-9._:/-]{0,511} --provider [A-Za-z0-9][A-Za-z0-9._-]{0,127}( --session)?$/.test(value)) {
+      throw new HttpError(400, 'Model selection is invalid')
+    }
+    return {
+      session_id: sessionID,
+      key,
+      value,
+      ...(params.confirm_expensive_model === true ? { confirm_expensive_model: true } : {}),
+    }
+  }
+  if (key === 'reasoning') {
+    const value = safeString(params.value, 'reasoning value', true)!
+    if (!new Set(['none', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max']).has(value)) {
+      throw new HttpError(400, 'Reasoning value is invalid')
+    }
+    return { session_id: sessionID, key, value, scope: 'session' }
+  }
+  if (key === 'fast') {
+    const value = safeString(params.value, 'fast value', true)!
+    if (value !== 'fast' && value !== 'normal') throw new HttpError(400, 'Fast value is invalid')
+    return { session_id: sessionID, key, value, scope: 'session' }
+  }
+  throw new HttpError(403, 'Config key is not allowed')
+}
+
+function normalizedSessionOpenParams(
+  method: 'session.create' | 'session.resume',
+  params: Record<string, unknown>,
+): Record<string, unknown> {
+  const profile = safeString(params.profile, 'profile', true)!
+  const columns = typeof params.cols === 'number' && Number.isInteger(params.cols)
+    ? Math.min(500, Math.max(20, params.cols))
+    : 80
+  if (method === 'session.resume') {
+    return {
+      session_id: safeString(params.session_id, 'session_id', true)!,
+      profile,
+      source: 'web',
+      close_on_disconnect: false,
+      omit_messages: params.omit_messages === true,
+      cols: columns,
+    }
+  }
+  const title = safeString(params.title, 'title')
+  const reasoning = safeString(params.reasoning_effort, 'reasoning effort')
+  const allowedReasoning = new Set(['none', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'])
+  if (reasoning && !allowedReasoning.has(reasoning)) throw new HttpError(400, 'Reasoning effort is invalid')
+  return {
+    profile,
+    source: 'web',
+    close_on_disconnect: false,
+    cols: columns,
+    ...(title ? { title } : {}),
+    ...(reasoning ? { reasoning_effort: reasoning } : {}),
+    ...(typeof params.fast === 'boolean' ? { fast: params.fast } : {}),
+  }
+}
+
+function checkedChatFrame(data: RawData, isBinary: boolean): string {
+  if (isBinary) throw new HttpError(400, 'Binary chat frames are not accepted')
+  const bytes = Buffer.isBuffer(data) ? data : Buffer.from(data as ArrayBuffer)
+  if (bytes.byteLength > CHAT_MAX_PAYLOAD) throw new HttpError(413, 'Chat frame is too large')
+  let value: unknown
+  try {
+    value = JSON.parse(bytes.toString('utf8'))
+  } catch {
+    throw new HttpError(400, 'Chat frame must be JSON')
+  }
+  if (!value || typeof value !== 'object' || Array.isArray(value)) {
+    throw new HttpError(400, 'Chat frame must be a JSON-RPC object')
+  }
+  const request = value as Record<string, unknown>
+  if (typeof request.method !== 'string' || !CHAT_METHODS.has(request.method)) {
+    throw new HttpError(403, 'Chat RPC method is not allowed')
+  }
+  if (request.id !== undefined && typeof request.id !== 'string' && typeof request.id !== 'number') {
+    throw new HttpError(400, 'Chat RPC id is invalid')
+  }
+  if (!request.params || typeof request.params !== 'object' || Array.isArray(request.params)) {
+    throw new HttpError(400, 'Chat RPC params must be an object')
+  }
+  let params = { ...(request.params as Record<string, unknown>) }
+  safeString(params.session_id, 'session_id', SESSION_ID_REQUIRED_METHODS.has(request.method))
+  const profileRequired = request.method === 'session.create' || request.method === 'session.resume'
+  safeString(params.profile, 'profile', profileRequired)
+  if (request.method === 'session.create' || request.method === 'session.resume') {
+    params = normalizedSessionOpenParams(request.method, params)
+  }
+  if (request.method === 'config.set') params = normalizedConfigParams(params)
+  if (request.method === 'prompt.submit' || request.method === 'session.steer') {
+    const text = typeof params.text === 'string' ? params.text.trim() : ''
+    if (!text || text.length > 200_000) throw new HttpError(400, 'Prompt text is invalid')
+    params = {
+      session_id: safeString(params.session_id, 'session_id', true)!,
+      text,
+      ...(request.method === 'prompt.submit' && params.queued === true ? { queued: true } : {}),
+    }
+  }
+
+  if (request.method === 'image.attach_bytes' || request.method === 'pdf.attach') {
+    const encoded = typeof params.content_base64 === 'string' ? params.content_base64 : ''
+    const estimated = Math.floor(encoded.length * 0.75)
+    if (!encoded || estimated > 25 * 1_024 * 1_024) {
+      throw new HttpError(413, 'Attachment exceeds 25 MiB')
+    }
+  }
+  if (request.method === 'file.attach') {
+    const dataURL = typeof params.data_url === 'string' ? params.data_url : ''
+    const marker = dataURL.indexOf(',')
+    if (marker < 0 || Math.floor((dataURL.length - marker - 1) * 0.75) > 25 * 1_024 * 1_024) {
+      throw new HttpError(413, 'Attachment exceeds 25 MiB')
+    }
+  }
+  return JSON.stringify({ ...request, params })
+}
+
+function upstreamWebSocketURL(config: ServerConfig, channel: RealtimeChannel, lease: {
+  credential: { name: 'ticket' | 'token'; value: string }
+  epoch?: string
+  cursor?: number
+}): URL {
+  const url = new URL(config.upstream)
+  url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
+  const prefix = config.upstream.pathname === '/' ? '' : config.upstream.pathname.replace(/\/$/, '')
+  url.pathname = channel === 'chat'
+    ? `${prefix}/api/ws`
+    : `${prefix}/api/plugins/yaoyao/v1/events`
+  url.search = ''
+  url.searchParams.set(lease.credential.name, lease.credential.value)
+  if (channel === 'groups') {
+    url.searchParams.set('epoch', lease.epoch!)
+    url.searchParams.set('cursor', String(lease.cursor ?? 0))
+  }
+  return url
+}
+
+function closePair(client: WebSocket, upstream: WebSocket, code = 1000, reason = ''): void {
+  const safeReason = safeCloseReason(reason)
+  const safeCode = safeCloseCode(code)
+  if (client.readyState === WebSocket.OPEN || client.readyState === WebSocket.CONNECTING) {
+    client.close(safeCode, safeReason)
+  }
+  if (upstream.readyState === WebSocket.OPEN || upstream.readyState === WebSocket.CONNECTING) {
+    upstream.close(safeCode, safeReason)
+  }
+}
+
+class GroupFrameValidator {
+  #ready = false
+  #cursor: number
+
+  constructor(readonly epoch: string, cursor: number) {
+    this.#cursor = cursor
+  }
+
+  accept(data: RawData, isBinary: boolean): boolean {
+    if (isBinary) throw new HttpError(400, 'Binary group frames are not accepted')
+    let envelope: Record<string, unknown>
+    try {
+      const value = JSON.parse(Buffer.from(data as Uint8Array).toString('utf8')) as unknown
+      if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('not an object')
+      envelope = value as Record<string, unknown>
+    } catch {
+      throw new HttpError(400, 'Malformed group event frame')
+    }
+    const type = envelope.type
+    const cursor = Number(envelope.cursor)
+    if (type === 'group.ready') {
+      const heartbeat = Number(envelope.heartbeatSeconds)
+      if (this.#ready || envelope.epoch !== this.epoch || !Number.isSafeInteger(cursor)
+        || cursor !== this.#cursor || !Number.isFinite(heartbeat) || heartbeat <= 0) {
+        throw new HttpError(409, 'Invalid group.ready frame')
+      }
+      this.#ready = true
+      return true
+    }
+    if (type === 'group.reset_required') {
+      if (typeof envelope.epoch !== 'string' || !Number.isSafeInteger(cursor)
+        || typeof envelope.reason !== 'string') {
+        throw new HttpError(400, 'Invalid group.reset_required frame')
+      }
+      return true
+    }
+    if (!this.#ready) throw new HttpError(409, 'Group event arrived before group.ready')
+    if (type === 'group.event') {
+      if (envelope.epoch !== this.epoch || !Number.isSafeInteger(cursor) || cursor < 0) {
+        throw new HttpError(409, 'Group event epoch or cursor is invalid')
+      }
+      if (cursor <= this.#cursor) return false
+      if (cursor !== this.#cursor + 1) throw new HttpError(409, 'Group event cursor gap')
+      this.#cursor = cursor
+      return true
+    }
+    if (type === 'group.heartbeat') {
+      if ((envelope.epoch !== undefined && envelope.epoch !== this.epoch)
+        || !Number.isSafeInteger(cursor) || cursor !== this.#cursor) {
+        throw new HttpError(409, 'Group heartbeat cursor mismatch')
+      }
+      return true
+    }
+    throw new HttpError(400, 'Unknown group event frame')
+  }
+
+  resetFrame(reason: string): string {
+    return JSON.stringify({
+      type: 'group.reset_required',
+      epoch: this.epoch,
+      cursor: this.#cursor,
+      reason,
+    })
+  }
+}
+
+function relaySocket(
+  client: WebSocket,
+  upstream: WebSocket,
+  channel: RealtimeChannel,
+  onFinished: () => void,
+  groupAnchor?: { epoch: string; cursor: number },
+): void {
+  const groupValidator = groupAnchor
+    ? new GroupFrameValidator(groupAnchor.epoch, groupAnchor.cursor)
+    : undefined
+  let clientAlive = true
+  let upstreamAlive = true
+  let finished = false
+  const finish = () => {
+    if (finished) return
+    finished = true
+    clearInterval(heartbeat)
+    onFinished()
+  }
+  const heartbeat = setInterval(() => {
+    if (!clientAlive || !upstreamAlive) {
+      client.terminate()
+      upstream.terminate()
+      finish()
+      return
+    }
+    clientAlive = false
+    upstreamAlive = false
+    if (client.readyState === WebSocket.OPEN) client.ping()
+    if (upstream.readyState === WebSocket.OPEN) upstream.ping()
+  }, 25_000)
+  heartbeat.unref()
+  client.on('pong', () => { clientAlive = true })
+  upstream.on('pong', () => { upstreamAlive = true })
+
+  client.on('message', (data, isBinary) => {
+    try {
+      if (channel === 'groups') throw new HttpError(403, 'Group event stream is read-only')
+      if (upstream.readyState !== WebSocket.OPEN) throw new HttpError(409, 'Hermes socket is not ready')
+      if (upstream.bufferedAmount > MAX_BUFFERED_BYTES) throw new HttpError(429, 'Hermes socket is congested')
+      upstream.send(checkedChatFrame(data, isBinary))
+    } catch (error) {
+      const reason = error instanceof Error ? error.message : 'Invalid frame'
+      closePair(client, upstream, 1008, reason)
+    }
+  })
+  upstream.on('message', (data, isBinary) => {
+    const length = typeof data === 'string' ? Buffer.byteLength(data) : Buffer.byteLength(data as Uint8Array)
+    if ((channel === 'groups' && length > GROUP_MAX_PAYLOAD) || client.bufferedAmount > MAX_BUFFERED_BYTES) {
+      closePair(client, upstream, 1009, 'Upstream frame or buffer limit exceeded')
+      return
+    }
+    if (groupValidator) {
+      try {
+        if (!groupValidator.accept(data, isBinary)) return
+      } catch (error) {
+        const reason = error instanceof Error ? error.message : 'invalid_group_frame'
+        if (client.readyState === WebSocket.OPEN) client.send(groupValidator.resetFrame(reason))
+        closePair(client, upstream, 1008, 'Group event stream requires reset')
+        return
+      }
+    }
+    if (client.readyState === WebSocket.OPEN) client.send(data, { binary: isBinary })
+  })
+  client.on('close', (code, reason) => {
+    if (upstream.readyState === WebSocket.OPEN) {
+      upstream.close(safeCloseCode(code), safeCloseReason(reason.toString()))
+    }
+    finish()
+  })
+  upstream.on('close', (code, reason) => {
+    if (client.readyState === WebSocket.OPEN) {
+      client.close(safeCloseCode(code), safeCloseReason(reason.toString()))
+    }
+    finish()
+  })
+  client.on('error', () => {
+    upstream.terminate()
+    finish()
+  })
+  upstream.on('error', () => {
+    if (client.readyState === WebSocket.OPEN) client.close(1011, 'Hermes WebSocket failed')
+    finish()
+  })
+}
+
+export function installWebSocketRelay(
+  server: HttpServer,
+  config: ServerConfig,
+  leases: RealtimeLeaseStore,
+): () => void {
+  const socketServer = new WebSocketServer({ noServer: true, maxPayload: CHAT_MAX_PAYLOAD })
+  const active = new Map<string, number>()
+  const onUpgrade = (request: Parameters<NonNullable<HttpServer['on']>>[1] extends never ? never : any, socket: Duplex, head: Buffer) => {
+    let url: URL
+    try {
+      url = new URL(request.url ?? '/', `http://${request.headers.host ?? 'invalid'}`)
+    } catch {
+      rejectUpgrade(socket, 400, 'Invalid WebSocket URL')
+      return
+    }
+    if (!url.pathname.startsWith('/ws/')) {
+      if (config.production) rejectUpgrade(socket, 404, 'Unknown WebSocket endpoint')
+      return
+    }
+    const channel: RealtimeChannel | undefined = url.pathname === '/ws/chat'
+      ? 'chat'
+      : url.pathname === '/ws/groups' ? 'groups' : undefined
+    if (!channel) {
+      rejectUpgrade(socket, 404, 'Unknown WebSocket endpoint')
+      return
+    }
+    const host = request.headers.host
+    const origin = typeof request.headers.origin === 'string' ? request.headers.origin : undefined
+    const secure = Boolean(config.tlsCert)
+    if (!isAllowedHostHeader(host, config) || !isExactOrigin(origin, host, secure)) {
+      rejectUpgrade(socket, 403, 'Host or Origin rejected')
+      return
+    }
+    if ([...url.searchParams.keys()].some((name) => name !== 'lease')) {
+      rejectUpgrade(socket, 400, 'Unexpected WebSocket query parameter')
+      return
+    }
+    const leaseID = url.searchParams.get('lease')
+    if (!leaseID || leaseID.length > 128) {
+      rejectUpgrade(socket, 401, 'Missing realtime lease')
+      return
+    }
+    let lease
+    const accountKey = requestAccountKey(request)
+    try {
+      lease = leases.consume(leaseID, channel, origin!, accountKey)
+    } catch {
+      rejectUpgrade(socket, 401, 'Realtime lease is invalid or expired')
+      return
+    }
+    const connectionKey = `${channel}:${accountKey}`
+    const maximum = channel === 'chat' ? 4 : 2
+    if ((active.get(connectionKey) ?? 0) >= maximum) {
+      rejectUpgrade(socket, 429, 'Too many realtime connections')
+      return
+    }
+    active.set(connectionKey, (active.get(connectionKey) ?? 0) + 1)
+
+    socketServer.handleUpgrade(request, socket, head, (client) => {
+      const upstream = new WebSocket(upstreamWebSocketURL(config, channel, lease), {
+        headers: { Origin: config.upstream.origin },
+        maxPayload: channel === 'chat' ? CHAT_MAX_PAYLOAD : GROUP_MAX_PAYLOAD,
+        handshakeTimeout: 15_000,
+      })
+      let released = false
+      const release = () => {
+        if (released) return
+        released = true
+        const count = (active.get(connectionKey) ?? 1) - 1
+        if (count <= 0) active.delete(connectionKey)
+        else active.set(connectionKey, count)
+      }
+      upstream.once('open', () => relaySocket(
+        client,
+        upstream,
+        channel,
+        release,
+        channel === 'groups' ? { epoch: lease.epoch!, cursor: lease.cursor ?? 0 } : undefined,
+      ))
+      upstream.once('unexpected-response', () => {
+        if (client.readyState === WebSocket.OPEN) client.close(1011, 'Hermes rejected WebSocket')
+        release()
+      })
+      upstream.once('error', () => {
+        if (upstream.readyState !== WebSocket.OPEN && client.readyState === WebSocket.OPEN) {
+          client.close(1011, 'Unable to connect to Hermes')
+          release()
+        }
+      })
+      client.once('close', () => {
+        if (upstream.readyState === WebSocket.CONNECTING) upstream.terminate()
+        release()
+      })
+    })
+  }
+  server.on('upgrade', onUpgrade)
+  return () => {
+    server.off('upgrade', onUpgrade)
+    for (const client of socketServer.clients) client.close(1001, 'Server shutting down')
+    socketServer.close()
+  }
+}

+ 1 - 0
src/shared/index.ts

@@ -0,0 +1 @@
+export * from './types.js'

+ 426 - 0
src/shared/types.ts

@@ -0,0 +1,426 @@
+export type JsonPrimitive = string | number | boolean | null
+export type JsonValue = JsonPrimitive | JsonValue[] | { [key: string]: JsonValue }
+
+export type AuthStatus =
+  | 'checking'
+  | 'anonymous'
+  | 'authenticating'
+  | 'authenticated'
+  | 'expired'
+  | 'error'
+
+export interface CurrentUser {
+  id: string
+  username: string
+  displayName?: string
+  email?: string
+  provider?: string
+  role?: string
+}
+
+export interface Profile {
+  name: string
+  agentName?: string
+  displayName?: string
+  description?: string
+  isDefault: boolean
+  model?: string
+  provider?: string
+  gatewayRunning?: boolean
+  avatar?: string
+}
+
+export interface ModelOption {
+  id: string
+  name: string
+  provider: string
+  supportsReasoning?: boolean
+  reasoningEfforts?: string[]
+  isDefault?: boolean
+}
+
+export interface BootstrapResponse {
+  status?: string
+  authRequired: boolean
+  user?: CurrentUser
+  profiles: Profile[]
+  csrfToken: string
+  insecureLan?: boolean
+  groupUploadsEnabled?: boolean
+  models?: ModelOption[]
+}
+
+export interface UploadReference {
+  id: string
+  name: string
+  mimeType: string
+  size: number
+  markdown?: string
+  refText?: string
+}
+
+export interface AccountScope {
+  upstream?: string
+  userId: string
+}
+
+export interface SessionSummary {
+  id: string
+  profile?: string
+  source: string
+  title: string
+  preview?: string
+  model?: string
+  provider?: string
+  agent?: string
+  messageCount: number
+  toolCallCount: number
+  inputTokens?: number
+  outputTokens?: number
+  startedAt: number
+  updatedAt: number
+  endedAt?: number | null
+  parentSessionId?: string | null
+  forkPointMessageId?: string | null
+  workspace?: string | null
+  archived?: boolean
+  pinned?: boolean
+}
+
+export type ChatRole = 'user' | 'assistant' | 'tool' | 'system'
+export type DeliveryStage =
+  | 'preparing'
+  | 'attached'
+  | 'pending'
+  | 'accepted'
+  | 'streaming'
+  | 'settled'
+  | 'failed'
+  | 'unknown-receipt'
+
+export interface ChatAttachment {
+  id: string
+  name: string
+  mimeType: string
+  size: number
+  path?: string
+  url?: string
+  kind?: 'image' | 'pdf' | 'file'
+}
+
+export interface ToolCall {
+  id: string
+  name: string
+  status: 'pending' | 'running' | 'completed' | 'failed'
+  arguments?: JsonValue
+  result?: JsonValue
+  preview?: string
+  error?: string
+  durationSeconds?: number
+}
+
+export interface ChatMessage {
+  id: string
+  sessionId: string
+  profile?: string
+  role: ChatRole
+  content: string
+  reasoning?: string
+  timestamp: number
+  sequence?: number
+  clientMessageId?: string
+  serverMessageId?: string
+  stage: DeliveryStage
+  isStreaming?: boolean
+  error?: string
+  attachments?: ChatAttachment[]
+  toolCalls?: ToolCall[]
+  raw?: JsonValue
+}
+
+export interface ChatRoute {
+  profile: string
+  sessionId: string
+}
+
+export interface ChatUsage {
+  inputTokens?: number
+  outputTokens?: number
+  totalTokens?: number
+  contextTokens?: number
+  contextLimit?: number
+  percentUsed?: number
+  raw?: JsonValue
+}
+
+export interface ApprovalRequest {
+  id: string
+  sessionId: string
+  message?: string
+  toolName?: string
+  choices?: string[]
+  payload: Record<string, JsonValue>
+}
+
+export interface ClarificationRequest {
+  id: string
+  sessionId: string
+  question: string
+  choices?: string[]
+  payload: Record<string, JsonValue>
+}
+
+export type RealtimeConnectionState =
+  | 'idle'
+  | 'leasing'
+  | 'connecting'
+  | 'connected'
+  | 'ready'
+  | 'reconnecting'
+  | 'disconnected'
+  | 'failed'
+  | 'needs-reset'
+
+export interface ChatRouteState {
+  route: ChatRoute
+  messages: ChatMessage[]
+  runtimeSessionId?: string
+  historySynced: boolean
+  hasMoreBefore: boolean
+  loadedMessageCount: number
+  messageTotal: number
+  isLoadingHistory: boolean
+  isStreaming: boolean
+  isQueued: boolean
+  liveStatus?: string
+  usage?: ChatUsage
+  pendingApproval?: ApprovalRequest
+  pendingClarification?: ClarificationRequest
+  error?: string
+  generation: number
+}
+
+export interface RpcRequestFrame {
+  jsonrpc: '2.0'
+  id: string
+  method: string
+  params: Record<string, JsonValue>
+}
+
+export interface RpcEventFrame {
+  jsonrpc?: '2.0'
+  method: 'event'
+  params: {
+    type: string
+    session_id?: string
+    profile?: string
+    payload?: JsonValue
+  }
+}
+
+export interface GroupLimits {
+  maxAgentsPerRoom: number
+  maxMessageBytes: number
+  maxToolStateBytes?: number
+  maxInteractionPayloadBytes?: number
+  maxMessagePageSize: number
+  maxEventBatchSize?: number
+  maxAgentDepth?: number
+  maxRoomConcurrency?: number
+  maxPluginConcurrency?: number
+  defaultMaxReplyRounds: number
+  unlimitedReplyRoundsValue: number
+  maxAgentDisplayNameLength: number
+}
+
+export interface GroupCapabilities {
+  protocolVersion: number
+  journalEpoch: string
+  latestCursor: number
+  limits: GroupLimits
+  eventTypes: string[]
+}
+
+export type GroupAgentStatus = 'idle' | 'queued' | 'running' | 'awaiting_input' | 'unknown'
+export type GroupMessageStatus = 'queued' | 'streaming' | 'completed' | 'failed' | 'interrupted' | 'unknown'
+
+export interface GroupAgent {
+  id: string
+  roomId: string
+  profile: string
+  displayName: string
+  description: string
+  storedSessionId?: string | null
+  lastContextMessageSeq: number
+  enabled: boolean
+  replyWithoutMention: boolean
+  createdAt: number
+  updatedAt: number
+  status: GroupAgentStatus
+}
+
+export interface GroupMessage {
+  seq: number
+  id: string
+  roomId: string
+  senderKind: 'human' | 'agent' | 'system' | 'unknown'
+  senderId: string
+  senderName: string
+  rootMessageId: string
+  replyToMessageId?: string | null
+  clientMessageId?: string | null
+  content: string
+  reasoning: string
+  toolState: JsonValue[]
+  status: GroupMessageStatus
+  error: string
+  createdAt: number
+  updatedAt: number
+}
+
+export interface GroupRun {
+  id: string
+  roomId: string
+  agentId: string
+  triggerMessageId: string
+  responseMessageId: string
+  rootMessageId: string
+  depth: number
+  status: 'queued' | 'running' | 'awaiting_input' | 'completed' | 'failed' | 'interrupted' | 'unknown'
+  runtimeSessionId?: string | null
+  error: string
+  replyMode?: 'mentioned' | 'automatic' | null
+  createdAt: number
+  updatedAt: number
+}
+
+export interface GroupInteraction {
+  id: string
+  roomId: string
+  agentId: string
+  runId: string
+  kind: 'approval' | 'clarification' | 'unknown'
+  payload: JsonValue
+  status: 'pending' | 'resolved' | 'cancelled' | 'unknown'
+  createdAt: number
+  resolvedAt?: number | null
+}
+
+export interface GroupRoomSummary {
+  id: string
+  name: string
+  cwd: string
+  createdAt: number
+  updatedAt: number
+  archived: boolean
+  agentCount: number
+  lastMessage?: GroupMessage | null
+  unreadCount: number
+  maxReplyRounds: number
+}
+
+export interface GroupRoomDetail extends Omit<GroupRoomSummary, 'agentCount' | 'lastMessage' | 'unreadCount'> {
+  agents: GroupAgent[]
+  runs: GroupRun[]
+  pendingInteractions: GroupInteraction[]
+  latestCursor: number
+}
+
+export interface GroupRoomPage {
+  items: GroupRoomSummary[]
+  nextCursor?: string | null
+}
+
+export interface GroupMessagePage {
+  items: GroupMessage[]
+}
+
+export type GroupEventType =
+  | 'room.created'
+  | 'room.updated'
+  | 'room.deleted'
+  | 'agent.created'
+  | 'agent.updated'
+  | 'agent.deleted'
+  | 'agent.status'
+  | 'message.upsert'
+  | 'interaction.requested'
+  | 'interaction.resolved'
+  | 'run.updated'
+  | string
+
+export interface GroupSocketEnvelope {
+  type: 'group.ready' | 'group.event' | 'group.heartbeat' | 'group.reset_required'
+  epoch?: string
+  cursor?: number
+  heartbeatSeconds?: number
+  roomId?: string
+  event?: GroupEventType
+  payload?: JsonValue
+  reason?: string
+}
+
+export interface FileOrigin {
+  id?: number | string
+  profile?: string
+  sessionId?: string
+  sessionTitle?: string
+  messageId?: string
+  authorKind?: 'user' | 'agent' | 'tool'
+  authorName?: string
+  observedAt?: number
+  originalPath?: string
+  referencePath?: string
+}
+
+export type FileKind = 'image' | 'video' | 'audio' | 'document' | 'other'
+
+export interface FileLibraryItem {
+  id: string
+  path: string
+  name: string
+  extension: string
+  mimeType: string
+  size: number
+  modifiedAt: number
+  exists: boolean
+  availability?: 'archived' | 'source' | 'unavailable'
+  archiveStatus?: string
+  firstSeenAt?: number
+  lastSeenAt?: number
+  messageTimestamp?: number | null
+  origins: FileOrigin[]
+  kind: FileKind
+  previewUrl?: string
+  downloadUrl?: string
+}
+
+export interface FileLibraryPage {
+  items: FileLibraryItem[]
+  nextCursor?: string | null
+  total?: number
+}
+
+export type ArtifactKind = 'image' | 'file' | 'link'
+
+export interface ConversationArtifact {
+  id: string
+  kind: ArtifactKind
+  value: string
+  label: string
+  sessionId: string
+  sessionTitle: string
+  profile?: string
+  messageId: string
+  timestamp: number
+  mimeType?: string
+  attachment?: ChatAttachment
+}
+
+export interface ComposerState {
+  draft: string
+  attachments: ChatAttachment[]
+  quotedMessageId?: string
+  isComposing: boolean
+  mode: 'send' | 'queue' | 'steer'
+}

+ 30 - 0
tests/brand.test.ts

@@ -0,0 +1,30 @@
+import { createHash } from 'node:crypto'
+import { readFile } from 'node:fs/promises'
+import { resolve } from 'node:path'
+import { describe, expect, it } from 'vitest'
+
+function pngSize(data: Buffer): { width: number; height: number } {
+  expect(data.subarray(1, 4).toString()).toBe('PNG')
+  return { width: data.readUInt32BE(16), height: data.readUInt32BE(20) }
+}
+
+describe('brand assets', () => {
+  it('keeps the mobile AppIcon as the exact brand source', async () => {
+    const data = await readFile(resolve('public/brand/AppIcon-1024.png'))
+    expect(createHash('sha256').update(data).digest('hex')).toBe(
+      'e19c03450c62238aeb70ad735f612d4bfc61964e1473b1be4bdca1d9230bd086',
+    )
+    expect(pngSize(data)).toEqual({ width: 1024, height: 1024 })
+  })
+
+  it.each([
+    ['favicon-32.png', 32],
+    ['logo-64.png', 64],
+    ['apple-touch-icon.png', 180],
+    ['icon-192.png', 192],
+    ['icon-512.png', 512],
+  ])('generates %s without changing aspect ratio', async (name, size) => {
+    const data = await readFile(resolve('public/icons', name))
+    expect(pngSize(data)).toEqual({ width: size, height: size })
+  })
+})

+ 50 - 0
tests/client/artifactStore.test.ts

@@ -0,0 +1,50 @@
+import { describe, expect, it } from 'vitest'
+import type { ChatMessage, SessionSummary } from '@shared/types'
+import { extractArtifacts } from '@/utils/artifacts'
+
+const session: SessionSummary = {
+  id: 'session-1', profile: 'yaoyao', source: 'web', title: '产物会话', messageCount: 4,
+  toolCallCount: 1, startedAt: 1, updatedAt: 5,
+}
+
+function row(id: string, role: ChatMessage['role'], content: string, extras: Partial<ChatMessage> = {}): ChatMessage {
+  return { id, sessionId: session.id, profile: session.profile, role, content, timestamp: 2, stage: 'settled', ...extras }
+}
+
+describe('artifact extractor', () => {
+  it('extracts assistant markdown with readable link titles and ignores user rows', () => {
+    const artifacts = extractArtifacts(session, [
+      row('user', 'user', '[不要收集](https://private.example/user)'),
+      row('assistant', 'assistant', '查看 [发布说明](https://example.com/releases/v1) 和 ![图](/tmp/chart.png)'),
+    ])
+    expect(artifacts.map(item => item.value)).toEqual(expect.arrayContaining(['https://example.com/releases/v1', '/tmp/chart.png']))
+    expect(artifacts.find(item => item.kind === 'link')?.label).toBe('发布说明')
+    expect(artifacts.find(item => item.kind === 'image')?.label).toBe('chart.png')
+  })
+
+  it('preserves structured attachment metadata for preview', () => {
+    const artifacts = extractArtifacts(session, [row('assistant', 'assistant', '', {
+      attachments: [{ id: 'attachment-1', name: 'demo.mp4', mimeType: 'video/mp4', size: 10, path: '/tmp/demo.mp4', kind: 'file' }],
+    })])
+    expect(artifacts[0]).toMatchObject({ kind: 'file', value: '/tmp/demo.mp4', mimeType: 'video/mp4' })
+    expect(artifacts[0].attachment?.id).toBe('attachment-1')
+  })
+
+  it('extracts hinted tool result paths and deduplicates within a session', () => {
+    const artifacts = extractArtifacts(session, [row('tool', 'tool', '{"output_path":"/tmp/result.pdf"}', {
+      toolCalls: [{ id: 'tool-1', name: 'write', status: 'completed', result: { output_path: '/tmp/result.pdf' } }],
+    })])
+    expect(artifacts).toHaveLength(1)
+    expect(artifacts[0]).toMatchObject({ kind: 'file', value: '/tmp/result.pdf', label: 'result.pdf' })
+  })
+
+  it('keeps the same artifact path when it was produced by distinct messages', () => {
+    const artifacts = extractArtifacts(session, [
+      row('assistant-1', 'assistant', '[第一版](/tmp/result.pdf)'),
+      row('assistant-2', 'assistant', '[第二版](/tmp/result.pdf)', { timestamp: 3 }),
+    ])
+    expect(artifacts).toHaveLength(2)
+    expect(artifacts.map(item => item.messageId)).toEqual(['assistant-1', 'assistant-2'])
+    expect(new Set(artifacts.map(item => item.id)).size).toBe(2)
+  })
+})

+ 64 - 0
tests/client/authProtocol.test.ts

@@ -0,0 +1,64 @@
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { bootstrap, login } from '@/api/auth'
+import { clearApiSecurityContext } from '@/api/client'
+
+afterEach(() => {
+  clearApiSecurityContext()
+  vi.unstubAllGlobals()
+})
+
+describe('authentication and CSRF protocol', () => {
+  it('keeps credentials in cookies and forwards the bootstrap CSRF token on login', async () => {
+    const fetchMock = vi.fn(async (input: RequestInfo | URL, _init?: RequestInit) => {
+      const path = new URL(String(input), 'http://localhost').pathname
+      const body = path.endsWith('/bootstrap')
+        ? { authRequired: true, authenticated: false, profiles: [], csrfToken: 'csrf-from-bootstrap' }
+        : {
+            authRequired: true,
+            authenticated: true,
+            user: { user_id: 'user-1', display_name: '夭夭' },
+            profiles: [{ name: 'yaoyao', is_default: true }],
+            csrfToken: 'csrf-after-login',
+          }
+      return new Response(JSON.stringify(body), { status: 200, headers: { 'content-type': 'application/json' } })
+    })
+    vi.stubGlobal('fetch', fetchMock)
+
+    const initial = await bootstrap()
+    expect(initial.user).toBeUndefined()
+    const authenticated = await login({ username: 'name', password: 'secret' })
+    expect(authenticated.user).toMatchObject({ id: 'user-1', username: '夭夭' })
+    const loginInit = fetchMock.mock.calls[1][1]!
+    expect(loginInit.credentials).toBe('include')
+    expect(new Headers(loginInit.headers).get('X-CSRF-Token')).toBe('csrf-from-bootstrap')
+  })
+
+  it('keeps the CSRF context after a rejected password so login can be retried', async () => {
+    let loginAttempts = 0
+    const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
+      const path = new URL(String(input), 'http://localhost').pathname
+      if (path.endsWith('/bootstrap')) {
+        return new Response(JSON.stringify({ authRequired: true, profiles: [], csrfToken: 'retry-token' }), {
+          status: 200, headers: { 'content-type': 'application/json' },
+        })
+      }
+      loginAttempts += 1
+      if (loginAttempts === 1) {
+        return new Response(JSON.stringify({ error: 'Invalid credentials', code: 'login_failed' }), {
+          status: 401, headers: { 'content-type': 'application/json' },
+        })
+      }
+      expect(new Headers(init?.headers).get('X-CSRF-Token')).toBe('retry-token')
+      return new Response(JSON.stringify({
+        authRequired: true,
+        user: { user_id: 'user-1', display_name: '夭夭' },
+        profiles: [{ name: 'yaoyao', is_default: true }],
+        csrfToken: 'after-retry',
+      }), { status: 200, headers: { 'content-type': 'application/json' } })
+    })
+    vi.stubGlobal('fetch', fetchMock)
+    await bootstrap()
+    await expect(login({ username: 'name', password: 'wrong' })).rejects.toMatchObject({ status: 401 })
+    await expect(login({ username: 'name', password: 'correct' })).resolves.toMatchObject({ user: { id: 'user-1' } })
+  })
+})

+ 95 - 0
tests/client/chatProtocol.test.ts

@@ -0,0 +1,95 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { ChatRpcSocket, GroupEventSocket } from '@/api/realtime'
+import { setApiCsrfToken } from '@/api/client'
+
+class FakeWebSocket extends EventTarget {
+  static readonly CONNECTING = 0
+  static readonly OPEN = 1
+  static readonly CLOSING = 2
+  static readonly CLOSED = 3
+  static instances: FakeWebSocket[] = []
+
+  readonly url: string
+  readyState = FakeWebSocket.CONNECTING
+  sent: string[] = []
+
+  constructor(url: string) {
+    super()
+    this.url = url
+    FakeWebSocket.instances.push(this)
+    queueMicrotask(() => {
+      this.readyState = FakeWebSocket.OPEN
+      this.dispatchEvent(new Event('open'))
+    })
+  }
+
+  send(data: string): void { this.sent.push(data) }
+  close(code = 1000, reason = ''): void {
+    this.readyState = FakeWebSocket.CLOSED
+    this.dispatchEvent(new CloseEvent('close', { code, reason }))
+  }
+  message(value: unknown): void { this.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(value) })) }
+}
+
+beforeEach(() => {
+  FakeWebSocket.instances = []
+  setApiCsrfToken('csrf-test')
+  vi.stubGlobal('WebSocket', FakeWebSocket)
+})
+
+afterEach(() => {
+  vi.unstubAllGlobals()
+  vi.restoreAllMocks()
+})
+
+function leaseFetch() {
+  const mock = vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) => new Response(JSON.stringify({ lease: 'lease-1' }), {
+    status: 201, headers: { 'content-type': 'application/json' },
+  }))
+  vi.stubGlobal('fetch', mock)
+  return mock
+}
+
+describe('raw realtime protocols', () => {
+  it('waits for gateway.ready and correlates JSON-RPC responses', async () => {
+    leaseFetch()
+    const socket = new ChatRpcSocket()
+    const events: string[] = []
+    socket.onEvent(event => events.push(event.type))
+    const connecting = socket.connect()
+    await vi.waitFor(() => expect(FakeWebSocket.instances).toHaveLength(1))
+    const transport = FakeWebSocket.instances[0]
+    await Promise.resolve()
+    expect(socket.state).toBe('connected')
+    transport.message({ jsonrpc: '2.0', method: 'event', params: { type: 'gateway.ready', payload: {} } })
+    await connecting
+    expect(socket.state).toBe('ready')
+
+    const response = socket.request('session.usage', { session_id: 'runtime-1' })
+    const frame = JSON.parse(transport.sent[0])
+    expect(frame).toMatchObject({ jsonrpc: '2.0', method: 'session.usage', params: { session_id: 'runtime-1' } })
+    transport.message({ jsonrpc: '2.0', id: frame.id, result: { total_tokens: 42 } })
+    await expect(response).resolves.toEqual({ total_tokens: 42 })
+    expect(events).toEqual(['gateway.ready'])
+    socket.close()
+  })
+
+  it('binds a group lease to epoch/cursor but exposes only the lease in WS query', async () => {
+    const fetchMock = leaseFetch()
+    const socket = new GroupEventSocket()
+    await socket.connect({
+      epoch: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa',
+      cursor: 12,
+      onEnvelope: () => undefined,
+      onReset: () => undefined,
+    })
+    const request = fetchMock.mock.calls[0]?.[1]
+    expect(request).toBeDefined()
+    expect(JSON.parse(String(request!.body))).toEqual({
+      channel: 'groups', epoch: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', cursor: 12,
+    })
+    const url = new URL(FakeWebSocket.instances[0].url)
+    expect([...url.searchParams.keys()]).toEqual(['lease'])
+    socket.close()
+  })
+})

+ 85 - 0
tests/client/chatStore.test.ts

@@ -0,0 +1,85 @@
+import { describe, expect, it } from 'vitest'
+import type { ChatMessage, ChatRouteState, RpcEventFrame } from '@shared/types'
+import { applyChatEvent, mergeChatMessages } from '@/utils/messageReducer'
+
+function message(id: string, content = '相同内容', clientMessageId?: string): ChatMessage {
+  return {
+    id, sessionId: 'session-1', profile: 'yaoyao', role: 'user', content,
+    clientMessageId, timestamp: 1, stage: 'settled',
+  }
+}
+
+function state(): ChatRouteState {
+  return {
+    route: { profile: 'yaoyao', sessionId: 'session-1' }, messages: [], historySynced: true,
+    hasMoreBefore: false, loadedMessageCount: 0, messageTotal: 0, isLoadingHistory: false,
+    isStreaming: false, isQueued: false, generation: 1,
+  }
+}
+
+function event(type: string, payload: Record<string, unknown>, sessionId = 'session-1'): RpcEventFrame['params'] {
+  return { type, session_id: sessionId, profile: 'yaoyao', payload: payload as never }
+}
+
+describe('chat message reducer', () => {
+  it('preserves repeated user text when server identities differ', () => {
+    const merged = mergeChatMessages([message('one')], [message('two')])
+    expect(merged).toHaveLength(2)
+    expect(merged.map(item => item.content)).toEqual(['相同内容', '相同内容'])
+  })
+
+  it('reconciles an optimistic row only by client message identity', () => {
+    const optimistic = { ...message('local', 'hello', 'client-1'), stage: 'pending' as const }
+    const persisted = { ...message('server-1', 'hello', 'client-1'), serverMessageId: 'server-1' }
+    const merged = mergeChatMessages([optimistic], [persisted])
+    expect(merged).toHaveLength(1)
+    expect(merged[0].id).toBe('server-1')
+    expect(merged[0].stage).toBe('settled')
+  })
+
+  it('ignores events attributed to another route', () => {
+    const original = state()
+    const reduced = applyChatEvent(original, event('message.delta', { delta: 'wrong' }, 'session-2'))
+    expect(reduced).toBe(original)
+    expect(reduced.messages).toEqual([])
+  })
+
+  it('uses terminal authoritative output to repair missed deltas', () => {
+    let current = applyChatEvent(state(), event('message.start', { message_id: 'assistant-1' }))
+    current = applyChatEvent(current, event('message.delta', { message_id: 'assistant-1', delta: '少量' }))
+    current = applyChatEvent(current, event('message.complete', {
+      message_id: 'assistant-1', text: '完整最终回答', authoritative_output: true,
+      usage: { input: 10, output: 20, total: 30 },
+    }))
+    expect(current.messages).toHaveLength(1)
+    expect(current.messages[0].content).toBe('完整最终回答')
+    expect(current.messages[0].stage).toBe('settled')
+    expect(current.usage?.totalTokens).toBe(30)
+  })
+
+  it('treats message.complete status=error as a failed terminal row', () => {
+    let current = applyChatEvent(state(), event('message.start', { message_id: 'assistant-1' }))
+    current = applyChatEvent(current, event('message.complete', {
+      message_id: 'assistant-1', status: 'error', text: '工具执行失败', error: 'permission denied',
+    }))
+    expect(current.messages[0]).toMatchObject({ stage: 'failed', isStreaming: false })
+    expect(current.error).toContain('permission denied')
+  })
+
+  it('maps the current 9119 context usage field names', () => {
+    const current = applyChatEvent(state(), event('session.usage', {
+      input: 10, output: 20, total: 30, context_used: 12_500, context_max: 114_688, context_percent: 10.9,
+    }))
+    expect(current.usage).toMatchObject({ contextTokens: 12_500, contextLimit: 114_688, percentUsed: 10.9 })
+  })
+
+  it('tracks approval and clarification independently', () => {
+    let current = applyChatEvent(state(), event('approval.request', { request_id: 'approval-1', message: '允许吗?' }))
+    current = applyChatEvent(current, event('clarify.request', { request_id: 'clarify-1', question: '选哪个?' }))
+    expect(current.pendingApproval?.id).toBe('approval-1')
+    expect(current.pendingClarification?.id).toBe('clarify-1')
+    current = applyChatEvent(current, event('approval.resolved', {}))
+    expect(current.pendingApproval).toBeUndefined()
+    expect(current.pendingClarification?.id).toBe('clarify-1')
+  })
+})

+ 17 - 0
tests/client/composer.test.ts

@@ -0,0 +1,17 @@
+import { mount } from '@vue/test-utils'
+import { describe, expect, it } from 'vitest'
+import ComposerShell from '@/components/composer/ComposerShell.vue'
+
+describe('composer availability', () => {
+  it('keeps the group-chat send action available while an Agent is running', () => {
+    const wrapper = mount(ComposerShell, { props: { mode: 'group', streaming: true } })
+    expect(wrapper.find('[aria-label="发送消息"]').exists()).toBe(true)
+    expect(wrapper.find('[aria-label="停止生成"]').exists()).toBe(false)
+  })
+
+  it('keeps the ordinary-chat interrupt action while its server run is active', () => {
+    const wrapper = mount(ComposerShell, { props: { mode: 'chat', streaming: true } })
+    expect(wrapper.find('[aria-label="停止生成"]').exists()).toBe(true)
+    expect(wrapper.find('[aria-label="发送消息"]').exists()).toBe(false)
+  })
+})

+ 25 - 0
tests/client/filesProtocol.test.ts

@@ -0,0 +1,25 @@
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { getFiles } from '@/api/files'
+
+afterEach(() => vi.unstubAllGlobals())
+
+describe('file library profile routing', () => {
+  it('uses the response/request profile when plugin origins omit it', async () => {
+    vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({
+      profile: 'yaoer',
+      items: [{
+        id: 7,
+        name: 'result.pdf',
+        path: '/tmp/result.pdf',
+        mimeType: 'application/pdf',
+        size: 10,
+        origins: [{ profile: '', sessionId: 'session-1', messageId: 'message-1' }],
+      }],
+      nextCursor: null,
+    }), { status: 200, headers: { 'content-type': 'application/json' } })))
+    const page = await getFiles({ profile: 'yaoer' })
+    expect(page.items[0].origins[0].profile).toBe('yaoer')
+    expect(page.items[0].previewUrl).toContain('profile=yaoer')
+    expect(page.items[0].downloadUrl).toContain('profile=yaoer')
+  })
+})

+ 55 - 0
tests/client/groupProtocol.test.ts

@@ -0,0 +1,55 @@
+import { describe, expect, it } from 'vitest'
+import type { GroupMessage, GroupSocketEnvelope } from '@shared/types'
+import {
+  applyGroupEnvelope, classifyGroupEnvelope, type GroupProtocolState, upsertGroupMessage,
+} from '@/utils/groupProtocol'
+
+const epoch = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'
+
+function groupMessage(id: string, clientMessageId: string, content: string, status: GroupMessage['status']): GroupMessage {
+  return {
+    seq: status === 'queued' ? 0 : 1, id, roomId: 'room-1', senderKind: 'human', senderId: 'me', senderName: '我',
+    rootMessageId: id, clientMessageId, content, reasoning: '', toolState: [], status, error: '', createdAt: 1, updatedAt: 1,
+  }
+}
+
+function initial(): GroupProtocolState {
+  return {
+    epoch, cursor: 8,
+    rooms: [{ id: 'room-1', name: '群聊', cwd: '', createdAt: 1, updatedAt: 1, archived: false, agentCount: 1, unreadCount: 0, maxReplyRounds: 3 }],
+    roomDetails: {}, messagesByRoom: { 'room-1': [] },
+  }
+}
+
+describe('group protocol v2 continuity', () => {
+  it('accepts only exact ready and next cursor, ignoring duplicates', () => {
+    expect(classifyGroupEnvelope(epoch, 8, { type: 'group.ready', epoch, cursor: 8 })).toBe('ready')
+    expect(classifyGroupEnvelope(epoch, 8, { type: 'group.event', epoch, cursor: 8 })).toBe('ignore')
+    expect(classifyGroupEnvelope(epoch, 8, { type: 'group.event', epoch, cursor: 9 })).toBe('apply')
+  })
+
+  it('requires reset for gaps, epoch changes, and server reset', () => {
+    expect(classifyGroupEnvelope(epoch, 8, { type: 'group.event', epoch, cursor: 10 })).toBe('reset')
+    expect(classifyGroupEnvelope(epoch, 8, { type: 'group.event', epoch: 'other', cursor: 9 })).toBe('reset')
+    expect(classifyGroupEnvelope(epoch, 8, { type: 'group.reset_required', epoch, cursor: 8 })).toBe('reset')
+  })
+
+  it('reconciles optimistic group messages by clientMessageId', () => {
+    const optimistic = groupMessage('local', 'client-1', 'hello', 'queued')
+    const server = groupMessage('server', 'client-1', 'hello', 'completed')
+    const merged = upsertGroupMessage([optimistic], server)
+    expect(merged).toHaveLength(1)
+    expect(merged[0]).toMatchObject({ id: 'server', status: 'completed' })
+  })
+
+  it('applies message events to their own room and advances the cursor', () => {
+    const payload = groupMessage('server', 'client-1', 'answer', 'completed')
+    const envelope: GroupSocketEnvelope = {
+      type: 'group.event', epoch, cursor: 9, roomId: 'room-1', event: 'message.upsert', payload: payload as never,
+    }
+    const reduced = applyGroupEnvelope(initial(), envelope)
+    expect(reduced.cursor).toBe(9)
+    expect(reduced.messagesByRoom['room-1']).toHaveLength(1)
+    expect(reduced.rooms[0].lastMessage?.id).toBe('server')
+  })
+})

Some files were not shown because too many files changed in this diff