Przeglądaj źródła

增加服务器统一名称配置并同步三端显示

LSamien 1 miesiąc temu
rodzic
commit
d77626bf46

+ 32 - 0
docs/verification/server-name.md

@@ -0,0 +1,32 @@
+# 服务器统一名称
+
+2026-09-07。服务器名称由 Web 服务端统一保存,同一服务上的 Agent 共用。Web、iOS 和安卓均可读取;管理员可以修改,普通账号只读。名称留空时统一显示服务端主机名。
+
+## 入口与同步
+
+- Web:设置 → 登录与安全 → 服务器名称;侧栏账号说明同步显示名称。
+- iOS:设置 → 服务器与账号 → 服务器统一名称;同一地址的已保存账号名称随之更新。原本的手机备注作为旧服务的备用备注保留。
+- 安卓:设置 → 服务器统一名称;服务器账号列表缓存同步更新。
+
+首次登录、账号切换、返回前台和打开设置时读取最新配置。Bot 模式复用现有事件响应中的名称快照,普通聊天复用 HTTP/SSE 的 `server.identity.changed` 通知。已有编辑草稿保留;如其他端先保存,当前编辑页提示冲突并提供重新载入。
+
+## 协议与存储
+
+`GET /api/app/server-identity` 返回 `serverId`、`name`、`displayName`、`revision`。`PUT` 接收 `name`、`expectedRevision`、`expectedServerId`,沿用身份认证、管理员权限、Origin 和 CSRF 校验。
+
+名称去掉首尾空白,最多 100 个 UTF-16 单元,拒绝内部控制字符。客户端提交编辑开始时的版本和服务器 ID;过期版本或更换连接目标返回 409,不覆盖已有值。服务端兼容明确指定当前目标的简单调用,因此两个 expected 字段可省略;三端界面始终发送这两个字段。
+
+数据保存在现有 `workspace.sqlite3` 的独立 `server_identity` 表中。首次初始化生成稳定 ID;名称更新在事务中完成,重启后保留。Agent 名称、服务器连接地址和远程节点配对备注沿用原配置。
+
+## 验证
+
+- 16 项服务端与客户端专项测试通过:持久化、恢复主机名、非法输入、过期版本、目标服务器校验、普通账号只读、编辑草稿及设置回归。
+- 认证与工作区回归测试另外 56 项通过。
+- Web 双窗口测试通过:一端保存,另一端无需刷新即可收到名称。
+- iOS AppModel 账号隔离测试及真实设置页面测试通过:读取 Web 名称、保存 iOS 名称、重启应用后保留。
+- 安卓真实设置组件、服务端读写和 Room 缓存验证通过:读取 iOS 名称、保存“家里的 Mac”、账号列表缓存更新,错误账号、地址和旧版本被忽略,普通用户组件只读。
+- Web 最终读回安卓保存的“家里的 Mac”。三端构建及 Android Lint 通过。
+
+联动验收顺序:启动 `tests/fixtures/workspace-server.ts`(18804/19124),运行 `playwright.server-name.config.ts` 的 `two clients` 用例,再运行 iOS `testUnifiedServerNamePersistsThroughSettings`、安卓 `ServerIdentitySyncTest`,最后运行 `SERVER_NAME_NATIVE_ROUNDTRIP=1 npx playwright test -c playwright.server-name.config.ts -g 'saved by Android'`。
+
+[Web 设置截图](server-name/web-native-name-roundtrip.png)。iOS、安卓截图在各自仓库的服务器名称验收目录。均使用隔离服务及模拟器,未进行生产发布或真机安装。

BIN
docs/verification/server-name/web-native-name-roundtrip.png


+ 2 - 0
playwright.server-name.config.ts

@@ -0,0 +1,2 @@
+import {defineConfig,devices} from '@playwright/test'
+export default defineConfig({testDir:'./tests/e2e',testMatch:'serverIdentity.spec.ts',outputDir:'test-results/server-name',workers:1,timeout:30000,reporter:'list',use:{...devices['Desktop Chrome'],baseURL:'http://127.0.0.1:18804',viewport:{width:1100,height:850}}})

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

@@ -1,3 +1,4 @@
+import { serverIdentityValue } from './serverIdentity'
 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'
@@ -21,6 +22,7 @@ function normalizeBootstrap(payload: unknown): BootstrapResponse {
     upstreamReady: bool(root.upstreamReady ?? root.upstream_ready),
     upstreamError: string(root.upstreamError ?? root.upstream_error) || undefined,
     serverKind: string(root.serverKind ?? root.server_kind) || undefined,
+    serverIdentity: serverIdentityValue(root.serverIdentity),
   }
   if (rawUser) response.user = normalizeUser(rawUser)
   setApiCsrfToken(csrfToken)

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

