Răsfoiți Sursa

feat: 业务平台接入——浏览器令牌、CORS 与 SDK 包分发

- POST /v1/browser-tokens:平台后端用 API Key 换取短期浏览器令牌(HMAC 无状态,默认 1 小时,最长 12 小时),
  Key 停用/删除即作废;令牌不能再签发令牌
- CORS:只对 /v1 开放给"设置 › 安全"中列出的平台来源,不带 Cookie;SSE 接管连接后同样带上 CORS 头
- 客户端:getToken + browserTokenProvider(缓存、提前换新、合并并发、401 刷新重试),请求改为 same-origin 凭据;
  新增 issueBrowserToken、usage
- SDK 分发:scripts/build-sdk.mjs 构建 shared / client / chat-ui(ESM + 类型 + 编译好的 style.css),
  部署到 /sdk/ 供平台按 URL 安装;chat-ui 用 UnoCSS vue-scoped 模式隔离样式,只带 --app-* 变量
- chat-ui:没有监听 openFile 时点击文件直接下载
- 后台:API Keys 页显示 SDK 安装命令;设置页可配置 CORS 来源
- 示例平台 examples/platform-demo(Vue 前端 + 30 行 Node 令牌后端),接入指南 docs/integration.md,ADR-0019

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Jerry Liang 1 săptămână în urmă
părinte
comite
748d18e8b3
49 a modificat fișierele cu 1028 adăugiri și 161 ștergeri
  1. 1 0
      .gitignore
  2. 22 0
      apps/admin/src/views/ApiKeysView.vue
  3. 5 0
      apps/admin/src/views/SettingsView.vue
  4. 3 1
      apps/server/src/app.ts
  5. 2 0
      apps/server/src/config.ts
  6. 9 0
      apps/server/src/http/auth.ts
  7. 14 2
      apps/server/src/http/routes/v1.ts
  8. 29 0
      apps/server/src/http/server.ts
  9. 2 0
      apps/server/src/http/sse.ts
  10. 59 0
      apps/server/src/services/browser-tokens.ts
  11. 16 5
      apps/server/src/services/catalog.ts
  12. 2 1
      apps/server/src/services/settings.ts
  13. 48 0
      apps/server/test/api.test.ts
  14. 33 0
      apps/server/test/client-sdk.test.ts
  15. 4 0
      deploy/deploy.sh
  16. 2 0
      docs/README.md
  17. 35 0
      docs/adr/0019-browser-tokens-and-sdk-distribution.md
  18. 16 9
      docs/design/04-api.md
  19. 2 2
      docs/design/11-roadmap.md
  20. 154 0
      docs/integration.md
  21. 14 0
      examples/platform-demo/README.md
  22. 13 0
      examples/platform-demo/index.html
  23. 24 0
      examples/platform-demo/package.json
  24. 36 0
      examples/platform-demo/server.mjs
  25. 75 0
      examples/platform-demo/src/App.vue
  26. 2 0
      examples/platform-demo/src/env.d.ts
  27. 7 0
      examples/platform-demo/src/main.ts
  28. 14 0
      examples/platform-demo/tsconfig.json
  29. 4 0
      examples/platform-demo/uno.config.ts
  30. 9 0
      examples/platform-demo/vite.config.ts
  31. 4 1
      packages/chat-ui/package.json
  32. 11 3
      packages/chat-ui/src/components/AgentChat.vue
  33. 1 0
      packages/chat-ui/src/index.ts
  34. 24 0
      packages/chat-ui/src/style.css
  35. 6 0
      packages/chat-ui/tsconfig.build.json
  36. 6 0
      packages/chat-ui/uno.config.ts
  37. 16 0
      packages/chat-ui/vite.config.ts
  38. 14 5
      packages/client/package.json
  39. 49 10
      packages/client/src/index.ts
  40. 10 0
      packages/client/tsup.config.ts
  41. 11 4
      packages/shared/package.json
  42. 12 0
      packages/shared/src/api.ts
  43. 2 0
      packages/shared/src/settings.ts
  44. 10 0
      packages/shared/tsup.config.ts
  45. 2 1
      packages/ui-tokens/package.json
  46. 2 117
      packages/ui-tokens/src/tokens.css
  47. 117 0
      packages/ui-tokens/src/variables.css
  48. 15 0
      pnpm-lock.yaml
  49. 60 0
      scripts/build-sdk.mjs

+ 1 - 0
.gitignore

@@ -6,3 +6,4 @@ dist/
 /data/
 .DS_Store
 .dev-data/
+dist-sdk/

+ 22 - 0
apps/admin/src/views/ApiKeysView.vue

@@ -17,7 +17,11 @@ const editor = reactive<{ open: boolean; id: string | null; name: string; allowe
 })
 const editorValid = computed(() => editor.allowed.length > 0 && editor.allowed.includes(editor.def) && (editor.id !== null || editor.name.trim().length > 0))
 
