feat(workspace): 新增工作区文件内容编辑功能,支持 Markdown 和 TXT 文件的保存与预览

This commit is contained in:
Wenjie Zhang 2026-05-01 21:26:51 +08:00
parent 981fd3170d
commit 596eeeaaac
8 changed files with 296 additions and 11 deletions

View File

@ -19,6 +19,9 @@ from yuxi.utils.datetime_utils import utc_isoformat_from_timestamp
from yuxi.utils.paths import WORKSPACE_DIR_NAME
EDITABLE_WORKSPACE_SUFFIXES = {".md", ".markdown", ".mdx", ".txt"}
def _workspace_root(user: User) -> Path:
try:
root = _global_user_data_dir(str(user.id)) / WORKSPACE_DIR_NAME
@ -143,6 +146,37 @@ async def read_workspace_file_content(*, path: str, current_user: User) -> dict:
}
async def write_workspace_file_content(*, path: str, content: str, current_user: User) -> dict:
root = _workspace_root(current_user)
target = _resolve_workspace_path(current_user, path)
if not target.exists():
raise HTTPException(status_code=404, detail="文件不存在")
if not target.is_file():
raise HTTPException(status_code=400, detail="当前路径是目录")
if target.suffix.lower() not in EDITABLE_WORKSPACE_SUFFIXES:
raise HTTPException(status_code=400, detail="当前文件类型不支持编辑")
raw_content = await asyncio.to_thread(target.read_bytes)
preview_type, supported, _message = _detect_preview_type(path, raw_content)
if preview_type not in {"markdown", "text"} or not supported:
raise HTTPException(status_code=400, detail="当前文件类型不支持编辑")
try:
raw_content.decode("utf-8")
except UnicodeDecodeError as exc:
raise HTTPException(status_code=400, detail="当前文件不是 UTF-8 文本") from exc
try:
await asyncio.to_thread(target.write_text, content, encoding="utf-8")
except PermissionError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return {
"success": True,
"path": _normalize_workspace_path(path).as_posix(),
"entry": _entry_for_path(root, target),
}
async def delete_workspace_path(*, path: str, current_user: User) -> dict:
root = _workspace_root(current_user)
target = _resolve_workspace_path(current_user, path)

View File

