Răsfoiți Sursa

发布 v0.3.26:持久登记跨端聊天所有权

LSamien 1 lună în urmă
părinte
comite
af7b506f2c

+ 2 - 2
README.md

@@ -1,6 +1,6 @@
 # 夭夭 Web
 
-当前发布版本:**v0.3.25**,配套 iOS **1.3(181)**。详见 [发布说明](docs/releases/v0.3.25.md)。
+当前发布版本:**v0.3.26**,配套 iOS **1.3(183)**。详见 [发布说明](docs/releases/v0.3.26.md)。
 
 夭夭 Web 为网页和 iOS 提供统一的 Agent、群聊、文件库及语音配置服务。Hermes 通过标准 9119 HTTP/WebSocket 接口执行任务;无需安装 Yaoyao 插件。
 
@@ -32,7 +32,7 @@ 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`。
+由 Web 或 iOS 创建的普通聊天会按登录用户、Profile 和 Session ID 在 `chat-cache.sqlite3` 中登记所有权并持久化;后续续聊与列表展示以这份服务端登记为准,不再单独依赖 Hermes 的 `source` 标记。Web 的只读历史记录不进入该数据库,继续按需读取 9119。可通过 `HERMES_YAOYAO_CHAT_CACHE_MODE=upstream-only|shadow|prefer-local` 切换策略,默认 `prefer-local`。
 
 ## iOS
 

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

@@ -0,0 +1,7 @@
+# v0.3.26
+
+- 15300 按登录用户、Profile 和 Session ID 持久登记普通聊天所有权,不再仅依赖 Hermes 的 `source` 字段判断能否续聊。
+- Web 会话列表合并服务端已登记的会话;即使 Hermes 的来源元数据滞后,iOS 新建的聊天也会立即出现在 Web,并可在两端继续。
+- 保留原生历史只读边界,未被 15300 登记的 Hermes 会话仍不会被误判为可写聊天。
+
+配套 iOS 1.3(183)。

+ 2 - 2
package-lock.json

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

+ 1 - 1
package.json

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

+ 3 - 3
release.json

@@ -1,6 +1,6 @@
 {
   "schemaVersion": 1,
-  "releaseVersion": "0.3.25",
-  "webVersion": "0.3.25",
-  "gitTag": "v0.3.25"
+  "releaseVersion": "0.3.26",
+  "webVersion": "0.3.26",
+  "gitTag": "v0.3.26"
 }

+ 2 - 1
src/server/app.ts

@@ -213,7 +213,8 @@ export function createApplication(options: ApplicationOptions = {}): Application
   const realtime = new RealtimeAPI(config, auth, csrf, pairings, upstream, upstreamSession, {
     coordinator: pushEventCoordinator,
     resolver: new HermesChatNotificationResolver(upstreamSession, (user, prompt) => push.promptDigest(user, prompt)),
-  })
+  }, (owner, profile, sessionID) =>
+    chatCache?.store.ownsSession(owner, profile, sessionID) ?? false)
   realtime.broker.protectedSession = id => workspace.ownsUpstream(id)
   realtime.broker.onNativeEvent = (owner, profile, storedId, frame) => {
     chatCache?.observe(owner, profile, storedId, frame)

+ 83 - 2
src/server/chatCache.ts

@@ -281,6 +281,80 @@ export class ChatCacheStore {
       .get(owner, profile, sessionID))
   }
 
+  ownsSession(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=?',
+    ).get(owner, profile, sessionID))
+  }
+
+  mergeOwnedSessionsIntoList(
+    owner: string,
+    fallbackProfile: string,
+    response: UpstreamResponse,
+  ): UpstreamResponse {
+    const payload = parseResponse(response)
+    if (!payload) return response
+    const collectionKey = Array.isArray(payload.items)
+      ? 'items'
+      : Array.isArray(payload.sessions) ? 'sessions' : undefined
+    if (!collectionKey) return response
+
+    const rows = (fallbackProfile
+      ? this.db.prepare(`SELECT profile,session_id,data FROM chat_sessions
+          WHERE owner=? AND profile=? ORDER BY last_synced_at DESC LIMIT 1000`)
+          .all(owner, fallbackProfile)
+      : this.db.prepare(`SELECT profile,session_id,data FROM chat_sessions
+          WHERE owner=? ORDER BY last_synced_at DESC LIMIT 1000`).all(owner)) as Array<{
+            profile: string
+            session_id: string
+            data: string
+          }>
+    const owned = new Map(rows.map(row => [
+      `${row.profile}\u0000${row.session_id}`,
+      row,
+    ]))
+    const merged = new Map<string, Record<string, unknown>>()
+    for (const value of payload[collectionKey] as unknown[]) {
+      const session = object(value)
+      if (!session) continue
+      const id = sessionIDOf(session)
+      const profile = profileOf(session, fallbackProfile) || 'default'
+      if (!id) continue
+      const key = `${profile}\u0000${id}`
+      if (sessionSource(session) === 'web' || owned.has(key)) {
+        merged.set(key, { ...session, source: 'web' })
+      }
+    }
+    for (const [key, row] of owned) {
+      if (merged.has(key)) continue
+      let stored: Record<string, unknown> = {}
+      try { stored = object(JSON.parse(row.data)) ?? {} } catch { /* Ignore damaged display metadata. */ }
+      merged.set(key, {
+        ...stored,
+        id: row.session_id,
+        profile: row.profile,
+        source: 'web',
+      })
+    }
+    const activity = (session: Record<string, unknown>): number => Number(
+      session.last_active_at
+        ?? session.last_active
+        ?? session.updated_at
+        ?? session.started_at
+        ?? 0,
+    ) || 0
+    const sessions = [...merged.values()].sort((left, right) =>
+      activity(right) - activity(left))
+    const body = Buffer.from(JSON.stringify({
+      ...payload,
+      [collectionKey]: sessions,
+      total: Math.max(Number(payload.total) || 0, sessions.length),
+    }))
+    const headers = new Headers(response.headers)
+    headers.delete('content-length')
+    return { ...response, headers, body }
+  }
+
   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')
@@ -370,10 +444,17 @@ export class ChatCacheCoordinator {
       return { response: local.response, source: 'local', state: local.state }
     }
     try {
-      const response = await load()
-      if (this.mode === 'prefer-local' && local && response.status >= 500) {
+      const upstreamResponse = await load()
+      if (this.mode === 'prefer-local' && local && upstreamResponse.status >= 500) {
         return { response: local.response, source: 'local', state: 'stale' }
       }
+      const response = kind === 'list' && knownWeb
+        ? this.store.mergeOwnedSessionsIntoList(
+            owner,
+            profile,
+            upstreamResponse,
+          )
+        : upstreamResponse
       this.store.putSnapshot(owner, key, kind, profile, sessionID, response, knownWeb)
       return { response, source: 'upstream', state: 'current' }
     } catch (error) {

+ 7 - 3
src/server/realtimeApi.ts

@@ -18,13 +18,15 @@ export class RealtimeAPI {
   private observers = new Map<string, ChatPushRelayObserver>()
   constructor(readonly config: ServerConfig, readonly auth: LocalAuthStore, readonly csrf: CsrfProtection,
     readonly pairings: NodePairingStore, readonly upstream: UpstreamClient, readonly session: UpstreamServiceSession,
-    readonly push?: { coordinator: PushEventCoordinator; resolver: ChatNotificationResolver }) {
+    readonly push?: { coordinator: PushEventCoordinator; resolver: ChatNotificationResolver },
+    readonly ownsSession?: (owner: string, profile: string, sessionId: string) => boolean) {
     this.broker = new RealtimeBroker(config.home, Date.now, change => {
       upstream.observeRealtime(change)
       if (change.kind === 'reset') session.invalidateAuthentication()
     })
   }
-  private async canResume(profile: string, sessionId: string, jar?: CookieJar): Promise<boolean> {
+  private async canResume(profile: string, sessionId: string, jar?: CookieJar, owner?: string): Promise<boolean> {
+    if (owner && this.ownsSession?.(owner, profile, sessionId)) return true
     const search = new URLSearchParams({ profile })
     const path = `/api/sessions/${encodeURIComponent(sessionId)}`
     const response = jar
@@ -45,6 +47,7 @@ export class RealtimeAPI {
     let valid: () => boolean
     let authorize: RealtimePrincipal['authorize']
     let jar: CookieJar | undefined
+    let owner: string | undefined
     let observer: ChatPushRelayObserver | undefined
     if (device) {
       const bearer = ctx.get('authorization').match(/^Bearer\s+(.+)$/i)?.[1] ?? ''
@@ -57,6 +60,7 @@ export class RealtimeAPI {
       }
     } else {
       const user = this.auth.require(ctx)
+      owner = user.id
       const cookie = ctx.get('cookie')
       key = `user:${user.id}:${this.auth.pushAuthorizationVersion(user.id)}`
       valid = () => { try { return this.auth.currentFromCookieHeader(cookie)?.id === user.id } catch { return false } }
@@ -70,7 +74,7 @@ export class RealtimeAPI {
     }
     return {
       key, instanceKey: this.config.upstream.href, upstreamKey: `${this.config.upstream.href}:${device ? `device:${device}` : 'service'}`, paired: Boolean(device), valid, authorize,
-      canResume: (profile, sessionId) => this.canResume(profile, sessionId, jar),
+      canResume: (profile, sessionId) => this.canResume(profile, sessionId, jar, owner),
       agent: this.upstream.directAgent,
       observeCommand: f => observer?.observeClientFrame(f),
       observeEvent: f => observer?.observeUpstreamFrame(Buffer.from(f), false),

+ 20 - 11
src/server/routes.ts

@@ -328,11 +328,11 @@ function nativeSessionsReadOnly(): never {
   )
 }
 
-function requireWritableWebSession(response: UpstreamResponse): void {
+function requireWritableWebSession(response: UpstreamResponse, locallyOwned = false): void {
   const payload = requireSuccess(response)
   const session = payload.session && typeof payload.session === 'object' && !Array.isArray(payload.session)
     ? payload.session as JsonObject : payload
-  if (session.source !== 'web') nativeSessionsReadOnly()
+  if (!locallyOwned && session.source !== 'web') nativeSessionsReadOnly()
 }
 
 function pairedProxyPath(rawPath: string): string {
@@ -1925,15 +1925,19 @@ export function createApiRouter(dependencies: RouteDependencies): Router {
     const id = safeIdentifier(ctx.params.sessionID, 'session ID')
     const search = searchFrom(ctx, ['profile'])
     await withJar(ctx, async jar => {
-      requireWritableWebSession(await dependencies.upstream.request(`/api/sessions/${encodeURIComponent(id)}`, jar, { search }))
+      const profile = search.get('profile') || 'default'
+      const owner = dependencies.auth.require(ctx).id
+      requireWritableWebSession(
+        await dependencies.upstream.request(`/api/sessions/${encodeURIComponent(id)}`, jar, { search }),
+        dependencies.chatCache?.store.ownsSession(owner, profile, id) ?? false,
+      )
       const request = body(ctx)
-      const profile = search.get('profile')
-      if (profile) request.profile = profile
+      const requestedProfile = search.get('profile')
+      if (requestedProfile) request.profile = requestedProfile
       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)
+        dependencies.chatCache?.store.markListsStale(owner)
+        void dependencies.chatCache?.reconcile(owner, profile, id)
       }
       sendUpstreamResponse(ctx, response, jar)
     })
@@ -1942,12 +1946,17 @@ export function createApiRouter(dependencies: RouteDependencies): Router {
     const id = safeIdentifier(ctx.params.sessionID, 'session ID')
     const search = searchFrom(ctx, ['profile'])
     await withJar(ctx, async jar => {
-      requireWritableWebSession(await dependencies.upstream.request(`/api/sessions/${encodeURIComponent(id)}`, jar, { search }))
+      const profile = search.get('profile') || 'default'
+      const owner = dependencies.auth.require(ctx).id
+      requireWritableWebSession(
+        await dependencies.upstream.request(`/api/sessions/${encodeURIComponent(id)}`, jar, { search }),
+        dependencies.chatCache?.store.ownsSession(owner, profile, id) ?? false,
+      )
       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',
+          owner,
+          profile,
           id,
         )
       }

+ 34 - 4
tests/fixtures/workspace-server.ts

@@ -18,6 +18,7 @@ const sessions = new Map<
   {
     id: string
     profile: string
+    source: string
     messages: Array<{ role: string; content: string; id: string; timestamp: number }>
     running: boolean
   }
@@ -69,14 +70,37 @@ const upstream = createServer((req, res) => {
     return
   }
   if (url.pathname === '/api/sessions') {
+    const requestedSource = url.searchParams.get('source')
+    const visible = [...sessions.values()].filter(session =>
+      !requestedSource || session.source === requestedSource)
     send({
-      sessions: [...sessions.values()].map((s) => ({
+      sessions: visible.map((s) => ({
         id: s.id,
         title: 'Internal',
-        source: 'yaoyao_workspace',
+        source: s.source,
         profile: s.profile,
       })),
-      total: sessions.size,
+      total: visible.length,
+      hasMore: false,
+      offset: Number(url.searchParams.get('offset') ?? 0),
+      limit: Number(url.searchParams.get('limit') ?? 100),
+    })
+    return
+  }
+  const detail = /^\/api\/sessions\/([^/]+)$/.exec(url.pathname)
+  if (detail) {
+    const session = sessions.get(detail[1]!)
+    if (!session) {
+      res.statusCode = 404
+      send({ error: 'session not found' })
+      return
+    }
+    send({
+      id: session.id,
+      profile: session.profile,
+      source: session.source,
+      title: 'Internal',
+      message_count: session.messages.length,
     })
     return
   }
@@ -131,7 +155,13 @@ wss.on('connection', (socket) => {
     if (f.method === 'session.create' || f.method === 'session.resume') {
       let stored = f.method === 'session.resume' ? sessions.get(f.params.session_id) : undefined
       if (!stored) {
-        stored = { id: randomUUID(), profile: f.params.profile, messages: [], running: false }
+        stored = {
+          id: randomUUID(),
+          profile: f.params.profile,
+          source: String(f.params.source ?? 'web'),
+          messages: [],
+          running: false,
+        }
         sessions.set(stored.id, stored)
       }
       const runtimeId = randomUUID()

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

@@ -89,6 +89,8 @@ describe('durable source=web chat cache', () => {
     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')
+    expect(f.store.ownsSession(owner, profile, sessionID)).toBe(true)
+    expect(f.store.ownsSession('another-owner', profile, sessionID)).toBe(false)
     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')

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

@@ -32,6 +32,78 @@ afterEach(() => {
 })
 
 describe('source=web chat cache routes', () => {
+  it('uses the Web-owned session registry when Hermes source metadata is stale', async () => {
+    const home = mkdtempSync(join(tmpdir(), 'yaoyao-owned-chat-routes-'))
+    homes.push(home)
+    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') {
+        return Response.json({
+          sessions: [{
+            id: 'owned-session',
+            profile: 'default',
+            source: 'ios',
+            title: 'Hermes 仍标记为 iOS',
+          }],
+          total: 1,
+        })
+      }
+      if (url.pathname === '/api/sessions/owned-session') {
+        if (init?.method === 'PATCH') return Response.json({ ok: true })
+        return Response.json({
+          id: 'owned-session',
+          profile: 'default',
+          source: 'ios',
+          title: 'Hermes 仍标记为 iOS',
+        })
+      }
+      return Response.json({ error: 'missing fixture' }, { status: 404 })
+    })
+    const runtime = createApplication({ config: config(home), fetchImpl })
+    runtimes.push(runtime)
+    const agent = request.agent(runtime.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 owner = String(setup.body.user.id)
+    runtime.chatCache!.store.recordRoute(
+      owner,
+      'default',
+      'owned-session',
+      'runtime-owned',
+    )
+
+    const list = await agent
+      .get('/api/app/sessions?view=chat&profile=default&limit=100')
+      .set('Host', host)
+      .expect(200)
+    expect(list.body.sessions).toContainEqual(expect.objectContaining({
+      id: 'owned-session',
+      source: 'web',
+      title: 'Hermes 仍标记为 iOS',
+    }))
+
+    await agent.patch('/api/app/sessions/owned-session?profile=default')
+      .set('Host', host).set('Origin', origin)
+      .set('X-CSRF-Token', setup.body.csrfToken)
+      .send({ title: 'Web 可继续管理' })
+      .expect(200)
+  })
+
   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 }