ProfilesView.vue 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391
  1. <script setup lang="ts">
  2. import { computed, defineAsyncComponent, onMounted, ref, watch } from 'vue'
  3. import { useRoute, useRouter } from 'vue-router'
  4. import type { CodexPluginDto, KnowledgeRootDto, McpServerDto, PathRule, ProfileDto, ProfileInput, ProfileVersionDto, ProviderStatusDto, ResolvedAccessDto, SkillInfo } from '@agent-server/shared'
  5. import PageHeader from '../components/PageHeader.vue'
  6. import SidePane from '../components/SidePane.vue'
  7. import { admin, client, errorMessage } from '../lib/api'
  8. import { PERMISSION_LABELS, clone, formatTime, toast } from '../lib/ui'
  9. // 编辑器按需加载
  10. const MonacoEditor = defineAsyncComponent(() => import('../components/MonacoEditor.vue'))
  11. const route = useRoute()
  12. const router = useRouter()
  13. const profiles = ref<ProfileDto[]>([])
  14. const knowledge = ref<KnowledgeRootDto[]>([])
  15. const skills = ref<SkillInfo[]>([])
  16. const mcpServers = ref<McpServerDto[]>([])
  17. function toggleMcp(id: string) {
  18. const d = draft.value
  19. if (!d) return
  20. const cur = d.mcpServerIds ?? []
  21. d.mcpServerIds = cur.includes(id) ? cur.filter(x => x !== id) : [...cur, id]
  22. }
  23. // Codex 插件:只列出已安装的;经 API 的会话只开放勾选的插件(Playground 始终全部开放)
  24. const codexPlugins = ref<CodexPluginDto[]>([])
  25. const codexPluginsError = ref('')
  26. function toggleCodexPlugin(id: string) {
  27. const d = draft.value
  28. if (!d) return
  29. const cur = d.codexPluginIds ?? []
  30. d.codexPluginIds = cur.includes(id) ? cur.filter(x => x !== id) : [...cur, id]
  31. }
  32. const missingCodexPlugins = computed(() => (draft.value?.codexPluginIds ?? []).filter(id => !codexPlugins.value.some(p => p.id === id)))
  33. async function loadCodexPlugins() {
  34. codexPluginsError.value = ''
  35. try {
  36. const r = await admin.codexPlugins.catalog({ installed: true })
  37. codexPlugins.value = r.items
  38. if (r.error) codexPluginsError.value = r.error
  39. } catch (e) {
  40. codexPluginsError.value = errorMessage(e)
  41. }
  42. }
  43. const providers = ref<ProviderStatusDto[]>([])
  44. const draft = ref<ProfileInput | null>(null)
  45. const preview = ref<ResolvedAccessDto | null>(null)
  46. const saving = ref(false)
  47. const id = computed(() => (route.params.id as string | undefined) || null)
  48. const isNew = computed(() => id.value === 'new')
  49. const current = computed(() => profiles.value.find(p => p.id === id.value) ?? null)
  50. const models = computed(() => providers.value.find(p => p.id === draft.value?.provider)?.models ?? [])
  51. const modelLabels = computed(() => providers.value.find(p => p.id === draft.value?.provider)?.modelLabels ?? {})
  52. const blank = (): ProfileInput => ({
  53. name: '', description: '', provider: 'claude', model: providers.value.find(p => p.id === 'claude')?.defaultModel ?? 'sonnet',
  54. systemPrompt: '', permissionMode: 'ask', allowedTools: [], disallowedTools: [], skills: 'all',
  55. fileAccess: { mode: 'restricted', read: [], write: [], deny: [] }, knowledgeRootIds: [], mcpServerIds: [], codexPluginIds: [], isDefault: false,
  56. })
  57. function toInput(p: ProfileDto): ProfileInput {
  58. const { id: _id, version: _v, updatedAt: _u, builtin: _b, builtinKey: _k, ...rest } = clone(p)
  59. return rest
  60. }
  61. async function load() {
  62. ;[profiles.value, knowledge.value, skills.value, providers.value, mcpServers.value] = await Promise.all([
  63. admin.profiles(), admin.knowledge(), admin.skills(), client.providers(), admin.mcp.state().then(s => s.servers).catch(() => []),
  64. ])
  65. }
  66. // "复制为新 Profile":带着当前内容进入新建页
  67. let copySource: ProfileInput | null = null
  68. function duplicate() {
  69. if (!draft.value) return
  70. copySource = { ...clone(draft.value), name: `${draft.value.name}(副本)`, isDefault: false }
  71. void router.push('/profiles/new')
  72. }
  73. // 切换选中项或列表刷新后重置草稿;新建时只在进入页面时生成空白草稿(或复制来源)
  74. const resetDraft = () => {
  75. if (isNew.value) {
  76. draft.value ??= copySource ?? blank()
  77. copySource = null
  78. }
  79. else draft.value = current.value ? toInput(current.value) : null
  80. }
  81. watch(id, () => {
  82. draft.value = null
  83. resetDraft()
  84. }, { immediate: true })
  85. watch(profiles, resetDraft)
  86. // 手动切换引擎时,原模型(如 sonnet)不属于新引擎,换成新引擎的默认模型;否则输入框保留旧值,候选列表按旧值过滤后为空
  87. function onProviderChange() {
  88. const d = draft.value
  89. if (d && !models.value.includes(d.model)) d.model = providers.value.find(p => p.id === d.provider)?.defaultModel || models.value[0] || ''
  90. }
  91. // 技能列表随引擎切换
  92. watch(() => draft.value?.provider, async p => {
  93. if (p === 'codex' && !codexPlugins.value.length) void loadCodexPlugins()
  94. if (p) skills.value = await admin.skills(p).catch(() => [])
  95. })
  96. // 文件访问策略生效预览
  97. let previewTimer: ReturnType<typeof setTimeout> | undefined
  98. watch(() => draft.value && JSON.stringify([draft.value.fileAccess, draft.value.knowledgeRootIds]), () => {
  99. clearTimeout(previewTimer)
  100. if (!draft.value) return
  101. previewTimer = setTimeout(async () => {
  102. preview.value = await admin.previewAccess(draft.value!.fileAccess, draft.value!.knowledgeRootIds).catch(() => null)
  103. }, 300)
  104. }, { immediate: true })
  105. const skillsAll = computed({
  106. get: () => draft.value?.skills === 'all',
  107. // 取消"全部"时保留原来全部启用的技能;第三方技能不在"全部"之内
  108. set: v => { if (draft.value) draft.value.skills = v ? 'all' : skills.value.filter(s => !s.optIn).map(s => s.id) },
  109. })
  110. // 历史数据中不带前缀的技能名指本地技能目录
  111. const skillSelected = (s: SkillInfo) => Array.isArray(draft.value?.skills) && (draft.value!.skills.includes(s.id) || (s.source === 'local' && draft.value!.skills.includes(s.name)))
  112. function toggleSkill(s: SkillInfo) {
  113. const d = draft.value
  114. if (!d || d.skills === 'all') return
  115. d.skills = skillSelected(s) ? d.skills.filter(x => x !== s.id && !(s.source === 'local' && x === s.name)) : [...d.skills, s.id]
  116. }
  117. function toggleKnowledge(kid: string) {
  118. const d = draft.value!
  119. d.knowledgeRootIds = d.knowledgeRootIds.includes(kid) ? d.knowledgeRootIds.filter(k => k !== kid) : [...d.knowledgeRootIds, kid]
  120. }
  121. const listText = (list: string[]) => list.join(', ')
  122. const parseList = (s: string) => s.split(/[,,\n]/).map(x => x.trim()).filter(Boolean)
  123. const rulePaths = (rules: PathRule[]) => rules.filter((r): r is { path: string } => 'path' in r).map(r => r.path).join('\n')
  124. const setRulePaths = (kind: 'read' | 'write', text: string) => {
  125. draft.value!.fileAccess[kind] = parseList(text).map(path => ({ path }))
  126. }
  127. async function save() {
  128. if (!draft.value) return
  129. saving.value = true
  130. try {
  131. const saved = isNew.value ? await admin.createProfile(draft.value) : await admin.updateProfile(id.value!, draft.value)
  132. toast('已保存', 'success')
  133. await load()
  134. if (isNew.value) await router.replace(`/profiles/${saved.id}`)
  135. } catch (err) {
  136. toast(errorMessage(err), 'error')
  137. } finally {
  138. saving.value = false
  139. }
  140. }
  141. // 历史版本
  142. const FIELD_LABELS: Record<string, string> = {
  143. name: '名称', description: '说明', provider: '引擎', model: '模型', systemPrompt: '系统提示词', permissionMode: '权限模式',
  144. allowedTools: '允许的工具', disallowedTools: '禁用的工具', skills: '技能', fileAccess: '文件访问策略', knowledgeRootIds: '资料库', mcpServerIds: '第三方 MCP 服务', codexPluginIds: 'Codex 插件',
  145. }
  146. const versions = ref<ProfileVersionDto[] | null>(null)
  147. async function openVersions() {
  148. if (!current.value) return
  149. versions.value = await admin.profileVersions(current.value.id).catch(err => (toast(errorMessage(err), 'error'), null))
  150. }
  151. async function restore(v: ProfileVersionDto) {
  152. if (!current.value || !confirm(`恢复到版本 ${v.version}?当前内容会保留在历史中,恢复结果保存为新版本。`)) return
  153. try {
  154. const p = await admin.restoreProfile(current.value.id, v.version)
  155. toast(`已恢复,保存为版本 ${p.version}`, 'success')
  156. versions.value = null
  157. await load()
  158. } catch (err) {
  159. toast(errorMessage(err), 'error')
  160. }
  161. }
  162. async function remove() {
  163. if (!current.value || !confirm(`删除 Profile「${current.value.name}」?`)) return
  164. try {
  165. await admin.deleteProfile(current.value.id)
  166. await load()
  167. await router.replace('/profiles')
  168. } catch (err) {
  169. toast(errorMessage(err), 'error')
  170. }
  171. }
  172. onMounted(() => load().catch(err => toast(errorMessage(err), 'error')))
  173. </script>
  174. <template>
  175. <SidePane title="Profiles">
  176. <template #actions>
  177. <button type="button" class="icon-btn" title="新建 Profile" @click="router.push('/profiles/new')"><span class="codicon codicon-add" /></button>
  178. </template>
  179. <template #side>
  180. <div
  181. 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"
  182. :class="{ '!bg-active': p.id === id }" @click="router.push(`/profiles/${p.id}`)"
  183. >
  184. <div class="truncate">{{ p.name }}<span v-if="p.builtin" class="hint"> · 内置</span><span v-if="p.isDefault" class="hint"> · 默认</span></div>
  185. <div class="text-[11px] text-fg-muted truncate">{{ p.provider }} / {{ p.model }} · {{ PERMISSION_LABELS[p.permissionMode] }}</div>
  186. </div>
  187. </template>
  188. <div v-if="!draft" class="flex-1 flex items-center justify-center hint">选择或新建一个 Profile</div>
  189. <template v-else>
  190. <PageHeader :title="isNew ? '新建 Profile' : draft.name" :subtitle="current ? `版本 ${current.version}` : undefined">
  191. <button v-if="current" type="button" class="btn-ghost" @click="openVersions"><span class="codicon codicon-history" />历史版本</button>
  192. <button v-if="current" type="button" class="btn-ghost" @click="duplicate"><span class="codicon codicon-copy" />复制为新 Profile</button>
  193. <button v-if="current && !current.isDefault && !current.builtin" type="button" class="btn-danger" @click="remove">删除</button>
  194. <button type="button" class="btn-primary" :disabled="saving || !draft.name.trim()" @click="save">{{ saving ? '保存中…' : '保存' }}</button>
  195. </PageHeader>
  196. <div class="flex-1 min-h-0 overflow-y-auto">
  197. <div class="max-w-200 p-6 flex flex-col gap-6">
  198. <section class="grid grid-cols-2 gap-4">
  199. <div><label class="label" for="p-name">名称</label><input id="p-name" v-model="draft.name" class="field"></div>
  200. <div><label class="label" for="p-desc">说明</label><input id="p-desc" v-model="draft.description" class="field"></div>
  201. <div>
  202. <label class="label" for="p-provider">引擎</label>
  203. <select id="p-provider" v-model="draft.provider" class="field" @change="onProviderChange" :disabled="!!current?.builtin" :title="current?.builtin ? '内置 Profile 的引擎不能修改' : ''">
  204. <option v-for="p in providers" :key="p.id" :value="p.id" :disabled="!p.enabled">{{ p.id }}{{ p.enabled ? '' : '(未启用)' }}</option>
  205. </select>
  206. </div>
  207. <div>
  208. <label class="label" for="p-model">模型</label>
  209. <select id="p-model" v-model="draft.model" class="field">
  210. <option v-for="m in models" :key="m" :value="m">{{ m }}{{ modelLabels[m] ? ` · ${modelLabels[m]}` : '' }}</option>
  211. <option v-if="draft.model && !models.includes(draft.model)" :value="draft.model">{{ draft.model }}(不在当前引擎的模型列表中)</option>
  212. </select>
  213. <div v-if="modelLabels[draft.model]" class="hint mt-1">{{ modelLabels[draft.model] }}</div>
  214. </div>
  215. <div>
  216. <label class="label" for="p-mode">权限模式</label>
  217. <select id="p-mode" v-model="draft.permissionMode" class="field">
  218. <option value="ask">需审批:执行命令、修改文件前询问</option>
  219. <option value="auto">自动执行:沙箱内自动放行</option>
  220. <option value="readonly">只读:禁止执行命令和修改文件</option>
  221. </select>
  222. </div>
  223. <label class="flex items-center gap-2 mt-5 cursor-pointer"><input v-model="draft.isDefault" type="checkbox">设为默认 Profile</label>
  224. </section>
  225. <section>
  226. <label class="label" for="p-prompt">系统提示词(追加在运行环境说明之后)</label>
  227. <div id="p-prompt" class="h-48"><MonacoEditor v-model="draft.systemPrompt" language="markdown" /></div>
  228. <div v-if="!draft.systemPrompt" class="hint mt-1">例如:你是家庭财务分析助手,金额统一用人民币元……(Markdown)</div>
  229. </section>
  230. <section class="flex flex-col gap-2">
  231. <div class="label m-0">技能</div>
  232. <label class="flex items-center gap-2 cursor-pointer"><input v-model="skillsAll" type="checkbox">启用全部可用技能(第三方技能与自定义技能除外,需取消此项后单独勾选)</label>
  233. <div class="flex flex-col gap-1">
  234. <label v-for="s in skills" :key="s.id" class="flex items-start gap-2 cursor-pointer" :class="{ 'opacity-60': skillsAll }">
  235. <input type="checkbox" class="mt-1" :checked="(skillsAll && !s.optIn) || skillSelected(s)" :disabled="skillsAll" @change="toggleSkill(s)">
  236. <span class="min-w-0">
  237. <span class="font-mono text-[12px]">{{ s.id }}</span>
  238. <span v-if="s.license === 'restricted'" class="badge bg-code text-warning ml-1">专有许可 · 仅 Claude</span>
  239. <span v-if="s.source === 'official'" class="badge bg-code ml-1">OpenAI 官方</span>
  240. <span v-if="s.source === 'system'" class="badge bg-code ml-1">Codex 自带</span>
  241. <span v-if="s.source === 'community'" class="badge bg-code text-link ml-1">第三方</span>
  242. <span v-if="s.source === 'custom'" class="badge bg-code text-accent ml-1">自定义</span>
  243. <span class="block hint truncate">{{ s.description }}</span>
  244. </span>
  245. </label>
  246. </div>
  247. <div v-if="!skills.length" class="hint">没有可用技能。可以在"技能与插件"页面安装插件,或在本地技能目录中添加技能。</div>
  248. </section>
  249. <section class="flex flex-col gap-2">
  250. <div class="label m-0">第三方 MCP 服务</div>
  251. <div class="hint">内置工具(文档转换、网页抓取等)始终可用;第三方服务需要在这里勾选。服务在"MCP 服务"页面添加。</div>
  252. <label v-for="m in mcpServers" :key="m.id" class="flex items-start gap-2 cursor-pointer" :class="{ 'opacity-60': !m.enabled }">
  253. <input type="checkbox" class="mt-1" :checked="(draft.mcpServerIds ?? []).includes(m.id)" @change="toggleMcp(m.id)">
  254. <span class="min-w-0">
  255. <span>{{ m.title }}</span>
  256. <code class="font-mono text-[12px] text-fg-muted ml-1">{{ m.name }}__*</code>
  257. <span class="badge bg-code ml-1">{{ m.transport === 'http' ? '远程' : '本地进程' }}</span>
  258. <span v-if="!m.enabled" class="badge bg-code text-fg-muted ml-1">已停用</span>
  259. <span class="block hint truncate">{{ m.tools.length }} 个工具{{ m.description ? ` · ${m.description}` : '' }}</span>
  260. </span>
  261. </label>
  262. <div v-if="!mcpServers.length" class="hint">还没有第三方 MCP 服务</div>
  263. </section>
  264. <section v-if="draft.provider === 'codex'" class="flex flex-col gap-2">
  265. <div class="label m-0">Codex 插件</div>
  266. <div class="hint">
  267. 插件带有应用连接器,使用你的 ChatGPT 账号授权的数据(如邮件、日历、网盘)。默认都不开放;勾选后,经 API 使用此 Profile 的会话可以调用这些插件,其他已安装的插件仍然不可用。
  268. 应用调用需要确认时会发出审批卡片。后台 Playground 中始终全部开放。插件在"技能与插件"页面安装。
  269. </div>
  270. <label v-for="p in codexPlugins" :key="p.id" class="flex items-start gap-2 cursor-pointer" :class="{ 'opacity-60': !p.enabled }">
  271. <input type="checkbox" class="mt-1" :checked="(draft.codexPluginIds ?? []).includes(p.id)" @change="toggleCodexPlugin(p.id)">
  272. <img v-if="p.logoUrl" :src="p.logoUrl" alt="" class="w-4 h-4 mt-0.5 rounded-sm flex-none">
  273. <span class="min-w-0">
  274. <span>{{ p.displayName }}</span>
  275. <code class="font-mono text-[12px] text-fg-muted ml-1">{{ p.id }}</code>
  276. <span v-if="!p.enabled" class="badge bg-code text-fg-muted ml-1">已停用</span>
  277. <span v-if="p.description" class="block hint truncate">{{ p.description }}</span>
  278. </span>
  279. </label>
  280. <label v-for="mid in missingCodexPlugins" :key="mid" class="flex items-start gap-2 cursor-pointer opacity-60">
  281. <input type="checkbox" class="mt-1" checked @change="toggleCodexPlugin(mid)">
  282. <span class="min-w-0"><code class="font-mono text-[12px]">{{ mid }}</code><span class="badge bg-code text-fg-muted ml-1">未安装,不会生效</span></span>
  283. </label>
  284. <div v-if="codexPluginsError" class="hint text-error">读取插件列表失败:{{ codexPluginsError }}</div>
  285. <div v-else-if="!codexPlugins.length && !missingCodexPlugins.length" class="hint">还没有安装 Codex 插件</div>
  286. </section>
  287. <section class="grid grid-cols-2 gap-4">
  288. <div>
  289. <label class="label" for="p-allow">仅允许这些工具(留空不限制)</label>
  290. <input id="p-allow" class="field" :value="listText(draft.allowedTools)" placeholder="例如 Read, Bash, WebSearch" @change="draft.allowedTools = parseList(($event.target as HTMLInputElement).value)">
  291. </div>
  292. <div>
  293. <label class="label" for="p-deny">禁用这些工具</label>
  294. <input id="p-deny" class="field" :value="listText(draft.disallowedTools)" placeholder="例如 WebFetch" @change="draft.disallowedTools = parseList(($event.target as HTMLInputElement).value)">
  295. </div>
  296. </section>
  297. <section class="card p-4 flex flex-col gap-4">
  298. <div class="flex items-center gap-2">
  299. <span class="codicon codicon-shield text-accent" />
  300. <span class="font-600">文件访问策略</span>
  301. <span class="hint">全局禁止项始终生效,不能被覆盖</span>
  302. </div>
  303. <div class="flex gap-4">
  304. <label class="flex items-center gap-1.5 cursor-pointer"><input v-model="draft.fileAccess.mode" type="radio" value="restricted">受限(只能读列出的路径,推荐)</label>
  305. <label class="flex items-center gap-1.5 cursor-pointer"><input v-model="draft.fileAccess.mode" type="radio" value="broad">宽松(全盘可读,排除禁止项)</label>
  306. </div>
  307. <div>
  308. <div class="label">资料库</div>
  309. <div v-if="!knowledge.length" class="hint">还没有资料库,可以在"资料库"页面添加</div>
  310. <div class="flex flex-col gap-1">
  311. <label v-for="k in knowledge" :key="k.id" class="flex items-center gap-2 cursor-pointer" :class="{ 'opacity-50': !k.enabled }">
  312. <input type="checkbox" :checked="draft.knowledgeRootIds.includes(k.id)" @change="toggleKnowledge(k.id)">
  313. <span>{{ k.name }}</span><span class="font-mono hint">{{ k.path }}</span>
  314. <span class="badge bg-code">{{ k.access === 'write' ? '读写' : '只读' }}</span>
  315. </label>
  316. </div>
  317. </div>
  318. <div class="grid grid-cols-3 gap-3">
  319. <div>
  320. <label class="label" for="p-read">额外可读路径(每行一个)</label>
  321. <textarea id="p-read" class="field-area h-24" :value="rulePaths(draft.fileAccess.read)" @change="setRulePaths('read', ($event.target as HTMLTextAreaElement).value)" />
  322. </div>
  323. <div>
  324. <label class="label" for="p-write">额外可写路径</label>
  325. <textarea id="p-write" class="field-area h-24" :value="rulePaths(draft.fileAccess.write)" @change="setRulePaths('write', ($event.target as HTMLTextAreaElement).value)" />
  326. </div>
  327. <div>
  328. <label class="label" for="p-fdeny">禁止路径 / 通配</label>
  329. <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)" />
  330. </div>
  331. </div>
  332. <div v-if="preview" class="grid grid-cols-3 gap-3 text-[12px]">
  333. <div v-for="(list, label) in { '可读': preview.readable, '可写': preview.writable, '禁止': preview.denied }" :key="label">
  334. <div class="section-title mb-1">生效:{{ label }}</div>
  335. <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">
  336. <span v-for="p in list" :key="p" class="break-all">{{ p }}</span>
  337. </div>
  338. </div>
  339. </div>
  340. </section>
  341. </div>
  342. </div>
  343. </template>
  344. <!-- 历史版本 -->
  345. <div v-if="versions && current" class="fixed inset-0 z-40 flex items-center justify-center bg-[var(--app-overlay)]" @click.self="versions = null">
  346. <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]">
  347. <div class="flex items-center gap-2">
  348. <span class="font-600 text-[14px] flex-1">「{{ current.name }}」的历史版本</span>
  349. <button type="button" class="icon-btn" title="关闭" @click="versions = null"><span class="codicon codicon-close" /></button>
  350. </div>
  351. <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">
  352. <div class="flex-1 min-w-0">
  353. <div class="flex items-center gap-2">
  354. <span class="font-600">版本 {{ v.version }}</span>
  355. <span v-if="v.version === current.version" class="badge bg-code text-accent">当前</span>
  356. <span class="hint">{{ formatTime(v.createdAt) }}</span>
  357. </div>
  358. <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>
  359. <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>
  360. </div>
  361. <button v-if="v.version !== current.version" type="button" class="btn-secondary flex-none" @click="restore(v)">恢复</button>
  362. </div>
  363. </div>
  364. </div>
  365. </SidePane>
  366. </template>