Speed up artifact downloads
This commit is contained in:
@@ -352,6 +352,19 @@ describe("PlatformApiClient AI providers", () => {
|
||||
}
|
||||
});
|
||||
}
|
||||
if (url.endsWith("/api/v1/artifacts/artifact-1/content")) {
|
||||
return new Response(new TextEncoder().encode("artifact full body").buffer, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "application/octet-stream",
|
||||
"Content-Length": "18",
|
||||
"X-Artifact-Id": artifact.id,
|
||||
"X-Artifact-Checksum": artifact.checksum,
|
||||
"X-Artifact-Content-Checksum": "sha256:fullchecksum",
|
||||
"X-Artifact-Storage": "platform-memory-transfer-session"
|
||||
}
|
||||
});
|
||||
}
|
||||
if (url.endsWith("/api/v1/server-instances/workflows/create") && init?.method === "POST") {
|
||||
return jsonResponse({ accepted: true, action: "create", instance: { ...server, state: "installing" }, job: { ...job, capability: "process.install" } });
|
||||
}
|
||||
@@ -611,6 +624,7 @@ describe("PlatformApiClient AI providers", () => {
|
||||
await expect(client.listArtifacts({ ownerKind: "job", ownerId: job.id, state: "available" })).resolves.toMatchObject({ count: 1, items: [{ id: artifact.id }] });
|
||||
await expect(client.openArtifactDownload(artifact.id)).resolves.toMatchObject({ downloadUrl: "/api/v1/artifacts/artifact-1/content", rangeSupported: true });
|
||||
await expect(client.readArtifactContent(artifact.id, 0, 8)).resolves.toMatchObject({ contentLength: 8, contentRange: "bytes 0-7/18", checksum: artifact.checksum });
|
||||
await expect(client.downloadArtifactContent(artifact.id).then((response) => response.arrayBuffer())).resolves.toHaveProperty("byteLength", 18);
|
||||
await expect(client.createServerWorkflow({ id: "server-2", pluginId: plugin.id, name: "Server 2", idempotencyKey: "idem-create" })).resolves.toMatchObject({
|
||||
action: "create"
|
||||
});
|
||||
@@ -659,7 +673,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(45);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(46);
|
||||
});
|
||||
|
||||
it("normalizes server file workspace null arrays from older platform responses", async () => {
|
||||
|
||||
@@ -298,6 +298,19 @@ export class PlatformApiClient {
|
||||
};
|
||||
}
|
||||
|
||||
async downloadArtifactContent(id: string): Promise<Response> {
|
||||
const headers = new Headers();
|
||||
const sessionToken = this.sessionTokenProvider();
|
||||
if (sessionToken) {
|
||||
headers.set("Authorization", `Bearer ${sessionToken}`);
|
||||
}
|
||||
const response = await fetch(`${this.baseUrl}/artifacts/${encodeURIComponent(id)}/content`, { headers, credentials: "same-origin" });
|
||||
if (!response.ok) {
|
||||
throw await responseError(response);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
async getJob(id: string): Promise<JobResponse> {
|
||||
return this.request<JobResponse>(`/jobs/${encodeURIComponent(id)}`);
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ Normal browser login uses the platform's HttpOnly SameSite cookie and `credentia
|
||||
- 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`, `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.
|
||||
- `listArtifacts`, `openArtifactDownload`, `downloadArtifactContent`, and `readArtifactContent` use platform artifact routes for available job/server artifacts. Browser downloads stream the full body through `/artifacts/{id}/content`; explicit range reads may still use bounded `offset`/`limit` chunks 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.
|
||||
- `invokeAI` posts `AIInvocationRequest` to `/ai/invocations` for platform-mediated AI assistance. Responses carry redacted recommendations, usage metadata, optional reviewable config suggestions, and safe errors; they must not include provider base URLs, key refs, raw keys, or direct provider transport details.
|
||||
|
||||
@@ -307,7 +307,7 @@ export function ServersPage({ session, operations, onNavigate }: PageComponentPr
|
||||
execute: async () => {
|
||||
if (action === "download-run") {
|
||||
const reference = await platformApiClient.downloadLatestRunDistribution(instance.id);
|
||||
await downloadArtifactReference(reference, (artifactId, offset, limit) => platformApiClient.readArtifactContent(artifactId, offset, limit));
|
||||
await downloadArtifactReference(reference, (artifactId) => platformApiClient.downloadArtifactContent(artifactId));
|
||||
return `run 下载已开始,artifact ${reference.artifactId},文件 ${safeArtifactFilename(reference.filename)}`;
|
||||
}
|
||||
if (action === "push-run-update") {
|
||||
@@ -354,7 +354,7 @@ export function ServersPage({ session, operations, onNavigate }: PageComponentPr
|
||||
executeStageIndex: 1,
|
||||
execute: async () => {
|
||||
const reference = await platformApiClient.openArtifactDownload(artifact.artifactId);
|
||||
await downloadArtifactReference(reference, (artifactId, offset, limit) => platformApiClient.readArtifactContent(artifactId, offset, limit));
|
||||
await downloadArtifactReference(reference, (artifactId) => platformApiClient.downloadArtifactContent(artifactId));
|
||||
return `run 下载已开始,文件 ${safeArtifactFilename(reference.filename)}`;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,49 +1,105 @@
|
||||
import type { ArtifactContentChunk, ArtifactDownloadReferenceResponse } from "../api/types";
|
||||
import type { ArtifactDownloadReferenceResponse } from "../api/types";
|
||||
|
||||
type ArtifactContentLoader = (artifactId: string) => Promise<Response>;
|
||||
type DownloadChunk = ArrayBuffer;
|
||||
|
||||
export async function downloadArtifactReference(
|
||||
reference: ArtifactDownloadReferenceResponse,
|
||||
readContent: (artifactId: string, offset: number, limit?: number) => Promise<ArtifactContentChunk>,
|
||||
openContent: ArtifactContentLoader,
|
||||
onProgress?: (progress: number) => void
|
||||
) {
|
||||
const chunks: ArrayBuffer[] = [];
|
||||
let offset = 0;
|
||||
const response = await openContent(reference.artifactId);
|
||||
const writer = await createArtifactFileWriter(reference.filename, reference.contentType);
|
||||
const totalBytes = Number(response.headers.get("Content-Length") ?? reference.sizeBytes);
|
||||
let downloadedBytes = 0;
|
||||
onProgress?.(0);
|
||||
|
||||
while (offset < reference.sizeBytes) {
|
||||
const chunk = await readContent(reference.artifactId, offset, reference.chunkSizeBytes);
|
||||
chunks.push(chunk.payload);
|
||||
offset += chunk.payload.byteLength;
|
||||
onProgress?.(Math.min(100, Math.round((offset / reference.sizeBytes) * 100)));
|
||||
if (chunk.payload.byteLength === 0) {
|
||||
break;
|
||||
try {
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) {
|
||||
const payload = await response.arrayBuffer();
|
||||
await writer.write(payload);
|
||||
downloadedBytes = payload.byteLength;
|
||||
onProgress?.(progressPercent(downloadedBytes, totalBytes));
|
||||
} else {
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
const chunk = copyStreamChunk(value);
|
||||
await writer.write(chunk);
|
||||
downloadedBytes += chunk.byteLength;
|
||||
onProgress?.(progressPercent(downloadedBytes, totalBytes));
|
||||
}
|
||||
}
|
||||
await writer.close();
|
||||
} catch (error) {
|
||||
await writer.abort();
|
||||
throw error;
|
||||
}
|
||||
|
||||
openArtifactBlob(reference, chunks);
|
||||
onProgress?.(100);
|
||||
}
|
||||
|
||||
export function openArtifactBlob(reference: ArtifactDownloadReferenceResponse, chunks: ArrayBuffer[]) {
|
||||
export function openArtifactBlob(reference: ArtifactDownloadReferenceResponse, chunks: DownloadChunk[]) {
|
||||
saveArtifactBlob(new Blob(chunks, { type: reference.contentType }), reference.filename);
|
||||
}
|
||||
|
||||
export function saveArtifactBlob(blob: Blob, filename: string) {
|
||||
if (typeof document === "undefined" || typeof URL === "undefined") {
|
||||
return;
|
||||
}
|
||||
const blob = new Blob(chunks, { type: reference.contentType });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = url;
|
||||
anchor.download = safeArtifactFilename(reference.filename);
|
||||
anchor.download = safeArtifactFilename(filename);
|
||||
anchor.rel = "noopener";
|
||||
document.body.append(anchor);
|
||||
anchor.click();
|
||||
anchor.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
window.setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||
}
|
||||
|
||||
export function safeArtifactFilename(filename: string): string {
|
||||
const cleaned = filename.replace(/[\\/]/g, "").trim();
|
||||
const cleaned = filename.replace(/[\\/:*?"<>|]+/g, "-").trim();
|
||||
return cleaned || "artifact.bin";
|
||||
}
|
||||
|
||||
interface ArtifactFileWriter {
|
||||
write(data: DownloadChunk): Promise<void>;
|
||||
close(): Promise<void>;
|
||||
abort(): Promise<void>;
|
||||
}
|
||||
|
||||
async function createArtifactFileWriter(filename: string, contentType: string): Promise<ArtifactFileWriter> {
|
||||
const picker = typeof window === "undefined" ? undefined : (window as Window & { showSaveFilePicker?: (options?: { suggestedName?: string }) => Promise<{ createWritable(): Promise<FileSystemWritableFileStream> }> }).showSaveFilePicker;
|
||||
if (picker) {
|
||||
const handle = await picker({ suggestedName: safeArtifactFilename(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: DownloadChunk[] = [];
|
||||
return {
|
||||
write: async (data) => { chunks.push(data); },
|
||||
close: async () => { saveArtifactBlob(new Blob(chunks, { type: contentType }), filename); },
|
||||
abort: async () => { chunks.length = 0; }
|
||||
};
|
||||
}
|
||||
|
||||
function progressPercent(downloadedBytes: number, totalBytes: number): number {
|
||||
if (!Number.isFinite(totalBytes) || totalBytes <= 0) return 0;
|
||||
return Math.min(100, Math.round((downloadedBytes / totalBytes) * 100));
|
||||
}
|
||||
|
||||
function copyStreamChunk(value: Uint8Array): ArrayBuffer {
|
||||
const copy = new Uint8Array(value.byteLength);
|
||||
copy.set(value);
|
||||
return copy.buffer;
|
||||
}
|
||||
|
||||
export function safeArtifactError(error: unknown): string {
|
||||
const message = error instanceof Error ? error.message : "制品传输失败";
|
||||
return message.replace(/\/Users\/[^\s]+/g, "[path]").replace(/Bearer\s+[^\s]+/gi, "[token]").replace(/sk-[A-Za-z0-9_-]+/g, "[secret]");
|
||||
|
||||
@@ -1,72 +1,18 @@
|
||||
import type { PlatformApiClient } from "../api/client";
|
||||
import type { ServerFileDownloadResponse } from "../api/types";
|
||||
import { downloadArtifactReference, safeArtifactFilename, saveArtifactBlob } from "./artifactTransfer";
|
||||
|
||||
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");
|
||||
saveArtifactBlob(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 writer = await createFileWriter(result.filename || result.artifact.filename);
|
||||
let offset = 0;
|
||||
const chunkSize = Math.max(1, result.artifact.chunkSizeBytes || 1024 * 1024);
|
||||
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;
|
||||
}
|
||||
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");
|
||||
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";
|
||||
await downloadArtifactReference(result.artifact, (artifactId) => client.downloadArtifactContent(artifactId));
|
||||
return `下载已开始:${safeArtifactFilename(result.filename || result.artifact.filename)}`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user