Explorar o código

feat: Profile 版本历史、复制与另存为;M2 完成

- 新增 profile_versions:创建和内容变化时保存快照(只改"是否默认"不产生新版本),迁移为现有 Profile 补齐当前版本
- 后台 Profiles:历史版本列表(显示与上一版相比改动的字段)、恢复(保存为新版本,历史只追加)、复制为新 Profile
- Playground:另存为 Profile(所用 Profile + 当前模型、权限模式、追加提示词)
- 文档:数据模型、路线图(M2 完成)、文档索引状态

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Jerry Liang hai 1 semana
pai
achega
9830dfdbb9

+ 3 - 1
apps/admin/src/lib/api.ts

@@ -1,7 +1,7 @@
 // 后台 API:同源 Cookie 鉴权,复用 AgentClient 的请求与错误处理
 // 后台 API:同源 Cookie 鉴权,复用 AgentClient 的请求与错误处理
 import { AgentClient } from '@agent-server/client'
 import { AgentClient } from '@agent-server/client'
 import type {
 import type {
-  ApiKeyDto, ApiKeyProfiles, AppSettings, CodexPluginCatalogDto, CodexPluginDetailDto, CodexPluginInstallResultDto, CodexSkillCatalogDto, CommunitySearchResultDto, CommunitySkillDto, CommunitySkillPreviewDto, DeepPartial, FileAccessPolicy, KnowledgeRootDto, PathCheck, PluginAction, PluginStateDto, ProfileDto, ProfileInput,
+  ApiKeyDto, ApiKeyProfiles, AppSettings, CodexPluginCatalogDto, CodexPluginDetailDto, CodexPluginInstallResultDto, CodexSkillCatalogDto, CommunitySearchResultDto, CommunitySkillDto, CommunitySkillPreviewDto, DeepPartial, FileAccessPolicy, KnowledgeRootDto, PathCheck, PluginAction, PluginStateDto, ProfileDto, ProfileInput, ProfileVersionDto,
   ProviderStatusDto, RecentErrorDto, ResolvedAccessDto, SkillInfo, UsageSummaryDto,
   ProviderStatusDto, RecentErrorDto, ResolvedAccessDto, SkillInfo, UsageSummaryDto,
 } from '@agent-server/shared'
 } from '@agent-server/shared'
 
 