+// 业务平台安装 SDK 的命令(包由本服务的 /sdk/ 提供)
+const sdk = ref<{ version: string; packages: { name: string; file: string }[] } | null>(null)
+const installCmd = computed(() => `pnpm add ${(sdk.value?.packages ?? []).map(p => `${location.origin}/sdk/${p.file}`).join(' ')}`)
 async function load() {
+  fetch('/sdk/index.json').then(r => (r.ok ? r.json() : null)).then(v => (sdk.value = v)).catch(() => {})
   try {
     ;[keys.value, profiles.value] = await Promise.all([admin.apiKeys(), admin.profiles()])
   } catch (err) {
@@ -129,6 +133,24 @@ onMounted(load)
           <tr v-if="!keys.length"><td colspan="5" class="py-4 hint">还没有 API Key</td></tr>
         </tbody>
       </table>
+
+      <!-- 业务平台接入:SDK 由本服务提供,按 URL 安装 -->
+      <section class="card p-4 flex flex-col gap-2 text-[12.5px]">
+        <div class="font-600 text-[13px] flex items-center gap-1.5"><span class="codicon codicon-plug" />业务平台接入</div>
+        <ol class="m-0 pl-5 flex flex-col gap-1">
+          <li>在平台<b>后端</b>保存 API Key,提供一个接口调用 <code class="font-mono">POST /v1/browser-tokens</code> 为已登录用户换取浏览器令牌(1 小时有效)。<b>不要把 API Key 放进前端代码。</b></li>
+          <li>在"设置 › 安全"的 CORS 来源中加入平台的地址(如 <code class="font-mono">https://tools.example.com</code>)。</li>
+          <li>前端安装 SDK,使用 <code class="font-mono">&lt;AgentChat&gt;</code> 组件;详见仓库中的 docs/integration.md 与 examples/platform-demo。</li>
+        </ol>
+        <template v-if="sdk">
+          <div class="flex items-center gap-2 mt-1">
+            <span class="hint">安装 SDK {{ sdk.version }}:</span>
+            <button type="button" class="btn-ghost" @click="copy(installCmd)"><span class="codicon codicon-copy" />复制</button>
+          </div>
+          <code class="block rounded-md bg-code p-2 font-mono text-[12px] break-all">{{ installCmd }}</code>
+        </template>
+        <div v-else class="hint">当前服务没有提供 SDK 包(部署时由 scripts/build-sdk.mjs 生成)</div>
+      </section>
     </div>
 
     <div v-if="editor.open" class="fixed inset-0 z-40 flex items-center justify-center bg-[var(--app-overlay)]" @click.self="editor.open = false">

+ 5 - 0
apps/admin/src/views/SettingsView.vue

@@ -114,6 +114,11 @@ const LABELS: Record<(typeof PATH_KEYS)[number], string> = {
               <option value="restricted">受限(推荐)</option><option value="broad">宽松</option>
             </select>
           </div>
+          <div>
+            <label class="label" for="s-cors">允许浏览器直接调用 API 的业务平台来源(CORS,每行一个)</label>
+            <textarea id="s-cors" class="field-area h-20 font-mono" :value="lines(draft.security.corsOrigins)" placeholder="https://tools.example.com" @change="draft.security.corsOrigins = parseLines(($event.target as HTMLTextAreaElement).value)" />
+            <div class="hint mt-1">格式为 https://host[:port],不带路径。浏览器中请使用平台后端签发的浏览器令牌,不要把 API Key 放进前端代码(见接入指南)。</div>
+          </div>
         </section>
 
         <section class="flex flex-col gap-3">

+ 3 - 1
apps/server/src/app.ts

@@ -20,6 +20,7 @@ import { RunManager } from './services/runs'
 import { SessionService } from './services/sessions'
 import { SettingsService } from './services/settings'
 import { UsageService } from './services/usage'
+import { BrowserTokenService } from './services/browser-tokens'
 
 export interface AppLogger {
   info(o: object, m?: string): void
@@ -40,6 +41,7 @@ export function createApp(log: AppLogger, opts: { db?: DB; providers?: Partial<R
   const apiKeys = new ApiKeyService(db, profiles)
   const audit = new AuditService(db)
   const usage = new UsageService(db)
+  const browserTokens = new BrowserTokenService(db)
   const interactions = new InteractionService(db, events, settings)
   const sessions = new SessionService(db, settings, profiles, apiKeys, files, events)
   const adminAuth = new AdminAuthService(db, log, config.adminPassword)
@@ -56,7 +58,7 @@ export function createApp(log: AppLogger, opts: { db?: DB; providers?: Partial<R
   sessions.extraModels = p => providers[p]?.knownModels?.() ?? []
   const runs = new RunManager({ db, log, settings, sessions, profiles, knowledge, files, events, interactions, audit, usage, plugins, communitySkills, gatewayTokens, providers })
 
-  return { db, log, settings, events, files, profiles, knowledge, apiKeys, audit, interactions, sessions, usage, adminAuth, plugins, claudeLogin, codexSkills, communitySkills, codexPlugins, gatewayTokens, providers, runs }
+  return { db, log, settings, events, files, profiles, knowledge, apiKeys, audit, interactions, sessions, usage, browserTokens, adminAuth, plugins, claudeLogin, codexSkills, communitySkills, codexPlugins, gatewayTokens, providers, runs }
 }
 
 export type App = ReturnType<typeof createApp>

+ 2 - 0
apps/server/src/config.ts

@@ -19,6 +19,8 @@ export const config = {
   adminPassword: env.AGENT_SERVER_ADMIN_PASSWORD,
   /** 后台静态资源目录(生产部署时由服务托管) */
   adminDist: env.AGENT_SERVER_ADMIN_DIST,
+  /** 业务平台 SDK 包目录(默认为后台静态资源旁边的 sdk 目录) */
+  sdkDist: env.AGENT_SERVER_SDK_DIST,
   /**
    * 信任哪些反向代理的 X-Forwarded-* 头:未设置或 false 为不信任;true 为信任所有来源(仅在服务只能经由代理访问时使用);
    * 也可以是逗号分隔的代理 IP / CIDR(推荐),只信任来自这些地址的转发信息,直接访问的客户端无法伪造

+ 9 - 0
apps/server/src/http/auth.ts

@@ -1,6 +1,7 @@
 import type { FastifyReply, FastifyRequest } from 'fastify'
 import type { App } from '../app'
 import { AppError } from '../lib/errors'
+import { BrowserTokenService } from '../services/browser-tokens'
 
 export const ADMIN_COOKIE = 'agent_admin'
 
@@ -9,6 +10,8 @@ export interface Principal {
   kind: 'apikey' | 'admin'
   /** 写入 sessions.source:API Key id 或 'admin' */
   source: string
+  /** 使用浏览器令牌(由 API Key 签发)调用:权限与 Key 相同,但不能再签发令牌 */
+  browser?: boolean
 }
 
 declare module 'fastify' {
@@ -30,6 +33,12 @@ export function isAdmin(app: App, req: FastifyRequest) {
 export function requireClient(app: App) {
   return async (req: FastifyRequest, _reply: FastifyReply) => {
     const key = bearer(req)
+    if (key && BrowserTokenService.isBrowserToken(key)) {
+      const id = app.browserTokens.verify(key)
+      if (!id || !app.apiKeys.isActive(id)) throw new AppError(401, 'invalid_token', '浏览器令牌无效或已过期')
+      req.principal = { kind: 'apikey', source: id, browser: true }
+      return
+    }
     if (key) {
       const id = app.apiKeys.verify(key)
       if (!id) throw new AppError(401, 'invalid_api_key', 'API Key 无效或已停用')

+ 14 - 2
apps/server/src/http/routes/v1.ts

@@ -2,10 +2,11 @@
 import fs from 'node:fs'
 import path from 'node:path'
 import type { FastifyInstance, FastifyRequest } from 'fastify'
-import type { InteractionResponse, ProviderStatusDto, SessionDetailDto } from '@agent-server/shared'
+import type { CreateBrowserTokenBody, InteractionResponse, ProviderStatusDto, SessionDetailDto } from '@agent-server/shared'
 import { z } from 'zod'
 import type { App } from '../../app'
-import { badRequest, notFound } from '../../lib/errors'
+import { AppError, badRequest, notFound } from '../../lib/errors'
+import { BROWSER_TOKEN_MAX_TTL } from '../../services/browser-tokens'
 import { parse } from '../../services/catalog'
 import { principal, requireClient } from '../auth'
 import { streamSessionEvents } from '../sse'
@@ -43,6 +44,17 @@ export default async function v1Routes(f: FastifyInstance, { app }: { app: App }
 
   // 引擎状态缓存 30 秒,避免频繁调用 CLI
   let statusCache: { at: number; data: ProviderStatusDto[] } | null = null
+  // 业务平台的后端用 API Key 换取浏览器令牌(ADR-0019)
+  f.post('/browser-tokens', async (req, reply) => {
+    const p = principal(req)
+    if (p.kind !== 'apikey' || p.browser) throw new AppError(403, 'forbidden', '只能用 API Key 签发浏览器令牌')
+    const body = (req.body ?? {}) as CreateBrowserTokenBody
+    const ttl = body.ttlSeconds === undefined ? undefined : Number(body.ttlSeconds)
+    if (ttl !== undefined && !(ttl >= 60 && ttl <= BROWSER_TOKEN_MAX_TTL)) throw badRequest(`ttlSeconds 应在 60 ~ ${BROWSER_TOKEN_MAX_TTL} 之间`)
+    reply.code(201)
+    return app.browserTokens.issue(p.source, ttl)
+  })
+
   // 本平台(调用者自己的来源)的用量
   f.get<{ Querystring: { days?: string } }>('/usage', async req => app.usage.summary({ days: Number(req.query.days ?? 30) || 30, source: principal(req).source }))
 

+ 29 - 0
apps/server/src/http/server.ts

@@ -30,6 +30,21 @@ export async function buildServer(app: App, opts: { logger: FastifyServerOptions
     return reply.code(status).send(body)
   })
 
+  // CORS:只对 /v1 开放给设置中允许的业务平台来源(不带 Cookie,使用 Authorization 头);后台与网关不开放
+  f.addHook('onRequest', async (req, reply) => {
+    const origin = req.headers.origin
+    if (!origin || !req.url.startsWith('/v1/') || !app.settings.get().security.corsOrigins.includes(origin)) return
+    reply.header('access-control-allow-origin', origin)
+    reply.header('vary', 'Origin')
+    reply.header('access-control-expose-headers', 'content-disposition')
+    if (req.method === 'OPTIONS') {
+      reply.header('access-control-allow-methods', 'GET, POST, PATCH, DELETE')
+      reply.header('access-control-allow-headers', 'authorization, content-type, last-event-id')
+      reply.header('access-control-max-age', '600')
+      return reply.code(204).send()
+    }
+  })
+
   f.get('/healthz', async () => ({ ok: true }))
   f.get('/readyz', async () => {
     app.db.$client.prepare('select 1').get()
@@ -51,5 +66,19 @@ export async function buildServer(app: App, opts: { logger: FastifyServerOptions
     f.get('/', (_req, reply) => reply.redirect('/admin/'))
   }
 
+  // 业务平台使用的 SDK 包(scripts/build-sdk.mjs 生成,公开、不含密钥):生产在 app/sdk,开发在仓库的 dist-sdk
+  const sdkDist = [config.sdkDist, adminDist && path.resolve(adminDist, '../sdk'), path.resolve(here, '../../../../dist-sdk'), path.resolve(here, '../../../dist-sdk')]
+    .find((p): p is string => !!p && fs.existsSync(path.join(p, 'index.json')))
+  if (sdkDist) {
+    // 每次请求读取目录,重新部署 SDK 后无需重启
+    f.get<{ Params: { file: string } }>('/sdk/:file', (req, reply) => {
+      const file = req.params.file
+      if (!/^[\w.-]+\.(tgz|json)$/.test(file) || !fs.existsSync(path.join(sdkDist, file))) return reply.code(404).send({ error: { code: 'not_found', message: '文件不存在' } })
+      reply.header('Content-Type', file.endsWith('.json') ? 'application/json' : 'application/gzip')
+      reply.header('Cache-Control', file.endsWith('.json') ? 'no-cache' : 'public, max-age=31536000, immutable')
+      return reply.send(fs.createReadStream(path.join(sdkDist, file)))
+    })
+  }
+
   return f
 }

+ 2 - 0
apps/server/src/http/sse.ts

@@ -8,7 +8,9 @@ const PING_MS = 15_000
 export function streamSessionEvents(app: App, req: FastifyRequest, reply: FastifyReply, sessionId: string, lastId: number) {
   reply.hijack()
   const res = reply.raw
+  // hijack 后 reply.header 不再生效:带上前面钩子设置的头(CORS 等)
   res.writeHead(200, {
+    ...(reply.getHeaders() as Record<string, string>),
     'Content-Type': 'text/event-stream; charset=utf-8',
     'Cache-Control': 'no-cache, no-transform',
     Connection: 'keep-alive',

+ 59 - 0
apps/server/src/services/browser-tokens.ts

@@ -0,0 +1,59 @@
+// 浏览器令牌(ADR-0019):业务平台的后端用 API Key 换取短期令牌交给浏览器,API Key 不出现在前端代码中。
+// 令牌无状态(HMAC 签名,服务重启后仍然有效),携带签发它的 API Key id 与过期时间;
+// 每次使用都检查该 API Key 仍然启用,停用或删除 Key 即同时作废它签发的全部令牌。
+import crypto from 'node:crypto'
+import { eq } from 'drizzle-orm'
+import type { BrowserTokenDto } from '@agent-server/shared'
+import type { DB } from '../db'
+import { settings as settingsTable } from '../db/schema'
+
+const SECRET_KEY = 'browser_token_secret'
+const PREFIX = 'bt_'
+export const BROWSER_TOKEN_DEFAULT_TTL = 3600
+export const BROWSER_TOKEN_MAX_TTL = 12 * 3600
+
+export class BrowserTokenService {
+  private secret: Buffer
+
+  constructor(private db: DB) {
+    const row = db.select().from(settingsTable).where(eq(settingsTable.key, SECRET_KEY)).get()
+    if (row) {
+      this.secret = Buffer.from(row.value as string, 'base64')
+    } else {
+      this.secret = crypto.randomBytes(32)
+      db.insert(settingsTable).values({ key: SECRET_KEY, value: this.secret.toString('base64') }).run()
+    }
+  }
+
+  static isBrowserToken(token: string) {
+    return token.startsWith(PREFIX)
+  }
+
+  issue(apiKeyId: string, ttlSeconds = BROWSER_TOKEN_DEFAULT_TTL, now = Date.now()): BrowserTokenDto {
+    const ttl = Math.min(Math.max(60, Math.floor(ttlSeconds)), BROWSER_TOKEN_MAX_TTL)
+    const exp = Math.floor(now / 1000) + ttl
+    const payload = Buffer.from(JSON.stringify({ k: apiKeyId, e: exp, n: crypto.randomBytes(6).toString('base64url') })).toString('base64url')
+    return { token: `${PREFIX}${payload}.${this.sign(payload)}`, expiresAt: new Date(exp * 1000).toISOString() }
+  }
+
+  /** 返回签发它的 API Key id;签名不符或已过期返回 null(Key 是否启用由调用方检查) */
+  verify(token: string, now = Date.now()): string | null {
+    if (!token.startsWith(PREFIX)) return null
+    const [payload, sig] = token.slice(PREFIX.length).split('.')
+    if (!payload || !sig) return null
+    const expected = Buffer.from(this.sign(payload))
+    const given = Buffer.from(sig)
+    if (expected.length !== given.length || !crypto.timingSafeEqual(expected, given)) return null
+    try {
+      const p = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')) as { k?: string; e?: number }
+      if (typeof p.k !== 'string' || typeof p.e !== 'number' || p.e * 1000 <= now) return null
+      return p.k
+    } catch {
+      return null
+    }
+  }
+
+  private sign(payload: string) {
+    return crypto.createHmac('sha256', this.secret).update(payload).digest('base64url')
+  }
+}

+ 16 - 5
apps/server/src/services/catalog.ts

@@ -258,13 +258,24 @@ export class ApiKeyService {
   verify(key: string): string | null {
     const row = this.db.select().from(apiKeys).where(and(eq(apiKeys.keyHash, hashKey(key)), eq(apiKeys.enabled, true))).get()
     if (!row) return null
-    // 最近使用时间每分钟最多写一次
+    this.touch(row.id)
+    return row.id
+  }
+
+  /** 浏览器令牌鉴权时检查签发它的 Key 仍然存在且启用 */
+  isActive(id: string): boolean {
+    const row = this.db.select({ id: apiKeys.id }).from(apiKeys).where(and(eq(apiKeys.id, id), eq(apiKeys.enabled, true))).get()
+    if (row) this.touch(id)
+    return !!row
+  }
+
+  /** 最近使用时间每分钟最多写一次 */
+  private touch(id: string) {
     const now = Date.now()
-    if ((this.lastTouched.get(row.id) ?? 0) < now - 60_000) {
-      this.lastTouched.set(row.id, now)
-      this.db.update(apiKeys).set({ lastUsedAt: nowIso() }).where(eq(apiKeys.id, row.id)).run()
+    if ((this.lastTouched.get(id) ?? 0) < now - 60_000) {
+      this.lastTouched.set(id, now)
+      this.db.update(apiKeys).set({ lastUsedAt: nowIso() }).where(eq(apiKeys.id, id)).run()
     }
-    return row.id
   }
 }
 

+ 2 - 1
apps/server/src/services/settings.ts

@@ -22,7 +22,7 @@ export function defaultSettings(): AppSettings {
       nodeModules: path.join(shared, 'runtime/node/node_modules'),
       matplotlibrc: path.join(shared, 'runtime/matplotlibrc'),
     },
-    security: { globalDeny: [], defaultFileAccessMode: 'restricted' },
+    security: { globalDeny: [], defaultFileAccessMode: 'restricted', corsOrigins: [] },
     network: { allowedDomains: ['pypi.org', 'files.pythonhosted.org'], blockPrivateFetch: true },
     limits: {
       concurrency: { global: 4, claude: 2, codex: 2 },
@@ -57,6 +57,7 @@ const settingsSchema: z.ZodType<AppSettings> = z.object({
   security: z.object({
     globalDeny: z.array(z.string().min(1)),
     defaultFileAccessMode: z.enum(['restricted', 'broad']),
+    corsOrigins: z.array(z.string().regex(/^https?:\/\/[a-z0-9.-]+(:\d+)?$/i, '来源格式应为 https://host[:port],不带路径')),
   }),
   network: z.object({ allowedDomains: z.array(domain), blockPrivateFetch: z.boolean() }),
   limits: z.object({

+ 48 - 0
apps/server/test/api.test.ts

@@ -331,3 +331,51 @@ describe('用量统计', () => {
     expect(s.bySource[0]!.name).toMatch(/已删除的 Key/)
   })
 })
+
+describe('浏览器令牌与 CORS', () => {
+  it('API Key 签发令牌;令牌可调用 /v1,但不能再签发;停用 Key 后令牌失效', async () => {
+    const builtin = app.profiles.getBuiltin().id
+    const k = app.apiKeys.create('browser-test', { allowedProfileIds: [builtin], defaultProfileId: builtin })
+    const issued = await server.inject({ method: 'POST', url: '/v1/browser-tokens', headers: { authorization: `Bearer ${k.key}` }, payload: { ttlSeconds: 600 } })
+    expect(issued.statusCode).toBe(201)
+    const { token, expiresAt } = issued.json()
+    expect(token).toMatch(/^bt_/)
+    expect(Date.parse(expiresAt) - Date.now()).toBeGreaterThan(590_000)
+    const bt = { authorization: `Bearer ${token}` }
+
+    const created = await server.inject({ method: 'POST', url: '/v1/sessions', headers: bt, payload: {} })
+    expect(created.statusCode).toBe(201)
+    // 令牌创建的会话归属于签发它的 Key
+    const list = (await server.inject({ method: 'GET', url: '/v1/sessions', headers: { authorization: `Bearer ${k.key}` } })).json()
+    expect(list.items.map((s: { id: string }) => s.id)).toContain(created.json().id)
+    expect((await server.inject({ method: 'POST', url: '/v1/browser-tokens', headers: bt, payload: {} })).statusCode).toBe(403)
+    expect((await server.inject({ method: 'POST', url: '/v1/browser-tokens', headers: { authorization: `Bearer ${k.key}` }, payload: { ttlSeconds: 999999 } })).statusCode).toBe(400)
+    // 篡改、过期
+    expect((await server.inject({ method: 'GET', url: '/v1/profiles', headers: { authorization: `Bearer ${token.slice(0, -2)}xx` } })).statusCode).toBe(401)
+    const old = app.browserTokens.issue(k.apiKey.id, 60, Date.now() - 120_000)
+    expect((await server.inject({ method: 'GET', url: '/v1/profiles', headers: { authorization: `Bearer ${old.token}` } })).statusCode).toBe(401)
+
+    app.apiKeys.setEnabled(k.apiKey.id, false)
+    const after = await server.inject({ method: 'GET', url: '/v1/profiles', headers: bt })
+    expect(after.statusCode).toBe(401)
+    expect(after.json().error.code).toBe('invalid_token')
+  })
+
+  it('只对设置中允许的来源返回 CORS 头;预检请求;不对后台开放', async () => {
+    app.settings.update({ security: { corsOrigins: ['https://tools.example.com'] } })
+    const pre = await server.inject({ method: 'OPTIONS', url: '/v1/sessions', headers: { origin: 'https://tools.example.com', 'access-control-request-method': 'POST' } })
+    expect(pre.statusCode).toBe(204)
+    expect(pre.headers['access-control-allow-origin']).toBe('https://tools.example.com')
+    expect(pre.headers['access-control-allow-headers']).toContain('last-event-id')
+    expect(pre.headers['access-control-allow-credentials']).toBeUndefined()
+
+    const ok = await server.inject({ method: 'GET', url: '/v1/profiles', headers: { ...auth(), origin: 'https://tools.example.com' } })
+    expect(ok.headers['access-control-allow-origin']).toBe('https://tools.example.com')
+    const other = await server.inject({ method: 'GET', url: '/v1/profiles', headers: { ...auth(), origin: 'https://evil.example.com' } })
+    expect(other.headers['access-control-allow-origin']).toBeUndefined()
+    const adminRoute = await server.inject({ method: 'GET', url: '/admin/v1/overview', headers: { origin: 'https://tools.example.com' } })
+    expect(adminRoute.headers['access-control-allow-origin']).toBeUndefined()
+    expect(() => app.settings.update({ security: { corsOrigins: ['https://x.com/path'] } })).toThrow()
+    app.settings.update({ security: { corsOrigins: [] } })
+  })
+})

+ 33 - 0
apps/server/test/client-sdk.test.ts

@@ -0,0 +1,33 @@
+import { describe, expect, it } from 'vitest'
+import { AgentClient, browserTokenProvider } from '../../../packages/client/src/index'
+
+describe('客户端 SDK:浏览器令牌', () => {
+  it('缓存令牌、并发共用一次获取、过期前 60 秒换新', async () => {
+    let calls = 0
+    let ttl = 3600_000
+    const provider = browserTokenProvider(async () => {
+      calls++
+      await new Promise(r => setTimeout(r, 10))
+      return { token: `bt_${calls}`, expiresAt: new Date(Date.now() + ttl).toISOString() }
+    })
+    const [a, b] = await Promise.all([provider({ refresh: false }), provider({ refresh: false })])
+    expect([a, b, calls]).toEqual(['bt_1', 'bt_1', 1])
+    expect(await provider({ refresh: false })).toBe('bt_1')
+    expect(await provider({ refresh: true })).toBe('bt_2')
+    ttl = 30_000 // 快过期的令牌下次使用时换新
+    expect(await provider({ refresh: true })).toBe('bt_3')
+    expect(await provider({ refresh: false })).toBe('bt_4')
+  })
+
+  it('401 时刷新令牌并重试一次;不携带跨域 Cookie', async () => {
+    const seen: { auth: string | null; credentials: RequestInit['credentials'] }[] = []
+    const fetchImpl = (async (_url: string, init: RequestInit) => {
+      const auth = (init.headers as Record<string, string>).authorization ?? null
+      seen.push({ auth, credentials: init.credentials })
+      return auth === 'Bearer bt_new' ? new Response('[]', { status: 200 }) : new Response(JSON.stringify({ error: { code: 'invalid_token', message: 'x' } }), { status: 401 })
+    }) as typeof fetch
+    const client = new AgentClient({ baseUrl: 'https://agent.example.com', fetch: fetchImpl, getToken: ({ refresh }) => (refresh ? 'bt_new' : 'bt_old') })
+    expect(await client.profiles()).toEqual([])
+    expect(seen).toEqual([{ auth: 'Bearer bt_old', credentials: 'same-origin' }, { auth: 'Bearer bt_new', credentials: 'same-origin' }])
+  })
+})

+ 4 - 0
deploy/deploy.sh

@@ -7,6 +7,7 @@ ROOT=/srv/agent-server
 
 pnpm install --frozen-lockfile
 pnpm -r build
+node scripts/build-sdk.mjs
 
 STAGE=$(mktemp -d)
 trap 'rm -rf "$STAGE"' EXIT
@@ -14,6 +15,9 @@ pnpm --filter @agent-server/server deploy --prod "$STAGE/server" >/dev/null
 
 sudo rsync -a --delete "$STAGE/server/" "$ROOT/app/server/"
 sudo rsync -a --delete apps/admin/dist/ "$ROOT/app/admin/"
+# SDK 包保留旧版本(业务平台可能锁定在旧版本),只更新 index.json 指向最新
+sudo mkdir -p "$ROOT/app/sdk"
+sudo rsync -a dist-sdk/ "$ROOT/app/sdk/"
 sudo rsync -a --delete skills/ "$ROOT/skills/"
 sudo chown -R root:root "$ROOT/app" "$ROOT/skills"
 sudo chmod -R a+rX "$ROOT/app" "$ROOT/skills"

+ 2 - 0
docs/README.md

@@ -9,6 +9,7 @@ AgentServer 是一个自托管的 Agent 服务:在服务端接入 **Claude Cod
 ```
 docs/
 ├─ README.md                  本文件:索引、术语、文档约定
+├─ integration.md             业务平台接入指南(SDK 安装、浏览器令牌、<AgentChat> 组件)
 ├─ design/                    设计文档(随设计演进持续更新)
 │  ├─ 01-requirements.md      需求与范围
 │  ├─ 02-architecture.md      总体架构、模块划分、运行时模型
@@ -47,6 +48,7 @@ docs/
 | [0016](adr/0016-official-skill-repos-per-engine.md) | 每个引擎接入各自厂商的官方技能仓库 | Accepted |
 | [0017](adr/0017-community-skills.md) | 第三方技能:skills.sh 目录 + GitHub 固定提交,两个引擎共用 | Accepted |
 | [0018](adr/0018-codex-curated-plugins-playground-only.md) | Codex 插件(openai-curated)只在 Playground 中使用 | Accepted |
+| [0019](adr/0019-browser-tokens-and-sdk-distribution.md) | 浏览器令牌、CORS 与 SDK 包分发 | Accepted |
 
 ## 调研
 

+ 35 - 0
docs/adr/0019-browser-tokens-and-sdk-distribution.md

@@ -0,0 +1,35 @@
+# ADR-0019 浏览器令牌、CORS 与 SDK 包分发
+
+- 状态:Accepted · 日期:2026-10-23 · 补充 ADR-0015
+
+## 背景
+业务平台(Vue + TS + UnoCSS)需要在浏览器里直接对话:流式事件、审批、上传文件。原设计在前端直接使用 API Key,这样任何能打开平台页面的人都能从开发者工具中拿到 Key 并长期使用。另外,`@agent-server/client` / `chat-ui` 只是仓库内的 TS 源码,平台项目无法直接安装。
+
+## 决策
+- **浏览器令牌**:平台后端用 API Key 调 `POST /v1/browser-tokens` 换取短期令牌 `bt_…`,交给浏览器使用。
+  - 默认 1 小时,最长 12 小时
+  - 令牌无状态:HMAC-SHA256 签名,载荷含签发它的 Key id、过期时间和随机数;密钥随机生成,存在数据库 settings 中,服务重启后仍然有效
+  - 每次使用都检查 Key 仍然启用,停用或删除 Key 即作废它签发的全部令牌
+  - 浏览器令牌不能再签发令牌;会话、用量都归属于签发它的 Key
+  - 平台后端应先校验自己的登录态,只为已登录用户签发
+- **客户端**:
+  - `AgentClient` 新增 `getToken`
+  - `browserTokenProvider` 负责缓存令牌、过期前 60 秒换新、合并并发请求;遇到 401 时刷新令牌并重试一次
+  - 请求使用 `credentials: 'same-origin'`:后台同源时继续使用 Cookie,跨域时不带 Cookie
+- **CORS**:
+  - 只对 `/v1` 开放给后台"设置 › 安全 › CORS 来源"中逐个列出的地址(`https://host[:port]`)
+  - 不返回 `Access-Control-Allow-Credentials`
+  - `/admin/v1`、`/mcp` 不开放
+  - SSE 响应在接管连接后同样带上 CORS 头
+- **SDK 分发**:`scripts/build-sdk.mjs` 构建三个包,部署时发布到服务的 `/sdk/`(公开,不含密钥),平台用 URL 安装。
+  - 三个包:`@agent-server/shared`(类型与常量)、`@agent-server/client`、`@agent-server/chat-ui`(ESM + 类型声明 + 编译好的 `style.css`)
+  - 包之间是对等依赖(peerDependencies),平台同时安装三个包,pnpm 按包名关联,不访问 npm 仓库
+  - 文件名带版本号,内容不可变(长期缓存);旧版本保留,平台可以锁定版本
+- **样式隔离**:chat-ui 使用 UnoCSS `vue-scoped` 模式,生成的原子类写入各组件的 scoped 样式,不与平台自己的 UnoCSS / Tailwind 类冲突。
+  - `style.css` 只包含 `--app-*` 设计变量,以及作用于 `.agent-chat` 内部的基础样式,不改动宿主页面的 `html` / `body`
+  - 平台可以覆盖 `--app-*` 变量调整配色;`<html data-theme="light|dark">` 可以指定亮/暗
+
+## 后果
+- API Key 只存在于平台后端,泄露面缩小到短期令牌
+- 没有后端的纯前端平台需要加一个最小的令牌接口(examples/platform-demo/server.mjs 约 30 行)
+- 升级 SDK:修改 packages/*/package.json 的版本号后部署,平台改安装 URL 中的版本号

+ 16 - 9
docs/design/04-api.md

@@ -4,6 +4,9 @@
 
 - 基础路径:`/v1`(业务)、`/admin/v1`(管理,仅后台会话可访问)、`/mcp`(MCP Gateway,仅 Run Token 可访问)
 - 鉴权:`Authorization: Bearer <api-key>`;每个上层平台一个 Key,不区分使用者
+- 浏览器中使用**浏览器令牌**(`bt_…`,ADR-0019):平台后端用 API Key 调 `POST /v1/browser-tokens { ttlSeconds? }` 换取(默认 1 小时,最长 12 小时),权限与签发它的 Key 相同,但不能再签发令牌;停用或删除 Key 即同时作废
+- 跨域:只对 `/v1` 开放给后台"设置 › 安全 › CORS 来源"中的地址,不携带 Cookie;`/admin/v1` 与 `/mcp` 不开放
+- SDK 包:`GET /sdk/index.json` 列出当前版本,`GET /sdk/<file>.tgz` 下载(公开,不含密钥,见 docs/integration.md)
 - 格式:JSON;时间为 ISO 8601;ID 为带前缀的 ULID(`ses_`、`run_`、`itx_`、`file_`、`prf_`)
 - 错误:`{ "error": { "code": "session_busy", "message": "...", "details": {} } }`
 
@@ -26,6 +29,7 @@
 | GET | `/v1/sessions/:id/stream` | **SSE**:订阅会话全部事件(推荐,一个会话一条连接) |
 | GET | `/v1/runs/:id` | Run 状态与用量 |
 | GET | `/v1/usage?days=30` | 本平台(调用者的 API Key)最近 N 天的用量:每日明细、按模型汇总 |
+| POST | `/v1/browser-tokens` | 平台后端用 API Key 签发浏览器令牌:`{ ttlSeconds? }` → `201 { token, expiresAt }` |
 | POST | `/v1/runs/:id/interrupt` | 中断 |
 
 ### 交互
@@ -116,16 +120,19 @@ type InteractionResponse =
 - 每 15s 发一次 `: ping` 注释帧保活
 - 浏览器原生 `EventSource` 无法设置 Authorization 头,因此 `@agent-server/client` 使用 `fetch` + `ReadableStream` 实现 SSE(支持自定义头和断线续传)
 
-## 6. 客户端 SDK 示意
+## 6. 客户端 SDK
 
-```ts
-import { AgentClient } from '@agent-server/client'
+完整的接入步骤与示例见 [docs/integration.md](../integration.md)。
 
-const client = new AgentClient({ baseUrl, apiKey })
+```ts
+import { AgentClient, browserTokenProvider } from '@agent-server/client'
+import { AgentChat, useChatSession } from '@agent-server/chat-ui'
+
+// 浏览器:令牌由平台后端换取,过期前自动刷新,401 时刷新重试一次
+const client = new AgentClient({
+  baseUrl: 'https://agent.example.com',
+  getToken: browserTokenProvider(() => fetch('/api/agent-token', { method: 'POST' }).then(r => r.json())),
+})
 const s = await client.sessions.create({ profileId: 'prf_data_analyst' })
-const file = await s.upload(screenshotBlob)
-s.on('event', ev => store.apply(ev))                 // 交给 chat-ui 的 store
-s.on('interaction', itx => ui.prompt(itx))
-await s.send({ text: '分析这张图和附件里的销售数据', attachments: [file.id] })
-await client.interactions.reply(itxId, { decision: 'allow_once' })
+const chat = useChatSession(client, s.id)     // 拉历史 + 订阅 SSE,交给 <AgentChat :client :session="chat">
 ```

+ 2 - 2
docs/design/11-roadmap.md

@@ -17,9 +17,9 @@
 2. 让 Agent 读取 `~/.ssh` 和 `~/.claude/.credentials.json`,通过 Read 工具和 Bash 两条路径都被拦截,并写入审计日志。
 3. 在后台新增一个资料库并在 Profile 中启用,Agent 能检索其中的内容;未启用的资料库读取会被拒绝。
 
-### M2:Codex 与客户端包(进行中:① Codex 接入 ✅、② MCP Gateway ✅、第三方技能(skills.sh,ADR-0017)✅、Codex openai-curated 插件目录(仅 Playground,ADR-0018)✅、④ 用量统计 ✅;之后:③ 业务平台接入)
+### M2:Codex 与客户端包(进行中:① Codex 接入 ✅、② MCP Gateway ✅、第三方技能(skills.sh,ADR-0017)✅、Codex openai-curated 插件目录(仅 Playground,ADR-0018)✅、④ 用量统计 ✅、③ 业务平台接入(浏览器令牌 + SDK 分发,ADR-0019,docs/integration.md)✅;之后:Profile 另存为与版本历史)
 - CodexAdapter:app-server 进程管理、个人配置覆盖与自检、协议类型生成、事件映射、审批桥接、权限配置映射
-- MCP Gateway 上线(内置 `web_fetch`、`kb_list_roots`、`ask_user`)
+- MCP Gateway 上线(内置 `convert_document`、`recalc_workbook`、`web_fetch`、`kb_list_roots`;`ask_user` 不再需要,两个引擎都有原生提问并已接入交互卡片)
 - Profile 管理(另存为、版本历史)
 - `packages/client` 与 `packages/chat-ui`,在一个上层平台中接入
 - 用量统计、概览页、审计日志页

+ 154 - 0
docs/integration.md

@@ -0,0 +1,154 @@
+# 业务平台接入指南
+
+把 AgentServer 的对话能力(Claude / Codex、流式输出、审批与提问、文件上传与产出)接入自己的平台(Vue 3 + TS)。可以直接对照完整的示例项目 [`examples/platform-demo`](../examples/platform-demo):它由一个 Vue 前端和一个约 30 行的 Node 后端组成。
+
+## 0. 工作方式
+
+```
+浏览器(平台前端)
+  │  ① POST /api/agent-token(平台自己的接口,校验平台登录态)
+  ▼
+平台后端 ── ② POST /v1/browser-tokens(携带 API Key)──► AgentServer
+  │  返回 { token: "bt_…", expiresAt }(默认 1 小时)
+  ▼
+浏览器 ── ③ 用 bt_ 令牌直接调用 AgentServer 的 /v1(跨域,SSE 流式)──► AgentServer
+```
+
+- **API Key 只放在平台后端**,不要写进前端代码或前端环境变量。
+- 浏览器令牌的权限与 API Key 相同:只能使用该 Key 可用的 Profile,只能看到该 Key 创建的会话。停用或删除 Key 后,令牌立即失效。
+- 一个平台一个 API Key,AgentServer 不区分平台内的具体用户。如果需要区分,在平台侧记录会话 ID 与用户的对应关系。
+
+## 1. 在 AgentServer 后台准备
+
+1. **API Keys › 签发 API Key**:勾选这个平台可以使用的 Profile,并设好默认 Profile。Key 只显示一次,存到平台后端的密钥配置里。
+2. **设置 › 安全 › CORS 来源**:加入平台前端的地址,例如 `https://tools.example.com`(只写协议、域名和端口,不带路径)。
+3. **API Keys** 页面底部的"业务平台接入"卡片里有当前 SDK 版本的安装命令,可以直接复制。
+
+## 2. 平台后端:签发浏览器令牌
+
+任何语言都可以实现,只需要转发一次请求。Node 示例:
+
+```ts
+// POST /api/agent-token —— 先校验平台自己的登录态
+app.post('/api/agent-token', requireLogin, async (req, res) => {
+  const r = await fetch(`${process.env.AGENT_SERVER_URL}/v1/browser-tokens`, {
+    method: 'POST',
+    headers: { authorization: `Bearer ${process.env.AGENT_API_KEY}`, 'content-type': 'application/json' },
+    body: JSON.stringify({ ttlSeconds: 3600 }),   // 60 ~ 43200,默认 3600
+  })
+  res.status(r.status).json(await r.json())        // { token, expiresAt }
+})
+```
+
+如果后端也用 Node,可以改用 SDK:`new AgentClient({ baseUrl, apiKey }).issueBrowserToken(3600)`。
+
+## 3. 前端:安装 SDK
+
+SDK 由 AgentServer 自己提供,三个包需要一起安装:
+
+```bash
+pnpm add https://agent.zenpower.cc/sdk/agent-server-shared-0.2.0.tgz \
+         https://agent.zenpower.cc/sdk/agent-server-client-0.2.0.tgz \
+         https://agent.zenpower.cc/sdk/agent-server-chat-ui-0.2.0.tgz
+```
+
+| 包 | 内容 |
+|---|---|
+| `@agent-server/shared` | API 与事件的类型、常量 |
+| `@agent-server/client` | `AgentClient`:会话、消息、SSE 订阅(断线续传)、交互回复、上传下载 |
+| `@agent-server/chat-ui` | 对话组件 `<AgentChat>` 与状态 `useChatSession`(依赖 `vue@^3.5`) |
+
+最新版本号见 `https://agent.zenpower.cc/sdk/index.json`。升级时修改上面 URL 中的版本号即可,旧版本会一直保留。
+
+## 4. 前端:使用对话组件
+
+```ts
+// main.ts:只引入设计变量和组件自身的样式,不会改动平台页面的其他样式
+import '@agent-server/chat-ui/style.css'
+```
+
+```vue
+<script setup lang="ts">
+import { onBeforeUnmount, shallowRef } from 'vue'
+import { AgentClient, browserTokenProvider } from '@agent-server/client'
+import { AgentChat, useChatSession, type ChatSession } from '@agent-server/chat-ui'
+
+const client = new AgentClient({
+  baseUrl: 'https://agent.zenpower.cc',
+  // 缓存令牌、过期前自动换新;令牌失效(401)时会刷新并重试一次
+  getToken: browserTokenProvider(async () => {
+    const r = await fetch('/api/agent-token', { method: 'POST' })
+    if (!r.ok) throw new Error('获取令牌失败')
+    return r.json()
+  }),
+})
+
+const chat = shallowRef<ChatSession | null>(null)
+async function newChat() {
+  chat.value?.dispose()
+  const s = await client.sessions.create()          // 可传 { profileId, model, title }
+  chat.value = useChatSession(client, s.id)          // 拉取历史事件 + 订阅实时事件
+}
+onBeforeUnmount(() => chat.value?.dispose())
+</script>
+
+<template>
+  <div style="height: 100vh">
+    <AgentChat v-if="chat" :client="client" :session="chat" placeholder="问点什么…" />
+  </div>
+</template>
+```
+
+`<AgentChat>` 会占满父元素的高度,请给它一个确定高度的容器。
+
+| 属性 / 插槽 / 事件 | 说明 |
+|---|---|
+| `client`、`session` | 必填:`AgentClient` 实例和 `useChatSession()` 的返回值 |
+| `readonly` | 只读回放:不显示输入框,不能回复审批 |
+| `placeholder` | 输入框提示文字 |
+| `#toolbar` 插槽 | 输入框底部工具栏,可放模型选择等 |
+| `#empty` 插槽 | 没有消息时显示的内容 |
+| `@open-file` | 点击文件卡片时触发(`{ fileId, path, mime, size }`),用于自己的预览。不监听时直接下载文件 |
+
+组件已经处理了:
+- 流式文本与思考过程
+- 工具调用卡片
+- 审批与提问卡片
+- 上传附件(点击、拖拽、粘贴截图)
+- Agent 产出文件的下载
+- 中断运行
+- 断线自动重连
+
+## 5. 常用接口
+
+```ts
+await client.profiles()                                   // 本平台可用的 Profile
+await client.sessions.list({ limit: 30 })                 // 本平台的会话(分页:nextCursor)
+await client.sessions.update(id, { title: '…', archived: true })
+await client.sessions.remove(id)                          // 删除会话及其工作区
+await client.sessions.files(id)                           // 会话中的全部文件
+await client.usage(30)                                    // 本平台最近 30 天的用量
+```
+
+不使用 `<AgentChat>`、自己渲染界面时:
+- 用 `client.subscribe(sessionId, { onEvent })` 订阅事件
+- 用 `client.sessions.send(id, { text, attachments })` 发送消息
+- 用 `client.interactions.reply(itxId, …)` 回复审批和提问
+
+事件类型见 [design/04-api.md](design/04-api.md)。`useChatSession` 的 `state` 也可以单独使用,它是已经整理好的时间线。
+
+## 6. 外观
+
+- 亮色/暗色默认跟随系统;在 `<html>` 上设置 `data-theme="light"` 或 `"dark"` 可以强制指定。
+- 配色来自 `--app-*` CSS 变量,平台可以在自己的样式里覆盖,例如 `:root { --app-button-background: #2563eb; }`。完整列表见 `@agent-server/ui-tokens` 的 `variables.css`。
+- 组件的原子类已经编译并隔离在组件内部,平台不需要在 UnoCSS 中扫描 SDK,也不需要引入 AgentServer 的 UnoCSS 预设。
+
+## 7. 常见问题
+
+| 现象 | 原因与处理 |
+|---|---|
+| 浏览器控制台报 CORS 错误 | 平台地址没有加入"设置 › 安全 › CORS 来源",或协议、端口写得不一致 |
+| 所有请求都返回 401 `invalid_token` | 令牌过期后刷新失败:检查平台的 `/api/agent-token` 接口,以及 API Key 是否已被停用 |
+| 403 `profile_not_allowed` | 创建会话时指定的 Profile 不在该 Key 的可用列表中 |
+| 流式输出一段一段地出现,或很久才出现 | 平台与 AgentServer 之间有反向代理在缓冲响应:对 `/v1/sessions/*/stream` 关闭缓冲(nginx:`proxy_buffering off`) |
+| 安装时报找不到 `@agent-server/shared` | 三个包要用 URL 一起安装(它们是对等依赖,不在 npm 仓库中) |

+ 14 - 0
examples/platform-demo/README.md

@@ -0,0 +1,14 @@
+# 示例平台:接入 AgentServer
+
+演示业务平台的完整接入方式(详见 `docs/integration.md`):
+
+- 前端安装 AgentServer 提供的 SDK 包,使用 `<AgentChat>` 组件
+- 平台后端(`server.mjs`)保存 API Key,为浏览器换取短期令牌
+
+```bash
+# 1. 安装(把 AGENT_SERVER_URL 换成实际地址,例如 https://agent.zenpower.cc)
+sed -i 's#AGENT_SERVER_URL#https://agent.zenpower.cc#' package.json && pnpm install
+# 2. 在 AgentServer 后台:签发 API Key;设置 › 安全 › CORS 来源中加入本平台的地址
+# 3. 构建并启动
+pnpm build && AGENT_SERVER_URL=https://agent.zenpower.cc AGENT_API_KEY=ask_xxx pnpm start
+```

+ 13 - 0
examples/platform-demo/index.html

@@ -0,0 +1,13 @@
+<!doctype html>
+<html lang="zh-CN">
+  <head>
+    <meta charset="UTF-8" />
+    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+    <title>示例平台 · AgentServer 接入</title>
+    <style>body { margin: 0; }</style>
+  </head>
+  <body>
+    <div id="app"></div>
+    <script type="module" src="/src/main.ts"></script>
+  </body>
+</html>

+ 24 - 0
examples/platform-demo/package.json

@@ -0,0 +1,24 @@
+{
+  "name": "agent-server-platform-demo",
+  "private": true,
+  "type": "module",
+  "scripts": {
+    "dev": "vite",
+    "build": "vite build",
+    "start": "node server.mjs",
+    "typecheck": "vue-tsc --noEmit"
+  },
+  "dependencies": {
+    "@agent-server/chat-ui": "AGENT_SERVER_URL/sdk/agent-server-chat-ui-0.2.0.tgz",
+    "@agent-server/client": "AGENT_SERVER_URL/sdk/agent-server-client-0.2.0.tgz",
+    "@agent-server/shared": "AGENT_SERVER_URL/sdk/agent-server-shared-0.2.0.tgz",
+    "vue": "^3.5.44"
+  },
+  "devDependencies": {
+    "@vitejs/plugin-vue": "^6.0.10",
+    "unocss": "^66.11.0",
+    "vite": "^8.3.5",
+    "vue-tsc": "^3.3.13",
+    "typescript": "^5.9.3"
+  }
+}

+ 36 - 0
examples/platform-demo/server.mjs

@@ -0,0 +1,36 @@
+// 业务平台后端示例(无依赖):提供构建好的前端,并用 API Key 为浏览器换取短期令牌。
+// API Key 只存在于后端环境变量中,浏览器拿到的是 1 小时有效的浏览器令牌。
+// 真实平台应先校验自己的登录态(只给已登录用户签发令牌)。
+import fs from 'node:fs'
+import http from 'node:http'
+import path from 'node:path'
+import { fileURLToPath } from 'node:url'
+
+const AGENT_SERVER_URL = process.env.AGENT_SERVER_URL ?? 'http://127.0.0.1:8787'
+const AGENT_API_KEY = process.env.AGENT_API_KEY
+const PORT = Number(process.env.PORT ?? 5180)
+const dist = path.join(path.dirname(fileURLToPath(import.meta.url)), 'dist')
+if (!AGENT_API_KEY) throw new Error('请设置环境变量 AGENT_API_KEY')
+
+const types = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript', '.css': 'text/css', '.svg': 'image/svg+xml' }
+
+http.createServer(async (req, res) => {
+  if (req.method === 'POST' && req.url === '/api/agent-token') {
+    // 在这里校验平台自己的登录态
+    const r = await fetch(`${AGENT_SERVER_URL}/v1/browser-tokens`, {
+      method: 'POST',
+      headers: { authorization: `Bearer ${AGENT_API_KEY}`, 'content-type': 'application/json' },
+      body: JSON.stringify({ ttlSeconds: 3600 }),
+    })
+    res.writeHead(r.status, { 'content-type': 'application/json' })
+    return res.end(await r.text())
+  }
+  if (req.url === '/api/config') {
+    res.writeHead(200, { 'content-type': 'application/json' })
+    return res.end(JSON.stringify({ agentServerUrl: process.env.AGENT_SERVER_PUBLIC_URL ?? AGENT_SERVER_URL }))
+  }
+  const file = path.join(dist, path.normalize(decodeURIComponent(req.url.split('?')[0])).replace(/^(\.\.[/\\])+/, ''))
+  const target = fs.existsSync(file) && fs.statSync(file).isFile() ? file : path.join(dist, 'index.html')
+  res.writeHead(200, { 'content-type': types[path.extname(target)] ?? 'application/octet-stream' })
+  fs.createReadStream(target).pipe(res)
+}).listen(PORT, '127.0.0.1', () => console.log(`示例平台:http://127.0.0.1:${PORT}(AgentServer:${AGENT_SERVER_URL})`))

+ 75 - 0
examples/platform-demo/src/App.vue

@@ -0,0 +1,75 @@
+<script setup lang="ts">
+import { onBeforeUnmount, onMounted, ref, shallowRef } from 'vue'
+import { AgentClient, browserTokenProvider, type ProfileSummary, type SessionDto } from '@agent-server/client'
+import { AgentChat, useChatSession, type ChatSession } from '@agent-server/chat-ui'
+
+const client = shallowRef<AgentClient | null>(null)
+const profiles = ref<ProfileSummary[]>([])
+const sessions = ref<SessionDto[]>([])
+const profileId = ref('')
+const current = ref<string | null>(null)
+const chat = shallowRef<ChatSession | null>(null)
+const error = ref<string | null>(null)
+
+async function refreshSessions() {
+  sessions.value = (await client.value!.sessions.list({ limit: 30 })).items
+}
+
+async function open(id: string) {
+  chat.value?.dispose()
+  current.value = id
+  chat.value = useChatSession(client.value!, id)
+}
+
+async function create() {
+  const s = await client.value!.sessions.create({ profileId: profileId.value || undefined })
+  await refreshSessions()
+  await open(s.id)
+}
+
+onMounted(async () => {
+  try {
+    const { agentServerUrl } = await (await fetch('/api/config')).json()
+    // 浏览器令牌:由平台后端用 API Key 换取,过期前自动刷新
+    const getToken = browserTokenProvider(async () => {
+      const r = await fetch('/api/agent-token', { method: 'POST' })
+      if (!r.ok) throw new Error(`获取令牌失败(HTTP ${r.status})`)
+      return r.json()
+    })
+    client.value = new AgentClient({ baseUrl: agentServerUrl, getToken })
+    profiles.value = await client.value.profiles()
+    profileId.value = profiles.value.find(p => p.isDefault)?.id ?? profiles.value[0]?.id ?? ''
+    await refreshSessions()
+  } catch (err) {
+    error.value = (err as Error).message
+  }
+})
+onBeforeUnmount(() => chat.value?.dispose())
+</script>
+
+<template>
+  <div class="h-screen flex font-sans text-[14px] text-gray-800 bg-gray-50">
+    <aside class="w-64 flex-none border-r border-gray-200 bg-white flex flex-col">
+      <div class="p-3 border-b border-gray-200 font-600">示例平台</div>
+      <div class="p-3 flex flex-col gap-2">
+        <select v-model="profileId" class="h-8 px-2 rounded border border-gray-300" aria-label="助手">
+          <option v-for="p in profiles" :key="p.id" :value="p.id">{{ p.name }}</option>
+        </select>
+        <button type="button" class="h-8 rounded bg-blue-600 text-white border-none cursor-pointer" :disabled="!client" @click="create">新建对话</button>
+      </div>
+      <div class="flex-1 overflow-y-auto">
+        <button
+          v-for="s in sessions" :key="s.id" type="button"
+          class="block w-full text-left px-3 py-2 border-none cursor-pointer truncate"
+          :class="s.id === current ? 'bg-blue-50 text-blue-700' : 'bg-transparent hover:bg-gray-100'"
+          @click="open(s.id)"
+        >{{ s.title || '新会话' }}</button>
+      </div>
+    </aside>
+    <main class="flex-1 min-w-0">
+      <div v-if="error" class="p-6 text-red-600">{{ error }}</div>
+      <AgentChat v-else-if="chat && client" :key="current!" :client="client" :session="chat" placeholder="问点什么,或上传文件让助手分析…" />
+      <div v-else class="h-full flex items-center justify-center text-gray-400">选择或新建一个对话</div>
+    </main>
+  </div>
+</template>

+ 2 - 0
examples/platform-demo/src/env.d.ts

@@ -0,0 +1,2 @@
+/// <reference types="vite/client" />
+declare module 'virtual:uno.css'

+ 7 - 0
examples/platform-demo/src/main.ts

@@ -0,0 +1,7 @@
+import 'virtual:uno.css'
+// 只有设计令牌变量与对话组件自身的样式,不会改动平台页面的其他样式
+import '@agent-server/chat-ui/style.css'
+import { createApp } from 'vue'
+import App from './App.vue'
+
+createApp(App).mount('#app')

+ 14 - 0
examples/platform-demo/tsconfig.json

@@ -0,0 +1,14 @@
+{
+  "compilerOptions": {
+    "target": "ES2022",
+    "module": "ESNext",
+    "moduleResolution": "Bundler",
+    "strict": true,
+    "jsx": "preserve",
+    "lib": ["ES2022", "DOM", "DOM.Iterable"],
+    "types": ["vite/client"],
+    "skipLibCheck": false,
+    "noEmit": true
+  },
+  "include": ["src/**/*.ts", "src/**/*.vue"]
+}

+ 4 - 0
examples/platform-demo/uno.config.ts

@@ -0,0 +1,4 @@
+import { defineConfig, presetWind3 } from 'unocss'
+
+// 平台自己的 UnoCSS 配置;chat-ui 的样式已经编译好,不需要在这里扫描或引入它的预设
+export default defineConfig({ presets: [presetWind3()] })

+ 9 - 0
examples/platform-demo/vite.config.ts

@@ -0,0 +1,9 @@
+import vue from '@vitejs/plugin-vue'
+import UnoCSS from 'unocss/vite'
+import { defineConfig } from 'vite'
+
+export default defineConfig({
+  plugins: [vue(), UnoCSS()],
+  // 开发时把 /api 转给 server.mjs
+  server: { port: 5181, proxy: { '/api': 'http://127.0.0.1:5180' } },
+})

+ 4 - 1
packages/chat-ui/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@agent-server/chat-ui",
-  "version": "0.1.0",
+  "version": "0.2.0",
   "private": true,
   "type": "module",
   "exports": {
@@ -21,7 +21,10 @@
   },
   "devDependencies": {
     "@types/markdown-it": "^14.2.0",
+    "@vitejs/plugin-vue": "^6.0.10",
     "typescript": "^5.9.3",
+    "unocss": "^66.11.0",
+    "vite": "^8.3.5",
     "vue": "^3.5.44",
     "vue-tsc": "^3.3.13"
   }

+ 11 - 3
packages/chat-ui/src/components/AgentChat.vue

@@ -1,8 +1,8 @@
 <script setup lang="ts">
-import { computed, nextTick, onMounted, ref, watch } from 'vue'
+import { computed, getCurrentInstance, nextTick, onMounted, ref, watch } from 'vue'
 import type { AgentClient } from '@agent-server/client'
 import type { FileDto } from '@agent-server/shared'
-import { provideClient } from '../context'
+import { downloadFile, provideClient } from '../context'
 import type { ChatSession } from '../store'
 import Composer from './Composer.vue'
 import Timeline from './Timeline.vue'
@@ -11,6 +11,14 @@ const props = defineProps<{ client: AgentClient; session: ChatSession; readonly?
 const emit = defineEmits<{ openFile: [file: { fileId: string; path: string; mime: string; size: number }] }>()
 provideClient(props.client)
 
+type OpenedFile = { fileId: string; path: string; mime: string; size: number }
+// 外部没有监听 openFile(例如业务平台没有自己的预览)时,点击文件直接下载
+const hasOpenListener = !!getCurrentInstance()?.vnode.props?.onOpenFile
+function openFile(f: OpenedFile) {
+  if (hasOpenListener) emit('openFile', f)
+  else void downloadFile(props.client, f.fileId, f.path.split('/').pop() ?? f.path)
+}
+
 const state = computed(() => props.session.state)
 const running = computed(() => state.value.runState !== 'idle')
 const scroller = ref<HTMLElement>()
@@ -61,7 +69,7 @@ defineExpose({ focus: () => composer.value?.focus(), setText: (t: string) => com
         <div v-else-if="!state.items.length" class="empty"><slot name="empty">开始一个新对话</slot></div>
         <Timeline
           v-else :items="state.items" :readonly="readonly"
-          :reply="readonly ? undefined : session.reply" @open-file="emit('openFile', $event)"
+          :reply="readonly ? undefined : session.reply" @open-file="openFile"
         />
         <div v-if="state.runState === 'queued'" class="working"><span class="codicon codicon-loading codicon-modifier-spin" />排队中…</div>
         <div v-else-if="state.runState === 'running'" class="working"><span class="codicon codicon-loading codicon-modifier-spin" />处理中…</div>

+ 1 - 0
packages/chat-ui/src/index.ts

@@ -1,4 +1,5 @@
 import '@vscode/codicons/dist/codicon.css'
+import './style.css'
 
 export { default as AgentChat } from './components/AgentChat.vue'
 export { default as Composer } from './components/Composer.vue'

+ 24 - 0
packages/chat-ui/src/style.css

@@ -0,0 +1,24 @@
+/* 业务平台引入的样式:只有设计令牌变量(不改动宿主页面的 html/body),组件自身的样式已按组件隔离 */
+@import '@agent-server/ui-tokens/variables.css';
+
+/* 宿主页面没有后台的全局基础样式,这里只对对话组件内部生效 */
+.agent-chat {
+  color: var(--app-primary-foreground);
+  font-family: var(--app-font-family);
+  font-size: var(--app-font-size);
+  line-height: 1.5;
+  -webkit-font-smoothing: antialiased;
+}
+.agent-chat *,
+.agent-chat *::before,
+.agent-chat *::after {
+  box-sizing: border-box;
+}
+.agent-chat :where(button, input, select, textarea) {
+  font: inherit;
+  color: inherit;
+}
+.agent-chat :focus-visible {
+  outline: 1px solid var(--app-input-active-border);
+  outline-offset: -1px;
+}

+ 6 - 0
packages/chat-ui/tsconfig.build.json

@@ -0,0 +1,6 @@
+{
+  "extends": "./tsconfig.json",
+  "compilerOptions": { "noEmit": false, "declaration": true, "emitDeclarationOnly": true, "outDir": "dist/types", "rootDir": "src" },
+  "include": ["src/**/*.ts", "src/**/*.vue"],
+  "exclude": ["src/env.d.ts"]
+}

+ 6 - 0
packages/chat-ui/uno.config.ts

@@ -0,0 +1,6 @@
+import { defineConfig, presetWind3 } from 'unocss'
+import { presetAgentTokens } from '@agent-server/ui-tokens'
+
+export default defineConfig({
+  presets: [presetWind3({ preflight: false }), presetAgentTokens()],
+})

+ 16 - 0
packages/chat-ui/vite.config.ts

@@ -0,0 +1,16 @@
+import vue from '@vitejs/plugin-vue'
+import UnoCSS from 'unocss/vite'
+import { defineConfig } from 'vite'
+
+// 发布给业务平台的库构建:UnoCSS 使用 vue-scoped 模式,生成的原子类写进各组件的 scoped 样式,
+// 不会与宿主平台自己的 UnoCSS / Tailwind 类冲突;所有样式汇总到 dist/style.css
+export default defineConfig({
+  plugins: [vue(), UnoCSS({ mode: 'vue-scoped' })],
+  build: {
+    lib: { entry: 'src/index.ts', formats: ['es'], fileName: 'index', cssFileName: 'style' },
+    rollupOptions: { external: ['vue', 'markdown-it', '@agent-server/client'] },
+    target: 'es2022',
+    outDir: 'dist',
+    emptyOutDir: true,
+  },
+})

+ 14 - 5
packages/client/package.json

@@ -1,10 +1,19 @@
 {
   "name": "@agent-server/client",
-  "version": "0.1.0",
+  "version": "0.2.0",
   "private": true,
   "type": "module",
-  "exports": { ".": "./src/index.ts" },
-  "scripts": { "typecheck": "tsc --noEmit" },
-  "dependencies": { "@agent-server/shared": "workspace:*" },
-  "devDependencies": { "typescript": "^5.9.3" }
+  "exports": {
+    ".": "./src/index.ts"
+  },
+  "scripts": {
+    "typecheck": "tsc --noEmit"
+  },
+  "dependencies": {
+    "@agent-server/shared": "workspace:*"
+  },
+  "devDependencies": {
+    "tsup": "^9.0.0",
+    "typescript": "^5.9.3"
+  }
 }

+ 49 - 10
packages/client/src/index.ts

@@ -3,6 +3,7 @@
 import type {
   AgentEvent,
   ApiErrorBody,
+  BrowserTokenDto,
   CreateSessionBody,
   FileDto,
   InteractionRequest,
@@ -12,6 +13,7 @@ import type {
   ProfileDto,
   ProviderStatusDto,
   RunDto,
+  UsageSummaryDto,
   SendMessageBody,
   SessionDetailDto,
   SessionDto,
@@ -20,8 +22,13 @@ import type {
 export interface AgentClientOptions {
   /** 服务地址,例如 https://agent.example.com;同源时可为空字符串 */
   baseUrl: string
-  /** API Key;后台同源使用 Cookie 时可省略 */
+  /** API Key:只在业务平台的后端使用,不要放进浏览器代码 */
   apiKey?: string
+  /**
+   * 浏览器中使用:返回浏览器令牌(由平台后端用 API Key 换取,见 browserTokenProvider)。
+   * 令牌失效(401)时会以 { refresh: true } 再调用一次并重试
+   */
+  getToken?: (opts: { refresh: boolean }) => string | Promise<string>
   fetch?: typeof fetch
 }
 
@@ -40,16 +47,29 @@ export class AgentClient {
     this.fetchImpl = opts.fetch ?? globalThis.fetch.bind(globalThis)
   }
 
-  private headers(extra?: Record<string, string>): Record<string, string> {
-    return { ...(this.opts.apiKey ? { authorization: `Bearer ${this.opts.apiKey}` } : {}), ...extra }
+  private async headers(extra?: Record<string, string>, refresh = false): Promise<Record<string, string>> {
+    const token = this.opts.getToken ? await this.opts.getToken({ refresh }) : this.opts.apiKey
+    return { ...(token ? { authorization: `Bearer ${token}` } : {}), ...extra }
+  }
+
+  /** 发请求;使用浏览器令牌时,令牌失效则刷新后重试一次。Cookie 只在同源(后台)时携带 */
+  private async send(path: string, init: RequestInit & { headers?: Record<string, string> }): Promise<Response> {
+    const go = async (refresh: boolean) => this.fetchImpl(`${this.opts.baseUrl}${path}`, {
+      ...init, credentials: 'same-origin', headers: await this.headers(init.headers, refresh),
+    })
+    const res = await go(false)
+    if (res.status === 401 && this.opts.getToken) {
+      await res.body?.cancel()
+      return go(true)
+    }
+    return res
   }
 
   async request<T>(method: string, path: string, body?: unknown): Promise<T> {
     const isForm = typeof FormData !== 'undefined' && body instanceof FormData
-    const res = await this.fetchImpl(`${this.opts.baseUrl}${path}`, {
+    const res = await this.send(path, {
       method,
-      credentials: 'include',
-      headers: this.headers(body !== undefined && !isForm ? { 'content-type': 'application/json' } : undefined),
+      headers: body !== undefined && !isForm ? { 'content-type': 'application/json' } : undefined,
       body: body === undefined ? undefined : isForm ? body : JSON.stringify(body),
     })
     if (!res.ok) {
@@ -61,6 +81,10 @@ export class AgentClient {
   }
 
   providers = () => this.request<ProviderStatusDto[]>('GET', '/v1/providers')
+  /** 平台后端调用(需要 API Key):签发交给浏览器使用的短期令牌 */
+  issueBrowserToken = (ttlSeconds?: number) => this.request<BrowserTokenDto>('POST', '/v1/browser-tokens', ttlSeconds === undefined ? {} : { ttlSeconds })
+  /** 本平台最近 N 天的用量 */
+  usage = (days = 30) => this.request<UsageSummaryDto>('GET', `/v1/usage?days=${days}`)
   profiles = () => this.request<ProfileSummary[]>('GET', '/v1/profiles')
 
   sessions = {
@@ -98,7 +122,7 @@ export class AgentClient {
 
   /** 带鉴权头下载文件为 Blob(API Key 模式下预览图片用) */
   async fileBlob(fileId: string): Promise<Blob> {
-    const res = await this.fetchImpl(this.fileUrl(fileId, true), { headers: this.headers(), credentials: 'include' })
+    const res = await this.send(`/v1/files/${fileId}?inline=1`, {})
     if (!res.ok) throw new AgentApiError(res.status, 'http_error', `HTTP ${res.status}`)
     return res.blob()
   }
@@ -114,9 +138,8 @@ export class AgentClient {
       while (!stopped) {
         controller = new AbortController()
         try {
-          const res = await this.fetchImpl(`${this.opts.baseUrl}/v1/sessions/${sessionId}/stream`, {
-            headers: this.headers({ accept: 'text/event-stream', 'last-event-id': String(lastSeq) }),
-            credentials: 'include',
+          const res = await this.send(`/v1/sessions/${sessionId}/stream`, {
+            headers: { accept: 'text/event-stream', 'last-event-id': String(lastSeq) },
             signal: controller.signal,
           })
           if (!res.ok || !res.body) {
@@ -152,6 +175,22 @@ export class AgentClient {
   }
 }
 
+/**
+ * 浏览器令牌缓存:fetchToken 调用平台自己的后端(后端再用 API Key 调 issueBrowserToken)。
+ * 在过期前 60 秒自动换新,并发请求共用同一次获取
+ */
+export function browserTokenProvider(fetchToken: () => Promise<BrowserTokenDto>): NonNullable<AgentClientOptions['getToken']> {
+  let current: BrowserTokenDto | null = null
+  let pending: Promise<BrowserTokenDto> | null = null
+  return async ({ refresh }) => {
+    const fresh = current && Date.parse(current.expiresAt) - Date.now() > 60_000
+    if (fresh && !refresh) return current!.token
+    pending ??= fetchToken().finally(() => { pending = null })
+    current = await pending
+    return current.token
+  }
+}
+
 export interface StreamHandlers {
   onEvent: (e: AgentEvent) => void
   onStatus?: (s: 'open' | 'reconnecting' | 'closed') => void

+ 10 - 0
packages/client/tsup.config.ts

@@ -0,0 +1,10 @@
+import { defineConfig } from 'tsup'
+
+// 发布给业务平台的构建(scripts/build-sdk.mjs):ESM + 类型声明,@agent-server/shared 作为对等依赖
+export default defineConfig({
+  entry: ['src/index.ts'],
+  format: ['esm'],
+  target: 'es2022',
+  dts: true,
+  clean: true,
+})

+ 11 - 4
packages/shared/package.json

@@ -1,9 +1,16 @@
 {
   "name": "@agent-server/shared",
-  "version": "0.1.0",
+  "version": "0.2.0",
   "private": true,
   "type": "module",
-  "exports": { ".": "./src/index.ts" },
-  "scripts": { "typecheck": "tsc --noEmit" },
-  "devDependencies": { "typescript": "^5.9.3" }
+  "exports": {
+    ".": "./src/index.ts"
+  },
+  "scripts": {
+    "typecheck": "tsc --noEmit"
+  },
+  "devDependencies": {
+    "tsup": "^9.0.0",
+    "typescript": "^5.9.3"
+  }
 }

+ 12 - 0
packages/shared/src/api.ts

@@ -366,3 +366,15 @@ export interface RecentErrorDto {
   error: string
   endedAt: string | null
 }
+
+// ---- 浏览器令牌(业务平台后端用 API Key 换取,交给浏览器使用,ADR-0019)----
+
+export interface CreateBrowserTokenBody {
+  /** 有效期(秒),默认 3600,最长 43200 */
+  ttlSeconds?: number
+}
+
+export interface BrowserTokenDto {
+  token: string
+  expiresAt: string
+}

+ 2 - 0
packages/shared/src/settings.ts

@@ -13,6 +13,8 @@ export interface AppSettings {
     /** 用户追加的全局禁止项;内置项见 BUILTIN_GLOBAL_DENY,不可删除 */
     globalDeny: string[]
     defaultFileAccessMode: 'restricted' | 'broad'
+    /** 允许浏览器直接调用 /v1 的业务平台来源,如 https://tools.example.com(CORS) */
+    corsOrigins: string[]
   }
   network: {
     allowedDomains: string[]

+ 10 - 0
packages/shared/tsup.config.ts

@@ -0,0 +1,10 @@
+import { defineConfig } from 'tsup'
+
+// 发布给业务平台的构建(scripts/build-sdk.mjs):ESM + 类型声明
+export default defineConfig({
+  entry: ['src/index.ts'],
+  format: ['esm'],
+  target: 'es2022',
+  dts: true,
+  clean: true,
+})

+ 2 - 1
packages/ui-tokens/package.json

@@ -5,7 +5,8 @@
   "type": "module",
   "exports": {
     ".": "./src/index.ts",
-    "./tokens.css": "./src/tokens.css"
+    "./tokens.css": "./src/tokens.css",
+    "./variables.css": "./src/variables.css"
   },
   "scripts": {
     "typecheck": "tsc --noEmit"

+ 2 - 117
packages/ui-tokens/src/tokens.css

@@ -1,120 +1,5 @@
-/*
- * VS Code 工作台 + Claude 插件风格设计令牌(docs/research/claude-vscode-ui-reference.md)
- * 默认跟随系统亮/暗;<html data-theme="light|dark"> 可手动指定。
- */
-:root {
-  --app-claude-orange: #d97757;
-  --app-claude-clay: #c6613f;
-  --app-claude-ivory: #faf9f5;
-  --app-success: #74c991;
-  --app-danger: #c74e39;
-  --app-warning: #e1c08d;
-
-  --app-spacing-small: 4px;
-  --app-spacing-medium: 8px;
-  --app-spacing-large: 12px;
-  --app-spacing-xlarge: 16px;
-  --corner-radius-small: 4px;
-  --corner-radius-medium: 6px;
-  --corner-radius-large: 8px;
-
-  --app-font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Noto Sans CJK SC", sans-serif;
-  --app-monospace-font-family: "JetBrains Mono", "Cascadia Code", Menlo, Consolas, "Noto Sans Mono CJK SC", monospace;
-  --app-font-size: 13px;
-  --app-monospace-font-size: 12px;
-}
-
-/* 亮色:Light Modern */
-:root,
-:root[data-theme="light"] {
-  color-scheme: light;
-  --app-primary-background: #f8f8f8;
-  --app-secondary-background: #ffffff;
-  --app-activitybar-background: #f8f8f8;
-  --app-statusbar-background: #f8f8f8;
-  --app-statusbar-foreground: #3b3b3b;
-  --app-primary-foreground: #3b3b3b;
-  --app-secondary-foreground: #717171;
-  --app-primary-border-color: #e5e5e5;
-  --app-input-background: #ffffff;
-  --app-input-secondary-background: #ffffff;
-  --app-input-border: #cecece;
-  --app-input-active-border: #005fb8;
-  --app-input-placeholder-foreground: #767676;
-  --app-code-background: #7f7f7f1a;
-  --app-inline-code-background: #7f7f7f1f;
-  --app-link-foreground: #005fb8;
-  --app-list-hover-background: #f2f2f2;
-  --app-list-active-background: #e4e6f1;
-  --app-button-background: #005fb8;
-  --app-button-foreground: #ffffff;
-  --app-button-hover-background: #0258a8;
-  --app-secondary-button-background: #e5e5e5;
-  --app-error-foreground: #c74e39;
-  --app-shadow: 0 1px 2px #0000001a;
-  --app-overlay: #0000004d;
-}
-
-/* 暗色:Dark Modern */
-@media (prefers-color-scheme: dark) {
-  :root:not([data-theme="light"]) {
-    color-scheme: dark;
-    --app-primary-background: #181818;
-    --app-secondary-background: #1f1f1f;
-    --app-activitybar-background: #181818;
-    --app-statusbar-background: #181818;
-    --app-statusbar-foreground: #cccccc;
-    --app-primary-foreground: #cccccc;
-    --app-secondary-foreground: #9d9d9d;
-    --app-primary-border-color: #2b2b2b;
-    --app-input-background: #313131;
-    --app-input-secondary-background: #1f1f1f;
-    --app-input-border: #3c3c3c;
-    --app-input-active-border: #2488db;
-    --app-input-placeholder-foreground: #989898;
-    --app-code-background: #7f7f7f26;
-    --app-inline-code-background: #7f7f7f26;
-    --app-link-foreground: #4daafc;
-    --app-list-hover-background: #2a2d2e;
-    --app-list-active-background: #37373d;
-    --app-button-background: #0078d4;
-    --app-button-foreground: #ffffff;
-    --app-button-hover-background: #026ec1;
-    --app-secondary-button-background: #313131;
-    --app-error-foreground: #f85149;
-    --app-shadow: 0 1px 2px #0000004d;
-    --app-overlay: #00000099;
-  }
-}
-
-:root[data-theme="dark"] {
-  color-scheme: dark;
-  --app-primary-background: #181818;
-  --app-secondary-background: #1f1f1f;
-  --app-activitybar-background: #181818;
-  --app-statusbar-background: #181818;
-  --app-statusbar-foreground: #cccccc;
-  --app-primary-foreground: #cccccc;
-  --app-secondary-foreground: #9d9d9d;
-  --app-primary-border-color: #2b2b2b;
-  --app-input-background: #313131;
-  --app-input-secondary-background: #1f1f1f;
-  --app-input-border: #3c3c3c;
-  --app-input-active-border: #2488db;
-  --app-input-placeholder-foreground: #989898;
-  --app-code-background: #7f7f7f26;
-  --app-inline-code-background: #7f7f7f26;
-  --app-link-foreground: #4daafc;
-  --app-list-hover-background: #2a2d2e;
-  --app-list-active-background: #37373d;
-  --app-button-background: #0078d4;
-  --app-button-foreground: #ffffff;
-  --app-button-hover-background: #026ec1;
-  --app-secondary-button-background: #313131;
-  --app-error-foreground: #f85149;
-  --app-shadow: 0 1px 2px #0000004d;
-  --app-overlay: #00000099;
-}
+/* 后台使用:设计令牌变量 + 页面基础样式。业务平台只需要变量时引用 variables.css */
+@import './variables.css';
 
 html,
 body,

+ 117 - 0
packages/ui-tokens/src/variables.css

@@ -0,0 +1,117 @@
+/*
+ * VS Code 工作台 + Claude 插件风格设计令牌(docs/research/claude-vscode-ui-reference.md)
+ * 默认跟随系统亮/暗;<html data-theme="light|dark"> 可手动指定。
+ */
+:root {
+  --app-claude-orange: #d97757;
+  --app-claude-clay: #c6613f;
+  --app-claude-ivory: #faf9f5;
+  --app-success: #74c991;
+  --app-danger: #c74e39;
+  --app-warning: #e1c08d;
+
+  --app-spacing-small: 4px;
+  --app-spacing-medium: 8px;
+  --app-spacing-large: 12px;
+  --app-spacing-xlarge: 16px;
+  --corner-radius-small: 4px;
+  --corner-radius-medium: 6px;
+  --corner-radius-large: 8px;
+
+  --app-font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Noto Sans CJK SC", sans-serif;
+  --app-monospace-font-family: "JetBrains Mono", "Cascadia Code", Menlo, Consolas, "Noto Sans Mono CJK SC", monospace;
+  --app-font-size: 13px;
+  --app-monospace-font-size: 12px;
+}
+
+/* 亮色:Light Modern */
+:root,
+:root[data-theme="light"] {
+  color-scheme: light;
+  --app-primary-background: #f8f8f8;
+  --app-secondary-background: #ffffff;
+  --app-activitybar-background: #f8f8f8;
+  --app-statusbar-background: #f8f8f8;
+  --app-statusbar-foreground: #3b3b3b;
+  --app-primary-foreground: #3b3b3b;
+  --app-secondary-foreground: #717171;
+  --app-primary-border-color: #e5e5e5;
+  --app-input-background: #ffffff;
+  --app-input-secondary-background: #ffffff;
+  --app-input-border: #cecece;
+  --app-input-active-border: #005fb8;
+  --app-input-placeholder-foreground: #767676;
+  --app-code-background: #7f7f7f1a;
+  --app-inline-code-background: #7f7f7f1f;
+  --app-link-foreground: #005fb8;
+  --app-list-hover-background: #f2f2f2;
+  --app-list-active-background: #e4e6f1;
+  --app-button-background: #005fb8;
+  --app-button-foreground: #ffffff;
+  --app-button-hover-background: #0258a8;
+  --app-secondary-button-background: #e5e5e5;
+  --app-error-foreground: #c74e39;
+  --app-shadow: 0 1px 2px #0000001a;
+  --app-overlay: #0000004d;
+}
+
+/* 暗色:Dark Modern */
+@media (prefers-color-scheme: dark) {
+  :root:not([data-theme="light"]) {
+    color-scheme: dark;
+    --app-primary-background: #181818;
+    --app-secondary-background: #1f1f1f;
+    --app-activitybar-background: #181818;
+    --app-statusbar-background: #181818;
+    --app-statusbar-foreground: #cccccc;
+    --app-primary-foreground: #cccccc;
+    --app-secondary-foreground: #9d9d9d;
+    --app-primary-border-color: #2b2b2b;
+    --app-input-background: #313131;
+    --app-input-secondary-background: #1f1f1f;
+    --app-input-border: #3c3c3c;
+    --app-input-active-border: #2488db;
+    --app-input-placeholder-foreground: #989898;
+    --app-code-background: #7f7f7f26;
+    --app-inline-code-background: #7f7f7f26;
+    --app-link-foreground: #4daafc;
+    --app-list-hover-background: #2a2d2e;
+    --app-list-active-background: #37373d;
+    --app-button-background: #0078d4;
+    --app-button-foreground: #ffffff;
+    --app-button-hover-background: #026ec1;
+    --app-secondary-button-background: #313131;
+    --app-error-foreground: #f85149;
+    --app-shadow: 0 1px 2px #0000004d;
+    --app-overlay: #00000099;
+  }
+}
+
+:root[data-theme="dark"] {
+  color-scheme: dark;
+  --app-primary-background: #181818;
+  --app-secondary-background: #1f1f1f;
+  --app-activitybar-background: #181818;
+  --app-statusbar-background: #181818;
+  --app-statusbar-foreground: #cccccc;
+  --app-primary-foreground: #cccccc;
+  --app-secondary-foreground: #9d9d9d;
+  --app-primary-border-color: #2b2b2b;
+  --app-input-background: #313131;
+  --app-input-secondary-background: #1f1f1f;
+  --app-input-border: #3c3c3c;
+  --app-input-active-border: #2488db;
+  --app-input-placeholder-foreground: #989898;
+  --app-code-background: #7f7f7f26;
+  --app-inline-code-background: #7f7f7f26;
+  --app-link-foreground: #4daafc;
+  --app-list-hover-background: #2a2d2e;
+  --app-list-active-background: #37373d;
+  --app-button-background: #0078d4;
+  --app-button-foreground: #ffffff;
+  --app-button-hover-background: #026ec1;
+  --app-secondary-button-background: #313131;
+  --app-error-foreground: #f85149;
+  --app-shadow: 0 1px 2px #0000004d;
+  --app-overlay: #00000099;
+}

+ 15 - 0
pnpm-lock.yaml

@@ -166,9 +166,18 @@ importers:
       '@types/markdown-it':
         specifier: ^14.2.0
         version: 14.2.0
+      '@vitejs/plugin-vue':
+        specifier: ^6.0.10
+        version: 6.0.10(vite@8.3.5)(vue@3.5.44)
       typescript:
         specifier: ^5.9.3
         version: 5.9.3
+      unocss:
+        specifier: ^66.11.0
+        version: 66.11.0(vite@8.3.5)
+      vite:
+        specifier: ^8.3.5
+        version: 8.3.5(@types/node@26.6.5)(esbuild@0.28.2)(tsx@4.23.15)
       vue:
         specifier: ^3.5.44
         version: 3.5.44(typescript@5.9.3)
@@ -182,12 +191,18 @@ importers:
         specifier: workspace:*
         version: link:../shared
     devDependencies:
+      tsup:
+        specifier: ^9.0.0
+        version: 9.0.0(tsx@4.23.15)(typescript@5.9.3)
       typescript:
         specifier: ^5.9.3
         version: 5.9.3
 
   packages/shared:
     devDependencies:
+      tsup:
+        specifier: ^9.0.0
+        version: 9.0.0(tsx@4.23.15)(typescript@5.9.3)
       typescript:
         specifier: ^5.9.3
         version: 5.9.3

+ 60 - 0
scripts/build-sdk.mjs

@@ -0,0 +1,60 @@
+#!/usr/bin/env node
+// 构建业务平台使用的 SDK 包(docs/integration.md):@agent-server/shared、@agent-server/client、@agent-server/chat-ui。
+// 输出 dist-sdk/<包名>-<版本>.tgz 与 dist-sdk/index.json,由服务在 /sdk/ 下公开提供(不含任何密钥)。
+// 包之间是对等依赖:业务平台用 URL 同时安装三个包,pnpm 按包名把它们互相关联,不会去 npm 仓库查找。
+import { execFileSync } from 'node:child_process'
+import fs from 'node:fs'
+import path from 'node:path'
+import { fileURLToPath } from 'node:url'
+
+const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
+const out = path.join(root, 'dist-sdk')
+const stage = path.join(out, 'stage')
+const run = (cmd, args, cwd) => execFileSync(cmd, args, { cwd, stdio: ['ignore', 'inherit', 'inherit'] })
+const pkgDir = name => path.join(root, 'packages', name)
+const readPkg = name => JSON.parse(fs.readFileSync(path.join(pkgDir(name), 'package.json'), 'utf8'))
+
+fs.rmSync(out, { recursive: true, force: true })
+fs.mkdirSync(stage, { recursive: true })
+
+run('npx', ['tsup', '--silent'], pkgDir('shared'))
+run('npx', ['tsup', '--silent'], pkgDir('client'))
+run('npx', ['vite', 'build', '--logLevel', 'warn'], pkgDir('chat-ui'))
+run('npx', ['vue-tsc', '-p', 'tsconfig.build.json'], pkgDir('chat-ui'))
+
+const version = readPkg('client').version
+const peer = { '@agent-server/shared': version }
+const packages = [
+  {
+    dir: 'shared', files: { 'dist/index.js': 'index.js', 'dist/index.d.ts': 'index.d.ts' },
+    manifest: { exports: { '.': { types: './index.d.ts', import: './index.js' } }, types: './index.d.ts' },
+  },
+  {
+    dir: 'client', files: { 'dist/index.js': 'index.js', 'dist/index.d.ts': 'index.d.ts' },
+    manifest: { exports: { '.': { types: './index.d.ts', import: './index.js' } }, types: './index.d.ts', peerDependencies: peer },
+  },
+  {
+    dir: 'chat-ui', files: { 'dist/index.js': 'index.js', 'dist/style.css': 'style.css', 'dist/types': 'types' },
+    manifest: {
+      exports: { '.': { types: './types/index.d.ts', import: './index.js' }, './style.css': './style.css' },
+      types: './types/index.d.ts',
+      sideEffects: ['*.css'],
+      dependencies: { 'markdown-it': readPkg('chat-ui').dependencies['markdown-it'] },
+      peerDependencies: { ...peer, '@agent-server/client': version, vue: readPkg('chat-ui').peerDependencies.vue },
+    },
+  },
+]
+
+const index = []
+for (const p of packages) {
+  const src = readPkg(p.dir)
+  const dir = path.join(stage, p.dir)
+  for (const [from, to] of Object.entries(p.files)) fs.cpSync(path.join(pkgDir(p.dir), from), path.join(dir, to), { recursive: true })
+  const manifest = { name: src.name, version, type: 'module', description: `AgentServer SDK:${p.dir}`, ...p.manifest }
+  fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify(manifest, null, 2))
+  const file = execFileSync('npm', ['pack', '--silent', '--pack-destination', out], { cwd: dir, encoding: 'utf8' }).trim().split('\n').pop()
+  index.push({ name: src.name, version, file })
+}
+fs.rmSync(stage, { recursive: true, force: true })
+fs.writeFileSync(path.join(out, 'index.json'), JSON.stringify({ version, packages: index }, null, 2))
+console.log(`SDK ${version}:${index.map(p => p.file).join('、')}`)