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 from yuxi.utils.paths import WORKSPACE_DIR_NAME
EDITABLE_WORKSPACE_SUFFIXES = {".md", ".markdown", ".mdx", ".txt"}
def _workspace_root(user: User) -> Path: def _workspace_root(user: User) -> Path:
try: try:
root = _global_user_data_dir(str(user.id)) / WORKSPACE_DIR_NAME 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: async def delete_workspace_path(*, path: str, current_user: User) -> dict:
root = _workspace_root(current_user) root = _workspace_root(current_user)
target = _resolve_workspace_path(current_user, path) target = _resolve_workspace_path(current_user, path)

View File

@ -11,6 +11,7 @@ from yuxi.services.workspace_service import (
list_workspace_tree, list_workspace_tree,
read_workspace_file_content, read_workspace_file_content,
upload_workspace_file, upload_workspace_file,
write_workspace_file_content,
) )
from yuxi.storage.postgres.models_business import User from yuxi.storage.postgres.models_business import User
@ -22,6 +23,11 @@ class CreateWorkspaceDirectoryRequest(BaseModel):
name: str name: str
class UpdateWorkspaceFileContentRequest(BaseModel):
path: str
content: str
@workspace.get("/tree", response_model=dict) @workspace.get("/tree", response_model=dict)
async def get_workspace_tree( async def get_workspace_tree(
path: str = Query("/", description="工作区目录路径"), 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) 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) @workspace.delete("/file", response_model=dict)
async def delete_workspace_file_route( async def delete_workspace_file_route(
path: str = Query(..., description="工作区文件或目录路径"), path: str = Query(..., description="工作区文件或目录路径"),

View File

@ -3,6 +3,9 @@ from __future__ import annotations
from pathlib import Path from pathlib import Path
from types import SimpleNamespace from types import SimpleNamespace
import pytest
from fastapi import HTTPException
from yuxi.agents.backends.sandbox import paths as workspace_paths from yuxi.agents.backends.sandbox import paths as workspace_paths
from yuxi.services import workspace_service as svc 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 root == tmp_path / "threads" / "shared" / "user-1" / "workspace"
assert agents_file.read_text(encoding="utf-8") == "保留已有内容" 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 开发记录
<!-- 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`。 - 扩展管理界面交互逻辑重构:将 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 样式。 - 统一卡片样式:`ExtensionCard` 新增 `tags` prop 支持传入 `[{label, color}]` 数组,内部使用 `<a-tag bordered=false size=small>` 渲染,与知识库卡片标签风格统一;知识库列表页 `DataBaseView` 改用 `ExtensionCard` + `ExtensionCardGrid` 替代原有自定义卡片,移除冗余 card 样式。
- 调整应用主导航:`AppLayout` 从默认窄栏升级为默认展开的侧边栏,保留折叠态图标导航;侧边栏样式收敛为 14px 文本 + 18px 图标的标准紧凑密度并统一导航项、任务中心、GitHub、用户信息的图标与文字对齐。折叠态改为仅通过显式按钮展开避免空白区域误触发。 - 调整应用主导航:`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 buildQuery = (params) => {
const query = new URLSearchParams() const query = new URLSearchParams()
@ -20,6 +20,10 @@ export const getWorkspaceFileContent = (path) => {
return apiGet(`/api/workspace/file?${query}`) return apiGet(`/api/workspace/file?${query}`)
} }
export const saveWorkspaceFileContent = (path, content) => {
return apiPut('/api/workspace/file', { path, content })
}
export const deleteWorkspacePath = (path) => { export const deleteWorkspacePath = (path) => {
const query = buildQuery({ path }) const query = buildQuery({ path })
return apiDelete(`/api/workspace/file?${query}`) return apiDelete(`/api/workspace/file?${query}`)

View File

@ -9,6 +9,42 @@
<span class="file-path-title">{{ filePath }}</span> <span class="file-path-title">{{ filePath }}</span>
</div> </div>
<div class="modal-actions"> <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"> <div v-if="isHtmlFile" class="preview-mode-switch">
<button <button
class="preview-mode-btn" class="preview-mode-btn"
@ -50,7 +86,15 @@
</div> </div>
<div class="file-content" :class="contentClass"> <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"> <div class="image-preview-wrapper">
<img :src="file.previewUrl" :alt="filePath" class="image-preview" /> <img :src="file.previewUrl" :alt="filePath" class="image-preview" />
</div> </div>
@ -190,13 +234,20 @@
<script setup> <script setup>
import { computed, onUnmounted, ref, watch } from 'vue' 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 { MdPreview } from 'md-editor-v3'
import hljs from 'highlight.js/lib/common' import hljs from 'highlight.js/lib/common'
import 'md-editor-v3/lib/preview.css' import 'md-editor-v3/lib/preview.css'
import { useThemeStore } from '@/stores/theme' import { useThemeStore } from '@/stores/theme'
import { getFileIcon, getFileIconColor } from '@/utils/file_utils' 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({ const props = defineProps({
file: { file: {
@ -227,6 +278,14 @@ const props = defineProps({
type: Boolean, type: Boolean,
default: false default: false
}, },
editable: {
type: Boolean,
default: false
},
saving: {
type: Boolean,
default: false
},
containerClass: { containerClass: {
type: [String, Array, Object], type: [String, Array, Object],
default: '' default: ''
@ -237,15 +296,26 @@ const props = defineProps({
} }
}) })
defineEmits(['close', 'download']) const emit = defineEmits(['close', 'download', 'save'])
const themeStore = useThemeStore() const themeStore = useThemeStore()
const theme = computed(() => (themeStore.isDark ? 'dark' : 'light')) const theme = computed(() => (themeStore.isDark ? 'dark' : 'light'))
const htmlPreviewMode = ref('render') const htmlPreviewMode = ref('render')
const editMode = ref('preview')
const draftContent = ref('')
const fullscreenPreviewVisible = ref(false) const fullscreenPreviewVisible = ref(false)
const htmlPreviewRenderKey = ref(0) const htmlPreviewRenderKey = ref(0)
const isMarkdown = computed(() => isMarkdownPreview(props.filePath, props.file?.previewType)) 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( const isHtmlFile = computed(
() => () =>
props.file?.previewType === 'text' && props.file?.previewType === 'text' &&
@ -293,6 +363,20 @@ const formatContent = (content) => {
return String(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 = () => { const openFullscreenPreview = () => {
if (!props.file) return if (!props.file) return
fullscreenPreviewVisible.value = true 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], () => { watch([() => props.filePath, () => props.file?.previewType, () => props.file?.content], () => {
if (isHtmlFile.value) { if (isHtmlFile.value) {
htmlPreviewRenderKey.value += 1 htmlPreviewRenderKey.value += 1
@ -381,8 +467,8 @@ onUnmounted(() => {
.modal-action-btn, .modal-action-btn,
.preview-mode-btn { .preview-mode-btn {
width: 32px; width: 24px;
height: 32px; height: 24px;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@ -401,6 +487,18 @@ onUnmounted(() => {
color: var(--gray-900); 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 { .preview-mode-btn.active {
background: var(--gray-0); background: var(--gray-0);
color: var(--gray-900); 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,
.file-content-pre { .file-content-pre {
font-family: 'JetBrains Mono', 'Fira Code', 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; font-family: 'JetBrains Mono', 'Fira Code', 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;

View File

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

View File

@ -79,7 +79,10 @@
:file="previewFile" :file="previewFile"
:file-path="selectedEntry?.path || ''" :file-path="selectedEntry?.path || ''"
:loading="loadingPreview" :loading="loadingPreview"
:editable="true"
:saving="savingPreviewFile"
@close="closePreview" @close="closePreview"
@save="handleSavePreviewFile"
/> />
</template> </template>
@ -123,7 +126,10 @@
:showClose="true" :showClose="true"
:showDownload="false" :showDownload="false"
:showFullscreen="true" :showFullscreen="true"
:editable="activeSourceKey === 'personal'"
:saving="savingPreviewFile"
@close="closePreview" @close="closePreview"
@save="handleSavePreviewFile"
/> />
</a-modal> </a-modal>
</div> </div>
@ -145,6 +151,7 @@ import {
downloadWorkspaceFile, downloadWorkspaceFile,
getWorkspaceFileContent, getWorkspaceFileContent,
getWorkspaceTree, getWorkspaceTree,
saveWorkspaceFileContent,
uploadWorkspaceFile uploadWorkspaceFile
} from '@/apis/workspace_api' } from '@/apis/workspace_api'
@ -159,6 +166,7 @@ const previewObjectUrl = ref('')
const previewModalVisible = ref(false) const previewModalVisible = ref(false)
const loadingTree = ref(false) const loadingTree = ref(false)
const loadingPreview = ref(false) const loadingPreview = ref(false)
const savingPreviewFile = ref(false)
const loadingDatabases = ref(false) const loadingDatabases = ref(false)
const databases = ref([]) const databases = ref([])
const selectedDatabase = ref(null) const selectedDatabase = ref(null)
@ -339,6 +347,29 @@ const closePreview = () => {
revokePreviewObjectUrl() 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 = () => { const openCreateDirectoryModal = () => {
if (activeSourceKey.value !== 'personal') return if (activeSourceKey.value !== 'personal') return
newDirectoryName.value = '' newDirectoryName.value = ''