Explorar o código

持久化普通聊天并按需校准 9119

LSamien hai 1 mes
pai
achega
6ab9a18c32

+ 2 - 0
.env.example

@@ -8,6 +8,8 @@ HERMES_YAOYAO_ALLOWED_HOSTS=
 HERMES_YAOYAO_ALLOW_INSECURE_LAN=0
 # The managed LaunchAgent sets this to 1 and keeps the local Hermes Dashboard on 9119 available.
 HERMES_YAOYAO_SUPERVISE_DASHBOARD=0
+# 普通 source=web 聊天默认优先读取 15300 持久化投影;故障排查时可改为 upstream-only。
+HERMES_YAOYAO_CHAT_CACHE_MODE=prefer-local
 # 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

+ 1 - 0
Dockerfile

@@ -23,6 +23,7 @@ ENV NODE_ENV=production \
     HERMES_YAOYAO_HOST=0.0.0.0 \
     HERMES_YAOYAO_PORT=15300 \
     HERMES_YAOYAO_HOME=/var/lib/hermes-yaoyao \
+    HERMES_YAOYAO_CHAT_CACHE_MODE=prefer-local \
     HERMES_YAOYAO_SUPERVISE_DASHBOARD=0
 
 WORKDIR /app

+ 4 - 2
README.md

@@ -1,6 +1,6 @@
 # 夭夭 Web
 
-当前发布版本:**v0.3.21**,配套 iOS **1.3(177)**。详见 [发布说明](docs/releases/v0.3.21.md)。
+当前发布版本:**v0.3.23**,配套 iOS **1.3(181)**。详见 [发布说明](docs/releases/v0.3.23.md)。
 
 夭夭 Web 为网页和 iOS 提供统一的 Agent、群聊、文件库及语音配置服务。Hermes 通过标准 9119 HTTP/WebSocket 接口执行任务;无需安装 Yaoyao 插件。
 
@@ -32,9 +32,11 @@ HERMES_YAOYAO_UPSTREAM=http://服务器:9119 npm start
 
 Web 数据默认保存在 `~/.hermes-yaoyao`,可用 `HERMES_YAOYAO_HOME` 指定。备份应包含整个数据目录及加密密钥。Docker 使用 `/var/lib/hermes-yaoyao` 持久化卷,不需要 Python、Hermes 文件系统挂载或插件安装载荷。
 
+由 Web 或 iOS 创建的 `source=web` 普通聊天会持久化到 `chat-cache.sqlite3`,完成同步后由 15300 直接提供列表、消息和已缓存附件。Web 的只读历史记录不进入该数据库,继续按需读取 9119。可通过 `HERMES_YAOYAO_CHAT_CACHE_MODE=upstream-only|shadow|prefer-local` 切换策略,默认 `prefer-local`。
+
 ## iOS
 
-iOS 统一连接 Web 地址,通过设置中的手机登录二维码或账号密码登录。不能再直接填写 9119 地址。原有直连账号需要重新配置为 Web 地址;不会自动改端口或删除旧缓存。
+iOS 统一连接 Web 地址,通过设置中的手机登录二维码或账号密码登录。不能再直接填写 9119 地址;旧 15300 Web 账号和本机聊天缓存继续保留。
 
 ## 数据与升级
 

+ 1 - 0
bin/hermes-yaoyao.mjs