@ -11,6 +11,7 @@ from yuxi.services.workspace_service import (
list_workspace_tree,
read_workspace_file_content,
upload_workspace_file,
write_workspace_file_content,
)
from yuxi.storage.postgres.models_business import User
@ -22,6 +23,11 @@ class CreateWorkspaceDirectoryRequest(BaseModel):
name: str
class UpdateWorkspaceFileContentRequest(BaseModel):
path: str
content: str
@workspace.get("/tree", response_model=dict)
async def get_workspace_tree(
path: str = Query("/", description="工作区目录路径"),
@ -38,6 +44,18 @@ async def get_workspace_file(
return await read_workspace_file_content(path=path, current_user=current_user)
@workspace.put("/file", response_model=dict)
async def update_workspace_file(
payload: UpdateWorkspaceFileContentRequest,
current_user: User = Depends(get_required_user),
):
return await write_workspace_file_content(
path=payload.path,
content=payload.content,
current_user=current_user,
)
@workspace.delete("/file", response_model=dict)
async def delete_workspace_file_route(
path: str = Query(..., description="工作区文件或目录路径"),

View File

@ -3,6 +3,9 @@ from __future__ import annotations
from pathlib import Path
from types import SimpleNamespace
import pytest
from fastapi import HTTPException
from yuxi.agents.backends.sandbox import paths as workspace_paths
from yuxi.services import workspace_service as svc
@ -38,3 +41,76 @@ def test_workspace_root_keeps_existing_agents_prompt_file(tmp_path: Path, monkey
assert root == tmp_path / "threads" / "shared" / "user-1" / "workspace"
assert agents_file.read_text(encoding="utf-8") == "保留已有内容"
@pytest.mark.asyncio
async def test_write_workspace_file_content_updates_markdown_file(tmp_path: Path, monkeypatch) -> None:
monkeypatch.setattr(workspace_paths.conf, "save_dir", str(tmp_path))
user = SimpleNamespace(id="user-1")
root = svc._workspace_root(user)
target = root / "note.md"
target.write_text("旧内容", encoding="utf-8")
result = await svc.write_workspace_file_content(path="/note.md", content="# 新内容", current_user=user)
assert result["success"] is True
assert result["path"] == "/note.md"
assert result["entry"]["path"] == "/note.md"
assert target.read_text(encoding="utf-8") == "# 新内容"
@pytest.mark.asyncio
async def test_write_workspace_file_content_updates_txt_file(tmp_path: Path, monkeypatch) -> None:
monkeypatch.setattr(workspace_paths.conf, "save_dir", str(tmp_path))
user = SimpleNamespace(id="user-1")
root = svc._workspace_root(user)
target = root / "note.txt"
target.write_text("old", encoding="utf-8")
await svc.write_workspace_file_content(path="/note.txt", content="new", current_user=user)
assert target.read_text(encoding="utf-8") == "new"
@pytest.mark.asyncio
async def test_write_workspace_file_content_rejects_unsupported_suffix(tmp_path: Path, monkeypatch) -> None:
monkeypatch.setattr(workspace_paths.conf, "save_dir", str(tmp_path))
user = SimpleNamespace(id="user-1")
root = svc._workspace_root(user)
target = root / "script.py"
target.write_text("print('hello')", encoding="utf-8")
with pytest.raises(HTTPException) as exc_info:
await svc.write_workspace_file_content(path="/script.py", content="print('bye')", current_user=user)
assert exc_info.value.status_code == 400
assert target.read_text(encoding="utf-8") == "print('hello')"
@pytest.mark.asyncio
async def test_write_workspace_file_content_rejects_directory_and_missing_file(tmp_path: Path, monkeypatch) -> None:
monkeypatch.setattr(workspace_paths.conf, "save_dir", str(tmp_path))
user = SimpleNamespace(id="user-1")
svc._workspace_root(user)
with pytest.raises(HTTPException) as directory_error:
await svc.write_workspace_file_content(path="/agents/", content="x", current_user=user)
with pytest.raises(HTTPException) as missing_error:
await svc.write_workspace_file_content(path="/missing.md", content="x", current_user=user)
assert directory_error.value.status_code == 400
assert missing_error.value.status_code == 404
@pytest.mark.asyncio
async def test_write_workspace_file_content_blocks_path_traversal(tmp_path: Path, monkeypatch) -> None:
monkeypatch.setattr(workspace_paths.conf, "save_dir", str(tmp_path))
with pytest.raises(HTTPException) as exc_info:
await svc.write_workspace_file_content(
path="/../outside.md",
content="x",
current_user=SimpleNamespace(id="user-1"),
)
assert exc_info.value.status_code == 403

View File

@ -37,7 +37,7 @@
### 0.6.2 开发记录
<!-- 0.6.2 的内容请放在这里 -->
- 新增个人工作区预览与管理:提供独立于对话 thread 的用户级 workspace API并增加“工作区”页面用于浏览个人 workspace 文件、预览 Markdown/文本/代码/图片/PDF支持新建文件夹、上传文件、下载文件、删除文件/文件夹和多选删除;知识库与团队空间入口先展示到占位层级;默认创建 `agents/AGENTS.md`,并在 Agent 执行时将其内容追加到系统提示词。
- 新增个人工作区预览与管理:提供独立于对话 thread 的用户级 workspace API并增加“工作区”页面用于浏览个人 workspace 文件、预览 Markdown/文本/代码/图片/PDF支持新建文件夹、上传文件、下载文件、删除文件/文件夹和多选删除;工作区预览支持 Markdown/TXT 在右侧预览框内切换编辑并保存,其他格式和非工作区预览默认只读;知识库与团队空间入口先展示到占位层级;默认创建 `agents/AGENTS.md`,并在 Agent 执行时将其内容追加到系统提示词。
- 扩展管理界面交互逻辑重构:将 MCP / Subagents / Skills 三个标签页从「左侧边栏 + 右侧详情面板」布局重构为「卡片式网格布局 + 路由跳转二级页面」布局,工具标签页改为卡片网格布局 + 弹窗详情(保持弹窗内容不变)。新增共享组件 `ExtensionCard`、`ExtensionCardGrid`、`ExtensionToolbar`、`ExtensionDetailLayout`,详情页(`McpDetailView`、`SubagentDetailView`、`SkillDetailView`)使用居中宽度限制,路由规划为 `/extensions/mcp/:name`、`/extensions/subagent/:name`、`/extensions/skill/:slug`。
- 统一卡片样式:`ExtensionCard` 新增 `tags` prop 支持传入 `[{label, color}]` 数组,内部使用 `<a-tag bordered=false size=small>` 渲染,与知识库卡片标签风格统一;知识库列表页 `DataBaseView` 改用 `ExtensionCard` + `ExtensionCardGrid` 替代原有自定义卡片,移除冗余 card 样式。
- 调整应用主导航:`AppLayout` 从默认窄栏升级为默认展开的侧边栏,保留折叠态图标导航;侧边栏样式收敛为 14px 文本 + 18px 图标的标准紧凑密度并统一导航项、任务中心、GitHub、用户信息的图标与文字对齐。折叠态改为仅通过显式按钮展开避免空白区域误触发。

View File

@ -1,4 +1,4 @@
import { apiDelete, apiGet, apiPost } from './base'
import { apiDelete, apiGet, apiPost, apiPut } from './base'
const buildQuery = (params) => {
const query = new URLSearchParams()
@ -20,6 +20,10 @@ export const getWorkspaceFileContent = (path) => {
return apiGet(`/api/workspace/file?${query}`)
}
export const saveWorkspaceFileContent = (path, content) => {
return apiPut('/api/workspace/file', { path, content })
}
export const deleteWorkspacePath = (path) => {
const query = buildQuery({ path })
return apiDelete(`/api/workspace/file?${query}`)

View File

@ -9,6 +9,42 @@
<span class="file-path-title">{{ filePath }}</span>
</div>
<div class="modal-actions">
<div v-if="canEdit" class="preview-mode-switch">
<button
class="preview-mode-btn"
:class="{ active: editMode === 'preview' }"
@click="editMode = 'preview'"
title="预览"
>
<Eye :size="16" />
</button>
<button
class="preview-mode-btn"
:class="{ active: editMode === 'edit' }"
@click="editMode = 'edit'"
title="编辑"
>
<Pencil :size="16" />
</button>
</div>
<button
v-if="canEdit && editMode === 'edit'"
class="modal-action-btn"
:disabled="saving || !draftChanged"
@click="requestSave"
title="保存"
>
<Save :size="18" />
</button>
<button
v-if="canEdit && editMode === 'edit'"
class="modal-action-btn"
:disabled="saving"
@click="cancelEdit"
title="取消编辑"
>
<X :size="18" />
</button>
<div v-if="isHtmlFile" class="preview-mode-switch">
<button
class="preview-mode-btn"
@ -50,7 +86,15 @@
</div>
<div class="file-content" :class="contentClass">
<template v-if="file?.previewType === 'image' && file?.previewUrl">
<template v-if="canEdit && editMode === 'edit'">
<textarea
v-model="draftContent"
class="file-edit-textarea"
:disabled="saving"
spellcheck="false"
/>
</template>
<template v-else-if="file?.previewType === 'image' && file?.previewUrl">
<div class="image-preview-wrapper">
<img :src="file.previewUrl" :alt="filePath" class="image-preview" />
</div>
@ -190,13 +234,20 @@
<script setup>
import { computed, onUnmounted, ref, watch } from 'vue'
import { Code2, Download, Globe, Maximize2, X } from 'lucide-vue-next'
import { Code2, Download, Eye, Globe, Maximize2, Pencil, Save, X } from 'lucide-vue-next'
import { MdPreview } from 'md-editor-v3'
import hljs from 'highlight.js/lib/common'
import 'md-editor-v3/lib/preview.css'
import { useThemeStore } from '@/stores/theme'
import { getFileIcon, getFileIconColor } from '@/utils/file_utils'
import { getCodeLanguageByPath, isHtmlPreview, isMarkdownPreview } from '@/utils/file_preview'
import {
getCodeLanguageByPath,
getPreviewFileExtension,
isHtmlPreview,
isMarkdownPreview
} from '@/utils/file_preview'
const EDITABLE_EXTENSIONS = new Set(['.md', '.markdown', '.mdx', '.txt'])
const props = defineProps({
file: {
@ -227,6 +278,14 @@ const props = defineProps({
type: Boolean,
default: false
},
editable: {
type: Boolean,
default: false
},
saving: {
type: Boolean,
default: false
},
containerClass: {
type: [String, Array, Object],
default: ''
@ -237,15 +296,26 @@ const props = defineProps({
}
})
defineEmits(['close', 'download'])
const emit = defineEmits(['close', 'download', 'save'])
const themeStore = useThemeStore()
const theme = computed(() => (themeStore.isDark ? 'dark' : 'light'))
const htmlPreviewMode = ref('render')
const editMode = ref('preview')
const draftContent = ref('')
const fullscreenPreviewVisible = ref(false)
const htmlPreviewRenderKey = ref(0)
const isMarkdown = computed(() => isMarkdownPreview(props.filePath, props.file?.previewType))
const canEdit = computed(
() =>
props.editable &&
props.file?.supported !== false &&
typeof props.file?.content === 'string' &&
EDITABLE_EXTENSIONS.has(getPreviewFileExtension(props.filePath))
)
const savedContent = computed(() => formatContent(props.file?.content))
const draftChanged = computed(() => draftContent.value !== savedContent.value)
const isHtmlFile = computed(
() =>
props.file?.previewType === 'text' &&
@ -293,6 +363,20 @@ const formatContent = (content) => {
return String(content)
}
const syncDraftContent = () => {
draftContent.value = savedContent.value
editMode.value = 'preview'
}
const requestSave = () => {
if (!canEdit.value || props.saving) return
emit('save', draftContent.value)
}
const cancelEdit = () => {
syncDraftContent()
}
const openFullscreenPreview = () => {
if (!props.file) return
fullscreenPreviewVisible.value = true
@ -309,6 +393,8 @@ watch(
}
)
watch([() => props.filePath, () => props.file?.content, canEdit], syncDraftContent, { immediate: true })
watch([() => props.filePath, () => props.file?.previewType, () => props.file?.content], () => {
if (isHtmlFile.value) {
htmlPreviewRenderKey.value += 1
@ -381,8 +467,8 @@ onUnmounted(() => {
.modal-action-btn,
.preview-mode-btn {
width: 32px;
height: 32px;
width: 24px;
height: 24px;
display: inline-flex;
align-items: center;
justify-content: center;
@ -401,6 +487,18 @@ onUnmounted(() => {
color: var(--gray-900);
}
.modal-action-btn:disabled,
.preview-mode-btn:disabled {
color: var(--gray-300);
cursor: not-allowed;
}
.modal-action-btn:disabled:hover,
.preview-mode-btn:disabled:hover {
background: transparent;
color: var(--gray-300);
}
.preview-mode-btn.active {
background: var(--gray-0);
color: var(--gray-900);
@ -436,6 +534,25 @@ onUnmounted(() => {
}
}
.file-edit-textarea {
width: 100%;
min-height: calc(80vh - 40px);
padding: 12px;
border: 0;
outline: none;
resize: none;
background: var(--gray-0);
color: var(--gray-1000);
font-family: 'JetBrains Mono', 'Fira Code', 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
font-size: 13px;
line-height: 1.5;
}
.file-edit-textarea:disabled {
color: var(--gray-600);
background: var(--gray-25);
}
.file-content pre,
.file-content-pre {
font-family: 'JetBrains Mono', 'Fira Code', 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;

View File

@ -8,9 +8,12 @@
:show-close="true"
:show-fullscreen="true"
:full-height="true"
:editable="editable"
:saving="saving"
container-class="workspace-preview-container"
content-class="workspace-preview-content"
@close="$emit('close')"
@save="$emit('save', $event)"
/>
<div v-else-if="loading" class="preview-state">
<a-spin />
@ -19,7 +22,7 @@
<div v-else class="preview-empty">
<FileSearch :size="28" />
<h3>选择文件以预览</h3>
<p>支持 Markdown文本代码图片与 PDF 只读预览</p>
<p>支持 MarkdownTXT 编辑其他格式保持只读预览</p>
</div>
</aside>
</template>
@ -31,10 +34,12 @@ import AgentFilePreview from '@/components/AgentFilePreview.vue'
defineProps({
file: { type: Object, default: null },
filePath: { type: String, default: '' },
loading: { type: Boolean, default: false }
loading: { type: Boolean, default: false },
editable: { type: Boolean, default: false },
saving: { type: Boolean, default: false }
})
defineEmits(['close'])
defineEmits(['close', 'save'])
</script>
<style scoped lang="less">

View File

@ -79,7 +79,10 @@
:file="previewFile"
:file-path="selectedEntry?.path || ''"
:loading="loadingPreview"
:editable="true"
:saving="savingPreviewFile"
@close="closePreview"
@save="handleSavePreviewFile"
/>
</template>
@ -123,7 +126,10 @@
:showClose="true"
:showDownload="false"
:showFullscreen="true"
:editable="activeSourceKey === 'personal'"
:saving="savingPreviewFile"
@close="closePreview"
@save="handleSavePreviewFile"
/>
</a-modal>
</div>
@ -145,6 +151,7 @@ import {
downloadWorkspaceFile,
getWorkspaceFileContent,
getWorkspaceTree,
saveWorkspaceFileContent,
uploadWorkspaceFile
} from '@/apis/workspace_api'
@ -159,6 +166,7 @@ const previewObjectUrl = ref('')
const previewModalVisible = ref(false)
const loadingTree = ref(false)
const loadingPreview = ref(false)
const savingPreviewFile = ref(false)
const loadingDatabases = ref(false)
const databases = ref([])
const selectedDatabase = ref(null)
@ -339,6 +347,29 @@ const closePreview = () => {
revokePreviewObjectUrl()
}
const handleSavePreviewFile = async (content) => {
if (!selectedEntry.value?.path || savingPreviewFile.value) return
savingPreviewFile.value = true
try {
const response = await saveWorkspaceFileContent(selectedEntry.value.path, content)
if (response.entry) {
selectedEntry.value = response.entry
}
previewFile.value = {
...previewFile.value,
content
}
await loadWorkspaceEntries(currentPath.value)
message.success('文件保存成功')
} catch (error) {
console.warn('保存工作区文件失败:', error)
message.error(error?.message || '文件保存失败')
} finally {
savingPreviewFile.value = false
}
}
const openCreateDirectoryModal = () => {
if (activeSourceKey.value !== 'personal') return
newDirectoryName.value = ''