| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391 |
- <script setup lang="ts">
- import { computed, defineAsyncComponent, onMounted, ref, watch } from 'vue'
- import { useRoute, useRouter } from 'vue-router'
- import type { CodexPluginDto, KnowledgeRootDto, McpServerDto, PathRule, ProfileDto, ProfileInput, ProfileVersionDto, ProviderStatusDto, ResolvedAccessDto, SkillInfo } 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, formatTime, toast } from '../lib/ui'
- // 编辑器按需加载
- const MonacoEditor = defineAsyncComponent(() => import('../components/MonacoEditor.vue'))
- const route = useRoute()
- const router = useRouter()
- const profiles = ref<ProfileDto[]>([])
- const knowledge = ref<KnowledgeRootDto[]>([])
- const skills = ref<SkillInfo[]>([])
- const mcpServers = ref<McpServerDto[]>([])
- function toggleMcp(id: string) {
- const d = draft.value
- if (!d) return
- const cur = d.mcpServerIds ?? []
- d.mcpServerIds = cur.includes(id) ? cur.filter(x => x !== id) : [...cur, id]
- }
- // Codex 插件:只列出已安装的;经 API 的会话只开放勾选的插件(Playground 始终全部开放)
- const codexPlugins = ref<CodexPluginDto[]>([])
- const codexPluginsError = ref('')
- function toggleCodexPlugin(id: string) {
- const d = draft.value
- if (!d) return
- const cur = d.codexPluginIds ?? []
- d.codexPluginIds = cur.includes(id) ? cur.filter(x => x !== id) : [...cur, id]
- }
- const missingCodexPlugins = computed(() => (draft.value?.codexPluginIds ?? []).filter(id => !codexPlugins.value.some(p => p.id === id)))
- async function loadCodexPlugins() {
- codexPluginsError.value = ''
- try {
- const r = await admin.codexPlugins.catalog({ installed: true })
- codexPlugins.value = r.items
- if (r.error) codexPluginsError.value = r.error
- } catch (e) {
- codexPluginsError.value = errorMessage(e)
- }
- }
- 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 modelLabels = computed(() => providers.value.find(p => p.id === draft.value?.provider)?.modelLabels ?? {})
- 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: [], mcpServerIds: [], codexPluginIds: [], isDefault: false,
- })
- function toInput(p: ProfileDto): ProfileInput {
- const { id: _id, version: _v, updatedAt: _u, builtin: _b, builtinKey: _k, ...rest } = clone(p)
- return rest
- }
- async function load() {
- ;[profiles.value, knowledge.value, skills.value, providers.value, mcpServers.value] = await Promise.all([
- admin.profiles(), admin.knowledge(), admin.skills(), client.providers(), admin.mcp.state().then(s => s.servers).catch(() => []),
- ])
- }
- // "复制为新 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 = () => {
- if (isNew.value) {
- draft.value ??= copySource ?? blank()
- copySource = null
- }
- else draft.value = current.value ? toInput(current.value) : null
- }
- watch(id, () => {
- draft.value = null
- resetDraft()
- }, { immediate: true })
- watch(profiles, resetDraft)
- // 手动切换引擎时,原模型(如 sonnet)不属于新引擎,换成新引擎的默认模型;否则输入框保留旧值,候选列表按旧值过滤后为空
- function onProviderChange() {
- const d = draft.value
- if (d && !models.value.includes(d.model)) d.model = providers.value.find(p => p.id === d.provider)?.defaultModel || models.value[0] || ''
- }
- // 技能列表随引擎切换
- watch(() => draft.value?.provider, async p => {
- if (p === 'codex' && !codexPlugins.value.length) void loadCodexPlugins()
- if (p) skills.value = await admin.skills(p).catch(() => [])
- })
- // 文件访问策略生效预览
- 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.filter(s => !s.optIn).map(s => s.id) },
- })
- // 历史数据中不带前缀的技能名指本地技能目录
- const skillSelected = (s: SkillInfo) => Array.isArray(draft.value?.skills) && (draft.value!.skills.includes(s.id) || (s.source === 'local' && draft.value!.skills.includes(s.name)))
- function toggleSkill(s: SkillInfo) {
- const d = draft.value
- if (!d || d.skills === 'all') return
- d.skills = skillSelected(s) ? d.skills.filter(x => x !== s.id && !(s.source === 'local' && x === s.name)) : [...d.skills, s.id]
- }
- 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
- }
- }
- // 历史版本
- const FIELD_LABELS: Record<string, string> = {
- name: '名称', description: '说明', provider: '引擎', model: '模型', systemPrompt: '系统提示词', permissionMode: '权限模式',
- allowedTools: '允许的工具', disallowedTools: '禁用的工具', skills: '技能', fileAccess: '文件访问策略', knowledgeRootIds: '资料库', mcpServerIds: '第三方 MCP 服务', codexPluginIds: 'Codex 插件',
- }
- 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() {
- 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.builtin" class="hint"> · 内置</span><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" 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 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" @change="onProviderChange" :disabled="!!current?.builtin" :title="current?.builtin ? '内置 Profile 的引擎不能修改' : ''">
- <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>
- <select id="p-model" v-model="draft.model" class="field">
- <option v-for="m in models" :key="m" :value="m">{{ m }}{{ modelLabels[m] ? ` · ${modelLabels[m]}` : '' }}</option>
- <option v-if="draft.model && !models.includes(draft.model)" :value="draft.model">{{ draft.model }}(不在当前引擎的模型列表中)</option>
- </select>
- <div v-if="modelLabels[draft.model]" class="hint mt-1">{{ modelLabels[draft.model] }}</div>
- </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>
- <div id="p-prompt" class="h-48"><MonacoEditor v-model="draft.systemPrompt" language="markdown" /></div>
- <div v-if="!draft.systemPrompt" class="hint mt-1">例如:你是家庭财务分析助手,金额统一用人民币元……(Markdown)</div>
- </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 class="flex flex-col gap-1">
- <label v-for="s in skills" :key="s.id" class="flex items-start gap-2 cursor-pointer" :class="{ 'opacity-60': skillsAll }">
- <input type="checkbox" class="mt-1" :checked="(skillsAll && !s.optIn) || skillSelected(s)" :disabled="skillsAll" @change="toggleSkill(s)">
- <span class="min-w-0">
- <span class="font-mono text-[12px]">{{ s.id }}</span>
- <span v-if="s.license === 'restricted'" class="badge bg-code text-warning ml-1">专有许可 · 仅 Claude</span>
- <span v-if="s.source === 'official'" class="badge bg-code ml-1">OpenAI 官方</span>
- <span v-if="s.source === 'system'" class="badge bg-code ml-1">Codex 自带</span>
- <span v-if="s.source === 'community'" class="badge bg-code text-link ml-1">第三方</span>
- <span v-if="s.source === 'custom'" class="badge bg-code text-accent ml-1">自定义</span>
- <span class="block hint truncate">{{ s.description }}</span>
- </span>
- </label>
- </div>
- <div v-if="!skills.length" class="hint">没有可用技能。可以在"技能与插件"页面安装插件,或在本地技能目录中添加技能。</div>
- </section>
- <section class="flex flex-col gap-2">
- <div class="label m-0">第三方 MCP 服务</div>
- <div class="hint">内置工具(文档转换、网页抓取等)始终可用;第三方服务需要在这里勾选。服务在"MCP 服务"页面添加。</div>
- <label v-for="m in mcpServers" :key="m.id" class="flex items-start gap-2 cursor-pointer" :class="{ 'opacity-60': !m.enabled }">
- <input type="checkbox" class="mt-1" :checked="(draft.mcpServerIds ?? []).includes(m.id)" @change="toggleMcp(m.id)">
- <span class="min-w-0">
- <span>{{ m.title }}</span>
- <code class="font-mono text-[12px] text-fg-muted ml-1">{{ m.name }}__*</code>
- <span class="badge bg-code ml-1">{{ m.transport === 'http' ? '远程' : '本地进程' }}</span>
- <span v-if="!m.enabled" class="badge bg-code text-fg-muted ml-1">已停用</span>
- <span class="block hint truncate">{{ m.tools.length }} 个工具{{ m.description ? ` · ${m.description}` : '' }}</span>
- </span>
- </label>
- <div v-if="!mcpServers.length" class="hint">还没有第三方 MCP 服务</div>
- </section>
- <section v-if="draft.provider === 'codex'" class="flex flex-col gap-2">
- <div class="label m-0">Codex 插件</div>
- <div class="hint">
- 插件带有应用连接器,使用你的 ChatGPT 账号授权的数据(如邮件、日历、网盘)。默认都不开放;勾选后,经 API 使用此 Profile 的会话可以调用这些插件,其他已安装的插件仍然不可用。
- 应用调用需要确认时会发出审批卡片。后台 Playground 中始终全部开放。插件在"技能与插件"页面安装。
- </div>
- <label v-for="p in codexPlugins" :key="p.id" class="flex items-start gap-2 cursor-pointer" :class="{ 'opacity-60': !p.enabled }">
- <input type="checkbox" class="mt-1" :checked="(draft.codexPluginIds ?? []).includes(p.id)" @change="toggleCodexPlugin(p.id)">
- <img v-if="p.logoUrl" :src="p.logoUrl" alt="" class="w-4 h-4 mt-0.5 rounded-sm flex-none">
- <span class="min-w-0">
- <span>{{ p.displayName }}</span>
- <code class="font-mono text-[12px] text-fg-muted ml-1">{{ p.id }}</code>
- <span v-if="!p.enabled" class="badge bg-code text-fg-muted ml-1">已停用</span>
- <span v-if="p.description" class="block hint truncate">{{ p.description }}</span>
- </span>
- </label>
- <label v-for="mid in missingCodexPlugins" :key="mid" class="flex items-start gap-2 cursor-pointer opacity-60">
- <input type="checkbox" class="mt-1" checked @change="toggleCodexPlugin(mid)">
- <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>
- </label>
- <div v-if="codexPluginsError" class="hint text-error">读取插件列表失败:{{ codexPluginsError }}</div>
- <div v-else-if="!codexPlugins.length && !missingCodexPlugins.length" class="hint">还没有安装 Codex 插件</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>
-
- <!-- 历史版本 -->
- <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>
- </template>
|