@@ -63,6 +63,8 @@ export const admin = {
   createProfile: (p: ProfileInput) => req<ProfileDto>('POST', '/profiles', p),
   createProfile: (p: ProfileInput) => req<ProfileDto>('POST', '/profiles', p),
   updateProfile: (id: string, p: ProfileInput) => req<ProfileDto>('PUT', `/profiles/${id}`, p),
   updateProfile: (id: string, p: ProfileInput) => req<ProfileDto>('PUT', `/profiles/${id}`, p),
   deleteProfile: (id: string) => req<void>('DELETE', `/profiles/${id}`),
   deleteProfile: (id: string) => req<void>('DELETE', `/profiles/${id}`),
+  profileVersions: (id: string) => req<ProfileVersionDto[]>('GET', `/profiles/${id}/versions`),
+  restoreProfile: (id: string, version: number) => req<ProfileDto>('POST', `/profiles/${id}/versions/${version}/restore`),
   previewAccess: (fileAccess: FileAccessPolicy, knowledgeRootIds: string[]) => req<ResolvedAccessDto>('POST', '/access/preview', { fileAccess, knowledgeRootIds }),
   previewAccess: (fileAccess: FileAccessPolicy, knowledgeRootIds: string[]) => req<ResolvedAccessDto>('POST', '/access/preview', { fileAccess, knowledgeRootIds }),
   knowledge: () => req<KnowledgeRootDto[]>('GET', '/knowledge'),
   knowledge: () => req<KnowledgeRootDto[]>('GET', '/knowledge'),
   createKnowledge: (k: KnowledgeInput) => req<KnowledgeRootDto>('POST', '/knowledge', k),
   createKnowledge: (k: KnowledgeInput) => req<KnowledgeRootDto>('POST', '/knowledge', k),

+ 23 - 1
apps/admin/src/views/PlaygroundView.vue

@@ -6,7 +6,7 @@ import type { ProfileSummary } from '@agent-server/client'
 import type { PermissionMode, ProviderStatusDto, SessionDetailDto, SessionDto } from '@agent-server/shared'
 import type { PermissionMode, ProviderStatusDto, SessionDetailDto, SessionDto } from '@agent-server/shared'
 import DebugPanel from '../components/DebugPanel.vue'
 import DebugPanel from '../components/DebugPanel.vue'
 import SidePane from '../components/SidePane.vue'
 import SidePane from '../components/SidePane.vue'
-import { client, errorMessage } from '../lib/api'
+import { admin, client, errorMessage } from '../lib/api'
 import { PERMISSION_LABELS, relativeTime, toast } from '../lib/ui'
 import { PERMISSION_LABELS, relativeTime, toast } from '../lib/ui'
 
 
 const route = useRoute()
 const route = useRoute()
@@ -26,6 +26,27 @@ const titleDraft = ref('')
 const showPrompt = ref(false)
 const showPrompt = ref(false)
 const promptDraft = ref('')
 const promptDraft = ref('')
 
 
+/** 把本会话的配置(所用 Profile + 当前模型、权限模式、追加提示词)另存为新的 Profile */
+async function saveAsProfile() {
+  const d = detail.value
+  if (!d) return
+  try {
+    const base = (await admin.profiles()).find(p => p.id === d.profileId)
+    if (!base) throw new Error('本会话所用的 Profile 已不存在')
+    const name = window.prompt('新 Profile 的名称', `${base.name}(${d.model})`)?.trim()
+    if (!name) return
+    const { id: _id, version: _v, updatedAt: _u, builtin: _b, ...input } = base
+    const created = await admin.createProfile({
+      ...input, name, isDefault: false, model: d.model, permissionMode: d.permissionMode,
+      systemPrompt: [base.systemPrompt, d.systemPromptAppend].filter(s => s.trim()).join('\n\n'),
+    })
+    toast(`已保存为 Profile「${created.name}」`, 'success')
+    void router.push(`/profiles/${created.id}`)
+  } catch (err) {
+    toast(errorMessage(err), 'error')
+  }
+}
+
 const started = computed(() => (chat.value?.state.items.length ?? 0) > 0)
 const started = computed(() => (chat.value?.state.items.length ?? 0) > 0)
 const idle = computed(() => chat.value?.state.runState === 'idle')
 const idle = computed(() => chat.value?.state.runState === 'idle')
 const models = computed(() => providers.value.find(p => p.id === detail.value?.provider)?.models ?? [])
 const models = computed(() => providers.value.find(p => p.id === detail.value?.provider)?.models ?? [])
@@ -190,6 +211,7 @@ onBeforeUnmount(() => {
         </button>
         </button>
         <span class="flex-1" />
         <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" title="会话追加提示词" @click="openPrompt"><span class="codicon codicon-note" /></button>
+        <button v-if="detail?.profileId" type="button" class="icon-btn" title="另存为 Profile(所用 Profile + 当前模型、权限模式、追加提示词)" @click="saveAsProfile"><span class="codicon codicon-save-as" /></button>
         <button type="button" class="icon-btn" :class="{ '!text-fg': showDebug }" title="调试面板" @click="showDebug = !showDebug">
         <button type="button" class="icon-btn" :class="{ '!text-fg': showDebug }" title="调试面板" @click="showDebug = !showDebug">
           <span class="codicon codicon-layout-sidebar-right" />
           <span class="codicon codicon-layout-sidebar-right" />
         </button>
         </button>

+ 61 - 4
apps/admin/src/views/ProfilesView.vue

@@ -1,11 +1,11 @@
 <script setup lang="ts">
 <script setup lang="ts">
 import { computed, onMounted, ref, watch } from 'vue'
 import { computed, onMounted, ref, watch } from 'vue'
 import { useRoute, useRouter } from 'vue-router'
 import { useRoute, useRouter } from 'vue-router'
-import type { KnowledgeRootDto, PathRule, ProfileDto, ProfileInput, ProviderStatusDto, ResolvedAccessDto, SkillInfo } from '@agent-server/shared'
+import type { KnowledgeRootDto, PathRule, ProfileDto, ProfileInput, ProfileVersionDto, ProviderStatusDto, ResolvedAccessDto, SkillInfo } from '@agent-server/shared'
 import PageHeader from '../components/PageHeader.vue'
 import PageHeader from '../components/PageHeader.vue'
 import SidePane from '../components/SidePane.vue'
 import SidePane from '../components/SidePane.vue'
 import { admin, client, errorMessage } from '../lib/api'
 import { admin, client, errorMessage } from '../lib/api'
-import { PERMISSION_LABELS, clone, toast } from '../lib/ui'
+import { PERMISSION_LABELS, clone, formatTime, toast } from '../lib/ui'
 
 
 const route = useRoute()
 const route = useRoute()
 const router = useRouter()
 const router = useRouter()
@@ -39,9 +39,20 @@ async function load() {
   ])
   ])
 }
 }
 
 
