Jerry Liang 1 неделя назад
Сommit
8f6b85857f
100 измененных файлов с 8028 добавлено и 0 удалено
  1. 8 0
      .editorconfig
  2. 7 0
      .gitignore
  3. 83 0
      README.md
  4. 13 0
      apps/admin/index.html
  5. 30 0
      apps/admin/package.json
  6. 28 0
      apps/admin/src/App.vue
  7. 121 0
      apps/admin/src/components/DebugPanel.vue
  8. 67 0
      apps/admin/src/components/FilePreview.vue
  9. 14 0
      apps/admin/src/components/PageHeader.vue
  10. 19 0
      apps/admin/src/components/SidePane.vue
  11. 105 0
      apps/admin/src/components/Workbench.vue
  12. 6 0
      apps/admin/src/env.d.ts
  13. 43 0
      apps/admin/src/lib/api.ts
  14. 52 0
      apps/admin/src/lib/ui.ts
  15. 11 0
      apps/admin/src/main.ts
  16. 35 0
      apps/admin/src/router.ts
  17. 93 0
      apps/admin/src/views/ApiKeysView.vue
  18. 41 0
      apps/admin/src/views/AuditView.vue
  19. 115 0
      apps/admin/src/views/KnowledgeView.vue
  20. 42 0
      apps/admin/src/views/LoginView.vue
  21. 61 0
      apps/admin/src/views/OverviewView.vue
  22. 252 0
      apps/admin/src/views/PlaygroundView.vue
  23. 235 0
      apps/admin/src/views/ProfilesView.vue
  24. 103 0
      apps/admin/src/views/SessionsView.vue
  25. 181 0
      apps/admin/src/views/SettingsView.vue
  26. 10 0
      apps/admin/tsconfig.json
  27. 5 0
      apps/admin/tsconfig.node.json
  28. 6 0
      apps/admin/uno.config.ts
  29. 18 0
      apps/admin/vite.config.ts
  30. 7 0
      apps/server/drizzle.config.ts
  31. 134 0
      apps/server/drizzle/0000_init.sql
  32. 883 0
      apps/server/drizzle/meta/0000_snapshot.json
  33. 13 0
      apps/server/drizzle/meta/_journal.json
  34. 42 0
      apps/server/package.json
  35. 36 0
      apps/server/scripts/smoke-claude.ts
  36. 32 0
      apps/server/scripts/try-claude-adapter.ts
  37. 43 0
      apps/server/src/app.ts
  38. 21 0
      apps/server/src/config.ts
  39. 34 0
      apps/server/src/db/index.ts
  40. 136 0
      apps/server/src/db/schema.ts
  41. 58 0
      apps/server/src/http/auth.ts
  42. 154 0
      apps/server/src/http/routes/admin.ts
  43. 179 0
      apps/server/src/http/routes/v1.ts
  44. 53 0
      apps/server/src/http/server.ts
  45. 39 0
      apps/server/src/http/sse.ts
  46. 14 0
      apps/server/src/lib/errors.ts
  47. 7 0
      apps/server/src/lib/ids.ts
  48. 43 0
      apps/server/src/lib/paths.ts
  49. 24 0
      apps/server/src/main.ts
  50. 345 0
      apps/server/src/providers/claude/adapter.ts
  51. 144 0
      apps/server/src/providers/claude/policy.ts
  52. 35 0
      apps/server/src/providers/claude/tools.ts
  53. 49 0
      apps/server/src/providers/types.ts
  54. 216 0
      apps/server/src/services/access-policy.ts
  55. 71 0
      apps/server/src/services/admin-auth.ts
  56. 224 0
      apps/server/src/services/catalog.ts
  57. 68 0
      apps/server/src/services/event-store.ts
  58. 140 0
      apps/server/src/services/files.ts
  59. 116 0
      apps/server/src/services/interactions.ts
  60. 58 0
      apps/server/src/services/office.ts
  61. 38 0
      apps/server/src/services/prompt.ts
  62. 292 0
      apps/server/src/services/runs.ts
  63. 148 0
      apps/server/src/services/sessions.ts
  64. 118 0
      apps/server/src/services/settings.ts
  65. 105 0
      apps/server/test/access-policy.test.ts
  66. 164 0
      apps/server/test/api.test.ts
  67. 23 0
      apps/server/test/office.test.ts
  68. 7 0
      apps/server/test/setup.ts
  69. 5 0
      apps/server/tsconfig.json
  70. 12 0
      apps/server/tsup.config.ts
  71. 10 0
      apps/server/vitest.config.ts
  72. 63 0
      docs/README.md
  73. 17 0
      docs/adr/0001-nodejs-typescript-backend.md
  74. 15 0
      docs/adr/0002-claude-agent-sdk.md
  75. 15 0
      docs/adr/0003-codex-app-server.md
  76. 24 0
      docs/adr/0004-isolation-strategy.md
  77. 17 0
      docs/adr/0005-cli-login-credentials.md
  78. 12 0
      docs/adr/0006-mcp-gateway.md
  79. 13 0
      docs/adr/0007-admin-ui-vue-vscode-style.md
  80. 18 0
      docs/adr/0008-personal-cli-login.md
  81. 14 0
      docs/adr/0009-isolation-without-dedicated-user.md
  82. 13 0
      docs/adr/0010-profile-file-access-policy.md
  83. 11 0
      docs/adr/0011-admin-managed-settings.md
  84. 74 0
      docs/design/01-requirements.md
  85. 127 0
      docs/design/02-architecture.md
  86. 148 0
      docs/design/03-providers.md
  87. 130 0
      docs/design/04-api.md
  88. 79 0
      docs/design/05-data-model.md
  89. 166 0
      docs/design/06-security.md
  90. 89 0
      docs/design/07-files-and-knowledge.md
  91. 82 0
      docs/design/08-platform-connectors.md
  92. 114 0
      docs/design/09-admin-ui.md
  93. 47 0
      docs/design/10-deployment.md
  94. 56 0
      docs/design/11-roadmap.md
  95. 106 0
      docs/research/claude-vscode-ui-reference.md
  96. 38 0
      docs/research/document-analysis-approaches.md
  97. 15 0
      package.json
  98. 28 0
      packages/chat-ui/package.json
  99. 118 0
      packages/chat-ui/src/components/AgentChat.vue
  100. 235 0
      packages/chat-ui/src/components/Composer.vue

+ 8 - 0
.editorconfig

@@ -0,0 +1,8 @@
+root = true
+[*]
+indent_style = space
+indent_size = 2
+end_of_line = lf
+charset = utf-8
+insert_final_newline = true
+trim_trailing_whitespace = true

+ 7 - 0
.gitignore

@@ -0,0 +1,7 @@
+node_modules/
+dist/
+*.log
+.env
+.env.*
+/data/
+.DS_Store

+ 83 - 0
README.md

@@ -0,0 +1,83 @@
+# AgentServer
+
+自托管的 Agent 服务:在服务端接入 Claude Code(Codex 在 M2 接入),通过统一的 REST + SSE API 为内部业务平台提供数据分析、资料查找、文档生成等能力,并附带一个 VS Code 风格的管理后台(含 Playground)。
+
+设计文档见 [docs/](docs/README.md)。
+
+## 目录结构
+
+```
+apps/server        Fastify + SQLite 服务端(API、Run 调度、引擎适配、安全策略)
+apps/admin         管理后台(Vue 3 + UnoCSS),生产环境由服务端托管在 /admin/
+packages/shared    事件与 API 类型(前后端共享)
+packages/client    TS 客户端 SDK(会话、SSE 断线续传、交互回复、上传)
+packages/chat-ui   Vue 对话组件(时间线、工具卡片、审批/提问卡片、输入框),业务平台可直接复用
+packages/ui-tokens 设计令牌(tokens.css)与 UnoCSS preset
+skills/            共享技能(部署到 /srv/agent-server/skills)
+docs/              设计文档、ADR、调研
+```
+
+## 前置条件
+
+- Node.js 22+、pnpm 8+
+- 已在终端登录 `claude`(服务直接复用 `~/.claude` 中的登录态)
+- bubblewrap、socat(Claude 沙箱);LibreOffice、poppler-utils、pandoc、ripgrep、fonts-noto-cjk
+- 数据目录 `/srv/agent-server`(含 Python 运行环境 `runtime/py`),见 [docs/design/10-deployment.md](docs/design/10-deployment.md)
+
+## 开发
+
+```bash
+pnpm install
+pnpm dev:server          # 服务端:http://127.0.0.1:8787(tsx watch)
+pnpm dev:admin           # 后台:http://127.0.0.1:5173/admin/(代理 /v1、/admin/v1 到服务端)
+```
+
+首次启动时,如果没有设置 `AGENT_SERVER_ADMIN_PASSWORD`,会生成随机的后台密码并打印在日志里(只打印一次),登录后请在"设置"中修改。
+
+| 环境变量 | 默认值 | 说明 |
+|---|---|---|
+| `AGENT_SERVER_HOST` / `AGENT_SERVER_PORT` | `127.0.0.1` / `8787` | 监听地址 |
+| `AGENT_SERVER_DATA_DIR` | `/srv/agent-server` | 数据目录 |
+| `AGENT_SERVER_ADMIN_PASSWORD` | (随机生成) | 仅首次启动时使用 |
+| `AGENT_SERVER_ADMIN_DIST` | `apps/admin/dist` | 后台静态资源目录 |
+
+其余运行配置(路径、资料库、禁止项、网络白名单、并发等)都在后台"设置"和"资料库"页面管理。
+
+## 构建与运行
+
+```bash
+pnpm build                                  # 构建全部包(后台产物在 apps/admin/dist)
+NODE_ENV=production node apps/server/dist/main.js
+```
+
+## 测试
+
+```bash
+pnpm test        # 服务端单元与集成测试(文件访问策略、API 全流程、LibreOffice 转换)
+pnpm typecheck   # 全部包类型检查
+```
+
+`apps/server/scripts/` 下有两个手动验证脚本(会消耗真实额度):`smoke-claude.ts` 用来验证 SDK 和登录态,`try-claude-adapter.ts` 用来验证审批流和越权拦截。
+
+## 调用示例
+
+```bash
+KEY=ask_xxx   # 在后台 API Keys 页面签发
+SID=$(curl -s -X POST localhost:8787/v1/sessions -H "authorization: Bearer $KEY" -H 'content-type: application/json' -d '{}' | jq -r .id)
+curl -s -X POST localhost:8787/v1/sessions/$SID/files -H "authorization: Bearer $KEY" -F file=@sales.xlsx
+curl -s -X POST localhost:8787/v1/sessions/$SID/messages -H "authorization: Bearer $KEY" -H 'content-type: application/json' \
+  -d '{"text":"分析 uploads/sales.xlsx,生成一份 Word 报告"}'
+curl -N localhost:8787/v1/sessions/$SID/stream -H "authorization: Bearer $KEY"   # SSE 事件流
+```
+
+前端推荐直接使用 `@agent-server/client` 和 `@agent-server/chat-ui`:
+
+```ts
+import { AgentClient } from '@agent-server/client'
+import { AgentChat, useChatSession } from '@agent-server/chat-ui'
+import '@agent-server/ui-tokens/tokens.css'
+
+const client = new AgentClient({ baseUrl: 'https://agent.example.com', apiKey: KEY })
+const session = useChatSession(client, sessionId)
+// <AgentChat :client="client" :session="session" />
+```

+ 13 - 0
apps/admin/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" />
+    <link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'><circle cx='8' cy='8' r='6' fill='%23d97757'/></svg>" />
+    <title>AgentServer</title>
+  </head>
+  <body>
+    <div id="app"></div>
+    <script type="module" src="/src/main.ts"></script>
+  </body>
+</html>

+ 30 - 0
apps/admin/package.json

@@ -0,0 +1,30 @@
+{
+  "name": "@agent-server/admin",
+  "version": "0.1.0",
+  "private": true,
+  "type": "module",
+  "scripts": {
+    "dev": "vite",
+    "build": "vue-tsc --noEmit && vite build",
+    "typecheck": "vue-tsc --noEmit",
+    "preview": "vite preview"
+  },
+  "dependencies": {
+    "@agent-server/chat-ui": "workspace:^",
+    "@agent-server/client": "workspace:^",
+    "@agent-server/shared": "workspace:^",
+    "@agent-server/ui-tokens": "workspace:^",
+    "@vscode/codicons": "0.0.46-24",
+    "pinia": "^4.1.0",
+    "vue": "^3.5.44",
+    "vue-router": "^5.4.0"
+  },
+  "devDependencies": {
+    "@types/node": "^26.6.5",
+    "@vitejs/plugin-vue": "^6.0.10",
+    "typescript": "^5.9.3",
+    "unocss": "^66.11.0",
+    "vite": "^8.3.5",
+    "vue-tsc": "^3.3.13"
+  }
+}

+ 28 - 0
apps/admin/src/App.vue

@@ -0,0 +1,28 @@
+<script setup lang="ts">
+import { computed } from 'vue'
+import { useRoute } from 'vue-router'
+import Workbench from './components/Workbench.vue'
+import { toasts } from './lib/ui'
+
+const route = useRoute()
+const isPublic = computed(() => !!route.meta.public)
+</script>
+
+<template>
+  <RouterView v-if="isPublic" />
+  <Workbench v-else>
+    <RouterView />
+  </Workbench>
+  <div class="fixed right-4 bottom-8 z-50 flex flex-col gap-2 items-end pointer-events-none">
+    <div
+      v-for="t in toasts" :key="t.id"
+      class="pointer-events-auto max-w-100 px-3 py-2 rounded-md shadow-lg border border-solid border-input bg-input-alt text-[13px] flex items-center gap-2"
+    >
+      <span
+        class="codicon"
+        :class="t.kind === 'error' ? 'codicon-error text-error' : t.kind === 'success' ? 'codicon-pass text-success' : 'codicon-info text-link'"
+      />
+      {{ t.text }}
+    </div>
+  </div>
+</template>

+ 121 - 0
apps/admin/src/components/DebugPanel.vue

@@ -0,0 +1,121 @@
+<script setup lang="ts">
+import { computed, ref, watch } from 'vue'
+import type { ChatState } from '@agent-server/chat-ui'
+import { basename, fileIcon, formatSize } from '@agent-server/chat-ui'
+import type { FileDto, SessionDetailDto } from '@agent-server/shared'
+import { client } from '../lib/api'
+import { PERMISSION_LABELS, formatTime } from '../lib/ui'
+import FilePreview from './FilePreview.vue'
+
+type OpenFile = { fileId: string; path: string; mime: string; size: number }
+
+const props = defineProps<{ state: ChatState; session: SessionDetailDto | null }>()
+const tab = defineModel<'events' | 'files' | 'session'>('tab', { default: 'events' })
+const preview = defineModel<OpenFile | null>('preview', { default: null })
+
+const expanded = ref<number | null>(null)
+const files = ref<FileDto[]>([])
+const fileChanges = computed(() => props.state.events.filter(e => e.type === 'file.changed').length)
+
+async function loadFiles() {
+  if (!props.session) return
+  files.value = await client.sessions.files(props.session.id).catch(() => [])
+}
+watch([() => props.session?.id, fileChanges, tab], () => {
+  if (tab.value === 'files') void loadFiles()
+}, { immediate: true })
+
+const EVENT_COLORS: Record<string, string> = {
+  'user.message': 'text-link', 'tool.started': 'text-fg-muted', 'tool.completed': 'text-fg-muted',
+  'interaction.required': 'text-warning', 'interaction.resolved': 'text-warning',
+  'run.failed': 'text-error', 'run.interrupted': 'text-warning', 'run.completed': 'text-success', 'file.changed': 'text-success',
+}
+
+function brief(e: ChatState['events'][number]): string {
+  const p = e.payload as unknown as Record<string, unknown>
+  switch (e.type) {
+    case 'user.message': case 'message.completed': return String(p.text ?? '').slice(0, 60)
+    case 'tool.started': return `${String(p.name)} ${String(p.title ?? '')}`
+    case 'tool.completed': return String(p.status)
+    case 'file.changed': return String(p.path)
+    case 'interaction.required': return String(p.kind)
+    case 'interaction.resolved': return String(p.resolution)
+    case 'usage': return `in ${String(p.inputTokens)} / out ${String(p.outputTokens)}`
+    case 'run.failed': return String(p.message)
+    default: return ''
+  }
+}
+
+const usage = computed(() => props.state.totalUsage)
+</script>
+
+<template>
+  <div class="h-full flex flex-col min-h-0 bg-bg-side border-l border-l-solid border-border">
+    <div class="h-[35px] flex-none flex items-stretch border-b border-b-solid border-border text-[11px] font-600 uppercase tracking-wide">
+      <button
+        v-for="t in ([['events', '事件'], ['files', '文件'], ['session', '会话']] as const)" :key="t[0]" type="button"
+        class="px-3 border-none bg-transparent cursor-pointer text-fg-muted hover:text-fg border-b-solid border-b-1"
+        :class="tab === t[0] ? '!text-fg border-b-accent' : 'border-b-transparent'" @click="tab = t[0]; preview = null"
+      >
+        {{ t[1] }}<template v-if="t[0] === 'events'"> ({{ state.events.length }})</template>
+      </button>
+    </div>
+
+    <!-- 事件 -->
+    <div v-if="tab === 'events'" class="flex-1 min-h-0 overflow-y-auto font-mono text-[11.5px]">
+      <div v-if="!state.events.length" class="p-4 hint font-sans">暂无事件</div>
+      <div v-for="e in state.events" :key="e.seq" class="border-b border-b-solid border-border">
+        <button
+          type="button" class="w-full flex items-baseline gap-2 px-3 py-1 border-none bg-transparent cursor-pointer text-left hover:bg-hover"
+          @click="expanded = expanded === e.seq ? null : e.seq"
+        >
+          <span class="text-fg-muted w-8 flex-none text-right">#{{ e.seq }}</span>
+          <span class="flex-none" :class="EVENT_COLORS[e.type] ?? 'text-fg'">{{ e.type }}</span>
+          <span class="truncate text-fg-muted">{{ brief(e) }}</span>
+        </button>
+        <pre v-if="expanded === e.seq" class="m-0 px-3 pb-2 whitespace-pre-wrap break-all text-fg-muted">{{ formatTime(e.ts) }}  run={{ e.runId }}
+{{ JSON.stringify(e.payload, null, 2) }}</pre>
+      </div>
+    </div>
+
+    <!-- 文件 -->
+    <template v-else-if="tab === 'files'">
+      <FilePreview v-if="preview" :file="preview" class="flex-1" />
+      <div v-else class="flex-1 min-h-0 overflow-y-auto py-1">
+        <div v-if="!files.length" class="p-4 hint">工作区还没有文件</div>
+        <button
+          v-for="f in files" :key="f.id" type="button"
+          class="w-full flex items-center gap-2 px-3 h-7 border-none bg-transparent cursor-pointer text-left hover:bg-hover"
+          @click="preview = { fileId: f.id, path: f.path, mime: f.mime, size: f.size }"
+        >
+          <span class="codicon flex-none" :class="[fileIcon(f.mime, f.path), f.origin === 'upload' ? 'text-link' : 'text-accent']" />
+          <span class="truncate" :title="f.path">{{ f.path }}</span>
+          <span class="flex-1" />
+          <span class="hint flex-none">{{ formatSize(f.size) }}</span>
+        </button>
+      </div>
+      <button v-if="preview" type="button" class="flex-none h-7 border-none border-t border-t-solid border-border bg-transparent cursor-pointer hint hover:text-fg" @click="preview = null">
+        <span class="codicon codicon-arrow-left text-[12px]" /> 返回文件列表
+      </button>
+    </template>
+
+    <!-- 会话信息 -->
+    <div v-else class="flex-1 min-h-0 overflow-y-auto p-3 text-[12px]">
+      <dl v-if="session" class="grid grid-cols-[88px_1fr] gap-x-2 gap-y-1.5 m-0">
+        <dt class="text-fg-muted">ID</dt><dd class="m-0 font-mono break-all">{{ session.id }}</dd>
+        <dt class="text-fg-muted">引擎 / 模型</dt><dd class="m-0">{{ session.provider }} / {{ session.model }}</dd>
+        <dt class="text-fg-muted">权限模式</dt><dd class="m-0">{{ PERMISSION_LABELS[session.permissionMode] }}</dd>
+        <dt class="text-fg-muted">来源</dt><dd class="m-0">{{ session.source }}</dd>
+        <dt class="text-fg-muted">创建时间</dt><dd class="m-0">{{ formatTime(session.createdAt) }}</dd>
+        <dt class="text-fg-muted">输入 tokens</dt><dd class="m-0">{{ usage.inputTokens.toLocaleString() }}(缓存读 {{ usage.cacheReadTokens.toLocaleString() }})</dd>
+        <dt class="text-fg-muted">输出 tokens</dt><dd class="m-0">{{ usage.outputTokens.toLocaleString() }}</dd>
+        <dt class="text-fg-muted">预估费用</dt><dd class="m-0">${{ (usage.costUsd ?? 0).toFixed(4) }}<span class="hint">(订阅登录下仅供参考)</span></dd>
+        <dt class="text-fg-muted">连接</dt><dd class="m-0">{{ state.connection }}</dd>
+      </dl>
+      <div v-if="session?.systemPromptAppend" class="mt-3">
+        <div class="text-fg-muted mb-1">会话追加提示词</div>
+        <pre class="m-0 p-2 rounded-md bg-code whitespace-pre-wrap font-mono text-[11.5px]">{{ session.systemPromptAppend }}</pre>
+      </div>
+    </div>
+  </div>
+</template>

+ 67 - 0
apps/admin/src/components/FilePreview.vue

@@ -0,0 +1,67 @@
+<script setup lang="ts">
+import { computed, onBeforeUnmount, ref, watch } from 'vue'
+import { basename, downloadFile, formatSize } from '@agent-server/chat-ui'
+import { client } from '../lib/api'
+
+const props = defineProps<{ file: { fileId: string; path: string; mime: string; size: number } }>()
+
+const TEXT_EXT = ['txt', 'md', 'csv', 'tsv', 'json', 'py', 'js', 'ts', 'sql', 'sh', 'log', 'yaml', 'yml', 'xml', 'html']
+const ext = computed(() => props.file.path.split('.').pop()?.toLowerCase() ?? '')
+const kind = computed(() => {
+  if (props.file.mime.startsWith('image/')) return 'image'
+  if (ext.value === 'pdf') return 'pdf'
+  if (TEXT_EXT.includes(ext.value) || props.file.mime.startsWith('text/')) return 'text'
+  return 'other'
+})
+
+const url = ref<string | null>(null)
+const text = ref('')
+const error = ref('')
+const loading = ref(false)
+
+async function load() {
+  if (url.value) URL.revokeObjectURL(url.value)
+  url.value = null
+  text.value = ''
+  error.value = ''
+  if (kind.value === 'other') return
+  loading.value = true
+  try {
+    const blob = await client.fileBlob(props.file.fileId)
+    if (kind.value === 'text') text.value = (await blob.text()).slice(0, 200_000)
+    else url.value = URL.createObjectURL(kind.value === 'pdf' ? new Blob([blob], { type: 'application/pdf' }) : blob)
+  } catch (err) {
+    error.value = (err as Error).message
+  } finally {
+    loading.value = false
+  }
+}
+watch(() => props.file.fileId, load, { immediate: true })
+onBeforeUnmount(() => url.value && URL.revokeObjectURL(url.value))
+</script>
+
+<template>
+  <div class="flex flex-col h-full min-h-0">
+    <div class="flex items-center gap-2 px-3 h-8 flex-none border-b border-b-solid border-border">
+      <span class="truncate font-500" :title="file.path">{{ basename(file.path) }}</span>
+      <span class="hint flex-none">{{ formatSize(file.size) }}</span>
+      <span class="flex-1" />
+      <button type="button" class="icon-btn" title="下载" @click="downloadFile(client, file.fileId, basename(file.path))">
+        <span class="codicon codicon-cloud-download" />
+      </button>
+    </div>
+    <div class="flex-1 min-h-0 overflow-auto">
+      <div v-if="loading" class="p-4 hint">加载中…</div>
+      <div v-else-if="error" class="p-4 text-error">{{ error }}</div>
+      <img v-else-if="kind === 'image' && url" :src="url" class="max-w-full block mx-auto" alt="">
+      <iframe v-else-if="kind === 'pdf' && url" :src="url" class="w-full h-full border-none" title="PDF 预览" />
+      <pre v-else-if="kind === 'text'" class="m-0 p-3 font-mono text-[12px] whitespace-pre-wrap break-all">{{ text }}</pre>
+      <div v-else class="p-4 hint flex flex-col gap-2 items-start">
+        这种格式无法直接预览。
+        <button type="button" class="btn-secondary" @click="downloadFile(client, file.fileId, basename(file.path))">
+          <span class="codicon codicon-cloud-download" />下载文件
+        </button>
+      </div>
+    </div>
+  </div>
+</template>

+ 14 - 0
apps/admin/src/components/PageHeader.vue

@@ -0,0 +1,14 @@
+<script setup lang="ts">
+defineProps<{ title: string; subtitle?: string }>()
+</script>
+
+<template>
+  <header class="flex-none flex items-center gap-3 px-6 h-12 border-b border-b-solid border-border">
+    <div class="min-w-0">
+      <h1 class="m-0 text-[14px] font-600 truncate">{{ title }}</h1>
+    </div>
+    <span v-if="subtitle" class="hint truncate">{{ subtitle }}</span>
+    <div class="flex-1" />
+    <slot />
+  </header>
+</template>

+ 19 - 0
apps/admin/src/components/SidePane.vue

@@ -0,0 +1,19 @@
+<script setup lang="ts">
+// 侧栏 + 主区域布局(对应 VS Code 的 Side Bar 与 Editor Area)
+defineProps<{ title: string; width?: number }>()
+</script>
+
+<template>
+  <div class="h-full flex">
+    <aside class="flex-none flex flex-col min-h-0 bg-bg-side border-r border-r-solid border-border" :style="{ width: `${width ?? 260}px` }">
+      <header class="h-[35px] flex-none flex items-center justify-between pl-5 pr-2">
+        <span class="section-title">{{ title }}</span>
+        <div class="flex items-center gap-0.5"><slot name="actions" /></div>
+      </header>
+      <div class="flex-1 min-h-0 overflow-y-auto"><slot name="side" /></div>
+    </aside>
+    <section class="flex-1 min-w-0 min-h-0 flex flex-col bg-bg">
+      <slot />
+    </section>
+  </div>
+</template>

+ 105 - 0
apps/admin/src/components/Workbench.vue

@@ -0,0 +1,105 @@
+<script setup lang="ts">
+import { onBeforeUnmount, onMounted, ref } from 'vue'
+import { useRoute, useRouter } from 'vue-router'
+import type { ProviderStatusDto } from '@agent-server/shared'
+import { admin } from '../lib/api'
+import { applyTheme, savedTheme, type Theme } from '../lib/ui'
+import { requireLogin } from '../router'
+
+const route = useRoute()
+const router = useRouter()
+
+const nav = [
+  { to: '/playground', icon: 'codicon-comment-discussion', label: 'Playground' },
+  { to: '/sessions', icon: 'codicon-history', label: '会话记录' },
+  { to: '/overview', icon: 'codicon-dashboard', label: '概览' },
+  { to: '/profiles', icon: 'codicon-hubot', label: 'Profiles' },
+  { to: '/knowledge', icon: 'codicon-library', label: '资料库' },
+  { to: '/api-keys', icon: 'codicon-key', label: 'API Keys' },
+  { to: '/audit', icon: 'codicon-checklist', label: '审计日志' },
+]
+
+const providers = ref<ProviderStatusDto[]>([])
+const runs = ref({ running: 0, queued: 0, active: 0 })
+const version = ref('')
+const theme = ref<Theme>(savedTheme())
+let timer: ReturnType<typeof setInterval> | undefined
+
+async function refresh() {
+  try {
+    const o = await admin.overview()
+    providers.value = o.providers
+    runs.value = o.runs
+    version.value = o.version
+  } catch (err) {
+    if ((err as { status?: number }).status === 401) requireLogin()
+  }
+}
+
+function cycleTheme() {
+  const order: Theme[] = ['system', 'light', 'dark']
+  theme.value = order[(order.indexOf(theme.value) + 1) % order.length]!
+  applyTheme(theme.value)
+}
+
+async function logout() {
+  await admin.logout().catch(() => {})
+  requireLogin()
+}
+
+const isActive = (to: string) => route.path === to || route.path.startsWith(`${to}/`)
+
+onMounted(() => {
+  void refresh()
+  timer = setInterval(refresh, 15000)
+})
+onBeforeUnmount(() => clearInterval(timer))
+</script>
+
+<template>
+  <div class="h-full flex flex-col">
+    <div class="flex-1 min-h-0 flex">
+      <!-- 活动栏 -->
+      <nav class="w-12 flex-none flex flex-col items-center bg-[var(--app-activitybar-background)] border-r border-r-solid border-border">
+        <button
+          v-for="n in nav" :key="n.to" type="button" :title="n.label"
+          class="relative w-12 h-12 flex items-center justify-center border-none bg-transparent cursor-pointer text-fg-muted hover:text-fg"
+          :class="{ '!text-fg': isActive(n.to) }" @click="router.push(n.to)"
+        >
+          <span v-if="isActive(n.to)" class="absolute left-0 top-0 bottom-0 w-0.5 bg-accent" />
+          <span class="codicon text-[22px]" :class="n.icon" />
+        </button>
+        <div class="flex-1" />
+        <button type="button" title="切换主题" class="w-12 h-11 flex items-center justify-center border-none bg-transparent cursor-pointer text-fg-muted hover:text-fg" @click="cycleTheme">
+          <span class="codicon text-[20px]" :class="theme === 'dark' ? 'codicon-color-mode' : theme === 'light' ? 'codicon-lightbulb' : 'codicon-device-desktop'" />
+        </button>
+        <button
+          type="button" title="设置" class="relative w-12 h-11 flex items-center justify-center border-none bg-transparent cursor-pointer text-fg-muted hover:text-fg"
+          :class="{ '!text-fg': isActive('/settings') }" @click="router.push('/settings')"
+        >
+          <span v-if="isActive('/settings')" class="absolute left-0 top-0 bottom-0 w-0.5 bg-accent" />
+          <span class="codicon codicon-settings-gear text-[22px]" />
+        </button>
+        <button type="button" title="退出登录" class="w-12 h-11 mb-1 flex items-center justify-center border-none bg-transparent cursor-pointer text-fg-muted hover:text-fg" @click="logout">
+          <span class="codicon codicon-sign-out text-[20px]" />
+        </button>
+      </nav>
+      <main class="flex-1 min-w-0 min-h-0">
+        <slot />
+      </main>
+    </div>
+    <!-- 状态栏 -->
+    <footer class="h-[22px] flex-none flex items-center gap-4 px-3 text-[12px] bg-[var(--app-statusbar-background)] text-[var(--app-statusbar-foreground)] border-t border-t-solid border-border">
+      <button
+        v-for="p in providers" :key="p.id" type="button" :title="p.message ?? p.account ?? ''"
+        class="flex items-center gap-1.5 border-none bg-transparent p-0 cursor-pointer text-inherit text-[12px]" @click="router.push('/overview')"
+      >
+        <span class="w-2 h-2 rounded-full" :class="p.available ? 'bg-success' : p.enabled ? 'bg-danger' : 'bg-fg-muted'" />
+        {{ p.id }} {{ p.available ? '已登录' : p.enabled ? '不可用' : '未启用' }}
+      </button>
+      <span class="flex items-center gap-1"><span class="codicon codicon-pulse text-[12px]" />运行中 {{ runs.running }} · 排队 {{ runs.queued }}</span>
+      <span class="flex-1" />
+      <span>AgentServer {{ version }}</span>
+    </footer>
+  </div>
+</template>

+ 6 - 0
apps/admin/src/env.d.ts

@@ -0,0 +1,6 @@
+/// <reference types="vite/client" />
+declare module '*.vue' {
+  import type { DefineComponent } from 'vue'
+  const component: DefineComponent<object, object, unknown>
+  export default component
+}

+ 43 - 0
apps/admin/src/lib/api.ts

@@ -0,0 +1,43 @@
+// 后台 API:同源 Cookie 鉴权,复用 AgentClient 的请求与错误处理
+import { AgentClient } from '@agent-server/client'
+import type {
+  ApiKeyDto, AppSettings, DeepPartial, FileAccessPolicy, KnowledgeRootDto, PathCheck, ProfileDto, ProfileInput,
+  ProviderStatusDto, ResolvedAccessDto,
+} from '@agent-server/shared'
+
+export const client = new AgentClient({ baseUrl: '' })
+const req = <T>(method: string, path: string, body?: unknown) => client.request<T>(method, `/admin/v1${path}`, body)
+
+export interface AuditRow { id: number; ts: string; actor: string; sessionId: string | null; kind: string; summary: string; detail: unknown }
+export type KnowledgeInput = Pick<KnowledgeRootDto, 'name' | 'path' | 'description' | 'access' | 'enabled'>
+
+export const admin = {
+  me: () => req<{ loggedIn: boolean }>('GET', '/auth/me'),
+  login: (password: string) => req<{ ok: true }>('POST', '/auth/login', { password }),
+  logout: () => req<{ ok: true }>('POST', '/auth/logout'),
+  changePassword: (current: string, next: string) => req<{ ok: true }>('POST', '/auth/password', { current, next }),
+  overview: () => req<{ providers: ProviderStatusDto[]; runs: { running: number; queued: number; active: number }; version: string }>('GET', '/overview'),
+  settings: () => req<{ settings: AppSettings; defaults: AppSettings; builtinGlobalDeny: string[] }>('GET', '/settings'),
+  updateSettings: (patch: DeepPartial<AppSettings>) => req<{ settings: AppSettings }>('PATCH', '/settings', patch),
+  checkPath: (path: string, purpose: 'knowledge' | 'generic' = 'knowledge') => req<PathCheck>('POST', '/paths/check', { path, purpose }),
+  skills: () => req<string[]>('GET', '/skills'),
+  profiles: () => req<ProfileDto[]>('GET', '/profiles'),
+  createProfile: (p: ProfileInput) => req<ProfileDto>('POST', '/profiles', p),
+  updateProfile: (id: string, p: ProfileInput) => req<ProfileDto>('PUT', `/profiles/${id}`, p),
+  deleteProfile: (id: string) => req<void>('DELETE', `/profiles/${id}`),
+  previewAccess: (fileAccess: FileAccessPolicy, knowledgeRootIds: string[]) => req<ResolvedAccessDto>('POST', '/access/preview', { fileAccess, knowledgeRootIds }),
+  knowledge: () => req<KnowledgeRootDto[]>('GET', '/knowledge'),
+  createKnowledge: (k: KnowledgeInput) => req<KnowledgeRootDto>('POST', '/knowledge', k),
+  updateKnowledge: (id: string, k: KnowledgeInput) => req<KnowledgeRootDto>('PUT', `/knowledge/${id}`, k),
+  deleteKnowledge: (id: string) => req<void>('DELETE', `/knowledge/${id}`),
+  apiKeys: () => req<ApiKeyDto[]>('GET', '/api-keys'),
+  createApiKey: (name: string) => req<{ key: string; apiKey: ApiKeyDto }>('POST', '/api-keys', { name }),
+  setApiKeyEnabled: (id: string, enabled: boolean) => req<{ ok: true }>('PATCH', `/api-keys/${id}`, { enabled }),
+  deleteApiKey: (id: string) => req<void>('DELETE', `/api-keys/${id}`),
+  audit: (q: { kind?: string; sessionId?: string; limit?: number } = {}) => {
+    const s = new URLSearchParams(Object.entries(q).filter(([, v]) => v !== undefined && v !== '').map(([k, v]) => [k, String(v)]))
+    return req<AuditRow[]>('GET', `/audit${s.size ? `?${s}` : ''}`)
+  },
+}
+
+export const errorMessage = (err: unknown) => (err instanceof Error ? err.message : String(err))

+ 52 - 0
apps/admin/src/lib/ui.ts

@@ -0,0 +1,52 @@
+import { reactive } from 'vue'
+
+/** 右下角轻提示 */
+export const toasts = reactive<{ id: number; text: string; kind: 'info' | 'error' | 'success' }[]>([])
+let seq = 0
+export function toast(text: string, kind: 'info' | 'error' | 'success' = 'info') {
+  const id = ++seq
+  toasts.push({ id, text, kind })
+  setTimeout(() => {
+    const i = toasts.findIndex(t => t.id === id)
+    if (i >= 0) toasts.splice(i, 1)
+  }, kind === 'error' ? 6000 : 3000)
+}
+
+export type Theme = 'system' | 'light' | 'dark'
+export function applyTheme(theme: Theme) {
+  if (theme === 'system') document.documentElement.removeAttribute('data-theme')
+  else document.documentElement.setAttribute('data-theme', theme)
+  try {
+    localStorage.setItem('agent-admin-theme', theme)
+  } catch {
+    // 隐私模式下忽略
+  }
+}
+export function savedTheme(): Theme {
+  try {
+    return (localStorage.getItem('agent-admin-theme') as Theme | null) ?? 'system'
+  } catch {
+    return 'system'
+  }
+}
+
+export function formatTime(iso: string | null) {
+  if (!iso) return '—'
+  const d = new Date(iso)
+  const today = new Date().toDateString() === d.toDateString()
+  return today ? d.toLocaleTimeString('zh-CN', { hour12: false }) : d.toLocaleString('zh-CN', { hour12: false })
+}
+
+export function relativeTime(iso: string) {
+  const diff = (Date.now() - new Date(iso).getTime()) / 1000
+  if (diff < 60) return '刚刚'
+  if (diff < 3600) return `${Math.floor(diff / 60)} 分钟前`
+  if (diff < 86400) return `${Math.floor(diff / 3600)} 小时前`
+  if (diff < 86400 * 7) return `${Math.floor(diff / 86400)} 天前`
+  return new Date(iso).toLocaleDateString('zh-CN')
+}
+
+export const PERMISSION_LABELS = { ask: '需审批', auto: '自动执行', readonly: '只读' } as const
+
+/** 深拷贝纯数据(structuredClone 无法拷贝 Vue 的响应式代理) */
+export const clone = <T>(v: T): T => JSON.parse(JSON.stringify(v)) as T

+ 11 - 0
apps/admin/src/main.ts

@@ -0,0 +1,11 @@
+import 'virtual:uno.css'
+import '@agent-server/ui-tokens/tokens.css'
+import '@vscode/codicons/dist/codicon.css'
+import { createPinia } from 'pinia'
+import { createApp } from 'vue'
+import App from './App.vue'
+import { router } from './router'
+import { applyTheme, savedTheme } from './lib/ui'
+
+applyTheme(savedTheme())
+createApp(App).use(createPinia()).use(router).mount('#app')

+ 35 - 0
apps/admin/src/router.ts

@@ -0,0 +1,35 @@
+import { createRouter, createWebHistory } from 'vue-router'
+import { admin } from './lib/api'
+
+export const router = createRouter({
+  history: createWebHistory('/admin/'),
+  routes: [
+    { path: '/', redirect: '/playground' },
+    { path: '/login', component: () => import('./views/LoginView.vue'), meta: { public: true } },
+    { path: '/playground/:sessionId?', name: 'playground', component: () => import('./views/PlaygroundView.vue') },
+    { path: '/sessions/:sessionId?', name: 'sessions', component: () => import('./views/SessionsView.vue') },
+    { path: '/overview', component: () => import('./views/OverviewView.vue') },
+    { path: '/profiles/:id?', name: 'profiles', component: () => import('./views/ProfilesView.vue') },
+    { path: '/knowledge', component: () => import('./views/KnowledgeView.vue') },
+    { path: '/api-keys', component: () => import('./views/ApiKeysView.vue') },
+    { path: '/audit', component: () => import('./views/AuditView.vue') },
+    { path: '/settings', component: () => import('./views/SettingsView.vue') },
+    { path: '/:pathMatch(.*)*', redirect: '/playground' },
+  ],
+})
+
+let checked = false
+router.beforeEach(async to => {
+  if (to.meta.public) return true
+  if (checked) return true
+  const { loggedIn } = await admin.me().catch(() => ({ loggedIn: false }))
+  if (!loggedIn) return { path: '/login', query: { next: to.fullPath } }
+  checked = true
+  return true
+})
+
+/** 接口返回 401 时回到登录页 */
+export function requireLogin() {
+  checked = false
+  void router.push({ path: '/login', query: { next: router.currentRoute.value.fullPath } })
+}

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

@@ -0,0 +1,93 @@
+<script setup lang="ts">
+import { onMounted, ref } from 'vue'
+import type { ApiKeyDto } from '@agent-server/shared'
+import PageHeader from '../components/PageHeader.vue'
+import { admin, errorMessage } from '../lib/api'
+import { formatTime, toast } from '../lib/ui'
+
+const keys = ref<ApiKeyDto[]>([])
+const name = ref('')
+const created = ref<{ key: string; name: string } | null>(null)
+
+async function load() {
+  keys.value = await admin.apiKeys().catch(err => (toast(errorMessage(err), 'error'), []))
+}
+
+async function create() {
+  if (!name.value.trim()) return
+  try {
+    const r = await admin.createApiKey(name.value.trim())
+    created.value = { key: r.key, name: r.apiKey.name }
+    name.value = ''
+    await load()
+  } catch (err) {
+    toast(errorMessage(err), 'error')
+  }
+}
+
+async function toggle(k: ApiKeyDto) {
+  await admin.setApiKeyEnabled(k.id, !k.enabled).catch(err => toast(errorMessage(err), 'error'))
+  await load()
+}
+
+async function remove(k: ApiKeyDto) {
+  if (!confirm(`删除 API Key「${k.name}」?使用它的平台将无法再调用。`)) return
+  await admin.deleteApiKey(k.id).catch(err => toast(errorMessage(err), 'error'))
+  await load()
+}
+
+async function copy(text: string) {
+  try {
+    await navigator.clipboard.writeText(text)
+    toast('已复制', 'success')
+  } catch {
+    toast('复制失败,请手动选择复制', 'error')
+  }
+}
+
+onMounted(load)
+</script>
+
+<template>
+  <div class="h-full flex flex-col">
+    <PageHeader title="API Keys" subtitle="每个接入的业务平台一个 Key,调用时放在 Authorization: Bearer 头中" />
+    <div class="flex-1 overflow-y-auto p-6 flex flex-col gap-5 max-w-240">
+      <form class="flex gap-2" @submit.prevent="create">
+        <input v-model="name" class="field max-w-80" placeholder="平台名称,例如:家庭记账" aria-label="名称">
+        <button type="submit" class="btn-primary" :disabled="!name.trim()"><span class="codicon codicon-add" />签发</button>
+      </form>
+
+      <div v-if="created" class="card p-4 flex flex-col gap-2 border-accent">
+        <div class="font-600">「{{ created.name }}」的 API Key(只显示这一次,请立即保存)</div>
+        <div class="flex gap-2">
+          <code class="flex-1 font-mono text-[12.5px] p-2 rounded-md bg-code break-all select-all">{{ created.key }}</code>
+          <button type="button" class="btn-secondary" @click="copy(created.key)"><span class="codicon codicon-copy" />复制</button>
+        </div>
+        <button type="button" class="btn-ghost self-start" @click="created = null">我已保存</button>
+      </div>
+
+      <table class="w-full border-collapse text-[12.5px]">
+        <thead>
+          <tr class="text-left text-fg-muted">
+            <th class="py-2 font-500">名称</th><th class="font-500">前缀</th><th class="font-500">状态</th>
+            <th class="font-500">创建时间</th><th class="font-500">最近使用</th><th />
+          </tr>
+        </thead>
+        <tbody>
+          <tr v-for="k in keys" :key="k.id" class="border-t border-t-solid border-border">
+            <td class="py-2">{{ k.name }}</td>
+            <td class="font-mono">{{ k.prefix }}…</td>
+            <td><span class="badge" :class="k.enabled ? 'bg-success text-white' : 'bg-code text-fg-muted'">{{ k.enabled ? '启用' : '停用' }}</span></td>
+            <td>{{ formatTime(k.createdAt) }}</td>
+            <td>{{ formatTime(k.lastUsedAt) }}</td>
+            <td class="text-right whitespace-nowrap">
+              <button type="button" class="btn-ghost" @click="toggle(k)">{{ k.enabled ? '停用' : '启用' }}</button>
+              <button type="button" class="icon-btn" title="删除" @click="remove(k)"><span class="codicon codicon-trash" /></button>
+            </td>
+          </tr>
+          <tr v-if="!keys.length"><td colspan="6" class="py-4 hint">还没有 API Key</td></tr>
+        </tbody>
+      </table>
+    </div>
+  </div>
+</template>

+ 41 - 0
apps/admin/src/views/AuditView.vue

@@ -0,0 +1,41 @@
+<script setup lang="ts">
+import { onMounted, ref, watch } from 'vue'
+import PageHeader from '../components/PageHeader.vue'
+import { admin, errorMessage, type AuditRow } from '../lib/api'
+import { formatTime, toast } from '../lib/ui'
+
+const KINDS = { '': '全部', command: '命令执行', file_access_denied: '越权文件访问', network_denied: '网络拦截', approval: '审批', admin: '后台操作' } as const
+const rows = ref<AuditRow[]>([])
+const kind = ref<keyof typeof KINDS>('')
+const expanded = ref<number | null>(null)
+
+async function load() {
+  rows.value = await admin.audit({ kind: kind.value || undefined, limit: 500 }).catch(err => (toast(errorMessage(err), 'error'), []))
+}
+watch(kind, load)
+onMounted(load)
+</script>
+
+<template>
+  <div class="h-full flex flex-col">
+    <PageHeader title="审计日志" subtitle="Agent 执行的命令、被拦截的访问、审批记录与后台操作">
+      <select v-model="kind" class="field w-40" aria-label="类型">
+        <option v-for="(label, k) in KINDS" :key="k" :value="k">{{ label }}</option>
+      </select>
+      <button type="button" class="btn-secondary" @click="load"><span class="codicon codicon-refresh" /></button>
+    </PageHeader>
+    <div class="flex-1 overflow-y-auto">
+      <div v-for="r in rows" :key="r.id" class="border-b border-b-solid border-border">
+        <button type="button" class="w-full flex items-baseline gap-3 px-6 py-1.5 border-none bg-transparent cursor-pointer text-left hover:bg-hover" @click="expanded = expanded === r.id ? null : r.id">
+          <span class="text-fg-muted w-36 flex-none text-[12px]">{{ formatTime(r.ts) }}</span>
+          <span class="w-24 flex-none text-[12px]" :class="r.kind.endsWith('denied') ? 'text-error' : 'text-fg-muted'">{{ KINDS[r.kind as keyof typeof KINDS] ?? r.kind }}</span>
+          <span class="flex-1 min-w-0 truncate font-mono text-[12px]">{{ r.summary }}</span>
+          <span class="text-fg-muted text-[11px] flex-none">{{ r.actor }}</span>
+        </button>
+        <pre v-if="expanded === r.id" class="m-0 px-6 pb-2 text-[11.5px] font-mono whitespace-pre-wrap break-all text-fg-muted">session={{ r.sessionId ?? '—' }}
+{{ JSON.stringify(r.detail, null, 2) }}</pre>
+      </div>
+      <div v-if="!rows.length" class="p-6 hint">没有记录</div>
+    </div>
+  </div>
+</template>

+ 115 - 0
apps/admin/src/views/KnowledgeView.vue

@@ -0,0 +1,115 @@
+<script setup lang="ts">
+import { onMounted, ref, watch } from 'vue'
+import type { KnowledgeRootDto, PathCheck } from '@agent-server/shared'
+import PageHeader from '../components/PageHeader.vue'
+import { admin, errorMessage, type KnowledgeInput } from '../lib/api'
+import { toast } from '../lib/ui'
+
+const roots = ref<KnowledgeRootDto[]>([])
+const editing = ref<(KnowledgeInput & { id?: string }) | null>(null)
+const check = ref<PathCheck | null>(null)
+const saving = ref(false)
+
+async function load() {
+  roots.value = await admin.knowledge().catch(err => (toast(errorMessage(err), 'error'), []))
+}
+
+function edit(k?: KnowledgeRootDto) {
+  editing.value = k
+    ? { id: k.id, name: k.name, path: k.path, description: k.description, access: k.access, enabled: k.enabled }
+    : { name: '', path: '/srv/agent-server/knowledge/', description: '', access: 'read', enabled: true }
+}
+
+let timer: ReturnType<typeof setTimeout> | undefined
+watch(() => editing.value?.path, p => {
+  clearTimeout(timer)
+  check.value = null
+  if (!p?.trim()) return
+  timer = setTimeout(async () => (check.value = await admin.checkPath(p).catch(() => null)), 300)
+})
+
+const valid = (c: PathCheck | null) => !!c && c.exists && c.readable && c.isDirectory && !c.denied
+
+async function save() {
+  const e = editing.value
+  if (!e) return
+  saving.value = true
+  try {
+    const { id, ...body } = e
+    if (id) await admin.updateKnowledge(id, body)
+    else await admin.createKnowledge(body)
+    editing.value = null
+    toast('已保存', 'success')
+    await load()
+  } catch (err) {
+    toast(errorMessage(err), 'error')
+  } finally {
+    saving.value = false
+  }
+}
+
+async function remove(k: KnowledgeRootDto) {
+  if (!confirm(`删除资料库「${k.name}」?(不会删除目录中的文件)`)) return
+  await admin.deleteKnowledge(k.id).catch(err => toast(errorMessage(err), 'error'))
+  await load()
+}
+
+onMounted(load)
+</script>
+
+<template>
+  <div class="h-full flex flex-col">
+    <PageHeader title="资料库" subtitle="登记服务器上的资料目录,在 Profile 中启用后 Agent 才能访问">
+      <button type="button" class="btn-primary" @click="edit()"><span class="codicon codicon-add" />添加资料库</button>
+    </PageHeader>
+    <div class="flex-1 overflow-y-auto p-6">
+      <div v-if="!roots.length" class="hint">还没有资料库。建议把资料放在 home 目录以外(例如 /srv/agent-server/knowledge),以 systemd 加固方式部署时无需额外配置。</div>
+      <div class="flex flex-col gap-2 max-w-240">
+        <div v-for="k in roots" :key="k.id" class="card p-3 flex items-start gap-3" :class="{ 'opacity-60': !k.enabled }">
+          <span class="codicon codicon-library text-[20px] mt-0.5" :class="valid(k.check) ? 'text-accent' : 'text-error'" />
+          <div class="flex-1 min-w-0">
+            <div class="flex items-center gap-2">
+              <span class="font-600">{{ k.name }}</span>
+              <span class="badge bg-code">{{ k.access === 'write' ? '读写' : '只读' }}</span>
+              <span v-if="!k.enabled" class="badge bg-code">已停用</span>
+            </div>
+            <div class="font-mono text-[12px] text-fg-muted break-all">{{ k.path }}</div>
+            <div v-if="k.description" class="mt-1">{{ k.description }}</div>
+            <div v-if="k.check.message" class="mt-1 text-[12px]" :class="valid(k.check) ? 'text-warning' : 'text-error'">{{ k.check.message }}</div>
+          </div>
+          <button type="button" class="icon-btn" title="编辑" @click="edit(k)"><span class="codicon codicon-edit" /></button>
+          <button type="button" class="icon-btn" title="删除" @click="remove(k)"><span class="codicon codicon-trash" /></button>
+        </div>
+      </div>
+    </div>
+
+    <div v-if="editing" class="fixed inset-0 z-40 flex items-center justify-center bg-[var(--app-overlay)]" @click.self="editing = null">
+      <form class="w-[520px] card bg-input-alt p-5 flex flex-col gap-3 shadow-xl" @submit.prevent="save">
+        <div class="font-600">{{ editing.id ? '编辑资料库' : '添加资料库' }}</div>
+        <div><label class="label" for="k-name">名称</label><input id="k-name" v-model="editing.name" class="field" placeholder="例如:家庭账单"></div>
+        <div>
+          <label class="label" for="k-path">目录路径(服务器上的绝对路径,支持 ~)</label>
+          <input id="k-path" v-model="editing.path" class="field font-mono">
+          <div v-if="check" class="mt-1 text-[12px] flex items-center gap-1" :class="valid(check) ? (check.message ? 'text-warning' : 'text-success') : 'text-error'">
+            <span class="codicon" :class="valid(check) ? 'codicon-pass' : 'codicon-error'" />
+            {{ check.message ?? '路径可用' }}
+          </div>
+        </div>
+        <div>
+          <label class="label" for="k-desc">说明(会告诉 Agent 这里有什么资料)</label>
+          <textarea id="k-desc" v-model="editing.description" class="field-area h-20 font-sans" placeholder="例如:2020 年以来的银行流水 CSV 和发票 PDF,按年份分目录" />
+        </div>
+        <div class="flex gap-4">
+          <label class="flex items-center gap-1.5 cursor-pointer"><input v-model="editing.access" type="radio" value="read">只读</label>
+          <label class="flex items-center gap-1.5 cursor-pointer"><input v-model="editing.access" type="radio" value="write">可读写</label>
+          <span class="flex-1" />
+          <label class="flex items-center gap-1.5 cursor-pointer"><input v-model="editing.enabled" type="checkbox">启用</label>
+        </div>
+        <div class="flex justify-end gap-2 mt-2">
+          <button type="button" class="btn-secondary" @click="editing = null">取消</button>
+          <button type="submit" class="btn-primary" :disabled="saving || !editing.name.trim() || !valid(check)">保存</button>
+        </div>
+      </form>
+    </div>
+  </div>
+</template>

+ 42 - 0
apps/admin/src/views/LoginView.vue

@@ -0,0 +1,42 @@
+<script setup lang="ts">
+import { ref } from 'vue'
+import { useRoute, useRouter } from 'vue-router'
+import { admin, errorMessage } from '../lib/api'
+
+const route = useRoute()
+const router = useRouter()
+const password = ref('')
+const error = ref('')
+const loading = ref(false)
+
+async function submit() {
+  if (!password.value) return
+  loading.value = true
+  error.value = ''
+  try {
+    await admin.login(password.value)
+    await router.replace(typeof route.query.next === 'string' ? route.query.next : '/playground')
+  } catch (err) {
+    error.value = errorMessage(err)
+  } finally {
+    loading.value = false
+  }
+}
+</script>
+
+<template>
+  <div class="h-full flex items-center justify-center bg-bg-side">
+    <form class="w-80 card p-6 flex flex-col gap-4 shadow-sm" @submit.prevent="submit">
+      <div class="flex items-center gap-2">
+        <span class="w-3 h-3 rounded-full bg-accent" />
+        <span class="text-[15px] font-600">AgentServer 管理后台</span>
+      </div>
+      <div>
+        <label class="label" for="pw">管理员密码</label>
+        <input id="pw" v-model="password" type="password" class="field" autocomplete="current-password" autofocus>
+      </div>
+      <div v-if="error" class="text-error text-[12px]">{{ error }}</div>
+      <button type="submit" class="btn-primary h-8" :disabled="loading || !password">{{ loading ? '登录中…' : '登录' }}</button>
+    </form>
+  </div>
+</template>

+ 61 - 0
apps/admin/src/views/OverviewView.vue

@@ -0,0 +1,61 @@
+<script setup lang="ts">
+import { onMounted, ref } from 'vue'
+import type { ProviderStatusDto } from '@agent-server/shared'
+import PageHeader from '../components/PageHeader.vue'
+import { admin, errorMessage } from '../lib/api'
+import { toast } from '../lib/ui'
+
+const providers = ref<ProviderStatusDto[]>([])
+const runs = ref({ running: 0, queued: 0, active: 0 })
+const loading = ref(false)
+
+async function load() {
+  loading.value = true
+  try {
+    const o = await admin.overview()
+    providers.value = o.providers
+    runs.value = o.runs
+  } catch (err) {
+    toast(errorMessage(err), 'error')
+  } finally {
+    loading.value = false
+  }
+}
+onMounted(load)
+</script>
+
+<template>
+  <div class="h-full flex flex-col">
+    <PageHeader title="概览" subtitle="引擎状态与运行情况">
+      <button type="button" class="btn-secondary" :disabled="loading" @click="load"><span class="codicon codicon-refresh" />刷新</button>
+    </PageHeader>
+    <div class="flex-1 overflow-y-auto p-6 flex flex-col gap-6 max-w-240">
+      <section class="grid grid-cols-3 gap-3">
+        <div v-for="(v, k) in { running: '运行中', queued: '排队中', active: '活动 Run' }" :key="k" class="card p-4">
+          <div class="hint">{{ v }}</div>
+          <div class="text-[24px] font-600 mt-1">{{ runs[k] }}</div>
+        </div>
+      </section>
+      <section class="flex flex-col gap-3">
+        <h2 class="section-title m-0">引擎</h2>
+        <div v-for="p in providers" :key="p.id" class="card p-4 flex gap-4 items-start">
+          <span class="codicon codicon-hubot text-[28px]" :class="p.available ? 'text-accent' : 'text-fg-muted'" />
+          <div class="flex-1 min-w-0 grid grid-cols-[96px_1fr] gap-y-1 text-[12.5px]">
+            <div class="col-span-2 flex items-center gap-2 mb-1">
+              <span class="text-[14px] font-600">{{ p.id === 'claude' ? 'Claude Code' : 'Codex' }}</span>
+              <span class="badge" :class="p.available ? 'bg-success text-white' : p.enabled ? 'bg-danger text-white' : 'bg-[var(--app-input-border)] text-fg-muted'">
+                {{ p.available ? '可用' : p.enabled ? '不可用' : '未启用' }}
+              </span>
+            </div>
+            <span class="text-fg-muted">账户</span><span>{{ p.account ?? '—' }}</span>
+            <span class="text-fg-muted">CLI</span><span class="font-mono break-all">{{ p.cliPath }} <span class="text-fg-muted">{{ p.version }}</span></span>
+            <span class="text-fg-muted">默认模型</span><span>{{ p.defaultModel || '—' }}</span>
+            <span class="text-fg-muted">可选模型</span><span>{{ p.models.join('、') || '—' }}</span>
+            <template v-if="p.message"><span class="text-fg-muted">说明</span><span class="text-warning">{{ p.message }}</span></template>
+          </div>
+        </div>
+        <div class="hint">登录态复用本机终端:未登录时请在服务器终端执行 <code class="font-mono">claude</code> 并完成 /login(Codex 为 <code class="font-mono">codex login</code>),无需重启服务。</div>
+      </section>
+    </div>
+  </div>
+</template>

+ 252 - 0
apps/admin/src/views/PlaygroundView.vue

@@ -0,0 +1,252 @@
+<script setup lang="ts">
+import { computed, onBeforeUnmount, onMounted, ref, shallowRef, watch } from 'vue'
+import { useRoute, useRouter } from 'vue-router'
+import { AgentChat, useChatSession, type ChatSession } from '@agent-server/chat-ui'
+import type { ProfileSummary } from '@agent-server/client'
+import type { PermissionMode, ProviderStatusDto, SessionDetailDto, SessionDto } from '@agent-server/shared'
+import DebugPanel from '../components/DebugPanel.vue'
+import SidePane from '../components/SidePane.vue'
+import { client, errorMessage } from '../lib/api'
+import { PERMISSION_LABELS, relativeTime, toast } from '../lib/ui'
+
+const route = useRoute()
+const router = useRouter()
+const sessionId = computed(() => (route.params.sessionId as string | undefined) || null)
+
+const sessions = ref<SessionDto[]>([])
+const profiles = ref<ProfileSummary[]>([])
+const providers = ref<ProviderStatusDto[]>([])
+const detail = ref<SessionDetailDto | null>(null)
+const chat = shallowRef<ChatSession | null>(null)
+const showDebug = ref(true)
+const debugTab = ref<'events' | 'files' | 'session'>('events')
+const preview = ref<{ fileId: string; path: string; mime: string; size: number } | null>(null)
+const editingTitle = ref(false)
+const titleDraft = ref('')
+const showPrompt = ref(false)
+const promptDraft = ref('')
+
+const started = computed(() => (chat.value?.state.items.length ?? 0) > 0)
+const idle = computed(() => chat.value?.state.runState === 'idle')
+const models = computed(() => providers.value.find(p => p.id === detail.value?.provider)?.models ?? [])
+
+async function loadSessions() {
+  try {
+    sessions.value = (await client.sessions.list({ limit: 100 })).items
+  } catch (err) {
+    toast(errorMessage(err), 'error')
+  }
+}
+
+async function loadDetail() {
+  if (!sessionId.value) return
+  detail.value = await client.sessions.get(sessionId.value)
+}
+
+watch(sessionId, async id => {
+  chat.value?.dispose()
+  chat.value = null
+  detail.value = null
+  preview.value = null
+  if (!id) return
+  try {
+    await loadDetail()
+    chat.value = useChatSession(client, id)
+  } catch (err) {
+    toast(errorMessage(err), 'error')
+    void router.replace('/playground')
+  }
+}, { immediate: true })
+
+// 运行状态变化时刷新列表和详情(标题、更新时间)
+watch(() => chat.value?.state.runState, (s, prev) => {
+  if (s !== prev) {
+    void loadSessions()
+    void loadDetail()
+  }
+})
+
+async function newSession(profileId?: string) {
+  try {
+    const s = await client.sessions.create({ profileId })
+    sessions.value.unshift(s)
+    await router.push(`/playground/${s.id}`)
+  } catch (err) {
+    toast(errorMessage(err), 'error')
+  }
+}
+
+async function update(patch: { model?: string; permissionMode?: PermissionMode; profileId?: string; systemPromptAppend?: string; title?: string }) {
+  if (!detail.value) return
+  try {
+    await client.sessions.update(detail.value.id, patch)
+    await loadDetail()
+    void loadSessions()
+  } catch (err) {
+    toast(errorMessage(err), 'error')
+  }
+}
+
+async function removeSession(s: SessionDto) {
+  if (!confirm(`删除会话「${s.title || '新会话'}」?工作区文件也会一起删除。`)) return
+  try {
+    await client.sessions.remove(s.id)
+    sessions.value = sessions.value.filter(x => x.id !== s.id)
+    if (sessionId.value === s.id) await router.push('/playground')
+  } catch (err) {
+    toast(errorMessage(err), 'error')
+  }
+}
+
+function startEditTitle() {
+  titleDraft.value = detail.value?.title ?? ''
+  editingTitle.value = true
+}
+async function saveTitle() {
+  editingTitle.value = false
+  if (detail.value && titleDraft.value.trim() !== detail.value.title) await update({ title: titleDraft.value.trim() })
+}
+
+function openPrompt() {
+  promptDraft.value = detail.value?.systemPromptAppend ?? ''
+  showPrompt.value = true
+}
+
+function openFile(f: { fileId: string; path: string; mime: string; size: number }) {
+  showDebug.value = true
+  debugTab.value = 'files'
+  preview.value = f
+}
+
+let timer: ReturnType<typeof setInterval> | undefined
+onMounted(async () => {
+  void loadSessions()
+  ;[profiles.value, providers.value] = await Promise.all([client.profiles().catch(() => []), client.providers().catch(() => [])])
+  timer = setInterval(loadSessions, 15000)
+})
+onBeforeUnmount(() => {
+  clearInterval(timer)
+  chat.value?.dispose()
+})
+</script>
+
+<template>
+  <SidePane title="Playground">
+    <template #actions>
+      <button type="button" class="icon-btn" title="新会话(默认 Profile)" @click="newSession()"><span class="codicon codicon-add" /></button>
+      <button type="button" class="icon-btn" title="刷新" @click="loadSessions"><span class="codicon codicon-refresh" /></button>
+    </template>
+    <template #side>
+      <div v-if="!sessions.length" class="px-5 py-3 hint">还没有会话,点击 + 新建</div>
+      <div
+        v-for="s in sessions" :key="s.id"
+        class="group flex items-center gap-2 h-[44px] pl-5 pr-2 cursor-pointer hover:bg-hover"
+        :class="{ '!bg-active': s.id === sessionId }" @click="router.push(`/playground/${s.id}`)"
+      >
+        <div class="flex-1 min-w-0">
+          <div class="truncate">{{ s.title || '新会话' }}</div>
+          <div class="text-[11px] text-fg-muted truncate">{{ s.model }} · {{ relativeTime(s.updatedAt) }}</div>
+        </div>
+        <span v-if="s.status !== 'idle'" class="w-2 h-2 rounded-full flex-none" :class="s.status === 'waiting_input' ? 'bg-warning' : 'bg-accent'" />
+        <button type="button" class="icon-btn opacity-0 group-hover:opacity-100" title="删除" @click.stop="removeSession(s)">
+          <span class="codicon codicon-trash" />
+        </button>
+      </div>
+    </template>
+
+    <!-- 未选择会话 -->
+    <div v-if="!sessionId" class="flex-1 flex flex-col items-center justify-center gap-4 p-8 bg-bg-side">
+      <span class="codicon codicon-comment-discussion text-[40px] text-fg-muted" />
+      <div class="text-[15px] font-600">在 Playground 中验证 Agent</div>
+      <div class="hint max-w-110 text-center">使用与业务平台完全相同的 /v1 接口。选择一个 Profile 开始新会话,对话中可以随时切换模型和权限模式。</div>
+      <div class="flex flex-col gap-1.5 w-80">
+        <button
+          v-for="p in profiles" :key="p.id" type="button"
+          class="flex items-center gap-2 px-3 py-2 rounded-md border border-solid border-input bg-input-alt cursor-pointer text-left hover:border-active"
+          @click="newSession(p.id)"
+        >
+          <span class="codicon codicon-hubot text-accent" />
+          <span class="flex-1 min-w-0">
+            <span class="block font-500">{{ p.name }}<span v-if="p.isDefault" class="hint"> · 默认</span></span>
+            <span class="block hint truncate">{{ p.provider }} / {{ p.model }} · {{ PERMISSION_LABELS[p.permissionMode] }}</span>
+          </span>
+        </button>
+      </div>
+    </div>
+
+    <template v-else>
+      <header class="h-[35px] flex-none flex items-center gap-2 px-4 border-b border-b-solid border-border bg-bg-side">
+        <input
+          v-if="editingTitle" v-model="titleDraft" class="field h-6 max-w-100" aria-label="会话标题" autofocus
+          @keydown.enter="saveTitle" @keydown.esc="editingTitle = false" @blur="saveTitle"
+        >
+        <button v-else type="button" class="border-none bg-transparent p-0 cursor-text font-500 truncate text-fg" title="点击修改标题" @click="startEditTitle">
+          {{ detail?.title || '新会话' }}
+        </button>
+        <span class="flex-1" />
+        <button type="button" class="icon-btn" title="会话追加提示词" @click="openPrompt"><span class="codicon codicon-note" /></button>
+        <button type="button" class="icon-btn" :class="{ '!text-fg': showDebug }" title="调试面板" @click="showDebug = !showDebug">
+          <span class="codicon codicon-layout-sidebar-right" />
+        </button>
+      </header>
+      <div class="flex-1 min-h-0 flex">
+        <div class="flex-1 min-w-0">
+          <AgentChat v-if="chat" :key="sessionId!" :client="client" :session="chat" @open-file="openFile">
+            <template #toolbar>
+              <template v-if="detail">
+                <label v-if="!started" class="pill" title="Profile">
+                  <span class="codicon codicon-hubot text-[12px]" />
+                  <select class="pill-select" :value="detail.profileId ?? ''" aria-label="Profile" @change="update({ profileId: ($event.target as HTMLSelectElement).value })">
+                    <option v-for="p in profiles" :key="p.id" :value="p.id">{{ p.name }}</option>
+                  </select>
+                </label>
+                <label class="pill" title="模型">
+                  <span class="text-fg-muted">{{ detail.provider }}</span>
+                  <select class="pill-select" :value="detail.model" :disabled="!idle" aria-label="模型" @change="update({ model: ($event.target as HTMLSelectElement).value })">
+                    <option v-for="m in (models.includes(detail.model) ? models : [detail.model, ...models])" :key="m" :value="m">{{ m }}</option>
+                  </select>
+                </label>
+                <label class="pill" title="权限模式">
+                  <span class="codicon text-[12px]" :class="detail.permissionMode === 'auto' ? 'codicon-unlock' : detail.permissionMode === 'readonly' ? 'codicon-eye' : 'codicon-shield'" />
+                  <select class="pill-select" :value="detail.permissionMode" :disabled="!idle" aria-label="权限模式" @change="update({ permissionMode: ($event.target as HTMLSelectElement).value as PermissionMode })">
+                    <option v-for="(label, k) in PERMISSION_LABELS" :key="k" :value="k">{{ label }}</option>
+                  </select>
+                </label>
+              </template>
+            </template>
+          </AgentChat>
+        </div>
+        <div v-if="showDebug && chat" class="w-[380px] flex-none min-h-0">
+          <DebugPanel v-model:tab="debugTab" v-model:preview="preview" :state="chat.state" :session="detail" />
+        </div>
+      </div>
+    </template>
+
+    <!-- 会话追加提示词 -->
+    <div v-if="showPrompt" class="fixed inset-0 z-40 flex items-center justify-center bg-[var(--app-overlay)]" @click.self="showPrompt = false">
+      <div class="w-[560px] card bg-input-alt p-4 flex flex-col gap-3 shadow-xl">
+        <div class="font-600">会话追加提示词</div>
+        <div class="hint">追加在 Profile 提示词之后,只对当前会话生效,下一轮对话起生效。</div>
+        <textarea v-model="promptDraft" class="field-area h-48" aria-label="追加提示词" />
+        <div class="flex justify-end gap-2">
+          <button type="button" class="btn-secondary" @click="showPrompt = false">取消</button>
+          <button type="button" class="btn-primary" :disabled="!idle" @click="update({ systemPromptAppend: promptDraft }); showPrompt = false">保存</button>
+        </div>
+      </div>
+    </div>
+  </SidePane>
+</template>
+
+<style scoped>
+.pill-select {
+  border: none;
+  background: transparent;
+  outline: none;
+  cursor: pointer;
+  padding: 0;
+  font-size: 12px;
+  max-width: 140px;
+}
+.pill-select option { background: var(--app-input-secondary-background); color: var(--app-primary-foreground); }
+.pill-select:disabled { cursor: not-allowed; opacity: 0.6; }
+</style>

+ 235 - 0
apps/admin/src/views/ProfilesView.vue

@@ -0,0 +1,235 @@
+<script setup lang="ts">
+import { computed, onMounted, ref, watch } from 'vue'
+import { useRoute, useRouter } from 'vue-router'
+import type { KnowledgeRootDto, PathRule, ProfileDto, ProfileInput, ProviderStatusDto, ResolvedAccessDto } from '@agent-server/shared'
+import PageHeader from '../components/PageHeader.vue'
+import SidePane from '../components/SidePane.vue'
+import { admin, client, errorMessage } from '../lib/api'
+import { PERMISSION_LABELS, clone, toast } from '../lib/ui'
+
+const route = useRoute()
+const router = useRouter()
+const profiles = ref<ProfileDto[]>([])
+const knowledge = ref<KnowledgeRootDto[]>([])
+const skills = ref<string[]>([])
+const providers = ref<ProviderStatusDto[]>([])
+const draft = ref<ProfileInput | null>(null)
+const preview = ref<ResolvedAccessDto | null>(null)
+const saving = ref(false)
+
+const id = computed(() => (route.params.id as string | undefined) || null)
+const isNew = computed(() => id.value === 'new')
+const current = computed(() => profiles.value.find(p => p.id === id.value) ?? null)
+const models = computed(() => providers.value.find(p => p.id === draft.value?.provider)?.models ?? [])
+
+const blank = (): ProfileInput => ({
+  name: '', description: '', provider: 'claude', model: providers.value.find(p => p.id === 'claude')?.defaultModel ?? 'sonnet',
+  systemPrompt: '', permissionMode: 'ask', allowedTools: [], disallowedTools: [], skills: 'all',
+  fileAccess: { mode: 'restricted', read: [], write: [], deny: [] }, knowledgeRootIds: [], isDefault: false,
+})
+
+function toInput(p: ProfileDto): ProfileInput {
+  const { id: _id, version: _v, updatedAt: _u, ...rest } = clone(p)
+  return rest
+}
+
+async function load() {
+  ;[profiles.value, knowledge.value, skills.value, providers.value] = await Promise.all([
+    admin.profiles(), admin.knowledge(), admin.skills(), client.providers(),
+  ])
+}
+
+// 切换选中项或列表刷新后重置草稿;新建时只在进入页面时生成空白草稿
+const resetDraft = () => {
+  if (isNew.value) draft.value ??= blank()
+  else draft.value = current.value ? toInput(current.value) : null
+}
+watch(id, () => {
+  draft.value = null
+  resetDraft()
+}, { immediate: true })
+watch(profiles, resetDraft)
+
+// 文件访问策略生效预览
+let previewTimer: ReturnType<typeof setTimeout> | undefined
+watch(() => draft.value && JSON.stringify([draft.value.fileAccess, draft.value.knowledgeRootIds]), () => {
+  clearTimeout(previewTimer)
+  if (!draft.value) return
+  previewTimer = setTimeout(async () => {
+    preview.value = await admin.previewAccess(draft.value!.fileAccess, draft.value!.knowledgeRootIds).catch(() => null)
+  }, 300)
+}, { immediate: true })
+
+const skillsAll = computed({
+  get: () => draft.value?.skills === 'all',
+  set: v => { if (draft.value) draft.value.skills = v ? 'all' : [...skills.value] },
+})
+function toggleSkill(name: string) {
+  const d = draft.value
+  if (!d || d.skills === 'all') return
+  d.skills = d.skills.includes(name) ? d.skills.filter(s => s !== name) : [...d.skills, name]
+}
+function toggleKnowledge(kid: string) {
+  const d = draft.value!
+  d.knowledgeRootIds = d.knowledgeRootIds.includes(kid) ? d.knowledgeRootIds.filter(k => k !== kid) : [...d.knowledgeRootIds, kid]
+}
+const listText = (list: string[]) => list.join(', ')
+const parseList = (s: string) => s.split(/[,,\n]/).map(x => x.trim()).filter(Boolean)
+const rulePaths = (rules: PathRule[]) => rules.filter((r): r is { path: string } => 'path' in r).map(r => r.path).join('\n')
+const setRulePaths = (kind: 'read' | 'write', text: string) => {
+  draft.value!.fileAccess[kind] = parseList(text).map(path => ({ path }))
+}
+
+async function save() {
+  if (!draft.value) return
+  saving.value = true
+  try {
+    const saved = isNew.value ? await admin.createProfile(draft.value) : await admin.updateProfile(id.value!, draft.value)
+    toast('已保存', 'success')
+    await load()
+    if (isNew.value) await router.replace(`/profiles/${saved.id}`)
+  } catch (err) {
+    toast(errorMessage(err), 'error')
+  } finally {
+    saving.value = false
+  }
+}
+
+async function remove() {
+  if (!current.value || !confirm(`删除 Profile「${current.value.name}」?`)) return
+  try {
+    await admin.deleteProfile(current.value.id)
+    await load()
+    await router.replace('/profiles')
+  } catch (err) {
+    toast(errorMessage(err), 'error')
+  }
+}
+
+onMounted(() => load().catch(err => toast(errorMessage(err), 'error')))
+</script>
+
+<template>
+  <SidePane title="Profiles">
+    <template #actions>
+      <button type="button" class="icon-btn" title="新建 Profile" @click="router.push('/profiles/new')"><span class="codicon codicon-add" /></button>
+    </template>
+    <template #side>
+      <div
+        v-for="p in profiles" :key="p.id" class="flex flex-col justify-center h-[44px] pl-5 pr-3 cursor-pointer hover:bg-hover"
+        :class="{ '!bg-active': p.id === id }" @click="router.push(`/profiles/${p.id}`)"
+      >
+        <div class="truncate">{{ p.name }}<span v-if="p.isDefault" class="hint"> · 默认</span></div>
+        <div class="text-[11px] text-fg-muted truncate">{{ p.provider }} / {{ p.model }} · {{ PERMISSION_LABELS[p.permissionMode] }}</div>
+      </div>
+    </template>
+
+    <div v-if="!draft" class="flex-1 flex items-center justify-center hint">选择或新建一个 Profile</div>
+    <template v-else>
+      <PageHeader :title="isNew ? '新建 Profile' : draft.name" :subtitle="current ? `版本 ${current.version}` : undefined">
+        <button v-if="current && !current.isDefault" type="button" class="btn-danger" @click="remove">删除</button>
+        <button type="button" class="btn-primary" :disabled="saving || !draft.name.trim()" @click="save">{{ saving ? '保存中…' : '保存' }}</button>
+      </PageHeader>
+      <div class="flex-1 min-h-0 overflow-y-auto">
+        <div class="max-w-200 p-6 flex flex-col gap-6">
+          <section class="grid grid-cols-2 gap-4">
+            <div><label class="label" for="p-name">名称</label><input id="p-name" v-model="draft.name" class="field"></div>
+            <div><label class="label" for="p-desc">说明</label><input id="p-desc" v-model="draft.description" class="field"></div>
+            <div>
+              <label class="label" for="p-provider">引擎</label>
+              <select id="p-provider" v-model="draft.provider" class="field">
+                <option v-for="p in providers" :key="p.id" :value="p.id" :disabled="!p.enabled">{{ p.id }}{{ p.enabled ? '' : '(未启用)' }}</option>
+              </select>
+            </div>
+            <div>
+              <label class="label" for="p-model">模型</label>
+              <input id="p-model" v-model="draft.model" class="field" list="p-models">
+              <datalist id="p-models"><option v-for="m in models" :key="m" :value="m" /></datalist>
+            </div>
+            <div>
+              <label class="label" for="p-mode">权限模式</label>
+              <select id="p-mode" v-model="draft.permissionMode" class="field">
+                <option value="ask">需审批:执行命令、修改文件前询问</option>
+                <option value="auto">自动执行:沙箱内自动放行</option>
+                <option value="readonly">只读:禁止执行命令和修改文件</option>
+              </select>
+            </div>
+            <label class="flex items-center gap-2 mt-5 cursor-pointer"><input v-model="draft.isDefault" type="checkbox">设为默认 Profile</label>
+          </section>
+
+          <section>
+            <label class="label" for="p-prompt">系统提示词(追加在运行环境说明之后)</label>
+            <textarea id="p-prompt" v-model="draft.systemPrompt" class="field-area h-40" placeholder="例如:你是家庭财务分析助手,金额统一用人民币元……" />
+          </section>
+
+          <section class="flex flex-col gap-2">
+            <div class="label m-0">技能</div>
+            <label class="flex items-center gap-2 cursor-pointer"><input v-model="skillsAll" type="checkbox">启用技能目录中的全部技能</label>
+            <div v-if="!skillsAll" class="flex flex-wrap gap-2">
+              <label v-for="s in skills" :key="s" class="pill cursor-pointer">
+                <input type="checkbox" :checked="Array.isArray(draft.skills) && draft.skills.includes(s)" @change="toggleSkill(s)">{{ s }}
+              </label>
+            </div>
+            <div class="hint">可用:{{ skills.join('、') || '(技能目录为空)' }}</div>
+          </section>
+
+          <section class="grid grid-cols-2 gap-4">
+            <div>
+              <label class="label" for="p-allow">仅允许这些工具(留空不限制)</label>
+              <input id="p-allow" class="field" :value="listText(draft.allowedTools)" placeholder="例如 Read, Bash, WebSearch" @change="draft.allowedTools = parseList(($event.target as HTMLInputElement).value)">
+            </div>
+            <div>
+              <label class="label" for="p-deny">禁用这些工具</label>
+              <input id="p-deny" class="field" :value="listText(draft.disallowedTools)" placeholder="例如 WebFetch" @change="draft.disallowedTools = parseList(($event.target as HTMLInputElement).value)">
+            </div>
+          </section>
+
+          <section class="card p-4 flex flex-col gap-4">
+            <div class="flex items-center gap-2">
+              <span class="codicon codicon-shield text-accent" />
+              <span class="font-600">文件访问策略</span>
+              <span class="hint">全局禁止项始终生效,不能被覆盖</span>
+            </div>
+            <div class="flex gap-4">
+              <label class="flex items-center gap-1.5 cursor-pointer"><input v-model="draft.fileAccess.mode" type="radio" value="restricted">受限(只能读列出的路径,推荐)</label>
+              <label class="flex items-center gap-1.5 cursor-pointer"><input v-model="draft.fileAccess.mode" type="radio" value="broad">宽松(全盘可读,排除禁止项)</label>
+            </div>
+            <div>
+              <div class="label">资料库</div>
+              <div v-if="!knowledge.length" class="hint">还没有资料库,可以在"资料库"页面添加</div>
+              <div class="flex flex-col gap-1">
+                <label v-for="k in knowledge" :key="k.id" class="flex items-center gap-2 cursor-pointer" :class="{ 'opacity-50': !k.enabled }">
+                  <input type="checkbox" :checked="draft.knowledgeRootIds.includes(k.id)" @change="toggleKnowledge(k.id)">
+                  <span>{{ k.name }}</span><span class="font-mono hint">{{ k.path }}</span>
+                  <span class="badge bg-code">{{ k.access === 'write' ? '读写' : '只读' }}</span>
+                </label>
+              </div>
+            </div>
+            <div class="grid grid-cols-3 gap-3">
+              <div>
+                <label class="label" for="p-read">额外可读路径(每行一个)</label>
+                <textarea id="p-read" class="field-area h-24" :value="rulePaths(draft.fileAccess.read)" @change="setRulePaths('read', ($event.target as HTMLTextAreaElement).value)" />
+              </div>
+              <div>
+                <label class="label" for="p-write">额外可写路径</label>
+                <textarea id="p-write" class="field-area h-24" :value="rulePaths(draft.fileAccess.write)" @change="setRulePaths('write', ($event.target as HTMLTextAreaElement).value)" />
+              </div>
+              <div>
+                <label class="label" for="p-fdeny">禁止路径 / 通配</label>
+                <textarea id="p-fdeny" class="field-area h-24" placeholder="**/*.env" :value="draft.fileAccess.deny.join('\n')" @change="draft.fileAccess.deny = parseList(($event.target as HTMLTextAreaElement).value)" />
+              </div>
+            </div>
+            <div v-if="preview" class="grid grid-cols-3 gap-3 text-[12px]">
+              <div v-for="(list, label) in { '可读': preview.readable, '可写': preview.writable, '禁止': preview.denied }" :key="label">
+                <div class="section-title mb-1">生效:{{ label }}</div>
+                <div class="rounded-md bg-code p-2 font-mono text-[11.5px] flex flex-col gap-0.5 max-h-40 overflow-y-auto">
+                  <span v-for="p in list" :key="p" class="break-all">{{ p }}</span>
+                </div>
+              </div>
+            </div>
+          </section>
+        </div>
+      </div>
+    </template>
+  </SidePane>
+</template>

+ 103 - 0
apps/admin/src/views/SessionsView.vue

@@ -0,0 +1,103 @@
+<script setup lang="ts">
+import { computed, onBeforeUnmount, onMounted, ref, shallowRef, watch } from 'vue'
+import { useRoute, useRouter } from 'vue-router'
+import { AgentChat, useChatSession, type ChatSession } from '@agent-server/chat-ui'
+import type { SessionDetailDto, SessionDto } from '@agent-server/shared'
+import DebugPanel from '../components/DebugPanel.vue'
+import SidePane from '../components/SidePane.vue'
+import { admin, client, errorMessage } from '../lib/api'
+import { relativeTime, toast } from '../lib/ui'
+
+// 所有来源(各 API Key 与后台)的会话,只读回放
+const route = useRoute()
+const router = useRouter()
+const sessionId = computed(() => (route.params.sessionId as string | undefined) || null)
+const sessions = ref<SessionDto[]>([])
+const cursor = ref<string | null>(null)
+const keyNames = ref<Record<string, string>>({ admin: '后台' })
+const filter = ref('')
+const chat = shallowRef<ChatSession | null>(null)
+const detail = ref<SessionDetailDto | null>(null)
+const debugTab = ref<'events' | 'files' | 'session'>('events')
+const preview = ref<{ fileId: string; path: string; mime: string; size: number } | null>(null)
+
+const shown = computed(() => sessions.value.filter(s => !filter.value || s.source === filter.value))
+
+async function load(more = false) {
+  try {
+    const page = await client.sessions.list({ scope: 'all', limit: 100, cursor: more ? cursor.value ?? undefined : undefined })
+    sessions.value = more ? [...sessions.value, ...page.items] : page.items
+    cursor.value = page.nextCursor
+  } catch (err) {
+    toast(errorMessage(err), 'error')
+  }
+}
+
+watch(sessionId, async id => {
+  chat.value?.dispose()
+  chat.value = null
+  detail.value = null
+  preview.value = null
+  if (!id) return
+  try {
+    detail.value = await client.sessions.get(id)
+    chat.value = useChatSession(client, id)
+  } catch (err) {
+    toast(errorMessage(err), 'error')
+  }
+}, { immediate: true })
+
+onMounted(async () => {
+  void load()
+  const keys = await admin.apiKeys().catch(() => [])
+  for (const k of keys) keyNames.value[k.id] = k.name
+})
+onBeforeUnmount(() => chat.value?.dispose())
+</script>
+
+<template>
+  <SidePane title="会话记录" :width="300">
+    <template #actions>
+      <button type="button" class="icon-btn" title="刷新" @click="load()"><span class="codicon codicon-refresh" /></button>
+    </template>
+    <template #side>
+      <div class="px-3 pb-2">
+        <select v-model="filter" class="field" aria-label="来源">
+          <option value="">全部来源</option>
+          <option v-for="(name, id) in keyNames" :key="id" :value="id">{{ name }}</option>
+        </select>
+      </div>
+      <div
+        v-for="s in shown" :key="s.id" class="flex items-center gap-2 h-[48px] pl-5 pr-3 cursor-pointer hover:bg-hover"
+        :class="{ '!bg-active': s.id === sessionId }" @click="router.push(`/sessions/${s.id}`)"
+      >
+        <div class="flex-1 min-w-0">
+          <div class="truncate">{{ s.title || '新会话' }}</div>
+          <div class="text-[11px] text-fg-muted truncate">{{ keyNames[s.source] ?? s.source }} · {{ s.model }} · {{ relativeTime(s.updatedAt) }}</div>
+        </div>
+        <span v-if="s.status !== 'idle'" class="w-2 h-2 rounded-full flex-none bg-accent" />
+      </div>
+      <button v-if="cursor" type="button" class="btn-ghost w-full" @click="load(true)">加载更多</button>
+    </template>
+
+    <div v-if="!sessionId" class="flex-1 flex items-center justify-center hint bg-bg-side">选择左侧的会话查看完整过程</div>
+    <template v-else>
+      <header class="h-[35px] flex-none flex items-center gap-2 px-4 border-b border-b-solid border-border bg-bg-side">
+        <span class="font-500 truncate">{{ detail?.title || '新会话' }}</span>
+        <span class="hint">只读回放</span>
+        <span class="flex-1" />
+        <button v-if="detail?.source === 'admin'" type="button" class="btn-ghost" @click="router.push(`/playground/${sessionId}`)">
+          <span class="codicon codicon-comment-discussion" />在 Playground 中继续
+        </button>
+      </header>
+      <div class="flex-1 min-h-0 flex">
+        <div class="flex-1 min-w-0">
+          <AgentChat v-if="chat" :key="sessionId!" :client="client" :session="chat" readonly @open-file="f => { debugTab = 'files'; preview = f }" />
+        </div>
+        <div v-if="chat" class="w-[380px] flex-none min-h-0">
+          <DebugPanel v-model:tab="debugTab" v-model:preview="preview" :state="chat.state" :session="detail" />
+        </div>
+      </div>
+    </template>
+  </SidePane>
+</template>

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

@@ -0,0 +1,181 @@
+<script setup lang="ts">
+import { computed, onMounted, ref, watch } from 'vue'
+import type { AppSettings, PathCheck } from '@agent-server/shared'
+import PageHeader from '../components/PageHeader.vue'
+import { admin, errorMessage } from '../lib/api'
+import { applyTheme, clone, savedTheme, toast, type Theme } from '../lib/ui'
+
+const draft = ref<AppSettings | null>(null)
+const original = ref('')
+const defaults = ref<AppSettings | null>(null)
+const builtinDeny = ref<string[]>([])
+const saving = ref(false)
+const pathChecks = ref<Record<string, PathCheck | null>>({})
+const theme = ref<Theme>(savedTheme())
+const pw = ref({ current: '', next: '', confirm: '' })
+
+const dirty = computed(() => !!draft.value && JSON.stringify(draft.value) !== original.value)
+const lines = (list: string[]) => list.join('\n')
+const parseLines = (s: string) => s.split('\n').map(x => x.trim()).filter(Boolean)
+
+async function load() {
+  const r = await admin.settings()
+  draft.value = clone(r.settings)
+  original.value = JSON.stringify(r.settings)
+  defaults.value = r.defaults
+  builtinDeny.value = r.builtinGlobalDeny
+}
+
+// 路径实时校验(只检查存在与可读,工作区根目录不做资料库限制)
+const PATH_KEYS = ['workspaceRoot', 'skillsDir', 'pythonRuntime', 'matplotlibrc'] as const
+let timer: ReturnType<typeof setTimeout> | undefined
+watch(() => draft.value && JSON.stringify(draft.value.paths), () => {
+  clearTimeout(timer)
+  timer = setTimeout(async () => {
+    for (const k of PATH_KEYS) {
+      const p = draft.value?.paths[k]
+      pathChecks.value[k] = p ? await admin.checkPath(p, 'generic').catch(() => null) : null
+    }
+  }, 400)
+})
+
+async function save() {
+  if (!draft.value) return
+  saving.value = true
+  try {
+    const r = await admin.updateSettings(draft.value)
+    draft.value = clone(r.settings)
+    original.value = JSON.stringify(r.settings)
+    toast('设置已保存,对新的运行立即生效', 'success')
+  } catch (err) {
+    toast(errorMessage(err), 'error')
+  } finally {
+    saving.value = false
+  }
+}
+
+function resetPaths() {
+  if (draft.value && defaults.value) draft.value.paths = clone(defaults.value.paths)
+}
+
+async function changePassword() {
+  if (pw.value.next !== pw.value.confirm) return toast('两次输入的新密码不一致', 'error')
+  try {
+    await admin.changePassword(pw.value.current, pw.value.next)
+    toast('密码已修改,请重新登录', 'success')
+    setTimeout(() => location.reload(), 800)
+  } catch (err) {
+    toast(errorMessage(err), 'error')
+  }
+}
+
+watch(theme, applyTheme)
+onMounted(() => load().catch(err => toast(errorMessage(err), 'error')))
+
+const LABELS: Record<(typeof PATH_KEYS)[number], string> = {
+  workspaceRoot: '会话工作区根目录', skillsDir: '共享技能目录', pythonRuntime: 'Python 运行环境(venv)', matplotlibrc: 'matplotlib 中文字体配置',
+}
+</script>
+
+<template>
+  <div class="h-full flex flex-col">
+    <PageHeader title="设置" subtitle="修改对新的运行立即生效">
+      <button type="button" class="btn-primary" :disabled="!dirty || saving" @click="save">{{ saving ? '保存中…' : '保存设置' }}</button>
+    </PageHeader>
+    <div v-if="draft" class="flex-1 overflow-y-auto">
+      <div class="max-w-200 p-6 flex flex-col gap-8">
+        <section class="flex flex-col gap-3">
+          <div class="flex items-center"><h2 class="section-title m-0 flex-1">路径</h2><button type="button" class="btn-ghost" @click="resetPaths">恢复默认</button></div>
+          <div v-for="k in PATH_KEYS" :key="k">
+            <label class="label" :for="`path-${k}`">{{ LABELS[k] }}</label>
+            <input :id="`path-${k}`" v-model="draft.paths[k]" class="field font-mono">
+            <div v-if="pathChecks[k]" class="text-[12px] mt-0.5" :class="pathChecks[k]!.exists ? 'text-fg-muted' : 'text-error'">
+              {{ pathChecks[k]!.exists ? '✓ 路径存在' : '路径不存在' }}
+            </div>
+          </div>
+          <div class="hint">资料库路径在"资料库"页面单独管理。</div>
+        </section>
+
+        <section class="flex flex-col gap-3">
+          <h2 class="section-title m-0">安全</h2>
+          <div>
+            <div class="label">内置禁止项(不可删除)</div>
+            <div class="rounded-md bg-code p-2 font-mono text-[12px] flex flex-col">
+              <span v-for="d in builtinDeny" :key="d"><span class="codicon codicon-lock text-[11px] text-fg-muted mr-1" />{{ d }}</span>
+            </div>
+          </div>
+          <div>
+            <label class="label" for="s-deny">追加的全局禁止项(每行一个路径或通配,所有 Profile 都不能访问)</label>
+            <textarea id="s-deny" class="field-area h-24" :value="lines(draft.security.globalDeny)" placeholder="~/Documents&#10;**/*.key" @change="draft.security.globalDeny = parseLines(($event.target as HTMLTextAreaElement).value)" />
+          </div>
+          <div>
+            <label class="label" for="s-mode">新建 Profile 的默认文件访问模式</label>
+            <select id="s-mode" v-model="draft.security.defaultFileAccessMode" class="field max-w-60">
+              <option value="restricted">受限(推荐)</option><option value="broad">宽松</option>
+            </select>
+          </div>
+        </section>
+
+        <section class="flex flex-col gap-3">
+          <h2 class="section-title m-0">网络</h2>
+          <div>
+            <label class="label" for="s-domains">Bash 命令允许访问的域名(每行一个,支持 *.example.com)</label>
+            <textarea id="s-domains" class="field-area h-24" :value="lines(draft.network.allowedDomains)" @change="draft.network.allowedDomains = parseLines(($event.target as HTMLTextAreaElement).value)" />
+            <div class="hint mt-1">只影响 Agent 在 Bash 中发起的请求;网页搜索由模型服务端执行,不受此限制。</div>
+          </div>
+          <label class="flex items-center gap-2 cursor-pointer"><input v-model="draft.network.blockPrivateFetch" type="checkbox">禁止网页抓取访问内网地址(推荐)</label>
+        </section>
+
+        <section class="flex flex-col gap-3">
+          <h2 class="section-title m-0">限制</h2>
+          <div class="grid grid-cols-3 gap-3">
+            <div><label class="label" for="c-g">全局并发</label><input id="c-g" v-model.number="draft.limits.concurrency.global" type="number" min="1" class="field"></div>
+            <div><label class="label" for="c-c">Claude 并发</label><input id="c-c" v-model.number="draft.limits.concurrency.claude" type="number" min="0" class="field"></div>
+            <div><label class="label" for="c-x">Codex 并发</label><input id="c-x" v-model.number="draft.limits.concurrency.codex" type="number" min="0" class="field"></div>
+            <div><label class="label" for="l-t">交互超时(秒)</label><input id="l-t" v-model.number="draft.limits.interactionTimeoutSec" type="number" min="30" class="field"></div>
+            <div><label class="label" for="l-u">单文件上传上限(MB)</label><input id="l-u" v-model.number="draft.limits.uploadMaxMB" type="number" min="1" class="field"></div>
+            <div><label class="label" for="l-r">归档工作区保留(天)</label><input id="l-r" v-model.number="draft.retention.workspaceDays" type="number" min="1" class="field"></div>
+          </div>
+          <div class="hint">同一份订阅额度与你在终端中的使用共享,并发不宜过高。</div>
+        </section>
+
+        <section class="flex flex-col gap-3">
+          <h2 class="section-title m-0">引擎</h2>
+          <div class="card p-4 flex flex-col gap-3">
+            <div class="flex items-center gap-2"><span class="font-600 flex-1">Claude Code</span><label class="flex items-center gap-1.5 cursor-pointer"><input v-model="draft.providers.claude.enabled" type="checkbox">启用</label></div>
+            <div><label class="label" for="cl-path">CLI 路径</label><input id="cl-path" v-model="draft.providers.claude.cliPath" class="field font-mono"></div>
+            <div class="grid grid-cols-2 gap-3">
+              <div><label class="label" for="cl-def">默认模型</label><input id="cl-def" v-model="draft.providers.claude.defaultModel" class="field"></div>
+              <div>
+                <label class="label" for="cl-models">可选模型(逗号分隔)</label>
+                <input id="cl-models" class="field" :value="draft.providers.claude.models.join(', ')" @change="draft.providers.claude.models = ($event.target as HTMLInputElement).value.split(',').map(s => s.trim()).filter(Boolean)">
+              </div>
+            </div>
+          </div>
+          <div class="card p-4 flex items-center gap-2 opacity-70">
+            <span class="font-600 flex-1">Codex</span><span class="hint">M2 接入(app-server),当前不可启用</span>
+          </div>
+        </section>
+
+        <section class="flex flex-col gap-3">
+          <h2 class="section-title m-0">外观</h2>
+          <div class="flex gap-4">
+            <label v-for="(label, t) in { system: '跟随系统', light: '亮色', dark: '暗色' }" :key="t" class="flex items-center gap-1.5 cursor-pointer">
+              <input v-model="theme" type="radio" :value="t">{{ label }}
+            </label>
+          </div>
+        </section>
+
+        <section class="flex flex-col gap-3">
+          <h2 class="section-title m-0">管理员密码</h2>
+          <form class="grid grid-cols-3 gap-3 items-end" @submit.prevent="changePassword">
+            <div><label class="label" for="pw-c">当前密码</label><input id="pw-c" v-model="pw.current" type="password" class="field" autocomplete="current-password"></div>
+            <div><label class="label" for="pw-n">新密码(至少 8 位)</label><input id="pw-n" v-model="pw.next" type="password" class="field" autocomplete="new-password"></div>
+            <div><label class="label" for="pw-r">确认新密码</label><input id="pw-r" v-model="pw.confirm" type="password" class="field" autocomplete="new-password"></div>
+            <button type="submit" class="btn-secondary self-start" :disabled="!pw.current || pw.next.length < 8">修改密码</button>
+          </form>
+        </section>
+      </div>
+    </div>
+  </div>
+</template>

+ 10 - 0
apps/admin/tsconfig.json

@@ -0,0 +1,10 @@
+{
+  "extends": "../../tsconfig.base.json",
+  "compilerOptions": {
+    "noEmit": true,
+    "lib": ["ES2023", "DOM", "DOM.Iterable"],
+    "jsx": "preserve",
+    "types": ["vite/client"]
+  },
+  "include": ["src/**/*.ts", "src/**/*.vue"]
+}

+ 5 - 0
apps/admin/tsconfig.node.json

@@ -0,0 +1,5 @@
+{
+  "extends": "../../tsconfig.base.json",
+  "compilerOptions": { "noEmit": true, "types": ["node"] },
+  "include": ["vite.config.ts", "uno.config.ts"]
+}

+ 6 - 0
apps/admin/uno.config.ts

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

+ 18 - 0
apps/admin/vite.config.ts

@@ -0,0 +1,18 @@
+import vue from '@vitejs/plugin-vue'
+import UnoCSS from 'unocss/vite'
+import { defineConfig } from 'vite'
+
+const target = process.env.AGENT_SERVER_URL ?? 'http://127.0.0.1:8787'
+
+export default defineConfig({
+  base: '/admin/',
+  plugins: [vue(), UnoCSS()],
+  server: {
+    host: '127.0.0.1',
+    port: 5173,
+    proxy: {
+      '/v1': { target, changeOrigin: false },
+      '/admin/v1': { target, changeOrigin: false },
+    },
+  },
+})

+ 7 - 0
apps/server/drizzle.config.ts

@@ -0,0 +1,7 @@
+import { defineConfig } from 'drizzle-kit'
+
+export default defineConfig({
+  dialect: 'sqlite',
+  schema: './src/db/schema.ts',
+  out: './drizzle',
+})

+ 134 - 0
apps/server/drizzle/0000_init.sql

@@ -0,0 +1,134 @@
+CREATE TABLE `admin_sessions` (
+	`id` text PRIMARY KEY NOT NULL,
+	`created_at` text DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) NOT NULL,
+	`expires_at` text NOT NULL
+);
+--> statement-breakpoint
+CREATE TABLE `api_keys` (
+	`id` text PRIMARY KEY NOT NULL,
+	`name` text NOT NULL,
+	`key_hash` text NOT NULL,
+	`key_prefix` text NOT NULL,
+	`enabled` integer DEFAULT true NOT NULL,
+	`created_at` text DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) NOT NULL,
+	`last_used_at` text
+);
+--> statement-breakpoint
+CREATE UNIQUE INDEX `api_keys_key_hash_unique` ON `api_keys` (`key_hash`);--> statement-breakpoint
+CREATE TABLE `audit_logs` (
+	`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
+	`ts` text DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) NOT NULL,
+	`actor` text NOT NULL,
+	`session_id` text,
+	`kind` text NOT NULL,
+	`summary` text NOT NULL,
+	`detail` text
+);
+--> statement-breakpoint
+CREATE INDEX `audit_ts_idx` ON `audit_logs` (`ts`);--> statement-breakpoint
+CREATE TABLE `events` (
+	`session_id` text NOT NULL,
+	`seq` integer NOT NULL,
+	`run_id` text,
+	`type` text NOT NULL,
+	`payload` text NOT NULL,
+	`ts` text NOT NULL,
+	PRIMARY KEY(`session_id`, `seq`)
+);
+--> statement-breakpoint
+CREATE TABLE `files` (
+	`id` text PRIMARY KEY NOT NULL,
+	`session_id` text NOT NULL,
+	`rel_path` text NOT NULL,
+	`origin` text NOT NULL,
+	`mime` text NOT NULL,
+	`size` integer NOT NULL,
+	`mtime_ms` real NOT NULL,
+	`created_at` text DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) NOT NULL,
+	`updated_at` text DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) NOT NULL
+);
+--> statement-breakpoint
+CREATE INDEX `files_session_path_idx` ON `files` (`session_id`,`rel_path`);--> statement-breakpoint
+CREATE TABLE `interactions` (
+	`id` text PRIMARY KEY NOT NULL,
+	`session_id` text NOT NULL,
+	`run_id` text NOT NULL,
+	`kind` text NOT NULL,
+	`request` text NOT NULL,
+	`response` text,
+	`status` text NOT NULL,
+	`created_at` text DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) NOT NULL,
+	`resolved_at` text,
+	`expires_at` text NOT NULL
+);
+--> statement-breakpoint
+CREATE INDEX `interactions_session_idx` ON `interactions` (`session_id`,`status`);--> statement-breakpoint
+CREATE TABLE `knowledge_roots` (
+	`id` text PRIMARY KEY NOT NULL,
+	`name` text NOT NULL,
+	`path` text NOT NULL,
+	`description` text DEFAULT '' NOT NULL,
+	`access` text DEFAULT 'read' NOT NULL,
+	`enabled` integer DEFAULT true NOT NULL,
+	`created_at` text DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) NOT NULL,
+	`updated_at` text DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) NOT NULL
+);
+--> statement-breakpoint
+CREATE TABLE `profiles` (
+	`id` text PRIMARY KEY NOT NULL,
+	`name` text NOT NULL,
+	`description` text DEFAULT '' NOT NULL,
+	`provider` text NOT NULL,
+	`model` text NOT NULL,
+	`system_prompt` text DEFAULT '' NOT NULL,
+	`permission_mode` text DEFAULT 'ask' NOT NULL,
+	`allowed_tools` text DEFAULT '[]' NOT NULL,
+	`disallowed_tools` text DEFAULT '[]' NOT NULL,
+	`skills` text DEFAULT '"all"' NOT NULL,
+	`file_access` text NOT NULL,
+	`knowledge_root_ids` text DEFAULT '[]' NOT NULL,
+	`is_default` integer DEFAULT false NOT NULL,
+	`version` integer DEFAULT 1 NOT NULL,
+	`created_at` text DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) NOT NULL,
+	`updated_at` text DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) NOT NULL
+);
+--> statement-breakpoint
+CREATE TABLE `runs` (
+	`id` text PRIMARY KEY NOT NULL,
+	`session_id` text NOT NULL,
+	`status` text NOT NULL,
+	`started_at` text,
+	`ended_at` text,
+	`input_tokens` integer,
+	`output_tokens` integer,
+	`cache_read_tokens` integer,
+	`cache_write_tokens` integer,
+	`cost_usd` real,
+	`error` text,
+	`created_at` text DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) NOT NULL
+);
+--> statement-breakpoint
+CREATE INDEX `runs_session_idx` ON `runs` (`session_id`);--> statement-breakpoint
+CREATE TABLE `sessions` (
+	`id` text PRIMARY KEY NOT NULL,
+	`source` text NOT NULL,
+	`profile_id` text,
+	`provider` text NOT NULL,
+	`model` text NOT NULL,
+	`permission_mode` text NOT NULL,
+	`system_prompt_append` text DEFAULT '' NOT NULL,
+	`provider_session_id` text,
+	`workspace_dir` text NOT NULL,
+	`title` text DEFAULT '' NOT NULL,
+	`last_seq` integer DEFAULT 0 NOT NULL,
+	`archived` integer DEFAULT false NOT NULL,
+	`created_at` text DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) NOT NULL,
+	`updated_at` text DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) NOT NULL
+);
+--> statement-breakpoint
+CREATE INDEX `sessions_source_idx` ON `sessions` (`source`,`updated_at`);--> statement-breakpoint
+CREATE TABLE `settings` (
+	`key` text PRIMARY KEY NOT NULL,
+	`value` text NOT NULL,
+	`updated_at` text DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) NOT NULL
+);

+ 883 - 0
apps/server/drizzle/meta/0000_snapshot.json

@@ -0,0 +1,883 @@
+{
+  "version": "6",
+  "dialect": "sqlite",
+  "id": "141f9f20-7e65-46e9-a8b2-be1e32e8f27b",
+  "prevId": "00000000-0000-0000-0000-000000000000",
+  "tables": {
+    "admin_sessions": {
+      "name": "admin_sessions",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        },
+        "expires_at": {
+          "name": "expires_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        }
+      },
+      "indexes": {},
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "api_keys": {
+      "name": "api_keys",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "name": {
+          "name": "name",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "key_hash": {
+          "name": "key_hash",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "key_prefix": {
+          "name": "key_prefix",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "enabled": {
+          "name": "enabled",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": true
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        },
+        "last_used_at": {
+          "name": "last_used_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        }
+      },
+      "indexes": {
+        "api_keys_key_hash_unique": {
+          "name": "api_keys_key_hash_unique",
+          "columns": [
+            "key_hash"
+          ],
+          "isUnique": true
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "audit_logs": {
+      "name": "audit_logs",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "integer",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": true
+        },
+        "ts": {
+          "name": "ts",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        },
+        "actor": {
+          "name": "actor",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "session_id": {
+          "name": "session_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "kind": {
+          "name": "kind",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "summary": {
+          "name": "summary",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "detail": {
+          "name": "detail",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        }
+      },
+      "indexes": {
+        "audit_ts_idx": {
+          "name": "audit_ts_idx",
+          "columns": [
+            "ts"
+          ],
+          "isUnique": false
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "events": {
+      "name": "events",
+      "columns": {
+        "session_id": {
+          "name": "session_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "seq": {
+          "name": "seq",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "run_id": {
+          "name": "run_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "type": {
+          "name": "type",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "payload": {
+          "name": "payload",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "ts": {
+          "name": "ts",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        }
+      },
+      "indexes": {},
+      "foreignKeys": {},
+      "compositePrimaryKeys": {
+        "events_session_id_seq_pk": {
+          "columns": [
+            "session_id",
+            "seq"
+          ],
+          "name": "events_session_id_seq_pk"
+        }
+      },
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "files": {
+      "name": "files",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "session_id": {
+          "name": "session_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "rel_path": {
+          "name": "rel_path",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "origin": {
+          "name": "origin",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "mime": {
+          "name": "mime",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "size": {
+          "name": "size",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "mtime_ms": {
+          "name": "mtime_ms",
+          "type": "real",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        }
+      },
+      "indexes": {
+        "files_session_path_idx": {
+          "name": "files_session_path_idx",
+          "columns": [
+            "session_id",
+            "rel_path"
+          ],
+          "isUnique": false
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "interactions": {
+      "name": "interactions",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "session_id": {
+          "name": "session_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "run_id": {
+          "name": "run_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "kind": {
+          "name": "kind",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "request": {
+          "name": "request",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "response": {
+          "name": "response",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "status": {
+          "name": "status",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        },
+        "resolved_at": {
+          "name": "resolved_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "expires_at": {
+          "name": "expires_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        }
+      },
+      "indexes": {
+        "interactions_session_idx": {
+          "name": "interactions_session_idx",
+          "columns": [
+            "session_id",
+            "status"
+          ],
+          "isUnique": false
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "knowledge_roots": {
+      "name": "knowledge_roots",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "name": {
+          "name": "name",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "path": {
+          "name": "path",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "description": {
+          "name": "description",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "''"
+        },
+        "access": {
+          "name": "access",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'read'"
+        },
+        "enabled": {
+          "name": "enabled",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": true
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        }
+      },
+      "indexes": {},
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "profiles": {
+      "name": "profiles",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "name": {
+          "name": "name",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "description": {
+          "name": "description",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "''"
+        },
+        "provider": {
+          "name": "provider",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "model": {
+          "name": "model",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "system_prompt": {
+          "name": "system_prompt",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "''"
+        },
+        "permission_mode": {
+          "name": "permission_mode",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'ask'"
+        },
+        "allowed_tools": {
+          "name": "allowed_tools",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'[]'"
+        },
+        "disallowed_tools": {
+          "name": "disallowed_tools",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'[]'"
+        },
+        "skills": {
+          "name": "skills",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'\"all\"'"
+        },
+        "file_access": {
+          "name": "file_access",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "knowledge_root_ids": {
+          "name": "knowledge_root_ids",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'[]'"
+        },
+        "is_default": {
+          "name": "is_default",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": false
+        },
+        "version": {
+          "name": "version",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": 1
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        }
+      },
+      "indexes": {},
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "runs": {
+      "name": "runs",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "session_id": {
+          "name": "session_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "status": {
+          "name": "status",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "started_at": {
+          "name": "started_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "ended_at": {
+          "name": "ended_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "input_tokens": {
+          "name": "input_tokens",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "output_tokens": {
+          "name": "output_tokens",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "cache_read_tokens": {
+          "name": "cache_read_tokens",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "cache_write_tokens": {
+          "name": "cache_write_tokens",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "cost_usd": {
+          "name": "cost_usd",
+          "type": "real",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "error": {
+          "name": "error",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        }
+      },
+      "indexes": {
+        "runs_session_idx": {
+          "name": "runs_session_idx",
+          "columns": [
+            "session_id"
+          ],
+          "isUnique": false
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "sessions": {
+      "name": "sessions",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "source": {
+          "name": "source",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "profile_id": {
+          "name": "profile_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "provider": {
+          "name": "provider",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "model": {
+          "name": "model",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "permission_mode": {
+          "name": "permission_mode",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "system_prompt_append": {
+          "name": "system_prompt_append",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "''"
+        },
+        "provider_session_id": {
+          "name": "provider_session_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "workspace_dir": {
+          "name": "workspace_dir",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "title": {
+          "name": "title",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "''"
+        },
+        "last_seq": {
+          "name": "last_seq",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": 0
+        },
+        "archived": {
+          "name": "archived",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": false
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        }
+      },
+      "indexes": {
+        "sessions_source_idx": {
+          "name": "sessions_source_idx",
+          "columns": [
+            "source",
+            "updated_at"
+          ],
+          "isUnique": false
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "settings": {
+      "name": "settings",
+      "columns": {
+        "key": {
+          "name": "key",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "value": {
+          "name": "value",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        }
+      },
+      "indexes": {},
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    }
+  },
+  "views": {},
+  "enums": {},
+  "_meta": {
+    "schemas": {},
+    "tables": {},
+    "columns": {}
+  },
+  "internal": {
+    "indexes": {}
+  }
+}

+ 13 - 0
apps/server/drizzle/meta/_journal.json

@@ -0,0 +1,13 @@
+{
+  "version": "7",
+  "dialect": "sqlite",
+  "entries": [
+    {
+      "idx": 0,
+      "version": "6",
+      "when": 1792660103685,
+      "tag": "0000_init",
+      "breakpoints": true
+    }
+  ]
+}

+ 42 - 0
apps/server/package.json

@@ -0,0 +1,42 @@
+{
+  "name": "@agent-server/server",
+  "version": "0.1.0",
+  "private": true,
+  "type": "module",
+  "scripts": {
+    "dev": "tsx watch --clear-screen=false src/main.ts",
+    "build": "tsup",
+    "start": "node dist/main.js",
+    "typecheck": "tsc --noEmit",
+    "test": "vitest run",
+    "db:generate": "drizzle-kit generate"
+  },
+  "dependencies": {
+    "@agent-server/shared": "workspace:^",
+    "@anthropic-ai/claude-agent-sdk": "^0.3.308",
+    "@fastify/cookie": "^11.1.3",
+    "@fastify/multipart": "^10.1.5",
+    "@fastify/static": "^10.1.7",
+    "better-sqlite3": "^13.0.3",
+    "drizzle-orm": "^0.45.4",
+    "fastify": "^5.12.7",
+    "mime-types": "^3.0.2",
+    "p-queue": "^10.0.0",
+    "picomatch": "^4.0.7",
+    "pino": "^10.4.0",
+    "ulid": "^3.0.2",
+    "zod": "^4.6.7"
+  },
+  "devDependencies": {
+    "@types/better-sqlite3": "^9.6.0",
+    "@types/mime-types": "^3.0.1",
+    "@types/node": "^26.6.5",
+    "@types/picomatch": "^4.0.3",
+    "drizzle-kit": "^0.31.11",
+    "pino-pretty": "^13.2.0",
+    "tsup": "^9.0.0",
+    "tsx": "^4.23.15",
+    "typescript": "^5.9.3",
+    "vitest": "^5.0.5"
+  }
+}

+ 36 - 0
apps/server/scripts/smoke-claude.ts

@@ -0,0 +1,36 @@
+// 手动冒烟测试:验证 Agent SDK 能用本机登录态运行,并观察消息流。
+// 用法:pnpm tsx scripts/smoke-claude.ts <cwd> [prompt]
+import { query } from '@anthropic-ai/claude-agent-sdk'
+
+const cwd = process.argv[2] ?? process.cwd()
+const prompt = process.argv[3] ?? '用 Bash 运行 `echo hello > out.txt`,然后读取 out.txt 告诉我内容。'
+
+const q = query({
+  prompt,
+  options: {
+    cwd,
+    model: 'haiku',
+    settingSources: [],
+    includePartialMessages: true,
+    pathToClaudeCodeExecutable: `${process.env.HOME}/.local/bin/claude`,
+    sandbox: { enabled: true, failIfUnavailable: true, allowUnsandboxedCommands: false },
+    canUseTool: async (tool, input, opts) => {
+      console.log('[canUseTool]', tool, JSON.stringify(input).slice(0, 200), opts.decisionReason ?? '')
+      return { behavior: 'allow', updatedInput: input }
+    },
+  },
+})
+
+for await (const m of q) {
+  if (m.type === 'stream_event') {
+    const e = m.event
+    if (e.type === 'content_block_delta' && e.delta.type === 'text_delta') process.stdout.write(e.delta.text)
+    continue
+  }
+  const brief = m.type === 'system' ? `${m.subtype} ${'apiKeySource' in m ? m.apiKeySource : ''} ${'model' in m ? m.model : ''} ${'claude_code_version' in m ? m.claude_code_version : ''}`
+    : m.type === 'assistant' ? m.message.content.map(b => b.type).join(',')
+    : m.type === 'user' ? JSON.stringify(m.message.content).slice(0, 160)
+    : m.type === 'result' ? `${m.subtype} cost=${m.total_cost_usd} turns=${m.num_turns}`
+    : ''
+  console.log(`\n<${m.type}> ${brief}`)
+}

+ 32 - 0
apps/server/scripts/try-claude-adapter.ts

@@ -0,0 +1,32 @@
+// 手动验证 ClaudeProvider:审批流、越权拦截、事件映射。
+// 用法:pnpm tsx scripts/try-claude-adapter.ts <workspaceDir> <permissionMode> <prompt>
+import type { PermissionMode } from '@agent-server/shared'
+import { ClaudeProvider } from '../src/providers/claude/adapter'
+import { resolveAccess } from '../src/services/access-policy'
+import { defaultSettings } from '../src/services/settings'
+
+const [ws = process.cwd(), mode = 'ask', prompt = 'hi'] = process.argv.slice(2)
+const settings = defaultSettings()
+settings.providers.claude.defaultModel = 'haiku'
+const access = resolveAccess({
+  policy: { mode: 'restricted', read: [], write: [], deny: [] },
+  knowledgeRoots: [], selectedKnowledgeRootIds: [], workspaceDir: ws, settings,
+})
+const ac = new AbortController()
+const res = await new ClaudeProvider().run({
+  sessionId: 'ses_test', runId: 'run_test', model: 'haiku', permissionMode: mode as PermissionMode,
+  providerSessionId: null, workspaceDir: ws, systemPromptAppend: '', allowedTools: [], disallowedTools: [], skills: 'all',
+  input: { text: prompt, images: [], attachments: [] }, access, settings, signal: ac.signal,
+  emit: (type, payload) => {
+    if (type === 'message.delta' || type === 'reasoning.delta') return
+    console.log(`[event] ${type}`, JSON.stringify(payload).slice(0, Number(process.env.EVENT_MAX ?? 220)))
+  },
+  interact: async req => {
+    console.log('[interact]', JSON.stringify(req).slice(0, 300))
+    return req.kind === 'approval' ? { resolution: 'allowed', scope: 'once' } : { resolution: 'answered', answers: { '0': req.questions[0]!.options[0]!.label } }
+  },
+  setProviderSessionId: id => console.log('[providerSessionId]', id),
+  audit: (kind, summary) => console.log(`[audit] ${kind}: ${summary}`),
+  log: { info: () => {}, warn: (o: object) => console.log('[warn]', JSON.stringify(o).slice(0, 200)), error: (o: object) => console.log('[error]', o) },
+})
+console.log('[result]', JSON.stringify(res))

+ 43 - 0
apps/server/src/app.ts

@@ -0,0 +1,43 @@
+// 服务容器:组装数据库、各个 service 与引擎
+import type { ProviderId } from '@agent-server/shared'
+import { config, dbPath } from './config'
+import { createDb, migrateDb, type DB } from './db'
+import { ClaudeProvider } from './providers/claude/adapter'
+import type { AgentProvider } from './providers/types'
+import { AdminAuthService } from './services/admin-auth'
+import { ApiKeyService, AuditService, KnowledgeService, ProfileService } from './services/catalog'
+import { EventStore } from './services/event-store'
+import { FileService } from './services/files'
+import { InteractionService } from './services/interactions'
+import { RunManager } from './services/runs'
+import { SessionService } from './services/sessions'
+import { SettingsService } from './services/settings'
+
+export interface AppLogger {
+  info(o: object, m?: string): void
+  warn(o: object, m?: string): void
+  error(o: object, m?: string): void
+  child(b: object): AppLogger
+}
+
+export function createApp(log: AppLogger, opts: { db?: DB; providers?: Partial<Record<ProviderId, AgentProvider>> } = {}) {
+  const db = opts.db ?? createDb(dbPath())
+  migrateDb(db)
+
+  const settings = new SettingsService(db)
+  const events = new EventStore(db)
+  const files = new FileService(db)
+  const profiles = new ProfileService(db, settings)
+  const knowledge = new KnowledgeService(db, settings)
+  const apiKeys = new ApiKeyService(db)
+  const audit = new AuditService(db)
+  const interactions = new InteractionService(db, events, settings)
+  const sessions = new SessionService(db, settings, profiles, files, events)
+  const adminAuth = new AdminAuthService(db, log, config.adminPassword)
+  const providers: Partial<Record<ProviderId, AgentProvider>> = opts.providers ?? { claude: new ClaudeProvider() }
+  const runs = new RunManager({ db, log, settings, sessions, profiles, knowledge, files, events, interactions, audit, providers })
+
+  return { db, log, settings, events, files, profiles, knowledge, apiKeys, audit, interactions, sessions, adminAuth, providers, runs }
+}
+
+export type App = ReturnType<typeof createApp>

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

@@ -0,0 +1,21 @@
+// 启动必需配置(ADR-0011):其余运行配置都在后台 settings 中管理
+import os from 'node:os'
+import path from 'node:path'
+
+const env = process.env
+
+export const config = {
+  host: env.AGENT_SERVER_HOST ?? '127.0.0.1',
+  port: Number(env.AGENT_SERVER_PORT ?? 8787),
+  /** 服务数据目录 */
+  dataDir: path.resolve(env.AGENT_SERVER_DATA_DIR ?? '/srv/agent-server'),
+  /** 首次启动时的后台密码;未设置则随机生成并打印到日志 */
+  adminPassword: env.AGENT_SERVER_ADMIN_PASSWORD,
+  /** 后台静态资源目录(生产部署时由服务托管) */
+  adminDist: env.AGENT_SERVER_ADMIN_DIST,
+  home: os.homedir(),
+  isDev: env.NODE_ENV !== 'production',
+  logLevel: env.LOG_LEVEL ?? 'info',
+}
+
+export const dbPath = () => path.join(config.dataDir, 'data', 'agent.db')

+ 34 - 0
apps/server/src/db/index.ts

@@ -0,0 +1,34 @@
+import fs from 'node:fs'
+import path from 'node:path'
+import { fileURLToPath } from 'node:url'
+import Database from 'better-sqlite3'
+import { drizzle } from 'drizzle-orm/better-sqlite3'
+import { migrate } from 'drizzle-orm/better-sqlite3/migrator'
+import * as schema from './schema'
+
+export type DB = ReturnType<typeof createDb>
+
+export function createDb(file: string) {
+  fs.mkdirSync(path.dirname(file), { recursive: true })
+  const sqlite = new Database(file)
+  sqlite.pragma('journal_mode = WAL')
+  sqlite.pragma('synchronous = NORMAL')
+  sqlite.pragma('foreign_keys = ON')
+  sqlite.pragma('busy_timeout = 5000')
+  return drizzle(sqlite, { schema })
+}
+
+/** 迁移目录:开发时在 apps/server/drizzle,打包后与 dist 同级 */
+function migrationsFolder() {
+  const here = path.dirname(fileURLToPath(import.meta.url))
+  for (const candidate of [path.join(here, '../../drizzle'), path.join(here, '../drizzle')]) {
+    if (fs.existsSync(path.join(candidate, 'meta/_journal.json'))) return candidate
+  }
+  throw new Error('找不到数据库迁移目录 drizzle/')
+}
+
+export function migrateDb(db: DB) {
+  migrate(db, { migrationsFolder: migrationsFolder() })
+}
+
+export { schema }

+ 136 - 0
apps/server/src/db/schema.ts

@@ -0,0 +1,136 @@
+import { sql } from 'drizzle-orm'
+import { index, integer, primaryKey, real, sqliteTable, text } from 'drizzle-orm/sqlite-core'
+import type { FileAccessPolicy, InteractionRequest, InteractionResponse, PermissionMode } from '@agent-server/shared'
+
+const now = sql`(strftime('%Y-%m-%dT%H:%M:%fZ','now'))`
+const createdAt = () => text('created_at').notNull().default(now)
+const updatedAt = () => text('updated_at').notNull().default(now)
+
+export const apiKeys = sqliteTable('api_keys', {
+  id: text('id').primaryKey(),
+  name: text('name').notNull(),
+  keyHash: text('key_hash').notNull().unique(),
+  keyPrefix: text('key_prefix').notNull(),
+  enabled: integer('enabled', { mode: 'boolean' }).notNull().default(true),
+  createdAt: createdAt(),
+  lastUsedAt: text('last_used_at'),
+})
+
+export const profiles = sqliteTable('profiles', {
+  id: text('id').primaryKey(),
+  name: text('name').notNull(),
+  description: text('description').notNull().default(''),
+  provider: text('provider', { enum: ['claude', 'codex'] }).notNull(),
+  model: text('model').notNull(),
+  systemPrompt: text('system_prompt').notNull().default(''),
+  permissionMode: text('permission_mode').$type<PermissionMode>().notNull().default('ask'),
+  allowedTools: text('allowed_tools', { mode: 'json' }).$type<string[]>().notNull().default(sql`'[]'`),
+  disallowedTools: text('disallowed_tools', { mode: 'json' }).$type<string[]>().notNull().default(sql`'[]'`),
+  skills: text('skills', { mode: 'json' }).$type<string[] | 'all'>().notNull().default(sql`'"all"'`),
+  fileAccess: text('file_access', { mode: 'json' }).$type<FileAccessPolicy>().notNull(),
+  knowledgeRootIds: text('knowledge_root_ids', { mode: 'json' }).$type<string[]>().notNull().default(sql`'[]'`),
+  isDefault: integer('is_default', { mode: 'boolean' }).notNull().default(false),
+  version: integer('version').notNull().default(1),
+  createdAt: createdAt(),
+  updatedAt: updatedAt(),
+})
+
+export const knowledgeRoots = sqliteTable('knowledge_roots', {
+  id: text('id').primaryKey(),
+  name: text('name').notNull(),
+  path: text('path').notNull(),
+  description: text('description').notNull().default(''),
+  access: text('access', { enum: ['read', 'write'] }).notNull().default('read'),
+  enabled: integer('enabled', { mode: 'boolean' }).notNull().default(true),
+  createdAt: createdAt(),
+  updatedAt: updatedAt(),
+})
+
+export const sessions = sqliteTable('sessions', {
+  id: text('id').primaryKey(),
+  /** 调用来源:API Key id,或后台的 'admin' */
+  source: text('source').notNull(),
+  profileId: text('profile_id'),
+  provider: text('provider', { enum: ['claude', 'codex'] }).notNull(),
+  model: text('model').notNull(),
+  permissionMode: text('permission_mode').$type<PermissionMode>().notNull(),
+  systemPromptAppend: text('system_prompt_append').notNull().default(''),
+  providerSessionId: text('provider_session_id'),
+  workspaceDir: text('workspace_dir').notNull(),
+  title: text('title').notNull().default(''),
+  lastSeq: integer('last_seq').notNull().default(0),
+  archived: integer('archived', { mode: 'boolean' }).notNull().default(false),
+  createdAt: createdAt(),
+  updatedAt: updatedAt(),
+}, t => [index('sessions_source_idx').on(t.source, t.updatedAt)])
+
+export const runs = sqliteTable('runs', {
+  id: text('id').primaryKey(),
+  sessionId: text('session_id').notNull(),
+  status: text('status', { enum: ['queued', 'running', 'waiting_input', 'completed', 'failed', 'interrupted'] }).notNull(),
+  startedAt: text('started_at'),
+  endedAt: text('ended_at'),
+  inputTokens: integer('input_tokens'),
+  outputTokens: integer('output_tokens'),
+  cacheReadTokens: integer('cache_read_tokens'),
+  cacheWriteTokens: integer('cache_write_tokens'),
+  costUsd: real('cost_usd'),
+  error: text('error'),
+  createdAt: createdAt(),
+}, t => [index('runs_session_idx').on(t.sessionId)])
+
+export const events = sqliteTable('events', {
+  sessionId: text('session_id').notNull(),
+  seq: integer('seq').notNull(),
+  runId: text('run_id'),
+  type: text('type').notNull(),
+  payload: text('payload', { mode: 'json' }).notNull(),
+  ts: text('ts').notNull(),
+}, t => [primaryKey({ columns: [t.sessionId, t.seq] })])
+
+export const interactions = sqliteTable('interactions', {
+  id: text('id').primaryKey(),
+  sessionId: text('session_id').notNull(),
+  runId: text('run_id').notNull(),
+  kind: text('kind', { enum: ['approval', 'question'] }).notNull(),
+  request: text('request', { mode: 'json' }).$type<InteractionRequest>().notNull(),
+  response: text('response', { mode: 'json' }).$type<InteractionResponse>(),
+  status: text('status', { enum: ['pending', 'allowed', 'denied', 'answered', 'timeout', 'cancelled'] }).notNull(),
+  createdAt: createdAt(),
+  resolvedAt: text('resolved_at'),
+  expiresAt: text('expires_at').notNull(),
+}, t => [index('interactions_session_idx').on(t.sessionId, t.status)])
+
+export const files = sqliteTable('files', {
+  id: text('id').primaryKey(),
+  sessionId: text('session_id').notNull(),
+  relPath: text('rel_path').notNull(),
+  origin: text('origin', { enum: ['upload', 'agent'] }).notNull(),
+  mime: text('mime').notNull(),
+  size: integer('size').notNull(),
+  mtimeMs: real('mtime_ms').notNull(),
+  createdAt: createdAt(),
+  updatedAt: updatedAt(),
+}, t => [index('files_session_path_idx').on(t.sessionId, t.relPath)])
+
+export const settings = sqliteTable('settings', {
+  key: text('key').primaryKey(),
+  value: text('value', { mode: 'json' }).notNull(),
+  updatedAt: updatedAt(),
+})
+
+export const auditLogs = sqliteTable('audit_logs', {
+  id: integer('id').primaryKey({ autoIncrement: true }),
+  ts: text('ts').notNull().default(now),
+  actor: text('actor').notNull(),
+  sessionId: text('session_id'),
+  kind: text('kind', { enum: ['command', 'file_access_denied', 'network_denied', 'approval', 'admin'] }).notNull(),
+  summary: text('summary').notNull(),
+  detail: text('detail', { mode: 'json' }),
+}, t => [index('audit_ts_idx').on(t.ts)])
+
+export const adminSessions = sqliteTable('admin_sessions', {
+  id: text('id').primaryKey(),
+  createdAt: createdAt(),
+  expiresAt: text('expires_at').notNull(),
+})

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

@@ -0,0 +1,58 @@
+import type { FastifyReply, FastifyRequest } from 'fastify'
+import type { App } from '../app'
+import { AppError } from '../lib/errors'
+
+export const ADMIN_COOKIE = 'agent_admin'
+
+/** 调用方:上层平台(API Key)或后台管理员 */
+export interface Principal {
+  kind: 'apikey' | 'admin'
+  /** 写入 sessions.source:API Key id 或 'admin' */
+  source: string
+}
+
+declare module 'fastify' {
+  interface FastifyRequest {
+    principal?: Principal
+  }
+}
+
+function bearer(req: FastifyRequest): string | undefined {
+  const h = req.headers.authorization
+  return h?.startsWith('Bearer ') ? h.slice(7).trim() : undefined
+}
+
+export function isAdmin(app: App, req: FastifyRequest) {
+  return app.adminAuth.validate(req.cookies[ADMIN_COOKIE])
+}
+
+/** /v1:API Key 或后台登录态均可 */
+export function requireClient(app: App) {
+  return async (req: FastifyRequest, _reply: FastifyReply) => {
+    const key = bearer(req)
+    if (key) {
+      const id = app.apiKeys.verify(key)
+      if (!id) throw new AppError(401, 'invalid_api_key', 'API Key 无效或已停用')
+      req.principal = { kind: 'apikey', source: id }
+      return
+    }
+    if (isAdmin(app, req)) {
+      req.principal = { kind: 'admin', source: 'admin' }
+      return
+    }
+    throw new AppError(401, 'unauthorized', '缺少 API Key')
+  }
+}
+
+/** /admin/v1:仅后台登录态 */
+export function requireAdmin(app: App) {
+  return async (req: FastifyRequest, _reply: FastifyReply) => {
+    if (!isAdmin(app, req)) throw new AppError(401, 'unauthorized', '请先登录后台')
+    req.principal = { kind: 'admin', source: 'admin' }
+  }
+}
+
+export const principal = (req: FastifyRequest): Principal => {
+  if (!req.principal) throw new AppError(401, 'unauthorized', '未认证')
+  return req.principal
+}

+ 154 - 0
apps/server/src/http/routes/admin.ts

@@ -0,0 +1,154 @@
+// 管理 API:仅后台登录态可访问
+import type { FastifyInstance } from 'fastify'
+import { z } from 'zod'
+import { BUILTIN_GLOBAL_DENY, type AppSettings, type DeepPartial, type FileAccessPolicy } from '@agent-server/shared'
+import type { App } from '../../app'
+import { AppError } from '../../lib/errors'
+import { listSkills } from '../../providers/claude/adapter'
+import { checkKnowledgePath, checkPathBasic, resolveAccess, toResolvedDto } from '../../services/access-policy'
+import { parse, type AuditKind, type KnowledgeInput } from '../../services/catalog'
+import { defaultSettings } from '../../services/settings'
+import { ADMIN_COOKIE, isAdmin, requireAdmin } from '../auth'
+import { providerStatuses } from './v1'
+
+type IdParams = { Params: { id: string } }
+
+export default async function adminRoutes(f: FastifyInstance, { app }: { app: App }) {
+  // ---- 登录(无需认证)----
+  // 简单的失败限流:每个 IP 10 分钟内最多失败 5 次
+  const failures = new Map<string, number[]>()
+  f.post('/auth/login', async (req, reply) => {
+    const { password } = parse(z.object({ password: z.string().min(1) }), req.body)
+    const recent = (failures.get(req.ip) ?? []).filter(t => t > Date.now() - 600_000)
+    if (recent.length >= 5) throw new AppError(429, 'too_many_attempts', '登录失败次数过多,请 10 分钟后再试')
+    let s
+    try {
+      s = app.adminAuth.login(password)
+      failures.delete(req.ip)
+    } catch (err) {
+      failures.set(req.ip, [...recent, Date.now()])
+      throw err
+    }
+    reply.setCookie(ADMIN_COOKIE, s.id, { path: '/', httpOnly: true, sameSite: 'strict', secure: req.protocol === 'https', expires: s.expiresAt })
+    app.audit.log({ actor: 'admin', kind: 'admin', summary: '后台登录', detail: { ip: req.ip } })
+    return { ok: true }
+  })
+
+  f.post('/auth/logout', async (req, reply) => {
+    app.adminAuth.logout(req.cookies[ADMIN_COOKIE])
+    reply.clearCookie(ADMIN_COOKIE, { path: '/' })
+    return { ok: true }
+  })
+
+  f.get('/auth/me', async req => ({ loggedIn: isAdmin(app, req) }))
+
+  // ---- 以下需要登录 ----
+  f.register(async g => {
+    g.addHook('preHandler', requireAdmin(app))
+
+    g.post('/auth/password', async req => {
+      const body = parse(z.object({ current: z.string(), next: z.string().min(8) }), req.body)
+      app.adminAuth.changePassword(body.current, body.next)
+      app.audit.log({ actor: 'admin', kind: 'admin', summary: '修改后台密码' })
+      return { ok: true }
+    })
+
+    g.get('/overview', async () => ({
+      providers: await providerStatuses(app),
+      runs: app.runs.stats(),
+      version: '0.1.0',
+    }))
+
+    g.get('/providers', async () => providerStatuses(app))
+
+    // 设置
+    g.get('/settings', async () => ({ settings: app.settings.get(), defaults: defaultSettings(), builtinGlobalDeny: BUILTIN_GLOBAL_DENY }))
+    g.patch('/settings', async req => {
+      const next = app.settings.update(req.body as DeepPartial<AppSettings>)
+      app.audit.log({ actor: 'admin', kind: 'admin', summary: '修改系统设置', detail: req.body })
+      return { settings: next }
+    })
+    g.get('/skills', async () => listSkills(app.settings.get().paths.skillsDir))
+
+    g.post('/paths/check', async req => {
+      const { path, purpose } = parse(z.object({ path: z.string().min(1), purpose: z.enum(['knowledge', 'generic']).default('knowledge') }), req.body)
+      return purpose === 'generic' ? checkPathBasic(path) : checkKnowledgePath(path, app.settings.get())
+    })
+
+    // Profile
+    g.get('/profiles', async () => app.profiles.list())
+    g.get<IdParams>('/profiles/:id', async req => app.profiles.get(req.params.id))
+    g.post('/profiles', async (req, reply) => {
+      reply.code(201)
+      const p = app.profiles.create(req.body as never)
+      app.audit.log({ actor: 'admin', kind: 'admin', summary: `创建 Profile:${p.name}` })
+      return p
+    })
+    g.put<IdParams>('/profiles/:id', async req => {
+      const p = app.profiles.update(req.params.id, req.body as never)
+      app.audit.log({ actor: 'admin', kind: 'admin', summary: `修改 Profile:${p.name}`, detail: { version: p.version } })
+      return p
+    })
+    g.delete<IdParams>('/profiles/:id', async (req, reply) => {
+      app.profiles.delete(req.params.id)
+      app.audit.log({ actor: 'admin', kind: 'admin', summary: `删除 Profile:${req.params.id}` })
+      reply.code(204)
+    })
+    /** 文件访问策略生效预览(06 §4.3) */
+    g.post('/access/preview', async req => {
+      const body = req.body as { fileAccess: FileAccessPolicy; knowledgeRootIds: string[] }
+      const settings = app.settings.get()
+      return toResolvedDto(resolveAccess({
+        policy: body.fileAccess,
+        knowledgeRoots: app.knowledge.list(),
+        selectedKnowledgeRootIds: body.knowledgeRootIds ?? [],
+        workspaceDir: `${settings.paths.workspaceRoot}/<会话ID>`,
+        settings,
+      }))
+    })
+
+    // 资料库
+    g.get('/knowledge', async () => app.knowledge.list())
+    g.post('/knowledge', async (req, reply) => {
+      reply.code(201)
+      const k = app.knowledge.create(req.body as KnowledgeInput)
+      app.audit.log({ actor: 'admin', kind: 'admin', summary: `添加资料库:${k.name}(${k.path})` })
+      return k
+    })
+    g.put<IdParams>('/knowledge/:id', async req => {
+      const k = app.knowledge.update(req.params.id, req.body as KnowledgeInput)
+      app.audit.log({ actor: 'admin', kind: 'admin', summary: `修改资料库:${k.name}(${k.path})` })
+      return k
+    })
+    g.delete<IdParams>('/knowledge/:id', async (req, reply) => {
+      app.knowledge.delete(req.params.id)
+      app.audit.log({ actor: 'admin', kind: 'admin', summary: `删除资料库:${req.params.id}` })
+      reply.code(204)
+    })
+
+    // API Key
+    g.get('/api-keys', async () => app.apiKeys.list())
+    g.post('/api-keys', async (req, reply) => {
+      const { name } = parse(z.object({ name: z.string().trim().min(1).max(60) }), req.body)
+      reply.code(201)
+      const r = app.apiKeys.create(name)
+      app.audit.log({ actor: 'admin', kind: 'admin', summary: `签发 API Key:${name}` })
+      return r
+    })
+    g.patch<IdParams>('/api-keys/:id', async req => {
+      const { enabled } = parse(z.object({ enabled: z.boolean() }), req.body)
+      app.apiKeys.setEnabled(req.params.id, enabled)
+      app.audit.log({ actor: 'admin', kind: 'admin', summary: `${enabled ? '启用' : '停用'} API Key:${req.params.id}` })
+      return { ok: true }
+    })
+    g.delete<IdParams>('/api-keys/:id', async (req, reply) => {
+      app.apiKeys.delete(req.params.id)
+      app.audit.log({ actor: 'admin', kind: 'admin', summary: `删除 API Key:${req.params.id}` })
+      reply.code(204)
+    })
+
+    // 审计
+    g.get<{ Querystring: { kind?: AuditKind; sessionId?: string; limit?: string } }>('/audit', async req =>
+      app.audit.list({ kind: req.query.kind, sessionId: req.query.sessionId, limit: req.query.limit ? Number(req.query.limit) : undefined }))
+  })
+}

+ 179 - 0
apps/server/src/http/routes/v1.ts

@@ -0,0 +1,179 @@
+// 业务 API(docs/design/04-api.md)
+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 { z } from 'zod'
+import type { App } from '../../app'
+import { badRequest, notFound } from '../../lib/errors'
+import { parse } from '../../services/catalog'
+import { principal, requireClient } from '../auth'
+import { streamSessionEvents } from '../sse'
+
+type IdParams = { Params: { id: string } }
+
+const interactionResponseSchema: z.ZodType<InteractionResponse> = z.union([
+  z.object({ decision: z.enum(['allow_once', 'allow_session']) }),
+  z.object({ decision: z.literal('deny'), reason: z.string().max(2000).optional() }),
+  z.object({ answers: z.record(z.string(), z.union([z.string(), z.array(z.string())])) }),
+])
+
+export async function providerStatuses(app: App): Promise<ProviderStatusDto[]> {
+  const settings = app.settings.get()
+  const result: ProviderStatusDto[] = []
+  for (const id of ['claude', 'codex'] as const) {
+    const p = app.providers[id]
+    if (p) {
+      result.push(await p.status(settings))
+    } else {
+      const s = settings.providers[id]
+      result.push({
+        id, enabled: s.enabled, available: false, loggedIn: null, account: null, cliPath: s.cliPath,
+        version: null, models: s.models, defaultModel: s.defaultModel, message: '尚未接入(M2)',
+      })
+    }
+  }
+  return result
+}
+
+export default async function v1Routes(f: FastifyInstance, { app }: { app: App }) {
+  f.addHook('preHandler', requireClient(app))
+
+  const sessionFor = (req: FastifyRequest<IdParams>) => app.sessions.getRow(req.params.id, principal(req).source)
+
+  // 引擎状态缓存 30 秒,避免频繁调用 CLI
+  let statusCache: { at: number; data: ProviderStatusDto[] } | null = null
+  f.get('/providers', async () => {
+    if (!statusCache || Date.now() - statusCache.at > 30_000) statusCache = { at: Date.now(), data: await providerStatuses(app) }
+    return statusCache.data
+  })
+
+  f.get('/profiles', async () => app.profiles.list().map(p => ({
+    id: p.id, name: p.name, description: p.description, provider: p.provider, model: p.model, permissionMode: p.permissionMode, isDefault: p.isDefault,
+  })))
+
+  // ---- 会话 ----
+  f.post('/sessions', async (req, reply) => {
+    const row = await app.sessions.create(principal(req).source, (req.body ?? {}) as object)
+    reply.code(201)
+    return app.sessions.toDto(row)
+  })
+
+  f.get<{ Querystring: { cursor?: string; limit?: string; scope?: string; archived?: string } }>('/sessions', async req => {
+    const q = req.query
+    return app.sessions.list(principal(req).source, {
+      cursor: q.cursor,
+      limit: q.limit ? Number(q.limit) : undefined,
+      all: q.scope === 'all',
+      archived: q.archived === undefined ? undefined : q.archived === 'true',
+    })
+  })
+
+  f.get<IdParams>('/sessions/:id', async (req): Promise<SessionDetailDto> => {
+    const row = sessionFor(req)
+    return {
+      ...app.sessions.toDto(row),
+      activeRunId: app.runs.activeForSession(row.id)?.runId ?? null,
+      pendingInteractions: app.interactions.pendingFor(row.id),
+      permissionMode: row.permissionMode,
+      systemPromptAppend: row.systemPromptAppend,
+    }
+  })
+
+  f.patch<IdParams>('/sessions/:id', async req => app.sessions.toDto(app.sessions.patch(req.params.id, principal(req).source, req.body)))
+
+  f.delete<IdParams>('/sessions/:id', async (req, reply) => {
+    await app.sessions.delete(req.params.id, principal(req).source)
+    reply.code(204)
+  })
+
+  f.get<IdParams & { Querystring: { after?: string; limit?: string } }>('/sessions/:id/events', async req => {
+    const row = sessionFor(req)
+    return { items: app.events.list(row.id, Number(req.query.after ?? 0), Math.min(Number(req.query.limit ?? 5000), 5000)), lastSeq: row.lastSeq }
+  })
+
+  f.get<IdParams & { Querystring: { after?: string } }>('/sessions/:id/stream', async (req, reply) => {
+    const row = sessionFor(req)
+    const lastId = Number(req.headers['last-event-id'] ?? req.query.after ?? 0) || 0
+    streamSessionEvents(app, req, reply, row.id, lastId)
+  })
+
+  // ---- 消息与 Run ----
+  f.post<IdParams>('/sessions/:id/messages', async (req, reply) => {
+    const row = sessionFor(req)
+    reply.code(202)
+    return app.runs.send(row, (req.body ?? {}) as { text: string })
+  })
+
+  f.get<IdParams>('/runs/:id', async req => {
+    const run = app.runs.get(req.params.id)
+    app.sessions.getRow(run.sessionId, principal(req).source)
+    return run
+  })
+
+  f.post<IdParams>('/runs/:id/interrupt', async (req, reply) => {
+    const run = app.runs.get(req.params.id)
+    app.sessions.getRow(run.sessionId, principal(req).source)
+    app.runs.interrupt(run.id, run.sessionId)
+    reply.code(202)
+    return { ok: true }
+  })
+
+  // ---- 交互 ----
+  f.get<{ Querystring: { sessionId?: string } }>('/interactions', async req => {
+    const sid = req.query.sessionId
+    if (!sid) throw badRequest('需要 sessionId 参数')
+    app.sessions.getRow(sid, principal(req).source)
+    return app.interactions.pendingFor(sid)
+  })
+
+  f.post<IdParams>('/interactions/:id', async req => {
+    const body = parse(interactionResponseSchema, req.body)
+    const source = principal(req).source
+    app.interactions.respond(req.params.id, body, sid => {
+      try {
+        app.sessions.getRow(sid, source)
+        return true
+      } catch {
+        return false
+      }
+    })
+    return { ok: true }
+  })
+
+  // ---- 文件 ----
+  f.post<IdParams>('/sessions/:id/files', async (req, reply) => {
+    const row = sessionFor(req)
+    const limitBytes = app.settings.get().limits.uploadMaxMB * 1048576
+    const saved = []
+    for await (const part of req.files({ limits: { fileSize: limitBytes, files: 20 } })) {
+      saved.push(await app.files.saveUpload({
+        sessionId: row.id, workspaceDir: row.workspaceDir, filename: part.filename, stream: part.file, limitBytes,
+        truncated: () => part.file.truncated,
+      }))
+    }
+    if (!saved.length) throw badRequest('没有收到文件')
+    reply.code(201)
+    return saved
+  })
+
+  f.get<IdParams>('/sessions/:id/files', async req => {
+    const row = sessionFor(req)
+    await app.files.scan(row.id, row.workspaceDir)
+    return app.files.list(row.id)
+  })
+
+  f.get<IdParams & { Querystring: { inline?: string } }>('/files/:id', async (req, reply) => {
+    const file = app.files.get(req.params.id)
+    const row = app.sessions.getRow(file.sessionId, principal(req).source)
+    const abs = path.join(row.workspaceDir, file.relPath)
+    if (!fs.existsSync(abs)) throw notFound('文件')
+    const name = encodeURIComponent(path.basename(file.relPath))
+    reply.header('Content-Type', file.mime)
+    reply.header('Content-Disposition', `${req.query.inline ? 'inline' : 'attachment'}; filename*=UTF-8''${name}`)
+    reply.header('X-Content-Type-Options', 'nosniff')
+    // 预览 HTML/SVG 时禁止执行脚本
+    reply.header('Content-Security-Policy', "sandbox; default-src 'none'; img-src data: 'self'; style-src 'unsafe-inline'")
+    return reply.send(fs.createReadStream(abs))
+  })
+}

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

@@ -0,0 +1,53 @@
+import fs from 'node:fs'
+import path from 'node:path'
+import { fileURLToPath } from 'node:url'
+import cookie from '@fastify/cookie'
+import multipart from '@fastify/multipart'
+import fastifyStatic from '@fastify/static'
+import Fastify, { type FastifyServerOptions } from 'fastify'
+import type { ApiErrorBody } from '@agent-server/shared'
+import type { App } from '../app'
+import { config } from '../config'
+import { AppError } from '../lib/errors'
+import adminRoutes from './routes/admin'
+import v1Routes from './routes/v1'
+
+export async function buildServer(app: App, opts: { logger: FastifyServerOptions['loggerInstance'] }) {
+  const f = Fastify({ loggerInstance: opts.logger, trustProxy: true, bodyLimit: 2 * 1048576 })
+
+  await f.register(cookie)
+  await f.register(multipart)
+
+  f.setErrorHandler((err, req, reply) => {
+    if (err instanceof AppError) {
+      const body: ApiErrorBody = { error: { code: err.code, message: err.message, details: err.details } }
+      return reply.code(err.status).send(body)
+    }
+    const status = (err as { statusCode?: number }).statusCode ?? 500
+    if (status >= 500) req.log.error({ err }, 'unhandled error')
+    const body: ApiErrorBody = { error: { code: status >= 500 ? 'internal_error' : 'bad_request', message: status >= 500 ? '服务器内部错误' : (err as Error).message } }
+    return reply.code(status).send(body)
+  })
+
+  f.get('/healthz', async () => ({ ok: true }))
+  f.get('/readyz', async () => {
+    app.db.$client.prepare('select 1').get()
+    return { ok: true, runs: app.runs.stats() }
+  })
+
+  await f.register(v1Routes, { prefix: '/v1', app })
+  await f.register(adminRoutes, { prefix: '/admin/v1', app })
+
+  // 生产环境由服务托管后台静态资源,同源访问
+  // 源码运行时位于 src/http,打包后位于 dist,两种情况都向上寻找 apps/admin/dist
+  const here = path.dirname(fileURLToPath(import.meta.url))
+  const adminDist = config.adminDist
+    ?? [path.resolve(here, '../../../admin/dist'), path.resolve(here, '../../admin/dist')].find(p => fs.existsSync(path.join(p, 'index.html')))
+  if (adminDist && fs.existsSync(path.join(adminDist, 'index.html'))) {
+    await f.register(fastifyStatic, { root: adminDist, prefix: '/admin/', wildcard: false })
+    f.get('/admin/*', (_req, reply) => reply.sendFile('index.html'))
+    f.get('/', (_req, reply) => reply.redirect('/admin/'))
+  }
+
+  return f
+}

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

@@ -0,0 +1,39 @@
+// SSE:补发 seq > lastId 的事件,然后实时推送(docs/design/04-api.md §5)
+import type { FastifyReply, FastifyRequest } from 'fastify'
+import type { AgentEvent } from '@agent-server/shared'
+import type { App } from '../app'
+
+const PING_MS = 15_000
+
+export function streamSessionEvents(app: App, req: FastifyRequest, reply: FastifyReply, sessionId: string, lastId: number) {
+  reply.hijack()
+  const res = reply.raw
+  res.writeHead(200, {
+    'Content-Type': 'text/event-stream; charset=utf-8',
+    'Cache-Control': 'no-cache, no-transform',
+    Connection: 'keep-alive',
+    'X-Accel-Buffering': 'no',
+  })
+  res.write('retry: 2000\n\n')
+
+  let sent = lastId
+  const write = (e: AgentEvent) => {
+    if (e.seq <= sent) return
+    sent = e.seq
+    res.write(`id: ${e.seq}\nevent: ${e.type}\ndata: ${JSON.stringify(e)}\n\n`)
+  }
+
+  // 先订阅再补发,补发与实时之间的事件靠 seq 去重
+  const pending: AgentEvent[] = []
+  let replaying = true
+  const unsubscribe = app.events.subscribe(sessionId, e => (replaying ? pending.push(e) : write(e)))
+  for (const e of app.events.list(sessionId, lastId)) write(e)
+  replaying = false
+  for (const e of pending) write(e)
+
+  const ping = setInterval(() => res.write(': ping\n\n'), PING_MS)
+  req.raw.on('close', () => {
+    clearInterval(ping)
+    unsubscribe()
+  })
+}

+ 14 - 0
apps/server/src/lib/errors.ts

@@ -0,0 +1,14 @@
+export class AppError extends Error {
+  constructor(
+    readonly status: number,
+    readonly code: string,
+    message: string,
+    readonly details?: unknown,
+  ) {
+    super(message)
+  }
+}
+
+export const notFound = (what: string) => new AppError(404, 'not_found', `${what} 不存在`)
+export const badRequest = (message: string, details?: unknown) => new AppError(400, 'bad_request', message, details)
+export const conflict = (code: string, message: string) => new AppError(409, code, message)

+ 7 - 0
apps/server/src/lib/ids.ts

@@ -0,0 +1,7 @@
+import { ulid } from 'ulid'
+
+export type IdPrefix = 'ses' | 'run' | 'itx' | 'file' | 'prf' | 'kb' | 'key' | 'adm' | 'msg'
+
+export const newId = (prefix: IdPrefix) => `${prefix}_${ulid().toLowerCase()}`
+
+export const nowIso = () => new Date().toISOString()

+ 43 - 0
apps/server/src/lib/paths.ts

@@ -0,0 +1,43 @@
+import fs from 'node:fs'
+import path from 'node:path'
+import { config } from '../config'
+
+/**
+ * 展开 `~` 与 `{dataDir}` 占位符,返回绝对路径(不解析软链接)。
+ * 相对的通配模式(如 `**\/*.env`)保持原样,表示"在任何位置匹配"。
+ */
+export function expandPath(p: string): string {
+  let out = p.trim()
+  if (out === '~' || out.startsWith('~/')) out = path.join(config.home, out.slice(1))
+  out = out.replaceAll('{dataDir}', config.dataDir)
+  if (!path.isAbsolute(out) && hasGlob(out)) return out
+  return path.resolve(out)
+}
+
+/** child 是否等于 parent 或位于其下 */
+export function isWithin(child: string, parent: string): boolean {
+  const rel = path.relative(parent, child)
+  return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel))
+}
+
+/**
+ * 解析软链接后的真实路径。目标不存在时(例如即将新建的文件),
+ * 解析最近的已存在祖先目录,再拼回剩余部分,防止借软链接父目录越界。
+ */
+export function realpathLoose(p: string): string {
+  const abs = path.resolve(p)
+  let cur = abs
+  const rest: string[] = []
+  while (true) {
+    try {
+      return path.join(fs.realpathSync(cur), ...rest.reverse())
+    } catch {
+      const parent = path.dirname(cur)
+      if (parent === cur) return abs
+      rest.push(path.basename(cur))
+      cur = parent
+    }
+  }
+}
+
+export const hasGlob = (p: string) => /[*?[\]{}]/.test(p)

+ 24 - 0
apps/server/src/main.ts

@@ -0,0 +1,24 @@
+import pino from 'pino'
+import { createApp } from './app'
+import { config } from './config'
+import { buildServer } from './http/server'
+
+const logger = pino({
+  level: config.logLevel,
+  transport: config.isDev ? { target: 'pino-pretty', options: { translateTime: 'HH:MM:ss', ignore: 'pid,hostname' } } : undefined,
+})
+
+const app = createApp(logger)
+const server = await buildServer(app, { logger })
+
+const shutdown = async (signal: string) => {
+  logger.info({ signal }, 'shutting down')
+  await server.close()
+  app.db.$client.close()
+  process.exit(0)
+}
+process.on('SIGINT', () => void shutdown('SIGINT'))
+process.on('SIGTERM', () => void shutdown('SIGTERM'))
+
+await server.listen({ host: config.host, port: config.port })
+logger.info({ dataDir: config.dataDir }, `AgentServer 已启动:http://${config.host}:${config.port}`)

+ 345 - 0
apps/server/src/providers/claude/adapter.ts

@@ -0,0 +1,345 @@
+// Claude 适配器:Agent SDK → 统一事件(docs/design/03-providers.md §2,ADR-0002)
+import { execFile } from 'node:child_process'
+import fs from 'node:fs'
+import path from 'node:path'
+import { promisify } from 'node:util'
+import {
+  query,
+  type CanUseTool,
+  type HookCallback,
+  type PermissionResult,
+  type SDKMessage,
+  type SDKUserMessage,
+} from '@anthropic-ai/claude-agent-sdk'
+import type { AppSettings, InteractionQuestion, ProviderStatusDto, UsagePayload } from '@agent-server/shared'
+import { toClaudeSandbox } from '../../services/access-policy'
+import { newId } from '../../lib/ids'
+import { ProviderRunError, type AgentProvider, type RunContext, type RunResult } from '../types'
+import { DEFAULT_DISALLOWED_TOOLS, decideTool, toolTitle, type PolicyContext } from './policy'
+import { OFFICE_SERVER, officeServer } from './tools'
+
+const execFileP = promisify(execFile)
+const MAX_TOOL_OUTPUT = 64 * 1024
+
+export class ClaudeProvider implements AgentProvider {
+  readonly id = 'claude' as const
+  /** 会话级"本会话允许"规则(进程内保存,重启后失效) */
+  private sessionAllowed = new Map<string, Set<string>>()
+
+  async run(ctx: RunContext): Promise<RunResult> {
+    const allowed = this.sessionAllowed.get(ctx.sessionId) ?? new Set<string>()
+    this.sessionAllowed.set(ctx.sessionId, allowed)
+    const policyCtx: PolicyContext = {
+      access: ctx.access,
+      permissionMode: ctx.permissionMode,
+      blockPrivateFetch: ctx.settings.network.blockPrivateFetch,
+      cwd: ctx.workspaceDir,
+      sessionAllowed: allowed,
+    }
+    /** hook 已拒绝或用户拒绝的工具调用,用于把 tool.completed 标记为 denied */
+    const deniedToolIds = new Set<string>()
+
+    const preToolUse: HookCallback = async (input, toolUseId) => {
+      if (input.hook_event_name !== 'PreToolUse') return {}
+      const toolInput = (input.tool_input ?? {}) as Record<string, unknown>
+      const decision = await decideTool(input.tool_name, toolInput, policyCtx)
+      if (input.tool_name === 'Bash' && decision.kind !== 'deny') {
+        ctx.audit('command', String(toolInput.command ?? '').slice(0, 500), { toolUseId })
+      }
+      if (decision.kind === 'deny') {
+        if (toolUseId) deniedToolIds.add(toolUseId)
+        if (decision.audit) ctx.audit(decision.audit, decision.reason, { tool: input.tool_name, input: toolInput })
+      }
+      // allow 显式放行(MCP 工具默认会再询问);ask 让 CLI 走权限流程 → canUseTool → 交互;沙箱限制不受影响
+      return { hookSpecificOutput: {
+        hookEventName: 'PreToolUse',
+        permissionDecision: decision.kind,
+        permissionDecisionReason: decision.kind === 'deny' ? decision.reason : undefined,
+      } }
+    }
+
+    const canUseTool: CanUseTool = async (tool, input, opts): Promise<PermissionResult> => {
+      if (tool === 'AskUserQuestion') return this.askQuestion(ctx, input)
+      const outcome = await ctx.interact({
+        kind: 'approval',
+        tool,
+        title: toolTitle(tool, input, ctx.workspaceDir) || tool,
+        input,
+        detail: {
+          command: typeof input.command === 'string' ? input.command : undefined,
+          path: opts.blockedPath ?? (typeof input.file_path === 'string' ? input.file_path : undefined),
+          url: typeof input.url === 'string' ? input.url : undefined,
+          reason: opts.decisionReason,
+        },
+        options: ['allow_once', 'allow_session', 'deny'],
+      })
+      ctx.audit('approval', `${tool}: ${outcome.resolution}`, { input })
+      if (outcome.resolution === 'allowed') {
+        if (outcome.scope === 'session') allowed.add(tool)
+        return { behavior: 'allow', updatedInput: input }
+      }
+      if (opts.toolUseID) deniedToolIds.add(opts.toolUseID)
+      const message = outcome.resolution === 'denied'
+        ? `用户拒绝了这次操作${outcome.reason ? `,原因:${outcome.reason}` : ''}`
+        : outcome.resolution === 'timeout' ? '等待用户确认超时,操作已取消' : '操作已取消'
+      return { behavior: 'deny', message }
+    }
+
+    const abort = new AbortController()
+    const onAbort = () => abort.abort()
+    ctx.signal.addEventListener('abort', onAbort, { once: true })
+
+    const q = query({
+      prompt: singleMessage(buildUserMessage(ctx)),
+      options: {
+        cwd: ctx.workspaceDir,
+        model: ctx.model,
+        resume: ctx.providerSessionId ?? undefined,
+        pathToClaudeCodeExecutable: ctx.settings.providers.claude.cliPath,
+        settingSources: [],
+        includePartialMessages: true,
+        permissionMode: 'default',
+        systemPrompt: { type: 'preset', preset: 'claude_code', append: ctx.systemPromptAppend },
+        allowedTools: ctx.allowedTools.length ? ctx.allowedTools : undefined,
+        disallowedTools: [...DEFAULT_DISALLOWED_TOOLS, ...ctx.disallowedTools],
+        ...skillsOptions(ctx),
+        mcpServers: { [OFFICE_SERVER]: officeServer(ctx.workspaceDir) },
+        sandbox: toClaudeSandbox(ctx.access, ctx.settings),
+        hooks: { PreToolUse: [{ hooks: [preToolUse] }] },
+        canUseTool,
+        env: agentEnv(ctx),
+        abortController: abort,
+        stderr: line => ctx.log.warn({ stderr: line.trim() }, 'claude stderr'),
+      },
+    })
+
+    const mapper = new MessageMapper(ctx, deniedToolIds)
+    try {
+      for await (const m of q) {
+        const done = mapper.handle(m)
+        if (done) return done
+      }
+      if (ctx.signal.aborted) throw new ProviderRunError('interrupted', '已中断')
+      throw new ProviderRunError('no_result', 'Claude 进程提前结束,未返回结果')
+    } catch (err) {
+      if (ctx.signal.aborted) throw new ProviderRunError('interrupted', '已中断')
+      throw err
+    } finally {
+      ctx.signal.removeEventListener('abort', onAbort)
+    }
+  }
+
+  forgetSession(sessionId: string) {
+    this.sessionAllowed.delete(sessionId)
+  }
+
+  private async askQuestion(ctx: RunContext, input: Record<string, unknown>): Promise<PermissionResult> {
+    const raw = (input.questions ?? []) as { question: string; header?: string; multiSelect?: boolean; options: { label: string; description?: string }[] }[]
+    const questions: InteractionQuestion[] = raw.map((q, i) => ({
+      id: String(i),
+      question: q.question,
+      header: q.header,
+      multiSelect: !!q.multiSelect,
+      options: q.options.map(o => ({ label: o.label, description: o.description })),
+    }))
+    const outcome = await ctx.interact({ kind: 'question', questions })
+    if (outcome.resolution !== 'answered') {
+      return { behavior: 'deny', message: outcome.resolution === 'timeout' ? '用户没有在规定时间内回答' : '用户没有回答' }
+    }
+    // SDK 约定:answers 以问题原文为键,多选用逗号拼接
+    const answers: Record<string, string> = {}
+    for (const q of questions) {
+      const a = outcome.answers[q.id] ?? outcome.answers[q.question]
+      if (a !== undefined) answers[q.question] = Array.isArray(a) ? a.join(', ') : a
+    }
+    return { behavior: 'allow', updatedInput: { ...input, answers } }
+  }
+
+  async status(settings: AppSettings): Promise<ProviderStatusDto> {
+    const s = settings.providers.claude
+    const base: ProviderStatusDto = {
+      id: 'claude', enabled: s.enabled, available: false, loggedIn: null, account: null,
+      cliPath: s.cliPath, version: null, models: s.models, defaultModel: s.defaultModel, message: null,
+    }
+    try {
+      const [{ stdout: ver }, { stdout: auth }] = await Promise.all([
+        execFileP(s.cliPath, ['--version'], { timeout: 15000 }),
+        execFileP(s.cliPath, ['auth', 'status', '--json'], { timeout: 15000 }),
+      ])
+      const info = JSON.parse(auth) as { loggedIn?: boolean; authMethod?: string; subscriptionType?: string; email?: string }
+      return {
+        ...base,
+        version: ver.trim().split(' ')[0] ?? null,
+        loggedIn: !!info.loggedIn,
+        available: s.enabled && !!info.loggedIn,
+        account: info.loggedIn ? [info.email, info.subscriptionType && `(${info.subscriptionType})`].filter(Boolean).join(' ') : null,
+        message: info.loggedIn ? null : '未登录:请在终端执行 claude 并完成 /login',
+      }
+    } catch (err) {
+      return { ...base, message: `无法执行 ${s.cliPath}:${(err as Error).message}` }
+    }
+  }
+}
+
+/** 流式输入:只发送一条用户消息(支持图片),发完即结束输入 */
+async function* singleMessage(msg: SDKUserMessage): AsyncGenerator<SDKUserMessage> {
+  yield msg
+}
+
+function buildUserMessage(ctx: RunContext): SDKUserMessage {
+  const { input } = ctx
+  let text = input.text
+  if (input.attachments.length) {
+    const list = input.attachments.map(a => `- ${a.path}(${a.mime},${formatSize(a.size)})`).join('\n')
+    text += `\n\n<attachments>\n用户上传了以下文件,位于工作区中:\n${list}\n</attachments>`
+  }
+  const content = [
+    ...input.images.map(img => ({
+      type: 'image' as const,
+      source: { type: 'base64' as const, media_type: img.mime as 'image/png', data: img.base64 },
+    })),
+    { type: 'text' as const, text },
+  ]
+  return { type: 'user', message: { role: 'user', content }, parent_tool_use_id: null } as SDKUserMessage
+}
+
+/**
+ * 共享技能目录按 Claude 插件格式组织:<dir>/.claude-plugin/plugin.json + <dir>/skills/<name>/SKILL.md。
+ * 必须显式列出技能:省略 skills 时 CLI 会加载用户自己的技能(包括 claude.ai 同步的技能)。
+ */
+function skillsOptions(ctx: RunContext): { plugins?: { type: 'local'; path: string }[]; skills: string[] } {
+  const dir = ctx.settings.paths.skillsDir
+  let pluginName: string
+  try {
+    pluginName = (JSON.parse(fs.readFileSync(path.join(dir, '.claude-plugin/plugin.json'), 'utf8')) as { name: string }).name
+  } catch {
+    return { skills: [] }
+  }
+  const available = listSkills(dir)
+  const chosen = ctx.skills === 'all' ? available : ctx.skills.filter(s => available.includes(s))
+  return { plugins: [{ type: 'local', path: dir }], skills: chosen.map(n => `${pluginName}:${n}`) }
+}
+
+export function listSkills(dir: string): string[] {
+  try {
+    return fs.readdirSync(path.join(dir, 'skills'), { withFileTypes: true })
+      .filter(d => d.isDirectory() && fs.existsSync(path.join(dir, 'skills', d.name, 'SKILL.md')))
+      .map(d => d.name)
+      .sort()
+  } catch {
+    return []
+  }
+}
+
+function agentEnv(ctx: RunContext): Record<string, string | undefined> {
+  const py = ctx.settings.paths.pythonRuntime
+  const agentDir = path.join(ctx.workspaceDir, '.agent')
+  const env: Record<string, string | undefined> = {
+    ...process.env,
+    PATH: `${path.join(py, 'bin')}:${process.env.PATH ?? ''}`,
+    VIRTUAL_ENV: py,
+    MATPLOTLIBRC: ctx.settings.paths.matplotlibrc,
+    MPLCONFIGDIR: path.join(agentDir, 'mpl'),
+    // 不覆盖 TMPDIR:沙箱在其中创建 Unix socket,路径过长会导致沙箱无法启动
+    CLAUDE_AGENT_SDK_CLIENT_APP: 'agent-server/0.1.0',
+  }
+  // 只使用 CLI 登录态,避免意外切换到 API 计费(ADR-0008)
+  delete env.ANTHROPIC_API_KEY
+  delete env.ANTHROPIC_AUTH_TOKEN
+  return env
+}
+
+interface OpenBlock { kind: 'text' | 'thinking'; id: string; text: string }
+
+/** 把 SDK 消息流映射为统一事件;收到 result 时返回 RunResult */
+class MessageMapper {
+  private blocks = new Map<number, OpenBlock>()
+
+  constructor(private ctx: RunContext, private deniedToolIds: Set<string>) {}
+
+  handle(m: SDKMessage): RunResult | null {
+    switch (m.type) {
+      case 'system':
+        if (m.subtype === 'init') this.ctx.setProviderSessionId(m.session_id)
+        return null
+      case 'stream_event':
+        if (m.parent_tool_use_id === null) this.onStreamEvent(m.event)
+        return null
+      case 'assistant':
+        if (m.error) this.ctx.log.warn({ error: m.error }, 'claude assistant error')
+        if (m.parent_tool_use_id !== null) return null
+        for (const block of m.message.content) {
+          if (block.type === 'tool_use') {
+            const input = (block.input ?? {}) as Record<string, unknown>
+            this.ctx.emit('tool.started', { toolCallId: block.id, name: block.name, title: toolTitle(block.name, input, this.ctx.workspaceDir), input })
+          }
+        }
+        return null
+      case 'user':
+        if (m.parent_tool_use_id !== null || typeof m.message.content === 'string') return null
+        for (const block of m.message.content) {
+          if (block.type !== 'tool_result') continue
+          const output = toolResultText(block.content)
+          this.ctx.emit('tool.completed', {
+            toolCallId: block.tool_use_id,
+            status: this.deniedToolIds.has(block.tool_use_id) ? 'denied' : block.is_error ? 'error' : 'ok',
+            output: output.slice(0, MAX_TOOL_OUTPUT),
+            truncated: output.length > MAX_TOOL_OUTPUT,
+          })
+        }
+        return null
+      case 'result': {
+        const u = m.usage
+        const usage: UsagePayload = {
+          inputTokens: u.input_tokens ?? 0,
+          outputTokens: u.output_tokens ?? 0,
+          cacheReadTokens: u.cache_read_input_tokens ?? 0,
+          cacheWriteTokens: u.cache_creation_input_tokens ?? 0,
+          costUsd: m.total_cost_usd,
+        }
+        if (m.subtype !== 'success' || m.is_error) {
+          const detail = m.subtype === 'success' ? m.result : m.errors.join('; ') || m.subtype
+          throw new ProviderRunError(m.subtype, detail || '运行失败')
+        }
+        return { usage }
+      }
+      default:
+        return null
+    }
+  }
+
+  private onStreamEvent(e: Extract<SDKMessage, { type: 'stream_event' }>['event']) {
+    if (e.type === 'content_block_start') {
+      const kind = e.content_block.type === 'text' ? 'text' : e.content_block.type === 'thinking' ? 'thinking' : null
+      if (kind) this.blocks.set(e.index, { kind, id: newId('msg'), text: '' })
+    } else if (e.type === 'content_block_delta') {
+      const b = this.blocks.get(e.index)
+      if (!b) return
+      if (e.delta.type === 'text_delta') {
+        b.text += e.delta.text
+        this.ctx.emit('message.delta', { messageId: b.id, text: e.delta.text })
+      } else if (e.delta.type === 'thinking_delta') {
+        b.text += e.delta.thinking
+        this.ctx.emit('reasoning.delta', { blockId: b.id, text: e.delta.thinking })
+      }
+    } else if (e.type === 'content_block_stop') {
+      const b = this.blocks.get(e.index)
+      if (!b) return
+      this.blocks.delete(e.index)
+      if (b.kind === 'text' && b.text) this.ctx.emit('message.completed', { messageId: b.id, text: b.text })
+      if (b.kind === 'thinking' && b.text) this.ctx.emit('reasoning.completed', { blockId: b.id, text: b.text })
+    } else if (e.type === 'message_start') {
+      this.blocks.clear()
+    }
+  }
+}
+
+function toolResultText(content: unknown): string {
+  if (typeof content === 'string') return content
+  if (!Array.isArray(content)) return ''
+  return content.map(c => (c?.type === 'text' ? String(c.text) : c?.type === 'image' ? '[图片]' : '')).join('\n')
+}
+
+function formatSize(n: number) {
+  return n < 1024 ? `${n} B` : n < 1048576 ? `${(n / 1024).toFixed(1)} KB` : `${(n / 1048576).toFixed(1)} MB`
+}

+ 144 - 0
apps/server/src/providers/claude/policy.ts

@@ -0,0 +1,144 @@
+// Claude 工具调用策略:PreToolUse hook 对每次工具调用都会触发(canUseTool 对只读工具和沙箱内 Bash 不会触发),
+// 因此路径校验、私网拦截和"是否需要审批"都在这里决定(docs/design/06-security.md §4.2)
+import dns from 'node:dns/promises'
+import net from 'node:net'
+import path from 'node:path'
+import type { PermissionMode } from '@agent-server/shared'
+import { checkAccess, type AccessOp, type ResolvedAccess } from '../../services/access-policy'
+import { CONVERT_TOOL } from './tools'
+
+export type ToolDecision =
+  | { kind: 'allow' }
+  | { kind: 'ask' }
+  | { kind: 'deny'; reason: string; audit: 'file_access_denied' | 'network_denied' | null }
+
+/** 服务场景下不适用的内置工具 */
+export const DEFAULT_DISALLOWED_TOOLS = [
+  'EnterWorktree', 'ExitWorktree', 'CronCreate', 'CronDelete', 'CronList', 'RemoteTrigger',
+  'PushNotification', 'ScheduleWakeup', 'EnterPlanMode', 'ExitPlanMode', 'Monitor',
+]
+
+const READ_TOOLS: Record<string, string[]> = {
+  Read: ['file_path'],
+  Glob: ['path'],
+  Grep: ['path'],
+  NotebookRead: ['notebook_path'],
+}
+const WRITE_TOOLS: Record<string, string[]> = {
+  Write: ['file_path'],
+  Edit: ['file_path'],
+  MultiEdit: ['file_path'],
+  NotebookEdit: ['notebook_path'],
+}
+const SHELL_TOOLS = new Set(['Bash', 'PowerShell'])
+/** 无副作用、无需审批的工具 */
+const SAFE_TOOLS = new Set(['TodoWrite', 'Skill', 'ToolSearch', 'WebSearch', 'Agent', 'TaskCreate', 'TaskGet', 'TaskList', 'TaskUpdate', 'TaskStop'])
+
+export interface PolicyContext {
+  access: ResolvedAccess
+  permissionMode: PermissionMode
+  blockPrivateFetch: boolean
+  cwd: string
+  /** 本会话中被"本会话允许"过的工具 */
+  sessionAllowed: Set<string>
+}
+
+export async function decideTool(tool: string, input: Record<string, unknown>, ctx: PolicyContext): Promise<ToolDecision> {
+  if (tool === 'AskUserQuestion') return { kind: 'ask' }
+
+  const pathCheck = (fields: string[], op: AccessOp): ToolDecision | null => {
+    const targets = fields.map(f => input[f]).filter((v): v is string => typeof v === 'string' && v.length > 0)
+    // Glob/Grep 省略 path 时作用于工作目录
+    if (targets.length === 0) targets.push(ctx.cwd)
+    for (const t of targets) {
+      const decision = checkAccess(ctx.access, path.resolve(ctx.cwd, t), op)
+      if (!decision.allowed) return { kind: 'deny', reason: decision.reason, audit: 'file_access_denied' }
+    }
+    return null
+  }
+
+  if (tool in READ_TOOLS) return pathCheck(READ_TOOLS[tool]!, 'read') ?? { kind: 'allow' }
+
+  if (tool in WRITE_TOOLS) {
+    if (ctx.permissionMode === 'readonly') return { kind: 'deny', reason: '当前为只读模式,不允许修改文件', audit: null }
+    return pathCheck(WRITE_TOOLS[tool]!, 'write') ?? needsApproval(tool, ctx)
+  }
+
+  if (SHELL_TOOLS.has(tool)) {
+    if (ctx.permissionMode === 'readonly') return { kind: 'deny', reason: '当前为只读模式,不允许执行命令', audit: null }
+    return needsApproval(tool, ctx)
+  }
+
+  if (tool === 'WebFetch') {
+    if (ctx.blockPrivateFetch) {
+      const blocked = await privateUrlReason(String(input.url ?? ''))
+      if (blocked) return { kind: 'deny', reason: blocked, audit: 'network_denied' }
+    }
+    return { kind: 'allow' }
+  }
+
+  if (SAFE_TOOLS.has(tool)) return { kind: 'allow' }
+  // 文档转换只读写工作区,且在独立沙箱中执行
+  if (tool === CONVERT_TOOL) {
+    return ctx.permissionMode === 'readonly' ? { kind: 'deny', reason: '当前为只读模式,不允许生成文件', audit: null } : { kind: 'allow' }
+  }
+  return needsApproval(tool, ctx)
+}
+
+function needsApproval(tool: string, ctx: PolicyContext): ToolDecision {
+  if (ctx.permissionMode === 'auto' || ctx.sessionAllowed.has(tool)) return { kind: 'allow' }
+  return { kind: 'ask' }
+}
+
+/** URL 指向私网、回环或链路本地地址时返回拒绝原因 */
+export async function privateUrlReason(raw: string): Promise<string | null> {
+  let url: URL
+  try {
+    url = new URL(raw)
+  } catch {
+    return `无效的 URL:${raw}`
+  }
+  if (!['http:', 'https:'].includes(url.protocol)) return `不支持的协议:${url.protocol}`
+  const host = url.hostname.replace(/^\[|\]$/g, '')
+  let addresses: string[]
+  try {
+    addresses = net.isIP(host) ? [host] : (await dns.lookup(host, { all: true })).map(a => a.address)
+  } catch {
+    return null // 解析失败交给工具自己报错
+  }
+  const bad = addresses.find(isPrivateAddress)
+  return bad ? `不允许访问内网地址:${host}(${bad})` : null
+}
+
+export function isPrivateAddress(ip: string): boolean {
+  if (net.isIPv4(ip)) {
+    const [a, b] = ip.split('.').map(Number) as [number, number]
+    return a === 10 || a === 127 || a === 0 || (a === 169 && b === 254) || (a === 172 && b >= 16 && b <= 31)
+      || (a === 192 && b === 168) || (a === 100 && b >= 64 && b <= 127)
+  }
+  const v6 = ip.toLowerCase()
+  if (v6.startsWith('::ffff:')) return isPrivateAddress(v6.slice(7))
+  return v6 === '::1' || v6 === '::' || v6.startsWith('fc') || v6.startsWith('fd') || v6.startsWith('fe80')
+}
+
+/** 工具调用的人类可读摘要,用于时间线和审批卡片 */
+export function toolTitle(tool: string, input: Record<string, unknown>, cwd: string): string {
+  const rel = (p: unknown) => {
+    if (typeof p !== 'string') return ''
+    const r = path.relative(cwd, path.resolve(cwd, p))
+    return r.startsWith('..') ? p : r || '.'
+  }
+  switch (tool) {
+    case 'Read': case 'Write': case 'Edit': case 'MultiEdit': return rel(input.file_path)
+    case 'NotebookEdit': case 'NotebookRead': return rel(input.notebook_path)
+    case 'Bash': case 'PowerShell': return String(input.description ?? input.command ?? '').split('\n')[0]!.slice(0, 200)
+    case 'Glob': return String(input.pattern ?? '')
+    case 'Grep': return `${String(input.pattern ?? '')}${input.path ? ` in ${rel(input.path)}` : ''}`
+    case 'WebFetch': return String(input.url ?? '')
+    case 'WebSearch': return String(input.query ?? '')
+    case 'Skill': return String(input.skill ?? input.name ?? '')
+    case 'Agent': case 'Task': return String(input.description ?? '')
+    case CONVERT_TOOL: return `${rel(input.input)} → ${String(input.format ?? '')}`
+    default: return ''
+  }
+}

+ 35 - 0
apps/server/src/providers/claude/tools.ts

@@ -0,0 +1,35 @@
+// 进程内 MCP 工具(Claude 专用);M2 起同样的能力经 MCP Gateway 提供给 Codex
+import path from 'node:path'
+import { createSdkMcpServer, tool } from '@anthropic-ai/claude-agent-sdk'
+import { z } from 'zod'
+import { CONVERT_FORMATS, convertDocument } from '../../services/office'
+
+export const OFFICE_SERVER = 'office'
+export const CONVERT_TOOL = `mcp__${OFFICE_SERVER}__convert_document`
+
+export function officeServer(workspaceDir: string) {
+  return createSdkMcpServer({
+    name: OFFICE_SERVER,
+    version: '0.1.0',
+    tools: [
+      tool(
+        'convert_document',
+        '用 LibreOffice 转换文档格式(例如 docx→pdf、xlsx→pdf、pptx→pdf、doc→docx、html→pdf)。'
+          + '输入和输出都必须在工作区内;输出文件名与输入相同、扩展名换成目标格式。不要在 Bash 里直接调用 soffice,它在沙箱中无法运行。',
+        {
+          input: z.string().describe('输入文件路径(相对工作区或绝对路径)'),
+          format: z.enum(CONVERT_FORMATS).describe('目标格式'),
+          out_dir: z.string().optional().describe('输出目录,默认 outputs'),
+        },
+        async args => {
+          try {
+            const out = await convertDocument({ workspaceDir, input: args.input, format: args.format, outDir: args.out_dir })
+            return { content: [{ type: 'text', text: `已生成:${path.relative(workspaceDir, out)}` }] }
+          } catch (err) {
+            return { content: [{ type: 'text', text: (err as Error).message }], isError: true }
+          }
+        },
+      ),
+    ],
+  })
+}

+ 49 - 0
apps/server/src/providers/types.ts

@@ -0,0 +1,49 @@
+import type { AgentEventType, AppSettings, EventPayloads, PermissionMode, ProviderId, ProviderStatusDto, UsagePayload } from '@agent-server/shared'
+import type { ResolvedAccess } from '../services/access-policy'
+import type { InteractionOutcome, NewApproval, NewQuestion } from '../services/interactions'
+
+export interface RunInput {
+  text: string
+  /** 作为多模态输入直接交给模型的图片 */
+  images: { mime: string; base64: string; name: string }[]
+  /** 附件清单(工作区相对路径),写进提示词 */
+  attachments: { path: string; name: string; mime: string; size: number }[]
+}
+
+export interface RunContext {
+  sessionId: string
+  runId: string
+  model: string
+  permissionMode: PermissionMode
+  providerSessionId: string | null
+  workspaceDir: string
+  systemPromptAppend: string
+  allowedTools: string[]
+  disallowedTools: string[]
+  skills: string[] | 'all'
+  input: RunInput
+  access: ResolvedAccess
+  settings: AppSettings
+  signal: AbortSignal
+  emit<T extends AgentEventType>(type: T, payload: EventPayloads[T]): void
+  interact(req: NewApproval | NewQuestion): Promise<InteractionOutcome>
+  setProviderSessionId(id: string): void
+  audit(kind: 'command' | 'file_access_denied' | 'network_denied' | 'approval', summary: string, detail?: unknown): void
+  log: { info(obj: object, msg?: string): void; warn(obj: object, msg?: string): void; error(obj: object, msg?: string): void }
+}
+
+export interface RunResult {
+  usage: UsagePayload | null
+}
+
+export interface AgentProvider {
+  readonly id: ProviderId
+  run(ctx: RunContext): Promise<RunResult>
+  status(settings: AppSettings): Promise<ProviderStatusDto>
+}
+
+export class ProviderRunError extends Error {
+  constructor(readonly code: string, message: string) {
+    super(message)
+  }
+}

+ 216 - 0
apps/server/src/services/access-policy.ts

@@ -0,0 +1,216 @@
+// 文件访问策略:全局强制禁止 ∩ Profile 策略 ∪ 基线(docs/design/06-security.md §4,ADR-0010)
+import fs from 'node:fs'
+import os from 'node:os'
+import path from 'node:path'
+import picomatch from 'picomatch'
+import {
+  BUILTIN_GLOBAL_DENY,
+  type AppSettings,
+  type FileAccessPolicy,
+  type KnowledgeRootDto,
+  type PathCheck,
+  type ResolvedAccessDto,
+} from '@agent-server/shared'
+import { config } from '../config'
+import { expandPath, hasGlob, isWithin, realpathLoose } from '../lib/paths'
+
+export type AccessOp = 'read' | 'write'
+
+export interface ResolvedAccess {
+  mode: FileAccessPolicy['mode']
+  workspaceDir: string
+  readable: string[]
+  writable: string[]
+  /** 无条件禁止(内置 + 全局 + Profile),可以是路径或通配 */
+  hardDeny: string[]
+  /** 默认禁止、但可被更具体的允许路径重新打开(如其他会话的工作区) */
+  softDeny: string[]
+}
+
+export type AccessDecision = { allowed: true } | { allowed: false; reason: string }
+
+/** restricted 模式下 Bash 沙箱额外屏蔽的大块目录 */
+const RESTRICTED_BASH_DENY = () => [config.home, '/srv', '/mnt', '/media', '/root']
+
+export function globalDenyList(settings: AppSettings): string[] {
+  return [...BUILTIN_GLOBAL_DENY, ...settings.security.globalDeny].map(expandPath)
+}
+
+export function resolveAccess(opts: {
+  policy: FileAccessPolicy
+  knowledgeRoots: KnowledgeRootDto[]
+  selectedKnowledgeRootIds: string[]
+  workspaceDir: string
+  settings: AppSettings
+}): ResolvedAccess {
+  const { policy, settings } = opts
+  const roots = new Map(opts.knowledgeRoots.map(r => [r.id, r]))
+  const readable = new Set<string>()
+  const writable = new Set<string>()
+
+  // 基线:工作区读写(含 Claude CLI 为该工作目录分配的临时目录);运行环境与技能只读
+  writable.add(opts.workspaceDir)
+  writable.add(claudeScratchDir(opts.workspaceDir))
+  readable.add(settings.paths.pythonRuntime)
+  readable.add(settings.paths.matplotlibrc)
+  readable.add(settings.paths.skillsDir)
+
+  for (const id of opts.selectedKnowledgeRootIds) {
+    const root = roots.get(id)
+    if (!root?.enabled) continue
+    const target = root.access === 'write' ? writable : readable
+    target.add(expandPath(root.path))
+  }
+  for (const rule of policy.read) {
+    const p = ruleToPath(rule, roots)
+    if (p) readable.add(p)
+  }
+  for (const rule of policy.write) {
+    const p = ruleToPath(rule, roots)
+    if (p) writable.add(p)
+  }
+  if (policy.mode === 'broad') readable.add('/')
+
+  return {
+    mode: policy.mode,
+    workspaceDir: opts.workspaceDir,
+    readable: [...readable].map(p => realpathLoose(p)),
+    writable: [...writable].map(p => realpathLoose(p)),
+    hardDeny: [...globalDenyList(settings), ...policy.deny.map(expandPath)],
+    softDeny: [realpathLoose(settings.paths.workspaceRoot), realpathLoose(config.dataDir)],
+  }
+}
+
+/** Claude CLI 的会话临时目录:<tmp>/claude-<uid>/<工作目录路径中非字母数字替换为 -> */
+export function claudeScratchDir(workspaceDir: string): string {
+  return path.join(os.tmpdir(), `claude-${process.getuid?.() ?? 0}`, workspaceDir.replace(/[^a-zA-Z0-9]/g, '-'))
+}
+
+function ruleToPath(rule: FileAccessPolicy['read'][number], roots: Map<string, KnowledgeRootDto>): string | null {
+  if ('path' in rule) return expandPath(rule.path)
+  const root = roots.get(rule.knowledgeRootId)
+  return root?.enabled ? expandPath(root.path) : null
+}
+
+const matcherCache = new Map<string, (p: string) => boolean>()
+
+function matchesEntry(target: string, entry: string): boolean {
+  if (!hasGlob(entry)) return isWithin(target, entry)
+  let m = matcherCache.get(entry)
+  if (!m) {
+    // 相对模式在任何位置匹配:不含 / 时按文件名匹配,否则匹配去掉开头 / 的路径
+    const relative = !path.isAbsolute(entry)
+    const pm = picomatch(entry, { dot: true, basename: relative && !entry.includes('/') })
+    m = relative ? (p: string) => pm(p.slice(1)) : pm
+    matcherCache.set(entry, m)
+  }
+  // 通配命中目录本身或其任一祖先,都视为命中
+  for (let p = target; ; p = path.dirname(p)) {
+    if (m(p)) return true
+    if (path.dirname(p) === p) return false
+  }
+}
+
+/** 沙箱配置只接受锚定路径:把相对通配挂到每个可访问根目录下 */
+function anchorGlobs(entries: string[], roots: string[]): string[] {
+  return entries.flatMap(e => (path.isAbsolute(e) ? [e] : roots.map(r => path.join(r, e.startsWith('**/') ? e : `**/${e}`))))
+}
+
+/** 返回 list 中包含 target 的最具体(最长)路径;通配项不参与 */
+function mostSpecific(target: string, list: string[]): string | null {
+  let best: string | null = null
+  for (const p of list) if (!hasGlob(p) && isWithin(target, p) && (!best || p.length > best.length)) best = p
+  return best
+}
+
+export function checkAccess(access: ResolvedAccess, rawPath: string, op: AccessOp): AccessDecision {
+  const target = realpathLoose(rawPath)
+  for (const entry of access.hardDeny) {
+    if (matchesEntry(target, entry)) return { allowed: false, reason: `路径被禁止访问:${entry}` }
+  }
+
+  const allowList = op === 'write' ? access.writable : [...access.readable, ...access.writable]
+  const allow = mostSpecific(target, allowList)
+  if (!allow) {
+    return { allowed: false, reason: op === 'write' ? `不允许写入工作区以外的路径:${target}` : `路径不在可读范围内:${target}` }
+  }
+  const soft = mostSpecific(target, access.softDeny)
+  if (soft && soft.length > allow.length) return { allowed: false, reason: `路径被禁止访问:${soft}` }
+  return { allowed: true }
+}
+
+/** 把合并后的策略映射为 Claude Bash 沙箱设置(06 §4.2) */
+export function toClaudeSandbox(access: ResolvedAccess, settings: AppSettings) {
+  const readable = access.readable.filter(p => p !== '/')
+  const hardDeny = anchorGlobs(access.hardDeny, [...readable, ...access.writable])
+  const denyRead = [...hardDeny, ...(access.mode === 'restricted' ? RESTRICTED_BASH_DENY() : access.softDeny)]
+  return {
+    enabled: true,
+    failIfUnavailable: true,
+    allowUnsandboxedCommands: false,
+    autoAllowBashIfSandboxed: true,
+    filesystem: {
+      // 注意:沙箱禁止创建 Unix socket,LibreOffice 无法在 Bash 中运行,改由 convert_document 工具提供(services/office.ts)
+      allowWrite: access.writable,
+      denyWrite: hardDeny,
+      denyRead,
+      allowRead: [...readable, ...access.writable],
+    },
+    network: { allowedDomains: settings.network.allowedDomains },
+  }
+}
+
+export function toResolvedDto(access: ResolvedAccess): ResolvedAccessDto {
+  return {
+    mode: access.mode,
+    readable: access.readable,
+    writable: access.writable,
+    denied: [...access.hardDeny, ...access.softDeny.map(p => `${p}(其他会话 / 服务数据,工作区除外)`)],
+  }
+}
+
+/** 通用路径检查(系统设置中的路径):只看是否存在、可读 */
+export function checkPathBasic(raw: string): PathCheck {
+  const p = expandPath(raw)
+  const result: PathCheck = { exists: false, readable: false, isDirectory: false, denied: null, underHome: isWithin(p, config.home), message: null }
+  try {
+    const st = fs.statSync(p)
+    result.exists = true
+    result.isDirectory = st.isDirectory()
+    fs.accessSync(p, fs.constants.R_OK)
+    result.readable = true
+  } catch {
+    result.message = result.exists ? '服务进程没有读取权限' : '路径不存在'
+  }
+  return result
+}
+
+export function checkKnowledgePath(raw: string, settings: AppSettings): PathCheck {
+  const p = expandPath(raw)
+  const result: PathCheck = { exists: false, readable: false, isDirectory: false, denied: null, underHome: isWithin(p, config.home), message: null }
+  for (const entry of globalDenyList(settings)) {
+    if (matchesEntry(p, entry) || (!hasGlob(entry) && isWithin(entry, p))) {
+      result.denied = entry
+      result.message = `与全局禁止项冲突:${entry}`
+      return result
+    }
+  }
+  if (isWithin(p, realpathLoose(settings.paths.workspaceRoot))) {
+    result.message = '不能把会话工作区目录设为资料库'
+    return result
+  }
+  try {
+    const st = fs.statSync(p)
+    result.exists = true
+    result.isDirectory = st.isDirectory()
+    fs.accessSync(p, fs.constants.R_OK)
+    result.readable = true
+  } catch {
+    result.message ??= result.exists ? '服务进程没有读取权限' : '路径不存在'
+  }
+  if (result.exists && !result.isDirectory) result.message = '资料库必须是目录'
+  if (!result.message && result.underHome) {
+    result.message = `位于 home 目录下;以 systemd 加固方式部署时需要在 unit 中追加 BindReadOnlyPaths=${p}`
+  }
+  return result
+}

+ 71 - 0
apps/server/src/services/admin-auth.ts

@@ -0,0 +1,71 @@
+// 后台单管理员登录:密码 scrypt 哈希存放在 settings 表,会话存放在 admin_sessions 表
+import crypto from 'node:crypto'
+import { eq, lt } from 'drizzle-orm'
+import type { DB } from '../db'
+import { adminSessions, settings } from '../db/schema'
+import { AppError, badRequest } from '../lib/errors'
+import { newId } from '../lib/ids'
+
+const KEY = 'admin_auth'
+const SESSION_TTL_MS = 7 * 24 * 3600 * 1000
+
+interface StoredAuth { salt: string; hash: string }
+
+const scrypt = (password: string, salt: string) => crypto.scryptSync(password, salt, 64).toString('hex')
+
+export class AdminAuthService {
+  constructor(private db: DB, log: { warn(o: object, m?: string): void }, initialPassword?: string) {
+    if (!this.stored()) {
+      const password = initialPassword || crypto.randomBytes(9).toString('base64url')
+      this.setPassword(password)
+      if (!initialPassword) log.warn({ password }, '已生成后台初始密码(只显示这一次),登录后请在设置中修改')
+    }
+    db.delete(adminSessions).where(lt(adminSessions.expiresAt, new Date().toISOString())).run()
+  }
+
+  private stored(): StoredAuth | null {
+    const row = this.db.select().from(settings).where(eq(settings.key, KEY)).get()
+    return (row?.value as StoredAuth | undefined) ?? null
+  }
+
+  setPassword(password: string) {
+    if (password.length < 8) throw badRequest('密码至少 8 位')
+    const salt = crypto.randomBytes(16).toString('hex')
+    const value: StoredAuth = { salt, hash: scrypt(password, salt) }
+    this.db.insert(settings).values({ key: KEY, value })
+      .onConflictDoUpdate({ target: settings.key, set: { value, updatedAt: new Date().toISOString() } }).run()
+  }
+
+  verifyPassword(password: string): boolean {
+    const s = this.stored()
+    if (!s) return false
+    const a = Buffer.from(scrypt(password, s.salt), 'hex')
+    const b = Buffer.from(s.hash, 'hex')
+    return a.length === b.length && crypto.timingSafeEqual(a, b)
+  }
+
+  login(password: string): { id: string; expiresAt: Date } {
+    if (!this.verifyPassword(password)) throw new AppError(401, 'invalid_password', '密码错误')
+    const id = `${newId('adm')}_${crypto.randomBytes(24).toString('base64url')}`
+    const expiresAt = new Date(Date.now() + SESSION_TTL_MS)
+    this.db.insert(adminSessions).values({ id, expiresAt: expiresAt.toISOString() }).run()
+    return { id, expiresAt }
+  }
+
+  validate(id: string | undefined): boolean {
+    if (!id) return false
+    const row = this.db.select().from(adminSessions).where(eq(adminSessions.id, id)).get()
+    return !!row && row.expiresAt > new Date().toISOString()
+  }
+
+  logout(id: string | undefined) {
+    if (id) this.db.delete(adminSessions).where(eq(adminSessions.id, id)).run()
+  }
+
+  /** 修改密码后注销所有后台会话 */
+  changePassword(current: string, next: string) {
+    if (!this.verifyPassword(current)) throw new AppError(401, 'invalid_password', '当前密码错误')
+    this.setPassword(next)
+    this.db.delete(adminSessions).run()
+  }
+}

+ 224 - 0
apps/server/src/services/catalog.ts

@@ -0,0 +1,224 @@
+// Profile、资料库、API Key、审计:后台管理的配置类数据
+import crypto from 'node:crypto'
+import { and, desc, eq, ne } from 'drizzle-orm'
+import { z } from 'zod'
+import type { ApiKeyDto, FileAccessPolicy, KnowledgeRootDto, ProfileDto, ProfileInput } from '@agent-server/shared'
+import type { DB } from '../db'
+import { apiKeys, auditLogs, knowledgeRoots, profiles } from '../db/schema'
+import { badRequest, notFound } from '../lib/errors'
+import { newId, nowIso } from '../lib/ids'
+import { checkKnowledgePath } from './access-policy'
+import type { SettingsService } from './settings'
+
+type ProfileRow = typeof profiles.$inferSelect
+
+const pathRule = z.union([z.object({ knowledgeRootId: z.string() }), z.object({ path: z.string().min(1) })])
+const fileAccessSchema: z.ZodType<FileAccessPolicy> = z.object({
+  mode: z.enum(['restricted', 'broad']),
+  read: z.array(pathRule),
+  write: z.array(pathRule),
+  deny: z.array(z.string().min(1)),
+})
+
+export const profileInputSchema: z.ZodType<ProfileInput> = z.object({
+  name: z.string().trim().min(1).max(60),
+  description: z.string().max(500),
+  provider: z.enum(['claude', 'codex']),
+  model: z.string().min(1),
+  systemPrompt: z.string().max(20000),
+  permissionMode: z.enum(['ask', 'auto', 'readonly']),
+  allowedTools: z.array(z.string()),
+  disallowedTools: z.array(z.string()),
+  skills: z.union([z.literal('all'), z.array(z.string())]),
+  fileAccess: fileAccessSchema,
+  knowledgeRootIds: z.array(z.string()),
+  isDefault: z.boolean(),
+})
+
+const toProfileDto = (r: ProfileRow): ProfileDto => ({
+  id: r.id, name: r.name, description: r.description, provider: r.provider, model: r.model, systemPrompt: r.systemPrompt,
+  permissionMode: r.permissionMode, allowedTools: r.allowedTools, disallowedTools: r.disallowedTools, skills: r.skills,
+  fileAccess: r.fileAccess, knowledgeRootIds: r.knowledgeRootIds, isDefault: r.isDefault, version: r.version, updatedAt: r.updatedAt,
+})
+
+export class ProfileService {
+  constructor(private db: DB, private settings: SettingsService) {
+    if (!db.select({ id: profiles.id }).from(profiles).limit(1).get()) this.seed()
+  }
+
+  private seed() {
+    this.create({
+      name: '通用助手',
+      description: '数据分析、资料查找与文档生成',
+      provider: 'claude',
+      model: this.settings.get().providers.claude.defaultModel,
+      systemPrompt: '',
+      permissionMode: 'ask',
+      allowedTools: [],
+      disallowedTools: [],
+      skills: 'all',
+      fileAccess: { mode: this.settings.get().security.defaultFileAccessMode, read: [], write: [], deny: [] },
+      knowledgeRootIds: [],
+      isDefault: true,
+    })
+  }
+
+  list(): ProfileDto[] {
+    return this.db.select().from(profiles).orderBy(desc(profiles.isDefault), profiles.name).all().map(toProfileDto)
+  }
+
+  get(id: string): ProfileDto {
+    const row = this.db.select().from(profiles).where(eq(profiles.id, id)).get()
+    if (!row) throw notFound('Profile')
+    return toProfileDto(row)
+  }
+
+  getDefault(): ProfileDto {
+    const row = this.db.select().from(profiles).where(eq(profiles.isDefault, true)).get()
+      ?? this.db.select().from(profiles).limit(1).get()
+    if (!row) throw notFound('默认 Profile')
+    return toProfileDto(row)
+  }
+
+  create(input: ProfileInput): ProfileDto {
+    const data = parse(profileInputSchema, input)
+    const id = newId('prf')
+    this.db.transaction(tx => {
+      if (data.isDefault) tx.update(profiles).set({ isDefault: false }).run()
+      tx.insert(profiles).values({ id, ...data }).run()
+    })
+    return this.get(id)
+  }
+
+  update(id: string, input: ProfileInput): ProfileDto {
+    const prev = this.get(id)
+    const data = parse(profileInputSchema, input)
+    if (prev.isDefault && !data.isDefault) throw badRequest('请先把另一个 Profile 设为默认')
+    this.db.transaction(tx => {
+      if (data.isDefault) tx.update(profiles).set({ isDefault: false }).where(ne(profiles.id, id)).run()
+      tx.update(profiles).set({ ...data, version: prev.version + 1, updatedAt: nowIso() }).where(eq(profiles.id, id)).run()
+    })
+    return this.get(id)
+  }
+
+  delete(id: string) {
+    const p = this.get(id)
+    if (p.isDefault) throw badRequest('不能删除默认 Profile')
+    this.db.delete(profiles).where(eq(profiles.id, id)).run()
+  }
+}
+
+const knowledgeInputSchema = z.object({
+  name: z.string().trim().min(1).max(60),
+  path: z.string().trim().min(1),
+  description: z.string().max(1000).default(''),
+  access: z.enum(['read', 'write']).default('read'),
+  enabled: z.boolean().default(true),
+})
+export type KnowledgeInput = z.input<typeof knowledgeInputSchema>
+
+export class KnowledgeService {
+  constructor(private db: DB, private settings: SettingsService) {}
+
+  list(): KnowledgeRootDto[] {
+    return this.db.select().from(knowledgeRoots).orderBy(knowledgeRoots.name).all()
+      .map(r => ({ id: r.id, name: r.name, path: r.path, description: r.description, access: r.access, enabled: r.enabled, check: checkKnowledgePath(r.path, this.settings.get()) }))
+  }
+
+  get(id: string): KnowledgeRootDto {
+    const k = this.list().find(r => r.id === id)
+    if (!k) throw notFound('资料库')
+    return k
+  }
+
+  create(input: KnowledgeInput): KnowledgeRootDto {
+    const data = this.validate(input)
+    const id = newId('kb')
+    this.db.insert(knowledgeRoots).values({ id, ...data }).run()
+    return this.get(id)
+  }
+
+  update(id: string, input: KnowledgeInput): KnowledgeRootDto {
+    this.get(id)
+    const data = this.validate(input)
+    this.db.update(knowledgeRoots).set({ ...data, updatedAt: nowIso() }).where(eq(knowledgeRoots.id, id)).run()
+    return this.get(id)
+  }
+
+  delete(id: string) {
+    this.get(id)
+    this.db.delete(knowledgeRoots).where(eq(knowledgeRoots.id, id)).run()
+  }
+
+  private validate(input: KnowledgeInput) {
+    const data = parse(knowledgeInputSchema, input)
+    const check = checkKnowledgePath(data.path, this.settings.get())
+    if (check.denied || !check.exists || !check.isDirectory || !check.readable) throw badRequest(check.message ?? '路径不可用', check)
+    return data
+  }
+}
+
+const hashKey = (key: string) => crypto.createHash('sha256').update(key).digest('hex')
+
+export class ApiKeyService {
+  private lastTouched = new Map<string, number>()
+
+  constructor(private db: DB) {}
+
+  list(): ApiKeyDto[] {
+    return this.db.select().from(apiKeys).orderBy(desc(apiKeys.createdAt)).all()
+      .map(r => ({ id: r.id, name: r.name, prefix: r.keyPrefix, enabled: r.enabled, createdAt: r.createdAt, lastUsedAt: r.lastUsedAt }))
+  }
+
+  /** 返回明文 Key,只在创建时出现一次 */
+  create(name: string): { key: string; apiKey: ApiKeyDto } {
+    if (!name.trim()) throw badRequest('名称不能为空')
+    const key = `ask_${crypto.randomBytes(24).toString('base64url')}`
+    const id = newId('key')
+    this.db.insert(apiKeys).values({ id, name: name.trim(), keyHash: hashKey(key), keyPrefix: key.slice(0, 10) }).run()
+    return { key, apiKey: this.list().find(k => k.id === id)! }
+  }
+
+  setEnabled(id: string, enabled: boolean) {
+    const r = this.db.update(apiKeys).set({ enabled }).where(eq(apiKeys.id, id)).run()
+    if (r.changes === 0) throw notFound('API Key')
+  }
+
+  delete(id: string) {
+    const r = this.db.delete(apiKeys).where(eq(apiKeys.id, id)).run()
+    if (r.changes === 0) throw notFound('API Key')
+  }
+
+  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
+    // 最近使用时间每分钟最多写一次
+    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()
+    }
+    return row.id
+  }
+}
+
+export type AuditKind = typeof auditLogs.$inferInsert['kind']
+
+export class AuditService {
+  constructor(private db: DB) {}
+
+  log(entry: { actor: string; sessionId?: string | null; kind: AuditKind; summary: string; detail?: unknown }) {
+    this.db.insert(auditLogs).values({ ...entry, sessionId: entry.sessionId ?? null, detail: entry.detail ?? null }).run()
+  }
+
+  list(opts: { kind?: AuditKind; sessionId?: string; limit?: number }) {
+    const where = and(opts.kind ? eq(auditLogs.kind, opts.kind) : undefined, opts.sessionId ? eq(auditLogs.sessionId, opts.sessionId) : undefined)
+    return this.db.select().from(auditLogs).where(where).orderBy(desc(auditLogs.id)).limit(Math.min(opts.limit ?? 200, 1000)).all()
+  }
+}
+
+export function parse<T>(schema: z.ZodType<T>, input: unknown): T {
+  const r = schema.safeParse(input)
+  if (!r.success) throw badRequest('参数校验失败', z.treeifyError(r.error))
+  return r.data
+}

+ 68 - 0
apps/server/src/services/event-store.ts

@@ -0,0 +1,68 @@
+// 事件管道:分配 seq → 落库(瞬时事件除外)→ 内存缓冲 → 广播(docs/design/02-architecture.md §3.3)
+import { EventEmitter } from 'node:events'
+import { and, asc, eq, gt, sql } from 'drizzle-orm'
+import { TRANSIENT_EVENT_TYPES, type AgentEvent, type AgentEventType, type EventPayloads } from '@agent-server/shared'
+import type { DB } from '../db'
+import { events, sessions } from '../db/schema'
+import { nowIso } from '../lib/ids'
+
+const BUFFER_SIZE = 2000
+
+export class EventStore {
+  private bus = new EventEmitter()
+  /** 每个会话最近的事件(含瞬时事件),用于 SSE 断线续传 */
+  private buffers = new Map<string, AgentEvent[]>()
+
+  constructor(private db: DB) {
+    this.bus.setMaxListeners(0)
+  }
+
+  append<T extends AgentEventType>(sessionId: string, runId: string | null, type: T, payload: EventPayloads[T]): AgentEvent {
+    const ts = nowIso()
+    const persist = !TRANSIENT_EVENT_TYPES.has(type)
+    const event = this.db.transaction(tx => {
+      const row = tx.update(sessions)
+        .set({ lastSeq: sql`${sessions.lastSeq} + 1`, ...(persist ? { updatedAt: ts } : {}) })
+        .where(eq(sessions.id, sessionId))
+        .returning({ seq: sessions.lastSeq })
+        .get()
+      if (!row) throw new Error(`会话不存在:${sessionId}`)
+      if (persist) tx.insert(events).values({ sessionId, seq: row.seq, runId, type, payload, ts }).run()
+      return { seq: row.seq, type, sessionId, runId, ts, payload } as AgentEvent
+    })
+
+    const buf = this.buffers.get(sessionId) ?? []
+    buf.push(event)
+    if (buf.length > BUFFER_SIZE) buf.splice(0, buf.length - BUFFER_SIZE)
+    this.buffers.set(sessionId, buf)
+
+    this.bus.emit(sessionId, event)
+    this.bus.emit('*', event)
+    return event
+  }
+
+  /** 读取 seq > after 的事件:优先使用内存缓冲(包含瞬时事件),不足时回落到数据库 */
+  list(sessionId: string, after = 0, limit = 5000): AgentEvent[] {
+    const buf = this.buffers.get(sessionId)
+    if (buf?.length && buf[0]!.seq <= after + 1) return buf.filter(e => e.seq > after).slice(0, limit)
+    const rows = this.db.select().from(events)
+      .where(and(eq(events.sessionId, sessionId), gt(events.seq, after)))
+      .orderBy(asc(events.seq))
+      .limit(limit)
+      .all()
+    const persisted = rows.map(r => ({ seq: r.seq, type: r.type, sessionId: r.sessionId, runId: r.runId, ts: r.ts, payload: r.payload }) as AgentEvent)
+    if (!buf?.length) return persisted
+    // 数据库部分 + 缓冲中更新的部分(缓冲区里才有瞬时事件)
+    const last = persisted.at(-1)?.seq ?? after
+    return [...persisted, ...buf.filter(e => e.seq > last)].slice(0, limit)
+  }
+
+  subscribe(sessionId: string | '*', fn: (e: AgentEvent) => void): () => void {
+    this.bus.on(sessionId, fn)
+    return () => this.bus.off(sessionId, fn)
+  }
+
+  dropBuffer(sessionId: string) {
+    this.buffers.delete(sessionId)
+  }
+}

+ 140 - 0
apps/server/src/services/files.ts

@@ -0,0 +1,140 @@
+// 工作区与文件:创建会话目录、保存上传、扫描 Agent 产出(docs/design/07-files-and-knowledge.md)
+import fs from 'node:fs'
+import fsp from 'node:fs/promises'
+import path from 'node:path'
+import { pipeline } from 'node:stream/promises'
+import type { Readable } from 'node:stream'
+import { and, eq } from 'drizzle-orm'
+import mime from 'mime-types'
+import type { FileDto } from '@agent-server/shared'
+import type { DB } from '../db'
+import { files } from '../db/schema'
+import { AppError, notFound } from '../lib/errors'
+import { newId, nowIso } from '../lib/ids'
+import { isWithin } from '../lib/paths'
+
+type FileRow = typeof files.$inferSelect
+
+const SCAN_MAX_FILES = 5000
+const SKIP_DIRS = new Set(['.agent', '.claude', '.codex', 'node_modules', '__pycache__', '.ipynb_checkpoints', '.venv'])
+
+export const toFileDto = (r: FileRow): FileDto => ({
+  id: r.id, sessionId: r.sessionId, path: r.relPath, origin: r.origin, mime: r.mime, size: r.size, createdAt: r.createdAt, updatedAt: r.updatedAt,
+})
+
+export interface FileChange { file: FileDto; kind: 'created' | 'modified' }
+
+export class FileService {
+  constructor(private db: DB) {}
+
+  async createWorkspace(dir: string) {
+    for (const sub of ['uploads', 'outputs', '.agent/mpl']) await fsp.mkdir(path.join(dir, sub), { recursive: true })
+  }
+
+  async removeWorkspace(dir: string, workspaceRoot: string) {
+    // 只删除工作区根目录下的会话目录,防止配置错误时误删
+    if (!isWithin(dir, workspaceRoot) || path.resolve(dir) === path.resolve(workspaceRoot)) return
+    await fsp.rm(dir, { recursive: true, force: true })
+  }
+
+  async saveUpload(opts: { sessionId: string; workspaceDir: string; filename: string; stream: Readable; limitBytes: number; truncated: () => boolean }): Promise<FileDto> {
+    const dir = path.join(opts.workspaceDir, 'uploads')
+    await fsp.mkdir(dir, { recursive: true })
+    const target = await uniquePath(dir, sanitizeName(opts.filename))
+    await pipeline(opts.stream, fs.createWriteStream(target, { flags: 'wx' }))
+    if (opts.truncated()) {
+      await fsp.rm(target, { force: true })
+      throw new AppError(413, 'file_too_large', `文件超过大小上限 ${Math.round(opts.limitBytes / 1048576)} MB`)
+    }
+    const st = await fsp.stat(target)
+    const row = {
+      id: newId('file'),
+      sessionId: opts.sessionId,
+      relPath: path.relative(opts.workspaceDir, target),
+      origin: 'upload' as const,
+      mime: mime.lookup(target) || 'application/octet-stream',
+      size: st.size,
+      mtimeMs: st.mtimeMs,
+    }
+    this.db.insert(files).values(row).run()
+    return toFileDto(this.db.select().from(files).where(eq(files.id, row.id)).get()!)
+  }
+
+  list(sessionId: string): FileDto[] {
+    return this.db.select().from(files).where(eq(files.sessionId, sessionId)).all().map(toFileDto)
+      .sort((a, b) => a.path.localeCompare(b.path))
+  }
+
+  get(fileId: string): FileRow {
+    const row = this.db.select().from(files).where(eq(files.id, fileId)).get()
+    if (!row) throw notFound('文件')
+    return row
+  }
+
+  getMany(sessionId: string, ids: string[]): FileRow[] {
+    return ids.map(id => {
+      const row = this.get(id)
+      if (row.sessionId !== sessionId) throw notFound('文件')
+      return row
+    })
+  }
+
+  /** 对比磁盘与数据库,登记新增或修改的文件 */
+  async scan(sessionId: string, workspaceDir: string): Promise<FileChange[]> {
+    const known = new Map(this.db.select().from(files).where(eq(files.sessionId, sessionId)).all().map(r => [r.relPath, r]))
+    const changes: FileChange[] = []
+    for await (const abs of walk(workspaceDir)) {
+      const relPath = path.relative(workspaceDir, abs)
+      const st = await fsp.stat(abs).catch(() => null)
+      if (!st?.isFile()) continue
+      const prev = known.get(relPath)
+      if (!prev) {
+        const row = { id: newId('file'), sessionId, relPath, origin: 'agent' as const, mime: mime.lookup(abs) || 'application/octet-stream', size: st.size, mtimeMs: st.mtimeMs }
+        this.db.insert(files).values(row).run()
+        changes.push({ file: toFileDto({ ...row, createdAt: nowIso(), updatedAt: nowIso() }), kind: 'created' })
+      } else if (prev.size !== st.size || Math.abs(prev.mtimeMs - st.mtimeMs) > 1) {
+        const updatedAt = nowIso()
+        this.db.update(files).set({ size: st.size, mtimeMs: st.mtimeMs, updatedAt }).where(and(eq(files.id, prev.id))).run()
+        changes.push({ file: toFileDto({ ...prev, size: st.size, mtimeMs: st.mtimeMs, updatedAt }), kind: 'modified' })
+      }
+    }
+    return changes
+  }
+
+  deleteForSession(sessionId: string) {
+    this.db.delete(files).where(eq(files.sessionId, sessionId)).run()
+  }
+}
+
+async function* walk(root: string): AsyncGenerator<string> {
+  let count = 0
+  const stack = [root]
+  while (stack.length) {
+    const dir = stack.pop()!
+    const entries = await fsp.readdir(dir, { withFileTypes: true }).catch(() => [])
+    for (const e of entries) {
+      if (e.isSymbolicLink()) continue
+      const abs = path.join(dir, e.name)
+      if (e.isDirectory()) {
+        if (!SKIP_DIRS.has(e.name)) stack.push(abs)
+      } else if (e.isFile()) {
+        if (++count > SCAN_MAX_FILES) return
+        yield abs
+      }
+    }
+  }
+}
+
+function sanitizeName(name: string): string {
+  const base = path.basename(name).replace(/[\x00-\x1f/\\:*?"<>|]/g, '_').trim()
+  return base && base !== '.' && base !== '..' ? base.slice(0, 180) : 'upload'
+}
+
+async function uniquePath(dir: string, name: string): Promise<string> {
+  const ext = path.extname(name)
+  const stem = name.slice(0, name.length - ext.length)
+  for (let i = 0; ; i++) {
+    const candidate = path.join(dir, i === 0 ? name : `${stem} (${i})${ext}`)
+    if (!fs.existsSync(candidate)) return candidate
+  }
+}

+ 116 - 0
apps/server/src/services/interactions.ts

@@ -0,0 +1,116 @@
+// 交互:审批与提问。挂起 Promise 等待前端回复,超时按拒绝处理(docs/design/04-api.md §4)
+import { and, eq } from 'drizzle-orm'
+import type {
+  InteractionApprovalRequest,
+  InteractionQuestionRequest,
+  InteractionRequest,
+  InteractionResolution,
+  InteractionResponse,
+} from '@agent-server/shared'
+import type { DB } from '../db'
+import { interactions } from '../db/schema'
+import { badRequest, conflict, notFound } from '../lib/errors'
+import { newId, nowIso } from '../lib/ids'
+import type { EventStore } from './event-store'
+import type { SettingsService } from './settings'
+
+export type NewApproval = Omit<InteractionApprovalRequest, 'interactionId' | 'expiresAt'>
+export type NewQuestion = Omit<InteractionQuestionRequest, 'interactionId' | 'expiresAt'>
+
+export type InteractionOutcome =
+  | { resolution: 'allowed'; scope: 'once' | 'session' }
+  | { resolution: 'denied'; reason?: string }
+  | { resolution: 'answered'; answers: Record<string, string | string[]> }
+  | { resolution: 'timeout' | 'cancelled' }
+
+interface Pending {
+  sessionId: string
+  runId: string
+  request: InteractionRequest
+  resolve: (o: InteractionOutcome) => void
+  timer: NodeJS.Timeout
+}
+
+export class InteractionService {
+  private pending = new Map<string, Pending>()
+
+  constructor(
+    private db: DB,
+    private events: EventStore,
+    private settings: SettingsService,
+  ) {
+    // 服务重启后,之前未决的交互已经没有等待者
+    db.update(interactions).set({ status: 'cancelled', resolvedAt: nowIso() }).where(eq(interactions.status, 'pending')).run()
+  }
+
+  request(sessionId: string, runId: string, req: NewApproval | NewQuestion, signal?: AbortSignal): Promise<InteractionOutcome> {
+    const id = newId('itx')
+    const timeoutMs = this.settings.get().limits.interactionTimeoutSec * 1000
+    const request = { ...req, interactionId: id, expiresAt: new Date(Date.now() + timeoutMs).toISOString() } as InteractionRequest
+    this.db.insert(interactions).values({
+      id, sessionId, runId, kind: request.kind, request, status: 'pending', expiresAt: request.expiresAt,
+    }).run()
+
+    return new Promise<InteractionOutcome>(resolve => {
+      const timer = setTimeout(() => this.finish(id, { resolution: 'timeout' }, null), timeoutMs)
+      this.pending.set(id, { sessionId, runId, request, resolve, timer })
+      signal?.addEventListener('abort', () => this.finish(id, { resolution: 'cancelled' }, null), { once: true })
+      this.events.append(sessionId, runId, 'interaction.required', request)
+    })
+  }
+
+  respond(id: string, body: InteractionResponse, sessionScope?: (sessionId: string) => boolean): void {
+    const p = this.pending.get(id)
+    if (!p) {
+      const row = this.db.select().from(interactions).where(eq(interactions.id, id)).get()
+      if (!row) throw notFound('交互')
+      throw conflict('already_resolved', '该交互已经处理过了')
+    }
+    if (sessionScope && !sessionScope(p.sessionId)) throw notFound('交互')
+
+    let outcome: InteractionOutcome
+    if ('answers' in body) {
+      if (p.request.kind !== 'question') throw badRequest('审批类交互需要 decision 字段')
+      outcome = { resolution: 'answered', answers: body.answers }
+    } else {
+      if (p.request.kind !== 'approval') throw badRequest('提问类交互需要 answers 字段')
+      if (!p.request.options.includes(body.decision)) throw badRequest(`不支持的选项:${body.decision}`)
+      outcome = body.decision === 'deny'
+        ? { resolution: 'denied', reason: body.reason }
+        : { resolution: 'allowed', scope: body.decision === 'allow_session' ? 'session' : 'once' }
+    }
+    this.finish(id, outcome, body)
+  }
+
+  pendingFor(sessionId: string): InteractionRequest[] {
+    return [...this.pending.values()].filter(p => p.sessionId === sessionId).map(p => p.request)
+  }
+
+  cancelRun(runId: string) {
+    for (const [id, p] of this.pending) if (p.runId === runId) this.finish(id, { resolution: 'cancelled' }, null)
+  }
+
+  hasPending(runId: string) {
+    for (const p of this.pending.values()) if (p.runId === runId) return true
+    return false
+  }
+
+  private finish(id: string, outcome: InteractionOutcome, response: InteractionResponse | null) {
+    const p = this.pending.get(id)
+    if (!p) return
+    this.pending.delete(id)
+    clearTimeout(p.timer)
+    const status: InteractionResolution = outcome.resolution
+    this.db.update(interactions)
+      .set({ status, response, resolvedAt: nowIso() })
+      .where(and(eq(interactions.id, id), eq(interactions.status, 'pending')))
+      .run()
+    this.events.append(p.sessionId, p.runId, 'interaction.resolved', {
+      interactionId: id,
+      resolution: status,
+      ...(outcome.resolution === 'answered' ? { answers: outcome.answers } : {}),
+      ...(outcome.resolution === 'denied' && outcome.reason ? { reason: outcome.reason } : {}),
+    })
+    p.resolve(outcome)
+  }
+}

+ 58 - 0
apps/server/src/services/office.ts

@@ -0,0 +1,58 @@
+// 文档格式转换:在独立的 bubblewrap 沙箱中运行 LibreOffice。
+// 引擎的 Bash 沙箱禁止创建 Unix socket(LibreOffice 必需),而放开全部 Unix socket 会暴露 docker.sock 等,
+// 因此转换不走 Bash,而是作为受控工具提供:断网、私有 /tmp 与 /run、隐藏 home 与服务数据,只挂载当前工作区。
+import { execFile } from 'node:child_process'
+import fs from 'node:fs'
+import path from 'node:path'
+import { promisify } from 'node:util'
+import { config } from '../config'
+import { isWithin, realpathLoose } from '../lib/paths'
+
+const execFileP = promisify(execFile)
+
+export const CONVERT_FORMATS = ['pdf', 'docx', 'xlsx', 'pptx', 'odt', 'ods', 'html', 'txt', 'csv', 'png'] as const
+export type ConvertFormat = (typeof CONVERT_FORMATS)[number]
+
+const TIMEOUT_MS = 120_000
+
+export class ConvertError extends Error {}
+
+export async function convertDocument(opts: { workspaceDir: string; input: string; format: ConvertFormat; outDir?: string }): Promise<string> {
+  const ws = fs.realpathSync(opts.workspaceDir)
+  const input = realpathLoose(path.resolve(ws, opts.input))
+  const outDir = realpathLoose(path.resolve(ws, opts.outDir ?? 'outputs'))
+  if (!isWithin(input, ws)) throw new ConvertError(`输入文件必须位于工作区内:${opts.input}`)
+  if (!isWithin(outDir, ws)) throw new ConvertError(`输出目录必须位于工作区内:${opts.outDir}`)
+  if (!fs.existsSync(input) || !fs.statSync(input).isFile()) throw new ConvertError(`文件不存在:${opts.input}`)
+  if (!CONVERT_FORMATS.includes(opts.format)) throw new ConvertError(`不支持的格式:${opts.format}`)
+  fs.mkdirSync(outDir, { recursive: true })
+
+  const args = [
+    '--ro-bind', '/', '/',
+    '--dev', '/dev',
+    '--proc', '/proc',
+    '--tmpfs', '/tmp',
+    '--tmpfs', '/run',
+    '--tmpfs', config.home,
+    '--tmpfs', config.dataDir,
+    '--bind', ws, ws,
+    '--unshare-all',
+    '--die-with-parent',
+    '--new-session',
+    '--chdir', ws,
+    '--setenv', 'HOME', '/tmp',
+    '--setenv', 'TMPDIR', '/tmp',
+    '--setenv', 'LANG', 'C.UTF-8',
+    'soffice', '-env:UserInstallation=file:///tmp/lo', '--headless', '--norestore',
+    '--convert-to', opts.format, '--outdir', outDir, input,
+  ]
+  try {
+    await execFileP('bwrap', args, { timeout: TIMEOUT_MS, killSignal: 'SIGKILL', maxBuffer: 4 * 1048576 })
+  } catch (err) {
+    const e = err as { stderr?: string; killed?: boolean; message: string }
+    throw new ConvertError(e.killed ? '转换超时' : `转换失败:${(e.stderr || e.message).trim().slice(-800)}`)
+  }
+  const out = path.join(outDir, `${path.parse(input).name}.${opts.format}`)
+  if (!fs.existsSync(out)) throw new ConvertError('LibreOffice 没有生成输出文件,请检查输入文件是否能被打开')
+  return out
+}

+ 38 - 0
apps/server/src/services/prompt.ts

@@ -0,0 +1,38 @@
+// 系统提示词追加部分:运行环境说明 + 资料库清单 + Profile/会话提示词
+import path from 'node:path'
+import type { AppSettings, KnowledgeRootDto, PermissionMode } from '@agent-server/shared'
+
+export function buildSystemPrompt(opts: {
+  workspaceDir: string
+  settings: AppSettings
+  knowledgeRoots: KnowledgeRootDto[]
+  profilePrompt: string
+  sessionPrompt: string
+  permissionMode: PermissionMode
+}): string {
+  const py = path.join(opts.settings.paths.pythonRuntime, 'bin/python')
+  const parts = [
+    `# 运行环境(AgentServer)
+你在 AgentServer 中为用户完成数据分析、资料查找与分析、生成文档和报表等任务。用户通过网页与你对话,看不到你的终端。
+
+- 工作目录:${opts.workspaceDir}
+  - 用户上传的文件在 uploads/ 下
+  - **最终交付的文件请保存到 outputs/**,用户可以在界面上直接预览和下载;中间文件随意放置
+- Python:直接使用 \`python\`(即 ${py}),已预装 pandas、numpy、pyarrow、duckdb、matplotlib、seaborn、plotly、openpyxl、xlsxwriter、python-docx、python-pptx、pypdf、pdfplumber、reportlab、markitdown。不要向这个共享环境 pip install;确实需要额外依赖时先询问用户。
+- 系统工具:pandoc、pdftotext/pdftoppm、ripgrep(rg)。
+- 文档格式转换(docx/xlsx/pptx → pdf 等)请用 \`convert_document\` 工具;不要在 Bash 里调用 soffice/libreoffice,它在沙箱中无法运行。
+- matplotlib 已配置中文字体,图表可以直接使用中文。
+- 你只能访问被授权的路径;访问被拒绝时向用户说明,不要尝试绕过限制。${opts.permissionMode === 'readonly' ? '\n- 当前会话是只读模式:不能修改文件或执行命令。' : ''}
+- 默认用中文回复,结论先行,表格和要点优先。`,
+  ]
+
+  if (opts.knowledgeRoots.length) {
+    const list = opts.knowledgeRoots
+      .map(k => `- ${k.name}:${k.path}(${k.access === 'write' ? '可读写' : '只读'})${k.description ? `\n  ${k.description}` : ''}`)
+      .join('\n')
+    parts.push(`# 可用资料库\n查找资料时优先检索以下目录:\n${list}`)
+  }
+  if (opts.profilePrompt.trim()) parts.push(opts.profilePrompt.trim())
+  if (opts.sessionPrompt.trim()) parts.push(opts.sessionPrompt.trim())
+  return parts.join('\n\n')
+}

+ 292 - 0
apps/server/src/services/runs.ts

@@ -0,0 +1,292 @@
+// Run 调度:排队、并发控制、执行、中断与重启恢复(docs/design/02-architecture.md §3.2)
+import fsp from 'node:fs/promises'
+import path from 'node:path'
+import { eq, inArray } from 'drizzle-orm'
+import PQueue from 'p-queue'
+import { z } from 'zod'
+import type { AgentEventType, EventPayloads, ProviderId, RunDto, SendMessageBody, SessionDto } from '@agent-server/shared'
+import type { DB } from '../db'
+import { runs } from '../db/schema'
+import { conflict, notFound } from '../lib/errors'
+import { newId, nowIso } from '../lib/ids'
+import type { AgentProvider, RunInput } from '../providers/types'
+import { ProviderRunError } from '../providers/types'
+import { resolveAccess } from './access-policy'
+import { parse, type AuditService, type KnowledgeService, type ProfileService } from './catalog'
+import type { EventStore } from './event-store'
+import type { FileService } from './files'
+import type { InteractionService } from './interactions'
+import type { SessionRow, SessionService } from './sessions'
+import type { SettingsService } from './settings'
+import { buildSystemPrompt } from './prompt'
+
+type RunRow = typeof runs.$inferSelect
+
+const sendSchema: z.ZodType<SendMessageBody> = z.object({
+  text: z.string().trim().min(1).max(100_000),
+  attachments: z.array(z.string()).max(50).optional(),
+})
+
+const IMAGE_MIMES = new Set(['image/png', 'image/jpeg', 'image/gif', 'image/webp'])
+const MAX_INLINE_IMAGE = 5 * 1024 * 1024
+
+interface ActiveRun {
+  runId: string
+  sessionId: string
+  controller: AbortController
+  status: 'queued' | 'running' | 'waiting_input'
+}
+
+interface Logger { info(o: object, m?: string): void; warn(o: object, m?: string): void; error(o: object, m?: string): void; child(b: object): Logger }
+
+export class RunManager {
+  private global: PQueue
+  private perProvider: Record<ProviderId, PQueue>
+  private active = new Map<string, ActiveRun>()
+
+  constructor(
+    private deps: {
+      db: DB
+      log: Logger
+      settings: SettingsService
+      sessions: SessionService
+      profiles: ProfileService
+      knowledge: KnowledgeService
+      files: FileService
+      events: EventStore
+      interactions: InteractionService
+      audit: AuditService
+      providers: Partial<Record<ProviderId, AgentProvider>>
+    },
+  ) {
+    const c = deps.settings.get().limits.concurrency
+    this.global = new PQueue({ concurrency: c.global })
+    this.perProvider = { claude: new PQueue({ concurrency: Math.max(c.claude, 1) }), codex: new PQueue({ concurrency: Math.max(c.codex, 1) }) }
+    deps.settings.on('changed', s => {
+      this.global.concurrency = s.limits.concurrency.global
+      this.perProvider.claude.concurrency = Math.max(s.limits.concurrency.claude, 1)
+      this.perProvider.codex.concurrency = Math.max(s.limits.concurrency.codex, 1)
+    })
+    deps.sessions.statusOf = id => this.sessionStatus(id)
+    this.recoverAfterRestart()
+  }
+
+  sessionStatus(sessionId: string): SessionDto['status'] {
+    const a = this.activeForSession(sessionId)
+    if (!a) return 'idle'
+    return a.status === 'waiting_input' ? 'waiting_input' : 'running'
+  }
+
+  activeForSession(sessionId: string): ActiveRun | undefined {
+    for (const a of this.active.values()) if (a.sessionId === sessionId) return a
+    return undefined
+  }
+
+  stats() {
+    return { running: this.global.pending, queued: this.global.size + this.perProvider.claude.size + this.perProvider.codex.size, active: this.active.size }
+  }
+
+  /** 发送消息:登记 Run 并排队,立即返回 runId */
+  send(session: SessionRow, body: SendMessageBody): RunDto {
+    const data = parse(sendSchema, body)
+    if (this.activeForSession(session.id)) throw conflict('session_busy', '会话正在运行,请等待完成或先中断')
+    const provider = this.deps.providers[session.provider]
+    if (!provider || !this.deps.settings.get().providers[session.provider].enabled) throw conflict('provider_unavailable', `引擎 ${session.provider} 当前不可用`)
+    const attachments = this.deps.files.getMany(session.id, data.attachments ?? [])
+
+    const runId = newId('run')
+    this.deps.db.insert(runs).values({ id: runId, sessionId: session.id, status: 'queued' }).run()
+    const active: ActiveRun = { runId, sessionId: session.id, controller: new AbortController(), status: 'queued' }
+    this.active.set(runId, active)
+
+    this.deps.sessions.ensureTitle(session.id, data.text)
+    this.deps.events.append(session.id, runId, 'user.message', {
+      text: data.text,
+      attachments: attachments.map(f => ({ fileId: f.id, name: path.basename(f.relPath), mime: f.mime, size: f.size })),
+    })
+    const queue = this.perProvider[session.provider]
+    this.deps.events.append(session.id, runId, 'run.queued', { position: queue.size + queue.pending })
+
+    void queue.add(() => this.global.add(() => this.execute(runId, session.id, provider, data.text, attachments.map(f => f.id))))
+      .catch(err => this.deps.log.error({ err, runId }, 'run task crashed'))
+    return this.get(runId)
+  }
+
+  interrupt(runId: string, sessionId?: string) {
+    const a = this.active.get(runId)
+    if (!a || (sessionId && a.sessionId !== sessionId)) {
+      const row = this.deps.db.select().from(runs).where(eq(runs.id, runId)).get()
+      if (!row || (sessionId && row.sessionId !== sessionId)) throw notFound('Run')
+      return // 已结束
+    }
+    a.controller.abort()
+    this.deps.interactions.cancelRun(runId)
+    if (a.status === 'queued') this.finish(a, 'interrupted', { reason: 'user' })
+  }
+
+  get(runId: string): RunDto {
+    const r = this.deps.db.select().from(runs).where(eq(runs.id, runId)).get()
+    if (!r) throw notFound('Run')
+    return toRunDto(r)
+  }
+
+  private async execute(runId: string, sessionId: string, provider: AgentProvider, text: string, attachmentIds: string[]) {
+    const a = this.active.get(runId)
+    if (!a || a.controller.signal.aborted) return
+    const { deps } = this
+    const log = deps.log.child({ runId, sessionId })
+    const emit = <T extends AgentEventType>(type: T, payload: EventPayloads[T]) => { deps.events.append(sessionId, runId, type, payload) }
+    const startedAt = Date.now()
+
+    a.status = 'running'
+    deps.db.update(runs).set({ status: 'running', startedAt: nowIso() }).where(eq(runs.id, runId)).run()
+
+    let scanTimer: NodeJS.Timeout | undefined
+    let session: SessionRow | undefined
+    const scanFiles = async () => {
+      if (!session) return
+      for (const c of await deps.files.scan(sessionId, session.workspaceDir)) {
+        emit('file.changed', { fileId: c.file.id, path: c.file.path, kind: c.kind, size: c.file.size, mime: c.file.mime })
+      }
+    }
+
+    try {
+      session = deps.sessions.getRow(sessionId)
+      const settings = deps.settings.get()
+      const profile = session.profileId ? safe(() => deps.profiles.get(session!.profileId!)) ?? deps.profiles.getDefault() : deps.profiles.getDefault()
+      const knowledgeRoots = deps.knowledge.list()
+      const access = resolveAccess({
+        policy: profile.fileAccess,
+        knowledgeRoots,
+        selectedKnowledgeRootIds: profile.knowledgeRootIds,
+        workspaceDir: session.workspaceDir,
+        settings,
+      })
+      emit('run.started', { provider: session.provider, model: session.model })
+
+      const result = await provider.run({
+        sessionId,
+        runId,
+        model: session.model,
+        permissionMode: session.permissionMode,
+        providerSessionId: session.providerSessionId,
+        workspaceDir: session.workspaceDir,
+        systemPromptAppend: buildSystemPrompt({
+          workspaceDir: session.workspaceDir,
+          settings,
+          knowledgeRoots: knowledgeRoots.filter(k => k.enabled && profile.knowledgeRootIds.includes(k.id)),
+          profilePrompt: profile.systemPrompt,
+          sessionPrompt: session.systemPromptAppend,
+          permissionMode: session.permissionMode,
+        }),
+        allowedTools: profile.allowedTools,
+        disallowedTools: profile.disallowedTools,
+        skills: profile.skills,
+        input: await this.buildInput(session, text, attachmentIds),
+        access,
+        settings,
+        signal: a.controller.signal,
+        emit: (type, payload) => {
+          emit(type, payload)
+          if (type === 'tool.completed') {
+            clearTimeout(scanTimer)
+            scanTimer = setTimeout(() => void scanFiles().catch(() => {}), 300)
+          }
+        },
+        interact: async req => {
+          a.status = 'waiting_input'
+          deps.db.update(runs).set({ status: 'waiting_input' }).where(eq(runs.id, runId)).run()
+          try {
+            return await deps.interactions.request(sessionId, runId, req, a.controller.signal)
+          } finally {
+            if (!deps.interactions.hasPending(runId) && !a.controller.signal.aborted) {
+              a.status = 'running'
+              deps.db.update(runs).set({ status: 'running' }).where(eq(runs.id, runId)).run()
+            }
+          }
+        },
+        setProviderSessionId: id => deps.sessions.setProviderSessionId(sessionId, id),
+        audit: (kind, summary, detail) => deps.audit.log({ actor: `agent:${session!.provider}`, sessionId, kind, summary, detail }),
+        log,
+      })
+
+      clearTimeout(scanTimer)
+      await scanFiles().catch(err => log.warn({ err }, 'file scan failed'))
+      if (result.usage) {
+        emit('usage', result.usage)
+        deps.db.update(runs).set({
+          inputTokens: result.usage.inputTokens, outputTokens: result.usage.outputTokens,
+          cacheReadTokens: result.usage.cacheReadTokens, cacheWriteTokens: result.usage.cacheWriteTokens, costUsd: result.usage.costUsd ?? null,
+        }).where(eq(runs.id, runId)).run()
+      }
+      this.finish(a, 'completed', { durationMs: Date.now() - startedAt })
+    } catch (err) {
+      clearTimeout(scanTimer)
+      await scanFiles().catch(() => {})
+      if (a.controller.signal.aborted) {
+        this.finish(a, 'interrupted', { reason: 'user' })
+      } else {
+        const code = err instanceof ProviderRunError ? err.code : 'internal_error'
+        const message = err instanceof Error ? err.message : String(err)
+        log.error({ err }, 'run failed')
+        this.finish(a, 'failed', { code, message })
+      }
+    }
+  }
+
+  private finish(a: ActiveRun, status: 'completed', p: EventPayloads['run.completed']): void
+  private finish(a: ActiveRun, status: 'failed', p: EventPayloads['run.failed']): void
+  private finish(a: ActiveRun, status: 'interrupted', p: EventPayloads['run.interrupted']): void
+  private finish(a: ActiveRun, status: 'completed' | 'failed' | 'interrupted', p: never) {
+    if (!this.active.has(a.runId)) return
+    this.active.delete(a.runId)
+    this.deps.db.update(runs).set({
+      status, endedAt: nowIso(), error: status === 'failed' ? (p as EventPayloads['run.failed']).message : null,
+    }).where(eq(runs.id, a.runId)).run()
+    this.deps.events.append(a.sessionId, a.runId, `run.${status}`, p)
+  }
+
+  private async buildInput(session: SessionRow, text: string, attachmentIds: string[]): Promise<RunInput> {
+    const input: RunInput = { text, images: [], attachments: [] }
+    for (const f of this.deps.files.getMany(session.id, attachmentIds)) {
+      const name = path.basename(f.relPath)
+      input.attachments.push({ path: f.relPath, name, mime: f.mime, size: f.size })
+      if (IMAGE_MIMES.has(f.mime) && f.size <= MAX_INLINE_IMAGE) {
+        const buf = await fsp.readFile(path.join(session.workspaceDir, f.relPath))
+        input.images.push({ mime: f.mime, base64: buf.toString('base64'), name })
+      }
+    }
+    return input
+  }
+
+  private recoverAfterRestart() {
+    const stale = this.deps.db.select().from(runs).where(inArray(runs.status, ['queued', 'running', 'waiting_input'])).all()
+    for (const r of stale) {
+      this.deps.db.update(runs).set({ status: 'interrupted', endedAt: nowIso(), error: '服务重启' }).where(eq(runs.id, r.id)).run()
+      try {
+        this.deps.events.append(r.sessionId, r.id, 'run.interrupted', { reason: 'restart' })
+      } catch {
+        // 会话已被删除
+      }
+    }
+    if (stale.length) this.deps.log.warn({ count: stale.length }, '服务重启:未完成的 Run 已标记为 interrupted')
+  }
+}
+
+function toRunDto(r: RunRow): RunDto {
+  return {
+    id: r.id, sessionId: r.sessionId, status: r.status, startedAt: r.startedAt, endedAt: r.endedAt, error: r.error,
+    usage: r.inputTokens === null ? null : {
+      inputTokens: r.inputTokens, outputTokens: r.outputTokens ?? 0, cacheReadTokens: r.cacheReadTokens ?? 0,
+      cacheWriteTokens: r.cacheWriteTokens ?? 0, costUsd: r.costUsd ?? undefined,
+    },
+  }
+}
+
+function safe<T>(fn: () => T): T | undefined {
+  try {
+    return fn()
+  } catch {
+    return undefined
+  }
+}

+ 148 - 0
apps/server/src/services/sessions.ts

@@ -0,0 +1,148 @@
+import path from 'node:path'
+import { and, desc, eq, lt, or } from 'drizzle-orm'
+import { z } from 'zod'
+import type { CreateSessionBody, Page, ProviderId, SessionDto } from '@agent-server/shared'
+import type { DB } from '../db'
+import { events, interactions, runs, sessions } from '../db/schema'
+import { badRequest, conflict, notFound } from '../lib/errors'
+import { newId, nowIso } from '../lib/ids'
+import { parse, type ProfileService } from './catalog'
+import type { EventStore } from './event-store'
+import type { FileService } from './files'
+import type { SettingsService } from './settings'
+
+export type SessionRow = typeof sessions.$inferSelect
+
+const createSchema: z.ZodType<CreateSessionBody> = z.object({
+  profileId: z.string().optional(),
+  provider: z.enum(['claude', 'codex']).optional(),
+  model: z.string().min(1).optional(),
+  title: z.string().max(200).optional(),
+  permissionMode: z.enum(['ask', 'auto', 'readonly']).optional(),
+  systemPromptAppend: z.string().max(20000).optional(),
+})
+
+const patchSchema = z.object({
+  title: z.string().max(200).optional(),
+  archived: z.boolean().optional(),
+  // 以下只能在空闲时修改;引擎与 Profile 只能在还没有对话时修改
+  model: z.string().min(1).optional(),
+  permissionMode: z.enum(['ask', 'auto', 'readonly']).optional(),
+  systemPromptAppend: z.string().max(20000).optional(),
+  provider: z.enum(['claude', 'codex']).optional(),
+  profileId: z.string().optional(),
+})
+
+export class SessionService {
+  /** 由 RunManager 注入:查询会话当前状态 */
+  statusOf: (sessionId: string) => SessionDto['status'] = () => 'idle'
+
+  constructor(
+    private db: DB,
+    private settings: SettingsService,
+    private profiles: ProfileService,
+    private files: FileService,
+    private events: EventStore,
+  ) {}
+
+  async create(source: string, body: CreateSessionBody): Promise<SessionRow> {
+    const data = parse(createSchema, body)
+    const profile = data.profileId ? this.profiles.get(data.profileId) : this.profiles.getDefault()
+    const provider: ProviderId = data.provider ?? profile.provider
+    const s = this.settings.get()
+    const providerSettings = s.providers[provider]
+    if (!providerSettings.enabled) throw badRequest(`引擎 ${provider} 未启用`)
+    const model = data.model ?? (provider === profile.provider ? profile.model : providerSettings.defaultModel)
+    if (!model) throw badRequest('未指定模型')
+
+    const id = newId('ses')
+    const workspaceDir = path.join(s.paths.workspaceRoot, id)
+    await this.files.createWorkspace(workspaceDir)
+    this.db.insert(sessions).values({
+      id, source, profileId: profile.id, provider, model, workspaceDir,
+      permissionMode: data.permissionMode ?? profile.permissionMode,
+      systemPromptAppend: data.systemPromptAppend ?? '',
+      title: data.title ?? '',
+    }).run()
+    return this.getRow(id)
+  }
+
+  getRow(id: string, source?: string): SessionRow {
+    const row = this.db.select().from(sessions).where(eq(sessions.id, id)).get()
+    // 非后台调用只能访问自己创建的会话
+    if (!row || (source && source !== 'admin' && row.source !== source)) throw notFound('会话')
+    return row
+  }
+
+  toDto(r: SessionRow): SessionDto {
+    return {
+      id: r.id, title: r.title, provider: r.provider, model: r.model, profileId: r.profileId, status: this.statusOf(r.id),
+      archived: r.archived, source: r.source, lastSeq: r.lastSeq, createdAt: r.createdAt, updatedAt: r.updatedAt,
+    }
+  }
+
+  list(source: string, opts: { cursor?: string; limit?: number; all?: boolean; archived?: boolean }): Page<SessionDto> {
+    const limit = Math.min(Math.max(opts.limit ?? 50, 1), 200)
+    let cursorCond
+    if (opts.cursor) {
+      const [updatedAt, id] = Buffer.from(opts.cursor, 'base64url').toString().split('|')
+      cursorCond = or(lt(sessions.updatedAt, updatedAt!), and(eq(sessions.updatedAt, updatedAt!), lt(sessions.id, id!)))
+    }
+    const rows = this.db.select().from(sessions)
+      .where(and(
+        opts.all && source === 'admin' ? undefined : eq(sessions.source, source),
+        opts.archived === undefined ? undefined : eq(sessions.archived, opts.archived),
+        cursorCond,
+      ))
+      .orderBy(desc(sessions.updatedAt), desc(sessions.id))
+      .limit(limit + 1)
+      .all()
+    const items = rows.slice(0, limit)
+    const last = items.at(-1)
+    return {
+      items: items.map(r => this.toDto(r)),
+      nextCursor: rows.length > limit && last ? Buffer.from(`${last.updatedAt}|${last.id}`).toString('base64url') : null,
+    }
+  }
+
+  patch(id: string, source: string, body: unknown): SessionRow {
+    const row = this.getRow(id, source)
+    const data = parse(patchSchema, body)
+    const changesRunConfig = data.model !== undefined || data.permissionMode !== undefined || data.systemPromptAppend !== undefined
+      || data.provider !== undefined || data.profileId !== undefined
+    if (changesRunConfig && this.statusOf(id) !== 'idle') throw conflict('session_busy', '会话正在运行,请等待完成后再修改')
+    if ((data.provider && data.provider !== row.provider) || (data.profileId && data.profileId !== row.profileId)) {
+      if (row.providerSessionId) throw badRequest('会话已经开始对话,不能再更换引擎或 Profile')
+    }
+    if (data.profileId) this.profiles.get(data.profileId)
+    if (data.provider && !this.settings.get().providers[data.provider].enabled) throw badRequest(`引擎 ${data.provider} 未启用`)
+    this.db.update(sessions).set({ ...data, updatedAt: nowIso() }).where(eq(sessions.id, id)).run()
+    return this.getRow(id)
+  }
+
+  setProviderSessionId(id: string, providerSessionId: string) {
+    this.db.update(sessions).set({ providerSessionId }).where(eq(sessions.id, id)).run()
+  }
+
+  /** 首条消息时自动生成标题 */
+  ensureTitle(id: string, text: string) {
+    const row = this.getRow(id)
+    if (row.title) return
+    const title = text.replace(/\s+/g, ' ').trim().slice(0, 40) || '新会话'
+    this.db.update(sessions).set({ title }).where(eq(sessions.id, id)).run()
+  }
+
+  async delete(id: string, source: string) {
+    const row = this.getRow(id, source)
+    if (this.statusOf(id) !== 'idle') throw conflict('session_busy', '会话正在运行,请先中断')
+    this.db.transaction(tx => {
+      tx.delete(events).where(eq(events.sessionId, id)).run()
+      tx.delete(interactions).where(eq(interactions.sessionId, id)).run()
+      tx.delete(runs).where(eq(runs.sessionId, id)).run()
+      tx.delete(sessions).where(eq(sessions.id, id)).run()
+    })
+    this.files.deleteForSession(id)
+    this.events.dropBuffer(id)
+    await this.files.removeWorkspace(row.workspaceDir, this.settings.get().paths.workspaceRoot)
+  }
+}

+ 118 - 0
apps/server/src/services/settings.ts

@@ -0,0 +1,118 @@
+import { EventEmitter } from 'node:events'
+import path from 'node:path'
+import { eq } from 'drizzle-orm'
+import { z } from 'zod'
+import type { AppSettings, DeepPartial } from '@agent-server/shared'
+import { config } from '../config'
+import type { DB } from '../db'
+import { settings as settingsTable } from '../db/schema'
+import { badRequest } from '../lib/errors'
+
+const KEY = 'app'
+
+export function defaultSettings(): AppSettings {
+  const d = config.dataDir
+  return {
+    paths: {
+      workspaceRoot: path.join(d, 'workspaces'),
+      skillsDir: path.join(d, 'skills'),
+      pythonRuntime: path.join(d, 'runtime/py'),
+      matplotlibrc: path.join(d, 'runtime/matplotlibrc'),
+    },
+    security: { globalDeny: [], defaultFileAccessMode: 'restricted' },
+    network: { allowedDomains: ['pypi.org', 'files.pythonhosted.org'], blockPrivateFetch: true },
+    limits: {
+      concurrency: { global: 4, claude: 2, codex: 2 },
+      interactionTimeoutSec: 600,
+      uploadMaxMB: 50,
+      workspaceMaxMB: 2048,
+    },
+    retention: { workspaceDays: 30 },
+    providers: {
+      claude: {
+        enabled: true,
+        cliPath: path.join(config.home, '.local/bin/claude'),
+        defaultModel: 'sonnet',
+        models: ['sonnet', 'opus', 'haiku', 'fable'],
+      },
+      codex: {
+        enabled: false,
+        cliPath: path.join(config.home, '.local/bin/codex'),
+        defaultModel: '',
+        models: [],
+        configOverrides: [],
+      },
+    },
+  }
+}
+
+const absPath = z.string().min(1).refine(p => path.isAbsolute(p), '必须是绝对路径')
+const domain = z.string().regex(/^(\*\.)?[a-z0-9-]+(\.[a-z0-9-]+)+$/i, '域名格式不正确')
+
+const settingsSchema: z.ZodType<AppSettings> = z.object({
+  paths: z.object({ workspaceRoot: absPath, skillsDir: absPath, pythonRuntime: absPath, matplotlibrc: absPath }),
+  security: z.object({
+    globalDeny: z.array(z.string().min(1)),
+    defaultFileAccessMode: z.enum(['restricted', 'broad']),
+  }),
+  network: z.object({ allowedDomains: z.array(domain), blockPrivateFetch: z.boolean() }),
+  limits: z.object({
+    concurrency: z.object({
+      global: z.number().int().min(1).max(32),
+      claude: z.number().int().min(0).max(32),
+      codex: z.number().int().min(0).max(32),
+    }),
+    interactionTimeoutSec: z.number().int().min(30).max(86400),
+    uploadMaxMB: z.number().int().min(1).max(1024),
+    workspaceMaxMB: z.number().int().min(10).max(102400),
+  }),
+  retention: z.object({ workspaceDays: z.number().int().min(1).max(3650) }),
+  providers: z.object({
+    claude: z.object({ enabled: z.boolean(), cliPath: absPath, defaultModel: z.string().min(1), models: z.array(z.string().min(1)) }),
+    codex: z.object({
+      enabled: z.boolean(),
+      cliPath: absPath,
+      defaultModel: z.string(),
+      models: z.array(z.string().min(1)),
+      configOverrides: z.array(z.string().min(1)),
+    }),
+  }),
+})
+
+function deepMerge<T>(base: T, patch: unknown): T {
+  if (patch === undefined) return base
+  if (typeof base !== 'object' || base === null || Array.isArray(base) || typeof patch !== 'object' || patch === null || Array.isArray(patch)) {
+    return patch as T
+  }
+  const out: Record<string, unknown> = { ...(base as Record<string, unknown>) }
+  for (const [k, v] of Object.entries(patch)) out[k] = deepMerge(out[k], v)
+  return out as T
+}
+
+export class SettingsService extends EventEmitter<{ changed: [AppSettings] }> {
+  private cache: AppSettings
+
+  constructor(private db: DB) {
+    super()
+    const row = db.select().from(settingsTable).where(eq(settingsTable.key, KEY)).get()
+    // 存储的值与默认值合并,以便新版本增加的配置项自动获得默认值
+    this.cache = deepMerge(defaultSettings(), row?.value)
+  }
+
+  get(): AppSettings {
+    return this.cache
+  }
+
+  update(patch: DeepPartial<AppSettings>): AppSettings {
+    const merged = deepMerge(this.cache, patch)
+    const parsed = settingsSchema.safeParse(merged)
+    if (!parsed.success) throw badRequest('配置校验失败', z.treeifyError(parsed.error))
+    this.db.insert(settingsTable)
+      .values({ key: KEY, value: parsed.data })
+      .onConflictDoUpdate({ target: settingsTable.key, set: { value: parsed.data, updatedAt: new Date().toISOString() } })
+      .run()
+    this.cache = parsed.data
+    this.emit('changed', this.cache)
+    return this.cache
+  }
+}

+ 105 - 0
apps/server/test/access-policy.test.ts

@@ -0,0 +1,105 @@
+import fs from 'node:fs'
+import os from 'node:os'
+import path from 'node:path'
+import { describe, expect, it } from 'vitest'
+import type { FileAccessPolicy, KnowledgeRootDto } from '@agent-server/shared'
+import { config } from '../src/config'
+import { checkAccess, checkKnowledgePath, resolveAccess, toClaudeSandbox } from '../src/services/access-policy'
+import { defaultSettings } from '../src/services/settings'
+
+const settings = defaultSettings()
+const ws = path.join(settings.paths.workspaceRoot, 'ses_a')
+const other = path.join(settings.paths.workspaceRoot, 'ses_b')
+fs.mkdirSync(path.join(ws, 'uploads'), { recursive: true })
+fs.mkdirSync(other, { recursive: true })
+const kbDir = fs.mkdtempSync(path.join(os.tmpdir(), 'kb-'))
+const kb: KnowledgeRootDto = {
+  id: 'kb_1', name: '资料', path: kbDir, description: '', access: 'read', enabled: true,
+  check: { exists: true, readable: true, isDirectory: true, denied: null, underHome: false, message: null },
+}
+
+const policy = (p: Partial<FileAccessPolicy> = {}): FileAccessPolicy => ({ mode: 'restricted', read: [], write: [], deny: [], ...p })
+const resolve = (p: Partial<FileAccessPolicy> = {}, kbIds: string[] = []) =>
+  resolveAccess({ policy: policy(p), knowledgeRoots: [kb], selectedKnowledgeRootIds: kbIds, workspaceDir: ws, settings })
+
+describe('checkAccess', () => {
+  it('允许读写自己的工作区', () => {
+    const a = resolve()
+    expect(checkAccess(a, path.join(ws, 'uploads/x.csv'), 'read').allowed).toBe(true)
+    expect(checkAccess(a, path.join(ws, 'outputs/new.docx'), 'write').allowed).toBe(true)
+  })
+
+  it('restricted 模式拒绝工作区以外的读取', () => {
+    expect(checkAccess(resolve(), '/etc/hostname', 'read').allowed).toBe(false)
+  })
+
+  it('始终拒绝 CLI 凭证,即使是 broad 模式', () => {
+    const a = resolve({ mode: 'broad' })
+    expect(checkAccess(a, path.join(config.home, '.claude/.credentials.json'), 'read').allowed).toBe(false)
+    expect(checkAccess(a, path.join(config.home, '.codex/auth.json'), 'read').allowed).toBe(false)
+    expect(checkAccess(a, path.join(config.home, '.ssh/id_ed25519'), 'read').allowed).toBe(false)
+  })
+
+  it('broad 模式可读系统文件,但不能读其他会话的工作区和服务数据库', () => {
+    const a = resolve({ mode: 'broad' })
+    expect(checkAccess(a, '/etc/hostname', 'read').allowed).toBe(true)
+    expect(checkAccess(a, path.join(other, 'secret.txt'), 'read').allowed).toBe(false)
+    expect(checkAccess(a, path.join(config.dataDir, 'data/agent.db'), 'read').allowed).toBe(false)
+    expect(checkAccess(a, '/etc/hostname', 'write').allowed).toBe(false)
+  })
+
+  it('运行环境只读', () => {
+    const a = resolve()
+    expect(checkAccess(a, path.join(settings.paths.pythonRuntime, 'bin/python'), 'read').allowed).toBe(true)
+    expect(checkAccess(a, path.join(settings.paths.pythonRuntime, 'bin/python'), 'write').allowed).toBe(false)
+  })
+
+  it('资料库只有在 Profile 启用后才可读', () => {
+    expect(checkAccess(resolve(), path.join(kbDir, 'a.pdf'), 'read').allowed).toBe(false)
+    expect(checkAccess(resolve({}, ['kb_1']), path.join(kbDir, 'a.pdf'), 'read').allowed).toBe(true)
+    expect(checkAccess(resolve({}, ['kb_1']), path.join(kbDir, 'a.pdf'), 'write').allowed).toBe(false)
+  })
+
+  it('Profile 的通配禁止项生效', () => {
+    const a = resolve({ deny: ['**/*.env'] })
+    expect(checkAccess(a, path.join(ws, 'config/.env'), 'read').allowed).toBe(false)
+    expect(checkAccess(a, path.join(ws, 'data.csv'), 'read').allowed).toBe(true)
+  })
+
+  it('拒绝通过软链接逃逸工作区', () => {
+    const link = path.join(ws, 'escape')
+    fs.rmSync(link, { force: true })
+    fs.symlinkSync('/etc', link)
+    expect(checkAccess(resolve(), path.join(link, 'hostname'), 'read').allowed).toBe(false)
+  })
+
+  it('拒绝 .. 路径穿越', () => {
+    expect(checkAccess(resolve(), path.join(ws, '../ses_b/secret.txt'), 'read').allowed).toBe(false)
+  })
+})
+
+describe('toClaudeSandbox', () => {
+  it('restricted 模式屏蔽 home 并重新开放工作区', () => {
+    const sb = toClaudeSandbox(resolve(), settings)
+    expect(sb.failIfUnavailable).toBe(true)
+    expect(sb.allowUnsandboxedCommands).toBe(false)
+    expect(sb.filesystem.denyRead).toContain(config.home)
+    expect(sb.filesystem.allowRead).toContain(fs.realpathSync(ws))
+    expect(sb.filesystem.allowWrite).toContain(fs.realpathSync(ws))
+    expect(sb.filesystem.allowWrite).not.toContain(config.home)
+  })
+})
+
+describe('checkKnowledgePath', () => {
+  it('拒绝包含凭证的目录', () => {
+    expect(checkKnowledgePath('~', settings).denied).not.toBeNull()
+    expect(checkKnowledgePath('~/.ssh', settings).denied).not.toBeNull()
+  })
+  it('拒绝工作区目录', () => {
+    expect(checkKnowledgePath(ws, settings).message).toContain('工作区')
+  })
+  it('接受普通目录', () => {
+    const c = checkKnowledgePath(kbDir, settings)
+    expect(c.exists && c.readable && c.isDirectory && !c.denied).toBe(true)
+  })
+})

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

@@ -0,0 +1,164 @@
+import fs from 'node:fs'
+import path from 'node:path'
+import pino from 'pino'
+import { afterAll, beforeAll, describe, expect, it } from 'vitest'
+import type { AgentEvent, ProviderStatusDto } from '@agent-server/shared'
+import { createApp, type App } from '../src/app'
+import { buildServer } from '../src/http/server'
+import type { AgentProvider, RunContext } from '../src/providers/types'
+
+/** 假引擎:输出文本 → 请求审批 → 写产出文件 */
+class FakeProvider implements AgentProvider {
+  readonly id = 'claude' as const
+  lastCtx: RunContext | null = null
+  async run(ctx: RunContext) {
+    this.lastCtx = ctx
+    ctx.setProviderSessionId('fake-session')
+    ctx.emit('message.delta', { messageId: 'm1', text: '你好' })
+    ctx.emit('message.completed', { messageId: 'm1', text: '你好' })
+    if (ctx.input.text.includes('审批')) {
+      ctx.emit('tool.started', { toolCallId: 't1', name: 'Bash', title: 'echo', input: { command: 'echo' } })
+      const outcome = await ctx.interact({ kind: 'approval', tool: 'Bash', title: 'echo', input: {}, options: ['allow_once', 'allow_session', 'deny'] })
+      ctx.emit('tool.completed', { toolCallId: 't1', status: outcome.resolution === 'allowed' ? 'ok' : 'denied', output: '', truncated: false })
+    }
+    if (ctx.input.text.includes('等待')) await new Promise((_, reject) => ctx.signal.addEventListener('abort', () => reject(new Error('aborted'))))
+    fs.writeFileSync(path.join(ctx.workspaceDir, 'outputs/report.md'), '# 报告')
+    return { usage: { inputTokens: 1, outputTokens: 2, cacheReadTokens: 0, cacheWriteTokens: 0, costUsd: 0.001 } }
+  }
+  async status(): Promise<ProviderStatusDto> {
+    return { id: 'claude', enabled: true, available: true, loggedIn: true, account: 'test', cliPath: '', version: 'x', models: ['m'], defaultModel: 'm', message: null }
+  }
+}
+
+let app: App
+let server: Awaited<ReturnType<typeof buildServer>>
+let apiKey: string
+const fake = new FakeProvider()
+const auth = () => ({ authorization: `Bearer ${apiKey}` })
+
+async function waitFor<T>(fn: () => T | undefined, ms = 3000): Promise<T> {
+  const end = Date.now() + ms
+  while (Date.now() < end) {
+    const v = fn()
+    if (v) return v
+    await new Promise(r => setTimeout(r, 20))
+  }
+  throw new Error('timeout')
+}
+
+const events = async (sid: string) =>
+  (await server.inject({ method: 'GET', url: `/v1/sessions/${sid}/events`, headers: auth() })).json().items as AgentEvent[]
+
+beforeAll(async () => {
+  const log = pino({ level: 'silent' })
+  app = createApp(log, { providers: { claude: fake } })
+  server = await buildServer(app, { logger: log })
+  apiKey = app.apiKeys.create('test').key
+})
+afterAll(() => server.close())
+
+describe('鉴权', () => {
+  it('没有 Key 返回 401', async () => {
+    expect((await server.inject({ method: 'GET', url: '/v1/sessions' })).statusCode).toBe(401)
+  })
+  it('后台登录后可以访问管理接口', async () => {
+    const login = await server.inject({ method: 'POST', url: '/admin/v1/auth/login', payload: { password: 'test-password' } })
+    expect(login.statusCode).toBe(200)
+    const cookie = login.cookies[0]!
+    const res = await server.inject({ method: 'GET', url: '/admin/v1/settings', cookies: { [cookie.name]: cookie.value } })
+    expect(res.statusCode).toBe(200)
+    expect(res.json().settings.paths.workspaceRoot).toContain('workspaces')
+  })
+  it('错误密码返回 401', async () => {
+    expect((await server.inject({ method: 'POST', url: '/admin/v1/auth/login', payload: { password: 'nope' } })).statusCode).toBe(401)
+  })
+})
+
+describe('会话与 Run', () => {
+  it('完整流程:上传 → 发送 → 审批 → 产出文件', async () => {
+    const created = await server.inject({ method: 'POST', url: '/v1/sessions', headers: auth(), payload: {} })
+    expect(created.statusCode).toBe(201)
+    const sid = created.json().id as string
+
+    const boundary = 'xBOUNDARYx'
+    const upload = await server.inject({
+      method: 'POST', url: `/v1/sessions/${sid}/files`,
+      headers: { ...auth(), 'content-type': `multipart/form-data; boundary=${boundary}` },
+      payload: `--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="销售.csv"\r\nContent-Type: text/csv\r\n\r\na,b\n1,2\n\r\n--${boundary}--\r\n`,
+    })
+    expect(upload.statusCode).toBe(201)
+    const fileId = upload.json()[0].id as string
+    expect(upload.json()[0].path).toBe('uploads/销售.csv')
+
+    const sent = await server.inject({ method: 'POST', url: `/v1/sessions/${sid}/messages`, headers: auth(), payload: { text: '请审批', attachments: [fileId] } })
+    expect(sent.statusCode).toBe(202)
+    const runId = sent.json().id as string
+
+    // 会话忙时不能再发
+    const busy = await server.inject({ method: 'POST', url: `/v1/sessions/${sid}/messages`, headers: auth(), payload: { text: 'x' } })
+    expect(busy.statusCode).toBe(409)
+
+    const itx = await waitFor(() => app.interactions.pendingFor(sid)[0])
+    expect(fake.lastCtx?.input.attachments[0]?.path).toBe('uploads/销售.csv')
+    const detail = await server.inject({ method: 'GET', url: `/v1/sessions/${sid}`, headers: auth() })
+    expect(detail.json().status).toBe('waiting_input')
+
+    const reply = await server.inject({ method: 'POST', url: `/v1/interactions/${itx.interactionId}`, headers: auth(), payload: { decision: 'allow_once' } })
+    expect(reply.statusCode).toBe(200)
+    const again = await server.inject({ method: 'POST', url: `/v1/interactions/${itx.interactionId}`, headers: auth(), payload: { decision: 'allow_once' } })
+    expect(again.statusCode).toBe(409)
+
+    await waitFor(() => app.runs.activeForSession(sid) === undefined || undefined)
+    const run = (await server.inject({ method: 'GET', url: `/v1/runs/${runId}`, headers: auth() })).json()
+    expect(run.status).toBe('completed')
+    expect(run.usage.outputTokens).toBe(2)
+
+    const types = (await events(sid)).map(e => e.type)
+    expect(types).toEqual(expect.arrayContaining([
+      'user.message', 'run.queued', 'run.started', 'message.completed', 'tool.started',
+      'interaction.required', 'interaction.resolved', 'tool.completed', 'file.changed', 'usage', 'run.completed',
+    ]))
+    // 内存缓冲中包含瞬时的 delta 事件
+    expect(types).toContain('message.delta')
+
+    const files = (await server.inject({ method: 'GET', url: `/v1/sessions/${sid}/files`, headers: auth() })).json()
+    const report = files.find((f: { path: string }) => f.path === 'outputs/report.md')
+    expect(report.origin).toBe('agent')
+    const dl = await server.inject({ method: 'GET', url: `/v1/files/${report.id}`, headers: auth() })
+    expect(dl.body).toBe('# 报告')
+
+    const session = (await server.inject({ method: 'GET', url: `/v1/sessions/${sid}`, headers: auth() })).json()
+    expect(session.title).toBe('请审批')
+  })
+
+  it('中断运行中的 Run', async () => {
+    const sid = (await server.inject({ method: 'POST', url: '/v1/sessions', headers: auth(), payload: {} })).json().id
+    const runId = (await server.inject({ method: 'POST', url: `/v1/sessions/${sid}/messages`, headers: auth(), payload: { text: '等待' } })).json().id
+    await waitFor(() => app.runs.activeForSession(sid)?.status === 'running' || undefined)
+    expect((await server.inject({ method: 'POST', url: `/v1/runs/${runId}/interrupt`, headers: auth() })).statusCode).toBe(202)
+    await waitFor(() => app.runs.activeForSession(sid) === undefined || undefined)
+    expect(app.runs.get(runId).status).toBe('interrupted')
+    expect((await events(sid)).at(-1)?.type).toBe('run.interrupted')
+  })
+
+  it('其他 API Key 看不到这个会话', async () => {
+    const sid = (await server.inject({ method: 'POST', url: '/v1/sessions', headers: auth(), payload: {} })).json().id
+    const otherKey = app.apiKeys.create('other').key
+    const res = await server.inject({ method: 'GET', url: `/v1/sessions/${sid}`, headers: { authorization: `Bearer ${otherKey}` } })
+    expect(res.statusCode).toBe(404)
+  })
+
+  it('删除会话同时删除工作区', async () => {
+    const created = await app.sessions.create(app.apiKeys.list()[0]!.id, {})
+    expect(fs.existsSync(created.workspaceDir)).toBe(true)
+    await app.sessions.delete(created.id, 'admin')
+    expect(fs.existsSync(created.workspaceDir)).toBe(false)
+  })
+})
+
+describe('资料库', () => {
+  it('拒绝不安全的路径', () => {
+    expect(() => app.knowledge.create({ name: 'home', path: '~' })).toThrow(/全局禁止项/)
+    expect(() => app.knowledge.create({ name: 'none', path: '/does/not/exist' })).toThrow(/不存在/)
+  })
+})

+ 23 - 0
apps/server/test/office.test.ts

@@ -0,0 +1,23 @@
+import fs from 'node:fs'
+import path from 'node:path'
+import { describe, expect, it } from 'vitest'
+import { config } from '../src/config'
+import { convertDocument } from '../src/services/office'
+
+// 真实调用 LibreOffice(约数秒),验证独立沙箱中的转换可用
+describe('convertDocument', () => {
+  const ws = path.join(config.dataDir, 'workspaces/ses_office')
+  fs.mkdirSync(ws, { recursive: true })
+  fs.writeFileSync(path.join(ws, '中文 报告.txt'), '你好,LibreOffice')
+
+  it('把工作区内的文件转换为 PDF(中文文件名)', async () => {
+    const out = await convertDocument({ workspaceDir: ws, input: '中文 报告.txt', format: 'pdf' })
+    expect(out).toBe(path.join(fs.realpathSync(ws), 'outputs/中文 报告.pdf'))
+    expect(fs.readFileSync(out).subarray(0, 4).toString()).toBe('%PDF')
+  }, 120_000)
+
+  it('拒绝工作区以外的输入和输出', async () => {
+    await expect(convertDocument({ workspaceDir: ws, input: '/etc/hostname', format: 'pdf' })).rejects.toThrow(/工作区内/)
+    await expect(convertDocument({ workspaceDir: ws, input: '中文 报告.txt', format: 'pdf', outDir: '/tmp' })).rejects.toThrow(/工作区内/)
+  })
+})

+ 7 - 0
apps/server/test/setup.ts

@@ -0,0 +1,7 @@
+import fs from 'node:fs'
+import os from 'node:os'
+import path from 'node:path'
+
+const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-server-test-'))
+process.env.AGENT_SERVER_DATA_DIR = dir
+process.env.AGENT_SERVER_ADMIN_PASSWORD = 'test-password'

+ 5 - 0
apps/server/tsconfig.json

@@ -0,0 +1,5 @@
+{
+  "extends": "../../tsconfig.base.json",
+  "compilerOptions": { "noEmit": true, "lib": ["ES2023"], "types": ["node"] },
+  "include": ["src", "scripts", "test", "drizzle.config.ts", "tsup.config.ts"]
+}

+ 12 - 0
apps/server/tsup.config.ts

@@ -0,0 +1,12 @@
+import { defineConfig } from 'tsup'
+
+export default defineConfig({
+  entry: ['src/main.ts'],
+  format: ['esm'],
+  platform: 'node',
+  target: 'node22',
+  sourcemap: true,
+  clean: true,
+  // 共享类型包以源码形式引用,打包进产物
+  noExternal: ['@agent-server/shared'],
+})

+ 10 - 0
apps/server/vitest.config.ts

@@ -0,0 +1,10 @@
+import { defineConfig } from 'vitest/config'
+
+export default defineConfig({
+  test: {
+    environment: 'node',
+    // 每个测试文件使用独立的临时数据目录
+    setupFiles: ['./test/setup.ts'],
+    pool: 'forks',
+  },
+})

+ 63 - 0
docs/README.md

@@ -0,0 +1,63 @@
+# AgentServer 设计文档
+
+AgentServer 是一个自托管的 Agent 服务:在服务端接入 **Claude Code** 和 **Codex**,用统一的 API 给多个内部业务前端提供 Agent 能力,并附带一个管理后台(含 Playground 对话调试)。
+
+> 状态:**M1 已完成**(Claude 主链路 + 管理后台),M2 进行中 · 最后更新:2026-10-22
+
+## 文档结构
+
+```
+docs/
+├─ README.md                  本文件:索引、术语、文档约定
+├─ design/                    设计文档(随设计演进持续更新)
+│  ├─ 01-requirements.md      需求与范围
+│  ├─ 02-architecture.md      总体架构、模块划分、运行时模型
+│  ├─ 03-providers.md         引擎接入:Claude Agent SDK / Codex app-server
+│  ├─ 04-api.md               对外 API、统一事件模型、交互协议
+│  ├─ 05-data-model.md        数据模型与存储
+│  ├─ 06-security.md          隔离、沙箱、网络、凭证与合规
+│  ├─ 07-files-and-knowledge.md  文件/截图上传、文档分析、本地与远程资料检索
+│  ├─ 08-platform-connectors.md  内部平台数据接入(MCP Gateway)
+│  ├─ 09-admin-ui.md          管理后台与 Playground(含视觉规范)
+│  ├─ 10-deployment.md        部署与运维
+│  └─ 11-roadmap.md           里程碑与待决事项
+├─ adr/                       架构决策记录(一事一档,只追加不改写;推翻时新增一条并标注 Superseded)
+└─ research/                  调研材料(选型依据、参考实现分析)
+```
+
+## 架构决策索引(ADR)
+
+| # | 决策 | 状态 |
+|---|---|---|
+| [0001](adr/0001-nodejs-typescript-backend.md) | 后端采用 Node.js + TypeScript | Accepted |
+| [0002](adr/0002-claude-agent-sdk.md) | Claude 通过 Agent SDK 接入 | Accepted |
+| [0003](adr/0003-codex-app-server.md) | Codex 通过 `codex app-server` 接入 | Accepted |
+| [0004](adr/0004-isolation-strategy.md) | 隔离:引擎沙箱 + 专用用户 + systemd 加固 | Superseded → 0009 |
+| [0005](adr/0005-cli-login-credentials.md) | 模型凭证使用 CLI 登录态,按使用者路由 | Superseded → 0008 |
+| [0006](adr/0006-mcp-gateway.md) | 内部平台数据通过统一 MCP Gateway 暴露 | Accepted(连接器暂缓) |
+| [0007](adr/0007-admin-ui-vue-vscode-style.md) | 后台用 Vue 3 + UnoCSS,视觉对齐 Claude VS Code 插件 | Accepted |
+| [0008](adr/0008-personal-cli-login.md) | 复用本人终端的 CLI 登录态,不区分使用者 | Accepted |
+| [0009](adr/0009-isolation-without-dedicated-user.md) | 暂不使用专用用户:引擎沙箱 + systemd 加固 + 应用层校验 | Accepted |
+| [0010](adr/0010-profile-file-access-policy.md) | 文件读写权限由 Profile 配置,三层合并 | Accepted |
+| [0011](adr/0011-admin-managed-settings.md) | 路径等运行配置由后台管理,不写死在配置文件中 | Accepted |
+
+## 调研
+
+- [research/claude-vscode-ui-reference.md](research/claude-vscode-ui-reference.md) — Claude VS Code 插件视觉规范提取
+- [research/document-analysis-approaches.md](research/document-analysis-approaches.md) — 官方客户端的文档分析方案
+
+## 术语
+
+| 术语 | 含义 |
+|---|---|
+| Provider / 引擎 | 底层 Agent 实现:`claude` 或 `codex` |
+| Platform / 平台 | 接入 AgentServer 的一个上层业务前端(持有一个 API Key);服务不区分终端使用者 |
+| Session / 会话 | 一段多轮对话,绑定一个 Provider、一个 Workspace |
+| Run | 会话中的一次"用户发言 → Agent 完成"的执行过程 |
+| Event | Run 过程中产生的统一事件(文本增量、工具调用、交互请求…) |
+| Interaction / 交互 | Agent 需要人参与的点:审批(approval)或提问(question) |
+| Workspace / 工作区 | 会话的独立目录,存放上传文件与产出文件 |
+| Profile | Agent 预设:提示词、模型、工具、连接器、权限模式的组合 |
+| Connector / 连接器 | (暂缓)一个内部平台的数据接入定义,通过 MCP Gateway 暴露为工具 |
+| Knowledge Root / 资料库 | 在后台登记的本地资料目录,Profile 启用后 Agent 可以访问 |
+| File Access Policy / 文件访问策略 | Profile 中的可读/可写/禁止路径配置,与全局禁止项、基线合并后生效 |

+ 17 - 0
docs/adr/0001-nodejs-typescript-backend.md

@@ -0,0 +1,17 @@
+# ADR-0001 后端采用 Node.js + TypeScript
+
+- 状态:Accepted · 日期:2026-10-22
+
+## 背景
+需要同时集成 Claude Agent SDK 与 Codex app-server,并为 Vue 前端提供共享类型。本机已有 Node 22、Go、Python。
+
+## 决策
+后端使用 Node.js 22 + TypeScript(Fastify)。
+
+## 理由
+- Claude Agent SDK 的 TS 版本是一等公民;Codex app-server 可以直接生成 TS 协议类型
+- 事件类型、API DTO 在前后端(Vue + TS)之间零成本共享
+- 单人维护,统一用一种语言
+
+## 代价
+Python 生态的数据处理能力不在服务进程内;这部分由 Agent 在工作区里执行 Python 完成,服务本身不需要。

+ 15 - 0
docs/adr/0002-claude-agent-sdk.md

@@ -0,0 +1,15 @@
+# ADR-0002 Claude 通过 Agent SDK 接入
+
+- 状态:Accepted · 日期:2026-10-22
+
+## 决策
+使用 `@anthropic-ai/claude-agent-sdk`,不直接解析 `claude -p --output-format stream-json`。
+
+## 理由
+- `canUseTool` 异步回调可以挂起等待人工审批和回答提问(硬需求)
+- 原生支持 resume、interrupt、流式输入(图片)、部分消息增量
+- 默认不加载本机 settings,行为可预测
+
+## 代价
+与 SDK 版本耦合;需要锁定版本,并通过回归用例验证升级。
+详见 [design/03-providers.md](../design/03-providers.md)。

+ 15 - 0
docs/adr/0003-codex-app-server.md

@@ -0,0 +1,15 @@
+# ADR-0003 Codex 通过 app-server 接入
+
+- 状态:Accepted · 日期:2026-10-22
+
+## 决策
+使用 `codex app-server`(JSON-RPC over stdio),不使用 Codex TS SDK 或 `codex exec --json`。
+
+## 理由
+- 只有 app-server 支持服务端向客户端发起审批请求,满足交互审批需求
+- 事件粒度最细(item + delta),支持 thread 恢复、turn 中断、账户状态查询
+- 与官方 VS Code 插件使用同一协议,长期维护有保障
+
+## 代价
+- 协议仍标注 experimental:锁定 CLI 版本,用 `codex app-server generate-ts` 生成类型并纳入版本控制
+- 需要自己实现 JSON-RPC 客户端和进程管理(每份凭证一个常驻进程)

+ 24 - 0
docs/adr/0004-isolation-strategy.md

@@ -0,0 +1,24 @@
+# ADR-0004 隔离:引擎沙箱 + 专用用户 + systemd 加固
+
+- 状态:**Superseded by [ADR-0009](0009-isolation-without-dedicated-user.md)** · 日期:2026-10-22
+
+## 决策
+三层叠加,暂不使用 Docker:
+1. 专用系统用户 `agentsvc`
+2. systemd 服务加固(ProtectHome/ProtectSystem/ReadWritePaths/资源上限)
+3. 引擎自带沙箱:Claude `sandbox.enabled` + `failIfUnavailable` + `allowUnsandboxedCommands:false`;Codex `workspace-write` + `on-request`
+另外加一层应用层校验(路径、WebFetch 私网拦截、MCP Gateway 审批)。
+
+## 理由
+- 使用者可信、单机部署,Docker 的复杂度不划算
+- 引擎沙箱只管 shell 命令,并且默认可读大部分文件系统;systemd 和专用用户正好补上"读"的缺口
+- 专用用户的代价只是一次性 CLI 登录,换来配置失误时的兜底,以及服务与个人 CLI 状态的分离
+
+## 被否决的方案
+- 只用 systemd、不建专用用户:同 UID 时信号和非 home 文件仍可达,且 systemd 249 不支持 PrivatePIDs
+- Docker / Podman:留作以后需要可复现环境或执行不可信代码时的选项
+- gVisor / Firecracker:过重
+
+## 约束
+systemd 中不能开启 `RestrictNamespaces`(bubblewrap 需要 user namespace)。
+详见 [design/06-security.md](../design/06-security.md)。

+ 17 - 0
docs/adr/0005-cli-login-credentials.md

@@ -0,0 +1,17 @@
+# ADR-0005 模型凭证使用 CLI 登录态,按使用者路由
+
+- 状态:**Superseded by [ADR-0008](0008-personal-cli-login.md)** · 日期:2026-10-22
+
+## 背景
+用户决定使用 CLI 登录态(Claude 订阅、ChatGPT 登录),不用 API Key。两家条款均不允许代表他人通过个人订阅转发请求或共享账号。
+
+## 决策
+- 每份凭证对应一个独立配置目录(`CLAUDE_CONFIG_DIR` / `CODEX_HOME`),由服务管理
+- 使用者在每个引擎下绑定一份凭证;订阅类凭证只能绑定给其所有者(服务端强制)
+- 预留 `api_key` 类型凭证,供不具备自有订阅的使用者使用
+- 服务进程环境中不设置 `ANTHROPIC_API_KEY` / `OPENAI_API_KEY`,以免意外切换计费方式
+
+## 后果
+- 家人要使用,必须有自己的登录凭证或 API Key 凭证
+- 本人通过服务化方式使用自己的订阅,仍有被风控的可能,这个风险已知并接受
+- 条款需定期复核(见 design/06-security.md §7)

+ 12 - 0
docs/adr/0006-mcp-gateway.md

@@ -0,0 +1,12 @@
+# ADR-0006 内部平台数据通过统一 MCP Gateway 暴露
+
+- 状态:Accepted · 日期:2026-10-22
+
+## 决策
+在 AgentServer 进程内实现一个 Streamable HTTP MCP Server(`/mcp`),内部平台以"连接器"TS 模块的形式注册工具;两个引擎都作为 MCP 客户端连接它,每个 Run 使用独立的短期令牌。
+
+## 理由
+- 一套工具定义两个引擎共用(Claude SDK 的进程内 MCP 无法给 Codex 用)
+- 平台凭证留在服务端;Agent 无需访问局域网
+- 写操作在 Gateway 内统一审批,不依赖引擎侧机制
+- 大结果落盘为文件,适合数据分析场景

+ 13 - 0
docs/adr/0007-admin-ui-vue-vscode-style.md

@@ -0,0 +1,13 @@
+# ADR-0007 后台用 Vue 3 + UnoCSS,视觉对齐 Claude VS Code 插件
+
+- 状态:Accepted · 日期:2026-10-22
+
+## 决策
+- 后台技术栈与业务平台一致:Vue 3 + TS + UnoCSS + Vite
+- 对话组件抽成 `packages/chat-ui`,后台 Playground 与业务平台共用
+- 视觉规范对齐 Claude VS Code 插件(时间线圆点、工具行、交互卡片、橙色聚焦输入框),整体布局采用 VS Code 工作台结构
+- 只复刻设计令牌和布局,不拷贝插件源码和资源;图标用 `@vscode/codicons`
+
+## 理由
+用户偏好该风格;技术栈统一可以复用组件,降低维护成本。
+规范来源见 [research/claude-vscode-ui-reference.md](../research/claude-vscode-ui-reference.md)。

+ 18 - 0
docs/adr/0008-personal-cli-login.md

@@ -0,0 +1,18 @@
+# ADR-0008 复用本人终端的 CLI 登录态,不区分使用者
+
+- 状态:Accepted · 日期:2026-10-22 · 取代 ADR-0005
+
+## 背景
+CLI 由本人在终端登录,服务只供本人的上层平台使用,不需要区分终端使用者。
+
+## 决策
+- Claude 使用默认的 `~/.claude` 登录态,Codex 使用默认的 `~/.codex` 登录态,与终端共用
+- 去掉使用者、凭证路由等概念;上层平台只用 API Key 区分来源
+- 不复制凭证文件(ChatGPT 的 refresh token 会轮换,复制会导致终端登录失效)
+- 服务环境中不设置 `ANTHROPIC_API_KEY` / `OPENAI_API_KEY`
+- 隔离个人配置:Claude 用 `settingSources: []`;Codex 用 `-c` 覆盖禁用个人的插件和 MCP,并做启动自检
+
+## 后果
+- 服务与终端使用共享同一份订阅额度
+- 凭证文件对服务进程可见,必须由全局强制禁止项(ADR-0010)在引擎沙箱和应用层拦截
+- 以后如需开放给他人,需要新增 API Key 类凭证;数据模型已预留 `credentials`

+ 14 - 0
docs/adr/0009-isolation-without-dedicated-user.md

@@ -0,0 +1,14 @@
+# ADR-0009 暂不使用专用用户:引擎沙箱 + systemd 加固 + 应用层校验
+
+- 状态:Accepted · 日期:2026-10-22 · 取代 ADR-0004
+
+## 决策
+- 服务以 `jerry-remote` 身份运行(为了直接使用本人的 CLI 登录态)
+- systemd:`ProtectHome=tmpfs` 隐藏 home,只回绑 `~/.claude`、`~/.claude.json`、`~/.codex` 和 CLI 安装目录;`ProtectSystem=strict`;资源上限
+- 引擎沙箱与应用层校验按文件访问策略(ADR-0010)配置
+
+## 已知缺口(接受)
+同 UID 下可以向你的其他进程发信号;home 以外属于你的文件按权限可访问;加固配置出错时没有兜底。
+
+## 以后迁移
+需要时改用专用用户,步骤见 design/06-security.md §5.3。服务代码不受影响。

+ 13 - 0
docs/adr/0010-profile-file-access-policy.md

@@ -0,0 +1,13 @@
+# ADR-0010 文件读写权限由 Profile 配置,三层合并
+
+- 状态:Accepted · 日期:2026-10-22
+
+## 决策
+- 每个 Profile 配置 `FileAccessPolicy`:模式(restricted / broad)、可读、可写、禁止
+- 生效权限 = 全局强制禁止(不可覆盖)∩ Profile 策略 ∪ 基线(工作区读写、运行环境只读)
+- 映射:Claude → 沙箱 `allowRead/denyRead/allowWrite` + `permissions.deny` + `canUseTool` 路径校验(权威判断);Codex → 权限配置(permission profiles)的 read/write/deny 条目
+- 默认使用 restricted 模式
+
+## 理由
+两个引擎都有按路径控制读权限的原生机制;Claude 的文件工具不在沙箱内,需要应用层统一校验。
+详见 design/06-security.md §4。

+ 11 - 0
docs/adr/0011-admin-managed-settings.md

@@ -0,0 +1,11 @@
+# ADR-0011 路径等运行配置由后台管理
+
+- 状态:Accepted · 日期:2026-10-22
+
+## 决策
+资料库路径、工作区根目录、技能目录、Python 环境、全局禁止路径、网络白名单、并发与超时等,都存放在 SQLite 的 `settings` 和 `knowledge_roots` 表中,通过后台编辑。环境变量和配置文件只保留启动必需项:监听地址、数据库路径、后台初始密码。
+
+## 规则
+- 保存时校验:路径是否存在、服务进程能否看到(受 ProtectHome 影响)、是否与全局禁止项冲突
+- 修改对新的 Run 立即生效;引擎级配置变更时平滑重启 app-server
+- 所有修改写入审计日志

+ 74 - 0
docs/design/01-requirements.md

@@ -0,0 +1,74 @@
+# 01 需求与范围
+
+## 1. 背景与目标
+
+在一台自有 Linux 服务器上运行 AgentServer,把 Claude Code 和 Codex 的 Agent 能力以统一 API 提供给多个**内部**业务平台使用,并提供管理后台进行配置、调试和监控。
+
+## 2. 使用场景
+
+| 维度 | 结论 |
+|---|---|
+| 使用者 | 本人;服务不区分使用者,只对上层平台提供 API |
+| 接入方 | 多个自建内部平台(前端技术栈:Vue + TS + UnoCSS),每个平台一个 API Key |
+| 任务类型 | 数据分析、资料查找与分析、生成数据文档/报表等通用任务;**一般不写代码** |
+| 工作区 | 不需要 git;每个会话一个独立目录 |
+| 资料来源 | 本地资料(服务器目录,**路径在后台设定**)+ 远程资料(公网检索);内部平台数据接入暂缓 |
+| 输入 | 文本、数据文件(CSV/Excel/PDF/Word…)、截图图片 |
+| 模型凭证 | 复用本人在终端登录的 CLI 登录态(`~/.claude`、`~/.codex`),见 [06-security §7](06-security.md#7-凭证与合规) |
+| 部署 | 单机单实例 |
+| 后台用户 | 仅本人 |
+
+## 3. 功能需求
+
+### 3.1 Agent 接入(F-AGENT)
+- F-AGENT-1 支持 Claude Code(Agent SDK)和 Codex(app-server)两个引擎
+- F-AGENT-2 会话:创建、多轮对话、恢复(服务重启后可继续)、中断、删除
+- F-AGENT-3 流式输出:文本增量、思考过程、工具调用与结果、文件产出、用量
+- F-AGENT-4 统一事件模型,前端不感知引擎差异
+- F-AGENT-5 **交互式人工介入**:工具执行审批、Agent 主动提问,前端实时响应后 Agent 继续执行
+- F-AGENT-6 Profile 预设:提示词、模型、权限模式、可用工具、技能(Skills)、**文件访问策略(可读/可写/禁止路径)**
+- F-AGENT-7 输入支持图片(截图)与文件附件
+
+### 3.2 文件与资料(F-DATA)
+- F-DATA-1 上传数据文件和截图到会话工作区
+- F-DATA-2 浏览、预览、下载工作区产出文件(报告、图表、xlsx/docx/pdf)
+- F-DATA-3 本地资料库:**在后台设定**资料目录(路径、说明、读写属性),Profile 选择启用
+- F-DATA-4 远程检索:公网搜索与网页抓取
+- F-DATA-5 (暂缓)内部平台数据访问:通过 MCP Gateway 连接器提供,设计见 08
+
+### 3.3 对外 API(F-API)
+- F-API-1 REST + SSE;SSE 支持断线续传
+- F-API-2 平台级 API Key 鉴权;不区分使用者
+- F-API-3 提供 TS 客户端 SDK 与 Vue 对话组件包,供各平台直接复用
+
+### 3.4 管理后台(F-ADMIN)
+- F-ADMIN-1 登录(单管理员)
+- F-ADMIN-2 **Playground**:在后台直接与 Agent 对话以验证功能,视觉风格对齐 Claude VS Code 插件
+- F-ADMIN-3 会话浏览与事件回放
+- F-ADMIN-4 Profile 管理(可从 Playground 另存)
+- F-ADMIN-5 引擎状态:登录状态、可用模型、限额信息(只读)
+- F-ADMIN-6 API Key 管理
+- F-ADMIN-7 资料库管理(路径设定、可见性检测)
+- F-ADMIN-10 系统设置:路径(工作区、技能、Python 环境)、全局禁止路径、网络白名单、并发与超时、保留期
+- F-ADMIN-8 概览:运行中任务、用量趋势、错误
+- F-ADMIN-9 审计日志:命令执行、越权访问拦截、审批记录
+
+## 4. 非功能需求
+
+| 编号 | 要求 |
+|---|---|
+| NF-1 安全 | Agent 只能写自己的工作区;看不到服务器上的个人文件与凭证;不能直接访问局域网 |
+| NF-2 资源 | 全局与每引擎并发上限;单进程内存/CPU 上限;超出排队 |
+| NF-3 可恢复 | 服务重启后历史会话可继续;中断中的 Run 标记为 `interrupted` |
+| NF-4 可观测 | 全量事件落库可回放;结构化日志;用量统计 |
+| NF-5 可维护 | 引擎 CLI 版本锁定,升级需在 Playground 回归验证 |
+| NF-6 合规 | 订阅登录态仅供本人使用(见 06-security §7) |
+
+## 5. 不做(Out of Scope)
+
+- 多租户、对外公开服务、计费
+- 代码仓库/git 集成、PR 工作流
+- 多实例/集群部署
+- 后台多管理员与 RBAC
+- 区分终端使用者、按使用者路由凭证
+- 内部平台连接器(框架已设计,暂缓实现)

+ 127 - 0
docs/design/02-architecture.md

@@ -0,0 +1,127 @@
+# 02 总体架构
+
+## 1. 架构总览
+
+```
+ 业务平台 A/B/C (Vue)            管理后台 (Vue, 同源静态托管)
+   │ @agent-server/client           │
+   │ REST + SSE  (/v1)              │ REST + SSE (/v1 + /admin/v1)
+┌──▼────────────────────────────────▼──────────────────────────────┐
+│ HTTP 层 (Fastify):鉴权(API Key/AdminSession) · 校验(Zod) · 限流    │
+├──────────────────────────────────────────────────────────────────┤
+│ Session Service   Run Manager     Interaction Service             │
+│ Workspace Service Profile Service Settings Service                │
+│ Access Policy     Knowledge Service  Usage/Audit  Event Store/Bus │
+├──────────────────────────────────────────────────────────────────┤
+│ Provider Adapter 层(统一 AgentEvent)                             │
+│  ├─ ClaudeAdapter  ── @anthropic-ai/claude-agent-sdk ──► claude CLI 子进程(每会话)│
+│  └─ CodexAdapter   ── JSON-RPC(stdio) ──► codex app-server 常驻进程(单个)        │
+├──────────────────────────────────────────────────────────────────┤
+│ MCP Gateway (/mcp):web_fetch · 资料库检索 ·(预留)内部平台连接器      │
+├──────────────────────────────────────────────────────────────────┤
+│ SQLite (Drizzle)     文件系统:/srv/agent-server/{workspaces,knowledge,skills,runtime}│
+│                      CLI 登录态:~/.claude、~/.codex(本人终端登录)          │
+└──────────────────────────────────────────────────────────────────┘
+```
+
+## 2. 工程结构(pnpm monorepo)
+
+```
+apps/
+  server/            Node 22 + TS + Fastify
+    src/
+      http/          路由:v1/、admin/v1/、mcp/
+      services/      session, run, interaction, workspace, profile, settings, access-policy, knowledge, usage, audit
+      providers/
+        types.ts     AgentProvider 接口、AgentEvent 定义(从 shared 引入)
+        claude/      Agent SDK 适配、消息→事件映射、canUseTool 桥接
+        codex/       app-server 进程管理、JSON-RPC 客户端、通知→事件映射、审批桥接
+      mcp-gateway/   MCP Server 实现、内置工具(web_fetch、kb_*)
+      connectors/    (预留)内部平台连接器
+      db/            Drizzle schema、迁移
+  admin/             Vue 3 + TS + UnoCSS + Vite + Vue Router + Pinia
+packages/
+  shared/            AgentEvent、API DTO、Zod schema(前后端共享类型)
+  client/            TS 客户端 SDK(会话、SSE 订阅、断线续传、交互回复、上传)
+  chat-ui/           Vue 对话组件(消息时间线、工具卡片、交互卡片、输入框),Playground 与业务平台共用
+  ui-tokens/         UnoCSS preset:VS Code 风格设计令牌(亮/暗)
+docs/
+```
+
+## 3. 核心抽象
+
+### 3.1 Provider 接口
+
+```ts
+interface AgentProvider {
+  readonly id: 'claude' | 'codex'
+  capabilities(): ProviderCapabilities           // 模型列表、是否支持图片/思考/提问等
+  health(): Promise<ProviderHealth> // 登录状态、限额
+  startRun(ctx: RunContext): RunHandle
+}
+
+interface RunContext {
+  session: SessionRecord          // 含 providerSessionId(用于 resume)
+  input: UserInput                // 文本 + 图片 + 附件引用
+  profile: ResolvedProfile        // 已合并的模型、提示词、权限、工具、连接器
+  workspaceDir: string
+  access: ResolvedAccessPolicy    // 合并后的文件访问策略(06 §4)
+  interact: (req: InteractionRequest) => Promise<InteractionResponse> // 挂起等待人工
+  signal: AbortSignal
+}
+
+interface RunHandle {
+  events: AsyncIterable<AgentEvent>
+  interrupt(): Promise<void>
+}
+```
+
+`interact` 是两个引擎交互能力的统一出口:Claude 的 `canUseTool` 回调、Codex 的审批请求、MCP Gateway 的写操作审批,最终都调用它,由 Interaction Service 发出 `interaction.required` 事件,并挂起 Promise 等待前端回复。
+
+### 3.2 Run 生命周期
+
+```
+queued ──► running ──► completed
+   │          │  ├──► failed
+   │          │  └──► interrupted   (用户中断 / 服务重启 / 超时)
+   │          └──► waiting_input ──► running   (有未决交互时)
+   └──► cancelled
+```
+
+- 一个会话同一时刻只允许一个活动 Run,新消息在 Run 进行中提交时返回 `409`(后续可改为排队)。
+- `waiting_input` 状态不计入并发配额以外的超时;交互超时(默认 10 分钟,可配)按"拒绝"处理。
+
+### 3.3 事件管道
+
+```
+Adapter 产出 AgentEvent
+   → Run Manager 分配 seq(会话内单调递增)
+   → Event Store 写入 SQLite(events 表)
+   → Event Bus 广播(进程内 EventEmitter)
+   → SSE 连接按 sessionId / runId 过滤推送
+```
+先落库、再推送;SSE 重连时依据 `Last-Event-ID`(= seq)从库中补发。`message.delta` 这类高频事件在落库时按 100ms 窗口合并,推送不合并。
+
+## 4. 进程模型
+
+| 引擎 | 进程 | 说明 |
+|---|---|---|
+| Claude | 每个活动 Run 一个 `claude` 子进程(由 SDK 拉起),Run 结束退出 | 会话状态由 CLI 自身持久化到 `~/.claude/projects/`,下次用 `resume` 继续 |
+| Codex | 一个常驻 `codex app-server`,内部管理多个 thread | 进程异常退出自动重启,并把关联的活动 Run 标记 `interrupted` |
+
+并发控制:全局上限(默认 4)+ 每引擎上限(默认 2),使用进程内队列(p-queue),上限在后台设定。注意服务与你在终端中的使用共享同一份订阅额度。
+
+## 5. 技术栈
+
+| 层 | 选型 | 理由 |
+|---|---|---|
+| 运行时 | Node.js 22 LTS + TypeScript | 两个引擎的官方集成以 TS 生态最完善(ADR-0001) |
+| Web 框架 | Fastify | 性能好、原生 schema 校验、插件体系成熟 |
+| 校验 | Zod(经 `fastify-type-provider-zod`) | 类型与 schema 一处定义,前后端共享 |
+| 存储 | SQLite(better-sqlite3)+ Drizzle ORM | 单机足够;WAL 模式支撑并发读写 |
+| 队列 | p-queue(进程内) | 单实例无需 Redis |
+| MCP | `@modelcontextprotocol/sdk`(Streamable HTTP) | 两个引擎都支持 HTTP MCP |
+| 前端 | Vue 3 + TS + UnoCSS + Vite + Pinia + Vue Router | 与业务平台一致(ADR-0007) |
+| 图标 | `@vscode/codicons` | 与 VS Code 视觉一致,开源许可 |
+| Markdown | markdown-it + Shiki | 代码高亮主题可跟随亮/暗 |
+| 日志 | pino | Fastify 内置,结构化 JSON |

+ 148 - 0
docs/design/03-providers.md

@@ -0,0 +1,148 @@
+# 03 引擎接入
+
+已定:Claude 用 **Agent SDK**(ADR-0002),Codex 用 **`codex app-server`**(ADR-0003)。
+
+## 1. 方案比较(选型依据)
+
+### 1.1 Claude Code
+
+| 维度 | **Agent SDK(采用)** | CLI `claude -p --output-format stream-json` |
+|---|---|---|
+| 集成形态 | TS 库,内部拉起 CLI,返回类型化消息流 | 自行 spawn,逐行解析 JSON |
+| 交互审批 | `canUseTool` 异步回调,可挂起等待人工 | 只能借 `--permission-prompt-tool` 绕道 MCP |
+| Agent 提问 | AskUserQuestion 同样经回调,可转成前端表单 | 难以处理 |
+| 自定义工具 | 进程内 MCP(`createSdkMcpServer`) | 需独立 MCP Server |
+| 恢复/中断 | `resume` / `interrupt()` | resume 可,中断只能杀进程 |
+| 配置隔离 | 默认不加载用户本地 settings,行为可预测 | 读取本机配置 |
+| 图片输入 | 流式输入模式下可发 image content block | 需 stream-json 输入,自行组装 |
+| 缺点 | 与 SDK 版本耦合 | 一切自建 |
+
+### 1.2 Codex
+
+| 维度 | Codex SDK(TS) | **app-server(采用)** | `codex exec --json` |
+|---|---|---|---|
+| 形态 | `codex exec` 轻封装 | JSON-RPC over stdio(VS Code 插件同款协议) | 一次性命令 |
+| 交互审批 | ✗ | ✓ 服务端向客户端发起审批请求 | ✗ |
+| 流式粒度 | item 级 | item + delta 级 | item 级 |
+| 多轮/恢复/中断 | ✓ / ✓ / 有限 | ✓ / ✓ / ✓ | 有限 |
+| 账户/限额查询 | ✗ | ✓(账户与登录相关方法) | ✗ |
+| 类型 | 自带 | `codex app-server generate-ts` 生成 | 无 |
+| 缺点 | 无审批 | 协议仍标注 experimental,需锁版本 | 无审批 |
+
+**结论**:交互式审批是硬需求,只有 SDK(Claude)和 app-server(Codex)能原生满足。
+
+## 2. ClaudeAdapter 设计
+
+### 2.1 调用方式
+- 使用 **流式输入模式**(`prompt` 传 AsyncIterable),以便发送图片与在 Run 内追加输入。
+- 关键 Options:
+  - `cwd`: 工作区目录
+  - `resume`: `session.providerSessionId`(首轮为空)
+  - `model`、`systemPrompt`(`{ type: 'preset', preset: 'claude_code', append }`,保留内置工具说明并追加 Profile 提示词)
+  - `permissionMode`: `default`(所有非只读操作经 `canUseTool`)
+  - `canUseTool`: 桥接到 `ctx.interact`(见 2.3)
+  - `allowedTools` / `disallowedTools`: 来自 Profile
+  - `additionalDirectories`: 资料库只读目录
+  - `mcpServers`: `{ gateway: { type: 'http', url, headers: { Authorization: 'Bearer <run-token>' } } }`
+  - `settingSources`: `[]`(不加载个人的 settings、插件、hooks);沙箱与权限规则由文件访问策略生成,通过 `settings` 选项显式传入(见 06 §4.2)
+  - `pathToClaudeCodeExecutable`: 后台设定的 CLI 路径(默认 `~/.local/bin/claude`),与终端使用同一个 CLI 和登录态
+  - `env`: 使用默认的 `~/.claude` 登录态;**不传** `ANTHROPIC_API_KEY`;设置 `MATPLOTLIBRC` 和 Python venv 的 `PATH`
+  - `includePartialMessages: true`:获取文本增量
+  - `abortController`: 绑定 `ctx.signal`
+- 首条 `system/init` 消息中取 `session_id` 回写 `session.providerSessionId`。
+
+### 2.2 消息 → 事件映射
+
+| SDK 消息 | AgentEvent |
+|---|---|
+| `system` (init) | `run.started`(附模型、工具清单) |
+| `stream_event` text delta | `message.delta` |
+| `stream_event` thinking delta | `reasoning.delta` |
+| `assistant` text block | `message.completed` |
+| `assistant` tool_use block | `tool.started` |
+| `user` tool_result block | `tool.completed`(Write/Edit 产出文件额外发 `file.changed`) |
+| `result` success | `usage` + `run.completed` |
+| `result` error_* | `run.failed` |
+
+### 2.3 交互桥接
+```ts
+canUseTool: async (toolName, input, { signal, suggestions }) => {
+  if (toolName === 'AskUserQuestion')
+    return answerQuestion(await ctx.interact({ kind: 'question', questions: input.questions }))
+  if (policy.autoAllow(toolName, input)) return { behavior: 'allow', updatedInput: input }
+  const r = await ctx.interact({ kind: 'approval', tool: toolName, input, suggestions })
+  return r.decision === 'allow'
+    ? { behavior: 'allow', updatedInput: r.updatedInput ?? input }
+    : { behavior: 'deny', message: r.reason ?? '用户拒绝' }
+}
+```
+- `policy.autoAllow`:只读工具(Read/Grep/Glob/WebSearch)、工作区内写入、沙箱内的 Bash 自动放行;"总是允许"的选择记入会话级规则。
+- 实现时需核对当前 SDK 版本中 AskUserQuestion 的回答格式。
+
+## 3. CodexAdapter 设计
+
+### 3.1 进程管理
+- 单个常驻进程:`codex app-server`(stdio),使用默认的 `~/.codex` 登录态。
+- **必须覆盖个人配置**:你的 `~/.codex/config.toml` 中启用了插件(browser、computer-use 等)和 MCP(node_repl)。启动时通过 `-c` 覆盖禁用这些项(`providers.codex.configOverrides`,在后台可编辑),并在启动自检中确认最终生效的工具清单里不含这些项。
+- 启动后执行 `initialize` 握手;崩溃后指数退避重启。
+- 协议类型由 `codex app-server generate-ts` 生成,提交到 `apps/server/src/providers/codex/protocol/`,并与锁定的 CLI 版本对应。
+
+### 3.2 会话映射
+| AgentServer | app-server |
+|---|---|
+| Session(首轮) | 新建 thread(指定 cwd、model、sandbox、approval policy) |
+| Session(续聊) | 恢复 thread(`providerSessionId` = threadId) |
+| Run | 一个 turn(输入:文本 + 本地图片路径) |
+| interrupt | 中断当前 turn |
+
+Thread 参数:`approvalPolicy = on-request`、`cwd = workspaceDir`,沙箱使用**权限配置**(permission profiles,由文件访问策略生成,见 06 §4.2),不再设置 `sandbox_mode`。MCP Gateway 通过 `-c mcp_servers.gateway...` 注入。
+
+> 权限配置能否按 thread 指定(不同 Profile 使用不同策略)需要在实现时核对;如果只能在进程级配置,就在启动时预先注册所有 Profile 的权限配置(`permissions.<profileId>.*`),按 thread 选择使用哪一个。
+
+> 具体方法名以生成的协议类型为准,实现阶段落定后回填本节。
+
+### 3.3 通知 → 事件映射
+
+| app-server 通知 / 请求 | AgentEvent |
+|---|---|
+| turn 开始 | `run.started` |
+| agent message delta / item 完成 | `message.delta` / `message.completed` |
+| reasoning delta | `reasoning.delta` |
+| 命令执行 item 开始/完成 | `tool.started` / `tool.completed`(name=`shell`) |
+| 文件变更 item | `tool.*` + `file.changed` |
+| MCP 工具调用 item | `tool.*`(name=`mcp__gateway__xxx`) |
+| web search item | `tool.*`(name=`web_search`) |
+| token 用量通知 | `usage` |
+| turn 完成 / 失败 | `run.completed` / `run.failed` |
+| **服务端请求**:命令执行审批 / 文件变更审批 | `ctx.interact({kind:'approval'})` → 回复 accept / decline(含"本会话总是允许") |
+
+## 4. 能力差异与对齐
+
+| 能力 | Claude | Codex | 对齐策略 |
+|---|---|---|---|
+| 主动提问 | ✓ AskUserQuestion | 无专门工具 | Codex 侧以普通文本提问,前端照常渲染 |
+| 图片输入 | ✓ | ✓ | 统一为上传后引用 |
+| PDF 原生阅读 | ✓(Read 工具多模态) | ✗(需 pdftotext 等) | Profile 中为 Codex 默认附带文档技能 |
+| 网页抓取 | WebFetch(本机发起) | 无内置抓取,靠 web_search | 见 07 §4 |
+| 思考过程 | ✓ | ✓(reasoning summary) | 统一 `reasoning.delta`,默认折叠 |
+| Skills | ✓ | ✓ | 共享同一份技能目录(见 07 §3) |
+
+## 5. 版本管理
+- `claude`、`codex` CLI 版本与 SDK 版本在 `apps/server/package.json` 和部署脚本中锁定。
+- 升级流程:升级 → 重新生成 Codex 协议类型 → 运行 Playground 回归用例(见 09 §3.6)→ 发布。
+
+## 6. 实现记录(M1,2026-10-22,SDK 0.3.308 / CLI 2.1.307)
+
+实测中确认、并已体现在代码里的行为:
+
+| 现象 | 处理 |
+|---|---|
+| `canUseTool` **不会**对只读工具(Read/Grep/Glob)和沙箱内自动放行的 Bash 触发 | 路径校验、私网拦截和是否需要审批统一放在 `PreToolUse` hook 中决定(`providers/claude/policy.ts`);hook 返回 `ask` 时 CLI 才调用 `canUseTool`,再转成交互 |
+| hook 不显式返回 `allow` 时,MCP 工具仍会进入权限询问 | hook 对放行的工具显式返回 `permissionDecision: 'allow'` |
+| AskUserQuestion 的回答格式 | `canUseTool` 返回 `allow`,`updatedInput.answers` 以**问题原文**为键,多选用逗号拼接 |
+| `settingSources: []` 不能阻止加载用户自己的技能(包括 claude.ai 同步的技能) | 始终显式传入 `skills` 列表(来自共享技能目录,形如 `agent-skills:<name>`);目录为空时传 `[]` |
+| 覆盖 `TMPDIR` 到工作区后,沙箱报 "Failed to create bridge sockets" | 沙箱会在 TMPDIR 中创建 Unix socket,路径过长会失败;不覆盖 TMPDIR |
+| 单条消息的流式输入在输出 `result` 后正常结束 | 每个 Run 调用一次 `query()` 并用 `resume` 续接,无需常驻进程 |
+| 登录态 | `system/init` 中 `apiKeySource: none` 表示走 claude.ai OAuth;服务环境中删除 `ANTHROPIC_API_KEY` / `ANTHROPIC_AUTH_TOKEN` |
+
+服务端提供的进程内 MCP 工具:`mcp__office__convert_document`(LibreOffice 文档转换,见 06 §8)。M2 起会迁移到 MCP Gateway,供 Codex 共用。

+ 130 - 0
docs/design/04-api.md

@@ -0,0 +1,130 @@
+# 04 对外 API 与事件模型
+
+## 1. 约定
+
+- 基础路径:`/v1`(业务)、`/admin/v1`(管理,仅后台会话可访问)、`/mcp`(MCP Gateway,仅 Run Token 可访问)
+- 鉴权:`Authorization: Bearer <api-key>`;每个上层平台一个 Key,不区分使用者
+- 格式:JSON;时间为 ISO 8601;ID 为带前缀的 ULID(`ses_`、`run_`、`itx_`、`file_`、`prf_`)
+- 错误:`{ "error": { "code": "session_busy", "message": "...", "details": {} } }`
+
+## 2. 资源接口
+
+### 会话
+| 方法 | 路径 | 说明 |
+|---|---|---|
+| POST | `/v1/sessions` | 创建:`{ profileId?, provider?, model?, title? }`,profile 中的值可被覆盖(受 profile 的 `overridable` 白名单约束) |
+| GET | `/v1/sessions?cursor=&limit=` | 当前 API Key 的会话列表(`?scope=all` 可列出所有 Key 的会话,需要 Key 具备该权限) |
+| GET | `/v1/sessions/:id` | 详情:元数据 + 消息摘要 + 未决交互 |
+| PATCH | `/v1/sessions/:id` | 改标题、归档 |
+| DELETE | `/v1/sessions/:id` | 删除(含工作区) |
+| GET | `/v1/sessions/:id/events?after=<seq>` | 拉取历史事件(用于渲染完整时间线) |
+
+### 消息与 Run
+| 方法 | 路径 | 说明 |
+|---|---|---|
+| POST | `/v1/sessions/:id/messages` | `{ text, attachments?: fileId[] }` → `202 { runId }`;会话忙时返回 `409 session_busy` |
+| GET | `/v1/sessions/:id/stream` | **SSE**:订阅会话全部事件(推荐,一个会话一条连接) |
+| GET | `/v1/runs/:id` | Run 状态与用量 |
+| POST | `/v1/runs/:id/interrupt` | 中断 |
+
+### 交互
+| 方法 | 路径 | 说明 |
+|---|---|---|
+| GET | `/v1/interactions?status=pending` | 未决交互 |
+| POST | `/v1/interactions/:id` | 回复(见 §4) |
+
+### 文件
+| 方法 | 路径 | 说明 |
+|---|---|---|
+| POST | `/v1/sessions/:id/files` | multipart 上传(数据文件 / 截图),返回 `fileId`;写入工作区 `uploads/` |
+| GET | `/v1/sessions/:id/files` | 列出工作区文件树(uploads / outputs) |
+| GET | `/v1/files/:fileId` | 下载;`?inline=1` 用于预览 |
+| GET | `/v1/files/:fileId/preview` | 预览渲染:图片缩略图、CSV/xlsx 前 N 行、docx/pdf 转 PDF/HTML |
+
+### 其他
+| 方法 | 路径 | 说明 |
+|---|---|---|
+| GET | `/v1/profiles` | 当前平台可用的 Profile |
+| GET | `/v1/providers` | 引擎可用性与模型列表 |
+
+## 3. 统一事件模型
+
+所有事件共享信封:
+
+```ts
+interface AgentEventEnvelope<T extends string, P> {
+  seq: number            // 会话内单调递增;SSE 的 id
+  type: T
+  sessionId: string
+  runId: string
+  ts: string
+  payload: P
+}
+```
+
+| type | payload | 说明 |
+|---|---|---|
+| `run.queued` | `{ position }` | 排队中 |
+| `run.started` | `{ provider, model }` | |
+| `message.delta` | `{ messageId, text }` | 助手文本增量 |
+| `message.completed` | `{ messageId, text }` | 助手一段完整文本 |
+| `reasoning.delta` | `{ blockId, text }` | 思考过程(前端默认折叠) |
+| `tool.started` | `{ toolCallId, name, title, input }` | `title` 是适配层生成的人类可读摘要,如 `Read sales.xlsx` |
+| `tool.progress` | `{ toolCallId, output }` | 命令输出增量(可选) |
+| `tool.completed` | `{ toolCallId, status: 'ok'\|'error'\|'denied', output, truncated }` | |
+| `file.changed` | `{ fileId, path, kind: 'created'\|'modified', size, mime }` | 工作区产出文件 |
+| `interaction.required` | 见 §4 | 需人工介入,Run 进入 `waiting_input` |
+| `interaction.resolved` | `{ interactionId, resolution, by }` | 已回复/超时/取消 |
+| `usage` | `{ inputTokens, outputTokens, cacheReadTokens, costUsd? }` | 订阅登录下 `costUsd` 为估算值 |
+| `run.completed` | `{ durationMs }` | |
+| `run.failed` | `{ code, message }` | |
+| `run.interrupted` | `{ reason: 'user'\|'restart'\|'timeout' }` | |
+
+用户消息也写入事件流(`user.message`,payload 含文本与附件),以便事件流单独就能完整重建时间线。
+
+## 4. 交互协议
+
+```ts
+// interaction.required payload
+type InteractionRequest =
+  | { interactionId: string; kind: 'approval'
+      tool: string; title: string; input: unknown
+      detail?: { command?: string; diff?: string; url?: string; connector?: string }
+      options: Array<'allow_once' | 'allow_session' | 'deny'>
+      expiresAt: string }
+  | { interactionId: string; kind: 'question'
+      questions: Array<{ id: string; question: string; header?: string
+                         multiSelect: boolean; options: { label: string; description?: string }[] }>
+      expiresAt: string }
+
+// POST /v1/interactions/:id
+type InteractionResponse =
+  | { decision: 'allow_once' | 'allow_session' }
+  | { decision: 'deny'; reason?: string }       // reason 回传给 Agent
+  | { answers: Record<string, string | string[]> }
+```
+
+- 同一交互只接受第一次回复,之后的回复返回 `409 already_resolved`。
+- 多个前端同时打开同一会话时,都会收到 `interaction.resolved`,用于同步关闭卡片。
+- 超时默认按 `deny` 处理(`reason: "timeout"`)。
+
+## 5. SSE 细节
+
+- `GET /v1/sessions/:id/stream`,响应头 `Content-Type: text/event-stream`,每条 `id: <seq>`、`event: <type>`、`data: <json>`
+- 重连时携带 `Last-Event-ID`,服务端补发 `seq > lastId` 的事件
+- 每 15s 发一次 `: ping` 注释帧保活
+- 浏览器原生 `EventSource` 无法设置 Authorization 头,因此 `@agent-server/client` 使用 `fetch` + `ReadableStream` 实现 SSE(支持自定义头和断线续传)
+
+## 6. 客户端 SDK 示意
+
+```ts
+import { AgentClient } from '@agent-server/client'
+
+const client = new AgentClient({ baseUrl, apiKey })
+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' })
+```

+ 79 - 0
docs/design/05-data-model.md

@@ -0,0 +1,79 @@
+# 05 数据模型
+
+SQLite(WAL 模式),Drizzle 管理迁移。文件内容不入库,只存元数据。
+
+## 1. 实体关系
+
+```
+api_keys ──< sessions >── profiles ──< profile_knowledge >── knowledge_roots
+                ├─< runs ──< interactions
+                ├─< events
+                └─< files
+settings   audit_logs   usage_daily   admin_sessions
+```
+
+不区分使用者:上层平台通过 API Key 调用,会话归属到 API Key。
+
+## 2. 表定义(要点)
+
+| 表 | 关键字段 | 说明 |
+|---|---|---|
+| `api_keys` | id, name, key_hash, key_prefix, enabled, created_at, last_used_at | 每个上层平台一个 Key,便于区分来源和单独吊销;只存哈希 |
+| `profiles` | id, name, provider, model, system_prompt, permission_mode, allowed_tools(json), disallowed_tools(json), skills(json), file_access(json), network(json), overridable(json), auto_allow_rules(json), version, updated_at | Agent 预设;`file_access` 见 06 §4 |
+| `knowledge_roots` | id, name, path, description, access('read'\|'write'), enabled, visible(检测结果), checked_at | 资料库,**在后台设定** |
+| `profile_knowledge` | profile_id, knowledge_root_id | Profile 启用的资料库 |
+| `sessions` | id, api_key_id, profile_id, provider, model, provider_session_id, workspace_dir, title, status, last_seq, created_at, updated_at, archived | |
+| `runs` | id, session_id, status, started_at, ended_at, input_tokens, output_tokens, cost_usd, error | |
+| `events` | session_id, seq, run_id, type, payload(json), ts — PK(session_id, seq) | 事件全量落库 |
+| `interactions` | id, run_id, session_id, kind, request(json), response(json), status, created_at, resolved_at, expires_at | |
+| `files` | id, session_id, rel_path, origin('upload'\|'agent'), mime, size, sha256, created_at | |
+| `settings` | key, value(json), updated_at | 全局配置,见 §3 |
+| `audit_logs` | id, ts, actor, session_id, kind('command'\|'file_access_denied'\|'approval'\|'admin'), summary, detail(json) | |
+| `usage_daily` | date, api_key_id, provider, model, runs, input_tokens, output_tokens, cost_usd | |
+| `admin_sessions` | id, created_at, expires_at | 后台登录会话 |
+
+预留(暂不建表):`connectors`、`profile_connectors`(内部平台接入,见 08);`credentials`(API Key 类凭证,见 ADR-0008)。
+
+## 3. 后台可设定的全局配置(`settings`)
+
+| key | 默认值 | 说明 |
+|---|---|---|
+| `paths.workspaceRoot` | `/srv/agent-server/workspaces` | 会话工作区根目录 |
+| `paths.skillsDir` | `/srv/agent-server/skills` | 共享技能目录 |
+| `paths.pythonRuntime` | `/srv/agent-server/runtime/py` | Python 环境 |
+| `paths.matplotlibrc` | `/srv/agent-server/runtime/matplotlibrc` | 中文字体配置 |
+| `security.globalDeny` | 内置清单(见 06 §4.1)+ 自定义 | 内置项不可删除 |
+| `security.defaultFileAccess` | `restricted` | 新建 Profile 的默认模式 |
+| `network.allowedDomains` | `pypi.org`, `files.pythonhosted.org` | Claude Bash 沙箱的白名单 |
+| `network.blockPrivateFetch` | `true` | WebFetch 私网拦截 |
+| `limits.concurrency` | `{ global: 4, claude: 2, codex: 2 }` | |
+| `limits.interactionTimeoutSec` | `600` | 交互超时(按拒绝处理) |
+| `limits.uploadMaxMB` / `workspaceMaxMB` | `50` / `2048` | |
+| `retention.workspaceDays` | `30` | 归档后的工作区保留天数 |
+| `providers.claude` | `{ enabled, cliPath, defaultModel }` | |
+| `providers.codex` | `{ enabled, cliPath, defaultModel, configOverrides }` | `configOverrides` 用于禁用个人 config 中的插件和 MCP |
+
+修改后对**新的 Run** 立即生效;`providers.codex` 的修改会触发 app-server 进程平滑重启(等活动 turn 结束后再重启)。
+
+## 4. 文件系统布局
+
+```
+/srv/agent-server/                  (属主 jerry-remote)
+├─ app/                             部署的构建产物
+├─ data/agent.db                    SQLite
+├─ workspaces/<sessionId>/
+│   ├─ uploads/                     用户上传(数据文件、截图)
+│   ├─ outputs/                     最终产出
+│   └─ .agent/                      工具大输出等内部文件
+├─ knowledge/                       默认资料库位置(也可以在后台添加其他路径)
+├─ skills/                          共享技能
+├─ runtime/py/                      Python venv(已创建)
+├─ runtime/matplotlibrc             中文字体配置(已创建)
+├─ runtime/requirements.lock        锁定的 Python 依赖版本
+└─ logs/
+```
+
+## 5. 保留策略
+- 工作区:会话归档后保留 `retention.workspaceDays` 天,之后清理目录,会话与事件保留
+- 事件:永久保留;`tool.completed.output` 超过 64KB 时截断,全文写入 `.agent/tool-outputs/`
+- 审计日志:保留 1 年

+ 166 - 0
docs/design/06-security.md

@@ -0,0 +1,166 @@
+# 06 安全:隔离、沙箱、文件访问策略、网络与凭证
+
+## 1. 威胁模型
+
+调用方(自己的上层平台)可信,但 **Agent 的行为不完全可信**:它会执行命令、读写文件、访问网络,而且可能被网页或文档中的提示注入误导。要防的是 Agent 越界。
+
+| 风险 | 例子 |
+|---|---|
+| R1 读取敏感文件 | 读 `~/.ssh`、CLI 登录凭证、个人文档、其他会话的工作区 |
+| R2 越界写入/破坏 | 删改工作区以外的文件 |
+| R3 访问局域网 | 访问路由器、NAS、内网服务的管理接口 |
+| R4 数据外传 | 把资料内容发到外部网站 |
+| R5 资源耗尽 | 死循环脚本、大文件把机器拖垮 |
+
+## 2. 分层防御
+
+```
+L3 应用层     文件访问策略校验(canUseTool)· 审批 · WebFetch 私网拦截
+L2 引擎沙箱   Claude Bash 沙箱 · Codex 权限配置(bubblewrap)
+L1 systemd    ProtectHome=tmpfs / ProtectSystem=strict / 资源上限
+(L0 专用用户:暂不启用,见 §5.3)
+```
+
+## 3. 引擎自带沙箱具体限制了什么
+
+### 3.1 Claude Code(`@anthropic-ai/sandbox-runtime`,Linux 上用 bubblewrap + socat)
+
+| 方面 | 默认行为 | 配置 |
+|---|---|---|
+| **作用范围** | **只作用于 Bash 等 shell 命令及其子进程** | — |
+| 写 | 工作目录、临时目录、`additionalDirectories` | `sandbox.filesystem.allowWrite / denyWrite` |
+| 读 | **默认能读机器上大部分路径,包括 `~/.ssh`** | `sandbox.filesystem.denyRead / allowRead`(范围更窄的规则优先) |
+| 网络 | 没有直连出口,经本机代理按域名白名单放行(默认为空) | `sandbox.network.allowedDomains / deniedDomains` |
+| 逃逸口 | Claude 可以申请 `dangerouslyDisableSandbox` 在沙箱外重试 | `allowUnsandboxedCommands: false` |
+| 不可用时 | 默认静默降级为无沙箱运行 | `failIfUnavailable: true` |
+
+**不在沙箱内**:Read/Edit/Write/Grep/Glob/WebFetch 只受权限规则约束(`denyRead` 拦不住 Read 工具);MCP、hooks 以完整权限运行。
+
+### 3.2 Codex(bubblewrap;权限配置 permission profiles)
+- 作用于 Codex 拉起的**所有命令**(Codex 读文件也是通过命令完成的)。
+- 新版的**权限配置**按路径设置 `read` / `write` / `deny`,范围更窄的路径优先;同一路径上 `deny` 优先于 `write`,`write` 优先于 `read`。可以从 `":root" = "deny"` 开始做"默认拒绝"。
+- 内置路径别名:`:minimal`(运行时必需的系统路径)、`:workspace_roots`、`:tmpdir`。
+- 网络默认关闭;开启后可以用代理加域名规则过滤。
+- 注意:使用权限配置时不要再设置 `sandbox_mode`,两者混用时 Codex 会退回旧的沙箱设置。
+
+### 3.3 共同缺口
+- Claude 的文件工具不在沙箱内 → 需要 L3 校验。
+- 两者都依赖 bubblewrap 创建 user namespace → systemd **不能**开启 `RestrictNamespaces`。
+
+## 4. 文件访问策略(读权限可在 Agent 配置中设定)
+
+**可以设定**,而且两个引擎都有对应的原生机制。读写范围作为 Profile 的一部分,在后台编辑。
+
+### 4.1 策略模型
+
+```ts
+interface FileAccessPolicy {
+  mode: 'restricted' | 'broad'      // restricted:只能读列出的路径(推荐);broad:全盘可读,只排除 deny
+  read:  PathRule[]                 // 额外可读路径:资料库引用 或 绝对路径
+  write: PathRule[]                 // 额外可写路径(少用;工作区始终可写)
+  deny:  string[]                   // 额外禁止路径/通配,如 "**/*.env"
+}
+type PathRule = { knowledgeRootId: string } | { path: string }
+```
+
+最终生效的权限由三层合并而成,上层不能被下层放宽:
+
+| 层 | 内容 | 谁来设定 |
+|---|---|---|
+| ① 全局强制禁止(不可覆盖) | `~/.claude/.credentials.json`、`~/.claude.json`、`~/.codex/auth.json`、`~/.ssh`、`~/.gnupg`、`/srv/agent-server/data`、**其他会话的工作区** | 后台"设置 → 安全",可追加,不能删除内置项 |
+| ② Profile 策略 | 上面的 `FileAccessPolicy` | 后台"Profiles" |
+| ③ 基线(始终存在) | 当前工作区读写;Python 运行环境、技能目录只读;`:minimal` 系统路径只读 | 内置 |
+
+### 4.2 映射到引擎
+
+| 合并后的策略 | Claude | Codex |
+|---|---|---|
+| 可读路径 | `additionalDirectories`(资料库)+ `sandbox.filesystem.allowRead` + `canUseTool` 对 Read/Grep/Glob 的路径白名单校验 | 权限配置:`":root"="deny"`(restricted 模式)、`":minimal"="read"`、各可读路径 `="read"` |
+| 可写路径 | 工作区 + `sandbox.filesystem.allowWrite` + `canUseTool` 对 Edit/Write 的校验 | `":workspace_roots"`: `"."="write"` + 额外可写路径 |
+| 禁止路径 | `sandbox.filesystem.denyRead` + `permissions.deny: Read(...)` + `canUseTool` | 各禁止路径 `="deny"` |
+| restricted 模式下的 Bash | `denyRead` 覆盖 home 和 `/srv/agent-server` 等大块目录,再用 `allowRead` 重新开放工作区和可读路径 | 由 `":root"="deny"` 天然覆盖 |
+
+`canUseTool` 中的路径校验是 Claude 侧的**权威判断**:路径先 `realpath` 规范化(防止 `..` 和软链接绕过),再与合并后的策略比对。命中 deny 时直接拒绝,不在策略范围内的路径按配置拒绝或转人工审批。
+
+### 4.3 后台体验
+- Profile 编辑页有一个"文件访问"区块:选择模式,从资料库列表勾选可读目录,添加自定义路径和禁止规则。
+- 右侧显示"生效预览":合并三层之后的最终可读、可写、禁止清单。
+- 保存时校验:路径是否存在、服务进程能否看到(见 §5.2 中 ProtectHome 的影响)、是否与全局禁止项冲突。
+
+## 5. 进程隔离(systemd)
+
+### 5.1 现阶段:以 `jerry-remote` 身份运行,用 systemd 加固
+
+CLI 登录态保存在你的 home 目录中,所以服务需要访问 `~/.claude`、`~/.claude.json`、`~/.codex` 和 CLI 安装目录。做法是先用 `ProtectHome=tmpfs` 把整个 home 隐藏起来,再只把需要的路径绑定回来:
+
+```ini
+[Service]
+User=jerry-remote
+Group=jerry-remote
+WorkingDirectory=/srv/agent-server
+ExecStart=/usr/bin/node /srv/agent-server/app/apps/server/dist/main.js
+Environment=HOME=/home/jerry-remote
+
+# 文件系统:整盘只读,home 不可见,只回绑必要路径
+ProtectSystem=strict
+ProtectHome=tmpfs
+BindPaths=/home/jerry-remote/.claude /home/jerry-remote/.claude.json /home/jerry-remote/.codex
+BindReadOnlyPaths=/home/jerry-remote/.local/bin /home/jerry-remote/.local/share/claude
+ReadWritePaths=/srv/agent-server
+PrivateTmp=yes
+
+# 内核与权限
+NoNewPrivileges=yes
+ProtectKernelTunables=yes
+ProtectKernelModules=yes
+ProtectControlGroups=yes
+LockPersonality=yes
+# RestrictNamespaces 不能开:bubblewrap 需要 user/mount/net namespace
+
+# 资源上限
+MemoryMax=8G
+CPUQuota=600%
+TasksMax=512
+```
+
+效果:Agent 看不到你的 `~/web`、`~/.ssh`、`~/Documents` 等目录。CLI 凭证目录虽然对进程可见,但由 §4.1 第①层的全局强制禁止在引擎沙箱和应用层两处拦截。
+
+### 5.2 对资料库路径的影响
+在 home 目录下的资料(例如 `~/Documents/资料`)默认对服务**不可见**。有两种处理方式:
+- **推荐**:把资料放到 home 以外(如 `/srv/agent-server/knowledge`、`/data/...`)。
+- 或者在 unit 中追加一行 `BindReadOnlyPaths=`,然后重启服务。后台在保存资料库时会检测路径是否可见,不可见时直接给出需要追加的配置行。
+
+### 5.3 专用用户(暂不启用)
+同 UID 运行时仍存在的缺口:服务可以向你的其他进程发信号(systemd 249 没有 `PrivatePIDs`);home 以外属于你的文件按权限仍可访问;加固配置出错时没有兜底。以后需要时,迁移步骤是:创建 `agentsvc` 用户 → 用该用户重新登录 CLI → 修改 unit 的 User 和绑定路径。服务代码不受影响,因为所有路径都从配置中读取。
+
+## 6. 网络策略
+
+| 通道 | 控制方式 |
+|---|---|
+| Claude Bash 命令 | 沙箱代理 + 域名白名单(后台可配,默认只放行 pip 源) |
+| Claude WebFetch | 在 `canUseTool` 中解析域名,拒绝私网、回环和链路本地地址 |
+| Claude WebSearch / Codex web_search | 由厂商服务端执行,不经过本机网络 |
+| Codex 命令 | 权限配置中 `network.enabled=false`;需要时走审批 |
+
+## 7. 凭证与合规
+
+- **使用方式**:你在终端中自己登录 `claude` 和 `codex`,服务直接复用这份登录态,供你自己的平台使用。按 Anthropic 条款的划分,这属于订阅所有者本人的使用;不涉及他人使用你的订阅。
+- **两点约束**:
+  1. Pro/Max 的额度"假设为普通的个人使用"。服务化之后,常驻和多平台的调用量会和你在终端里的使用**共享同一份额度**。
+  2. 以后如果开放给其他人使用,需要改用 API Key(凭证类型已预留,见 ADR-0008)。
+- **实现要求**:
+  - 服务进程环境中**不设置** `ANTHROPIC_API_KEY` / `OPENAI_API_KEY`,避免意外切换到 API 计费。
+  - **不复制**凭证文件。ChatGPT 的 refresh token 会轮换,复制一份会导致你终端里的登录失效。
+  - Codex 使用默认的 `~/.codex`,因此会加载你个人 `config.toml` 中的插件和 MCP(当前有 browser、computer-use、node_repl 等)。app-server 启动时**必须用 `-c` 覆盖并禁用这些项**,只启用服务需要的配置。实现时用 `--strict-config` 加启动自检来验证。
+  - Claude 侧通过 `settingSources: []` 不加载个人的 settings、插件和 hooks。
+- 后台"引擎状态"页只显示登录状态、账户摘要和额度,不显示 token。
+- 条款参考:[Claude Code Legal and compliance](https://code.claude.com/docs/en/legal-and-compliance),2026-10 核对。本文不是法律意见。
+
+## 8. M1 实测补充(2026-10-22)
+
+- **Bash 沙箱禁止创建 Unix socket**(seccomp:`socket(AF_UNIX) = EPERM`),LibreOffice 因此无法在 Bash 中运行。
+  - **不采用** `sandbox.network.allowAllUnixSockets: true`:运行账号在 `docker`、`lxd` 组中,一旦放开,Agent 就能连接 `/var/run/docker.sock`,等同获得宿主 root 权限。
+  - 改为提供受控工具 `convert_document`(`services/office.ts`):服务进程自行用 bubblewrap 启动 LibreOffice,`--unshare-all`(断网、独立 IPC/PID)、私有 `/tmp` 和 `/run`、隐藏 home 和服务数据目录、只挂载当前工作区;输入和输出路径都必须在工作区内。
+- **restricted 模式下 Bash 的读取范围**:沙箱只能整块屏蔽目录(home、`/srv`、`/mnt`、`/media`、`/root`),`/tmp`、`/etc` 等系统路径对 Bash 仍然可读。文件工具(Read/Grep/Glob)由 hook 严格限制在白名单内。这是已知、可接受的缺口;systemd 部署时 `PrivateTmp=yes` 会让 `/tmp` 成为服务私有。
+- Claude CLI 为每个工作目录分配的临时目录(`/tmp/claude-<uid>/<工作目录转义>`)已加入可写基线,否则模型会在写临时脚本时被拦截。
+- 建议(未实施):把运行账号移出 `docker` / `lxd` 组,或者尽早迁移到专用用户(§5.3)。这两个组本身就是本机上权限最大的入口。

+ 89 - 0
docs/design/07-files-and-knowledge.md

@@ -0,0 +1,89 @@
+# 07 文件、文档分析与资料检索
+
+## 1. 上传:数据文件与截图
+
+| 类型 | 处理 |
+|---|---|
+| 截图/图片(png/jpg/webp/gif) | 存到 `uploads/`;发送消息时**作为多模态输入**直接交给模型(Claude 用 image content block,Codex 用本地图片输入),同时保留文件,方便 Agent 后续用脚本处理 |
+| 数据文件(csv/xlsx/json/parquet) | 存到 `uploads/`;消息中附带文件清单(路径、大小、类型),由 Agent 自行读取 |
+| 文档(pdf/docx/pptx/md/txt) | 同上;Claude 可以直接用 Read 工具多模态阅读 PDF |
+
+- 限制:单文件 50MB(可配置),图片超过 2000px 时压缩,会话工作区总量 2GB。
+- 前端交互:拖拽、粘贴(截图直接 Ctrl+V)、点选;输入框上方显示附件条(缩略图 + 文件名 + 移除按钮)。
+- 上传后计算 sha256,同一会话内去重。
+
+## 2. 官方客户端怎么分析文档(调研结论)
+
+详见 [research/document-analysis-approaches.md](../research/document-analysis-approaches.md)。
+
+**结论:官方方案就是"模型原生多模态阅读 + 沙箱里执行 Python/Node 代码 + 技能(Skills)指导"三者组合,Python 是主力。**
+
+| 产品 | 方案 |
+|---|---|
+| Claude.ai | PDF 和图片由模型原生阅读(PDF 按页提供文本和页面图像);计算与文件生成在服务端沙箱容器中执行代码(Python 为主);用官方 Skills(xlsx / docx / pdf / pptx)规范操作:xlsx 用 openpyxl + pandas,并用 LibreOffice 重算公式;pdf 用 pypdf / pdfplumber;docx 用 docx-js(Node)生成、pandoc 读取 |
+| ChatGPT | Code Interpreter(Python 沙箱)做数据分析;File Search 对文档做检索;图片由模型视觉能力处理 |
+| Claude Code / Codex CLI | 没有托管沙箱,在本机执行同样的工具链;Claude Code 的 Read 工具可以直接读 PDF、图片 |
+
+所以本项目沿用同一套思路,不另造轮子:
+1. **视觉/阅读**交给模型:截图、PDF 页面、图表图片直接给模型看。
+2. **计算**交给代码:在工作区内运行 Python(pandas 等),结果可复现。
+3. **产出规范**交给技能:把官方的 xlsx/docx/pdf/pptx 技能放进共享技能目录,两个引擎共用。
+
+## 3. 运行环境与技能
+
+### 3.1 共享 Python 环境
+`/srv/agent-server/runtime/py`(venv,Agent 只读;**已创建**,版本锁定在 `runtime/requirements.lock`),已安装:
+
+| 用途 | 包 |
+|---|---|
+| 数据处理 | pandas, numpy, pyarrow, duckdb(直接用 SQL 查 CSV/Parquet) |
+| 可视化 | matplotlib, seaborn, plotly(交互式 HTML 图表) |
+| Excel | openpyxl, xlsxwriter |
+| Word / PPT | python-docx, python-pptx |
+| PDF | pypdf, pdfplumber, reportlab |
+| 转换 | markitdown(多种格式统一转 Markdown) |
+
+系统工具(均已安装):LibreOffice、poppler-utils、pandoc、ripgrep、bubblewrap、socat、中文字体 fonts-noto-cjk。
+
+中文图表:`runtime/matplotlibrc` 中已配置 Noto Sans CJK 字体,服务启动 Agent 时通过环境变量 `MATPLOTLIBRC` 注入(已验证中文标题和负号都能正常渲染)。
+
+Node 侧:`docx`(docx-js),供 docx 技能使用,随项目依赖在 M1 安装。
+
+Agent 不允许 `pip install` 到共享环境;需要临时依赖时,在工作区内创建临时 venv(经过审批)。
+
+### 3.2 技能(Skills)
+- 目录:`/srv/agent-server/skills/`,Claude 与 Codex 共用(Agent Skills 格式:`<name>/SKILL.md` + 脚本)。
+- 初始内容:官方 `xlsx`、`docx`、`pdf`、`pptx` 技能(来自 anthropics/skills 公开仓库,使用前核对许可证)+ 自定义的"报告规范"技能(统一的报告结构、图表配色、中文字体)。
+- Profile 中选择启用哪些技能;适配层负责把技能目录挂到各引擎的技能加载路径。
+
+### 3.3 产出约定
+系统提示词中约定:最终产物写入 `outputs/`,中间文件随意存放。`outputs/` 下的新文件触发 `file.changed`,前端以"产出物卡片"展示(可预览、可下载)。
+
+## 4. 资料检索
+
+### 4.1 本地资料
+- **资料库(Knowledge Root)**:**在后台"资料库"页设定**,字段包括名称、路径、说明(会告诉 Agent 这里放的是什么)、读写属性(默认只读)、启用状态。
+- 保存时检测:路径是否存在、服务进程能否看到(home 下的路径受 ProtectHome 影响,见 06 §5.2)、是否命中全局禁止项。
+- Profile 勾选启用哪些资料库;它们会并入该 Profile 的文件访问策略(06 §4),分别映射为 Claude 的 `additionalDirectories` 加沙箱 `allowRead`,以及 Codex 权限配置中的 `read` 条目。
+- 系统提示词中自动附上"可用资料库清单(名称、路径、说明)",让 Agent 知道去哪里找资料。
+- 检索手段,分两期:
+  - **M1:直接检索**。Agent 用 Grep/Glob/Read、ripgrep、pdftotext、markitdown 自行查找,适合资料量不大(几千个文件以内)的情况。
+  - **M3:索引检索**。MCP Gateway 提供 `kb_search` 工具:后台任务把资料转成文本(markitdown)后建 SQLite FTS5 全文索引(中文用 jieba 或 ICU 分词),可选再加向量检索。返回片段和出处路径,Agent 再按需读取原文。
+
+### 4.2 远程资料
+
+| 来源 | 方式 |
+|---|---|
+| 公网搜索 | Claude WebSearch / Codex web_search(均由厂商服务端执行) |
+| 网页抓取 | Claude WebFetch(应用层拦截私网地址);Codex 在沙箱联网审批通过后用 curl 等工具抓取,或通过 Gateway 的 `web_fetch` 工具(统一抓取、清洗为 Markdown、拦截私网) |
+| 内部平台 | 暂缓,预留 MCP Gateway 连接器(见 08) |
+| 远程文件存储(NAS / WebDAV / 网盘) | 两种方式:(a) 用 rclone 只读挂载为资料库;(b) 写一个连接器,提供 `list/search/read` 工具 |
+
+推荐两个引擎统一使用 Gateway 的 `web_fetch` 工具,抓取行为和安全策略就只需维护一处。
+
+## 5. M1 实现记录
+
+- 文档格式转换由 `convert_document` 工具完成(docx/xlsx/pptx → pdf 等),不在 Bash 中调用 soffice(原因见 06 §8)。
+- 共享技能目录按 Claude 插件格式组织(`skills/.claude-plugin/plugin.json` + `skills/skills/<name>/SKILL.md`),仓库中的 `skills/` 是源码,部署时同步到 `/srv/agent-server/skills`。
+- 当前只有一个自写技能 `report-conventions`(报告产出规范、中文图表、Word/Excel 写法、格式转换、自检清单)。官方 xlsx/docx/pdf/pptx 技能的许可证写明"由你与 Anthropic 的协议约束",没有复制进来(11 Q5)。
+- 实测验收:上传 xlsx 和截图 → 模型从截图读出目标值 → pandas 分析 → 中文柱状图 → python-docx 生成报告 → 转换为 PDF → 渲染预览图自检,全程在受限沙箱中完成。

+ 82 - 0
docs/design/08-platform-connectors.md

@@ -0,0 +1,82 @@
+# 08 内部平台数据接入:MCP Gateway
+
+> **状态:暂缓实现。** 当前阶段只对上层提供 API。本文保留设计,供以后接入时使用;其中 MCP Gateway 本身会在 M2 先上线,承载内置工具 `web_fetch` 和 `kb_*`(§5)。
+
+## 1. 目标
+让 Agent 能查询(必要时操作)你的内部平台数据,同时做到:
+- 平台凭证只保存在服务端,Agent 拿不到
+- Agent 不直接访问局域网(见 06 §5)
+- Claude 和 Codex 用同一套工具定义
+- 读操作自动放行,写操作必须人工审批
+- 每次调用都有审计记录
+
+## 2. 方案:服务内置的 MCP Gateway
+
+```
+claude CLI ──┐  MCP (Streamable HTTP)         ┌─► 平台 A API (LAN)
+             ├──► /mcp (AgentServer 进程内) ───┼─► 平台 B API (LAN)
+codex  ──────┘  Authorization: Bearer <run-token>  └─► 本地资料索引 / web_fetch
+```
+
+- 两个引擎都支持 HTTP 类型的 MCP Server,所以只做一个 Gateway,不在引擎侧分别实现。
+- **Run Token**:每个 Run 签发一个短期令牌(绑定 sessionId、userId、profileId,Run 结束即失效)。Gateway 据此判断当前 Run 能用哪些工具、以谁的身份调用。
+- 不选 Claude SDK 的进程内 MCP:Codex 用不了,会导致两套实现。
+
+## 3. 连接器(Connector)
+
+一个内部平台对应一个连接器,以 TS 模块的形式放在 `apps/server/src/connectors/<name>/`:
+
+```ts
+export default defineConnector({
+  id: 'finance',
+  name: '家庭账本',
+  config: z.object({ baseUrl: z.string().url(), token: z.string() }), // 后台填写,token 加密存储
+  tools: [
+    defineTool({
+      name: 'query_transactions',
+      description: '按日期范围、分类查询收支流水,返回 JSON 行',
+      input: z.object({ from: z.string(), to: z.string(), category: z.string().optional() }),
+      access: 'read',                       // read: 自动放行;write: 必须审批
+      async run(input, { config, user, fetch }) { /* 调平台 API */ },
+    }),
+    defineTool({
+      name: 'create_budget',
+      access: 'write',
+      approvalTitle: i => `创建预算:${i.name} ¥${i.amount}`,
+      /* ... */
+    }),
+  ],
+})
+```
+
+工具命名规则:`<connectorId>__<toolName>`;在引擎侧显示为 `mcp__gateway__finance__query_transactions`。
+
+### 3.1 结果处理
+- 大结果(超过 200 行或 100KB)不直接塞进上下文:写成 `workspace/.agent/data/<toolcall>.json|csv`,返回摘要、行数和文件路径,再由 Agent 用 pandas 分析。这一点对数据分析场景很重要。
+- 统一错误格式,平台不可用时给出清楚的报错。
+
+### 3.2 使用者身份
+- 当前不区分使用者;连接器以服务端配置的身份访问平台。以后如需区分,可以在 Run Token 中携带调用方(API Key)标识。
+
+## 4. 审批在 Gateway 内完成
+`access: 'write'` 的工具被调用时,Gateway 自己调用 Interaction Service 发起 `interaction.required`,**挂起 MCP 请求**直到人工回复,再执行或返回拒绝。
+
+这样不依赖引擎侧的审批机制,两个引擎行为一致。引擎侧(Claude 的 `canUseTool`)会把 `mcp__gateway__*` 加入自动放行,避免重复审批。
+
+需要注意 MCP 请求的超时:引擎侧的 MCP 工具调用超时要调大(至少不低于交互超时),具体配置项在实现时核对。
+
+## 5. 内置工具(Gateway 自带)
+
+| 工具 | 说明 | 期次 |
+|---|---|---|
+| `web_fetch` | 抓取 URL → 清洗为 Markdown;拦截私网地址;遵守域名黑名单 | M2 |
+| `kb_search` | 本地资料库全文检索(见 07 §4.1) | M3 |
+| `kb_list_roots` | 列出当前 Profile 可用的资料库及说明 | M2 |
+| `ask_user` | 给 Codex 用的结构化提问(对齐 Claude 的 AskUserQuestion) | M2 |
+
+## 6. 后台管理
+- 连接器列表:启用状态、健康检查(每个连接器可以实现 `ping()`)、最近调用情况
+- 配置表单:由连接器的 `config` schema 自动生成,敏感字段只写不读
+- 工具列表:可以单独禁用某个工具,或把 read 工具改为需要审批
+- Profile 中勾选启用的连接器和工具
+- 调用日志:参数、耗时、结果摘要、审批记录

+ 114 - 0
docs/design/09-admin-ui.md

@@ -0,0 +1,114 @@
+# 09 管理后台与 Playground
+
+## 1. 总体布局:VS Code 工作台风格
+
+整个后台仿照 VS Code 工作台:左侧活动栏(Activity Bar)放图标导航,侧栏(Side Bar)放列表,主区域(Editor Area)放详情,底部状态栏(Status Bar)显示服务状态。Playground 的对话面板对齐 Claude VS Code 插件。
+
+```
+┌──┬──────────────┬──────────────────────────────────────────────┐
+│◉ │ SESSIONS   + │  ┌ Playground ─────────────────────────────┐ │
+│▤ │ ▸ 销售分析…   │  │                                         │ │
+│⚙ │ ▸ 资料整理…   │  │       (对话面板,见 §3)                 │ │
+│⌘ │ ▸ …          │  │                                         │ │
+│☰ │              │  └─────────────────────────────────────────┘ │
+│  │              │                                              │
+├──┴──────────────┴──────────────────────────────────────────────┤
+│ ● claude 已登录  ● codex 已登录  运行中 1/4  排队 0        v0.1.0 │
+└────────────────────────────────────────────────────────────────┘
+```
+
+## 2. 页面清单
+
+| 活动栏 | 侧栏 | 主区域 |
+|---|---|---|
+| 💬 Playground | 会话列表(搜索、按引擎/使用者筛选) | 对话面板 + 右侧可收起的调试面板 |
+| 📊 概览 | — | 运行中任务、今日用量、7 天趋势、错误列表、凭证状态 |
+| 🗂 会话 | 全部会话(所有平台/使用者) | 只读回放(时间线 + 原始事件 + 文件),可"在 Playground 中继续" |
+| 🧩 Profiles | Profile 列表 | 表单编辑:引擎、模型、提示词(Monaco 编辑器)、权限、工具、技能、资料库、**文件访问策略**(模式、可读/可写/禁止路径,右侧显示合并后的生效预览,见 06 §4.3);版本历史 |
+| 📚 资料库 | 资料目录列表 | 名称、路径(带目录选择器)、说明、读写属性、启用;可见性检测结果;被哪些 Profile 使用;索引状态(M3) |
+| 🔑 引擎状态 | Claude / Codex | 登录状态、账户摘要、额度、CLI 路径与版本、生效的工具清单(用于确认个人插件已被禁用);未登录时提示在终端执行 `claude` / `codex login` |
+| 🗝 API Keys | Key 列表 | 签发(明文只显示一次)、吊销、最近使用时间 |
+| 🔌 连接器 | (暂缓) | — |
+| 📜 审计 | 筛选条件 | 命令执行、连接器调用、审批、后台操作 |
+| ⚙ 设置 | 分组:路径 / 安全 / 网络 / 限制 / 引擎 | **路径**:工作区根目录、技能目录、Python 环境、matplotlibrc;**安全**:全局禁止路径(内置项锁定)、新建 Profile 的默认文件访问模式;**网络**:域名白名单、私网拦截;**限制**:并发、交互超时、上传大小、保留期;**引擎**:CLI 路径、默认模型、Codex 配置覆盖项。所有路径输入都做实时校验 |
+
+## 3. Playground
+
+### 3.1 结构(对齐 Claude VS Code 插件)
+```
+┌ 顶栏 ─────────────────────────────────────────────────────────┐
+│ [会话标题 ▾ 历史]                    [+ 新会话] [⚙ 配置] [⋯]      │
+├ 时间线(居中,max-width 680px)──────────────────┬ 调试面板 ──────┤
+│ ┌──────────────────────────────┐               │ 事件 | JSON |   │
+│ │ 分析附件里的 Q3 销售数据 📎x2   │  ← 用户消息    │ 文件 | 用量     │
+│ └──────────────────────────────┘    (带边框的框) │               │
+│ │                                              │ #12 tool.start │
+│ ● 先看看数据结构。                  ← 助手文本     │ #13 tool.done  │
+│ │                                              │ ...            │
+│ ● Read  uploads/sales.xlsx        ← 工具行       │               │
+│ │  ┌ IN  │ {...}           ┐    (点击展开)     │               │
+│ │  └ OUT │ 1,204 rows…     ┘                   │               │
+│ ● Bash  python analyze.py         ● 绿=成功       │               │
+│ ● 需要你的确认                     ● 红=失败       │               │
+│ │ ┌ 允许运行此命令? ───────────┐  ● 黄=待处理     │               │
+│ │ │ pip install ...            │                 │               │
+│ │ │ [1 允许] [2 本会话允许] [3 拒绝…]│             │               │
+│ │ └────────────────────────────┘                 │               │
+│ ● 📄 outputs/Q3报告.docx  [预览][下载]            │               │
+├────────────────────────────────────────────────┤               │
+│ ┌ 输入框(圆角 8px,聚焦时 Claude 橙色光圈)──────┐ │               │
+│ │ [📎 sales.xlsx ×] [🖼 截图.png ×]             │ │               │
+│ │ 描述你想分析的问题…                            │ │               │
+│ │ [Claude ▾][Sonnet ▾][需审批 ▾][📎]        [➤] │ │               │
+│ └──────────────────────────────────────────────┘ │               │
+└────────────────────────────────────────────────┴───────────────┘
+```
+
+### 3.2 组件规范(来自插件样式的实测值,见 research 文档)
+
+| 组件 | 规范 |
+|---|---|
+| 时间线 | 消息左侧留 30px;左边距 12px 处画一条 1px 竖线(`--app-primary-border-color`);每条消息前有一个 7px 圆点(距左 9px);圆点颜色:默认 `--app-secondary-foreground`,成功 `#74c991`,失败 `#c74e39`,待处理/警告 `#e1c08d` |
+| 用户消息 | 1px 边框(`--app-input-border`)、6px 圆角、背景 `--app-input-background`、内边距 4px 6px;保留换行 |
+| 助手文本 | 无气泡,直接排版 Markdown;行内代码用 `--app-inline-code-background` |
+| 工具行 | 工具名加粗,参数摘要用等宽字体、0.85em、次要色;点击展开 IN/OUT 两行网格:标签列等宽字体、0.5 透明度,内容默认最高 60px 并带渐隐遮罩,点击展开全文 |
+| 工具结果 | 背景 `--app-code-background`,`white-space: pre`,可横向滚动 |
+| 思考过程 | `<details>` 折叠,摘要为斜体、次要色、0.8 透明度,悬停时变为 1 |
+| 交互卡片(审批/提问) | 背景 `--app-input-secondary-background`、1px 边框、8px 圆角、8px 内边距;标题加粗;按钮纵向排列并带数字快捷键(1/2/3),第 3 项"拒绝"可以展开输入拒绝理由;聚焦时边框为 `--app-input-active-border` 65% |
+| 输入框 | 1px 边框、8px 圆角、轻阴影 `0 1px 2px #0000001a`;**聚焦时**边框变为 Claude 橙 `#d97757`,外圈 3px 的 12% 橙色光晕;文本区内边距 10px 36px 10px 14px,最高 200px |
+| 发送按钮 | 26×26px,5px 圆角,背景 `#c6613f`(clay),图标色 `#faf9f5`(ivory);禁用时 0.4 透明度;运行中变为停止按钮 |
+| 底部工具条 | 引擎、模型、权限模式做成药丸按钮(背景为前景色 10% 混合,悬停 18%) |
+| 状态徽标 | 4px 圆角,4px 8px 内边距,500 字重:已连接为绿,失败为红,需登录为黄 |
+| 间距刻度 | 4 / 8 / 12 / 16px;圆角 4 / 6 / 8px |
+| 字体 | UI 用系统字体(`-apple-system, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif`)13px;等宽字体 `"JetBrains Mono", Menlo, Consolas, monospace` 12px |
+
+### 3.3 交互行为
+- 键盘:Enter 发送、Shift+Enter 换行、Esc 中断;交互卡片出现时自动获得焦点,数字键选择选项
+- 新内容到达时自动滚动到底部;用户向上滚动后暂停自动滚动,并显示"↓ 回到最新"按钮
+- 用户消息在滚动时吸顶(sticky),对应插件的 stickyHeader 行为
+- 工具调用默认折叠,失败的工具默认展开
+- 产出文件以卡片形式展示,点击在右侧调试面板的"文件"页签中预览
+
+### 3.4 配置抽屉(⚙)
+引擎、模型、Profile(选择后填充默认值)、权限模式(需审批 / 自动 / 只读)、系统提示词追加、启用的资料库和技能、文件访问策略的临时调整。底部有"另存为 Profile"按钮。
+
+### 3.5 调试面板(Playground 特有,插件里没有)
+- **事件**:带 seq、类型、耗时的事件列表,点击跳转到时间线对应位置
+- **JSON**:原始事件(AgentEvent),可以切换查看引擎原始消息(适配前的数据,仅调试模式下记录)
+- **文件**:工作区文件树,支持预览和下载
+- **用量**:token、耗时、预估费用、订阅限额剩余(引擎支持时显示)
+
+### 3.6 回归用例
+在 Playground 中可以把一个会话的首条输入和 Profile 保存为"用例",引擎升级后一键批量重放,人工比对结果(M3)。
+
+## 4. 主题
+- 亮/暗两套令牌:默认跟随系统,可手动切换。暗色取 VS Code Dark Modern 的配色,亮色取 Light Modern 的配色(见 research 文档的令牌表)。
+- 令牌集中放在 `packages/ui-tokens`(UnoCSS preset + CSS 变量),后台和业务平台共用,业务平台也可以只引入 `chat-ui` 加令牌。
+- 只复刻视觉规范(颜色、间距、布局),**不拷贝**插件的源码、图标和字体资源;图标用开源的 `@vscode/codicons`。
+
+## 5. 技术实现
+- Vue 3 `<script setup>` + TS;Pinia 管理会话和事件状态(`applyEvent` 是纯函数,由事件流增量构建视图模型)
+- `packages/chat-ui` 导出 `<AgentChat>`(完整面板)以及 `<Timeline>`、`<ToolCall>`、`<InteractionCard>`、`<Composer>` 等细粒度组件
+- Markdown:markdown-it + Shiki(亮/暗主题)+ 表格、KaTeX(数据分析常用)
+- 图表产出(png/svg/html)内联预览;html 用 `sandbox` iframe 隔离
+- 后台以静态资源形式由 Fastify 托管在 `/admin/`,同源部署,不需要处理 CORS

+ 47 - 0
docs/design/10-deployment.md

@@ -0,0 +1,47 @@
+# 10 部署与运维
+
+## 1. 目标环境(当前服务器)
+- Ubuntu 22.04.5 LTS,systemd 249,12 核 / 30GB 内存
+- Node 22.15、Python 3.10
+- `claude`(`~/.local/bin/claude`)、`codex` 0.154.0(`~/.local/bin/codex`),均由本人在终端登录
+- unprivileged user namespace 已开启,`bwrap` 可正常运行(已验证)
+
+## 2. 依赖安装状态(2026-10-22 已完成)
+
+| 依赖 | 状态 | 用途 |
+|---|---|---|
+| bubblewrap | ✅ 已有 | 两个引擎的沙箱 |
+| socat | ✅ 新装 | Claude 沙箱网络代理 |
+| ripgrep | ✅ 新装 | 资料检索、Codex 搜索 |
+| pandoc | ✅ 新装(2.9) | docx 读取与转换 |
+| fonts-noto-cjk | ✅ 新装 | 中文图表、PDF |
+| LibreOffice、poppler-utils | ✅ 已有 | Office 转换、公式重算、PDF 渲染 |
+| `/srv/agent-server/{data,workspaces,knowledge,skills,runtime,logs}` | ✅ 已创建(属主 jerry-remote,0750) | 服务数据目录 |
+| Python venv `/srv/agent-server/runtime/py` | ✅ 已创建 | pandas 2.3、numpy、pyarrow、duckdb 1.5、matplotlib 3.10、seaborn、plotly、openpyxl、xlsxwriter、python-docx、python-pptx、pypdf、pdfplumber、reportlab、markitdown |
+| `/srv/agent-server/runtime/requirements.lock` | ✅ | 锁定的 Python 依赖版本,用于重建环境 |
+| `/srv/agent-server/runtime/matplotlibrc` | ✅ 已验证中文渲染 | 默认字体为 Noto Sans CJK |
+| Node 依赖(docx 等) | ⏳ M1 | 随项目 `package.json` 安装 |
+| 官方技能(xlsx/docx/pdf/pptx) | ⏳ M1 | 核对许可证后放入 `skills/` |
+
+重建 Python 环境:
+```bash
+python3 -m venv /srv/agent-server/runtime/py
+/srv/agent-server/runtime/py/bin/pip install -r /srv/agent-server/runtime/requirements.lock
+```
+
+## 3. 运行方式
+- **开发期**:在项目目录直接 `pnpm dev` 运行,不经过 systemd,用于快速迭代。
+- **常驻运行**:使用 systemd 系统服务,`User=jerry-remote`,加固配置见 06 §5.1。构建产物部署到 `/srv/agent-server/app`(因为 ProtectHome 会隐藏 home 下的项目目录)。
+
+## 4. 对外访问
+- AgentServer 只监听 `127.0.0.1:8787`
+- 前面放 Caddy 或 Nginx 做 HTTPS 和反向代理;需要为 SSE 关闭缓冲(Nginx 用 `proxy_buffering off`,并加大读超时)
+- 只在内网或 VPN(如 Tailscale)中开放
+
+## 5. 运维
+- 日志:pino JSON 输出到 journald;`journalctl -u agent-server`
+- 备份:每天对 `data/agent.db` 做 SQLite 在线备份;CLI 登录态不备份
+- 健康检查:`GET /healthz`(进程存活)、`GET /readyz`(数据库、两个 CLI 的登录状态、app-server 进程、Codex 个人插件是否已禁用的自检结果)
+- 登录过期:后台"引擎状态"页和状态栏显示红色徽标;在终端重新登录即可,服务不需要重启
+- 升级:见 03 §5
+- 定时任务(进程内):工作区清理、用量日汇总、引擎健康检查(每 10 分钟)、资料库可见性复检

+ 56 - 0
docs/design/11-roadmap.md

@@ -0,0 +1,56 @@
+# 11 里程碑与待决事项
+
+## 里程碑
+
+### M1:主链路跑通(Claude)— ✅ 已完成(2026-10-22)
+- monorepo 骨架;shared 类型;SQLite schema 与迁移
+- ClaudeAdapter:流式输出、resume、中断、`canUseTool` 交互(审批 + 提问)、图片输入
+- 会话 / 消息 / SSE(含断线续传)/ 交互 / 文件上传下载 API;API Key 鉴权
+- **文件访问策略**:三层合并、Claude 侧映射(沙箱 + 权限规则 + `canUseTool` 路径校验)、生效预览
+- 安全基线:Claude 沙箱(`failIfUnavailable`、关闭沙箱外重试)、WebFetch 私网拦截、全局强制禁止项
+- **后台设置**:路径、安全、网络、限制、引擎;资料库管理(含可见性检测)
+- 后台:登录、Playground(时间线 + 交互卡片 + 输入框 + 调试面板)、会话回放、引擎状态、API Keys
+- 技能接入(自写 report-conventions;官方技能见 Q5)
+
+**验收**:
+1. 在 Playground 中上传一份 xlsx 和一张截图,让 Claude 分析并生成带中文图表的 docx 报告,过程中触发一次审批,最终能下载报告;重启服务后可以继续该会话。
+2. 让 Agent 读取 `~/.ssh` 和 `~/.claude/.credentials.json`,通过 Read 工具和 Bash 两条路径都被拦截,并写入审计日志。
+3. 在后台新增一个资料库并在 Profile 中启用,Agent 能检索其中的内容;未启用的资料库读取会被拒绝。
+
+### M2:Codex 与客户端包
+- CodexAdapter:app-server 进程管理、个人配置覆盖与自检、协议类型生成、事件映射、审批桥接、权限配置映射
+- MCP Gateway 上线(内置 `web_fetch`、`kb_list_roots`、`ask_user`)
+- Profile 管理(另存为、版本历史)
+- `packages/client` 与 `packages/chat-ui`,在一个上层平台中接入
+- 用量统计、概览页、审计日志页
+- systemd 常驻部署
+
+### M3:完善
+- 资料库全文索引(`kb_search`)
+- 工作区自动清理、回归用例批量重放
+- 网络加强(nftables 按 cgroup 拦截局域网)
+
+### 以后(按需)
+- 内部平台连接器(设计见 08)
+- 专用系统用户(迁移步骤见 06 §5.3)
+- API Key 类模型凭证(开放给他人使用时,见 ADR-0008)
+
+## 待决事项
+
+| # | 问题 | 计划 |
+|---|---|---|
+| Q1 | Codex 权限配置能否按 thread 指定 | M2 实现时核对(03 §3.2) |
+| Q2 | codex app-server 协议的具体方法名与审批回复格式 | M2 用 `generate-ts` 落定后回填 03 §3 |
+| Q3 | 用 `-c` 能否完整禁用个人 config 中的插件和 MCP | M2 启动自检验证;不行时再评估独立 CODEX_HOME 方案(注意不能复制 auth.json) |
+| Q4 | Claude SDK 中 AskUserQuestion 的回答格式 | ✅ 已核对:answers 以问题原文为键(03 §6) |
+| Q5 | 官方 Skills 的许可证是否允许在自托管服务中使用 | 许可证为"受你与 Anthropic 的协议约束",无法自行判定;当前使用自写技能,是否引入由你决定 |
+| Q6 | 本地资料的规模与存放位置 | 决定 M3 索引的优先级;建议放在 home 以外 |
+| Q7 | 运行账号在 docker / lxd 组中(相当于 root) | 建议移出这两个组,或者提前迁移到专用用户(06 §8) |
+
+## M1 交付清单
+
+- 服务端:会话 / 消息 / SSE(断线续传)/ 交互 / 文件 / Profile / 资料库 / 设置 / API Key / 审计 API;Claude 适配器(流式、resume、中断、审批、提问、图片输入、技能、文档转换工具);三层文件访问策略;后台登录
+- 前端包:`@agent-server/client`、`@agent-server/chat-ui`、`@agent-server/ui-tokens`
+- 后台:Playground(模型和权限模式可切换、调试面板、文件预览)、会话回放、概览、Profiles(含访问策略生效预览)、资料库(路径校验)、API Keys、审计、设置(亮/暗主题)
+- 测试:23 个自动化测试(访问策略、API 全流程、LibreOffice 转换),并用真实 Claude 完成了 4 次端到端验收
+- 未包含(按计划在 M2):Codex、MCP Gateway、systemd 常驻部署、用量统计图表

+ 106 - 0
docs/research/claude-vscode-ui-reference.md

@@ -0,0 +1,106 @@
+# Claude VS Code 插件视觉规范提取
+
+- 来源:本机已安装的 `anthropic.claude-code-2.1.296-linux-x64/webview/index.css`(2026-10-22 提取)
+- 用途:作为 Playground / chat-ui 的视觉规范依据。只参考设计令牌与布局,不拷贝源码和资源。
+
+## 1. 令牌体系
+
+插件定义了一层 `--app-*` 语义变量,映射到 VS Code 主题变量 `--vscode-*`。我们在独立网页中没有 `--vscode-*`,所以用 VS Code 默认主题的取值填充(下表的"暗/亮"两列为 Dark Modern / Light Modern 的参考值,实现时以主题文件为准)。
+
+| 语义令牌 | 映射到 | 暗 | 亮 |
+|---|---|---|---|
+| `--app-primary-background` | sideBar.background | `#181818` | `#f8f8f8` |
+| `--app-secondary-background` | editor.background | `#1f1f1f` | `#ffffff` |
+| `--app-primary-foreground` | foreground | `#cccccc` | `#3b3b3b` |
+| `--app-secondary-foreground` | descriptionForeground | `#9d9d9d` | `#717171` |
+| `--app-primary-border-color` | sideBarActivityBarTop.border | `#2b2b2b` | `#e5e5e5` |
+| `--app-input-background` | input.background | `#313131` | `#ffffff` |
+| `--app-input-secondary-background` | menu.background | `#1f1f1f` | `#ffffff` |
+| `--app-input-border` | inlineChatInput / input / widget border | `#3c3c3c` | `#cecece` |
+| `--app-input-active-border` | inputOption.activeBorder | `#2488db` | `#005fb8` |
+| `--app-input-placeholder-foreground` | input.placeholderForeground | `#989898` | `#767676` |
+| `--app-code-background` | textCodeBlock.background | `#7f7f7f26` | `#7f7f7f26` |
+| `--app-inline-code-background` | textPreformat.background | `#7f7f7f26` | `#7f7f7f26` |
+| `--app-link-foreground` | textLink.foreground | `#4daafc` | `#005fb8` |
+| `--app-list-hover-background` | list.hoverBackground | `#2a2d2e` | `#f2f2f2` |
+| `--app-error-foreground` | errorForeground | `#f85149` | `#f85149` |
+
+## 2. 品牌色(插件中硬编码)
+
+| 令牌 | 值 | 用途 |
+|---|---|---|
+| `--app-claude-orange` | `#d97757` | 输入框聚焦边框与光晕 |
+| `--app-claude-clay-button-orange` | `#c6613f` | 发送按钮背景 |
+| `--app-claude-ivory` | `#faf9f5` | 发送按钮图标 |
+| `--app-claude-slate` | `#141413` | — |
+| 成功 | `#74c991` | 时间线圆点、已连接徽标 |
+| 失败 | `#c74e39` | 时间线圆点、失败徽标 |
+| 警告/待处理 | `#e1c08d` | 时间线圆点、需登录徽标 |
+
+## 3. 刻度
+- 间距:`--app-spacing-small/medium/large/xlarge` = 4 / 8 / 12 / 16px
+- 圆角:`--corner-radius-small/medium/large` = 4 / 6 / 8px
+- 等宽字号:编辑器字号(默认 12px),小号 −2px
+- 列表:项内边距 4px 8px,项间距 2px,圆角 4px
+
+## 4. 关键组件规则(摘录)
+
+```css
+/* 时间线:竖线 + 圆点 */
+.timelineMessage        { padding-left: 30px; }
+.timelineMessage::before{ left: 9px; width: 7px; height: 7px; border-radius: 50%;
+                          top: calc(var(--message-padding-top, 8px) + 7px);
+                          background: var(--app-secondary-foreground); }
+.timelineMessage::after { left: 12px; width: 1px; top: 0; bottom: 0;
+                          background: var(--app-primary-border-color); }
+.dotSuccess::before { background: #74c991 } .dotFailure::before { background: #c74e39 }
+.dotWarning::before { background: #e1c08d }
+
+/* 用户消息 */
+.userMessage { border: 1px solid var(--app-input-border); border-radius: 6px;
+               background: var(--app-input-background); padding: 4px 6px; white-space: pre-wrap; }
+
+/* 工具行 */
+.toolNameText           { font-weight: 700; margin-right: 4px; }
+.toolNameTextSecondary  { font-family: monospace; font-size: .85em; }
+.toolBodyRow            { display: grid; grid-template-columns: subgrid;
+                          border-top: .5px solid var(--app-input-border); padding: 4px; }
+.toolBodyRowLabel       { font-family: monospace; opacity: .5; font-size: .85em; }
+.toolBodyRowContent     { max-height: 60px; overflow: hidden;
+                          mask-image: linear-gradient(to bottom, #000 50px, transparent 60px); }
+.toolResult             { background: var(--app-code-background); white-space: pre; overflow-x: auto; }
+
+/* 审批卡片 */
+.permissionRequestContainer { background: var(--app-input-secondary-background);
+                              border: 1px solid var(--app-input-border); border-radius: 8px;
+                              padding: 8px; max-height: 70vh; }
+.permissionRequestContainer:focus-within
+  { border-color: color-mix(in srgb, var(--app-input-active-border) 65%, transparent); }
+
+/* 输入框 */
+.inputContainer { border: 1px solid var(--app-input-border); border-radius: 8px;
+                  box-shadow: 0 1px 2px #0000001a; }
+.inputContainer:focus-within { border-color: #d97757;
+  box-shadow: 0 0 0 3px color-mix(in srgb, #d97757 12%, transparent),
+              0 1px 2px color-mix(in srgb, #d97757, transparent 80%); }
+.messageInput  { padding: 10px 36px 10px 14px; max-height: 200px; line-height: 1.5; }
+.sendButton    { width: 26px; height: 26px; border-radius: 5px;
+                 background: #c6613f; color: #faf9f5; }
+
+/* 思考过程 */
+.thinkingSummary { color: var(--app-secondary-foreground); opacity: .8; font-style: italic; }
+
+/* 状态徽标 */
+.statusBadge { border-radius: 4px; padding: 4px 8px; font-weight: 500; }
+```
+
+主容器:时间线和输入框都限制在 `max-width: 680px` 内居中,输入框距底部和左右各 16px。
+
+## 5. 组件清单(按类名出现频次,反映插件的 UI 构成)
+timelineMessage、userMessageContainer、toolUse/toolResult/toolBodyRow、permissionRequest*、thinkingSummary、inputContainer/messageInput/sendButton、pill(模型/模式选择)、sessionItem(历史会话)、statusBadge、mcpStatus、subagentRow、menuPopup/menuItem、banner、emptyState、previewPane。
+
+## 6. 升级跟踪
+插件升级后可以重新执行提取脚本,比对令牌变化:
+```bash
+grep -oE -- '--app-[a-zA-Z0-9-]+:[^;]+' ~/.vscode/extensions/anthropic.claude-code-*/webview/index.css | sort -u
+```

+ 38 - 0
docs/research/document-analysis-approaches.md

@@ -0,0 +1,38 @@
+# 官方客户端的文档分析方案调研
+
+日期:2026-10-22
+
+## 1. 结论
+官方客户端都不是"单靠 Python",而是三种手段组合:
+1. **模型原生多模态阅读**:图片、PDF 页面直接给模型看(理解版式、图表、截图)
+2. **沙箱代码执行**:计算、清洗、统计、作图、生成文件,以 Python 为主,docx 生成会用 Node
+3. **技能/检索**:用技能文档约束工具用法和产出质量;对大文档做检索,避免整篇塞进上下文
+
+## 2. 各产品
+
+### Claude.ai(Anthropic)
+- **PDF/图片**:模型原生支持 PDF 输入(每页提取文本并配上页面图像)与图片输入
+- **代码执行与文件创建**:在服务端沙箱容器中运行代码(Python 为主,也有 Node),可以读取上传文件、生成 xlsx/docx/pptx/pdf 供下载。更早的 "Analysis tool" 是在浏览器里跑 JavaScript,后来被这一能力取代
+- **官方 Skills**(本机 `~/.claude/skills/synced/` 中可见同款):
+  - `xlsx`:openpyxl(带公式和格式的读写)+ pandas(批量数据)+ markitdown;用 LibreOffice 的 `recalc.py` 重算公式并检查错误;要求写公式而不是写死结果
+  - `pdf`:pypdf(合并、拆分、表单)、pdfplumber(文本和表格提取),提取出的表格交给 pandas
+  - `docx`:docx-js(Node)生成新文档;编辑现有文档时解压后改 XML;读取用 `pandoc -t markdown`;转换和接受修订用 LibreOffice;`pdftoppm` 渲染预览
+  - `pptx`:同类思路
+
+### ChatGPT(OpenAI)
+- **Code Interpreter / 数据分析**:Python 沙箱(有状态的 Jupyter 内核),pandas、matplotlib 等,可以生成文件
+- **File Search**:对上传文档切块做向量检索,回答时引用片段
+- **视觉**:图片、截图由模型直接理解
+
+### Claude Code / Codex CLI
+- 没有托管沙箱,在本机执行同样的工具链
+- Claude Code 的 Read 工具可以直接读取 PDF(按页)、图片、ipynb;Excel 等二进制格式靠 Python
+- Codex 可以查看图片;PDF 等格式依赖本机命令(pdftotext 等)或技能
+- 两者都支持 Agent Skills,可以共用同一份技能目录
+
+## 3. 对本项目的启示
+- Python 是正确的主力,但要配齐:pandas/openpyxl/pdfplumber/python-docx/markitdown + LibreOffice + pandoc + 中文字体
+- 截图、扫描件、图表优先让模型直接看,不要先 OCR
+- 引入官方技能(核对许可证),并补一份自定义"报告规范"技能
+- 大数据源(内部平台查询结果)先落盘为文件,再用代码分析,不直接塞进上下文
+- 大量本地资料需要检索层(FTS 或向量),对应 ChatGPT 的 File Search 思路,排在 M3

+ 15 - 0
package.json

@@ -0,0 +1,15 @@
+{
+  "name": "agent-server",
+  "private": true,
+  "type": "module",
+  "scripts": {
+    "dev": "pnpm -r --parallel --filter ./apps/* dev",
+    "dev:server": "pnpm --filter @agent-server/server dev",
+    "dev:admin": "pnpm --filter @agent-server/admin dev",
+    "build": "pnpm -r build",
+    "typecheck": "pnpm -r typecheck",
+    "test": "pnpm -r test"
+  },
+  "engines": { "node": ">=22" },
+  "packageManager": "pnpm@8.6.11"
+}

+ 28 - 0
packages/chat-ui/package.json

@@ -0,0 +1,28 @@
+{
+  "name": "@agent-server/chat-ui",
+  "version": "0.1.0",
+  "private": true,
+  "type": "module",
+  "exports": {
+    ".": "./src/index.ts"
+  },
+  "scripts": {
+    "typecheck": "vue-tsc --noEmit"
+  },
+  "dependencies": {
+    "@agent-server/client": "workspace:*",
+    "@agent-server/shared": "workspace:*",
+    "@agent-server/ui-tokens": "workspace:*",
+    "@vscode/codicons": "0.0.46-24",
+    "markdown-it": "^15.0.3"
+  },
+  "peerDependencies": {
+    "vue": "^3.5.0"
+  },
+  "devDependencies": {
+    "@types/markdown-it": "^14.2.0",
+    "typescript": "^5.9.3",
+    "vue": "^3.5.44",
+    "vue-tsc": "^3.3.13"
+  }
+}

+ 118 - 0
packages/chat-ui/src/components/AgentChat.vue

@@ -0,0 +1,118 @@
+<script setup lang="ts">
+import { computed, 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 type { ChatSession } from '../store'
+import Composer from './Composer.vue'
+import Timeline from './Timeline.vue'
+
+const props = defineProps<{ client: AgentClient; session: ChatSession; readonly?: boolean; placeholder?: string }>()
+const emit = defineEmits<{ openFile: [file: { fileId: string; path: string; mime: string; size: number }] }>()
+provideClient(props.client)
+
+const state = computed(() => props.session.state)
+const running = computed(() => state.value.runState !== 'idle')
+const scroller = ref<HTMLElement>()
+const composer = ref<InstanceType<typeof Composer>>()
+const stick = ref(true)
+
+function onScroll() {
+  const el = scroller.value
+  if (el) stick.value = el.scrollHeight - el.scrollTop - el.clientHeight < 60
+}
+
+function scrollToBottom(smooth = false) {
+  const el = scroller.value
+  if (el) el.scrollTo({ top: el.scrollHeight, behavior: smooth ? 'smooth' : 'auto' })
+}
+
+// 新内容到达时,只有用户停留在底部附近才自动滚动
+watch(() => [state.value.lastSeq, state.value.items.length], async () => {
+  if (!stick.value) return
+  await nextTick()
+  scrollToBottom()
+})
+watch(() => state.value.loading, async loading => {
+  if (!loading) {
+    await nextTick()
+    scrollToBottom()
+  }
+})
+
+async function onSend(text: string, files: FileDto[]) {
+  stick.value = true
+  try {
+    await props.session.send(text, files)
+  } catch {
+    composer.value?.setText(text) // 失败时把内容放回输入框
+  }
+}
+
+onMounted(() => composer.value?.focus())
+defineExpose({ focus: () => composer.value?.focus(), setText: (t: string) => composer.value?.setText(t) })
+</script>
+
+<template>
+  <div class="agent-chat">
+    <div ref="scroller" class="scroller" @scroll="onScroll">
+      <div class="column">
+        <div v-if="state.loading" class="empty">加载中…</div>
+        <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)"
+        />
+        <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>
+      </div>
+    </div>
+    <button v-if="!stick" type="button" class="to-bottom" @click="stick = true; scrollToBottom(true)">
+      <span class="codicon codicon-arrow-down" />回到最新
+    </button>
+    <div v-if="!readonly" class="composer-wrap">
+      <div v-if="state.error" class="error">{{ state.error }}</div>
+      <div v-if="state.connection === 'reconnecting'" class="conn">连接中断,正在重连…</div>
+      <Composer
+        ref="composer" :running="running" :placeholder="placeholder" :upload="session.upload"
+        @send="onSend" @interrupt="session.interrupt()"
+      >
+        <template #toolbar><slot name="toolbar" /></template>
+      </Composer>
+    </div>
+  </div>
+</template>
+
+<style scoped>
+.agent-chat {
+  position: relative;
+  display: flex;
+  flex-direction: column;
+  height: 100%;
+  min-height: 0;
+  background: var(--app-primary-background);
+}
+.scroller { flex: 1; min-height: 0; overflow-y: auto; }
+.column { max-width: 680px; margin: 0 auto; padding: 16px 16px 24px; }
+.empty { padding: 48px 0; text-align: center; color: var(--app-secondary-foreground); }
+.working { display: flex; align-items: center; gap: 6px; padding: 6px 0 0 30px; color: var(--app-secondary-foreground); font-size: 12px; }
+.composer-wrap { max-width: 680px; width: 100%; margin: 0 auto; padding: 0 16px 16px; }
+.error { margin-bottom: 6px; font-size: 12px; color: var(--app-error-foreground); }
+.conn { margin-bottom: 6px; font-size: 12px; color: var(--app-warning); }
+.to-bottom {
+  position: absolute;
+  left: 50%;
+  bottom: 120px;
+  transform: translateX(-50%);
+  display: inline-flex;
+  align-items: center;
+  gap: 4px;
+  padding: 3px 10px;
+  border: 1px solid var(--app-input-border);
+  border-radius: 999px;
+  background: var(--app-input-secondary-background);
+  box-shadow: var(--app-shadow);
+  cursor: pointer;
+  font-size: 12px;
+}
+</style>

+ 235 - 0
packages/chat-ui/src/components/Composer.vue

@@ -0,0 +1,235 @@
+<script setup lang="ts">
+import { computed, nextTick, onBeforeUnmount, ref } from 'vue'
+import type { FileDto } from '@agent-server/shared'
+import { formatSize } from '../util'
+
+interface PendingFile {
+  key: number
+  name: string
+  size: number
+  preview: string | null
+  status: 'uploading' | 'done' | 'error'
+  error?: string
+  file?: FileDto
+}
+
+const props = defineProps<{
+  running: boolean
+  disabled?: boolean
+  placeholder?: string
+  upload?: (files: File[]) => Promise<FileDto[]>
+}>()
+const emit = defineEmits<{ send: [text: string, files: FileDto[]]; interrupt: [] }>()
+
+const text = ref('')
+const files = ref<PendingFile[]>([])
+const input = ref<HTMLTextAreaElement>()
+const picker = ref<HTMLInputElement>()
+const dragging = ref(false)
+let seq = 0
+
+const uploading = computed(() => files.value.some(f => f.status === 'uploading'))
+const canSend = computed(() => !props.disabled && !props.running && !uploading.value && text.value.trim().length > 0)
+
+function autosize() {
+  const el = input.value
+  if (!el) return
+  el.style.height = 'auto'
+  el.style.height = `${Math.min(el.scrollHeight, 200)}px`
+}
+
+async function addFiles(list: File[]) {
+  if (!props.upload || !list.length) return
+  const pending = list.map(f => ({
+    key: ++seq,
+    name: f.name || 'image.png',
+    size: f.size,
+    preview: f.type.startsWith('image/') ? URL.createObjectURL(f) : null,
+    status: 'uploading' as const,
+  }))
+  files.value.push(...pending)
+  for (const [i, f] of list.entries()) {
+    const target = files.value.find(p => p.key === pending[i]!.key)!
+    try {
+      // 粘贴的截图没有文件名,补一个
+      const named = f.name ? f : new File([f], `截图-${new Date().toISOString().slice(11, 19).replaceAll(':', '')}.png`, { type: f.type })
+      const [saved] = await props.upload([named])
+      Object.assign(target, { status: 'done', file: saved, name: saved?.path.split('/').pop() ?? target.name })
+    } catch (err) {
+      Object.assign(target, { status: 'error', error: (err as Error).message })
+    }
+  }
+}
+
+function removeFile(key: number) {
+  const f = files.value.find(p => p.key === key)
+  if (f?.preview) URL.revokeObjectURL(f.preview)
+  files.value = files.value.filter(p => p.key !== key)
+}
+
+function submit() {
+  if (!canSend.value) return
+  const done = files.value.filter(f => f.status === 'done' && f.file).map(f => f.file!)
+  emit('send', text.value.trim(), done)
+  text.value = ''
+  for (const f of files.value) if (f.preview) URL.revokeObjectURL(f.preview)
+  files.value = []
+  void nextTick(autosize)
+}
+
+function onKeydown(e: KeyboardEvent) {
+  // 输入法组字过程中的回车不发送
+  if (e.key === 'Enter' && !e.shiftKey && !e.isComposing && e.keyCode !== 229) {
+    e.preventDefault()
+    submit()
+  } else if (e.key === 'Escape' && props.running) {
+    emit('interrupt')
+  }
+}
+
+function onPaste(e: ClipboardEvent) {
+  const list = [...(e.clipboardData?.files ?? [])]
+  if (list.length) {
+    e.preventDefault()
+    void addFiles(list)
+  }
+}
+
+function onDrop(e: DragEvent) {
+  dragging.value = false
+  const list = [...(e.dataTransfer?.files ?? [])]
+  if (list.length) void addFiles(list)
+}
+
+function onPick(e: Event) {
+  const el = e.target as HTMLInputElement
+  void addFiles([...(el.files ?? [])])
+  el.value = ''
+}
+
+onBeforeUnmount(() => {
+  for (const f of files.value) if (f.preview) URL.revokeObjectURL(f.preview)
+})
+
+defineExpose({ focus: () => input.value?.focus(), setText: (t: string) => { text.value = t; void nextTick(autosize) } })
+</script>
+
+<template>
+  <div
+    class="composer" :class="{ dragging }"
+    @dragover.prevent="dragging = !!upload" @dragleave="dragging = false" @drop.prevent="onDrop"
+  >
+    <div v-if="files.length" class="attachments">
+      <div v-for="f in files" :key="f.key" class="att" :class="f.status" :title="f.error ?? f.name">
+        <img v-if="f.preview" :src="f.preview" alt="">
+        <span v-else class="codicon codicon-file" />
+        <span class="att-name">{{ f.name }}</span>
+        <span class="att-size">{{ f.status === 'uploading' ? '上传中…' : f.status === 'error' ? '失败' : formatSize(f.size) }}</span>
+        <button type="button" class="att-x" aria-label="移除" @click="removeFile(f.key)"><span class="codicon codicon-close" /></button>
+      </div>
+    </div>
+    <textarea
+      ref="input" v-model="text" class="input" rows="1" aria-label="消息"
+      :placeholder="placeholder ?? '描述你想分析或查找的内容…(Enter 发送,Shift+Enter 换行,可粘贴截图)'"
+      :disabled="disabled" @input="autosize" @keydown="onKeydown" @paste="onPaste"
+    />
+    <div class="footer">
+      <div class="tools">
+        <button v-if="upload" type="button" class="icon-btn" title="添加文件或图片" @click="picker?.click()">
+          <span class="codicon codicon-attach" />
+        </button>
+        <input ref="picker" type="file" multiple hidden @change="onPick">
+        <slot name="toolbar" />
+      </div>
+      <button v-if="running" type="button" class="send stop" title="中断(Esc)" @click="emit('interrupt')">
+        <span class="codicon codicon-debug-stop" />
+      </button>
+      <button v-else type="button" class="send" title="发送(Enter)" :disabled="!canSend" @click="submit">
+        <span class="codicon codicon-arrow-up" />
+      </button>
+    </div>
+  </div>
+</template>
+
+<style scoped>
+.composer {
+  position: relative;
+  display: flex;
+  flex-direction: column;
+  border: 1px solid var(--app-input-border);
+  border-radius: var(--corner-radius-large);
+  background: var(--app-input-background);
+  box-shadow: var(--app-shadow);
+  transition: border-color 0.1s, box-shadow 0.1s;
+}
+.composer:focus-within {
+  border-color: var(--app-claude-orange);
+  box-shadow: 0 0 0 3px color-mix(in srgb, var(--app-claude-orange) 12%, transparent), 0 1px 2px color-mix(in srgb, var(--app-claude-orange), transparent 80%);
+}
+.composer.dragging { border-style: dashed; border-color: var(--app-claude-orange); }
+.input {
+  display: block;
+  width: 100%;
+  min-height: 1.5em;
+  max-height: 200px;
+  padding: 10px 14px 4px;
+  border: none;
+  outline: none;
+  resize: none;
+  background: transparent;
+  line-height: 1.5;
+  overflow-y: auto;
+}
+.input::placeholder { color: var(--app-input-placeholder-foreground); }
+.footer { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 4px 6px 6px 8px; }
+.tools { display: flex; align-items: center; gap: 4px; min-width: 0; flex-wrap: wrap; }
+.icon-btn, .att-x {
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  width: 26px;
+  height: 26px;
+  border: none;
+  border-radius: 5px;
+  background: transparent;
+  color: var(--app-secondary-foreground);
+  cursor: pointer;
+}
+.icon-btn:hover, .att-x:hover { background: var(--app-list-hover-background); color: var(--app-primary-foreground); }
+.send {
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  width: 26px;
+  height: 26px;
+  flex: none;
+  border: none;
+  border-radius: 5px;
+  background: var(--app-claude-clay);
+  color: var(--app-claude-ivory);
+  cursor: pointer;
+}
+.send:hover:not(:disabled) { filter: brightness(1.1); }
+.send:active:not(:disabled) { filter: brightness(0.9); }
+.send:disabled { opacity: 0.4; cursor: not-allowed; }
+.send.stop { background: var(--app-secondary-foreground); }
+.attachments { display: flex; flex-wrap: wrap; gap: 6px; padding: 8px 10px 0; }
+.att {
+  display: flex;
+  align-items: center;
+  gap: 6px;
+  max-width: 240px;
+  height: 30px;
+  padding: 0 2px 0 6px;
+  border: 1px solid var(--app-input-border);
+  border-radius: var(--corner-radius-medium);
+  background: var(--app-input-secondary-background);
+  font-size: 12px;
+}
+.att img { width: 22px; height: 22px; object-fit: cover; border-radius: 3px; }
+.att.error { border-color: var(--app-danger); }
+.att.uploading { opacity: 0.7; }
+.att-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
+.att-size { color: var(--app-secondary-foreground); flex: none; }
+.att-x { width: 20px; height: 20px; }
+</style>

Некоторые файлы не были показаны из-за большого количества измененных файлов