@@ -45,6 +45,7 @@ function environment(env = process.env) {
     'HERMES_YAOYAO_TLS_KEY',
     'HERMES_YAOYAO_ALLOW_INSECURE_LAN',
     'HERMES_YAOYAO_SUPERVISE_DASHBOARD',
+    'HERMES_YAOYAO_CHAT_CACHE_MODE',
     'HERMES_YAOYAO_RELEASE_SOURCE',
     'HERMES_YAOYAO_RELEASE_ROOT',
     'HERMES_YAOYAO_ALLOW_REMOTE_UPDATE',

+ 1 - 0
compose.yaml

@@ -27,6 +27,7 @@ services:
       HERMES_YAOYAO_ALLOWED_HOSTS: "localhost,127.0.0.1,${HERMES_YAOYAO_ALLOWED_HOSTS:-}"
       HERMES_YAOYAO_ALLOW_INSECURE_LAN: "1"
       HERMES_YAOYAO_SUPERVISE_DASHBOARD: "0"
+      HERMES_YAOYAO_CHAT_CACHE_MODE: "${HERMES_YAOYAO_CHAT_CACHE_MODE:-prefer-local}"
       HERMES_YAOYAO_HOME: /var/lib/hermes-yaoyao
       HERMES_YAOYAO_APNS_KEY_FILE: "${HERMES_YAOYAO_APNS_KEY_FILE:-}"
       HERMES_YAOYAO_APNS_KEY_ID: "${HERMES_YAOYAO_APNS_KEY_ID:-}"

+ 1 - 0
docker.env.example

@@ -5,6 +5,7 @@ HERMES_YAOYAO_PUBLISHED_PORT=15300
 # 9119 由项目外部的 Hermes Dashboard 提供,Compose 不会创建、启动或停止它。
 # Docker Desktop 可用这个地址访问宿主机;Linux 上的 9119 还必须监听容器可达的宿主机地址。
 HERMES_YAOYAO_UPSTREAM=http://host.docker.internal:9119
+HERMES_YAOYAO_CHAT_CACHE_MODE=prefer-local
 # HERMES_YAOYAO_UPSTREAM_USERNAME=yaoyao-service
 # HERMES_YAOYAO_UPSTREAM_PASSWORD_FILE=/run/secrets/hermes_dashboard_password
 

+ 9 - 3
docs/read-cache.md

@@ -1,6 +1,12 @@
 # 15300 热点读取缓存
 
-本功能仅优化数据读取与上游认证检查,不改变模型生成速度,不迁移 9119 数据库,也不替代 iOS 本地缓存。
+15300 包含两层读取能力:进程内热点缓存,以及仅面向 `source=web` 普通聊天的持久化投影。它们不改变模型生成速度,也不替代 iOS 本地缓存。
+
+## 持久化普通聊天
+
+`chat-cache.sqlite3` 保存 Web/iOS 创建的普通聊天会话、消息、事件游标和附件索引;`chat-cache-assets` 保存已读取的媒体文件。完整同步的会话优先从本地返回,缓存缺失、事件变更或同步断档时才访问 9119。Web 只读历史记录不写入这两个位置,保持现有按需读取和短时内存缓存。
+
+`HERMES_YAOYAO_CHAT_CACHE_MODE` 支持 `upstream-only`、`shadow` 和 `prefer-local`,默认 `prefer-local`。本地数据库不可用时服务退回上游读取;发送、停止、审批、澄清和分支始终需要 9119 确认。
 
 ## 上游认证租期
 
@@ -14,8 +20,8 @@
 | --- | --- | --- |
 | Profile/身份列表 | 5 秒 | 15 秒 |
 | 模型选项 | 30 秒 | 60 秒 |
-| 会话列表、团队/话题列表 | 2 秒 | 无 |
-| 会话详情、最近消息、团队详情 | 1 秒 | 无 |
+| 非持久化会话列表、团队/话题列表 | 2 秒 | 无 |
+| 非持久化会话详情、最近消息、团队详情 | 1 秒 | 无 |
 | 已加载的较早消息页 | 10 秒 | 无 |
 
 仅缓存白名单 GET 的成功 JSON。容量上限为 64 MiB / 512 项,每项不超过 2 MiB,使用 LRU 淘汰;相同读取会合并。缓存键包含已验证的 15300 用户/授权版本或配对设备、上游 Cookie 的摘要、Profile、分页参数和请求头。缓存键不记录原始凭据。

+ 7 - 0
docs/releases/v0.3.23.md

@@ -0,0 +1,7 @@
+# v0.3.23
+
+- `source=web` 普通聊天在 15300 的独立 SQLite 数据库中持久化,会话列表、消息和附件优先本地读取。
+- 9119 只在缓存缺失、事件变化、执行控制或校准时读取;完成消息会触发后台全量回填。
+- 15300 重启或 9119 离线时,已完整同步的普通聊天和媒体仍可读取。
+- Web 历史记录保持按需读取 9119,不写入持久化聊天缓存。
+- 提供 `upstream-only`、`shadow` 和 `prefer-local` 三种服务端运行模式,默认 `prefer-local`。

+ 2 - 2
package-lock.json

@@ -1,12 +1,12 @@
 {
   "name": "hermes-yaoyao",
-  "version": "0.3.22",
+  "version": "0.3.23",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {
     "": {
       "name": "hermes-yaoyao",
-      "version": "0.3.22",
+      "version": "0.3.23",
       "license": "BSL-1.1",
       "dependencies": {
         "@koa/bodyparser": "^5.0.0",

+ 1 - 1
package.json

@@ -1,6 +1,6 @@
 {
   "name": "hermes-yaoyao",
-  "version": "0.3.22",
+  "version": "0.3.23",
   "private": true,
   "license": "BSL-1.1",
   "description": "夭夭 Web:直连 Hermes 9119 的聊天、团队、文件库与产物工作台",

+ 3 - 3
release.json

@@ -1,6 +1,6 @@
 {
   "schemaVersion": 1,
-  "releaseVersion": "0.3.22",
-  "webVersion": "0.3.22",
-  "gitTag": "v0.3.22"
+  "releaseVersion": "0.3.23",
+  "webVersion": "0.3.23",
+  "gitTag": "v0.3.23"
 }

+ 26 - 0
src/server/app.ts

@@ -46,6 +46,7 @@ import {
   ChatPushJobManager,
   HermesChatNotificationResolver,
 } from './pushEvents.js'
+import { ChatCacheCoordinator, ChatCacheStore } from './chatCache.js'
 
 export interface ApplicationOptions {
   config?: ServerConfig
@@ -62,6 +63,7 @@ export interface ApplicationOptions {
   apnsConfiguration?: APNsConfigurationManager
   fcmConfiguration?: FCMConfigurationManager
   allowedHostsConfiguration?: AllowedHostsConfigurationManager
+  chatCache?: ChatCacheCoordinator
 }
 
 export interface ApplicationRuntime {
@@ -85,6 +87,7 @@ export interface ApplicationRuntime {
   allowedHostsConfiguration: AllowedHostsConfigurationManager
   pushEventCoordinator: PushCoordinatorEventAdapter
   chatPushJobs: ChatPushJobManager
+  chatCache?: ChatCacheCoordinator
   close(): void
 }
 
@@ -134,6 +137,18 @@ export function createApplication(options: ApplicationOptions = {}): Application
     upstream,
     () => auth.upstreamCredentials(configuredUpstreamCredentials),
   )
+  let chatCache = options.chatCache
+  if (!chatCache && config.chatCacheMode !== 'upstream-only') {
+    try {
+      chatCache = new ChatCacheCoordinator(
+        new ChatCacheStore(config.home),
+        upstreamSession,
+        config.chatCacheMode ?? 'prefer-local',
+      )
+    } catch {
+      chatCache = undefined
+    }
+  }
   const accountPairings = options.accountPairings ?? new AccountLoginPairingStore()
   const profileIdentities = options.profileIdentities
     ?? new UpstreamProfileIdentityService(config, upstreamSession)
@@ -201,11 +216,19 @@ export function createApplication(options: ApplicationOptions = {}): Application
   })
   realtime.broker.protectedSession = id => workspace.ownsUpstream(id)
   realtime.broker.onNativeEvent = (owner, profile, storedId, frame) => {
+    chatCache?.observe(owner, profile, storedId, frame)
     if (!['message.complete', 'tool.complete', 'tool.completed', 'attachment.staged'].includes(frame.type)) return
     const data = frame.payload ?? {}, text = JSON.stringify(data)
     const messageId = String(data.row_id ?? data.message_id ?? data.id ?? createHash('sha256').update(text).digest('hex'))
     void workspaceAssets.archiveText(owner, text, 'local', profile, storedId, messageId, frame.type === 'attachment.staged' ? 'user' : 'agent').catch(() => {})
   }
+  realtime.broker.onNativeGlobalEvent = (owner, type) => chatCache?.observeGlobal(owner, type)
+  realtime.broker.onNativeCommand = (owner, profile, storedId, method, params) => {
+    chatCache?.command(owner, profile, storedId, method, params)
+  }
+  realtime.broker.onNativeRoute = (owner, profile, storedId, runtimeId) => {
+    chatCache?.route(owner, profile, storedId, runtimeId)
+  }
   chatPushJobs.setTransportFactory(realtime.recoveryTransport, job => realtime.ownsPushJob(job.id))
   try {
     uploads.cleanupUncommitted()
@@ -353,6 +376,7 @@ export function createApplication(options: ApplicationOptions = {}): Application
     apnsConfiguration,
     fcmConfiguration,
     allowedHostsConfiguration,
+    chatCache,
   })
   app.use(router.routes())
   app.use(router.allowedMethods({ throw: false }))
@@ -387,6 +411,7 @@ export function createApplication(options: ApplicationOptions = {}): Application
     allowedHostsConfiguration,
     pushEventCoordinator,
     chatPushJobs,
+    chatCache,
     close: () => {
       workspaceAssets.close()
       workspaceRuntime.close()
@@ -397,6 +422,7 @@ export function createApplication(options: ApplicationOptions = {}): Application
       push.close()
       uploads.close()
       workspace.close()
+      chatCache?.store.close()
     },
   }
 }

+ 451 - 0
src/server/chatCache.ts

@@ -0,0 +1,451 @@
+import { createHash } from 'node:crypto'
+import { chmodSync, createReadStream, existsSync, mkdirSync, renameSync, statSync, unlinkSync, writeFileSync } from 'node:fs'
+import { join } from 'node:path'
+import { DatabaseSync } from 'node:sqlite'
+import type { UpstreamResponse } from './upstream.js'
+import type { UpstreamServiceSession } from './localAuth.js'
+
+export type ChatCacheMode = 'upstream-only' | 'shadow' | 'prefer-local'
+export type ChatCacheState = 'current' | 'stale' | 'syncing' | 'gap'
+export type ChatCacheSource = 'local' | 'upstream'
+
+interface StoredSnapshot {
+  response: UpstreamResponse
+  state: ChatCacheState
+}
+
+interface MessagePage {
+  response: UpstreamResponse
+  state: ChatCacheState
+}
+
+function object(value: unknown): Record<string, unknown> | undefined {
+  return value && typeof value === 'object' && !Array.isArray(value)
+    ? value as Record<string, unknown> : undefined
+}
+
+function parseResponse(response: UpstreamResponse): Record<string, unknown> | undefined {
+  if (response.status < 200 || response.status >= 300) return undefined
+  try { return object(JSON.parse(response.body.toString('utf8'))) } catch { return undefined }
+}
+
+function headerRecord(headers: Headers): string {
+  return JSON.stringify(Object.fromEntries([...headers.entries()].filter(([name]) => name.toLowerCase() !== 'set-cookie')))
+}
+
+function responseFromRow(row: Record<string, unknown>): UpstreamResponse {
+  return {
+    status: Number(row.status),
+    headers: new Headers(JSON.parse(String(row.headers)) as Record<string, string>),
+    body: Buffer.from(String(row.body), 'base64'),
+  }
+}
+
+function profileOf(value: Record<string, unknown>, fallback = ''): string {
+  return String(value.profile ?? value.profile_name ?? fallback).trim()
+}
+
+function sessionIDOf(value: Record<string, unknown>): string {
+  return String(value.id ?? value.session_id ?? value.sessionId ?? '').trim()
+}
+
+function sessionSource(value: Record<string, unknown>): string {
+  return String(value.source ?? '').trim()
+}
+
+function messageID(value: Record<string, unknown>, fallback: string): string {
+  const id = String(value.id ?? value.row_id ?? value.message_id ?? value.messageId ?? '').trim()
+  return id || createHash('sha256').update(JSON.stringify(value) + fallback).digest('hex')
+}
+
+function bool(value: unknown): boolean {
+  return value === true || value === 1 || value === '1' || value === 'true'
+}
+
+const PATH_PATTERN = /(?:file:\/\/)?(\/(?:Users|private|var|tmp)\/[^\s<>'"`)\]}]+\.[A-Za-z0-9]{1,12})/g
+
+function referencedPaths(value: unknown, output = new Set<string>()): Set<string> {
+  if (typeof value === 'string') {
+    for (const match of value.matchAll(PATH_PATTERN)) output.add(match[1]!)
+  } else if (Array.isArray(value)) {
+    for (const child of value) referencedPaths(child, output)
+  } else {
+    const record = object(value)
+    if (record) for (const child of Object.values(record)) referencedPaths(child, output)
+  }
+  return output
+}
+
+export class ChatCacheStore {
+  readonly db: DatabaseSync
+  readonly assetsRoot: string
+
+  constructor(home: string) {
+    mkdirSync(home, { recursive: true, mode: 0o700 })
+    const path = join(home, 'chat-cache.sqlite3')
+    this.db = new DatabaseSync(path)
+    chmodSync(path, 0o600)
+    this.assetsRoot = join(home, 'chat-cache-assets')
+    mkdirSync(this.assetsRoot, { recursive: true, mode: 0o700 })
+    this.db.exec(`PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON; PRAGMA busy_timeout=5000;
+      CREATE TABLE IF NOT EXISTS chat_sessions(
+        owner TEXT NOT NULL, profile TEXT NOT NULL, session_id TEXT NOT NULL,
+        runtime_id TEXT, source TEXT NOT NULL, data TEXT NOT NULL,
+        sync_state TEXT NOT NULL DEFAULT 'stale', complete INTEGER NOT NULL DEFAULT 0,
+        last_event_seq INTEGER NOT NULL DEFAULT 0, last_synced_at INTEGER NOT NULL,
+        PRIMARY KEY(owner,profile,session_id));
+      CREATE TABLE IF NOT EXISTS chat_messages(
+        owner TEXT NOT NULL, profile TEXT NOT NULL, session_id TEXT NOT NULL,
+        message_id TEXT NOT NULL, position INTEGER NOT NULL, data TEXT NOT NULL,
+        content_hash TEXT NOT NULL, PRIMARY KEY(owner,profile,session_id,message_id));
+      CREATE INDEX IF NOT EXISTS chat_messages_position ON chat_messages(owner,profile,session_id,position);
+      CREATE TABLE IF NOT EXISTS chat_events(
+        owner TEXT NOT NULL, profile TEXT NOT NULL, session_id TEXT NOT NULL,
+        event_seq INTEGER NOT NULL, event_type TEXT NOT NULL, payload TEXT NOT NULL,
+        received_at INTEGER NOT NULL, PRIMARY KEY(owner,profile,session_id,event_seq,event_type));
+      CREATE TABLE IF NOT EXISTS chat_attachments(
+        owner TEXT NOT NULL, profile TEXT NOT NULL, session_id TEXT NOT NULL,
+        source_path TEXT NOT NULL, sha256 TEXT, mime_type TEXT, size INTEGER,
+        local_path TEXT, state TEXT NOT NULL DEFAULT 'pending', updated_at INTEGER NOT NULL,
+        PRIMARY KEY(owner,profile,session_id,source_path));
+      CREATE INDEX IF NOT EXISTS chat_attachments_source ON chat_attachments(owner,source_path);
+      CREATE TABLE IF NOT EXISTS chat_sync_cursors(
+        owner TEXT NOT NULL, profile TEXT NOT NULL, generation TEXT,
+        event_cursor INTEGER NOT NULL DEFAULT 0, sync_state TEXT NOT NULL DEFAULT 'stale',
+        last_synced_at INTEGER NOT NULL, PRIMARY KEY(owner,profile));
+      CREATE TABLE IF NOT EXISTS chat_snapshots(
+        owner TEXT NOT NULL, cache_key TEXT NOT NULL, kind TEXT NOT NULL,
+        profile TEXT NOT NULL, session_id TEXT, status INTEGER NOT NULL,
+        headers TEXT NOT NULL, body TEXT NOT NULL, sync_state TEXT NOT NULL,
+        saved_at INTEGER NOT NULL, PRIMARY KEY(owner,cache_key));`)
+  }
+
+  close(): void { this.db.close() }
+
+  stats(): Record<string, number> {
+    const count = (table: string) => Number((this.db.prepare(`SELECT COUNT(*) count FROM ${table}`).get() as { count: number }).count)
+    const bytes = Number((this.db.prepare(`SELECT COALESCE(SUM(size),0) bytes FROM chat_attachments WHERE state='cached'`).get() as { bytes: number }).bytes)
+    return {
+      sessions: count('chat_sessions'),
+      messages: count('chat_messages'),
+      events: count('chat_events'),
+      attachments: count('chat_attachments'),
+      attachmentBytes: bytes,
+    }
+  }
+
+  snapshot(owner: string, key: string): StoredSnapshot | undefined {
+    const row = this.db.prepare('SELECT status,headers,body,sync_state FROM chat_snapshots WHERE owner=? AND cache_key=?')
+      .get(owner, key) as Record<string, unknown> | undefined
+    return row ? { response: responseFromRow(row), state: String(row.sync_state) as ChatCacheState } : undefined
+  }
+
+  putSnapshot(owner: string, key: string, kind: string, profile: string, sessionID: string | undefined,
+    response: UpstreamResponse, knownWeb = false): boolean {
+    const payload = parseResponse(response)
+    if (!payload) return false
+    if (kind === 'list' && !knownWeb) return false
+    const isWeb = knownWeb || this.payloadIsWeb(owner, profile, sessionID, payload)
+    if (!isWeb) return false
+    const now = Date.now()
+    this.db.exec('BEGIN IMMEDIATE')
+    try {
+      this.ingestPayload(owner, profile, sessionID, payload, now)
+      this.db.prepare(`INSERT INTO chat_snapshots(owner,cache_key,kind,profile,session_id,status,headers,body,sync_state,saved_at)
+        VALUES(?,?,?,?,?,?,?,?,?,?) ON CONFLICT(owner,cache_key) DO UPDATE SET status=excluded.status,headers=excluded.headers,
+        body=excluded.body,sync_state='current',saved_at=excluded.saved_at`)
+        .run(owner, key, kind, profile, sessionID ?? null, response.status, headerRecord(response.headers),
+          response.body.toString('base64'), 'current', now)
+      this.db.exec('COMMIT')
+      return true
+    } catch (error) {
+      this.db.exec('ROLLBACK')
+      throw error
+    }
+  }
+
+  messagePage(owner: string, profile: string, sessionID: string, offset: number, limit: number): MessagePage | undefined {
+    const session = this.db.prepare(`SELECT data,sync_state,complete FROM chat_sessions
+      WHERE owner=? AND profile=? AND session_id=? AND source='web'`).get(owner, profile, sessionID) as Record<string, unknown> | undefined
+    if (!session || !bool(session.complete)) return undefined
+    const totalRow = this.db.prepare('SELECT COUNT(*) count FROM chat_messages WHERE owner=? AND profile=? AND session_id=?')
+      .get(owner, profile, sessionID) as { count: number }
+    const total = Number(totalRow.count)
+    const end = Math.max(0, total - offset)
+    const start = Math.max(0, end - limit)
+    const rows = this.db.prepare(`SELECT data FROM chat_messages WHERE owner=? AND profile=? AND session_id=?
+      AND position>=? AND position<? ORDER BY position`).all(owner, profile, sessionID, start, end) as Array<{ data: string }>
+    const messages = rows.map(row => JSON.parse(row.data))
+    const body = Buffer.from(JSON.stringify({
+      session: JSON.parse(String(session.data)), messages,
+      pagination: { total, returned: messages.length, offset, limit, hasMore: start > 0 },
+    }))
+    return {
+      response: { status: 200, headers: new Headers({ 'content-type': 'application/json; charset=utf-8' }), body },
+      state: String(session.sync_state) as ChatCacheState,
+    }
+  }
+
+  recordEvent(owner: string, profile: string, sessionID: string, frame: Record<string, unknown>): void {
+    const rawSeq = Number(frame.seq ?? frame.sequence ?? 0)
+    const type = String(frame.type ?? 'unknown')
+    const payload = JSON.stringify(frame.payload ?? {})
+    const seq = Number.isSafeInteger(rawSeq) && rawSeq > 0
+      ? rawSeq
+      : Number.parseInt(createHash('sha256').update(`${type}:${payload}`).digest('hex').slice(0, 12), 16)
+    const now = Date.now()
+    const previous = this.db.prepare(`SELECT last_event_seq FROM chat_sessions
+      WHERE owner=? AND profile=? AND session_id=?`).get(owner, profile, sessionID) as { last_event_seq?: number } | undefined
+    const lastSeen = Number(previous?.last_event_seq ?? 0)
+    this.db.prepare(`INSERT OR IGNORE INTO chat_events(owner,profile,session_id,event_seq,event_type,payload,received_at)
+      VALUES(?,?,?,?,?,?,?)`).run(owner, profile, sessionID, Number.isSafeInteger(seq) ? seq : 0, type, payload, now)
+    const state: ChatCacheState = type === 'gateway.reset' || type.includes('reset')
+      || (rawSeq > 0 && lastSeen > 0 && rawSeq > lastSeen + 1) ? 'gap' : 'stale'
+    this.db.prepare(`UPDATE chat_sessions SET sync_state=?,last_event_seq=MAX(last_event_seq,?),last_synced_at=?
+      WHERE owner=? AND profile=? AND session_id=?`).run(state, rawSeq > 0 ? rawSeq : lastSeen, now, owner, profile, sessionID)
+    this.db.prepare(`UPDATE chat_snapshots SET sync_state=? WHERE owner=? AND (session_id=? OR kind='list')`)
+      .run(state, owner, sessionID)
+    this.db.prepare(`INSERT INTO chat_sync_cursors(owner,profile,event_cursor,sync_state,last_synced_at)
+      VALUES(?,?,?,?,?) ON CONFLICT(owner,profile) DO UPDATE SET event_cursor=MAX(event_cursor,excluded.event_cursor),
+      sync_state=excluded.sync_state,last_synced_at=excluded.last_synced_at`)
+      .run(owner, profile, rawSeq > 0 ? rawSeq : lastSeen, state, now)
+  }
+
+  recordRoute(owner: string, profile: string, sessionID: string, runtimeID: string): void {
+    const now = Date.now()
+    const summary = { id: sessionID, profile, source: 'web', title: '新对话', started_at: now, last_active: now }
+    this.db.prepare(`INSERT INTO chat_sessions(owner,profile,session_id,runtime_id,source,data,sync_state,complete,last_synced_at)
+      VALUES(?,?,?,?,?,?,?,?,?) ON CONFLICT(owner,profile,session_id) DO UPDATE SET runtime_id=excluded.runtime_id,
+      source='web',last_synced_at=excluded.last_synced_at`)
+      .run(owner, profile, sessionID, runtimeID, 'web', JSON.stringify(summary), 'stale', 0, now)
+  }
+
+  recordCommand(owner: string, profile: string, sessionID: string, method: string,
+    params: Record<string, unknown>): void {
+    const now = Date.now()
+    const summary = { id: sessionID, profile, source: 'web', title: '新对话', started_at: now, last_active: now }
+    this.db.prepare(`INSERT INTO chat_sessions(owner,profile,session_id,source,data,sync_state,complete,last_synced_at)
+      VALUES(?,?,?,?,?,?,?,?) ON CONFLICT(owner,profile,session_id) DO UPDATE SET sync_state='stale',complete=0`)
+      .run(owner, profile, sessionID, 'web', JSON.stringify(summary), 'stale', 0, now)
+    const payload = JSON.stringify(params)
+    const seq = Number.parseInt(createHash('sha256').update(`${method}:${payload}`).digest('hex').slice(0, 12), 16)
+    this.db.prepare(`INSERT OR IGNORE INTO chat_events(owner,profile,session_id,event_seq,event_type,payload,received_at)
+      VALUES(?,?,?,?,?,?,?)`).run(owner, profile, sessionID, seq, `command:${method}`, payload, now)
+    this.db.prepare("UPDATE chat_snapshots SET sync_state='stale' WHERE owner=? AND (session_id=? OR kind='list')")
+      .run(owner, sessionID)
+  }
+
+  markListsStale(owner: string): void {
+    this.db.prepare("UPDATE chat_snapshots SET sync_state='stale' WHERE owner=? AND kind='list'").run(owner)
+  }
+
+  deleteSession(owner: string, profile: string, sessionID: string): void {
+    const assets = this.db.prepare(`SELECT DISTINCT local_path FROM chat_attachments
+      WHERE owner=? AND profile=? AND session_id=? AND local_path IS NOT NULL`)
+      .all(owner, profile, sessionID) as Array<{ local_path: string }>
+    this.db.exec('BEGIN IMMEDIATE')
+    try {
+      for (const table of ['chat_messages', 'chat_events', 'chat_attachments', 'chat_sessions']) {
+        this.db.prepare(`DELETE FROM ${table} WHERE owner=? AND profile=? AND session_id=?`).run(owner, profile, sessionID)
+      }
+      this.db.prepare("DELETE FROM chat_snapshots WHERE owner=? AND (session_id=? OR kind='list')").run(owner, sessionID)
+      this.db.exec('COMMIT')
+      for (const asset of assets) {
+        const referenced = this.db.prepare('SELECT 1 ok FROM chat_attachments WHERE local_path=? LIMIT 1').get(asset.local_path)
+        if (!referenced) try { unlinkSync(asset.local_path) } catch { /* Already absent. */ }
+      }
+    } catch (error) { this.db.exec('ROLLBACK'); throw error }
+  }
+
+  attachment(owner: string, sourcePath: string): { localPath: string; mimeType?: string } | undefined {
+    const row = this.db.prepare(`SELECT local_path,mime_type FROM chat_attachments
+      WHERE owner=? AND source_path=? AND state='cached' ORDER BY updated_at DESC LIMIT 1`).get(owner, sourcePath) as Record<string, unknown> | undefined
+    const localPath = row?.local_path ? String(row.local_path) : ''
+    return localPath && existsSync(localPath) ? { localPath, ...(row?.mime_type ? { mimeType: String(row.mime_type) } : {}) } : undefined
+  }
+
+  knowsAttachment(owner: string, sourcePath: string): boolean {
+    return Boolean(this.db.prepare('SELECT 1 ok FROM chat_attachments WHERE owner=? AND source_path=? LIMIT 1').get(owner, sourcePath))
+  }
+
+  isWebSession(owner: string, profile: string, sessionID: string): boolean {
+    return Boolean(this.db.prepare("SELECT 1 ok FROM chat_sessions WHERE owner=? AND profile=? AND session_id=? AND source='web'")
+      .get(owner, profile, sessionID))
+  }
+
+  storeAttachment(owner: string, sourcePath: string, response: UpstreamResponse): string | undefined {
+    if (response.status !== 200 || !response.body.length) return undefined
+    const sha256 = createHash('sha256').update(response.body).digest('hex')
+    const directory = join(this.assetsRoot, sha256.slice(0, 2))
+    mkdirSync(directory, { recursive: true, mode: 0o700 })
+    const target = join(directory, sha256)
+    if (!existsSync(target)) {
+      const temporary = `${target}.${process.pid}.tmp`
+      writeFileSync(temporary, response.body, { mode: 0o600 })
+      renameSync(temporary, target)
+    }
+    const mimeType = response.headers.get('content-type')?.split(';', 1)[0]
+    this.db.prepare(`UPDATE chat_attachments SET sha256=?,mime_type=?,size=?,local_path=?,state='cached',updated_at=?
+      WHERE owner=? AND source_path=?`).run(sha256, mimeType ?? null, response.body.length, target, Date.now(), owner, sourcePath)
+    return target
+  }
+
+  assetStream(path: string): { stream: ReturnType<typeof createReadStream>; size: number } {
+    return { stream: createReadStream(path), size: statSync(path).size }
+  }
+
+  private payloadIsWeb(owner: string, profile: string, sessionID: string | undefined, payload: Record<string, unknown>): boolean {
+    const session = object(payload.session) ?? (sessionID ? payload : undefined)
+    if (session && sessionSource(session) === 'web') return true
+    const sessions = Array.isArray(payload.items) ? payload.items : Array.isArray(payload.sessions) ? payload.sessions : []
+    if (sessions.some(value => object(value) && sessionSource(object(value)!) === 'web')) return true
+    if (!sessionID) return false
+    return Boolean(this.db.prepare("SELECT 1 ok FROM chat_sessions WHERE owner=? AND profile=? AND session_id=? AND source='web'")
+      .get(owner, profile, sessionID))
+  }
+
+  private ingestPayload(owner: string, fallbackProfile: string, sessionID: string | undefined,
+    payload: Record<string, unknown>, now: number): void {
+    const candidates: Record<string, unknown>[] = []
+    const direct = object(payload.session)
+    if (direct) candidates.push(direct)
+    const listed = Array.isArray(payload.items) ? payload.items : Array.isArray(payload.sessions) ? payload.sessions : []
+    for (const value of listed) if (object(value)) candidates.push(object(value)!)
+    if (sessionID && !direct && sessionSource(payload)) candidates.push(payload)
+    for (const session of candidates) {
+      if (sessionSource(session) !== 'web') continue
+      const id = sessionIDOf(session)
+      if (!id) continue
+      const profile = profileOf(session, fallbackProfile) || fallbackProfile || 'default'
+      this.db.prepare(`INSERT INTO chat_sessions(owner,profile,session_id,runtime_id,source,data,sync_state,complete,last_synced_at)
+        VALUES(?,?,?,?,?,?,?,?,?) ON CONFLICT(owner,profile,session_id) DO UPDATE SET data=excluded.data,source='web',
+        sync_state='current',last_synced_at=excluded.last_synced_at`)
+        .run(owner, profile, id, null, 'web', JSON.stringify(session), 'current', 0, now)
+    }
+    const messages = Array.isArray(payload.messages) ? payload.messages.flatMap(value => object(value) ? [object(value)!] : []) : []
+    if (!messages.length || !sessionID) return
+    const profile = fallbackProfile || profileOf(direct ?? {}, 'default') || 'default'
+    const pagination = object(payload.pagination) ?? {}
+    const total = Math.max(messages.length, Number(pagination.total ?? payload.total ?? messages.length) || messages.length)
+    const offset = Math.max(0, Number(pagination.offset ?? payload.offset ?? 0) || 0)
+    const start = Math.max(0, total - offset - messages.length)
+    messages.forEach((message, index) => {
+      const data = JSON.stringify(message)
+      this.db.prepare(`INSERT INTO chat_messages(owner,profile,session_id,message_id,position,data,content_hash)
+        VALUES(?,?,?,?,?,?,?) ON CONFLICT(owner,profile,session_id,message_id) DO UPDATE SET position=excluded.position,
+        data=excluded.data,content_hash=excluded.content_hash`)
+        .run(owner, profile, sessionID, messageID(message, String(start + index)), start + index, data,
+          createHash('sha256').update(data).digest('hex'))
+      for (const sourcePath of referencedPaths(message)) {
+        this.db.prepare(`INSERT INTO chat_attachments(owner,profile,session_id,source_path,state,updated_at)
+          VALUES(?,?,?,?,?,?) ON CONFLICT(owner,profile,session_id,source_path) DO UPDATE SET updated_at=excluded.updated_at`)
+          .run(owner, profile, sessionID, sourcePath, 'pending', now)
+      }
+    })
+    const hasMore = bool(pagination.hasMore ?? pagination.has_more ?? payload.hasMore ?? payload.has_more)
+    const complete = !hasMore && offset + messages.length >= total
+    this.db.prepare(`UPDATE chat_sessions SET complete=MAX(complete,?),sync_state='current',last_synced_at=?
+      WHERE owner=? AND profile=? AND session_id=?`).run(complete ? 1 : 0, now, owner, profile, sessionID)
+  }
+}
+
+export class ChatCacheCoordinator {
+  private reconciling = new Map<string, Promise<void>>()
+  constructor(readonly store: ChatCacheStore, readonly upstream: UpstreamServiceSession,
+    readonly mode: ChatCacheMode = 'prefer-local') {}
+
+  async read(owner: string, key: string, kind: string, profile: string, sessionID: string | undefined,
+    load: () => Promise<UpstreamResponse>, knownWeb = false): Promise<{ response: UpstreamResponse; source: ChatCacheSource; state: ChatCacheState }> {
+    if (this.mode === 'upstream-only') return { response: await load(), source: 'upstream', state: 'current' }
+    const local = this.store.snapshot(owner, key)
+    if (this.mode === 'prefer-local' && local?.state === 'current') {
+      return { response: local.response, source: 'local', state: local.state }
+    }
+    try {
+      const response = await load()
+      if (this.mode === 'prefer-local' && local && response.status >= 500) {
+        return { response: local.response, source: 'local', state: 'stale' }
+      }
+      this.store.putSnapshot(owner, key, kind, profile, sessionID, response, knownWeb)
+      return { response, source: 'upstream', state: 'current' }
+    } catch (error) {
+      if (this.mode === 'prefer-local' && local) return { response: local.response, source: 'local', state: 'stale' }
+      throw error
+    }
+  }
+
+  async messages(owner: string, key: string, profile: string, sessionID: string, offset: number, limit: number,
+    load: () => Promise<UpstreamResponse>): Promise<{ response: UpstreamResponse; source: ChatCacheSource; state: ChatCacheState }> {
+    if (this.mode === 'prefer-local') {
+      const page = this.store.messagePage(owner, profile, sessionID, offset, limit)
+      if (page?.state === 'current') return { response: page.response, source: 'local', state: page.state }
+    }
+    const result = await this.read(owner, key, 'messages', profile, sessionID, load)
+    if (result.source === 'upstream'
+      && this.store.isWebSession(owner, profile, sessionID)
+      && !this.store.messagePage(owner, profile, sessionID, 0, 1)) {
+      void this.reconcile(owner, profile, sessionID)
+    }
+    return result
+  }
+
+  observe(owner: string, profile: string, sessionID: string, frame: Record<string, unknown>): void {
+    if (this.mode === 'upstream-only') return
+    this.store.recordEvent(owner, profile, sessionID, frame)
+    if (String(frame.type) === 'message.complete') void this.reconcile(owner, profile, sessionID)
+  }
+
+  observeGlobal(owner: string, type: string): void {
+    if (this.mode !== 'upstream-only' && type === 'sessions.changed') this.store.markListsStale(owner)
+  }
+
+  command(owner: string, profile: string, sessionID: string, method: string,
+    params: Record<string, unknown>): void {
+    if (this.mode !== 'upstream-only') this.store.recordCommand(owner, profile, sessionID, method, params)
+  }
+
+  route(owner: string, profile: string, sessionID: string, runtimeID: string): void {
+    if (this.mode !== 'upstream-only') this.store.recordRoute(owner, profile, sessionID, runtimeID)
+  }
+
+  async reconcile(owner: string, profile: string, sessionID: string): Promise<void> {
+    if (this.mode === 'upstream-only') return
+    const key = JSON.stringify([owner, profile, sessionID])
+    if (this.reconciling.has(key)) return this.reconciling.get(key)
+    const task = (async () => {
+      const detailPath = `/api/sessions/${encodeURIComponent(sessionID)}`
+      const detailSearch = new URLSearchParams({ profile })
+      const detail = await this.upstream.request(detailPath, { search: detailSearch })
+      this.store.putSnapshot(owner, `detail:${profile}:${sessionID}`, 'detail', profile, sessionID, detail)
+      let offset = 0
+      for (let page = 0; page < 200; page++) {
+        const search = new URLSearchParams({ profile, offset: String(offset), limit: '500', order: 'latest', include_compacted: 'true' })
+        const response = await this.upstream.request(`${detailPath}/messages`, { search })
+        if (!this.store.putSnapshot(owner, `messages:${profile}:${sessionID}:${offset}:500`, 'messages', profile, sessionID, response)) return
+        const payload = parseResponse(response)
+        const messages = Array.isArray(payload?.messages) ? payload.messages : []
+        const pagination = object(payload?.pagination) ?? {}
+        if (!bool(pagination.hasMore ?? pagination.has_more ?? payload?.hasMore ?? payload?.has_more) || !messages.length) return
+        offset += messages.length
+      }
+    })().catch(() => {}).finally(() => this.reconciling.delete(key))
+    this.reconciling.set(key, task)
+    return task
+  }
+
+  async cacheAttachment(owner: string, sourcePath: string): Promise<void> {
+    if (this.mode === 'upstream-only' || !this.store.knowsAttachment(owner, sourcePath) || this.store.attachment(owner, sourcePath)) return
+    try {
+      const response = await this.upstream.request('/api/files/download', {
+        search: new URLSearchParams({ path: sourcePath }), maxResponseBytes: 100 * 1_024 * 1_024,
+      })
+      this.store.storeAttachment(owner, sourcePath, response)
+    } catch { /* The next authorized request can retry. */ }
+  }
+}
+
+export function chatCacheKey(kind: string, profile: string, sessionID: string | undefined, search: URLSearchParams): string {
+  const normalized = new URLSearchParams(search)
+  normalized.sort()
+  return [kind, profile, sessionID ?? '', normalized.toString()].join(':')
+}

+ 12 - 0
src/server/config.ts

@@ -9,6 +9,7 @@ import {
   loadAllowedHostsConfiguration,
   type AllowedHostsConfigurationSnapshot,
 } from './allowedHostsConfiguration.js'
+import type { ChatCacheMode } from './chatCache.js'
 export { DEFAULT_APNS_TOPIC } from './apnsConfiguration.js'
 
 export interface APNsProviderConfig {
@@ -53,6 +54,7 @@ export interface ServerConfig {
   fcmConfigurationError?: string
   fcmSettings?: FCMConfigurationSnapshot
   allowedHostsSettings?: AllowedHostsConfigurationSnapshot
+  chatCacheMode?: ChatCacheMode
 }
 
 export const DEFAULT_YAOYAO_RELEASE_SOURCE = 'https://git.samien.cn/samien/hermes-yaoyao.git'
@@ -69,6 +71,14 @@ function parsePort(value: string | undefined): number {
   return port
 }
 
+function parseChatCacheMode(value: string | undefined): ChatCacheMode {
+  const mode = value?.trim() || 'prefer-local'
+  if (!['upstream-only', 'shadow', 'prefer-local'].includes(mode)) {
+    throw new Error('HERMES_YAOYAO_CHAT_CACHE_MODE must be upstream-only, shadow, or prefer-local')
+  }
+  return mode as ChatCacheMode
+}
+
 function parseUpstream(value: string | undefined): URL {
   const upstream = new URL(value ?? 'http://127.0.0.1:9119')
   if (!['http:', 'https:'].includes(upstream.protocol)) {
@@ -152,6 +162,7 @@ export function loadServerConfig(
   const allowInsecureLan = flag(env.HERMES_YAOYAO_ALLOW_INSECURE_LAN)
   const production = env.NODE_ENV === 'production'
   const superviseDashboard = flag(env.HERMES_YAOYAO_SUPERVISE_DASHBOARD)
+  const chatCacheMode = parseChatCacheMode(env.HERMES_YAOYAO_CHAT_CACHE_MODE)
   const releaseSource = parseReleaseSource(env.HERMES_YAOYAO_RELEASE_SOURCE)
   const releaseRoot = resolve(env.HERMES_YAOYAO_RELEASE_ROOT?.trim() || `${homedir()}/.local/share/hermes-yaoyao`)
   const allowRemoteUpdate = flag(env.HERMES_YAOYAO_ALLOW_REMOTE_UPDATE)
@@ -189,6 +200,7 @@ export function loadServerConfig(
     insecureLan,
     production,
     superviseDashboard,
+    chatCacheMode,
     releaseSource,
     releaseRoot,
     allowRemoteUpdate,

+ 34 - 1
src/server/realtimeBroker.ts

@@ -42,6 +42,9 @@ interface Upstream {
 export class RealtimeBroker {
   protectedSession: (id: string) => boolean = () => false
   onNativeEvent: (owner: string, profile: string, storedId: string, frame: Frame) => void = () => {}
+  onNativeGlobalEvent: (owner: string, type: string) => void = () => {}
+  onNativeCommand: (owner: string, profile: string, storedId: string, method: string, params: Frame) => void = () => {}
+  onNativeRoute: (owner: string, profile: string, storedId: string, runtimeId: string) => void = () => {}
   readonly epoch = randomUUID()
   readonly channels = new Map<string, RealtimeChannel>()
   private upstreams = new Map<string, Upstream>()
@@ -207,6 +210,10 @@ export class RealtimeBroker {
       if (!c.principal.valid()) throw new HttpError(401, 'Authentication expired', 'authentication_required')
       const previouslyActive = route?.active ?? false
       if (route && (method === 'prompt.submit' || method === 'session.steer')) route.active = true
+      const nativeOwner = this.nativeOwner(c.principal)
+      if (nativeOwner && route && ['prompt.submit', 'session.steer'].includes(method)) {
+        this.onNativeCommand(nativeOwner, route.profile, route.stored, method, p)
+      }
       const activity: RealtimeActivity = { kind: 'command', name: method, sessionId: route?.stored }
       this.onActivity(activity)
       const response = await this.rpc(u, method, p, f => c.principal.observeEvent?.(JSON.stringify(f)), f => c.principal.observeCommand?.(JSON.stringify(f)))
@@ -228,6 +235,10 @@ export class RealtimeBroker {
         r.active = result.running === true || info.running === true
         u.routes.set(key, r); u.byRuntime.set(runtime, r); c.routes.add(key)
         r.observers.set(c.principal.key, c.principal)
+        if (nativeOwner) this.onNativeRoute(nativeOwner, profile, stored, runtime)
+        if (nativeOwner && method === 'session.create') {
+          this.onNativeCommand(nativeOwner, profile, stored, method, p)
+        }
         for (const pending of [result.pending_approval, result.pending_clarify, result.inflight?.pending_approval, result.inflight?.pending_clarify]) {
           if (pending?.request_id) this.interactions.set(`${u.principal.upstreamKey}:${pending.request_id}`, runtime)
         }
@@ -357,9 +368,17 @@ export class RealtimeBroker {
     if (r) for (const principal of r.observers.values()) {
       if (principal.valid()) {
         principal.observeEvent?.(JSON.stringify(f))
-        if (principal.key.startsWith('user:')) this.onNativeEvent(principal.key.split(':')[1]!, r.profile, r.stored, p)
+        const owner = this.nativeOwner(principal)
+        if (owner) this.onNativeEvent(owner, r.profile, r.stored, p)
       }
     }
+    if (!r && p.type === 'sessions.changed') {
+      const owners = new Set([...this.channels.values()].flatMap(channel => {
+        const owner = this.nativeOwner(channel.principal)
+        return owner && channel.principal.valid() ? [owner] : []
+      }))
+      for (const owner of owners) this.onNativeGlobalEvent(owner, p.type)
+    }
     for (const c of this.channels.values()) {
       if (c.kind !== 'chat' || c.principal.upstreamKey !== u.principal.upstreamKey || !c.principal.valid()) continue
       if (r && !c.routes.has(this.routeKey(r.profile, r.stored))) continue
@@ -367,6 +386,10 @@ export class RealtimeBroker {
       this.emit(c, 'frame', f)
     }
   }
+  private nativeOwner(principal: RealtimePrincipal): string | undefined {
+    const match = /^(?:user|push):([^:]+)/.exec(principal.key)
+    return match?.[1]
+  }
   private async recover(u: Upstream, changed: boolean): Promise<void> {
     if (u.recovering) return
     u.recovering = true
@@ -392,6 +415,16 @@ export class RealtimeBroker {
   }
   private reset(u: Upstream, reason: string): void {
     this.onActivity({ kind: 'reset', name: reason })
+    for (const route of u.routes.values()) {
+      for (const principal of route.observers.values()) {
+        const owner = this.nativeOwner(principal)
+        if (owner && principal.valid()) {
+          this.onNativeEvent(owner, route.profile, route.stored, {
+            type: 'gateway.reset', payload: { reason }, seq: route.seq,
+          })
+        }
+      }
+    }
     for (const c of this.channels.values()) if (c.kind === 'chat' && c.principal.upstreamKey === u.principal.upstreamKey) this.emit(c, 'reset', { reason })
   }
   private emit(c: RealtimeChannel, event: StreamEntry['event'], value: Frame): void {

+ 171 - 10
src/server/routes.ts

@@ -46,6 +46,7 @@ import {
   canonicalAllowedHosts,
   normalizeAllowedHost,
 } from './allowedHostsConfiguration.js'
+import { chatCacheKey, type ChatCacheCoordinator } from './chatCache.js'
 
 type JsonObject = Record<string, unknown>
 
@@ -65,6 +66,7 @@ export interface RouteDependencies {
   apnsConfiguration: APNsConfigurationManager
   fcmConfiguration: FCMConfigurationManager
   allowedHostsConfiguration: AllowedHostsConfigurationManager
+  chatCache?: ChatCacheCoordinator
 }
 
 function body(ctx: Koa.Context): JsonObject {
@@ -634,6 +636,54 @@ async function proxyOptionalUnread(
   })
 }
 
+async function cachedChatRead(
+  ctx: Koa.Context,
+  dependencies: RouteDependencies,
+  options: {
+    key: string
+    kind: 'list' | 'detail' | 'messages'
+    profile: string
+    sessionID?: string
+    offset?: number
+    limit?: number
+    knownWeb?: boolean
+    load: (jar: CookieJar) => Promise<UpstreamResponse>
+  },
+): Promise<void> {
+  const user = dependencies.auth.require(ctx)
+  const load = async () => {
+    await dependencies.upstreamSession.ensure()
+    return options.load(dependencies.upstreamSession.jar)
+  }
+  if (!dependencies.chatCache) {
+    sendUpstreamResponse(ctx, await load(), dependencies.upstreamSession.jar)
+    return
+  }
+  if (ctx.get('x-yaoyao-cache').toLowerCase() === 'bypass') {
+    const response = await load()
+    dependencies.chatCache.store.putSnapshot(
+      user.id, options.key, options.kind, options.profile,
+      options.sessionID, response, options.knownWeb,
+    )
+    ctx.set('X-Yaoyao-Data-Source', 'upstream')
+    ctx.set('X-Yaoyao-Sync-State', 'current')
+    sendUpstreamResponse(ctx, response, dependencies.upstreamSession.jar)
+    return
+  }
+  const result = options.kind === 'messages' && options.sessionID
+    ? await dependencies.chatCache.messages(
+      user.id, options.key, options.profile, options.sessionID,
+      options.offset ?? 0, options.limit ?? 100, load,
+    )
+    : await dependencies.chatCache.read(
+      user.id, options.key, options.kind, options.profile,
+      options.sessionID, load, options.knownWeb,
+    )
+  ctx.set('X-Yaoyao-Data-Source', result.source)
+  ctx.set('X-Yaoyao-Sync-State', result.state)
+  sendUpstreamResponse(ctx, result.response, dependencies.upstreamSession.jar)
+}
+
 async function bootstrap(
   ctx: Koa.Context,
   dependencies: RouteDependencies,
@@ -850,10 +900,10 @@ function localMediaPath(root: string, relativePath: string): string {
   return resolvedFile
 }
 
-function sendLocalMedia(ctx: Koa.Context, path: string): void {
+function sendLocalMedia(ctx: Koa.Context, path: string, contentType?: string, displayName?: string): void {
   const size = statSync(path).size
-  const type = mimeLookup(path) || 'application/octet-stream'
-  const fileName = basename(path)
+  const type = contentType || mimeLookup(path) || 'application/octet-stream'
+  const fileName = displayName || basename(path)
   const range = ctx.get('range').match(/^bytes=(\d*)-(\d*)$/)
   ctx.set('Accept-Ranges', 'bytes')
   ctx.set('Cache-Control', 'private, no-store')
@@ -911,8 +961,14 @@ function hermesMediaPath(ctx: Koa.Context): string {
 }
 
 async function proxyHermesMedia(ctx: Koa.Context, dependencies: RouteDependencies): Promise<void> {
-  dependencies.auth.require(ctx)
+  const user = dependencies.auth.require(ctx)
   const path = hermesMediaPath(ctx)
+  const cached = dependencies.chatCache?.store.attachment(user.id, path)
+  if (cached) {
+    ctx.set('X-Yaoyao-Data-Source', 'local')
+    sendLocalMedia(ctx, cached.localPath, cached.mimeType, basename(path))
+    return
+  }
   // Generated media belongs to Hermes, which may run on a different machine
   // or inside a container. Let its file API enforce the managed-root policy.
   const response = await dependencies.upstreamSession.request('/api/files/download', {
@@ -920,6 +976,12 @@ async function proxyHermesMedia(ctx: Koa.Context, dependencies: RouteDependencie
     headers: ctx.get('range') ? { range: ctx.get('range') } : undefined,
     maxResponseBytes: 100 * 1_024 * 1_024,
   })
+  ctx.set('X-Yaoyao-Data-Source', 'upstream')
+  if (!ctx.get('range') && dependencies.chatCache?.store.knowsAttachment(user.id, path)) {
+    dependencies.chatCache.store.storeAttachment(user.id, path, response)
+  } else if (ctx.get('range')) {
+    void dependencies.chatCache?.cacheAttachment(user.id, path)
+  }
   sendUpstreamResponse(ctx, response, dependencies.upstreamSession.jar)
   ctx.set('Cache-Control', 'private, no-store')
   if (response.status >= 200 && response.status < 300) prepareFilePreview(ctx, basename(path))
@@ -950,7 +1012,12 @@ export function createApiRouter(dependencies: RouteDependencies): Router {
 
   router.get('/healthz', (ctx) => {
     ctx.set('Cache-Control', 'no-store')
-    json(ctx, 200, { ok: true })
+    json(ctx, 200, {
+      ok: true,
+      chatCache: dependencies.chatCache
+        ? { available: true, mode: dependencies.chatCache.mode, ...dependencies.chatCache.store.stats() }
+        : { available: false, mode: dependencies.config.chatCacheMode ?? 'upstream-only' },
+    })
   })
 
   // Hermes Gateway-compatible authentication surface for native clients.
@@ -1817,7 +1884,16 @@ export function createApiRouter(dependencies: RouteDependencies): Router {
     if (view === 'chat') search.set('source', 'web')
     else search.set('exclude_sources', view === 'history' ? 'cron,ios_group,yaoyao_workspace,web' : 'cron,ios_group,yaoyao_workspace')
     const path = search.get('profile') ? '/api/sessions' : '/api/profiles/sessions'
-    await proxy(ctx, dependencies.upstream, path, { search })
+    if (view !== 'chat') {
+      await proxy(ctx, dependencies.upstream, path, { search })
+      return
+    }
+    const profile = search.get('profile') || ''
+    await cachedChatRead(ctx, dependencies, {
+      key: chatCacheKey('list', profile, undefined, search),
+      kind: 'list', profile, knownWeb: true,
+      load: jar => dependencies.upstream.request(path, jar, { search }),
+    })
   })
   router.get('/api/app/sessions/:sessionID/messages', async (ctx) => {
     const id = safeIdentifier(ctx.params.sessionID, 'session ID')
@@ -1828,14 +1904,21 @@ export function createApiRouter(dependencies: RouteDependencies): Router {
     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,
+    const profile = incoming.get('profile') || 'default'
+    await cachedChatRead(ctx, dependencies, {
+      key: chatCacheKey('messages', profile, id, incoming),
+      kind: 'messages', profile, sessionID: id, offset, limit,
+      load: jar => dependencies.upstream.request(`/api/sessions/${encodeURIComponent(id)}/messages`, jar, { 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']),
+    const search = searchFrom(ctx, ['profile'])
+    const profile = search.get('profile') || 'default'
+    await cachedChatRead(ctx, dependencies, {
+      key: chatCacheKey('detail', profile, id, search),
+      kind: 'detail', profile, sessionID: id,
+      load: jar => dependencies.upstream.request(`/api/sessions/${encodeURIComponent(id)}`, jar, { search }),
     })
   })
   router.patch('/api/app/sessions/:sessionID', async (ctx) => {
@@ -1847,6 +1930,11 @@ export function createApiRouter(dependencies: RouteDependencies): Router {
       const profile = search.get('profile')
       if (profile) request.profile = profile
       const response = await dependencies.upstream.request(`/api/sessions/${encodeURIComponent(id)}`, jar, { method: 'PATCH', search, body: request })
+      if (response.status >= 200 && response.status < 300) {
+        const user = dependencies.auth.require(ctx)
+        dependencies.chatCache?.store.markListsStale(user.id)
+        void dependencies.chatCache?.reconcile(user.id, search.get('profile') || 'default', id)
+      }
       sendUpstreamResponse(ctx, response, jar)
     })
   })
@@ -1856,10 +1944,83 @@ export function createApiRouter(dependencies: RouteDependencies): Router {
     await withJar(ctx, async jar => {
       requireWritableWebSession(await dependencies.upstream.request(`/api/sessions/${encodeURIComponent(id)}`, jar, { search }))
       const response = await dependencies.upstream.request(`/api/sessions/${encodeURIComponent(id)}`, jar, { method: 'DELETE', search })
+      if (response.status >= 200 && response.status < 300) {
+        dependencies.chatCache?.store.deleteSession(
+          dependencies.auth.require(ctx).id,
+          search.get('profile') || 'default',
+          id,
+        )
+      }
       sendUpstreamResponse(ctx, response, jar)
     })
   })
 
+  for (const listPath of ['/api/sessions', '/api/profiles/sessions']) {
+    router.get(listPath, async ctx => {
+      const search = new URLSearchParams(ctx.querystring)
+      const profile = search.get('profile') || ''
+      const knownWeb = search.get('source') === 'web'
+      if (!knownWeb) {
+        await withJar(ctx, async jar => sendUpstreamResponse(
+          ctx,
+          await dependencies.upstream.request(listPath, jar, { search }),
+          jar,
+        ))
+        return
+      }
+      await cachedChatRead(ctx, dependencies, {
+        key: chatCacheKey('list', profile, undefined, search),
+        kind: 'list', profile, knownWeb: true,
+        load: jar => dependencies.upstream.request(listPath, jar, { search }),
+      })
+    })
+  }
+  router.get('/api/sessions/:sessionID/messages', async ctx => {
+    const id = safeIdentifier(ctx.params.sessionID, 'session ID')
+    const search = new URLSearchParams(ctx.querystring)
+    const profile = search.get('profile') || 'default'
+    const offset = Math.max(0, Number(search.get('offset') ?? 0) || 0)
+    const limit = Math.max(1, Math.min(500, Number(search.get('limit') ?? 100) || 100))
+    await cachedChatRead(ctx, dependencies, {
+      key: chatCacheKey('messages', profile, id, search),
+      kind: 'messages', profile, sessionID: id, offset, limit,
+      load: jar => dependencies.upstream.request(`/api/sessions/${encodeURIComponent(id)}/messages`, jar, { search }),
+    })
+  })
+  router.get('/api/sessions/:sessionID', async ctx => {
+    const id = safeIdentifier(ctx.params.sessionID, 'session ID')
+    const search = new URLSearchParams(ctx.querystring)
+    const profile = search.get('profile') || 'default'
+    await cachedChatRead(ctx, dependencies, {
+      key: chatCacheKey('detail', profile, id, search),
+      kind: 'detail', profile, sessionID: id,
+      load: jar => dependencies.upstream.request(`/api/sessions/${encodeURIComponent(id)}`, jar, { search }),
+    })
+  })
+  router.get('/api/files/download', async ctx => {
+    const user = dependencies.auth.require(ctx)
+    const path = typeof ctx.query.path === 'string' ? ctx.query.path : ''
+    if (!path) throw new HttpError(400, 'path is required', 'missing_path')
+    const cached = dependencies.chatCache?.store.attachment(user.id, path)
+    if (cached) {
+      ctx.set('X-Yaoyao-Data-Source', 'local')
+      sendLocalMedia(ctx, cached.localPath, cached.mimeType, basename(path))
+      return
+    }
+    const response = await dependencies.upstreamSession.request('/api/files/download', {
+      search: new URLSearchParams({ path }),
+      headers: ctx.get('range') ? { range: ctx.get('range') } : undefined,
+      maxResponseBytes: 100 * 1_024 * 1_024,
+    })
+    ctx.set('X-Yaoyao-Data-Source', 'upstream')
+    if (!ctx.get('range') && dependencies.chatCache?.store.knowsAttachment(user.id, path)) {
+      dependencies.chatCache.store.storeAttachment(user.id, path, response)
+    } else if (ctx.get('range')) {
+      void dependencies.chatCache?.cacheAttachment(user.id, path)
+    }
+    sendUpstreamResponse(ctx, response, dependencies.upstreamSession.jar)
+  })
+
   router.all('/api/*gatewayPath', async (ctx) => {
     if (ctx.path.startsWith('/api/app/') || ctx.path.startsWith('/api/pair/')) {
       throw new HttpError(404, 'Route not found', 'not_found')

+ 133 - 0
tests/server/chatCache.test.ts

@@ -0,0 +1,133 @@
+// @vitest-environment node
+import { mkdtempSync, rmSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { ChatCacheCoordinator, ChatCacheStore, chatCacheKey } from '../../src/server/chatCache.js'
+import type { UpstreamServiceSession } from '../../src/server/localAuth.js'
+import type { UpstreamResponse } from '../../src/server/upstream.js'
+
+const homes: string[] = []
+const owner = 'owner-1'
+const profile = 'default'
+const sessionID = 'session-web'
+
+function response(value: unknown, status = 200, contentType = 'application/json'): UpstreamResponse {
+  return { status, headers: new Headers({ 'content-type': contentType }), body: Buffer.from(typeof value === 'string' ? value : JSON.stringify(value)) }
+}
+
+function fixture() {
+  const home = mkdtempSync(join(tmpdir(), 'yaoyao-chat-cache-'))
+  homes.push(home)
+  const store = new ChatCacheStore(home)
+  const upstream = { request: vi.fn() } as unknown as UpstreamServiceSession
+  return { home, store, coordinator: new ChatCacheCoordinator(store, upstream, 'prefer-local') }
+}
+
+afterEach(() => {
+  for (const home of homes.splice(0)) rmSync(home, { recursive: true, force: true })
+})
+
+describe('durable source=web chat cache', () => {
+  it('serves a warm list without a second 9119 request and survives restart', async () => {
+    const f = fixture()
+    const search = new URLSearchParams({ source: 'web', profile })
+    const key = chatCacheKey('list', profile, undefined, search)
+    const load = vi.fn(async () => response({ sessions: [{ id: sessionID, profile, source: 'web', title: '本地会话' }] }))
+
+    const first = await f.coordinator.read(owner, key, 'list', profile, undefined, load, true)
+    const second = await f.coordinator.read(owner, key, 'list', profile, undefined, load, true)
+    expect(first.source).toBe('upstream')
+    expect(second.source).toBe('local')
+    expect(load).toHaveBeenCalledTimes(1)
+
+    f.store.close()
+    const restored = new ChatCacheStore(f.home)
+    const cached = restored.snapshot(owner, key)
+    expect(JSON.parse(cached!.response.body.toString()).sessions[0].title).toBe('本地会话')
+    restored.close()
+  })
+
+  it('materializes complete message pages and keeps owners isolated', () => {
+    const f = fixture()
+    f.store.putSnapshot(owner, 'detail', 'detail', profile, sessionID,
+      response({ id: sessionID, profile, source: 'web', title: '对话' }))
+    f.store.putSnapshot(owner, 'messages', 'messages', profile, sessionID, response({
+      session: { id: sessionID, profile, source: 'web', title: '对话' },
+      messages: [
+        { id: 'm1', role: 'user', content: '开始' },
+        { id: 'm2', role: 'assistant', content: '完成' },
+      ],
+      pagination: { total: 2, returned: 2, offset: 0, limit: 100, hasMore: false },
+    }))
+
+    const page = f.store.messagePage(owner, profile, sessionID, 0, 100)
+    expect(JSON.parse(page!.response.body.toString()).messages.map((item: { id: string }) => item.id)).toEqual(['m1', 'm2'])
+    expect(f.store.messagePage('another-owner', profile, sessionID, 0, 100)).toBeUndefined()
+    f.store.close()
+  })
+
+  it('never persists non-Web history', () => {
+    const f = fixture()
+    expect(f.store.putSnapshot(owner, 'history', 'detail', profile, 'history-1',
+      response({ id: 'history-1', profile, source: 'telegram', title: '历史' }))).toBe(false)
+    expect(f.store.snapshot(owner, 'history')).toBeUndefined()
+    f.store.close()
+  })
+
+  it('marks event gaps stale, deduplicates events, and falls back while 9119 is offline', async () => {
+    const f = fixture()
+    const key = 'detail-key'
+    f.store.putSnapshot(owner, key, 'detail', profile, sessionID,
+      response({ id: sessionID, profile, source: 'web', title: '稳定内容' }))
+    const frame = { type: 'message.delta', seq: 2, payload: { text: '增量' } }
+    f.store.recordEvent(owner, profile, sessionID, frame)
+    f.store.recordEvent(owner, profile, sessionID, frame)
+    const count = f.store.db.prepare('SELECT COUNT(*) count FROM chat_events').get() as { count: number }
+    expect(count.count).toBe(1)
+    f.store.recordRoute(owner, profile, sessionID, 'runtime-1')
+    const route = f.store.db.prepare('SELECT runtime_id FROM chat_sessions WHERE owner=? AND profile=? AND session_id=?')
+      .get(owner, profile, sessionID) as { runtime_id: string }
+    expect(route.runtime_id).toBe('runtime-1')
+    const fallback = await f.coordinator.read(owner, key, 'detail', profile, sessionID,
+      async () => { throw new Error('9119 offline') })
+    expect(fallback).toMatchObject({ source: 'local', state: 'stale' })
+    const serverFailure = await f.coordinator.read(owner, key, 'detail', profile, sessionID,
+      async () => response({ error: 'unavailable' }, 503))
+    expect(serverFailure).toMatchObject({ source: 'local', state: 'stale' })
+    f.store.close()
+  })
+
+  it('caches only attachment paths discovered in source=web messages', () => {
+    const f = fixture()
+    const sourcePath = '/Users/test/.hermes/profiles/default/images/result.png'
+    f.store.putSnapshot(owner, 'messages', 'messages', profile, sessionID, response({
+      session: { id: sessionID, profile, source: 'web' },
+      messages: [{ id: 'm1', role: 'assistant', content: `![结果](${sourcePath})` }],
+      pagination: { total: 1, offset: 0, hasMore: false },
+    }))
+    expect(f.store.knowsAttachment(owner, sourcePath)).toBe(true)
+    const bytes = response('image-data', 200, 'image/png')
+    expect(f.store.storeAttachment(owner, sourcePath, bytes)).toBeTruthy()
+    expect(f.store.attachment(owner, sourcePath)?.mimeType).toBe('image/png')
+    expect(f.store.knowsAttachment(owner, '/Users/test/history.png')).toBe(false)
+    f.store.close()
+  })
+
+  it('supports shadow and upstream-only rollback modes', async () => {
+    const home = mkdtempSync(join(tmpdir(), 'yaoyao-chat-cache-modes-')); homes.push(home)
+    const store = new ChatCacheStore(home)
+    const upstream = { request: vi.fn() } as unknown as UpstreamServiceSession
+    const load = vi.fn(async () => response({ sessions: [{ id: sessionID, profile, source: 'web' }] }))
+    const search = new URLSearchParams({ source: 'web' })
+    const key = chatCacheKey('list', profile, undefined, search)
+    const shadow = new ChatCacheCoordinator(store, upstream, 'shadow')
+    await shadow.read(owner, key, 'list', profile, undefined, load, true)
+    await shadow.read(owner, key, 'list', profile, undefined, load, true)
+    expect(load).toHaveBeenCalledTimes(2)
+    const upstreamOnly = new ChatCacheCoordinator(store, upstream, 'upstream-only')
+    await upstreamOnly.read(owner, key, 'list', profile, undefined, load, true)
+    expect(load).toHaveBeenCalledTimes(3)
+    store.close()
+  })
+})

+ 120 - 0
tests/server/chatCacheRoutes.test.ts

@@ -0,0 +1,120 @@
+// @vitest-environment node
+import { mkdtempSync, rmSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import request from 'supertest'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { createApplication, type ApplicationRuntime } from '../../src/server/app.js'
+import type { ServerConfig } from '../../src/server/config.js'
+
+const homes: string[] = []
+const runtimes: ApplicationRuntime[] = []
+const host = '127.0.0.1:15300'
+const origin = `http://${host}`
+
+function cookie(response: request.Response): string {
+  const values = response.headers['set-cookie'] as unknown as string[] | undefined
+  return (values ?? []).map(value => value.split(';', 1)[0]).join('; ')
+}
+
+function config(home: string): ServerConfig {
+  return {
+    host: '127.0.0.1', port: 15300, upstream: new URL('http://127.0.0.1:19119'),
+    allowedHosts: new Set(), home, mediaRoot: home, attachmentsRoot: home, imagesRoot: home,
+    mediaOwner: 'test', allowInsecureLan: false, insecureLan: false, production: false,
+    chatCacheMode: 'prefer-local',
+  }
+}
+
+afterEach(() => {
+  for (const runtime of runtimes.splice(0)) runtime.close()
+  for (const home of homes.splice(0)) rmSync(home, { recursive: true, force: true })
+})
+
+describe('source=web chat cache routes', () => {
+  it('serves warm and restarted chat reads locally while history stays upstream-only', async () => {
+    const home = mkdtempSync(join(tmpdir(), 'yaoyao-chat-cache-routes-')); homes.push(home)
+    const counts = { list: 0, detail: 0, messages: 0, history: 0, media: 0 }
+    const mediaPath = '/Users/test/.hermes/profiles/default/images/result.png'
+    const fetchImpl = vi.fn<typeof fetch>(async (input, init) => {
+      const url = new URL(String(input))
+      const headers = new Headers(init?.headers)
+      if (url.pathname === '/api/status') return Response.json({ auth_required: false })
+      if (url.pathname === '/') return new Response('<script>window.__HERMES_SESSION_TOKEN__="cache-token-1234567890123456";</script>')
+      if (headers.get('x-hermes-session-token') !== 'cache-token-1234567890123456') return Response.json({}, { status: 401 })
+      if (url.pathname === '/api/profiles') return Response.json({ profiles: [{ name: 'default', is_default: true }] })
+      if (url.pathname === '/api/sessions' && url.searchParams.get('source') === 'web') {
+        counts.list++
+        return Response.json({ sessions: [{ id: 'session-web', profile: 'default', source: 'web', title: '持久会话' }] })
+      }
+      if (url.pathname === '/api/profiles/sessions' && url.searchParams.get('exclude_sources')?.includes('web')) {
+        counts.history++
+        return Response.json({ sessions: [{ id: 'history-only', source: 'telegram', title: '只读历史' }] })
+      }
+      if (url.pathname === '/api/sessions/session-web/messages') {
+        counts.messages++
+        return Response.json({
+          session: { id: 'session-web', profile: 'default', source: 'web', title: '持久会话' },
+          messages: [{ id: 'message-1', role: 'assistant', content: `持久消息\n![结果](${mediaPath})` }],
+          pagination: { total: 1, returned: 1, offset: 0, limit: 100, hasMore: false },
+        })
+      }
+      if (url.pathname === '/api/files/download') {
+        counts.media++
+        expect(url.searchParams.get('path')).toBe(mediaPath)
+        return new Response('png-bytes', { headers: { 'content-type': 'image/png' } })
+      }
+      if (url.pathname === '/api/sessions/session-web') {
+        counts.detail++
+        return Response.json({ id: 'session-web', profile: 'default', source: 'web', title: '持久会话' })
+      }
+      return Response.json({ error: 'missing fixture' }, { status: 404 })
+    })
+    const first = createApplication({ config: config(home), fetchImpl }); runtimes.push(first)
+    const agent = request.agent(first.app.callback())
+    const boot = await agent.get('/api/app/bootstrap').set('Host', host).expect(200)
+    const setup = await agent.post('/api/app/setup').set('Host', host).set('Origin', origin)
+      .set('X-CSRF-Token', boot.body.csrfToken).send({ username: 'owner', password: 'fixture-password' }).expect(200)
+    const sessionCookie = cookie(setup)
+
+    const listPath = '/api/app/sessions?view=chat&profile=default&limit=100'
+    await agent.get(listPath).set('Host', host).expect('X-Yaoyao-Data-Source', 'upstream').expect(200)
+    await agent.get(listPath).set('Host', host).expect('X-Yaoyao-Data-Source', 'local').expect(200)
+    await agent.get(listPath).set('Host', host).set('X-Yaoyao-Cache', 'bypass')
+      .expect('X-Yaoyao-Data-Source', 'upstream').expect(200)
+    const messagesPath = '/api/app/sessions/session-web/messages?profile=default&offset=0&limit=100'
+    await agent.get(messagesPath).set('Host', host).expect('X-Yaoyao-Data-Source', 'upstream').expect(200)
+    const cachedMessages = await agent.get(messagesPath).set('Host', host)
+      .expect('X-Yaoyao-Data-Source', 'local').expect(200)
+    expect(cachedMessages.body.messages[0].content).toContain('持久消息')
+    await agent.get(mediaPath).set('Host', host).expect('X-Yaoyao-Data-Source', 'upstream').expect(200)
+    const cachedMedia = await agent.get(mediaPath).set('Host', host)
+      .expect('X-Yaoyao-Data-Source', 'local').expect(200)
+    expect(Buffer.from(cachedMedia.body).toString()).toBe('png-bytes')
+    const rangedMedia = await agent.get(mediaPath).set('Host', host).set('Range', 'bytes=0-2')
+      .expect('X-Yaoyao-Data-Source', 'local').expect('Content-Range', 'bytes 0-2/9').expect(206)
+    expect(Buffer.from(rangedMedia.body).toString()).toBe('png')
+    await agent.get('/api/app/sessions?view=history').set('Host', host).expect(200)
+    await agent.get('/api/app/sessions?view=history').set('Host', host).expect(200)
+    expect(counts).toMatchObject({ list: 2, messages: 1, history: 1, media: 1 })
+    const historySnapshots = first.chatCache!.store.db.prepare("SELECT COUNT(*) count FROM chat_snapshots WHERE kind='history'").get() as { count: number }
+    expect(historySnapshots.count).toBe(0)
+
+    first.close(); runtimes.splice(runtimes.indexOf(first), 1)
+    const offlineFetch = vi.fn<typeof fetch>(async () => { throw new Error('9119 offline') })
+    const restarted = createApplication({ config: config(home), fetchImpl: offlineFetch }); runtimes.push(restarted)
+    const offlineList = await request(restarted.app.callback()).get(listPath).set('Host', host).set('Cookie', sessionCookie)
+      .expect('X-Yaoyao-Data-Source', 'local').expect(200)
+    expect(offlineList.body.sessions[0].title).toBe('持久会话')
+    const offlineMessages = await request(restarted.app.callback()).get(messagesPath).set('Host', host).set('Cookie', sessionCookie)
+      .expect('X-Yaoyao-Data-Source', 'local').expect(200)
+    expect(offlineMessages.body.messages[0].content).toContain('持久消息')
+    const offlineMedia = await request(restarted.app.callback()).get(mediaPath).set('Host', host).set('Cookie', sessionCookie)
+      .expect('X-Yaoyao-Data-Source', 'local').expect(200)
+    expect(Buffer.from(offlineMedia.body).toString()).toBe('png-bytes')
+    expect(offlineFetch).not.toHaveBeenCalled()
+    await request(restarted.app.callback()).get('/api/app/sessions?view=history').set('Host', host).set('Cookie', sessionCookie)
+      .expect(502)
+    expect(offlineFetch).toHaveBeenCalled()
+  })
+})