-// 切换选中项或列表刷新后重置草稿;新建时只在进入页面时生成空白草稿
+// "复制为新 Profile":带着当前内容进入新建页
+let copySource: ProfileInput | null = null
+function duplicate() {
+  if (!draft.value) return
+  copySource = { ...clone(draft.value), name: `${draft.value.name}(副本)`, isDefault: false }
+  void router.push('/profiles/new')
+}
+
+// 切换选中项或列表刷新后重置草稿;新建时只在进入页面时生成空白草稿(或复制来源)
 const resetDraft = () => {
 const resetDraft = () => {
-  if (isNew.value) draft.value ??= blank()
+  if (isNew.value) {
+    draft.value ??= copySource ?? blank()
+    copySource = null
+  }
   else draft.value = current.value ? toInput(current.value) : null
   else draft.value = current.value ? toInput(current.value) : null
 }
 }
 watch(id, () => {
 watch(id, () => {
@@ -103,6 +114,28 @@ async function save() {
   }
   }
 }
 }
 
 
+// 历史版本
+const FIELD_LABELS: Record<string, string> = {
+  name: '名称', description: '说明', provider: '引擎', model: '模型', systemPrompt: '系统提示词', permissionMode: '权限模式',
+  allowedTools: '允许的工具', disallowedTools: '禁用的工具', skills: '技能', fileAccess: '文件访问策略', knowledgeRootIds: '资料库',
+}
+const versions = ref<ProfileVersionDto[] | null>(null)
+async function openVersions() {
+  if (!current.value) return
+  versions.value = await admin.profileVersions(current.value.id).catch(err => (toast(errorMessage(err), 'error'), null))
+}
+async function restore(v: ProfileVersionDto) {
+  if (!current.value || !confirm(`恢复到版本 ${v.version}?当前内容会保留在历史中,恢复结果保存为新版本。`)) return
+  try {
+    const p = await admin.restoreProfile(current.value.id, v.version)
+    toast(`已恢复,保存为版本 ${p.version}`, 'success')
+    versions.value = null
+    await load()
+  } catch (err) {
+    toast(errorMessage(err), 'error')
+  }
+}
+
 async function remove() {
 async function remove() {
   if (!current.value || !confirm(`删除 Profile「${current.value.name}」?`)) return
   if (!current.value || !confirm(`删除 Profile「${current.value.name}」?`)) return
   try {
   try {
@@ -135,6 +168,8 @@ onMounted(() => load().catch(err => toast(errorMessage(err), 'error')))
     <div v-if="!draft" class="flex-1 flex items-center justify-center hint">选择或新建一个 Profile</div>
     <div v-if="!draft" class="flex-1 flex items-center justify-center hint">选择或新建一个 Profile</div>
     <template v-else>
     <template v-else>
       <PageHeader :title="isNew ? '新建 Profile' : draft.name" :subtitle="current ? `版本 ${current.version}` : undefined">
       <PageHeader :title="isNew ? '新建 Profile' : draft.name" :subtitle="current ? `版本 ${current.version}` : undefined">
+        <button v-if="current" type="button" class="btn-ghost" @click="openVersions"><span class="codicon codicon-history" />历史版本</button>
+        <button v-if="current" type="button" class="btn-ghost" @click="duplicate"><span class="codicon codicon-copy" />复制为新 Profile</button>
         <button v-if="current && !current.isDefault && !current.builtin" type="button" class="btn-danger" @click="remove">删除</button>
         <button v-if="current && !current.isDefault && !current.builtin" type="button" class="btn-danger" @click="remove">删除</button>
         <button type="button" class="btn-primary" :disabled="saving || !draft.name.trim()" @click="save">{{ saving ? '保存中…' : '保存' }}</button>
         <button type="button" class="btn-primary" :disabled="saving || !draft.name.trim()" @click="save">{{ saving ? '保存中…' : '保存' }}</button>
       </PageHeader>
       </PageHeader>
@@ -247,5 +282,27 @@ onMounted(() => load().catch(err => toast(errorMessage(err), 'error')))
         </div>
         </div>
       </div>
       </div>
     </template>
     </template>
+  
+    <!-- 历史版本 -->
+    <div v-if="versions && current" class="fixed inset-0 z-40 flex items-center justify-center bg-[var(--app-overlay)]" @click.self="versions = null">
+      <div class="w-[min(560px,92vw)] max-h-[80vh] overflow-y-auto card bg-input-alt p-5 flex flex-col gap-3 shadow-xl text-[12.5px]">
+        <div class="flex items-center gap-2">
+          <span class="font-600 text-[14px] flex-1">「{{ current.name }}」的历史版本</span>
+          <button type="button" class="icon-btn" title="关闭" @click="versions = null"><span class="codicon codicon-close" /></button>
+        </div>
+        <div v-for="v in versions" :key="v.version" class="flex items-start gap-3 py-2 border-t border-t-solid border-border first:border-t-0">
+          <div class="flex-1 min-w-0">
+            <div class="flex items-center gap-2">
+              <span class="font-600">版本 {{ v.version }}</span>
+              <span v-if="v.version === current.version" class="badge bg-code text-accent">当前</span>
+              <span class="hint">{{ formatTime(v.createdAt) }}</span>
+            </div>
+            <div class="hint">{{ v.changed.length ? `改动:${v.changed.map(k => FIELD_LABELS[k] ?? k).join('、')}` : v.version === Math.min(...versions.map(x => x.version)) ? '最早的版本' : '无改动' }}</div>
+            <div class="hint truncate">{{ v.snapshot.provider }} / {{ v.snapshot.model }} · {{ PERMISSION_LABELS[v.snapshot.permissionMode] }}<template v-if="v.snapshot.systemPrompt"> · {{ v.snapshot.systemPrompt.slice(0, 60) }}</template></div>
+          </div>
+          <button v-if="v.version !== current.version" type="button" class="btn-secondary flex-none" @click="restore(v)">恢复</button>
+        </div>
+      </div>
+    </div>
   </SidePane>
   </SidePane>
 </template>
 </template>

+ 16 - 0
apps/server/drizzle/0003_profile_versions.sql

@@ -0,0 +1,16 @@
+CREATE TABLE `profile_versions` (
+	`profile_id` text NOT NULL,
+	`version` integer NOT NULL,
+	`snapshot` text NOT NULL,
+	`created_at` text DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) NOT NULL,
+	PRIMARY KEY(`profile_id`, `version`)
+);
+--> statement-breakpoint
+-- 数据迁移:为现有 Profile 保存当前版本的快照
+INSERT INTO `profile_versions` (`profile_id`, `version`, `snapshot`, `created_at`)
+SELECT `id`, `version`, json_object(
+  'name', `name`, 'description', `description`, 'provider', `provider`, 'model', `model`, 'systemPrompt', `system_prompt`,
+  'permissionMode', `permission_mode`, 'allowedTools', json(`allowed_tools`), 'disallowedTools', json(`disallowed_tools`),
+  'skills', json(`skills`), 'fileAccess', json(`file_access`), 'knowledgeRootIds', json(`knowledge_root_ids`)
+), `updated_at`
+FROM `profiles`;

+ 1073 - 0
apps/server/drizzle/meta/0003_snapshot.json

@@ -0,0 +1,1073 @@
+{
+  "version": "6",
+  "dialect": "sqlite",
+  "id": "23a4fdb9-89c3-4826-a763-de1fc9ab358a",
+  "prevId": "9f4fe72a-3a23-43e5-94ac-0969150e5f3d",
+  "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
+        },
+        "allowed_profile_ids": {
+          "name": "allowed_profile_ids",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'[]'"
+        },
+        "default_profile_id": {
+          "name": "default_profile_id",
+          "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'))"
+        },
+        "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": {}
+    },
+    "profile_versions": {
+      "name": "profile_versions",
+      "columns": {
+        "profile_id": {
+          "name": "profile_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "version": {
+          "name": "version",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "snapshot": {
+          "name": "snapshot",
+          "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'))"
+        }
+      },
+      "indexes": {},
+      "foreignKeys": {},
+      "compositePrimaryKeys": {
+        "profile_versions_profile_id_version_pk": {
+          "columns": [
+            "profile_id",
+            "version"
+          ],
+          "name": "profile_versions_profile_id_version_pk"
+        }
+      },
+      "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
+        },
+        "builtin": {
+          "name": "builtin",
+          "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": false,
+          "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": {}
+    },
+    "usage_daily": {
+      "name": "usage_daily",
+      "columns": {
+        "date": {
+          "name": "date",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "source": {
+          "name": "source",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "provider": {
+          "name": "provider",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "model": {
+          "name": "model",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "runs": {
+          "name": "runs",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": 0
+        },
+        "failed": {
+          "name": "failed",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": 0
+        },
+        "interrupted": {
+          "name": "interrupted",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": 0
+        },
+        "input_tokens": {
+          "name": "input_tokens",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": 0
+        },
+        "output_tokens": {
+          "name": "output_tokens",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": 0
+        },
+        "cache_read_tokens": {
+          "name": "cache_read_tokens",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": 0
+        },
+        "cache_write_tokens": {
+          "name": "cache_write_tokens",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": 0
+        },
+        "cost_usd": {
+          "name": "cost_usd",
+          "type": "real",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": 0
+        },
+        "duration_ms": {
+          "name": "duration_ms",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": 0
+        }
+      },
+      "indexes": {},
+      "foreignKeys": {},
+      "compositePrimaryKeys": {
+        "usage_daily_date_source_provider_model_pk": {
+          "columns": [
+            "date",
+            "source",
+            "provider",
+            "model"
+          ],
+          "name": "usage_daily_date_source_provider_model_pk"
+        }
+      },
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    }
+  },
+  "views": {},
+  "enums": {},
+  "_meta": {
+    "schemas": {},
+    "tables": {},
+    "columns": {}
+  },
+  "internal": {
+    "indexes": {}
+  }
+}

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

@@ -22,6 +22,13 @@
       "when": 1792758196040,
       "when": 1792758196040,
       "tag": "0002_usage_daily",
       "tag": "0002_usage_daily",
       "breakpoints": true
       "breakpoints": true
+    },
+    {
+      "idx": 3,
+      "version": "6",
+      "when": 1792759407952,
+      "tag": "0003_profile_versions",
+      "breakpoints": true
     }
     }
   ]
   ]
 }
 }