@@ -1,3 +1,4 @@
+import { publishServerIdentity } from './serverIdentity'
 import type { GroupSocketEnvelope, JsonValue, RealtimeConnectionState, RpcEventFrame } from '@shared/types'
 import { ApiError } from './client'
 import { number, record, string } from '@/utils/normalize'
@@ -69,6 +70,7 @@ export class ChatRpcSocket {
       else this.earlyFrames.push(text)
       return
     }
+    if (type === 'server.identity.changed') publishServerIdentity(params.payload)
     if (type === 'gateway.ready') this.publishState('ready')
     const event: RpcEventFrame['params'] = {
       type, session_id: string(params.session_id) || undefined,

+ 22 - 0
src/client/api/serverIdentity.ts

@@ -0,0 +1,22 @@
+import { apiRequest } from './client'
+import type { ServerIdentity } from '@shared/serverIdentity'
+
+const listeners = new Set<(identity: ServerIdentity) => void>()
+export function serverIdentityValue(value: unknown): ServerIdentity | undefined {
+  if (!value || typeof value !== 'object') return
+  const v = value as Record<string, unknown>
+  if (typeof v.serverId !== 'string' || typeof v.name !== 'string' || typeof v.displayName !== 'string'
+    || !Number.isSafeInteger(v.revision) || Number(v.revision) < 0 || v.displayName.length > 255) return
+  return v as unknown as ServerIdentity
+}
+export function publishServerIdentity(value: unknown): void {
+  const identity = serverIdentityValue(value)
+  if (identity) for (const listener of listeners) listener(identity)
+}
+export function onServerIdentity(listener: (identity: ServerIdentity) => void): () => void {
+  listeners.add(listener)
+  return () => listeners.delete(listener)
+}
+export const fetchServerIdentity = () => apiRequest<ServerIdentity>('/api/app/server-identity')
+export const saveServerIdentity = (name: string, expectedRevision: number, expectedServerId: string) =>
+  apiRequest<ServerIdentity>('/api/app/server-identity', { method: 'PUT', body: { name, expectedRevision, expectedServerId } })

+ 4 - 0
src/client/components/app/AccountSecurityPanel.vue

@@ -1,4 +1,5 @@
 <script setup lang="ts">
+import ServerIdentityPanel from './ServerIdentityPanel.vue'
 import { computed, ref, watch } from 'vue'
 import AppIcon from '@/components/common/AppIcon.vue'
 import AccountInitialAvatar from '@/components/common/AccountInitialAvatar.vue'
@@ -42,7 +43,9 @@ const confirmationState = computed<'empty' | 'match' | 'mismatch'>(() => {
   if (!confirmation.value) return 'empty'
   return passwordsMatch.value ? 'match' : 'mismatch'
 })
+const serverNameDirty = ref(false)
 const dirty = computed(() => (
+  serverNameDirty.value ||
   (isAdmin.value && username.value.trim() !== initialUsername.value)
   || Boolean(currentPassword.value || newPassword.value || confirmation.value)
 ))
@@ -178,6 +181,7 @@ watch(canSave, value => emit('can-save-change', value), { immediate: true, flush
       <span class="account-badge">{{ auth.user?.username || '当前账号' }}</span>
     </header>
 
+    <ServerIdentityPanel v-if="auth.serverIdentity" @dirty-change="serverNameDirty = $event" />
     <section class="account-avatar-card" aria-label="账号头像">
       <AccountInitialAvatar :name="auth.user?.username || '当前账号'" :image-url="auth.user?.avatar" :size="64" />
       <div><strong>账号头像</strong><small>默认显示用户名首字母。头像只在 Web 修改,iOS 会同步显示。</small></div>

+ 48 - 0
src/client/components/app/ServerIdentityPanel.vue

@@ -0,0 +1,48 @@
+<script setup lang="ts">
+import { computed, ref, watch } from 'vue'
+import { useAuthStore } from '@/stores/auth'
+import { saveServerIdentity } from '@/api/serverIdentity'
+
+const auth = useAuthStore()
+const serverId = ref('')
+const name = ref(''), revision = ref(0), baseline = ref(''), busy = ref(false), error = ref(''), notice = ref('')
+const editable = computed(() => auth.user?.role === 'admin')
+const dirty = computed(() => name.value !== baseline.value)
+const emit = defineEmits<{ 'dirty-change': [dirty: boolean] }>()
+watch(dirty, value => emit('dirty-change', value))
+function hydrate() {
+  if (!auth.serverIdentity) return
+  name.value = baseline.value = auth.serverIdentity.name
+  revision.value = auth.serverIdentity.revision
+  serverId.value = auth.serverIdentity.serverId
+}
+watch(() => auth.serverIdentity, () => { if (!dirty.value) hydrate() }, { immediate: true })
+async function reload() { await auth.refreshServerIdentity(); hydrate(); error.value = '' }
+async function save() {
+  busy.value = true; error.value = ''; notice.value = ''
+  try {
+    const value = await saveServerIdentity(name.value, revision.value, serverId.value)
+    auth.acceptServerIdentity(value); hydrate(); notice.value = '服务器名称已保存,三端同步显示。'
+  } catch (cause) { error.value = cause instanceof Error ? cause.message : '保存失败' }
+  finally { busy.value = false }
+}
+void auth.refreshServerIdentity()
+</script>
+
+<template>
+  <section v-if="auth.serverIdentity" class="server-identity" aria-label="服务器名称">
+    <h3>服务器名称</h3>
+    <form v-if="editable" @submit.prevent="save">
+      <label>统一显示名称<input v-model="name" name="server-display-name" maxlength="100" placeholder="例如:家里的 Mac" :disabled="busy" /></label>
+      <p>该服务器上的 Agent 共用此名称,Web、iOS 和安卓同步。留空恢复服务器主机名。</p>
+      <p v-if="error" role="alert">{{ error }}</p><p v-if="notice" role="status">{{ notice }}</p>
+      <button type="submit" :disabled="busy || !dirty">{{ busy ? '保存中…' : '保存服务器名称' }}</button>
+      <button v-if="error" type="button" :disabled="busy" @click="reload">重新载入</button>
+    </form>
+    <template v-else><strong>{{ auth.serverIdentity.displayName }}</strong><p>由服务器管理员设置,三端同步显示。</p></template>
+  </section>
+</template>
+
+<style scoped>
+.server-identity{padding:16px 0;border-top:1px solid var(--line)}h3{margin:0 0 12px;font-size:15px}label{display:grid;gap:8px;font-size:13px}input{box-sizing:border-box;width:100%;padding:10px 12px;border:1px solid var(--line);border-radius:10px;background:var(--surface);color:var(--text)}p{font-size:12px;line-height:1.6;color:var(--text-secondary)}button{min-height:36px;padding:7px 12px;border:1px solid var(--line);border-radius:8px;background:var(--surface);color:var(--text);cursor:pointer}button:disabled{opacity:.5;cursor:default}[role=alert]{color:var(--danger,#dc2626)}
+</style>

+ 4 - 2
src/client/components/app/WorkspaceShell.vue

@@ -37,6 +37,7 @@ const SIDEBAR_SEARCH_EVENT = 'hermes-yaoyao:sidebar-search'
 const SIDEBAR_SEARCH_CLOSE_EVENT = 'hermes-yaoyao:sidebar-search-close'
 
 const props = withDefaults(defineProps<{
+  serverName?: string
   userName?: string
   userAvatar?: string
   pairingUserName?: string
@@ -58,6 +59,7 @@ const props = withDefaults(defineProps<{
   identityError?: string
   identityResetVersion?: number
 }>(), {
+  serverName: '',
   userName: '',
   userAvatar: '',
   pairingUserName: '',
@@ -467,7 +469,7 @@ onBeforeUnmount(() => {
             <AgentAvatar v-else :name="profileTitle(activeProfile)" :avatar="activeProfile?.agentAvatar || ''" :size="30" />
             <span class="account-copy">
               <strong>{{ applicationWorkspace ? userName : profileTitle(activeProfile) }}</strong>
-              <span>{{ applicationWorkspace ? '当前账号' : activeProfile?.name || userName || '未选择 Agent' }}</span>
+              <span>{{ serverName || (applicationWorkspace ? '当前账号' : activeProfile?.name || userName || '未选择 Agent') }}</span>
             </span>
             <AppIcon v-if="!applicationWorkspace" class="sidebar-account-switcher__chevron" name="chevron-down" :size="14" />
           </button>
@@ -574,7 +576,7 @@ onBeforeUnmount(() => {
             <AgentAvatar v-else :name="profileTitle(activeProfile)" :avatar="activeProfile?.agentAvatar || ''" :size="30" />
             <span class="account-copy">
               <strong>{{ applicationWorkspace ? userName : profileTitle(activeProfile) }}</strong>
-              <span>{{ applicationWorkspace ? '当前账号' : activeProfile?.name || userName || '未选择 Agent' }}</span>
+              <span>{{ serverName || (applicationWorkspace ? '当前账号' : activeProfile?.name || userName || '未选择 Agent') }}</span>
             </span>
             <AppIcon v-if="!applicationWorkspace" class="sidebar-account-switcher__chevron" name="chevron-down" :size="14" />
           </button>

+ 1 - 1
src/client/components/workspace/WorkspaceView.vue

@@ -55,7 +55,7 @@ onBeforeUnmount(() => window.removeEventListener('focus', refreshOnWindowFocus))
 </script>
 
 <template>
-  <WorkspaceShell
+  <WorkspaceShell :server-name="auth.serverIdentity?.displayName"
     :user-name="userName"
     :user-avatar="auth.user?.avatar"
     :pairing-user-name="auth.user?.username || ''"

+ 27 - 1
src/client/stores/auth.ts

@@ -1,4 +1,6 @@
-import { computed, ref } from 'vue'
+import type { ServerIdentity } from '@shared/serverIdentity'
+import { fetchServerIdentity, onServerIdentity } from '@/api/serverIdentity'
+import { computed, onScopeDispose, ref } from 'vue'
 import { defineStore } from 'pinia'
 import type { AuthStatus, BootstrapResponse, CurrentUser, Profile } from '@shared/types'
 import * as authApi from '@/api/auth'
@@ -10,6 +12,8 @@ function message(error: unknown): string {
 
 export const useAuthStore = defineStore('auth', () => {
   const status = ref<AuthStatus>('checking')
+  const serverIdentity = ref<ServerIdentity>()
+  let identityRequest = 0
   const user = ref<CurrentUser>()
   const profiles = ref<Profile[]>([])
   const activeProfileName = ref('')
@@ -28,7 +32,26 @@ export const useAuthStore = defineStore('auth', () => {
   const isBotOnly = computed(() => Boolean(user.value && user.value.role !== 'admin'))
   const isAuthenticated = computed(() => status.value === 'authenticated')
 
+  function acceptServerIdentity(value: ServerIdentity): void {
+    if (serverIdentity.value?.serverId === value.serverId && serverIdentity.value.revision > value.revision) return
+    if (serverIdentity.value?.serverId === value.serverId && serverIdentity.value.revision === value.revision && serverIdentity.value.name === value.name && serverIdentity.value.displayName === value.displayName) return
+    serverIdentity.value = value
+  }
+  async function refreshServerIdentity(): Promise<void> {
+    if (!isAuthenticated.value) return
+    const generation = ++identityRequest, account = user.value?.id
+    try {
+      const value = await fetchServerIdentity()
+      if (generation === identityRequest && isAuthenticated.value && user.value?.id === account) acceptServerIdentity(value)
+    } catch { /* An older/offline server keeps the last known display name. */ }
+  }
+  const stopIdentity = onServerIdentity(value => { if (isAuthenticated.value) acceptServerIdentity(value) })
+  const refreshIdentityOnFocus = () => { void refreshServerIdentity() }
+  if (typeof window !== 'undefined') window.addEventListener('focus', refreshIdentityOnFocus)
+  onScopeDispose(() => { stopIdentity(); if (typeof window !== 'undefined') window.removeEventListener('focus', refreshIdentityOnFocus) })
+
   function publish(response: BootstrapResponse): void {
+    if (response.serverIdentity) acceptServerIdentity(response.serverIdentity)
     authRequired.value = response.authRequired
     setupRequired.value = Boolean(response.setupRequired)
     csrfToken.value = response.csrfToken
@@ -51,6 +74,7 @@ export const useAuthStore = defineStore('auth', () => {
     if (status.value === 'anonymous' || status.value === 'checking') return
     status.value = 'expired'
     user.value = undefined
+    serverIdentity.value = undefined; identityRequest++
     profiles.value = []
     activeProfileName.value = ''
     csrfToken.value = ''
@@ -98,6 +122,7 @@ export const useAuthStore = defineStore('auth', () => {
     try { await authApi.logout() } catch { /* local state still signs out */ }
     status.value = 'anonymous'
     user.value = undefined
+    serverIdentity.value = undefined; identityRequest++
     profiles.value = []
     activeProfileName.value = ''
     csrfToken.value = ''
@@ -138,6 +163,7 @@ export const useAuthStore = defineStore('auth', () => {
   }
 
   return {
+    serverIdentity, acceptServerIdentity, refreshServerIdentity,
     status, user, profiles, activeProfileName, activeProfile, csrfToken, error, authRequired, setupRequired, insecureLan, groupUploadsEnabled,
     upstreamReady, upstreamError, isAuthenticated, isBotOnly, bootstrap, login, setup, logout, selectProfile, refreshProfiles, refreshProfileAvatars,
     changeCredentials, updateAccountAvatar, expire,

+ 5 - 2
src/client/views/ConversationsView.vue

@@ -1,4 +1,6 @@
 <script setup lang="ts">
+import { publishServerIdentity } from '@/api/serverIdentity'
+import type { ServerIdentity } from '@shared/serverIdentity'
 import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
 import { useRoute, useRouter } from 'vue-router'
 import WorkspaceShell from '@/components/app/WorkspaceShell.vue'
@@ -257,10 +259,11 @@ async function markRead() {
 }
 async function poll() {
   try {
-    const r = await apiRequest<{ events: WorkspaceEvent[]; cursor: number }>(
+    const r = await apiRequest<{ events: WorkspaceEvent[]; cursor: number; serverIdentity?: ServerIdentity }>(
       `/api/app/events?after=${cursor}`,
     )
     if (disposed) return
+    publishServerIdentity(r.serverIdentity)
     cursor = r.cursor
     if (r.events.length) {
       await refresh()
@@ -537,7 +540,7 @@ onBeforeUnmount(() => {
 })
 </script>
 <template>
-  <WorkspaceShell
+  <WorkspaceShell :server-name="auth.serverIdentity?.displayName"
     :user-name="auth.user?.username"
     :user-avatar="auth.user?.avatar"
     :profiles="auth.profiles"

+ 3 - 1
src/server/app.ts

@@ -297,7 +297,8 @@ export function createApplication(options: ApplicationOptions = {}): Application
       const workspacePath = /^\/api\/app\/(?:capabilities|agents|conversations|runs|interactions|events|uploads|files|message-files)(?:\/|$)/.test(path)
         && path !== '/api/app/agents/remote'
       const pushPath = /^\/api\/(?:app\/)?push\/v1\//.test(path)
-      if (!publicPath && !accountPath && !workspacePath && !pushPath)
+      const identityRead = path === '/api/app/server-identity' && ['GET', 'HEAD'].includes(ctx.method)
+      if (!publicPath && !accountPath && !workspacePath && !pushPath && !identityRead)
         throw new HttpError(403, '子账号只能使用 Bot 模式', 'bot_mode_required')
     }
     await next()
@@ -384,6 +385,7 @@ export function createApplication(options: ApplicationOptions = {}): Application
     }
   })
   const router = createApiRouter({
+    onServerIdentityChanged: identity => realtime.broker.publishServerIdentity(identity, config.upstream.href),
     onUserAccessChanged: async owner => {
       for (const c of workspace.list<{ id: string }>(owner, 'conversation')) await workspaceRuntime.stopConversation(owner, c.id)
     },

+ 7 - 0
src/server/realtimeBroker.ts

@@ -460,6 +460,13 @@ export class RealtimeBroker {
     }
     for (const c of this.channels.values()) if (c.kind === 'chat' && c.principal.upstreamKey === u.principal.upstreamKey) this.emit(c, 'reset', { reason })
   }
+  publishServerIdentity(identity: import('../shared/serverIdentity.js').ServerIdentity, instanceKey: string): void {
+    for (const channel of this.channels.values()) {
+      if (channel.principal.valid() && channel.principal.instanceKey === instanceKey) {
+        this.emit(channel, 'frame', { jsonrpc: '2.0', method: 'event', params: { type: 'server.identity.changed', payload: identity } })
+      }
+    }
+  }
   private emit(c: RealtimeChannel, event: StreamEntry['event'], value: Frame): void {
     const publicValue = this.withoutProtectedSessions(value)
     if (!publicValue) return

+ 15 - 0
src/server/routes.ts

@@ -1,3 +1,5 @@
+import { readServerIdentity, updateServerIdentity } from './serverIdentity.js'
+import type { ServerIdentity } from '../shared/serverIdentity.js'
 import type Koa from 'koa'
 import type { WorkspaceStore } from './workspaceStore.js'
 import type { WorkspaceConversation } from '../shared/workspace.js'
@@ -51,6 +53,7 @@ import { chatCacheKey, type ChatCacheCoordinator } from './chatCache.js'
 type JsonObject = Record<string, unknown>
 
 export interface RouteDependencies {
+  onServerIdentityChanged?: (identity: ServerIdentity) => void
   onUserAccessChanged?: (owner: string) => Promise<void>
   workspace: WorkspaceStore
   config: ServerConfig
@@ -870,6 +873,7 @@ async function bootstrap(
     status,
     authRequired: true,
     authenticated: true,
+    serverIdentity: readServerIdentity(dependencies.workspace),
     user,
     profiles,
     csrfToken,
@@ -1916,6 +1920,17 @@ export function createApiRouter(dependencies: RouteDependencies): Router {
     dependencies.auth.requireAdmin(ctx)
     json(ctx, 200, pushSystemStatus(dependencies))
   })
+  router.get('/api/app/server-identity', (ctx) => {
+    dependencies.auth.require(ctx)
+    ctx.set('Cache-Control', 'no-store')
+    json(ctx, 200, readServerIdentity(dependencies.workspace))
+  })
+  router.put('/api/app/server-identity', (ctx) => {
+    dependencies.auth.requireAdmin(ctx)
+    const identity = updateServerIdentity(dependencies.workspace, body(ctx))
+    dependencies.onServerIdentityChanged?.(identity)
+    json(ctx, 200, identity)
+  })
   router.get('/api/app/system/allowed-hosts', (ctx) => {
     dependencies.auth.requireAdmin(ctx)
     json(ctx, 200, dependencies.allowedHostsConfiguration.snapshot())

+ 34 - 0
src/server/serverIdentity.ts

@@ -0,0 +1,34 @@
+import { hostname } from 'node:os'
+import type { WorkspaceStore } from './workspaceStore.js'
+import type { ServerIdentity } from '../shared/serverIdentity.js'
+import { HttpError } from './errors.js'
+
+export function readServerIdentity(store: WorkspaceStore): ServerIdentity {
+  const row = store.db.prepare('SELECT * FROM server_identity WHERE singleton=1').get()!
+  const name = String(row.name)
+  return { serverId: String(row.server_id), name, displayName: name || hostname(), revision: Number(row.revision) }
+}
+
+export function updateServerIdentity(store: WorkspaceStore, input: Record<string, unknown>): ServerIdentity {
+  if (typeof input.name !== 'string' || Object.keys(input).some(key => !['name', 'expectedRevision', 'expectedServerId'].includes(key))) {
+    throw new HttpError(400, '请输入服务器名称', 'invalid_server_name')
+  }
+  const name = input.name.trim()
+  if (name.length > 100 || /[\u0000-\u001f\u007f]/.test(name)) {
+    throw new HttpError(400, '服务器名称最多 100 个字符,不能包含换行或控制字符', 'invalid_server_name')
+  }
+  if (input.expectedRevision !== undefined && (!Number.isSafeInteger(input.expectedRevision) || Number(input.expectedRevision) < 0)) {
+    throw new HttpError(400, '服务器配置版本无效', 'invalid_server_revision')
+  }
+  return store.atomic(() => {
+    const current = readServerIdentity(store)
+    if (input.expectedServerId !== undefined && input.expectedServerId !== current.serverId) {
+      throw new HttpError(409, '服务器连接已切换,请重新载入后再保存', 'server_identity_target_changed')
+    }
+    if (input.expectedRevision !== undefined && input.expectedRevision !== current.revision) {
+      throw new HttpError(409, '服务器名称已在其他端更新,请重新载入后再保存', 'server_identity_conflict')
+    }
+    if (name !== current.name) store.db.prepare('UPDATE server_identity SET name=?,revision=revision+1 WHERE singleton=1').run(name)
+    return readServerIdentity(store)
+  })
+}

+ 2 - 1
src/server/workspaceRoutes.ts

@@ -1,3 +1,4 @@
+import { readServerIdentity } from './serverIdentity.js'
 import Router from '@koa/router'
 import type Koa from 'koa'
 import { createReadStream, statSync } from 'node:fs'
@@ -248,7 +249,7 @@ export function workspaceRouter(
       after = number(ctx.query.after, 0),
       events = store.events(user, after)
     ctx.set('Cache-Control', 'no-store')
-    ctx.body = { events, cursor: events.at(-1)?.seq ?? after }
+    ctx.body = { events, cursor: events.at(-1)?.seq ?? after, serverIdentity: readServerIdentity(store) }
   })
   router.get('/api/app/nodes', (ctx) => {
     auth.requireAdmin(ctx)

+ 2 - 0
src/server/workspaceStore.ts

@@ -115,6 +115,8 @@ export class WorkspaceStore {
       CREATE INDEX IF NOT EXISTS workspace_message_task ON workspace_entities(owner, json_extract(data,'$.conversationTaskId'), json_extract(data,'$.seq')) WHERE kind='message';
       CREATE INDEX IF NOT EXISTS workspace_run_task ON workspace_entities(owner, json_extract(data,'$.conversationTaskId'), json_extract(data,'$.status')) WHERE kind='run';
       CREATE INDEX IF NOT EXISTS workspace_turn_task ON workspace_entities(owner, json_extract(data,'$.conversationTaskId'), json_extract(data,'$.status')) WHERE kind='turn';`)
+    this.db.exec('CREATE TABLE IF NOT EXISTS server_identity(singleton INTEGER PRIMARY KEY CHECK(singleton=1), server_id TEXT NOT NULL, name TEXT NOT NULL, revision INTEGER NOT NULL)')
+    this.db.prepare('INSERT OR IGNORE INTO server_identity VALUES(1, ?, ?, 0)').run(randomUUID(), '')
     this.changes.setMaxListeners(0)
     this.atomic(() => {
       this.db.exec('CREATE TABLE IF NOT EXISTS workspace_migrations(id TEXT PRIMARY KEY)')

+ 6 - 0
src/shared/serverIdentity.ts

@@ -0,0 +1,6 @@
+export interface ServerIdentity {
+  serverId: string
+  name: string
+  displayName: string
+  revision: number
+}

+ 1 - 0
src/shared/types.ts

@@ -44,6 +44,7 @@ export interface ModelOption {
 }
 
 export interface BootstrapResponse {
+  serverIdentity?: import('./serverIdentity.js').ServerIdentity
   status?: string
   authRequired: boolean
   setupRequired?: boolean

+ 31 - 0
tests/client/serverIdentityPanel.test.ts

@@ -0,0 +1,31 @@
+import { createPinia, setActivePinia } from 'pinia'
+import { flushPromises, mount } from '@vue/test-utils'
+import { afterEach, expect, it, vi } from 'vitest'
+import { useAuthStore } from '@/stores/auth'
+import ServerIdentityPanel from '@/components/app/ServerIdentityPanel.vue'
+const api=vi.hoisted(()=>({fetch:vi.fn(),save:vi.fn()}))
+vi.mock('@/api/serverIdentity',async original=>({...await original<typeof import('@/api/serverIdentity')>(),fetchServerIdentity:api.fetch,saveServerIdentity:api.save}))
+const initial={serverId:'server-a',name:'家里',displayName:'家里',revision:1}
+let auth:ReturnType<typeof useAuthStore>
+function setup(role='admin'){
+  setActivePinia(createPinia());auth=useAuthStore();auth.status='authenticated';auth.user={id:'user',username:'fixture',role}
+  auth.acceptServerIdentity(initial);api.fetch.mockResolvedValue(initial)
+  return mount(ServerIdentityPanel)
+}
+afterEach(()=>{auth?.$dispose();vi.clearAllMocks()})
+it('saves the server name with the loaded revision',async()=>{
+  const wrapper=setup();await flushPromises()
+  api.save.mockResolvedValue({...initial,name:'办公室',displayName:'办公室',revision:2})
+  await wrapper.get('input').setValue('办公室');await wrapper.get('form').trigger('submit');await flushPromises()
+  expect(api.save).toHaveBeenCalledWith('办公室',1,'server-a');expect(auth.serverIdentity?.displayName).toBe('办公室')
+  wrapper.unmount()
+})
+it('shows the same name read-only for ordinary accounts',async()=>{
+  const wrapper=setup('user');await flushPromises();expect(wrapper.text()).toContain('家里');expect(wrapper.find('input').exists()).toBe(false);wrapper.unmount()
+})
+it('keeps an unsaved draft when another client changes the name and ignores older revisions',async()=>{
+  const wrapper=setup();await flushPromises();await wrapper.get('input').setValue('我的草稿')
+  auth.acceptServerIdentity({...initial,name:'其他端',displayName:'其他端',revision:2});await flushPromises()
+  expect(wrapper.get<HTMLInputElement>('input').element.value).toBe('我的草稿')
+  auth.acceptServerIdentity(initial);expect(auth.serverIdentity?.name).toBe('其他端');wrapper.unmount()
+})

+ 25 - 0
tests/e2e/serverIdentity.spec.ts

@@ -0,0 +1,25 @@
+import {expect,test,type Page} from '@playwright/test'
+async function login(page:Page){await page.goto('/conversations');await page.getByRole('textbox',{name:'账号',exact:true}).fill('fixture');await page.getByRole('textbox',{name:'密码',exact:true}).fill('fixture-pass');await page.getByRole('button',{name:'登录',exact:true}).click();await expect(page.getByRole('button',{name:'设置与模式'}).first()).toBeVisible()}
+async function settings(page:Page){await page.getByRole('button',{name:'设置与模式'}).first().click();await page.getByRole('menuitem',{name:'进入设置',exact:true}).click();await expect(page.locator('input[name=server-display-name]')).toBeVisible();await expect(page.locator('.settings-center-layer')).toHaveCSS('opacity','1')}
+test('shares a server name across two clients without reloading',async({page,browser},testInfo)=>{
+  await login(page);await settings(page)
+  const observerContext=await browser.newContext({baseURL:'http://127.0.0.1:18804',viewport:{width:1100,height:850}})
+  const observer=await observerContext.newPage();await login(observer);await settings(observer)
+  if(await page.locator('input[name=server-display-name]').inputValue()==='Web 服务器') {
+    await page.locator('input[name=server-display-name]').fill('临时同步验证')
+    await page.getByRole('button',{name:'保存服务器名称',exact:true}).click()
+    await expect(observer.locator('input[name=server-display-name]')).toHaveValue('临时同步验证')
+  }
+  await page.locator('input[name=server-display-name]').fill('Web 服务器')
+  await page.getByRole('button',{name:'保存服务器名称',exact:true}).click()
+  await expect(page.getByRole('status').filter({hasText:'服务器名称已保存'})).toBeVisible()
+  await expect(observer.locator('input[name=server-display-name]')).toHaveValue('Web 服务器')
+  await page.screenshot({path:testInfo.outputPath('web-server-name.png'),fullPage:true,animations:'disabled'})
+  await observerContext.close()
+})
+test('reads the name saved by Android back through the Web settings',async({page},testInfo)=>{
+  test.skip(process.env.SERVER_NAME_NATIVE_ROUNDTRIP!=='1','Run after the iOS and Android settings tests')
+  await login(page);await settings(page)
+  await expect(page.locator('input[name=server-display-name]')).toHaveValue('家里的 Mac')
+  await page.screenshot({path:testInfo.outputPath('web-native-name-roundtrip.png'),fullPage:true,animations:'disabled'})
+})

+ 55 - 0
tests/server/serverIdentity.test.ts

@@ -0,0 +1,55 @@
+// @vitest-environment node
+import { mkdtempSync, rmSync } from 'node:fs'
+import { tmpdir, hostname } from 'node:os'
+import { join } from 'node:path'
+import request from 'supertest'
+import { afterEach, describe, expect, it } from 'vitest'
+import { WorkspaceStore } from '../../src/server/workspaceStore.js'
+import { readServerIdentity, updateServerIdentity } from '../../src/server/serverIdentity.js'
+import { createAuthenticatedApplication, createUserAuthenticatedApplication } from './authenticatedApplication.js'
+import type { ServerConfig } from '../../src/server/config.js'
+
+const homes: string[] = []
+const close: Array<() => void> = []
+const home = () => { const path=mkdtempSync(join(tmpdir(),'server-name-'));homes.push(path);return path }
+const store = (path=home()) => { const s=new WorkspaceStore(path);close.push(()=>s.close());return s }
+afterEach(()=>{for(const f of close.splice(0).reverse())f();for(const h of homes.splice(0))rmSync(h,{recursive:true,force:true})})
+describe('server identity',()=>{
+  it('persists one name across accounts and restarts without changing the server id',()=>{
+    const path=home(),s=store(path),initial=readServerIdentity(s)
+    const saved=updateServerIdentity(s,{name:'  家里的 Mac  ',expectedRevision:0})
+    expect(saved).toEqual({...initial,name:'家里的 Mac',displayName:'家里的 Mac',revision:1})
+    expect(readServerIdentity(store(path))).toEqual(saved)
+    expect(updateServerIdentity(s,{name:'家里的 Mac',expectedRevision:1})).toEqual(saved)
+  })
+  it('clears to the server hostname and rejects a stale edit',()=>{
+    const s=store();updateServerIdentity(s,{name:'办公室',expectedRevision:0})
+    expect(()=>updateServerIdentity(s,{name:'旧草稿',expectedRevision:0})).toThrow(/其他端更新/)
+    expect(readServerIdentity(s).name).toBe('办公室')
+    expect(updateServerIdentity(s,{name:'   ',expectedRevision:1})).toMatchObject({name:'',displayName:hostname(),revision:2})
+  })
+  it('rejects invalid values without changing the persisted revision',()=>{
+    const s=store()
+    for(const input of [{name:3},{name:'a'.repeat(101)},{name:'a\nb'},{name:'a\u0000b'},{name:'ok',expectedRevision:-1},{name:'ok',unexpected:1}])expect(()=>updateServerIdentity(s,input)).toThrow()
+    expect(readServerIdentity(s).revision).toBe(0)
+  })
+  it('keeps independent servers separate',()=>{
+    const a=store(),b=store();updateServerIdentity(a,{name:'A'})
+    expect(()=>updateServerIdentity(b,{name:'错误目标',expectedServerId:readServerIdentity(a).serverId})).toThrow(/服务器连接已切换/)
+    expect(readServerIdentity(b).name).toBe('');expect(readServerIdentity(a).serverId).not.toBe(readServerIdentity(b).serverId)
+  })
+  it('allows authenticated reads, admin saves, and includes updates in existing poll responses',async()=>{
+    const path=home()
+    const config:ServerConfig={home:path,host:'127.0.0.1',port:15300,upstream:new URL('http://127.0.0.1:19124'),allowedHosts:new Set(),mediaRoot:path,attachmentsRoot:path,imagesRoot:path,mediaOwner:'fixture',allowInsecureLan:false,insecureLan:false,production:false}
+    const fetchImpl=async()=>new Response(JSON.stringify({state:'ready',profiles:[],auth_required:false}),{headers:{'Content-Type':'application/json'}})
+    const admin=createAuthenticatedApplication({config,fetchImpl:fetchImpl as typeof fetch});close.push(()=>admin.close())
+    const agent=request.agent(admin.app.callback())
+    const bootstrap=await agent.get('/api/app/bootstrap').set('Host','127.0.0.1:15300').expect(200)
+    const saved=await agent.put('/api/app/server-identity').set('Host','127.0.0.1:15300').set('Origin','http://127.0.0.1:15300').set('X-CSRF-Token',bootstrap.body.csrfToken).send({name:'共享服务器',expectedRevision:0}).expect(200)
+    expect((await agent.get('/api/app/events?after=0').set('Host','127.0.0.1:15300').expect(200)).body.serverIdentity).toEqual(saved.body)
+    const member=createUserAuthenticatedApplication({config,fetchImpl:fetchImpl as typeof fetch});close.push(()=>member.close())
+    const reader=request.agent(member.app.callback()),token=(await reader.get('/api/app/bootstrap').set('Host','127.0.0.1:15300')).body.csrfToken
+    expect((await reader.get('/api/app/server-identity').set('Host','127.0.0.1:15300').expect(200)).body).toEqual(saved.body)
+    await reader.put('/api/app/server-identity').set('Host','127.0.0.1:15300').set('Origin','http://127.0.0.1:15300').set('X-CSRF-Token',token).send({name:'无权限'}).expect(403)
+  })
+})