feat(workspace): 新增工作空间文件管理器(针对 workDir)

后端 server/workspace.go(全部在 requireAuth 之后,路径限定在 workDir):
- list/read/write/mkdir/delete/download/upload 七个端点
- wsResolve 用 Clean("/"+rel)+前缀校验防 .. 遍历;上传经 filepath.Base
  再二次校验;读文本 2MB 上限(超限/二进制仅下载);上传 512MB 上限;
  下载文件名过滤 + filename* UTF-8 编码

前端:
- 侧边栏「工作空间」入口(/function/workspace)
- api/types:workspaceList/read/write/mkdir/delete + upload(FormData)
  + download(blob 带 token)
- page:面包屑导航 + 目录表格 + 上传/新建文件夹/刷新 + 每行下载/删除
  + 右侧 Sheet 文本编辑器(二进制/超大仅下载)

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
Autumn-27andClaude Opus 4.8 committed 2026-08-09 01:01:19 -04:00
1 parent 5f0fac33aa
commit 6bffd491ee
6 files changed
+726

No files matched your search

+9
View File
@@ -485,6 +485,15 @@ func (s *Server) Handler() http.Handler {
mux.HandleFunc("GET /api/tasks/{id}/coverage", s.taskCoverage)
mux.HandleFunc("GET /api/tasks/{id}/coverage-graph", s.taskCoverageGraph)
mux.HandleFunc("GET /api/tasks/{id}/asset-refs", s.taskAssetRefs)
// 工作空间文件管理器(针对 workDir)
mux.HandleFunc("GET /api/workspace/list", s.wsList)
mux.HandleFunc("GET /api/workspace/read", s.wsRead)
mux.HandleFunc("POST /api/workspace/write", s.wsWrite)
mux.HandleFunc("POST /api/workspace/mkdir", s.wsMkdir)
mux.HandleFunc("DELETE /api/workspace/delete", s.wsDelete)
mux.HandleFunc("GET /api/workspace/download", s.wsDownload)
mux.HandleFunc("POST /api/workspace/upload", s.wsUpload)
mux.HandleFunc("GET /api/tasks/{id}/scope", s.taskScopeList)
mux.HandleFunc("POST /api/tasks/{id}/control", s.control)
mux.HandleFunc("POST /api/active", s.setActive)
+290
View File
@@ -0,0 +1,290 @@
package server
import (
"bytes"
"io"
"mime/multipart"
"net/http"
"net/url"
"os"
"path/filepath"
"sort"
"strings"
"unicode/utf8"
)
// Workspace file manager — browse / view / edit / download / upload / delete the
// shared work dir (s.m.dir), where all agents write their artifacts. Every path is
// confined to the work dir root (traversal via ".." is neutralised). All routes sit
// behind requireAuth (see Handler()).
const (
maxWorkspaceRead = 2 << 20 // 2 MiB: files bigger than this aren't inlined for view/edit (download instead)
maxWorkspaceUpload = 512 << 20 // 512 MiB per upload request
)
// wsResolve maps a user-supplied relative path to an absolute path INSIDE the work
// dir. It returns ok=false if the path would escape the root. filepath.Clean on a
// rooted copy collapses any ".." so nothing can climb above the root.
func (s *Server) wsResolve(rel string) (string, bool) {
base := filepath.Clean(s.m.dir)
rel = strings.TrimPrefix(strings.TrimSpace(rel), "/")
clean := filepath.Clean("/" + rel) // e.g. "/a/../../etc" → "/etc" (still rooted at "/")
abs := filepath.Clean(filepath.Join(base, clean))
if abs != base && !strings.HasPrefix(abs, base+string(os.PathSeparator)) {
return "", false
}
return abs, true
}
// wsRel renders an absolute path back as a workspace-relative path (forward slashes).
func (s *Server) wsRel(abs string) string {
base := filepath.Clean(s.m.dir)
rel, err := filepath.Rel(base, abs)
if err != nil || rel == "." {
return ""
}
return filepath.ToSlash(rel)
}
type wsEntry struct {
Name string `json:"name"`
Path string `json:"path"`
Dir bool `json:"dir"`
Size int64 `json:"size"`
MTime int64 `json:"mtime"` // unix millis
}
// GET /api/workspace/list?path=<rel>
func (s *Server) wsList(w http.ResponseWriter, r *http.Request) {
abs, ok := s.wsResolve(r.URL.Query().Get("path"))
if !ok {
writeErr(w, 400, "非法路径")
return
}
fi, err := os.Stat(abs)
if err != nil {
writeErr(w, 404, "路径不存在")
return
}
if !fi.IsDir() {
writeErr(w, 400, "不是目录")
return
}
ents, err := os.ReadDir(abs)
if err != nil {
writeErr(w, 500, err.Error())
return
}
out := make([]wsEntry, 0, len(ents))
for _, e := range ents {
info, err := e.Info()
if err != nil {
continue
}
out = append(out, wsEntry{
Name: e.Name(),
Path: s.wsRel(filepath.Join(abs, e.Name())),
Dir: e.IsDir(),
Size: info.Size(),
MTime: info.ModTime().UnixMilli(),
})
}
// 目录在前,各自按名称排序。
sort.Slice(out, func(i, j int) bool {
if out[i].Dir != out[j].Dir {
return out[i].Dir
}
return strings.ToLower(out[i].Name) < strings.ToLower(out[j].Name)
})
writeJSON(w, 200, map[string]any{"path": s.wsRel(abs), "entries": out})
}
// GET /api/workspace/read?path=<rel> — inline text for view/edit. Binary or oversize
// files return {binary:true}/{too_large:true} with no content (use download instead).
func (s *Server) wsRead(w http.ResponseWriter, r *http.Request) {
abs, ok := s.wsResolve(r.URL.Query().Get("path"))
if !ok {
writeErr(w, 400, "非法路径")
return
}
fi, err := os.Stat(abs)
if err != nil {
writeErr(w, 404, "文件不存在")
return
}
if fi.IsDir() {
writeErr(w, 400, "是目录,不能作为文件读取")
return
}
if fi.Size() > maxWorkspaceRead {
writeJSON(w, 200, map[string]any{"path": s.wsRel(abs), "size": fi.Size(), "too_large": true, "binary": true})
return
}
data, err := os.ReadFile(abs)
if err != nil {
writeErr(w, 500, err.Error())
return
}
if bytes.IndexByte(data, 0) >= 0 || !utf8.Valid(data) {
writeJSON(w, 200, map[string]any{"path": s.wsRel(abs), "size": fi.Size(), "binary": true})
return
}
writeJSON(w, 200, map[string]any{"path": s.wsRel(abs), "size": fi.Size(), "binary": false, "content": string(data)})
}
// POST /api/workspace/write {path, content} — create/overwrite a text file.
func (s *Server) wsWrite(w http.ResponseWriter, r *http.Request) {
var req struct {
Path string `json:"path"`
Content string `json:"content"`
}
if err := decode(r, &req); err != nil {
writeErr(w, 400, err.Error())
return
}
abs, ok := s.wsResolve(req.Path)
if !ok || abs == filepath.Clean(s.m.dir) {
writeErr(w, 400, "非法路径")
return
}
if fi, err := os.Stat(abs); err == nil && fi.IsDir() {
writeErr(w, 400, "目标是目录")
return
}
if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil {
writeErr(w, 500, err.Error())
return
}
if err := os.WriteFile(abs, []byte(req.Content), 0o644); err != nil {
writeErr(w, 500, err.Error())
return
}
writeJSON(w, 200, map[string]any{"ok": true, "path": s.wsRel(abs)})
}
// POST /api/workspace/mkdir {path}
func (s *Server) wsMkdir(w http.ResponseWriter, r *http.Request) {
var req struct {
Path string `json:"path"`
}
if err := decode(r, &req); err != nil {
writeErr(w, 400, err.Error())
return
}
abs, ok := s.wsResolve(req.Path)
if !ok || abs == filepath.Clean(s.m.dir) {
writeErr(w, 400, "非法路径")
return
}
if err := os.MkdirAll(abs, 0o755); err != nil {
writeErr(w, 500, err.Error())
return
}
writeJSON(w, 200, map[string]any{"ok": true, "path": s.wsRel(abs)})
}
// DELETE /api/workspace/delete?path=<rel> — removes a file or a directory tree
// (confined to the work dir; the root itself can't be deleted).
func (s *Server) wsDelete(w http.ResponseWriter, r *http.Request) {
abs, ok := s.wsResolve(r.URL.Query().Get("path"))
if !ok {
writeErr(w, 400, "非法路径")
return
}
if abs == filepath.Clean(s.m.dir) {
writeErr(w, 400, "不能删除工作区根目录")
return
}
if _, err := os.Stat(abs); err != nil {
writeErr(w, 404, "路径不存在")
return
}
if err := os.RemoveAll(abs); err != nil {
writeErr(w, 500, err.Error())
return
}
writeJSON(w, 200, map[string]any{"ok": true})
}
// GET /api/workspace/download?path=<rel> — stream a file as an attachment.
func (s *Server) wsDownload(w http.ResponseWriter, r *http.Request) {
abs, ok := s.wsResolve(r.URL.Query().Get("path"))
if !ok {
writeErr(w, 400, "非法路径")
return
}
fi, err := os.Stat(abs)
if err != nil || fi.IsDir() {
writeErr(w, 404, "文件不存在")
return
}
name := filepath.Base(abs)
// RFC 5987 filename* keeps non-ASCII names intact; plain filename is the fallback.
w.Header().Set("Content-Disposition", "attachment; filename=\""+sanitizeFilename(name)+"\"; filename*=UTF-8''"+url.PathEscape(name))
http.ServeFile(w, r, abs)
}
// POST /api/workspace/upload?path=<dir> — multipart form field "file" (one or more).
func (s *Server) wsUpload(w http.ResponseWriter, r *http.Request) {
dirAbs, ok := s.wsResolve(r.URL.Query().Get("path"))
if !ok {
writeErr(w, 400, "非法路径")
return
}
if fi, err := os.Stat(dirAbs); err != nil || !fi.IsDir() {
writeErr(w, 400, "目标目录不存在")
return
}
r.Body = http.MaxBytesReader(w, r.Body, maxWorkspaceUpload)
if err := r.ParseMultipartForm(32 << 20); err != nil {
writeErr(w, 400, "解析上传失败或超出大小限制:"+err.Error())
return
}
files := r.MultipartForm.File["file"]
if len(files) == 0 {
writeErr(w, 400, "缺少上传文件(表单字段 file)")
return
}
saved := 0
for _, hdr := range files {
name := filepath.Base(hdr.Filename) // strip any path component
if name == "" || name == "." || name == ".." {
continue
}
destAbs, okd := s.wsResolve(filepath.Join(s.wsRel(dirAbs), name))
if !okd {
continue
}
if err := saveUpload(hdr, destAbs); err != nil {
writeErr(w, 500, err.Error())
return
}
saved++
}
writeJSON(w, 200, map[string]any{"uploaded": saved})
}
func saveUpload(hdr *multipart.FileHeader, dest string) error {
src, err := hdr.Open()
if err != nil {
return err
}
defer src.Close()
out, err := os.Create(dest)
if err != nil {
return err
}
defer out.Close()
_, err = io.Copy(out, src)
return err
}
// sanitizeFilename strips characters unsafe for a Content-Disposition filename token.
func sanitizeFilename(name string) string {
name = strings.ReplaceAll(name, "\"", "")
name = strings.ReplaceAll(name, "\\", "")
name = strings.ReplaceAll(name, "\n", "")
name = strings.ReplaceAll(name, "\r", "")
return name
}
@@ -0,0 +1,364 @@
"use client";
import * as React from "react";
import {
DownloadIcon,
FileIcon,
FolderIcon,
FolderPlusIcon,
HardDriveIcon,
RefreshCwIcon,
SaveIcon,
Trash2Icon,
UploadIcon,
} from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
Sheet,
SheetContent,
SheetFooter,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { cn } from "@/lib/utils";
import { api } from "@/lib/api";
import type { WorkspaceEntry, WorkspaceFile } from "@/lib/types";
function fmtSize(n: number): string {
if (n < 1024) return `${n} B`;
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
if (n < 1024 * 1024 * 1024) return `${(n / 1024 / 1024).toFixed(1)} MB`;
return `${(n / 1024 / 1024 / 1024).toFixed(1)} GB`;
}
function fmtTime(ms: number): string {
return new Date(ms).toLocaleString("zh-CN", {
year: "2-digit",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
});
}
type EditState = {
file: WorkspaceFile;
content: string;
dirty: boolean;
saving: boolean;
};
export default function WorkspacePage() {
const [path, setPath] = React.useState("");
const [entries, setEntries] = React.useState<WorkspaceEntry[]>([]);
const [loading, setLoading] = React.useState(true);
const [edit, setEdit] = React.useState<EditState | null>(null);
const [mkdirOpen, setMkdirOpen] = React.useState(false);
const [mkdirName, setMkdirName] = React.useState("");
const uploadRef = React.useRef<HTMLInputElement>(null);
const load = React.useCallback(
(p: string) => {
setLoading(true);
api
.workspaceList(p)
.then((r) => {
setEntries(r.entries);
setPath(r.path);
})
.catch((e) => toast.error(`读取目录失败:${(e as Error).message}`))
.finally(() => setLoading(false));
},
[],
);
React.useEffect(() => {
load("");
}, [load]);
const crumbs = React.useMemo(() => {
const parts = path ? path.split("/") : [];
const acc: { name: string; path: string }[] = [{ name: "工作空间", path: "" }];
let cur = "";
for (const part of parts) {
cur = cur ? `${cur}/${part}` : part;
acc.push({ name: part, path: cur });
}
return acc;
}, [path]);
const openFile = (e: WorkspaceEntry) => {
api
.workspaceRead(e.path)
.then((f) => setEdit({ file: f, content: f.content ?? "", dirty: false, saving: false }))
.catch((err) => toast.error(`打开文件失败:${(err as Error).message}`));
};
const saveFile = () => {
if (!edit) return;
setEdit({ ...edit, saving: true });
api
.workspaceWrite(edit.file.path, edit.content)
.then(() => {
toast.success("已保存");
setEdit((cur) => (cur ? { ...cur, dirty: false, saving: false } : cur));
load(path);
})
.catch((err) => {
toast.error(`保存失败:${(err as Error).message}`);
setEdit((cur) => (cur ? { ...cur, saving: false } : cur));
});
};
const del = (e: WorkspaceEntry) => {
if (!window.confirm(`确认删除 ${e.dir ? "目录" : "文件"} “${e.name}”?${e.dir ? "(含其下所有内容)" : ""}`)) return;
api
.workspaceDelete(e.path)
.then(() => {
toast.success("已删除");
load(path);
})
.catch((err) => toast.error(`删除失败:${(err as Error).message}`));
};
const doUpload = (files: FileList | null) => {
if (!files || files.length === 0) return;
api
.workspaceUpload(path, Array.from(files))
.then((r) => {
toast.success(`已上传 ${r.uploaded} 个文件`);
load(path);
})
.catch((err) => toast.error(`上传失败:${(err as Error).message}`))
.finally(() => {
if (uploadRef.current) uploadRef.current.value = "";
});
};
const doMkdir = () => {
const name = mkdirName.trim();
if (!name) return;
const target = path ? `${path}/${name}` : name;
api
.workspaceMkdir(target)
.then(() => {
toast.success("已创建目录");
setMkdirOpen(false);
setMkdirName("");
load(path);
})
.catch((err) => toast.error(`创建失败:${(err as Error).message}`));
};
return (
<div className="flex flex-col gap-4">
{/* 头部:面包屑 + 操作 */}
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-1 text-sm">
<HardDriveIcon className="text-muted-foreground mr-1 size-4 shrink-0" />
{crumbs.map((c, i) => (
<React.Fragment key={c.path}>
{i > 0 && <span className="text-muted-foreground">/</span>}
<button
type="button"
onClick={() => load(c.path)}
className={cn(
"max-w-[160px] truncate rounded px-1.5 py-0.5 hover:bg-accent",
i === crumbs.length - 1 ? "text-foreground font-medium" : "text-muted-foreground",
)}
>
{c.name}
</button>
</React.Fragment>
))}
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={() => setMkdirOpen(true)}>
<FolderPlusIcon /> 新建文件夹
</Button>
<Button variant="outline" size="sm" onClick={() => uploadRef.current?.click()}>
<UploadIcon /> 上传
</Button>
<Button variant="ghost" size="icon" className="size-8" onClick={() => load(path)} title="刷新">
<RefreshCwIcon className={cn("size-4", loading && "animate-spin")} />
</Button>
<input
ref={uploadRef}
type="file"
multiple
className="hidden"
onChange={(e) => doUpload(e.target.files)}
/>
</div>
</div>
<Card>
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>名称</TableHead>
<TableHead className="w-28 text-right">大小</TableHead>
<TableHead className="w-40">修改时间</TableHead>
<TableHead className="w-24 text-right">操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{entries.length === 0 && (
<TableRow>
<TableCell colSpan={4} className="text-muted-foreground py-10 text-center text-sm">
{loading ? "加载中…" : "空目录"}
</TableCell>
</TableRow>
)}
{entries.map((e) => (
<TableRow key={e.path}>
<TableCell>
<button
type="button"
onClick={() => (e.dir ? load(e.path) : openFile(e))}
className="flex items-center gap-2 text-left hover:underline"
>
{e.dir ? (
<FolderIcon className="size-4 shrink-0 text-blue-500" />
) : (
<FileIcon className="text-muted-foreground size-4 shrink-0" />
)}
<span className="truncate font-mono text-sm">{e.name}</span>
</button>
</TableCell>
<TableCell className="text-muted-foreground text-right font-mono text-xs">
{e.dir ? "—" : fmtSize(e.size)}
</TableCell>
<TableCell className="text-muted-foreground text-xs">{fmtTime(e.mtime)}</TableCell>
<TableCell>
<div className="flex items-center justify-end gap-1">
{!e.dir && (
<Button
variant="ghost"
size="icon"
className="size-7"
title="下载"
onClick={() => api.workspaceDownload(e.path).catch((err) => toast.error(`下载失败:${(err as Error).message}`))}
>
<DownloadIcon className="size-3.5" />
</Button>
)}
<Button
variant="ghost"
size="icon"
className="text-destructive size-7"
title="删除"
onClick={() => del(e)}
>
<Trash2Icon className="size-3.5" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
{/* 文件查看 / 编辑 */}
<Sheet open={edit !== null} onOpenChange={(o) => !o && setEdit(null)}>
<SheetContent side="right" className="flex w-full flex-col gap-0 p-0 sm:max-w-2xl">
{edit && (
<>
<SheetHeader className="border-b p-4">
<SheetTitle className="flex items-center gap-2 truncate font-mono text-sm">
<FileIcon className="size-4 shrink-0" />
<span className="truncate" title={edit.file.path}>
{edit.file.path}
</span>
</SheetTitle>
<span className="text-muted-foreground text-xs">{fmtSize(edit.file.size)}</span>
</SheetHeader>
{edit.file.binary || edit.file.too_large ? (
<div className="flex flex-1 flex-col items-center justify-center gap-3 p-8 text-center">
<p className="text-muted-foreground text-sm">
{edit.file.too_large ? "文件过大,不支持在线预览/编辑。" : "二进制文件,不支持在线预览/编辑。"}
</p>
<Button variant="outline" onClick={() => api.workspaceDownload(edit.file.path)}>
<DownloadIcon /> 下载文件
</Button>
</div>
) : (
<>
<div className="min-h-0 flex-1 p-3">
<Textarea
value={edit.content}
onChange={(ev) => setEdit({ ...edit, content: ev.target.value, dirty: true })}
spellCheck={false}
className="h-full min-h-[50vh] resize-none font-mono text-xs leading-relaxed"
/>
</div>
<SheetFooter className="flex-row items-center justify-between border-t p-3">
<span className="text-muted-foreground text-xs">{edit.dirty ? "未保存的修改" : "已同步"}</span>
<div className="flex gap-2">
<Button
variant="outline"
onClick={() => api.workspaceDownload(edit.file.path)}
>
<DownloadIcon /> 下载
</Button>
<Button onClick={saveFile} disabled={!edit.dirty || edit.saving}>
<SaveIcon /> {edit.saving ? "保存中…" : "保存"}
</Button>
</div>
</SheetFooter>
</>
)}
</>
)}
</SheetContent>
</Sheet>
{/* 新建文件夹 */}
<Dialog open={mkdirOpen} onOpenChange={setMkdirOpen}>
<DialogContent className="sm:max-w-sm">
<DialogHeader>
<DialogTitle>新建文件夹</DialogTitle>
</DialogHeader>
<Input
autoFocus
value={mkdirName}
onChange={(e) => setMkdirName(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && doMkdir()}
placeholder="文件夹名称"
/>
<DialogFooter>
<Button variant="outline" onClick={() => setMkdirOpen(false)}>
取消
</Button>
<Button onClick={doMkdir} disabled={!mkdirName.trim()}>
创建
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
+41
View File
@@ -12,6 +12,7 @@ import type {
Tool, Conversation, AgentTrigger,
InterceptRule, InterceptPending, InterceptApprovalRow,
TaskAssetView, SSProject, SSTask, ConvTokenSummary, CoverageGraphData, CoverageAssetRefs,
WorkspaceListing, WorkspaceFile,
CommandRecord, LLMRecordItem, LLMRecordDetail,
} from "@/lib/types";
@@ -131,6 +132,46 @@ export const api = {
taskAssetRefs: (id: string, assetId: number) =>
get<CoverageAssetRefs>(`/tasks/${id}/asset-refs?asset_id=${assetId}`),
// ---- workspace file manager (workDir) ----
workspaceList: (path = "") =>
get<WorkspaceListing>(`/workspace/list?path=${encodeURIComponent(path)}`),
workspaceRead: (path: string) =>
get<WorkspaceFile>(`/workspace/read?path=${encodeURIComponent(path)}`),
workspaceWrite: (path: string, content: string) =>
post<{ ok: boolean; path: string }>(`/workspace/write`, { path, content }),
workspaceMkdir: (path: string) =>
post<{ ok: boolean; path: string }>(`/workspace/mkdir`, { path }),
workspaceDelete: (path: string) =>
del<{ ok: boolean }>(`/workspace/delete?path=${encodeURIComponent(path)}`),
workspaceUpload: async (dir: string, files: File[]) => {
const fd = new FormData();
for (const f of files) fd.append("file", f);
const token = getToken();
const r = await fetch(`/api/workspace/upload?path=${encodeURIComponent(dir)}`, {
method: "POST",
headers: { ...(token ? { Authorization: `Bearer ${token}` } : {}) },
body: fd,
});
if (!r.ok) throw new Error(`上传失败: ${r.status}`);
return r.json() as Promise<{ uploaded: number }>;
},
workspaceDownload: async (path: string) => {
const token = getToken();
const r = await fetch(`/api/workspace/download?path=${encodeURIComponent(path)}`, {
headers: { ...(token ? { Authorization: `Bearer ${token}` } : {}) },
});
if (!r.ok) throw new Error(`下载失败: ${r.status}`);
const blob = await r.blob();
const objUrl = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = objUrl;
a.download = path.split("/").pop() || "download";
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(objUrl);
},
// ---- assets ----
// Server-side paginated: pass limit/offset, get back the page + full match total.
assets: (type = "", limit = 50, offset = 0) =>
+20
View File
@@ -178,6 +178,26 @@ export interface CoverageAssetRefs {
findings: CoverageAssetRef[];
}
// ---- Workspace file manager (workDir) ----
export interface WorkspaceEntry {
name: string;
path: string; // workspace-relative, forward slashes
dir: boolean;
size: number;
mtime: number; // unix millis
}
export interface WorkspaceListing {
path: string;
entries: WorkspaceEntry[];
}
export interface WorkspaceFile {
path: string;
size: number;
binary: boolean;
too_large?: boolean;
content?: string;
}
// 公司资产范围规则的一条(归属唯一真值来源)。
export interface ScopeRow {
id: number;
@@ -4,6 +4,7 @@ import {
Brain,
Bug,
ClipboardList,
FolderOpen,
FolderSync,
LayoutDashboard,
type LucideIcon,
@@ -72,6 +73,7 @@ export const sidebarItems: NavGroup[] = [
{ id: "llm-records", title: "LLM 录制", url: "/function/llm-records", icon: Radio },
{ id: "assets", title: "资产", url: "/function/assets", icon: Network },
{ id: "sync", title: "资产同步", url: "/function/sync", icon: FolderSync },
{ id: "workspace", title: "工作空间", url: "/function/workspace", icon: FolderOpen },
],
},
{