Fix server file browse flow

This commit is contained in:
npc0-hue
2026-08-26 22:00:23 +08:00
parent 861ee752ed
commit 6369a8099a
22 changed files with 627 additions and 132 deletions
+6 -1
View File
@@ -281,6 +281,10 @@ describe("PlatformApiClient AI providers", () => {
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/browse") && init?.method === "POST") {
expect(JSON.parse(String(init.body))).toEqual({ directoryKey: "configs", query: "server", recursive: true, idempotencyKey: "idem-file-browse" });
return jsonResponse({ serverInstanceId: server.id, pluginId: plugin.id, directoryKey: "configs", state: "ready", 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: "配置文件" }], job: { ...job, id: "job-file-browse", capability: "files.list", targetKey: "configs" }, reason: "目录读取完成。" });
}
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" });
}
@@ -595,6 +599,7 @@ describe("PlatformApiClient AI providers", () => {
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.browseServerFiles(server.id, { directoryKey: "configs", query: "server", recursive: true, idempotencyKey: "idem-file-browse" })).resolves.toMatchObject({ state: "ready", entries: [{ logicalKey: "config/server.properties" }] });
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" } });
@@ -654,7 +659,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(44);
expect(fetchMock).toHaveBeenCalledTimes(45);
});
it("normalizes server file workspace null arrays from older platform responses", async () => {
+4
View File
@@ -596,6 +596,10 @@ export class PlatformApiClient {
return normalizeServerFileList(await this.request<ServerFileListResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/files/refresh`, { method: "POST", body: request }));
}
async browseServerFiles(serverInstanceId: string, request: ServerFileListRequest): Promise<ServerFileListResponse> {
return normalizeServerFileList(await this.request<ServerFileListResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/files/browse`, { 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 });
}
+1 -1
View File
@@ -28,7 +28,7 @@ Normal browser login uses the platform's HttpOnly SameSite cookie and `credentia
- `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 remains the low-level compatibility dispatch for file work.
- `getServerFileWorkspace`, `listServerFiles`, `refreshServerFiles`, `readServerFile`, `getServerFileReadSnapshot`, `writeServerFile`, `uploadServerFile`, and `prepareServerFileDownload` power the first-party server-detail file manager. The page renders a generic server-root entry, requests live listings through `files.list`, reads 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.
- `getServerFileWorkspace`, `browseServerFiles`, `listServerFiles`, `refreshServerFiles`, `readServerFile`, `getServerFileReadSnapshot`, `writeServerFile`, `uploadServerFile`, and `prepareServerFileDownload` power the first-party server-detail file manager. The page renders a generic server-root entry and uses `browseServerFiles` as the live directory path for open, refresh, and search actions; compatibility `list`/`refresh` clients remain available for older flows. File reads use `files.read`, saves use `files.write`, and browser uploads stage 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.
+5 -2
View File
@@ -97,10 +97,13 @@ describe("ServerDetailPage config write approval", () => {
it("keeps the server file manager list-first without plugin declaration gates", () => {
expect(serverDetailPageSource).toContain("server-file-editor-overlay");
expect(serverDetailPageSource).toContain("refreshRuntimeList({ silent: true })");
expect(serverDetailPageSource).toContain("window.setInterval(() => void loadList({ silent: true }), 300)");
expect(serverDetailPageSource).toContain("browseServerFiles");
expect(serverDetailPageSource).toContain("browseList({ forceNew: true })");
expect(serverDetailPageSource).toContain("window.setTimeout(() => void browseList({ silent: true }), 500)");
expect(serverDetailPageSource).toContain("if (!options.silent) setList({ status: \"loading\" })");
expect(serverDetailPageSource).toContain("serverFileListPendingLabel");
expect(serverDetailPageSource).not.toContain("refreshRuntimeList");
expect(serverDetailPageSource).not.toContain("window.setInterval(() => void loadList");
expect(serverDetailPageSource).not.toContain("server-file-layout");
expect(serverDetailPageSource).not.toContain("未声明目录");
expect(serverDetailPageSource).not.toContain("插件尚未声明");
+43 -63
View File
@@ -563,14 +563,14 @@ function ServerFilesSection({ instance, session, operations }: ServerFilesSectio
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 initialRuntimeLoadRef = useRef(false);
const browseRequestRef = useRef<{ key: string; idempotencyKey: string } | null>(null);
const activeDirectory = workspace.status === "ready" ? workspace.data.directories.find((item) => item.key === directoryKey) : undefined;
const canUpload = workspace.status === "ready" && Boolean(activeDirectory) && !uploadBusy;
const entries = list.status === "ready" ? list.data.entries : [];
const loadWorkspace = useCallback(async () => {
initialRuntimeLoadRef.current = false;
browseRequestRef.current = null;
setWorkspace({ status: "loading" });
try {
const response = await platformApiClient.getServerFileWorkspace(instance.id);
@@ -584,56 +584,45 @@ function ServerFilesSection({ instance, session, operations }: ServerFilesSectio
}
}, [instance.id]);
const loadList = useCallback(async (options: { silent?: boolean } = {}) => {
if (!directoryKey) return;
const browseList = useCallback(async (options: { silent?: boolean; forceNew?: boolean; manual?: boolean } = {}): Promise<ServerFileListResponse | undefined> => {
if (!directoryKey) return undefined;
if (!options.silent) setList({ status: "loading" });
const browseKey = `${directoryKey}:${relativePath}:${searchQuery}:${recursive}`;
if (options.forceNew || browseRequestRef.current?.key !== browseKey) {
browseRequestRef.current = { key: browseKey, idempotencyKey: serverFileIdempotency("browse", instance.id, `${browseKey}:${Date.now()}`) };
}
const operationId = options.manual ? operations.begin({ intent: "读取文件目录", targetKind: "server", targetId: instance.id, requester: session.displayName }) : "";
if (!options.silent) setPanelResult({ status: "pending", label: "正在读取目录…" });
try {
const response = await platformApiClient.listServerFiles(instance.id, { directoryKey, path: relativePath || undefined, query: searchQuery || undefined, recursive });
const response = await platformApiClient.browseServerFiles(instance.id, { directoryKey, path: relativePath || undefined, query: searchQuery || undefined, recursive, idempotencyKey: browseRequestRef.current?.idempotencyKey });
setList({ status: "ready", data: response });
if (options.silent && response.state === "ready") setPanelResult(null);
if (options.silent && response.state === "pending") setPanelResult({ status: "pending", label: serverFileListPendingLabel(response) });
if (options.silent && response.state === "failed") setPanelResult({ status: "failed", label: response.reason ?? "目录刷新失败" });
if (operationId) operations.succeed(operationId, response.state === "ready" ? "目录读取完成" : "正在读取目录", response.job);
if (response.state === "ready") setPanelResult(null);
else if (response.state === "failed") setPanelResult({ status: "failed", label: response.reason ?? "目录读取失败" });
else setPanelResult({ status: "pending", label: serverFileListPendingLabel(response) });
return response;
} catch (error) {
setList({ status: "error", reason: error instanceof Error ? error.message : "文件列表加载失败" });
if (operationId) operations.fail(operationId, error instanceof Error ? error.message : "目录读取失败", operationId);
setPanelResult({ status: "failed", label: error instanceof Error ? error.message : "目录读取失败" });
return undefined;
}
}, [directoryKey, instance.id, recursive, relativePath, searchQuery]);
}, [directoryKey, instance.id, operations, recursive, relativePath, searchQuery, session.displayName]);
useEffect(() => {
void loadWorkspace();
}, [loadWorkspace]);
const refreshRuntimeList = useCallback(async (options: { silent?: boolean } = {}) => {
if (!directoryKey) return;
const operationId = options.silent ? "" : operations.begin({ intent: "刷新文件目录", targetKind: "server", targetId: instance.id, requester: session.displayName });
if (!options.silent) 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 });
if (operationId) operations.succeed(operationId, `目录刷新任务 ${response.job?.id ?? "已派发"}`, response.job);
if (response.state === "ready") setPanelResult(null);
else setPanelResult({ status: serverFileListResultStatus(response), label: serverFileListResultLabel(response) });
} catch (error) {
const reason = error instanceof Error ? error.message : "目录刷新失败";
if (operationId) operations.fail(operationId, reason, operationId);
setPanelResult({ status: "failed", label: reason });
}
}, [directoryKey, instance.id, operations, relativePath, recursive, searchQuery, session.displayName]);
useEffect(() => {
if (workspace.status !== "ready" || !directoryKey) return;
if (!initialRuntimeLoadRef.current) {
initialRuntimeLoadRef.current = true;
void refreshRuntimeList({ silent: true });
return;
}
void loadList();
}, [directoryKey, loadList, refreshRuntimeList, workspace.status]);
void browseList({ forceNew: true });
}, [browseList, directoryKey, relativePath, recursive, searchQuery, workspace.status]);
useEffect(() => {
if (workspace.status !== "ready" || !directoryKey || list.status !== "ready" || list.data.state !== "pending") return;
const timer = window.setInterval(() => void loadList({ silent: true }), 300);
return () => window.clearInterval(timer);
}, [directoryKey, list, loadList, workspace.status]);
const timer = window.setTimeout(() => void browseList({ silent: true }), 500);
return () => window.clearTimeout(timer);
}, [browseList, directoryKey, list, workspace.status]);
async function openEntry(entry: ServerFileEntryResponse) {
if (entry.kind === "directory") {
@@ -662,7 +651,7 @@ function ServerFilesSection({ instance, session, operations }: ServerFilesSectio
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}` });
setPanelResult({ status: "pending", label: "正在读取文件…" });
} catch (error) {
const reason = error instanceof Error ? error.message : "文件读取失败";
setEditor({ entry, key, draft: "", loading: false, saving: false, error: reason });
@@ -678,8 +667,8 @@ function ServerFilesSection({ instance, session, operations }: ServerFilesSectio
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();
setPanelResult({ status: "pending", label: "正在保存文件…" });
await browseList({ forceNew: true });
} catch (error) {
const reason = error instanceof Error ? error.message : "文件保存失败";
operations.fail(operationId, reason, operationId);
@@ -692,12 +681,19 @@ function ServerFilesSection({ instance, session, operations }: ServerFilesSectio
const key = serverFileEntryKey(entry);
if (!key) return;
const operationId = operations.begin({ intent: "下载文件", targetKind: "server", targetId: instance.id, requester: session.displayName });
const idempotencyKey = serverFileIdempotency("download", instance.id, key);
setPanelResult({ status: "pending", label: "正在准备文件下载…" });
try {
const result = await platformApiClient.prepareServerFileDownload(instance.id, { key, idempotencyKey: serverFileIdempotency("download", instance.id, key) });
let result = await platformApiClient.prepareServerFileDownload(instance.id, { key, idempotencyKey });
for (let attempt = 0; result.status === "pending" && attempt < 240; attempt += 1) {
setPanelResult({ status: "pending", label: `正在准备下载… ${Math.min(99, Math.max(1, attempt))}%` });
await new Promise((resolve) => window.setTimeout(resolve, 500));
result = await platformApiClient.prepareServerFileDownload(instance.id, { key, idempotencyKey });
}
if (result.status !== "ready") throw new Error(result.reason ?? "文件下载准备超时");
const message = await downloadServerFileResult(platformApiClient, result);
operations.succeed(operationId, message, result.job);
setPanelResult({ status: result.status === "ready" ? "succeeded" : "pending", label: message });
setPanelResult({ status: "succeeded", label: message });
} catch (error) {
const reason = error instanceof Error ? error.message : "文件下载失败";
operations.fail(operationId, reason, operationId);
@@ -719,8 +715,8 @@ function ServerFilesSection({ instance, session, operations }: ServerFilesSectio
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();
setPanelResult({ status: "pending", label: `上传已提交${response.relativePath}` });
await browseList({ forceNew: true });
} catch (error) {
const reason = error instanceof Error ? error.message : "文件上传失败";
operations.fail(operationId, reason, operationId);
@@ -777,7 +773,7 @@ function ServerFilesSection({ instance, session, operations }: ServerFilesSectio
<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>
<button type="button" className="icon-command" onClick={() => void browseList({ forceNew: true, manual: true })} 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>
@@ -785,7 +781,7 @@ function ServerFilesSection({ instance, session, operations }: ServerFilesSectio
</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 === "error" && <ErrorState title="文件列表不可用" reason={list.reason} diagnosticId={`server-file-list:${instance.id}:${directoryKey}`} onRetry={() => void browseList({ forceNew: true })} compact />}
{list.status === "ready" && (
<div className="resource-table-wrap server-file-table-wrap">
<table className="resource-table server-file-table">
@@ -848,30 +844,14 @@ function serverFileEntryRowKey(entry: ServerFileEntryResponse): string {
return `${entry.kind}:${entry.directoryKey}:${entry.relativePath ?? ""}:${entry.logicalKey ?? ""}:${entry.name}`;
}
function serverFileListResultStatus(response: ServerFileListResponse): "pending" | "succeeded" | "failed" {
if (response.state === "ready") return "succeeded";
if (response.state === "failed") return "failed";
return "pending";
}
function serverFileListResultLabel(response: ServerFileListResponse): string {
if (response.state === "failed") return response.reason ?? "目录刷新失败";
return serverFileListPendingLabel(response);
}
function serverFileListEmptyLabel(response: ServerFileListResponse): string {
if (response.state === "pending") return serverFileListPendingLabel(response);
if (response.state === "failed") return response.reason ?? "目录刷新失败";
if (response.state === "failed") return response.reason ?? "目录读取失败";
return "当前目录暂无文件。";
}
function serverFileListPendingLabel(response: ServerFileListResponse): string {
const job = response.job;
if (!job) return response.reason ?? "正在读取当前目录,Run 返回后会自动更新。";
const progress = job.progress?.message?.trim();
const attempt = job.attempt > 0 ? ` · 第 ${job.attempt} 次尝试` : "";
const nextAttempt = job.state === "retrying" && job.nextAttemptAt ? ` · 下次 ${formatDateTime(job.nextAttemptAt)}` : "";
return `文件刷新任务 ${serverFileJobStateLabel(job.state)}${attempt}${nextAttempt}${progress ? ` · ${progress}` : ""}`;
return "正在读取当前目录…";
}
function serverFileJobStateLabel(state: JobResponse["state"]): string {
+37 -7
View File
@@ -12,19 +12,49 @@ export async function downloadServerFileResult(client: PlatformApiClient, result
if (!result.artifact) {
return "文件内容尚未可用。";
}
const chunks: ArrayBuffer[] = [];
const writer = await createFileWriter(result.filename || result.artifact.filename);
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;
try {
while (offset < result.artifact.sizeBytes) {
const chunk = await client.readArtifactContent(result.artifact.artifactId, offset, Math.min(chunkSize, result.artifact.sizeBytes - offset));
if (chunk.contentLength <= 0) throw new Error("文件下载返回空数据");
await writer.write(chunk.payload);
offset += chunk.contentLength;
}
await writer.close();
} catch (error) {
await writer.abort();
throw error;
}
saveBlob(new Blob(chunks, { type: result.artifact.contentType || "application/octet-stream" }), result.filename || result.artifact.filename);
return `下载已开始:${result.filename || result.artifact.filename}`;
}
interface FileWriter {
write(data: ArrayBuffer): Promise<void>;
close(): Promise<void>;
abort(): Promise<void>;
}
async function createFileWriter(filename: string): Promise<FileWriter> {
const picker = (window as Window & { showSaveFilePicker?: (options?: { suggestedName?: string }) => Promise<{ createWritable(): Promise<FileSystemWritableFileStream> }> }).showSaveFilePicker;
if (picker) {
const handle = await picker({ suggestedName: safeFilename(filename) });
const writable = await handle.createWritable();
return {
write: async (data) => { await writable.write(data); },
close: async () => { await writable.close(); },
abort: async () => { await writable.abort(); }
};
}
const chunks: ArrayBuffer[] = [];
return {
write: async (data) => { chunks.push(data); },
close: async () => { saveBlob(new Blob(chunks, { type: "application/octet-stream" }), safeFilename(filename)); },
abort: async () => { chunks.length = 0; }
};
}
function saveBlob(blob: Blob, filename: string) {
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");