+ 9 - 1
apps/server/src/db/schema.ts

@@ -1,6 +1,6 @@
 import { sql } from 'drizzle-orm'
 import { sql } from 'drizzle-orm'
 import { index, integer, primaryKey, real, sqliteTable, text } from 'drizzle-orm/sqlite-core'
 import { index, integer, primaryKey, real, sqliteTable, text } from 'drizzle-orm/sqlite-core'
-import type { FileAccessPolicy, InteractionRequest, InteractionResponse, PermissionMode } from '@agent-server/shared'
+import type { FileAccessPolicy, InteractionRequest, InteractionResponse, PermissionMode, ProfileSnapshot } from '@agent-server/shared'
 
 
 const now = sql`(strftime('%Y-%m-%dT%H:%M:%fZ','now'))`
 const now = sql`(strftime('%Y-%m-%dT%H:%M:%fZ','now'))`
 const createdAt = () => text('created_at').notNull().default(now)
 const createdAt = () => text('created_at').notNull().default(now)
@@ -159,3 +159,11 @@ export const usageDaily = sqliteTable('usage_daily', {
   costUsd: real('cost_usd').notNull().default(0),
   costUsd: real('cost_usd').notNull().default(0),
   durationMs: integer('duration_ms').notNull().default(0),
   durationMs: integer('duration_ms').notNull().default(0),
 }, t => [primaryKey({ columns: [t.date, t.source, t.provider, t.model] })])
 }, t => [primaryKey({ columns: [t.date, t.source, t.provider, t.model] })])
