Browse Source

feat: 后台文件管理器与 Monaco 编辑器

- 文件管理页:会话工作区与资料库可写,本地技能目录与 Python 运行环境只读;常用目录快速跳转
- 新建、上传(拖放)、重命名/移动、删除、目录打包下载;文本文件在线编辑并检查冲突
- 服务数据目录与全局禁止项不可见,路径不越出位置,会话目录受保护;会话文件记录随之更新
- 技能编辑器与 Profile 系统提示词改用 Monaco(按需加载)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Jerry Liang 1 tuần trước cách đây
mục cha
commit
61b9e66318

+ 1 - 0
apps/admin/package.json

@@ -15,6 +15,7 @@
     "@agent-server/shared": "workspace:^",
     "@agent-server/ui-tokens": "workspace:^",
     "@vscode/codicons": "0.0.46-24",
+    "monaco-editor": "^0.52.2",
     "pinia": "^4.1.0",
     "vue": "^3.5.44",
     "vue-router": "^5.4.0"

+ 7 - 5
apps/admin/src/components/CustomSkillsPanel.vue

@@ -1,10 +1,13 @@
 <script setup lang="ts">
 // 自定义技能(docs/design/12-custom-skills.md):左侧技能列表与技能源,右侧文件编辑;导入审阅、版本历史与对比用对话框
-import { computed, onMounted, ref } from 'vue'
+import { computed, defineAsyncComponent, onMounted, ref } from 'vue'
 import type { CustomSkillDetailDto, CustomSkillDto, CustomSkillFileDto, FileDiffDto, SkillScanDto, SkillSourceDto, SkillSourceInput } from '@agent-server/shared'
 import { admin, errorMessage } from '../lib/api'
 import { formatTime, toast } from '../lib/ui'
 
+// 编辑器按需加载
+const MonacoEditor = defineAsyncComponent(() => import('./MonacoEditor.vue'))
+
 const skills = ref<CustomSkillDto[]>([])
 const sources = ref<SkillSourceDto[]>([])
 const current = ref<CustomSkillDetailDto | null>(null)
@@ -399,10 +402,9 @@ defineExpose({ load })
               <a :href="admin.customSkills.fileUrl(current.id, openFileInfo.path)" target="_blank" rel="noopener" class="btn-ghost !h-6 no-underline"><span class="codicon codicon-desktop-download" />下载</a>
               <button v-if="!readonlySkill && !openFileInfo.text" type="button" class="btn-ghost !h-6" @click="pickUpload(openFileInfo.path)"><span class="codicon codicon-replace" />替换</button>
             </div>
-            <textarea
-              v-if="openFileInfo.text" v-model="editorText" class="field-area flex-1 min-h-0 !resize-none !text-[12.5px] leading-relaxed" spellcheck="false"
-              :readonly="readonlySkill" :aria-label="`编辑 ${openFileInfo.path}`" @blur="flush"
-            />
+            <div v-if="openFileInfo.text" class="flex-1 min-h-0">
+              <MonacoEditor v-model="editorText" :filename="openFileInfo.path" :readonly="readonlySkill" @blur="flush" @save="flush" />
+            </div>
             <div v-else class="flex-1 flex items-center justify-center hint">二进制文件(模版、图片等)不能在线编辑,可以下载或替换。</div>
             <div v-if="openFileInfo.path === 'SKILL.md'" class="hint">frontmatter 必须包含 name(与技能名一致)和 description;description 决定 Agent 是否会使用这个技能。</div>
           </template>

+ 64 - 0
apps/admin/src/components/MonacoEditor.vue

@@ -0,0 +1,64 @@
+<script setup lang="ts">
+// Monaco 编辑器组件:v-model 绑定内容,按文件名或 language 着色;跟随后台的亮 / 暗主题;Ctrl/⌘+S 触发 save
+import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
+import { languageFor, monaco } from '../lib/monaco'
+
+const props = defineProps<{ modelValue: string; filename?: string; language?: string; readonly?: boolean; wordWrap?: boolean }>()
+const emit = defineEmits<{ 'update:modelValue': [value: string]; save: []; blur: [] }>()
+
+const host = ref<HTMLDivElement>()
+let editor: monaco.editor.IStandaloneCodeEditor | null = null
+const media = window.matchMedia('(prefers-color-scheme: dark)')
+const isDark = () => {
+  const t = document.documentElement.getAttribute('data-theme')
+  return t ? t === 'dark' : media.matches
+}
+const applyTheme = () => monaco.editor.setTheme(isDark() ? 'vs-dark' : 'vs')
+const observer = new MutationObserver(applyTheme)
+const lang = () => props.language ?? languageFor(props.filename ?? '')
+
+onMounted(() => {
+  editor = monaco.editor.create(host.value!, {
+    value: props.modelValue,
+    language: lang(),
+    readOnly: props.readonly,
+    automaticLayout: true,
+    minimap: { enabled: false },
+    fontSize: 13,
+    fontFamily: 'var(--app-monospace-font-family)',
+    wordWrap: props.wordWrap ?? lang() === 'markdown' ? 'on' : 'off',
+    scrollBeyondLastLine: false,
+    tabSize: 2,
+    renderWhitespace: 'selection',
+    unicodeHighlight: { ambiguousCharacters: false },
+  })
+  applyTheme()
+  observer.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] })
+  media.addEventListener('change', applyTheme)
+  editor.onDidChangeModelContent(() => {
+    const v = editor!.getValue()
+    if (v !== props.modelValue) emit('update:modelValue', v)
+  })
+  editor.onDidBlurEditorText(() => emit('blur'))
+  editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, () => emit('save'))
+})
+
+watch(() => props.modelValue, v => {
+  if (editor && v !== editor.getValue()) editor.setValue(v)
+})
+watch(() => [props.filename, props.language], () => {
+  const model = editor?.getModel()
+  if (model) monaco.editor.setModelLanguage(model, lang())
+})
+watch(() => props.readonly, r => editor?.updateOptions({ readOnly: r }))
+
+onBeforeUnmount(() => {
+  observer.disconnect()
+  media.removeEventListener('change', applyTheme)
+  editor?.dispose()
+})
+</script>
+
+<template>
+  <div ref="host" class="w-full h-full min-h-0 rounded-sm overflow-hidden border border-solid border-input" />
+</template>

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

