Add server file manager workflow

This commit is contained in:
npc0-hue
2026-08-24 12:56:55 +08:00
parent dc9b0eaf2a
commit 33d3a13e74
20 changed files with 1961 additions and 22 deletions
+55 -1
View File
@@ -144,6 +144,18 @@ const runtimeDownload: ArtifactDownloadReferenceResponse = {
storageBehavior: "platform-memory-transfer-session"
};
const serverFileWorkspace = {
serverInstanceId: server.id,
pluginId: plugin.id,
defaultDirectoryKey: "configs",
directories: [{ key: "configs", label: "配置", scope: "config" }],
files: [{ key: "config/server.properties", directoryKey: "configs", label: "server.properties", kind: "config", editable: true }],
configFields: [],
transfer: { channel: "run-file-transfer", uploadChunkSizeBytes: 1048576, downloadChunkSizeBytes: 1048576, maxInlineEditBytes: 65536, maxBrowserUploadBytes: 52428800 },
declaredOnly: true,
runtimeWorkspaceScope: "server-runtime"
};
describe("PlatformApiClient AI providers", () => {
afterEach(() => {
setPlatformApiSessionToken(null);
@@ -259,6 +271,40 @@ describe("PlatformApiClient AI providers", () => {
job: { ...job, id: "job-file-read", capability: "files.read", targetKey: "logs/latest.log" }
});
}
if (url.endsWith("/api/v1/server-instances/server-1/files/workspace") && (!init?.method || init.method === "GET")) {
return jsonResponse(serverFileWorkspace);
}
if (url.endsWith("/api/v1/server-instances/server-1/files/list?directoryKey=configs&query=server&recursive=true")) {
return jsonResponse({ serverInstanceId: server.id, pluginId: plugin.id, directoryKey: "configs", state: "declared", entries: [{ name: "server.properties", kind: "file", directoryKey: "configs", relativePath: "config/server.properties", logicalKey: "config/server.properties", scope: "config", sizeBytes: 42, editable: true, downloadable: true, remark: "配置文件" }], reason: "declared" });
}
if (url.endsWith("/api/v1/server-instances/server-1/files/refresh") && init?.method === "POST") {
expect(JSON.parse(String(init.body))).toEqual({ directoryKey: "configs", query: "server", recursive: true, idempotencyKey: "idem-file-list" });
return jsonResponse({ serverInstanceId: server.id, pluginId: plugin.id, directoryKey: "configs", state: "pending", entries: [], job: { ...job, id: "job-file-list", capability: "files.list", targetKey: "configs" }, reason: "queued" });
}
if (url.endsWith("/api/v1/server-instances/server-1/files/read-snapshot?key=config%2Fserver.properties")) {
return jsonResponse({ serverInstanceId: server.id, pluginId: plugin.id, key: "config/server.properties", state: "ready", content: "server.name=Example\n", version: 3, checksum: "sha256:filechecksum", sizeBytes: 20, readAt: "2026-07-03T00:00:00Z" });
}
if (url.endsWith("/api/v1/server-instances/server-1/files/read") && init?.method === "POST") {
expect(JSON.parse(String(init.body))).toEqual({ key: "config/server.properties", idempotencyKey: "idem-file-read" });
return jsonResponse({ status: "queued", serverInstanceId: server.id, pluginId: plugin.id, operation: "read", key: "config/server.properties", job: { ...job, id: "job-server-file-read", capability: "files.read", targetKey: "config/server.properties" } });
}
if (url.endsWith("/api/v1/server-instances/server-1/files/write") && init?.method === "POST") {
expect(JSON.parse(String(init.body))).toEqual({ key: "config/server.properties", content: "server.name=Example\n", expectedVersion: 3, expectedChecksum: "sha256:filechecksum", idempotencyKey: "idem-file-write" });
return jsonResponse({ status: "queued", serverInstanceId: server.id, pluginId: plugin.id, operation: "write", key: "config/server.properties", job: { ...job, id: "job-server-file-write", capability: "files.write", targetKey: "config/server.properties" } });
}
if (url.endsWith("/api/v1/server-instances/server-1/files/upload") && init?.method === "POST") {
const body = init.body as FormData;
expect(body.get("directoryKey")).toBe("configs");
expect(body.get("relativePath")).toBe("");
expect(body.get("filename")).toBe("server.properties");
expect(body.get("idempotencyKey")).toBe("idem-file-upload");
expect(body.get("file")).toBeInstanceOf(File);
return jsonResponse({ status: "queued", serverInstanceId: server.id, directoryKey: "configs", relativePath: "server.properties", artifactId: "artifact-upload-1", inputRef: "artifact://artifact-upload-1", sizeBytes: 20, checksum: "sha256:uploadchecksum", job: { ...job, id: "job-server-file-upload", capability: "files.write", targetKey: "configs/server.properties" } });
}
if (url.endsWith("/api/v1/server-instances/server-1/files/download") && init?.method === "POST") {
expect(JSON.parse(String(init.body))).toEqual({ key: "config/server.properties", idempotencyKey: "idem-file-download" });
return jsonResponse({ status: "ready", serverInstanceId: server.id, key: "config/server.properties", filename: "server.properties", contentType: "text/plain; charset=utf-8", content: "server.name=Example\n", checksum: "sha256:filechecksum", sizeBytes: 20, readAt: "2026-07-03T00:00:00Z" });
}
if (url.endsWith("/api/v1/run/endpoints")) {
return jsonResponse({ items: [endpoint], count: 1 });
}
@@ -546,6 +592,14 @@ describe("PlatformApiClient AI providers", () => {
status: "queued",
job: { capability: "files.read", targetKey: "logs/latest.log" }
});
await expect(client.getServerFileWorkspace(server.id)).resolves.toMatchObject({ defaultDirectoryKey: "configs", transfer: { channel: "run-file-transfer" } });
await expect(client.listServerFiles(server.id, { directoryKey: "configs", query: "server", recursive: true })).resolves.toMatchObject({ state: "declared", entries: [{ logicalKey: "config/server.properties" }] });
await expect(client.refreshServerFiles(server.id, { directoryKey: "configs", query: "server", recursive: true, idempotencyKey: "idem-file-list" })).resolves.toMatchObject({ state: "pending", job: { capability: "files.list" } });
await expect(client.getServerFileReadSnapshot(server.id, "config/server.properties")).resolves.toMatchObject({ state: "ready", content: "server.name=Example\n" });
await expect(client.readServerFile(server.id, { key: "config/server.properties", idempotencyKey: "idem-file-read" })).resolves.toMatchObject({ operation: "read", job: { capability: "files.read" } });
await expect(client.writeServerFile(server.id, { key: "config/server.properties", content: "server.name=Example\n", expectedVersion: 3, expectedChecksum: "sha256:filechecksum", idempotencyKey: "idem-file-write" })).resolves.toMatchObject({ operation: "write", job: { capability: "files.write" } });
await expect(client.uploadServerFile(server.id, { directoryKey: "configs", file: new File(["server.name=Example\n"], "server.properties", { type: "text/plain" }), idempotencyKey: "idem-file-upload" })).resolves.toMatchObject({ inputRef: "artifact://artifact-upload-1", job: { capability: "files.write" } });
await expect(client.prepareServerFileDownload(server.id, { key: "config/server.properties", idempotencyKey: "idem-file-download" })).resolves.toMatchObject({ status: "ready", filename: "server.properties", content: "server.name=Example\n" });
await expect(client.listRunEndpoints()).resolves.toMatchObject({ count: 1 });
await expect(client.listJobs()).resolves.toMatchObject({ count: 1 });
await expect(client.listJobs(server.id)).resolves.toMatchObject({ count: 1 });
@@ -600,7 +654,7 @@ describe("PlatformApiClient AI providers", () => {
client.invokeAI({ requestId: "ai-1", serverInstanceId: server.id, purpose: "config.suggest", prompt: "Tune PVP safely", currentConfig: "server.name=Example Survival #1\n" })
).resolves.toMatchObject({ status: "ok", usage: { mocked: true }, configRecommendation: { diffSummary: "review required" } });
expect(fetchMock).toHaveBeenCalledTimes(36);
expect(fetchMock).toHaveBeenCalledTimes(44);
});
it("calls plugin marketplace endpoints with filter and state contracts", async () => {
+64
View File
@@ -32,6 +32,7 @@ import type {
CurrentUserResponse,
DependencyCatalogResponse,
DependencyJobRequest,
DeclaredFileReadSnapshotResponse,
FileOperationDispatchRequest,
FileOperationDispatchResponse,
GameClientBridgeCancelRequest,
@@ -84,6 +85,14 @@ import type {
ServerDeploymentResponse,
ServerInstanceListResponse,
ServerDeletionRequest,
ServerFileDownloadRequest,
ServerFileDownloadResponse,
ServerFileListRequest,
ServerFileListResponse,
ServerFileReadRequest,
ServerFileUploadResponse,
ServerFileWorkspaceResponse,
ServerFileWriteRequest,
ServerInstanceUpdateRequest,
ServerInstanceResponse,
ServerMemberListResponse,
@@ -575,6 +584,51 @@ export class PlatformApiClient {
});
}
async getServerFileWorkspace(serverInstanceId: string): Promise<ServerFileWorkspaceResponse> {
return this.request<ServerFileWorkspaceResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/files/workspace`);
}
async listServerFiles(serverInstanceId: string, request: Partial<ServerFileListRequest> = {}): Promise<ServerFileListResponse> {
const params = serverFileListQuery(request);
return this.request<ServerFileListResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/files/list${params}`);
}
async refreshServerFiles(serverInstanceId: string, request: ServerFileListRequest): Promise<ServerFileListResponse> {
return this.request<ServerFileListResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/files/refresh`, { method: "POST", body: request });
}
async readServerFile(serverInstanceId: string, request: ServerFileReadRequest): Promise<FileOperationDispatchResponse> {
return this.request<FileOperationDispatchResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/files/read`, { method: "POST", body: request });
}
async getServerFileReadSnapshot(serverInstanceId: string, key: string): Promise<DeclaredFileReadSnapshotResponse> {
const params = new URLSearchParams({ key });
return this.request<DeclaredFileReadSnapshotResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/files/read-snapshot?${params.toString()}`);
}
async writeServerFile(serverInstanceId: string, request: ServerFileWriteRequest): Promise<FileOperationDispatchResponse> {
return this.request<FileOperationDispatchResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/files/write`, { method: "POST", body: request });
}
async uploadServerFile(serverInstanceId: string, request: { directoryKey: string; relativePath?: string; file: File; idempotencyKey: string }): Promise<ServerFileUploadResponse> {
const body = new FormData();
body.set("directoryKey", request.directoryKey);
body.set("relativePath", request.relativePath ?? "");
body.set("filename", request.file.name);
body.set("idempotencyKey", request.idempotencyKey);
body.set("file", request.file);
const headers = new Headers();
const sessionToken = this.sessionTokenProvider();
if (sessionToken) headers.set("Authorization", `Bearer ${sessionToken}`);
const response = await fetch(`${this.baseUrl}/server-instances/${encodeURIComponent(serverInstanceId)}/files/upload`, { method: "POST", credentials: "same-origin", headers, body });
if (!response.ok) throw await responseError(response);
return response.json() as Promise<ServerFileUploadResponse>;
}
async prepareServerFileDownload(serverInstanceId: string, request: ServerFileDownloadRequest): Promise<ServerFileDownloadResponse> {
return this.request<ServerFileDownloadResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/files/download`, { method: "POST", body: request });
}
async listLogStreams(serverInstanceId?: string): Promise<LogStreamListResponse> {
const query = serverInstanceId ? `?serverInstanceId=${encodeURIComponent(serverInstanceId)}` : "";
return this.request<LogStreamListResponse>(`/log-streams${query}`);
@@ -900,4 +954,14 @@ function artifactQuery(filter: ArtifactFilterRequest): string {
return query ? `?${query}` : "";
}
function serverFileListQuery(request: Partial<ServerFileListRequest>): string {
const params = new URLSearchParams();
if (request.directoryKey) params.set("directoryKey", request.directoryKey);
if (request.path) params.set("path", request.path);
if (request.query) params.set("query", request.query);
if (request.recursive) params.set("recursive", "true");
const query = params.toString();
return query ? `?${query}` : "";
}
export const platformApiClient = new PlatformApiClient(readWebRuntimeEnv().platformApiBaseUrl);
+2 -1
View File
@@ -27,7 +27,8 @@ Normal browser login uses the platform's HttpOnly SameSite cookie and `credentia
- `startServerInstance` and `stopServerInstance` post `ServerLifecycleCommandRequest` with the current config version and receive the lifecycle job response.
- `listServerAdministratorCandidates`, `addServerAdministrator`, and `removeServerAdministrator` call server membership endpoints so server owners can invite or remove active non-platform-admin server administrators.
- Game-specific pages use the scoped `plugin-data` collection API and declared plugin bridge machine actions; Platform does not expose game-specific projection or workflow clients.
- `dispatchFileOperation` posts `FileOperationDispatchRequest` to `/file-operations/dispatch` using logical file keys and scoped refs rather than raw host paths; it is not wired into SCUM server-detail/plugin pages as a raw file workbench.
- `dispatchFileOperation` posts `FileOperationDispatchRequest` to `/file-operations/dispatch` using logical file keys and scoped refs rather than raw host paths; it remains the low-level compatibility dispatch for plugin-declared file work.
- `getServerFileWorkspace`, `listServerFiles`, `refreshServerFiles`, `readServerFile`, `getServerFileReadSnapshot`, `writeServerFile`, `uploadServerFile`, and `prepareServerFileDownload` power the first-party server-detail file manager. The page renders plugin-declared logical directories, requests live listings through `files.list`, reads editable snapshots through `files.read`, saves through `files.write`, and stages browser uploads as server-instance artifacts before Run pulls input chunks on the dedicated file-transfer channel. Server detail may call only these server-file APIs plus the encapsulated download helper; it must not call raw artifact-transfer methods directly.
- `listArtifacts`, `openArtifactDownload`, and `readArtifactContent` use platform artifact routes for available job/server artifacts. Browser reads are chunked through `/artifacts/{id}/content` and must render only safe filenames, checksums, progress, and platform storage behavior.
- `authorizePluginBridge` posts `PluginBridgeAuthorizeRequest` to `/plugin-bridge/authorize` for preflight decisions.
- `executePluginBridge` posts `PluginBridgeExecuteRequest` to `/plugin-bridge/execute` from host-owned bridge dispatch utilities only. Plugin pages receive typed `PluginBridgeExecuteResponse` envelopes and never receive the platform API client, bearer token, raw provider key, run socket, host path, or storage credential.
+107 -1
View File
@@ -1421,7 +1421,7 @@ export interface ServerConfigWriteDispatchResponse {
job: JobResponse;
}
export type FileOperationKind = "read" | "write";
export type FileOperationKind = "list" | "read" | "write";
export interface FileOperationDispatchRequest {
serverInstanceId: string;
@@ -1459,6 +1459,112 @@ export interface DeclaredFileReadSnapshotResponse {
reason?: string;
}
export interface ServerFileTransferPolicyResponse {
channel: string;
uploadChunkSizeBytes: number;
downloadChunkSizeBytes: number;
maxInlineEditBytes: number;
maxBrowserUploadBytes: number;
notes?: string[];
}
export interface ServerFileWorkspaceResponse {
serverInstanceId: string;
pluginId: string;
defaultDirectoryKey: string;
directories: PluginLogicalDirectoryResponse[];
files: PluginLogicalFileResponse[];
configFields: PluginConfigFieldResponse[];
transfer: ServerFileTransferPolicyResponse;
declaredOnly: boolean;
runtimeWorkspaceScope?: string;
}
export type ServerFileEntryKind = "directory" | "file";
export interface ServerFileEntryResponse {
name: string;
kind: ServerFileEntryKind;
directoryKey: string;
relativePath?: string;
logicalKey?: string;
scope?: string;
sizeBytes?: number;
modifiedAt?: string;
checksum?: string;
editable: boolean;
downloadable: boolean;
remark?: string;
}
export interface ServerFileListRequest {
directoryKey: string;
path?: string;
query?: string;
recursive?: boolean;
idempotencyKey?: string;
}
export interface ServerFileListResponse {
serverInstanceId: string;
pluginId: string;
directoryKey: string;
path?: string;
state: "ready" | "pending" | "declared" | string;
entries: ServerFileEntryResponse[];
job?: JobResponse;
refreshedAt?: string;
reason?: string;
}
export interface ServerFileReadRequest {
pluginId?: string;
key: string;
idempotencyKey: string;
}
export interface ServerFileWriteRequest {
pluginId?: string;
key: string;
content?: string;
inputRef?: string;
expectedVersion?: number;
expectedChecksum?: string;
idempotencyKey: string;
}
export interface ServerFileUploadResponse {
status: string;
serverInstanceId: string;
directoryKey: string;
relativePath: string;
artifactId: string;
inputRef: string;
sizeBytes: number;
checksum: string;
job: JobResponse;
}
export interface ServerFileDownloadRequest {
key: string;
idempotencyKey: string;
}
export interface ServerFileDownloadResponse {
status: "ready" | "pending" | string;
serverInstanceId: string;
key: string;
filename: string;
contentType?: string;
content?: string;
checksum?: string;
sizeBytes?: number;
artifact?: ArtifactDownloadReferenceResponse;
job?: JobResponse;
readAt?: string;
reason?: string;
}
export interface LogStreamResponse {
id: string;
serverInstanceId: string;
+2 -1
View File
@@ -144,10 +144,11 @@ export interface PlatformOverviewSignal {
at: string;
}
export type ServerDetailSection = "manage" | "llm" | `plugin:${string}`;
export type ServerDetailSection = "manage" | "files" | "llm" | `plugin:${string}`;
export const serverDetailSections: Array<{ id: ServerDetailSection; label: string }> = [
{ id: "manage", label: "管理" },
{ id: "files", label: "文件" },
{ id: "llm", label: "AI 助手" }
];
@@ -64,6 +64,9 @@ describe("ServerDetailPage config write approval", () => {
});
it("keeps artifact transfer and backend internals out of server detail", () => {
expect(serverDetailPageSource).toContain("ServerFilesSection");
expect(serverDetailPageSource).toContain("getServerFileWorkspace");
expect(serverDetailPageSource).toContain("downloadServerFileResult");
expect(serverDetailPageSource).not.toContain("openArtifactDownload");
expect(serverDetailPageSource).not.toContain("readArtifactContent");
expect(serverDetailPageSource).not.toContain("浏览器制品传输");
@@ -130,6 +133,7 @@ describe("ServerDetailPage config write approval", () => {
it("routes plugin-declared pages into server detail tabs", () => {
expect(serverDetailPageSource).toContain("serverDetailSectionEntries(readyPlugin)");
expect(serverDetailPageSource).toContain("plugin:${page.key}");
expect(serverDetailPageSource).toContain('section === "files"');
expect(serverDetailPageSource).toContain("PluginPageSection");
expect(serverDetailPageSource).toContain("PluginPageHostPage");
expect(serverDetailPageSource).not.toContain("ScumFileManagementSection");
+324 -3
View File
@@ -1,9 +1,10 @@
import { MoonStar, PackageOpen, Pencil, ShieldCheck, Sparkles, Square, Terminal, UserRoundMinus, UserRoundPlus, WandSparkles } from "lucide-react";
import { type FormEvent, useCallback, useEffect, useMemo, useState } from "react";
import { ChevronRight, Download, Eye, FileText, Folder, MoonStar, PackageOpen, Pencil, RefreshCw, Save, Search, ShieldCheck, Sparkles, Square, Terminal, Upload, UserRoundMinus, UserRoundPlus, WandSparkles } from "lucide-react";
import { type ChangeEvent, type FormEvent, useCallback, useEffect, useMemo, useState } from "react";
import { platformApiClient } from "../api/client";
import type {
ConfigDiffLineResponse,
DeclaredFileReadSnapshotResponse,
GamePluginResponse,
JobResponse,
ServerInstanceResponse,
@@ -11,7 +12,10 @@ import type {
ServerMetricsResponse,
ServerDeploymentResponse,
ServerConfigDiffPreviewResponse,
RunEndpointResponse
RunEndpointResponse,
ServerFileEntryResponse,
ServerFileListResponse,
ServerFileWorkspaceResponse
} from "../api/types";
import { ConfirmDialog, UsageMeter } from "../components/OperationControls";
import { ServerManagementTerminalDrawer } from "../components/ServerManagementTerminalDrawer";
@@ -30,6 +34,7 @@ import {
serverMetadataUpdateRequestFromForm
} from "../schemas/serverManagement";
import { cx } from "../utils/classes";
import { downloadServerFileResult } from "../utils/serverFileTransfer";
import { stateLabel, statusClass } from "./ServersPage";
import { PluginPageHostPage } from "./PluginPageHostPage";
@@ -281,6 +286,7 @@ export function ServerDetailPage(props: PageComponentProps) {
/>
)}
{section === "manage" && <ServerAdministratorsSection instance={instance.data} session={session} onChanged={(next) => setInstance({ status: "ready", data: next })} />}
{section === "files" && <ServerFilesSection instance={instance.data} session={session} operations={operations} />}
{section === "llm" && <LlmSection serverId={serverId} instance={instance.data} session={session} operations={operations} />}
<ServerManagementTerminalDrawer open={terminalOpen} serverId={instance.data.id} serverName={instance.data.name} pluginId={instance.data.pluginId} canManage={canManageServers} onClose={() => setTerminalOpen(false)} />
</>
@@ -560,6 +566,321 @@ function ServerAdministratorsSection({ instance, session, onChanged }: ServerAdm
);
}
interface ServerFilesSectionProps {
instance: ServerInstanceResponse;
session: PageComponentProps["session"];
operations: PageComponentProps["operations"];
}
interface ServerFileEditorState {
entry: ServerFileEntryResponse | null;
key: string;
draft: string;
snapshot?: DeclaredFileReadSnapshotResponse;
loading: boolean;
saving: boolean;
message?: string;
error?: string;
}
function ServerFilesSection({ instance, session, operations }: ServerFilesSectionProps) {
const [workspace, setWorkspace] = useState<LoadState<ServerFileWorkspaceResponse>>({ status: "loading" });
const [list, setList] = useState<LoadState<ServerFileListResponse>>({ status: "loading" });
const [directoryKey, setDirectoryKey] = useState("");
const [relativePath, setRelativePath] = useState("");
const [searchDraft, setSearchDraft] = useState("");
const [searchQuery, setSearchQuery] = useState("");
const [recursive, setRecursive] = useState(false);
const [panelResult, setPanelResult] = useState<{ status: "pending" | "succeeded" | "failed"; label: string } | null>(null);
const [uploadBusy, setUploadBusy] = useState(false);
const [editor, setEditor] = useState<ServerFileEditorState>({ entry: null, key: "", draft: "", loading: false, saving: false });
const activeDirectory = workspace.status === "ready" ? workspace.data.directories.find((item) => item.key === directoryKey) : undefined;
const canUpload = workspace.status === "ready" && activeDirectory?.scope !== "logs" && !uploadBusy;
const entries = list.status === "ready" ? list.data.entries : [];
const loadWorkspace = useCallback(async () => {
setWorkspace({ status: "loading" });
try {
const response = await platformApiClient.getServerFileWorkspace(instance.id);
setWorkspace({ status: "ready", data: response });
setDirectoryKey((current) => current || response.defaultDirectoryKey || response.directories[0]?.key || "");
} catch (error) {
setWorkspace({ status: "error", reason: error instanceof Error ? error.message : "文件工作区加载失败" });
setList({ status: "error", reason: "文件工作区不可用" });
}
}, [instance.id]);
const loadList = useCallback(async () => {
if (!directoryKey) return;
setList({ status: "loading" });
try {
const response = await platformApiClient.listServerFiles(instance.id, { directoryKey, path: relativePath || undefined, query: searchQuery || undefined, recursive });
setList({ status: "ready", data: response });
} catch (error) {
setList({ status: "error", reason: error instanceof Error ? error.message : "文件列表加载失败" });
}
}, [directoryKey, instance.id, recursive, relativePath, searchQuery]);
useEffect(() => {
void loadWorkspace();
}, [loadWorkspace]);
useEffect(() => {
if (workspace.status !== "ready" || !directoryKey) return;
void loadList();
}, [directoryKey, loadList, workspace.status]);
async function refreshRuntimeList() {
if (!directoryKey) return;
const operationId = operations.begin({ intent: "刷新文件目录", targetKind: "server", targetId: instance.id, requester: session.displayName });
setPanelResult({ status: "pending", label: "正在向 Run 请求实时目录…" });
try {
const response = await platformApiClient.refreshServerFiles(instance.id, { directoryKey, path: relativePath || undefined, query: searchQuery || undefined, recursive, idempotencyKey: serverFileIdempotency("list", instance.id, directoryKey) });
setList({ status: "ready", data: response });
operations.succeed(operationId, `目录刷新任务 ${response.job?.id ?? "已派发"}`, response.job);
setPanelResult({ status: "pending", label: response.reason ?? "目录刷新任务已派发,稍后可再次刷新查看实时结果。" });
} catch (error) {
const reason = error instanceof Error ? error.message : "目录刷新失败";
operations.fail(operationId, reason, operationId);
setPanelResult({ status: "failed", label: reason });
}
}
async function openEntry(entry: ServerFileEntryResponse) {
if (entry.kind === "directory") {
setDirectoryKey(entry.directoryKey || directoryKey);
setRelativePath(entry.relativePath ?? "");
setEditor({ entry: null, key: "", draft: "", loading: false, saving: false });
return;
}
await openEditor(entry);
}
async function openEditor(entry: ServerFileEntryResponse) {
const key = serverFileEntryKey(entry);
if (!key) {
setPanelResult({ status: "failed", label: "该文件缺少插件声明的逻辑 key,不能读取。" });
return;
}
setEditor({ entry, key, draft: "", loading: true, saving: false, message: "正在读取最近快照…" });
try {
const snapshot = await platformApiClient.getServerFileReadSnapshot(instance.id, key);
if (snapshot.state === "ready") {
setEditor({ entry, key, draft: snapshot.content ?? "", snapshot, loading: false, saving: false, message: snapshot.content === undefined ? snapshot.reason ?? "文件快照已就绪,但内容不适合内联编辑。" : "已加载最近读取快照。" });
return;
}
const operationId = operations.begin({ intent: "读取文件", targetKind: "server", targetId: instance.id, requester: session.displayName });
const dispatch = await platformApiClient.readServerFile(instance.id, { key, idempotencyKey: serverFileIdempotency("read", instance.id, key) });
operations.succeed(operationId, `读取任务 ${dispatch.job.id} 已派发`, dispatch.job);
setEditor({ entry, key, draft: "", snapshot, loading: false, saving: false, message: snapshot.reason ?? "读取任务已派发;Run 返回后再次打开即可编辑。" });
setPanelResult({ status: "pending", label: `读取任务已派发:${dispatch.job.id}` });
} catch (error) {
const reason = error instanceof Error ? error.message : "文件读取失败";
setEditor({ entry, key, draft: "", loading: false, saving: false, error: reason });
setPanelResult({ status: "failed", label: reason });
}
}
async function saveEditor() {
if (!editor.entry || !editor.key || editor.saving || !editor.entry.editable) return;
const operationId = operations.begin({ intent: "保存文件", targetKind: "server", targetId: instance.id, requester: session.displayName });
setEditor((current) => ({ ...current, saving: true, error: undefined, message: "正在派发写入任务…" }));
try {
const dispatch = await platformApiClient.writeServerFile(instance.id, { key: editor.key, content: editor.draft, expectedVersion: editor.snapshot?.version, expectedChecksum: editor.snapshot?.checksum, idempotencyKey: serverFileIdempotency("write", instance.id, editor.key) });
operations.succeed(operationId, `写入任务 ${dispatch.job.id} 已派发`, dispatch.job);
setEditor((current) => ({ ...current, saving: false, message: "保存任务已派发;Run 会在工作区内原子写入。" }));
setPanelResult({ status: "pending", label: `写入任务已派发:${dispatch.job.id}` });
await loadList();
} catch (error) {
const reason = error instanceof Error ? error.message : "文件保存失败";
operations.fail(operationId, reason, operationId);
setEditor((current) => ({ ...current, saving: false, error: reason }));
setPanelResult({ status: "failed", label: reason });
}
}
async function downloadEntry(entry: ServerFileEntryResponse) {
const key = serverFileEntryKey(entry);
if (!key || !entry.downloadable) return;
const operationId = operations.begin({ intent: "下载文件", targetKind: "server", targetId: instance.id, requester: session.displayName });
setPanelResult({ status: "pending", label: "正在准备文件下载…" });
try {
const result = await platformApiClient.prepareServerFileDownload(instance.id, { key, idempotencyKey: serverFileIdempotency("download", instance.id, key) });
const message = await downloadServerFileResult(platformApiClient, result);
operations.succeed(operationId, message, result.job);
setPanelResult({ status: result.status === "ready" ? "succeeded" : "pending", label: message });
} catch (error) {
const reason = error instanceof Error ? error.message : "文件下载失败";
operations.fail(operationId, reason, operationId);
setPanelResult({ status: "failed", label: reason });
}
}
async function uploadFile(event: ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0];
event.target.value = "";
if (!file || !directoryKey || workspace.status !== "ready") return;
if (file.size > workspace.data.transfer.maxBrowserUploadBytes) {
setPanelResult({ status: "failed", label: `文件超过浏览器上传上限:${formatBytes(workspace.data.transfer.maxBrowserUploadBytes)}` });
return;
}
const operationId = operations.begin({ intent: "上传文件", targetKind: "server", targetId: instance.id, requester: session.displayName });
setUploadBusy(true);
setPanelResult({ status: "pending", label: `正在暂存上传:${file.name}` });
try {
const response = await platformApiClient.uploadServerFile(instance.id, { directoryKey, relativePath: relativePath || undefined, file, idempotencyKey: serverFileIdempotency("upload", instance.id, file.name) });
operations.succeed(operationId, `上传已暂存,写入任务 ${response.job.id} 已派发`, response.job);
setPanelResult({ status: "pending", label: `上传已走独立文件通道排队:${response.relativePath}` });
await loadList();
} catch (error) {
const reason = error instanceof Error ? error.message : "文件上传失败";
operations.fail(operationId, reason, operationId);
setPanelResult({ status: "failed", label: reason });
} finally {
setUploadBusy(false);
}
}
function submitSearch(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setSearchQuery(searchDraft.trim());
}
function goUp() {
if (relativePath) {
setRelativePath(relativePath.split("/").filter(Boolean).slice(0, -1).join("/"));
return;
}
if (workspace.status === "ready" && directoryKey !== workspace.data.defaultDirectoryKey) {
setDirectoryKey(workspace.data.defaultDirectoryKey || workspace.data.directories[0]?.key || directoryKey);
}
}
if (workspace.status === "loading") return <LoadingState label="正在加载文件工作区…" compact />;
if (workspace.status === "error") return <ErrorState title="文件工作区不可用" reason={workspace.reason} diagnosticId={`server-files:${instance.id}`} onRetry={() => void loadWorkspace()} compact />;
return (
<article className="console-panel server-file-manager" aria-label="server file manager">
<div className="panel-header">
<h2><Folder size={16} style={{ verticalAlign: "-2px" }} /> </h2>
<span className="page-status"> {workspace.data.transfer.channel} · {formatBytes(workspace.data.transfer.maxInlineEditBytes)}</span>
</div>
<div className="server-file-pathbar" aria-label="当前文件路径">
<button type="button" className="icon-command" onClick={goUp} disabled={!relativePath && directoryKey === workspace.data.defaultDirectoryKey}><ChevronRight size={14} className="server-file-back-icon" /><span></span></button>
<span className="server-file-path-chip">{activeDirectory?.label ?? (directoryKey || "未声明目录")}</span>
{relativePath.split("/").filter(Boolean).map((part) => <span key={part} className="server-file-path-chip server-file-path-child"><ChevronRight size={12} />{part}</span>)}
</div>
<div className="server-file-toolbar">
<div className="server-file-directory-tabs" role="tablist" aria-label="文件目录">
{workspace.data.directories.map((directory) => (
<button key={directory.key} type="button" className={cx("segmented-button", directory.key === directoryKey && "segmented-button-active")} onClick={() => { setDirectoryKey(directory.key); setRelativePath(""); }}>
{directory.label}
</button>
))}
</div>
<form className="server-file-search" onSubmit={submitSearch}>
<Search size={14} />
<input type="search" value={searchDraft} placeholder="搜索文件/目录" onChange={(event) => setSearchDraft(event.target.value)} />
<label><input type="checkbox" checked={recursive} onChange={(event) => setRecursive(event.target.checked)} /> </label>
<button type="submit" className="icon-command"></button>
</form>
<div className="action-strip server-file-actions">
<button type="button" className="icon-command" onClick={() => void refreshRuntimeList()} disabled={!directoryKey}><RefreshCw size={14} /><span></span></button>
<label className={cx("server-file-upload-control", !canUpload && "server-file-upload-disabled")} title={canUpload ? "上传到当前逻辑目录" : "当前目录不可上传或正在上传"}>
<Upload size={14} /><span>{uploadBusy ? "上传中…" : "上传"}</span><input type="file" disabled={!canUpload} onChange={(event) => void uploadFile(event)} />
</label>
</div>
</div>
{panelResult && <ResultBadge status={panelResult.status} label={panelResult.label} />}
{list.status === "loading" && <LoadingState label="正在加载文件列表…" compact />}
{list.status === "error" && <ErrorState title="文件列表不可用" reason={list.reason} diagnosticId={`server-file-list:${instance.id}:${directoryKey}`} onRetry={() => void loadList()} compact />}
{list.status === "ready" && (
<div className="server-file-layout">
<div className="resource-table-wrap server-file-table-wrap">
<table className="resource-table server-file-table">
<thead><tr><th aria-label="选择"><input type="checkbox" disabled /></th><th></th><th></th><th></th><th></th><th></th></tr></thead>
<tbody>
{entries.length === 0 && <tr><td colSpan={6}><span className="provider-id"></span></td></tr>}
{entries.map((entry) => (
<tr key={serverFileEntryRowKey(entry)} className={entry.kind === "directory" ? "server-file-directory-row" : undefined}>
<td><input type="checkbox" disabled /></td>
<td>
<button type="button" className="table-link-button server-file-name-button" onClick={() => void openEntry(entry)}>
{entry.kind === "directory" ? <Folder size={16} /> : <FileText size={16} />}<span>{entry.name}</span>
</button>
<span className="provider-id">{entry.logicalKey || entry.relativePath || entry.directoryKey}</span>
</td>
<td>{entry.kind === "directory" ? "计算" : formatBytes(entry.sizeBytes)}</td>
<td>{formatDateTime(entry.modifiedAt)}</td>
<td>{entry.remark || entry.scope || "--"}</td>
<td>
<div className="row-actions human-row-actions">
{entry.kind === "directory" ? <button type="button" title="打开目录" onClick={() => void openEntry(entry)}><Eye size={14} /><span></span></button> : <button type="button" title="读取/编辑" disabled={!entry.editable} onClick={() => void openEditor(entry)}><Pencil size={14} /><span></span></button>}
{entry.kind === "file" && <button type="button" title="下载" disabled={!entry.downloadable} onClick={() => void downloadEntry(entry)}><Download size={14} /><span></span></button>}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
<aside className="server-file-editor" aria-label="file editor">
<div className="panel-header"><h3><FileText size={15} style={{ verticalAlign: "-2px" }} /> </h3>{editor.entry && <span className="page-status">{editor.entry.name}</span>}</div>
{!editor.entry && <p className="section-copy"> Run </p>}
{editor.entry && editor.loading && <LoadingState label="正在读取文件快照…" compact />}
{editor.entry && editor.error && <ErrorState title="文件编辑不可用" reason={editor.error} diagnosticId={`server-file-edit:${instance.id}:${editor.key}`} compact />}
{editor.entry && editor.message && !editor.error && <span className="provider-id">{editor.message}</span>}
{editor.entry && editor.snapshot?.state === "ready" && editor.snapshot.content !== undefined && (
<label className="server-file-editor-field">
<textarea value={editor.draft} spellCheck={false} onChange={(event) => setEditor((current) => ({ ...current, draft: event.target.value }))} />
</label>
)}
{editor.entry && (
<div className="action-strip server-file-editor-actions">
<button type="button" className="primary-command" disabled={!editor.entry.editable || editor.loading || editor.saving || editor.snapshot?.content === undefined} onClick={() => void saveEditor()}><Save size={14} /><span>{editor.saving ? "保存中…" : "保存"}</span></button>
<button type="button" className="icon-command" disabled={!editor.entry.downloadable} onClick={() => void downloadEntry(editor.entry!)}><Download size={14} /><span></span></button>
</div>
)}
</aside>
</div>
)}
</article>
);
}
function serverFileEntryKey(entry: ServerFileEntryResponse): string {
return entry.logicalKey || entry.relativePath || entry.name;
}
function serverFileEntryRowKey(entry: ServerFileEntryResponse): string {
return `${entry.kind}:${entry.directoryKey}:${entry.relativePath ?? ""}:${entry.logicalKey ?? ""}:${entry.name}`;
}
function serverFileIdempotency(prefix: string, serverId: string, key: string): string {
return `web-file-${prefix}-${serverId}-${String(key).replace(/[^a-zA-Z0-9_.-]+/g, "-").slice(0, 40)}-${Date.now()}`;
}
function formatBytes(value?: number): string {
if (value === undefined || !Number.isFinite(value)) return "--";
if (value < 1024) return `${value} B`;
const units = ["KB", "MB", "GB", "TB"];
let scaled = value / 1024;
let unitIndex = 0;
while (scaled >= 1024 && unitIndex < units.length - 1) {
scaled /= 1024;
unitIndex += 1;
}
return `${scaled >= 10 ? scaled.toFixed(1) : scaled.toFixed(2)} ${units[unitIndex]}`;
}
function formatDateTime(value?: string): string {
if (!value) return "--";
const parsed = new Date(value);
return Number.isNaN(parsed.getTime()) ? value : parsed.toLocaleString();
}
function HeaderStat({ label, value }: { label: string; value: string }) {
return (
<span className="server-card-stat">
+21
View File
@@ -450,6 +450,26 @@ to{transform:translate(-50%,-50%) rotate(calc(var(--construct-drift) + 360deg))}
.server-toolbar select{min-height:38px;border:1px solid var(--line-strong);border-radius:8px;padding:0 10px;background:var(--surface-solid);color:var(--ink);font:inherit;min-width:180px}
.server-toolbar select:focus{border-color:var(--accent);outline:2px solid var(--accent-soft)}
.server-toolbar input[type=search]:focus{border-color:var(--accent);outline:2px solid var(--accent-soft)}
.server-file-manager{display:grid;gap:12px}
.server-file-pathbar{display:flex;align-items:center;gap:8px;min-width:0;padding:8px;border:1px solid var(--line);border-radius:8px;background:var(--frosted-surface),color-mix(in srgb,var(--surface) 74%,transparent);box-shadow:inset 0 1px 0 var(--crystal-rim)}
.server-file-back-icon{transform:rotate(180deg)}
.server-file-path-chip{display:inline-flex;align-items:center;gap:4px;min-height:30px;padding:0 10px;border:1px solid var(--line);border-radius:999px;background:var(--control-surface);color:var(--ink-soft);font-size:12px;font-weight:800;white-space:nowrap}
.server-file-path-child{color:var(--ink-faint)}
.server-file-toolbar{display:grid;grid-template-columns:minmax(180px,1fr) minmax(260px,1.2fr) auto;gap:8px;align-items:center;min-width:0}
.server-file-directory-tabs{display:flex;gap:6px;overflow:auto;min-width:0;padding-bottom:2px}
.server-file-search{display:flex;align-items:center;gap:7px;min-width:0;min-height:38px;padding:0 8px;border:1px solid var(--line);border-radius:8px;background:var(--control-surface);box-shadow:inset 0 1px 0 var(--crystal-rim)}
.server-file-search input[type=search]{min-width:120px;flex:1 1 auto;border:0;background:transparent;color:var(--ink);font:inherit;outline:0}
.server-file-search label{display:inline-flex;align-items:center;gap:5px;color:var(--ink-faint);font-size:12px;font-weight:800;white-space:nowrap}
.server-file-actions{justify-content:flex-end;flex-wrap:nowrap}
.server-file-upload-control{min-height:36px;display:inline-flex;align-items:center;justify-content:center;gap:8px;border:1px solid var(--line-strong);border-radius:8px;padding:0 12px;background:var(--control-surface);color:var(--ink-soft);cursor:pointer;box-shadow:inset 0 1px 0 var(--crystal-rim),0 8px 18px var(--glass-shadow);font-weight:700;white-space:nowrap}
.server-file-upload-control input{display:none}.server-file-upload-control:hover,.server-file-upload-control:focus-within{border-color:var(--accent);color:var(--ink);box-shadow:inset 0 1px 0 var(--crystal-rim),0 0 0 2px var(--accent-soft),0 12px 26px var(--glass-shadow)}
.server-file-upload-disabled{opacity:.55;cursor:not-allowed}
.server-file-layout{display:grid;grid-template-columns:minmax(0,1fr) minmax(280px,360px);gap:12px;align-items:start;min-width:0}
.server-file-table-wrap{max-height:560px}.server-file-table{min-width:860px}.server-file-table td:first-child,.server-file-table th:first-child{width:44px}.server-file-directory-row{background:color-mix(in srgb,var(--accent-soft) 54%,transparent)}
.server-file-name-button{display:inline-flex;align-items:center;gap:8px;color:var(--ink);font-weight:850}.server-file-name-button svg{color:var(--accent-deep)}
.server-file-editor{display:grid;gap:10px;padding:12px;border:1px solid var(--line);border-radius:8px;background:var(--glass-wash),var(--glass-tint),var(--surface);box-shadow:inset 0 1px 0 var(--crystal-rim),0 14px 30px var(--glass-shadow);min-width:0}
.server-file-editor-field{display:grid;gap:6px;color:var(--ink-soft);font-size:13px;font-weight:800}.server-file-editor-field textarea{min-height:320px;width:100%;border:1px solid var(--line-strong);border-radius:8px;padding:10px;background:var(--surface-solid);color:var(--ink);font:13px/1.45 var(--font-mono);resize:vertical}.server-file-editor-field textarea:focus{border-color:var(--accent);outline:2px solid var(--accent-soft)}
.server-file-editor-actions .primary-command{width:auto}
.server-card-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,420px),1fr));gap:16px}
.server-card{display:grid;gap:12px;padding:16px;border:1px solid var(--line);border-radius:8px;background:var(--frosted-surface),var(--glass-tint),var(--surface);backdrop-filter:blur(22px) saturate(1.28);text-align:left;transition:transform 120ms ease,border-color 120ms ease;box-shadow:var(--jelly-inset),inset 0 0 0 1px var(--diamond-line),0 18px 42px var(--glass-shadow),0 0 28px rgba(255,255,255,.2);position:relative;overflow:hidden;min-width:0}
.server-card:focus-visible,.server-card:hover{border-color:var(--accent);outline:0;transform:translateY(-2px)}
@@ -709,6 +729,7 @@ to{transform:translate(-50%,-50%) rotate(calc(var(--construct-drift) + 360deg))}
.section-tabs{overflow-x:auto;flex-wrap:nowrap;padding-bottom:4px}
.server-toolbar{align-items:stretch}
.server-toolbar input[type=search],.server-toolbar select{flex:1 1 100%;min-width:0}
.server-file-toolbar,.server-file-layout{grid-template-columns:1fr}.server-file-search,.server-file-actions,.server-file-pathbar{align-items:stretch;flex-wrap:wrap}.server-file-search input[type=search]{min-width:0}.server-file-actions{justify-content:stretch}.server-file-actions>*{flex:1 1 auto}
.plugin-control-row,.server-card-head,.server-detail-title-row{grid-template-columns:1fr;align-items:stretch}
.server-card-head,.server-detail-title-row{display:grid}
.server-detail-title-row .action-strip{align-items:stretch}
+42
View File
@@ -0,0 +1,42 @@
import type { PlatformApiClient } from "../api/client";
import type { ServerFileDownloadResponse } from "../api/types";
export async function downloadServerFileResult(client: PlatformApiClient, result: ServerFileDownloadResponse): Promise<string> {
if (result.status !== "ready") {
return result.reason ?? "文件读取任务已派发,完成后可再次下载。";
}
if (result.content !== undefined) {
saveBlob(new Blob([result.content], { type: result.contentType ?? "text/plain;charset=utf-8" }), result.filename || "server-file.txt");
return `下载已开始:${result.filename || result.key}`;
}
if (!result.artifact) {
return "文件内容尚未可用。";
}
const chunks: ArrayBuffer[] = [];
let offset = 0;
const chunkSize = Math.max(1, result.artifact.chunkSizeBytes || 1024 * 1024);
while (offset < result.artifact.sizeBytes) {
const chunk = await client.readArtifactContent(result.artifact.artifactId, offset, Math.min(chunkSize, result.artifact.sizeBytes - offset));
chunks.push(chunk.payload);
offset += chunk.contentLength;
if (chunk.contentLength <= 0) break;
}
saveBlob(new Blob(chunks, { type: result.artifact.contentType || "application/octet-stream" }), result.filename || result.artifact.filename);
return `下载已开始:${result.filename || result.artifact.filename}`;
}
function saveBlob(blob: Blob, filename: string) {
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = safeFilename(filename);
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
window.setTimeout(() => URL.revokeObjectURL(url), 1000);
}
function safeFilename(filename: string): string {
const cleaned = filename.replace(/[\\/:*?"<>|]+/g, "-").trim();
return cleaned || "server-file.txt";
}