+
+/** Profile 的历史版本:每次内容变化保存一份快照(不含"是否默认"),可以查看和恢复 */
+export const profileVersions = sqliteTable('profile_versions', {
+  profileId: text('profile_id').notNull(),
+  version: integer('version').notNull(),
+  snapshot: text('snapshot', { mode: 'json' }).$type<ProfileSnapshot>().notNull(),
+  createdAt: createdAt(),
+}, t => [primaryKey({ columns: [t.profileId, t.version] })])

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

@@ -188,6 +188,12 @@ export default async function adminRoutes(f: FastifyInstance, { app }: { app: Ap
       app.audit.log({ actor: 'admin', kind: 'admin', summary: `修改 Profile:${p.name}`, detail: { version: p.version } })
       app.audit.log({ actor: 'admin', kind: 'admin', summary: `修改 Profile:${p.name}`, detail: { version: p.version } })
       return p
       return p
     })
     })
+    g.get<IdParams>('/profiles/:id/versions', async req => app.profiles.versions(req.params.id))
+    g.post<{ Params: { id: string; version: string } }>('/profiles/:id/versions/:version/restore', async req => {
+      const p = app.profiles.restore(req.params.id, Number(req.params.version))
+      app.audit.log({ actor: 'admin', kind: 'admin', summary: `恢复 Profile:${p.name} → 版本 ${req.params.version}(保存为版本 ${p.version})` })
+      return p
+    })
     g.delete<IdParams>('/profiles/:id', async (req, reply) => {
     g.delete<IdParams>('/profiles/:id', async (req, reply) => {
       const refs = app.apiKeys.referencing(req.params.id)
       const refs = app.apiKeys.referencing(req.params.id)
       if (refs.length) throw new AppError(400, 'profile_in_use', `这个 Profile 正被 API Key 使用:${refs.join('、')},请先在 API Keys 页面取消勾选`)
       if (refs.length) throw new AppError(400, 'profile_in_use', `这个 Profile 正被 API Key 使用:${refs.join('、')},请先在 API Keys 页面取消勾选`)

+ 41 - 4
apps/server/src/services/catalog.ts

@@ -2,9 +2,9 @@
 import crypto from 'node:crypto'
 import crypto from 'node:crypto'
 import { and, desc, eq, ne } from 'drizzle-orm'
 import { and, desc, eq, ne } from 'drizzle-orm'
 import { z } from 'zod'
 import { z } from 'zod'
-import type { ApiKeyDto, ApiKeyProfiles, FileAccessPolicy, KnowledgeRootDto, ProfileDto, ProfileInput, ProviderId } from '@agent-server/shared'
+import type { ApiKeyDto, ApiKeyProfiles, FileAccessPolicy, KnowledgeRootDto, ProfileDto, ProfileInput, ProfileSnapshot, ProfileVersionDto, ProviderId } from '@agent-server/shared'
 import type { DB } from '../db'
 import type { DB } from '../db'
-import { apiKeys, auditLogs, knowledgeRoots, profiles } from '../db/schema'
+import { apiKeys, auditLogs, knowledgeRoots, profiles, profileVersions } from '../db/schema'
 import { AppError, badRequest, notFound } from '../lib/errors'
 import { AppError, badRequest, notFound } from '../lib/errors'
 import { newId, nowIso } from '../lib/ids'
 import { newId, nowIso } from '../lib/ids'
 import { checkKnowledgePath } from './access-policy'
 import { checkKnowledgePath } from './access-policy'
@@ -41,6 +41,16 @@ const toProfileDto = (r: ProfileRow): ProfileDto => ({
   fileAccess: r.fileAccess, knowledgeRootIds: r.knowledgeRootIds, isDefault: r.isDefault, builtin: r.builtin, version: r.version, updatedAt: r.updatedAt,
   fileAccess: r.fileAccess, knowledgeRootIds: r.knowledgeRootIds, isDefault: r.isDefault, builtin: r.builtin, version: r.version, updatedAt: r.updatedAt,
 })
 })
 
 
+const SNAPSHOT_KEYS = ['name', 'description', 'provider', 'model', 'systemPrompt', 'permissionMode', 'allowedTools', 'disallowedTools', 'skills', 'fileAccess', 'knowledgeRootIds'] as const satisfies readonly (keyof ProfileSnapshot)[]
+
+function snapshotOf(p: ProfileSnapshot): ProfileSnapshot {
+  return Object.fromEntries(SNAPSHOT_KEYS.map(k => [k, p[k]])) as ProfileSnapshot
+}
+
+function changedFields(a: ProfileSnapshot, b: ProfileSnapshot): (keyof ProfileSnapshot)[] {
+  return SNAPSHOT_KEYS.filter(k => JSON.stringify(a[k]) !== JSON.stringify(b[k]))
+}
+
 /** 内置的标准 Profile:每个引擎一个"通用助手",不可删除,引擎不可修改 */
 /** 内置的标准 Profile:每个引擎一个"通用助手",不可删除,引擎不可修改 */
 const BUILTIN_NAMES: Record<ProviderId, string> = { claude: '通用助手(Claude)', codex: '通用助手(Codex)' }
 const BUILTIN_NAMES: Record<ProviderId, string> = { claude: '通用助手(Claude)', codex: '通用助手(Codex)' }
 const FALLBACK_MODELS: Record<ProviderId, string> = { claude: 'sonnet', codex: 'gpt-6.1-sol' }
 const FALLBACK_MODELS: Record<ProviderId, string> = { claude: 'sonnet', codex: 'gpt-6.1-sol' }
@@ -102,22 +112,46 @@ export class ProfileService {
     this.db.transaction(tx => {
     this.db.transaction(tx => {
       if (data.isDefault) tx.update(profiles).set({ isDefault: false }).run()
       if (data.isDefault) tx.update(profiles).set({ isDefault: false }).run()
       tx.insert(profiles).values({ id, ...data, builtin }).run()
       tx.insert(profiles).values({ id, ...data, builtin }).run()
+      tx.insert(profileVersions).values({ profileId: id, version: 1, snapshot: snapshotOf(data) }).run()
     })
     })
     return this.get(id)
     return this.get(id)
   }
   }
 
 
+  /** 保存修改:内容有变化时版本号加一并保存快照;只改"是否默认"不产生新版本 */
   update(id: string, input: ProfileInput): ProfileDto {
   update(id: string, input: ProfileInput): ProfileDto {
     const prev = this.get(id)
     const prev = this.get(id)
     const data = parse(profileInputSchema, input)
     const data = parse(profileInputSchema, input)
     if (prev.isDefault && !data.isDefault) throw badRequest('请先把另一个 Profile 设为默认')
     if (prev.isDefault && !data.isDefault) throw badRequest('请先把另一个 Profile 设为默认')
     if (prev.builtin && data.provider !== prev.provider) throw badRequest('内置 Profile 的引擎不能修改')
     if (prev.builtin && data.provider !== prev.provider) throw badRequest('内置 Profile 的引擎不能修改')
+    const snapshot = snapshotOf(data)
+    const changed = changedFields(snapshotOf(prev), snapshot).length > 0
+    const version = changed ? prev.version + 1 : prev.version
     this.db.transaction(tx => {
     this.db.transaction(tx => {
       if (data.isDefault) tx.update(profiles).set({ isDefault: false }).where(ne(profiles.id, id)).run()
       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()
+      tx.update(profiles).set({ ...data, version, updatedAt: nowIso() }).where(eq(profiles.id, id)).run()
+      if (changed) tx.insert(profileVersions).values({ profileId: id, version, snapshot }).run()
     })
     })
     return this.get(id)
     return this.get(id)
   }
   }
 
 
+  /** 历史版本(新的在前),附带与上一个版本相比改动的字段 */
+  versions(id: string): ProfileVersionDto[] {
+    this.get(id)
+    const rows = this.db.select().from(profileVersions).where(eq(profileVersions.profileId, id)).orderBy(profileVersions.version).all()
+    return rows.map((r, i) => ({
+      version: r.version, createdAt: r.createdAt, snapshot: r.snapshot,
+      changed: i === 0 ? [] : changedFields(rows[i - 1]!.snapshot, r.snapshot),
+    })).reverse()
+  }
+
+  /** 恢复到某个历史版本:以该版本的内容保存为一个新版本(历史只追加) */
+  restore(id: string, version: number): ProfileDto {
+    const current = this.get(id)
+    const row = this.db.select().from(profileVersions).where(and(eq(profileVersions.profileId, id), eq(profileVersions.version, version))).get()
+    if (!row) throw notFound(`Profile 的版本 ${version}`)
+    return this.update(id, { ...row.snapshot, provider: current.builtin ? current.provider : row.snapshot.provider, isDefault: current.isDefault })
+  }
+
   getBuiltin(provider: ProviderId = 'claude'): ProfileDto {
   getBuiltin(provider: ProviderId = 'claude'): ProfileDto {
     const row = this.db.select().from(profiles).where(and(eq(profiles.builtin, true), eq(profiles.provider, provider))).get()
     const row = this.db.select().from(profiles).where(and(eq(profiles.builtin, true), eq(profiles.provider, provider))).get()
     return row ? toProfileDto(row) : this.getDefault()
     return row ? toProfileDto(row) : this.getDefault()
@@ -127,7 +161,10 @@ export class ProfileService {
     const p = this.get(id)
     const p = this.get(id)
     if (p.builtin) throw badRequest('内置的 Profile 不能删除')
     if (p.builtin) throw badRequest('内置的 Profile 不能删除')
     if (p.isDefault) throw badRequest('不能删除默认 Profile')
     if (p.isDefault) throw badRequest('不能删除默认 Profile')
-    this.db.delete(profiles).where(eq(profiles.id, id)).run()
+    this.db.transaction(tx => {
+      tx.delete(profiles).where(eq(profiles.id, id)).run()
+      tx.delete(profileVersions).where(eq(profileVersions.profileId, id)).run()
+    })
   }
   }
 }
 }
 
 

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