@@ -15,6 +15,7 @@ const nav = [
   { to: '/overview', icon: 'codicon-dashboard', label: '概览' },
   { to: '/profiles', icon: 'codicon-hubot', label: 'Profiles' },
   { to: '/knowledge', icon: 'codicon-library', label: '资料库' },
+  { to: '/files', icon: 'codicon-files', label: '文件管理' },
   { to: '/plugins', icon: 'codicon-extensions', label: '技能与插件' },
   { to: '/mcp', icon: 'codicon-server-process', label: 'MCP 服务' },
   { to: '/api-keys', icon: 'codicon-key', label: 'API Keys' },

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

@@ -1,12 +1,13 @@
 // 后台 API:同源 Cookie 鉴权,复用 AgentClient 的请求与错误处理
 import { AgentClient } from '@agent-server/client'
 import type {
-  ApiKeyDto, ApiKeyProfiles, ApiReferenceDto, AppSettings, BrowserTokenDto, CodexPluginCatalogDto, CodexPluginDetailDto, CodexPluginInstallResultDto, CodexSkillCatalogDto, CommunitySearchResultDto, CustomSkillDetailDto, CustomSkillDto, FileDiffDto, SkillScanDto, SkillSourceDto, SkillSourceInput, CommunitySkillDto, CommunitySkillPreviewDto, DeepPartial, FileAccessPolicy, KnowledgeRootDto, McpServerDto, McpServerInput, McpStateDto, McpTestResultDto, McpToolPolicy, PathCheck, PluginAction, PluginStateDto, ProfileDto, ProfileInput, ProfileVersionDto,
+  ApiKeyDto, ApiKeyProfiles, ApiReferenceDto, AppSettings, BrowserTokenDto, CodexPluginCatalogDto, CodexPluginDetailDto, CodexPluginInstallResultDto, CodexSkillCatalogDto, CommunitySearchResultDto, CustomSkillDetailDto, CustomSkillDto, FileDiffDto, SkillScanDto, SkillSourceDto, SkillSourceInput, CommunitySkillDto, CommunitySkillPreviewDto, DeepPartial, FileAccessPolicy, FsEntryDto, FsRootDto, FsShortcutDto, KnowledgeRootDto, McpServerDto, McpServerInput, McpStateDto, McpTestResultDto, McpToolPolicy, PathCheck, PluginAction, PluginStateDto, ProfileDto, ProfileInput, ProfileVersionDto,
   ProviderQuotaDto, ProviderStatusDto, RecentErrorDto, ResolvedAccessDto, SkillInfo, UsageSummaryDto,
 } from '@agent-server/shared'
 
 export const client = new AgentClient({ baseUrl: '' })
 const req = <T>(method: string, path: string, body?: unknown) => client.request<T>(method, `/admin/v1${path}`, body)
+const fsq = (root: string, path: string) => `root=${encodeURIComponent(root)}&path=${encodeURIComponent(path)}`
 
 export interface AuditRow { id: number; ts: string; actor: string; sessionId: string | null; kind: string; summary: string; detail: unknown }
 export type KnowledgeInput = Pick<KnowledgeRootDto, 'name' | 'path' | 'description' | 'access' | 'enabled'>
@@ -54,6 +55,22 @@ export const admin = {
     install: (name: string) => req<CodexPluginInstallResultDto>('POST', `/codex-plugins/${encodeURIComponent(name)}/install`),
     uninstall: (name: string) => req<{ ok: true }>('POST', `/codex-plugins/${encodeURIComponent(name)}/uninstall`),
   },
+  fs: {
+    roots: () => req<{ roots: FsRootDto[]; shortcuts: FsShortcutDto[] }>('GET', '/fs/roots'),
+    list: (root: string, path: string) => req<{ entries: FsEntryDto[]; truncated: boolean; sessionTitle: string | null }>('GET', `/fs/list?${fsq(root, path)}`),
+    readText: (root: string, path: string) => req<{ content: string; mtime: string }>('GET', `/fs/text?${fsq(root, path)}`),
+    writeText: (root: string, path: string, content: string, baseMtime: string | null) => req<{ mtime: string }>('PUT', `/fs/text?${fsq(root, path)}`, { content, baseMtime }),
+    mkdir: (root: string, path: string) => req<{ ok: true }>('POST', `/fs/mkdir?${fsq(root, path)}`),
+    move: (root: string, path: string, to: string) => req<{ ok: true }>('POST', `/fs/move?${fsq(root, path)}`, { to }),
+    remove: (root: string, path: string) => req<{ ok: true }>('DELETE', `/fs/entry?${fsq(root, path)}`),
+    upload: (root: string, dir: string, files: File[]) => {
+      const form = new FormData()
+      for (const f of files) form.append('file', f, f.name)
+      return req<FsEntryDto[]>('POST', `/fs/upload?${fsq(root, dir)}`, form)
+    },
+    downloadUrl: (root: string, path: string, inline = false) => `/admin/v1/fs/download?${fsq(root, path)}${inline ? '&inline=1' : ''}`,
+    zipUrl: (root: string, path: string) => `/admin/v1/fs/zip?${fsq(root, path)}`,
+  },
   customSkills: {
     list: () => req<CustomSkillDto[]>('GET', '/custom-skills'),
     create: (name: string, description: string) => req<CustomSkillDetailDto>('POST', '/custom-skills', { name, description }),

+ 34 - 0
apps/admin/src/lib/monaco.ts

@@ -0,0 +1,34 @@
+// Monaco 编辑器(按需加载):只引入编辑器核心(查找、折叠、右键菜单等)与常用语言的语法高亮、JSON 校验,
+// 不引入体积很大的 TypeScript 语言服务
+import * as monaco from 'monaco-editor/esm/vs/editor/editor.api'
+import 'monaco-editor/esm/vs/editor/edcore.main.js'
+import 'monaco-editor/esm/vs/basic-languages/markdown/markdown.contribution.js'
+import 'monaco-editor/esm/vs/basic-languages/python/python.contribution.js'
+import 'monaco-editor/esm/vs/basic-languages/javascript/javascript.contribution.js'
+import 'monaco-editor/esm/vs/basic-languages/typescript/typescript.contribution.js'
+import 'monaco-editor/esm/vs/basic-languages/shell/shell.contribution.js'
+import 'monaco-editor/esm/vs/basic-languages/yaml/yaml.contribution.js'
+import 'monaco-editor/esm/vs/basic-languages/html/html.contribution.js'
+import 'monaco-editor/esm/vs/basic-languages/css/css.contribution.js'
+import 'monaco-editor/esm/vs/basic-languages/xml/xml.contribution.js'
+import 'monaco-editor/esm/vs/basic-languages/sql/sql.contribution.js'
+import 'monaco-editor/esm/vs/basic-languages/ini/ini.contribution.js'
+import 'monaco-editor/esm/vs/basic-languages/r/r.contribution.js'
+import 'monaco-editor/esm/vs/language/json/monaco.contribution.js'
+import EditorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker'
+import JsonWorker from 'monaco-editor/esm/vs/language/json/json.worker?worker'
+
+self.MonacoEnvironment = {
+  getWorker: (_id: string, label: string) => (label === 'json' ? new JsonWorker() : new EditorWorker()),
+}
+
+const LANGS: [RegExp, string][] = [
+  [/\.(md|markdown|mdx)$/i, 'markdown'], [/\.py$/i, 'python'], [/\.(js|mjs|cjs)$/i, 'javascript'], [/\.ts$/i, 'typescript'],
+  [/\.(sh|bash|zsh)$/i, 'shell'], [/\.ya?ml$/i, 'yaml'], [/\.(json|jsonl)$/i, 'json'], [/\.html?$/i, 'html'], [/\.css$/i, 'css'],
+  [/\.(xml|svg)$/i, 'xml'], [/\.sql$/i, 'sql'], [/\.(ini|cfg|conf|toml|env)$/i, 'ini'], [/\.r$/i, 'r'],
+]
+
+/** 按文件名推断语言,未知的按纯文本 */
+export const languageFor = (name: string) => LANGS.find(([re]) => re.test(name))?.[1] ?? 'plaintext'
+
+export { monaco }

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

@@ -11,6 +11,7 @@ export const router = createRouter({
     { path: '/overview', component: () => import('./views/OverviewView.vue') },
     { path: '/profiles/:id?', name: 'profiles', component: () => import('./views/ProfilesView.vue') },
     { path: '/knowledge', component: () => import('./views/KnowledgeView.vue') },
+    { path: '/files', component: () => import('./views/FilesView.vue') },
     { path: '/plugins', component: () => import('./views/PluginsView.vue') },
     { path: '/mcp', component: () => import('./views/McpView.vue') },
     { path: '/api-keys', component: () => import('./views/ApiKeysView.vue') },

+ 291 - 0
apps/admin/src/views/FilesView.vue

@@ -0,0 +1,291 @@
+<script setup lang="ts">
+// 文件管理(管理员):会话工作区与资料库可读写,本地技能目录与运行环境只读;服务数据与凭证不可见。
+// 状态放在地址中(?root=&path=&file=),可以直接链接到某个目录或文件
+import { computed, defineAsyncComponent, onMounted, ref, watch } from 'vue'
+import { useRoute, useRouter } from 'vue-router'
+import type { FsEntryDto, FsRootDto, FsShortcutDto } from '@agent-server/shared'
+import PageHeader from '../components/PageHeader.vue'
+import { admin, errorMessage } from '../lib/api'
+import { formatTime, toast } from '../lib/ui'
+
+// 编辑器按需加载(首次打开文本文件时才下载 Monaco)
+const MonacoEditor = defineAsyncComponent(() => import('../components/MonacoEditor.vue'))
+
+const route = useRoute()
+const router = useRouter()
+const roots = ref<FsRootDto[]>([])
+const shortcuts = ref<FsShortcutDto[]>([])
+const entries = ref<FsEntryDto[]>([])
+const truncated = ref(false)
+const loading = ref(false)
+const busy = ref<string | null>(null)
+
+const rootId = computed(() => (route.query.root as string) || 'workspaces')
+const dir = computed(() => (route.query.path as string) || '')
+const filePath = computed(() => (route.query.file as string) || null)
+const root = computed(() => roots.value.find(r => r.id === rootId.value) ?? null)
+const writable = computed(() => !!root.value?.writable)
+const crumbs = computed(() => {
+  const parts = dir.value ? dir.value.split('/') : []
+  return parts.map((name, i) => ({ name, path: parts.slice(0, i + 1).join('/') }))
+})
+const join = (a: string, b: string) => (a ? `${a}/${b}` : b)
+/** 会话工作区的第一层是会话 ID,显示为会话标题 */
+const sessionTitle = ref<string | null>(null)
+const displayName = (e: FsEntryDto) => e.label ?? e.name
+
+function go(q: { root?: string; path?: string; file?: string | null }) {
+  const next = { root: q.root ?? rootId.value, path: q.path ?? dir.value, file: q.file === undefined ? filePath.value : q.file }
+  void router.push({ query: Object.fromEntries(Object.entries(next).filter(([, v]) => v)) as Record<string, string> })
+}
+
+async function loadRoots() {
+  try {
+    const r = await admin.fs.roots()
+    roots.value = r.roots
+    shortcuts.value = r.shortcuts
+  } catch (err) {
+    toast(errorMessage(err), 'error')
+  }
+}
+
+async function loadDir() {
+  if (!root.value?.exists) {
+    entries.value = []
+    return
+  }
+  loading.value = true
+  try {
+    const r = await admin.fs.list(rootId.value, dir.value)
+    entries.value = r.entries
+    truncated.value = r.truncated
+    sessionTitle.value = r.sessionTitle
+  } catch (err) {
+    entries.value = []
+    toast(errorMessage(err), 'error')
+  } finally {
+    loading.value = false
+  }
+}
+
+async function act(key: string, fn: () => Promise<unknown>, done?: string) {
+  busy.value = key
+  try {
+    await fn()
+    if (done) toast(done, 'success')
+    await loadDir()
+  } catch (err) {
+    toast(errorMessage(err), 'error')
+  } finally {
+    busy.value = null
+  }
+}
+
+function openEntry(e: FsEntryDto) {
+  if (e.type === 'dir') go({ path: join(dir.value, e.name), file: null })
+  else if (e.type === 'file') go({ file: join(dir.value, e.name) })
+}
+
+const newFolder = () => {
+  const name = window.prompt('新文件夹名称')?.trim()
+  if (name) void act('mkdir', () => admin.fs.mkdir(rootId.value, join(dir.value, name)), '已新建')
+}
+async function newFile() {
+  const name = window.prompt('新文件名称,例如 notes.md')?.trim()
+  if (!name) return
+  await act('new', () => admin.fs.writeText(rootId.value, join(dir.value, name), '', null))
+  go({ file: join(dir.value, name) })
+}
+function rename(e: FsEntryDto) {
+  const to = window.prompt('新名称(可以包含路径,如 ../archive/新名称)', e.name)?.trim()
+  if (!to || to === e.name) return
+  // 支持相对当前目录的简单路径(不允许越出位置,服务端会校验)
+  const parts = [...(dir.value ? dir.value.split('/') : [])]
+  for (const seg of to.split('/')) seg === '..' ? parts.pop() : seg && seg !== '.' && parts.push(seg)
+  void act('move', () => admin.fs.move(rootId.value, join(dir.value, e.name), parts.join('/')), '已移动')
+}
+function remove(e: FsEntryDto) {
+  if (!window.confirm(`删除${e.type === 'dir' ? '文件夹(包括其中全部内容)' : '文件'}「${e.name}」?此操作不可恢复。`)) return
+  const p = join(dir.value, e.name)
+  void act('delete', async () => {
+    await admin.fs.remove(rootId.value, p)
+    if (filePath.value && (filePath.value === p || filePath.value.startsWith(`${p}/`))) go({ file: null })
+  }, '已删除')
+}
+
+// 上传:按钮或拖放到列表
+const uploadInput = ref<HTMLInputElement>()
+const dragging = ref(false)
+async function upload(files: File[]) {
+  if (!files.length || !writable.value) return
+  await act('upload', () => admin.fs.upload(rootId.value, dir.value, files), `已上传 ${files.length} 个文件`)
+}
+function onDrop(e: DragEvent) {
+  dragging.value = false
+  void upload([...(e.dataTransfer?.files ?? [])])
+}
+
+// ---- 打开的文件:文本用 Monaco 编辑,图片预览,其他下载 ----
+const editor = ref<{ content: string; orig: string; mtime: string } | null>(null)
+const fileError = ref<string | null>(null)
+const fileName = computed(() => filePath.value?.split('/').pop() ?? '')
+const isImage = computed(() => /\.(png|jpe?g|gif|webp|bmp|svg)$/i.test(fileName.value))
+const dirty = computed(() => !!editor.value && editor.value.content !== editor.value.orig)
+
+async function loadFile() {
+  editor.value = null
+  fileError.value = null
+  if (!filePath.value || isImage.value) return
+  try {
+    const r = await admin.fs.readText(rootId.value, filePath.value)
+    editor.value = { content: r.content, orig: r.content, mtime: r.mtime }
+  } catch (err) {
+    fileError.value = errorMessage(err)
+  }
+}
+async function saveFile() {
+  if (!editor.value || !filePath.value || !writable.value || !dirty.value) return
+  busy.value = 'save'
+  try {
+    const r = await admin.fs.writeText(rootId.value, filePath.value, editor.value.content, editor.value.mtime)
+    editor.value.orig = editor.value.content
+    editor.value.mtime = r.mtime
+    toast('已保存', 'success')
+    await loadDir()
+  } catch (err) {
+    toast(errorMessage(err), 'error')
+  } finally {
+    busy.value = null
+  }
+}
+function closeFile() {
+  if (dirty.value && !window.confirm('有未保存的改动,确定关闭?')) return
+  go({ file: null })
+}
+
+const fmtSize = (n: number | null) => (n === null ? '' : n < 1024 ? `${n} B` : n < 1048576 ? `${(n / 1024).toFixed(1)} KB` : `${(n / 1048576).toFixed(1)} MB`)
+const icon = (e: FsEntryDto) => e.type === 'dir' ? 'codicon-folder' : e.type === 'link' ? 'codicon-file-symlink-file' : /\.(png|jpe?g|gif|webp|svg)$/i.test(e.name) ? 'codicon-file-media' : /\.(pdf|docx?|xlsx?|pptx?)$/i.test(e.name) ? 'codicon-file-pdf' : 'codicon-file'
+
+watch([rootId, dir], () => void loadDir())
+watch([rootId, filePath], () => void loadFile())
+onMounted(async () => {
+  await loadRoots()
+  await loadDir()
+  await loadFile()
+})
+</script>
+
+<template>
+  <div class="h-full flex flex-col">
+    <PageHeader title="文件管理" subtitle="以管理员身份管理会话工作区与资料库;本地技能目录与运行环境只读,服务数据与凭证不可见">
+      <button type="button" class="btn-secondary" @click="loadRoots(); loadDir()"><span class="codicon codicon-refresh" />刷新</button>
+    </PageHeader>
+    <div class="flex-1 min-h-0 flex text-[12.5px]">
+      <!-- 位置与常用目录 -->
+      <aside class="w-56 flex-none border-r border-r-solid border-border overflow-y-auto p-2 flex flex-col gap-3">
+        <div class="flex flex-col">
+          <div class="section-title px-2 mb-1">位置</div>
+          <button
+            v-for="r in roots" :key="r.id" type="button" :title="r.description"
+            class="flex items-center gap-1.5 px-2 py-1 rounded-sm border-none text-left cursor-pointer"
+            :class="r.id === rootId ? 'bg-active text-fg' : 'bg-transparent hover:bg-hover text-fg'" @click="go({ root: r.id, path: '', file: null })"
+          >
+            <span class="codicon text-[13px]" :class="r.kind === 'workspaces' ? 'codicon-folder-library' : r.kind === 'knowledge' ? 'codicon-library' : 'codicon-lock'" />
+            <span class="truncate flex-1" :class="{ 'opacity-50': !r.exists }">{{ r.name }}</span>
+            <span v-if="!r.writable" class="hint">只读</span>
+          </button>
+        </div>
+        <div class="flex flex-col">
+          <div class="section-title px-2 mb-1">常用目录</div>
+          <button
+            v-for="s in shortcuts" :key="`${s.rootId}:${s.path}`" type="button"
+            class="flex items-center gap-1.5 px-2 py-1 rounded-sm border-none bg-transparent hover:bg-hover text-left cursor-pointer text-fg"
+            :class="{ '!bg-active': s.rootId === rootId && s.path === dir }" @click="go({ root: s.rootId, path: s.path, file: null })"
+          >
+            <span class="codicon text-[13px] text-fg-muted" :class="`codicon-${s.icon}`" />
+            <span class="truncate">{{ s.label }}</span>
+          </button>
+        </div>
+      </aside>
+
+      <!-- 目录列表 -->
+      <section
+        class="min-w-0 flex flex-col border-r border-r-solid border-border" :class="filePath ? 'w-[420px] flex-none' : 'flex-1'"
+        @dragover.prevent="dragging = writable" @dragleave="dragging = false" @drop.prevent="onDrop"
+      >
+        <div class="flex-none flex items-center gap-1 px-3 h-9 border-b border-b-solid border-border">
+          <button type="button" class="border-none bg-transparent p-0 cursor-pointer text-link font-500" @click="go({ path: '', file: null })">{{ root?.name ?? rootId }}</button>
+          <template v-for="c in crumbs" :key="c.path">
+            <span class="codicon codicon-chevron-right text-fg-muted text-[11px]" />
+            <button type="button" class="border-none bg-transparent p-0 cursor-pointer text-link truncate max-w-40" @click="go({ path: c.path, file: null })">{{ rootId === 'workspaces' && c === crumbs[0] && sessionTitle ? sessionTitle : c.name }}</button>
+          </template>
+          <span class="flex-1" />
+          <template v-if="writable">
+            <button type="button" class="icon-btn" title="新建文件" @click="newFile"><span class="codicon codicon-new-file" /></button>
+            <button type="button" class="icon-btn" title="新建文件夹" @click="newFolder"><span class="codicon codicon-new-folder" /></button>
+            <button type="button" class="icon-btn" title="上传(也可以拖放到这里)" @click="uploadInput?.click()"><span class="codicon" :class="busy === 'upload' ? 'codicon-loading codicon-modifier-spin' : 'codicon-cloud-upload'" /></button>
+            <input ref="uploadInput" type="file" multiple class="hidden" aria-label="上传文件" @change="upload([...(($event.target as HTMLInputElement).files ?? [])]); ($event.target as HTMLInputElement).value = ''">
+          </template>
+          <a :href="admin.fs.zipUrl(rootId, dir)" class="icon-btn no-underline" title="打包下载当前目录"><span class="codicon codicon-desktop-download" /></a>
+        </div>
+        <div class="flex-1 min-h-0 overflow-y-auto relative" :class="{ 'outline outline-2 outline-dashed outline-[var(--app-input-active-border)] -outline-offset-4': dragging }">
+          <div v-if="root && !root.exists" class="p-4 hint">目录不存在:{{ root.description }}</div>
+          <table v-else class="w-full border-collapse">
+            <tbody>
+              <tr v-if="dir" class="hover:bg-hover cursor-pointer" @click="go({ path: crumbs.slice(0, -1).map(c => c.name).join('/'), file: null })">
+                <td class="px-3 py-1.5" colspan="4"><span class="codicon codicon-arrow-up text-fg-muted mr-1.5" />上一级</td>
+              </tr>
+              <tr
+                v-for="e in entries" :key="e.name" class="group cursor-pointer"
+                :class="filePath === join(dir, e.name) ? 'bg-active' : 'hover:bg-hover'" @click="openEntry(e)"
+              >
+                <td class="px-3 py-1.5 min-w-0">
+                  <span class="flex items-center gap-1.5 min-w-0">
+                    <span class="codicon text-[14px] flex-none" :class="[icon(e), e.type === 'dir' ? 'text-link' : 'text-fg-muted']" />
+                    <span class="truncate" :title="e.name">{{ displayName(e) }}</span>
+                    <span v-if="e.label" class="hint font-mono truncate">{{ e.name }}</span>
+                    <span v-if="e.type === 'link'" class="hint">(链接到位置之外,不跟随)</span>
+                  </span>
+                </td>
+                <td v-if="!filePath" class="px-2 py-1.5 text-right hint whitespace-nowrap">{{ fmtSize(e.size) }}</td>
+                <td v-if="!filePath" class="px-2 py-1.5 hint whitespace-nowrap">{{ formatTime(e.mtime) }}</td>
+                <td class="px-2 py-1 text-right whitespace-nowrap" @click.stop>
+                  <a v-if="e.type === 'file'" :href="admin.fs.downloadUrl(rootId, join(dir, e.name))" class="icon-btn no-underline opacity-0 group-hover:opacity-100" title="下载"><span class="codicon codicon-desktop-download" /></a>
+                  <template v-if="writable && !(rootId === 'workspaces' && !dir)">
+                    <button type="button" class="icon-btn opacity-0 group-hover:opacity-100" title="重命名 / 移动" @click="rename(e)"><span class="codicon codicon-edit" /></button>
+                    <button type="button" class="icon-btn opacity-0 group-hover:opacity-100" title="删除" @click="remove(e)"><span class="codicon codicon-trash" /></button>
+                  </template>
+                </td>
+              </tr>
+              <tr v-if="!loading && !entries.length"><td class="px-3 py-4 hint" colspan="4">空目录{{ writable ? '(可以把文件拖放到这里上传)' : '' }}</td></tr>
+            </tbody>
+          </table>
+          <div v-if="truncated" class="px-3 py-2 hint">条目过多,只显示前 5000 个</div>
+        </div>
+      </section>
+
+      <!-- 打开的文件 -->
+      <section v-if="filePath" class="flex-1 min-w-0 flex flex-col">
+        <div class="flex-none flex items-center gap-2 px-3 h-9 border-b border-b-solid border-border">
+          <span class="font-mono truncate" :title="filePath">{{ fileName }}</span>
+          <span v-if="dirty" class="badge bg-code text-warning">未保存</span>
+          <span v-if="!writable" class="badge bg-code">只读</span>
+          <span class="flex-1" />
+          <button v-if="editor && writable" type="button" class="btn-primary !h-6" :disabled="!dirty || busy === 'save'" title="Ctrl/⌘ + S" @click="saveFile">保存</button>
+          <a :href="admin.fs.downloadUrl(rootId, filePath, true)" target="_blank" rel="noopener" class="icon-btn no-underline" title="在新窗口打开"><span class="codicon codicon-link-external" /></a>
+          <a :href="admin.fs.downloadUrl(rootId, filePath)" class="icon-btn no-underline" title="下载"><span class="codicon codicon-desktop-download" /></a>
+          <button type="button" class="icon-btn" title="关闭" @click="closeFile"><span class="codicon codicon-close" /></button>
+        </div>
+        <div class="flex-1 min-h-0 p-2">
+          <img v-if="isImage" :src="admin.fs.downloadUrl(rootId, filePath, true)" :alt="fileName" class="max-w-full max-h-full object-contain block mx-auto">
+          <MonacoEditor v-else-if="editor" v-model="editor.content" :filename="fileName" :readonly="!writable" @save="saveFile" />
+          <div v-else-if="fileError" class="h-full flex flex-col items-center justify-center gap-2 hint">
+            <span>{{ fileError }}</span>
+            <a :href="admin.fs.downloadUrl(rootId, filePath, true)" target="_blank" rel="noopener" class="text-link">在新窗口打开</a>
+          </div>
+          <div v-else class="h-full flex items-center justify-center hint">加载中…</div>
+        </div>
+      </section>
+    </div>
+  </div>
+</template>

+ 6 - 2
apps/admin/src/views/ProfilesView.vue

@@ -1,5 +1,5 @@
 <script setup lang="ts">
-import { computed, onMounted, ref, watch } from 'vue'
+import { computed, defineAsyncComponent, onMounted, ref, watch } from 'vue'
 import { useRoute, useRouter } from 'vue-router'
 import type { KnowledgeRootDto, McpServerDto, PathRule, ProfileDto, ProfileInput, ProfileVersionDto, ProviderStatusDto, ResolvedAccessDto, SkillInfo } from '@agent-server/shared'
 import PageHeader from '../components/PageHeader.vue'
@@ -7,6 +7,9 @@ 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[]>([])
@@ -211,7 +214,8 @@ onMounted(() => load().catch(err => toast(errorMessage(err), 'error')))
 
           <section>
             <label class="label" for="p-prompt">系统提示词(追加在运行环境说明之后)</label>
-            <textarea id="p-prompt" v-model="draft.systemPrompt" class="field-area h-40" placeholder="例如:你是家庭财务分析助手,金额统一用人民币元……" />
+            <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">

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

@@ -25,6 +25,7 @@ import { McpUpstream } from './mcp-gateway/upstream'
 import { McpServerService } from './services/mcp-servers'
 import { CustomSkillService } from './custom-skills/service'
 import { SkillSourceService } from './custom-skills/sources'
+import { FileManagerService } from './services/file-manager'
 import { BrowserTokenService } from './services/browser-tokens'
 
 export interface AppLogger {
@@ -55,6 +56,7 @@ export function createApp(log: AppLogger, opts: { db?: DB; providers?: Partial<R
   // 按数据库重建生效目录(缺失或被改动的技能目录重新发布)
   customSkills.reconcile(msg => log.warn({}, msg))
   const skillSources = new SkillSourceService(db, customSkills, () => settings.get(), adminAudit)
+  const fileManager = new FileManagerService(db, () => settings.get(), () => knowledge.list(), files, adminAudit)
   const interactions = new InteractionService(db, events, settings)
   const sessions = new SessionService(db, settings, profiles, apiKeys, files, events)
   const adminAuth = new AdminAuthService(db, log, config.adminPassword)
@@ -71,7 +73,7 @@ export function createApp(log: AppLogger, opts: { db?: DB; providers?: Partial<R
   sessions.extraModels = p => providers[p]?.knownModels?.() ?? []
   const runs = new RunManager({ db, log, settings, sessions, profiles, knowledge, files, events, interactions, audit, usage, plugins, communitySkills, mcpServers, customSkills, gatewayTokens, providers })
 
-  return { db, log, settings, events, files, profiles, knowledge, apiKeys, audit, interactions, sessions, usage, browserTokens, apiPdf, mcpUpstream, mcpServers, customSkills, skillSources, adminAuth, plugins, claudeLogin, codexSkills, communitySkills, codexPlugins, gatewayTokens, providers, runs }
+  return { db, log, settings, events, files, profiles, knowledge, apiKeys, audit, interactions, sessions, usage, browserTokens, apiPdf, mcpUpstream, mcpServers, customSkills, skillSources, fileManager, adminAuth, plugins, claudeLogin, codexSkills, communitySkills, codexPlugins, gatewayTokens, providers, runs }
 }
 
 export type App = ReturnType<typeof createApp>

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

@@ -1,5 +1,7 @@
 // 管理 API:仅后台登录态可访问
+import fs from 'node:fs'
 import type { FastifyInstance, FastifyRequest } from 'fastify'
+import mime from 'mime-types'
 import { z } from 'zod'
 import { BUILTIN_GLOBAL_DENY, COMMUNITY_SKILL_PLUGIN, CUSTOM_SKILL_PLUGIN, type AppSettings, type McpServerInput, type DeepPartial, type FileAccessPolicy, type PluginAction } from '@agent-server/shared'
 import type { App } from '../../app'
@@ -201,6 +203,58 @@ export default async function adminRoutes(f: FastifyInstance, { app }: { app: Ap
       return app.mcpServers.test(b.input, b.id ?? null)
     })
 
+    // 文件管理(管理员):位置 + 相对路径
+    type FsQuery = { Querystring: { root?: string; path?: string; inline?: string } }
+    const fsRoot = (q: { root?: string }) => q.root ?? ''
+    g.get('/fs/roots', async () => ({ roots: app.fileManager.roots(), shortcuts: app.fileManager.shortcuts() }))
+    g.get<FsQuery>('/fs/list', async req => app.fileManager.list(fsRoot(req.query), req.query.path ?? ''))
+    g.get<FsQuery>('/fs/text', async req => app.fileManager.readText(fsRoot(req.query), req.query.path ?? ''))
+    g.put<FsQuery>('/fs/text', async req => {
+      const b = parse(z.object({ content: z.string(), baseMtime: z.string().nullable() }), req.body)
+      return app.fileManager.writeText(fsRoot(req.query), req.query.path ?? '', b.content, b.baseMtime)
+    })
+    g.get<FsQuery>('/fs/download', async (req, reply) => {
+      const f = app.fileManager.openFile(fsRoot(req.query), req.query.path ?? '')
+      const type = (mime.lookup(f.name) || 'application/octet-stream') as string
+      reply.header('Content-Type', type)
+      reply.header('Content-Disposition', `${req.query.inline ? 'inline' : 'attachment'}; filename*=UTF-8''${encodeURIComponent(f.name)}`)
+      reply.header('X-Content-Type-Options', 'nosniff')
+      // 预览 HTML / SVG 时禁止执行脚本
+      reply.header('Content-Security-Policy', "sandbox; default-src 'none'; img-src data: 'self'; style-src 'unsafe-inline'")
+      return reply.send(fs.createReadStream(f.abs))
+    })
+    g.get<FsQuery>('/fs/zip', async (req, reply) => {
+      const z_ = app.fileManager.zipDir(fsRoot(req.query), req.query.path ?? '')
+      reply.header('Content-Type', 'application/zip')
+      reply.header('Content-Disposition', `attachment; filename*=UTF-8''${encodeURIComponent(z_.name)}`)
+      return reply.send(z_.data)
+    })
+    g.post<FsQuery>('/fs/mkdir', async req => {
+      app.fileManager.mkdir(fsRoot(req.query), req.query.path ?? '')
+      return { ok: true }
+    })
+    g.post<FsQuery>('/fs/upload', async (req, reply) => {
+      const limit = app.settings.get().limits.uploadMaxMB * 1048576
+      const saved = []
+      for await (const part of req.files({ limits: { fileSize: limit, files: 50 } })) {
+        const data = await part.toBuffer()
+        if (part.file.truncated) throw new AppError(413, 'file_too_large', `文件超过 ${app.settings.get().limits.uploadMaxMB} MB`)
+        saved.push(app.fileManager.upload(fsRoot(req.query), req.query.path ?? '', part.filename, data))
+      }
+      if (!saved.length) throw badRequest('没有收到文件')
+      reply.code(201)
+      return saved
+    })
+    g.post<FsQuery>('/fs/move', async req => {
+      const b = parse(z.object({ to: z.string().min(1) }), req.body)
+      app.fileManager.move(fsRoot(req.query), req.query.path ?? '', b.to)
+      return { ok: true }
+    })
+    g.delete<FsQuery>('/fs/entry', async req => {
+      app.fileManager.delete(fsRoot(req.query), req.query.path ?? '')
+      return { ok: true }
+    })
+
     // 自定义技能(docs/design/12-custom-skills.md)
     type SkillParams = { Params: { id: string } }
     type FileQuery = { Params: { id: string }; Querystring: { path?: string; version?: string } }

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

@@ -100,6 +100,12 @@ function ruleToPath(rule: FileAccessPolicy['read'][number], roots: Map<string, K
 
 const matcherCache = new Map<string, (p: string) => boolean>()
 
+/** 路径是否命中全局禁止项(内置 + 设置中追加的);后台文件管理也遵守 */
+export function isGloballyDenied(target: string, settings: AppSettings): boolean {
+  const real = realpathLoose(target)
+  return globalDenyList(settings).some(e => matchesEntry(real, e))
+}
+
 function matchesEntry(target: string, entry: string): boolean {
   if (!hasGlob(entry)) return isWithin(target, entry)
   let m = matcherCache.get(entry)

+ 248 - 0
apps/server/src/services/file-manager.ts

@@ -0,0 +1,248 @@
+// 后台文件管理(管理员):只能访问登记的"位置"——会话工作区、资料库(可读写),本地技能目录、Python 运行环境(只读)。
+// 服务数据目录(数据库、版本库、MCP 数据)、服务账号 home(CLI 凭证)与全局禁止项一律不可见。
+// 路径以"位置 + 相对路径"表示;解析符号链接后必须仍在该位置之内
+import fs from 'node:fs'
+import path from 'node:path'
+import { desc, eq } from 'drizzle-orm'
+import { zipSync } from 'fflate'
+import type { AppSettings, FsEntryDto, FsRootDto, FsShortcutDto, KnowledgeRootDto } from '@agent-server/shared'
+import { config } from '../config'
+import type { DB } from '../db'
+import { sessions } from '../db/schema'
+import { AppError, badRequest, conflict, notFound } from '../lib/errors'
+import { isWithin, realpathLoose } from '../lib/paths'
+import { isGloballyDenied } from './access-policy'
+import type { FileService } from './files'
+
+const MAX_EDIT_BYTES = 2 * 1048576
+const MAX_ZIP_BYTES = 200 * 1048576
+const MAX_ZIP_FILES = 5000
+const MAX_LIST = 5000
+
+interface Root { id: string; name: string; kind: FsRootDto['kind']; path: string; writable: boolean; description: string }
+
+export class FileManagerService {
+  constructor(
+    private db: DB,
+    private settings: () => AppSettings,
+    private knowledge: () => KnowledgeRootDto[],
+    private files: FileService,
+    private audit: (summary: string, detail?: unknown) => void,
+  ) {}
+
+  roots(): FsRootDto[] {
+    return this.rootList().map(r => ({ id: r.id, name: r.name, kind: r.kind, writable: r.writable, description: r.description, exists: fs.existsSync(r.path) }))
+  }
+
+  /** 常用目录:工作区根目录、最近的会话、各资料库、本地技能目录 */
+  shortcuts(): FsShortcutDto[] {
+    const s = this.settings()
+    const out: FsShortcutDto[] = [{ label: '全部会话工作区', rootId: 'workspaces', path: '', icon: 'folder-library' }]
+    const recent = this.db.select({ id: sessions.id, title: sessions.title, dir: sessions.workspaceDir }).from(sessions).orderBy(desc(sessions.updatedAt)).limit(8).all()
+    for (const r of recent) {
+      if (!isWithin(r.dir, s.paths.workspaceRoot)) continue
+      out.push({ label: r.title || '新会话', rootId: 'workspaces', path: path.relative(s.paths.workspaceRoot, r.dir), icon: 'comment-discussion' })
+    }
+    for (const k of this.knowledge()) if (k.enabled) out.push({ label: k.name, rootId: `kb:${k.id}`, path: '', icon: 'library' })
+    out.push({ label: '本地技能目录', rootId: 'skills', path: '', icon: 'symbol-misc' })
+    return out
+  }
+
+  list(rootId: string, rel: string): { entries: FsEntryDto[]; truncated: boolean; sessionTitle: string | null } {
+    const { root, abs } = this.resolve(rootId, rel)
+    const titles = root.kind === 'workspaces' ? new Map(this.db.select({ id: sessions.id, title: sessions.title }).from(sessions).all().map(s => [s.id, s.title || '新会话'])) : new Map<string, string>()
+    const atWorkspaceRoot = root.kind === 'workspaces' && abs === realpathLoose(root.path)
+    if (!fs.statSync(abs).isDirectory()) throw badRequest('不是目录')
+    const entries: FsEntryDto[] = []
+    const names = fs.readdirSync(abs)
+    for (const name of names.slice(0, MAX_LIST)) {
+      const p = path.join(abs, name)
+      if (this.hidden(p)) continue
+      let st: fs.Stats
+      try {
+        st = fs.lstatSync(p)
+      } catch {
+        continue
+      }
+      let type: FsEntryDto['type'] = st.isDirectory() ? 'dir' : st.isFile() ? 'file' : st.isSymbolicLink() ? 'link' : 'other'
+      // 指向位置之内的符号链接按目标显示,指向外部的只显示为链接
+      if (type === 'link') {
+        const target = realpathLoose(p)
+        if (isWithin(target, realpathLoose(root.path)) && fs.existsSync(target)) type = fs.statSync(target).isDirectory() ? 'dir' : 'file'
+      }
+      entries.push({ name, type, size: st.isFile() ? st.size : null, mtime: st.mtime.toISOString(), ...(atWorkspaceRoot && titles.has(name) ? { label: titles.get(name) } : {}) })
+    }
+    entries.sort((a, b) => Number(b.type === 'dir') - Number(a.type === 'dir') || a.name.localeCompare(b.name))
+    const sessionId = root.kind === 'workspaces' ? path.relative(realpathLoose(root.path), abs).split(path.sep)[0] : ''
+    return { entries, truncated: names.length > MAX_LIST, sessionTitle: sessionId ? titles.get(sessionId) ?? null : null }
+  }
+
+  /** 读取文本(编辑用):不超过 2 MB,且必须是 UTF-8 文本 */
+  readText(rootId: string, rel: string): { content: string; mtime: string } {
+    const { abs } = this.resolve(rootId, rel)
+    const st = this.fileStat(abs)
+    if (st.size > MAX_EDIT_BYTES) throw badRequest(`文件超过 ${MAX_EDIT_BYTES / 1048576} MB,不能在线编辑,请下载`)
+    const buf = fs.readFileSync(abs)
+    if (buf.subarray(0, 8000).includes(0)) throw badRequest('这是二进制文件,不能在线编辑')
+    return { content: buf.toString('utf8'), mtime: st.mtime.toISOString() }
+  }
+
+  /** 下载或预览 */
+  openFile(rootId: string, rel: string): { abs: string; name: string; size: number } {
+    const { abs } = this.resolve(rootId, rel)
+    const st = this.fileStat(abs)
+    return { abs, name: path.basename(abs), size: st.size }
+  }
+
+  /** 保存文本:baseMtime 与磁盘不一致时拒绝(文件在打开后被别人修改了) */
+  writeText(rootId: string, rel: string, content: string, baseMtime: string | null): { mtime: string } {
+    const { root, abs } = this.resolve(rootId, rel, { write: true, allowMissing: true })
+    if (Buffer.byteLength(content) > MAX_EDIT_BYTES) throw badRequest(`内容超过 ${MAX_EDIT_BYTES / 1048576} MB`)
+    if (fs.existsSync(abs)) {
+      const st = this.fileStat(abs)
+      if (baseMtime && st.mtime.toISOString() !== baseMtime) throw conflict('file_changed', '文件在打开后被修改过,请重新加载后再保存')
+    } else if (baseMtime) {
+      throw conflict('file_changed', '文件已被删除或移动')
+    }
+    fs.mkdirSync(path.dirname(abs), { recursive: true })
+    fs.writeFileSync(abs, content)
+    this.audit(`文件管理:保存 ${root.name}/${rel}`)
+    this.afterChange(root, abs)
+    return { mtime: fs.statSync(abs).mtime.toISOString() }
+  }
+
+  mkdir(rootId: string, rel: string) {
+    const { root, abs } = this.resolve(rootId, rel, { write: true, allowMissing: true })
+    if (fs.existsSync(abs)) throw badRequest('已存在同名文件或目录')
+    fs.mkdirSync(abs, { recursive: true })
+    this.audit(`文件管理:新建目录 ${root.name}/${rel}`)
+  }
+
+  upload(rootId: string, relDir: string, name: string, data: Buffer): FsEntryDto {
+    const safe = path.basename(name).replace(/[\x00-\x1f/\\]/g, '_').trim()
+    if (!safe || safe === '.' || safe === '..') throw badRequest('文件名不正确')
+    const rel = relDir ? `${relDir.replace(/\/+$/, '')}/${safe}` : safe
+    const { root, abs } = this.resolve(rootId, rel, { write: true, allowMissing: true })
+    fs.mkdirSync(path.dirname(abs), { recursive: true })
+    fs.writeFileSync(abs, data)
+    this.audit(`文件管理:上传 ${root.name}/${rel}(${(data.length / 1024).toFixed(1)} KB)`)
+    this.afterChange(root, abs)
+    const st = fs.statSync(abs)
+    return { name: safe, type: 'file', size: st.size, mtime: st.mtime.toISOString() }
+  }
+
+  /** 重命名或移动(同一位置内) */
+  move(rootId: string, from: string, to: string) {
+    const src = this.resolve(rootId, from, { write: true })
+    const dst = this.resolve(rootId, to, { write: true, allowMissing: true })
+    if (src.abs === realpathLoose(src.root.path)) throw badRequest('不能移动位置的根目录')
+    if (fs.existsSync(dst.abs)) throw badRequest('目标已存在')
+    if (isWithin(dst.abs, src.abs)) throw badRequest('不能移动到自身之内')
+    this.protectSessionDir(src.root, src.abs)
+    fs.mkdirSync(path.dirname(dst.abs), { recursive: true })
+    fs.renameSync(src.abs, dst.abs)
+    this.audit(`文件管理:移动 ${src.root.name}/${from} → ${to}`)
+    this.afterChange(src.root, src.abs)
+    this.afterChange(dst.root, dst.abs)
+  }
+
+  delete(rootId: string, rel: string) {
+    const { root, abs } = this.resolve(rootId, rel, { write: true })
+    if (abs === realpathLoose(root.path)) throw badRequest('不能删除位置的根目录')
+    this.protectSessionDir(root, abs)
+    const isDir = fs.lstatSync(abs).isDirectory()
+    fs.rmSync(abs, { recursive: true, force: true })
+    this.audit(`文件管理:删除${isDir ? '目录' : '文件'} ${root.name}/${rel}`)
+    this.afterChange(root, abs)
+  }
+
+  /** 目录打包下载(跳过禁止的路径与符号链接) */
+  zipDir(rootId: string, rel: string): { name: string; data: Buffer } {
+    const { root, abs } = this.resolve(rootId, rel)
+    if (!fs.statSync(abs).isDirectory()) throw badRequest('不是目录')
+    const entries: Record<string, Uint8Array> = {}
+    let total = 0
+    let count = 0
+    const walk = (dir: string) => {
+      for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
+        const p = path.join(dir, e.name)
+        if (this.hidden(p) || e.isSymbolicLink()) continue
+        if (e.isDirectory()) walk(p)
+        else if (e.isFile()) {
+          const size = fs.statSync(p).size
+          total += size
+          if (++count > MAX_ZIP_FILES || total > MAX_ZIP_BYTES) throw badRequest(`目录太大(超过 ${MAX_ZIP_FILES} 个文件或 ${MAX_ZIP_BYTES / 1048576} MB),请分别下载`)
+          entries[path.relative(abs, p).split(path.sep).join('/')] = new Uint8Array(fs.readFileSync(p))
+        }
+      }
+    }
+    walk(abs)
+    const base = abs === realpathLoose(root.path) ? root.name : path.basename(abs)
+    return { name: `${base}.zip`, data: Buffer.from(zipSync(entries, { level: 6 })) }
+  }
+
+  // ---- 内部 ----
+
+  private rootList(): Root[] {
+    const s = this.settings()
+    const roots: Root[] = [
+      { id: 'workspaces', name: '会话工作区', kind: 'workspaces', path: s.paths.workspaceRoot, writable: true, description: '每个会话一个目录:uploads 上传的文件、outputs Agent 产出' },
+    ]
+    for (const k of this.knowledge()) {
+      roots.push({ id: `kb:${k.id}`, name: `资料库:${k.name}`, kind: 'knowledge', path: k.path, writable: true, description: `${k.description}${k.access === 'read' ? '(Agent 只读)' : '(Agent 可读写)'}` })
+    }
+    roots.push({ id: 'skills', name: '本地技能目录', kind: 'readonly', path: s.paths.skillsDir, writable: false, description: '随部署同步,在线修改会被覆盖,因此只读' })
+    roots.push({ id: 'runtime', name: 'Python 运行环境', kind: 'readonly', path: s.paths.pythonRuntime, writable: false, description: '随部署安装,只读' })
+    return roots
+  }
+
+  /** 服务数据目录、服务账号 home 与全局禁止项不可见 */
+  private hidden(abs: string): boolean {
+    const real = realpathLoose(abs)
+    const s = this.settings()
+    if (isWithin(real, realpathLoose(path.join(config.dataDir, 'data'))) || isWithin(real, realpathLoose(path.join(config.dataDir, 'home')))) return true
+    return isGloballyDenied(real, s)
+  }
+
+  private resolve(rootId: string, rel: string, opts: { write?: boolean; allowMissing?: boolean } = {}): { root: Root; abs: string } {
+    const root = this.rootList().find(r => r.id === rootId)
+    if (!root) throw notFound('位置')
+    if (opts.write && !root.writable) throw new AppError(403, 'readonly_root', `${root.name}是只读的`)
+    const rootReal = realpathLoose(root.path)
+    if (!fs.existsSync(rootReal)) throw notFound(`${root.name}(${root.path})`)
+    const clean = (rel ?? '').replace(/^\/+|\/+$/g, '')
+    // eslint-disable-next-line no-control-regex
+    if (/[\x00-\x1f\\]/.test(clean) || clean.split('/').some(p => p === '..' || p === '.')) throw badRequest('路径不正确')
+    const joined = path.join(rootReal, ...clean.split('/').filter(Boolean))
+    // 解析符号链接后仍须在位置之内(新建时检查父目录)
+    const exists = fs.existsSync(joined)
+    if (!exists && !opts.allowMissing) throw notFound(clean || root.name)
+    const real = exists ? realpathLoose(joined) : path.join(realpathLoose(path.dirname(joined)), path.basename(joined))
+    if (!isWithin(real, rootReal)) throw new AppError(403, 'outside_root', '路径指向位置之外')
+    if (this.hidden(real)) throw new AppError(403, 'denied', '这个路径不允许访问')
+    return { root, abs: real }
+  }
+
+  private fileStat(abs: string): fs.Stats {
+    const st = fs.statSync(abs)
+    if (!st.isFile()) throw badRequest('不是文件')
+    return st
+  }
+
+  /** 会话目录本身由会话管理,不能在这里删除或移动(删除会话请在会话页面) */
+  private protectSessionDir(root: Root, abs: string) {
+    if (root.kind === 'workspaces' && path.dirname(abs) === realpathLoose(root.path)) throw badRequest('会话目录不能在这里删除或移动,请在会话页面删除会话')
+  }
+
+  /** 会话工作区中的改动:重新登记文件,删除已不存在的记录 */
+  private afterChange(root: Root, abs: string) {
+    if (root.kind !== 'workspaces') return
+    const rel = path.relative(realpathLoose(root.path), abs)
+    const sessionId = rel.split(path.sep)[0]
+    if (!sessionId) return
+    const row = this.db.select().from(sessions).where(eq(sessions.id, sessionId)).get()
+    if (!row) return
+    this.files.prune(row.id, row.workspaceDir)
+    void this.files.scan(row.id, row.workspaceDir).catch(() => {})
+  }
+}

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

@@ -101,6 +101,18 @@ export class FileService {
     return changes
   }
 
+  /** 删除磁盘上已不存在的文件记录(后台文件管理删除或移动文件后调用) */
+  prune(sessionId: string, workspaceDir: string): number {
+    let removed = 0
+    for (const r of this.db.select().from(files).where(eq(files.sessionId, sessionId)).all()) {
+      if (!fs.existsSync(path.join(workspaceDir, r.relPath))) {
+        this.db.delete(files).where(eq(files.id, r.id)).run()
+        removed++
+      }
+    }
+    return removed
+  }
+
   deleteForSession(sessionId: string) {
     this.db.delete(files).where(eq(files.sessionId, sessionId)).run()
   }

+ 91 - 0
apps/server/test/file-manager.test.ts

@@ -0,0 +1,91 @@
+import fs from 'node:fs'
+import os from 'node:os'
+import path from 'node:path'
+import pino from 'pino'
+import { unzipSync } from 'fflate'
+import { beforeAll, describe, expect, it } from 'vitest'
+import { createApp, type App } from '../src/app'
+import { config } from '../src/config'
+
+let app: App
+let sid: string
+let ws: string
+let kbDir: string
+
+beforeAll(async () => {
+  app = createApp(pino({ level: 'silent' }), { providers: {} })
+  const builtin = app.profiles.getBuiltin().id
+  const key = app.apiKeys.create('fm', { allowedProfileIds: [builtin], defaultProfileId: builtin })
+  const s = await app.sessions.create(key.apiKey.id, {})
+  sid = s.id
+  ws = s.workspaceDir
+  fs.writeFileSync(path.join(ws, 'uploads', 'data.csv'), 'a,b\n1,2\n')
+  kbDir = fs.mkdtempSync(path.join(os.tmpdir(), 'kb-'))
+  fs.writeFileSync(path.join(kbDir, 'note.md'), '# 账单\n')
+  fs.symlinkSync('/etc', path.join(kbDir, 'etc-link'))
+  fs.mkdirSync(path.join(kbDir, '.ssh-like'))
+  app.knowledge.create({ name: '账单', path: kbDir, description: '家庭账单', access: 'read', enabled: true })
+})
+
+const kbRoot = () => app.fileManager.roots().find(r => r.kind === 'knowledge')!.id
+
+describe('后台文件管理', () => {
+  it('位置:会话工作区与资料库可写,本地技能与运行环境只读;常用目录包含最近的会话与资料库', () => {
+    const roots = app.fileManager.roots()
+    expect(roots.map(r => [r.id.split(':')[0], r.writable])).toEqual([['workspaces', true], ['kb', true], ['skills', false], ['runtime', false]])
+    const sc = app.fileManager.shortcuts()
+    expect(sc[0]).toMatchObject({ rootId: 'workspaces', path: '' })
+    expect(sc.some(s => s.rootId === 'workspaces' && s.path === sid)).toBe(true)
+    expect(sc.some(s => s.label === '账单')).toBe(true)
+  })
+
+  it('浏览、编辑(带冲突检查)、上传、新建目录、移动、删除;会话的文件记录随之更新', async () => {
+    expect(app.fileManager.list('workspaces', sid).entries.map(e => [e.name, e.type])).toEqual(expect.arrayContaining([['uploads', 'dir'], ['outputs', 'dir']]))
+    const t = app.fileManager.readText('workspaces', `${sid}/uploads/data.csv`)
+    expect(t.content).toBe('a,b\n1,2\n')
+    const saved = app.fileManager.writeText('workspaces', `${sid}/uploads/data.csv`, 'a,b\n3,4\n', t.mtime)
+    expect(fs.readFileSync(path.join(ws, 'uploads/data.csv'), 'utf8')).toBe('a,b\n3,4\n')
+    // 打开后文件被修改:拒绝保存
+    fs.writeFileSync(path.join(ws, 'uploads/data.csv'), 'changed')
+    fs.utimesSync(path.join(ws, 'uploads/data.csv'), new Date(), new Date(Date.now() + 5000))
+    expect(() => app.fileManager.writeText('workspaces', `${sid}/uploads/data.csv`, 'x', saved.mtime)).toThrow(/修改过/)
+
+    app.fileManager.mkdir('workspaces', `${sid}/outputs/drafts`)
+    app.fileManager.upload('workspaces', `${sid}/outputs/drafts`, 'report.md', Buffer.from('# 报告'))
+    await new Promise(r => setTimeout(r, 50))
+    expect(app.files.list(sid).map(f => f.path)).toContain('outputs/drafts/report.md')
+    app.fileManager.move('workspaces', `${sid}/outputs/drafts/report.md`, `${sid}/outputs/final.md`)
+    app.fileManager.delete('workspaces', `${sid}/outputs/drafts`)
+    await new Promise(r => setTimeout(r, 50))
+    const paths = app.files.list(sid).map(f => f.path)
+    expect(paths).toContain('outputs/final.md')
+    expect(paths).not.toContain('outputs/drafts/report.md')
+  })
+
+  it('保护:只读位置不能写;路径不能越出位置;外部符号链接不跟随;会话目录不能在这里删除', () => {
+    expect(() => app.fileManager.writeText('skills', 'x.md', 'x', null)).toThrow(/只读/)
+    expect(() => app.fileManager.list('workspaces', '../')).toThrow(/路径不正确/)
+    expect(() => app.fileManager.list('workspaces', `${sid}/../..`)).toThrow(/路径不正确/)
+    const kb = kbRoot()
+    expect(app.fileManager.list(kb, '').entries.find(e => e.name === 'etc-link')!.type).toBe('link')
+    expect(() => app.fileManager.readText(kb, 'etc-link/hostname')).toThrow(/位置之外/)
+    expect(() => app.fileManager.delete('workspaces', sid)).toThrow(/会话页面/)
+    expect(() => app.fileManager.delete('workspaces', '')).toThrow(/根目录/)
+  })
+
+  it('服务数据目录与全局禁止项不可见', () => {
+    // 包含服务数据目录的路径不能登记为资料库,文件管理也就无从进入
+    expect(() => app.knowledge.create({ name: '数据目录上层', path: config.dataDir, description: '', access: 'read', enabled: true })).toThrow(/全局禁止项/)
+    app.settings.update({ security: { globalDeny: [path.join(kbDir, '.ssh-like')] } })
+    expect(app.fileManager.list(kbRoot(), '').entries.map(e => e.name)).not.toContain('.ssh-like')
+    app.settings.update({ security: { globalDeny: [] } })
+  })
+
+  it('二进制文件不能在线编辑;目录可以打包下载', () => {
+    fs.writeFileSync(path.join(ws, 'outputs', 'img.bin'), Buffer.from([0, 1, 2, 0, 5]))
+    expect(() => app.fileManager.readText('workspaces', `${sid}/outputs/img.bin`)).toThrow(/二进制/)
+    const z = app.fileManager.zipDir('workspaces', `${sid}/outputs`)
+    expect(z.name).toBe('outputs.zip')
+    expect(Object.keys(unzipSync(new Uint8Array(z.data))).sort()).toEqual(['final.md', 'img.bin'])
+  })
+})

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

@@ -26,6 +26,7 @@
 | 🗂 会话 | 全部会话(所有平台/使用者) | 只读回放(时间线 + 原始事件 + 文件),可"在 Playground 中继续" |
 | 🧩 Profiles | Profile 列表 | 表单编辑:引擎、模型、提示词(Monaco 编辑器)、权限、工具、技能、资料库、**文件访问策略**(模式、可读/可写/禁止路径,右侧显示合并后的生效预览,见 06 §4.3);版本历史 |
 | 📚 资料库 | 资料目录列表 | 名称、路径(带目录选择器)、说明、读写属性、启用;可见性检测结果;被哪些 Profile 使用;索引状态(M3) |
+| 🗃 文件管理 | 位置 + 常用目录(全部会话工作区、最近 8 个会话、各资料库、本地技能目录) | 以管理员身份浏览与管理文件:会话工作区与资料库可写(新建文件/目录、上传与拖放、重命名/移动、删除、目录打包下载),本地技能目录与 Python 运行环境只读;文本文件用 Monaco 编辑(Ctrl/⌘+S 保存,打开后被改动则拒绝覆盖),图片预览,其他文件下载。服务数据目录、引擎 home 与全局禁止项不可见;路径不能越出位置,外部符号链接不跟随;会话目录本身不能在这里删除或移动;修改会话文件后同步该会话的文件记录。实现:`apps/server/src/services/file-manager.ts`、`/admin/v1/fs/*` |
 | 🔑 引擎状态 | Claude / Codex | 登录状态、账户摘要、额度、CLI 路径与版本、生效的工具清单(用于确认个人插件已被禁用);未登录时提示在终端执行 `claude` / `codex login` |
 | 🗝 API Keys | Key 列表 | 签发(明文只显示一次)、吊销、最近使用时间 |
 | 🔌 MCP 服务 | — | 内置工具(只读展示);第三方服务:添加远程 HTTP 或本地进程(沙箱)服务、测试连接、刷新工具、逐个工具设置策略(直接调用 / 跟随权限模式 / 关闭)、启停、删除;密钥只写不读;显示使用它的 Profile 与连接状态(ADR-0021) |

+ 1 - 1
docs/design/12-custom-skills.md

@@ -192,7 +192,7 @@
 
 | 草案 | 实施 | 原因 |
 | --- | --- | --- |
-| 文本文件用 Monaco 编辑("后台已在 Profile 提示词中使用") | 等宽文本框 | 后台并没有使用 Monaco(Profile 提示词是普通文本框),引入会增加数 MB 体积;以后需要再换 CodeMirror 等 |
+| 文本文件用 Monaco 编辑("后台已在 Profile 提示词中使用") | 起初为等宽文本框,现已改用 Monaco | 首版时后台并没有使用 Monaco;随文件管理一起引入 Monaco(按需加载,只打包常用语言),技能编辑器与 Profile 提示词都已换成 Monaco |
 | 发布后重新设置 Codex 的 `extraRoots` | 服务启动时就创建 `custom-skills/skills`,Codex 启动时把它加入 `extraRoots`;之后 Codex 每次列技能都会重新扫描该目录 | 已验证新增的技能无需重新设置即可出现;已加载会话按 ADR-0020 处理:自定义技能的发布状态指纹放进网关地址,变化时会话分叉 |
 | 草稿 `draftVersion` | 编辑中的改动保存在 `custom_skills.working_files`;"保存草稿"生成未发布的新版本,"保存并发布"同时发布 | 与 Profile 的"内容无变化不产生版本"一致,也便于"放弃改动"与"查看改动" |
 | 停用:运行时不加载 | 停用时整体移除生效目录,启用时按生效版本重新写入 | 两个引擎都只看生效目录,不需要各自过滤 |

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

@@ -671,3 +671,33 @@ export interface FileDiffDto {
   /** 文本文件的统一差异格式 */
   patch: string | null
 }
+
+// ---- 后台文件管理(管理员)----
+
+export interface FsRootDto {
+  /** workspaces、kb:<资料库 id>、skills、runtime */
+  id: string
+  name: string
+  kind: 'workspaces' | 'knowledge' | 'readonly'
+  writable: boolean
+  description: string
+  exists: boolean
+}
+
+export interface FsShortcutDto {
+  label: string
+  rootId: string
+  path: string
+  /** codicon 名称 */
+  icon: string
+}
+
+export interface FsEntryDto {
+  name: string
+  /** link:指向位置之外的符号链接(不跟随) */
+  type: 'file' | 'dir' | 'link' | 'other'
+  size: number | null
+  mtime: string
+  /** 会话工作区根目录下的会话目录:会话标题 */
+  label?: string
+}

+ 7 - 0
pnpm-lock.yaml

@@ -25,6 +25,9 @@ importers:
       '@vscode/codicons':
         specifier: 0.0.46-24
         version: 0.0.46-24
+      monaco-editor:
+        specifier: ^0.52.2
+        version: 0.52.2
       pinia:
         specifier: ^4.1.0
         version: 4.1.0(@vue/devtools-api@8.2.1)(typescript@5.9.3)(vue@3.5.44)
@@ -3551,6 +3554,10 @@ packages:
       pkg-types: 1.3.1
       ufo: 1.6.4
 
+  /monaco-editor@0.52.2:
+    resolution: {integrity: sha512-GEQWEZmfkOGLdd3XK8ryrfWz3AIP8YymVXiPHEdewrUq7mh0qrKrfHLNCXcbB6sTnMLnOZ3ztSiKcciFUkIJwQ==}
+    dev: false
+
   /ms@2.1.3:
     resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
     dev: false