@@ -379,3 +379,25 @@ describe('浏览器令牌与 CORS', () => {
     app.settings.update({ security: { corsOrigins: [] } })
     app.settings.update({ security: { corsOrigins: [] } })
   })
   })
 })
 })
+
+describe('Profile 版本历史', () => {
+  it('内容变化才产生新版本;可以查看改动字段并恢复(恢复保存为新版本)', () => {
+    const base = app.profiles.getBuiltin()
+    const { id: _id, version: _v, updatedAt: _u, builtin: _b, ...input } = base
+    const p = app.profiles.create({ ...input, name: '版本测试', isDefault: false })
+    expect(p.version).toBe(1)
+    const v2 = app.profiles.update(p.id, { ...input, name: '版本测试', isDefault: false, systemPrompt: '第二版' })
+    expect(v2.version).toBe(2)
+    // 没有变化不产生新版本
+    expect(app.profiles.update(p.id, { ...input, name: '版本测试', isDefault: false, systemPrompt: '第二版' }).version).toBe(2)
+    app.profiles.update(p.id, { ...input, name: '版本测试3', isDefault: false, systemPrompt: '第二版', model: 'opus' })
+
+    const versions = app.profiles.versions(p.id)
+    expect(versions.map(v => [v.version, v.changed])).toEqual([[3, ['name', 'model']], [2, ['systemPrompt']], [1, []]])
+    const restored = app.profiles.restore(p.id, 1)
+    expect(restored).toMatchObject({ version: 4, name: '版本测试', systemPrompt: input.systemPrompt, model: input.model })
+    expect(() => app.profiles.restore(p.id, 99)).toThrow(/不存在/)
+    app.profiles.delete(p.id)
+    expect(() => app.profiles.versions(p.id)).toThrow()
+  })
+})

+ 1 - 1
docs/README.md

@@ -2,7 +2,7 @@
 
 
 AgentServer 是一个自托管的 Agent 服务:在服务端接入 **Claude Code** 和 **Codex**,用统一的 API 给多个内部业务前端提供 Agent 能力,并附带一个管理后台(含 Playground 对话调试)。
 AgentServer 是一个自托管的 Agent 服务:在服务端接入 **Claude Code** 和 **Codex**,用统一的 API 给多个内部业务前端提供 Agent 能力,并附带一个管理后台(含 Playground 对话调试)。
 
 
-> 状态:**M1 已完成**(Claude 主链路 + 管理后台),M2 进行中 · 最后更新:2026-10-22
+> 状态:**M1、M2 已完成**(Claude 与 Codex、MCP Gateway、技能与插件、用量统计、业务平台接入),下一步 M3 · 最后更新:2026-10-23
 
 
 ## 文档结构
 ## 文档结构
 
 

+ 2 - 1
docs/design/05-data-model.md

@@ -9,7 +9,7 @@ api_keys ──< sessions >── profiles ──< profile_knowledge >── kno
                 ├─< runs ──< interactions
                 ├─< runs ──< interactions
                 ├─< events
                 ├─< events
                 └─< files
                 └─< files
-settings   audit_logs   usage_daily   admin_sessions
+settings   audit_logs   usage_daily   profile_versions   admin_sessions
 ```
 ```
 
 
 不区分使用者:上层平台通过 API Key 调用,会话归属到 API Key。
 不区分使用者:上层平台通过 API Key 调用,会话归属到 API Key。
@@ -30,6 +30,7 @@ settings   audit_logs   usage_daily   admin_sessions
 | `settings` | key, value(json), updated_at | 全局配置,见 §3 |
 | `settings` | key, value(json), updated_at | 全局配置,见 §3 |
 | `audit_logs` | id, ts, actor, session_id, kind('command'\|'file_access_denied'\|'approval'\|'admin'), summary, detail(json) | |
 | `audit_logs` | id, ts, actor, session_id, kind('command'\|'file_access_denied'\|'approval'\|'admin'), summary, detail(json) | |
 | `usage_daily` | date(服务器本地日期), source(API Key id 或 admin), provider, model, runs, failed, interrupted, input/output/cache_read/cache_write_tokens, cost_usd, duration_ms | Run 结束时累加;删除会话不影响历史用量;迁移 0002 用已有 Run 补齐 |
 | `usage_daily` | date(服务器本地日期), source(API Key id 或 admin), provider, model, runs, failed, interrupted, input/output/cache_read/cache_write_tokens, cost_usd, duration_ms | Run 结束时累加;删除会话不影响历史用量;迁移 0002 用已有 Run 补齐 |
+| `profile_versions` | profile_id, version, snapshot(Profile 内容,不含是否默认), created_at | 内容变化时追加;恢复旧版本也保存为新版本;迁移 0003 为现有 Profile 补齐当前版本 |
 | `admin_sessions` | id, created_at, expires_at | 后台登录会话 |
 | `admin_sessions` | id, created_at, expires_at | 后台登录会话 |
 
 
 预留(暂不建表):`connectors`、`profile_connectors`(内部平台接入,见 08);`credentials`(API Key 类凭证,见 ADR-0008)。
 预留(暂不建表):`connectors`、`profile_connectors`(内部平台接入,见 08);`credentials`(API Key 类凭证,见 ADR-0008)。

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

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

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

@@ -94,6 +94,17 @@ export interface ProfileDto {
 
 
 export type ProfileInput = Omit<ProfileDto, 'id' | 'version' | 'updatedAt' | 'builtin'>
 export type ProfileInput = Omit<ProfileDto, 'id' | 'version' | 'updatedAt' | 'builtin'>
 
 
+/** Profile 历史版本的内容(不含"是否默认") */
+export type ProfileSnapshot = Omit<ProfileInput, 'isDefault'>
+
+export interface ProfileVersionDto {
+  version: number
+  createdAt: string
+  snapshot: ProfileSnapshot
+  /** 与上一个版本相比改动的字段 */
+  changed: (keyof ProfileSnapshot)[]
+}
+
 export interface KnowledgeRootDto {
 export interface KnowledgeRootDto {
   id: string
   id: string
   name: string
   name: string