feat: add remote workspaces and verified deliverables

This commit is contained in:
wuyang
2026-07-26 12:58:04 +08:00
parent 37f83eaac7
commit 0b4d799216
30 changed files with 1595 additions and 55 deletions
+299
View File
@@ -0,0 +1,299 @@
<script lang="ts">
import Modal from '$lib/components/common/Modal.svelte';
import { WEBUI_API_BASE_URL } from '$lib/constants';
export let show = false;
type WorkspaceEntry = {
name: string;
path: string;
kind: 'file' | 'directory' | 'symlink';
size: number;
modified_at: number;
};
let path = '.';
let entries: WorkspaceEntry[] = [];
let loading = false;
let downloading = '';
let error = '';
let opened = false;
$: pathParts = path === '.' ? [] : path.split('/');
$: breadcrumbs = [
{ label: '工作区', path: '.' },
...pathParts.map((part, index) => ({
label: part,
path: pathParts.slice(0, index + 1).join('/')
}))
];
$: if (show && !opened) {
opened = true;
void loadFiles(path);
}
$: if (!show) opened = false;
const authHeaders = () => ({
Authorization: `Bearer ${localStorage.token}`
});
async function responseError(response: Response): Promise<string> {
try {
const payload = await response.json();
return typeof payload?.detail === 'string'
? payload.detail
: JSON.stringify(payload?.detail ?? payload);
} catch {
return `请求失败(${response.status}`;
}
}
async function loadFiles(nextPath: string) {
loading = true;
error = '';
try {
const response = await fetch(
`${WEBUI_API_BASE_URL}/k1412/workspace/files?path=${encodeURIComponent(nextPath)}`,
{ headers: authHeaders() }
);
if (!response.ok) throw new Error(await responseError(response));
const payload = await response.json();
path = payload.path || '.';
entries = payload.entries || [];
} catch (exception) {
error = exception instanceof Error ? exception.message : String(exception);
} finally {
loading = false;
}
}
function downloadName(response: Response, fallback: string): string {
const disposition = response.headers.get('content-disposition') ?? '';
const encoded = disposition.match(/filename\*=UTF-8''([^;]+)/i)?.[1];
if (encoded) {
try {
return decodeURIComponent(encoded);
} catch {
return fallback;
}
}
return fallback;
}
async function saveDownload(
endpoint: 'download' | 'archive',
itemPath: string,
fallback: string
) {
downloading = itemPath;
error = '';
try {
const response = await fetch(
`${WEBUI_API_BASE_URL}/k1412/workspace/${endpoint}?path=${encodeURIComponent(itemPath)}`,
{ headers: authHeaders() }
);
if (!response.ok) throw new Error(await responseError(response));
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = downloadName(response, fallback);
document.body.appendChild(link);
link.click();
link.remove();
URL.revokeObjectURL(url);
} catch (exception) {
error = exception instanceof Error ? exception.message : String(exception);
} finally {
downloading = '';
}
}
function formatSize(size: number): string {
if (size < 1024) return `${size} B`;
if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`;
if (size < 1024 * 1024 * 1024) return `${(size / 1024 / 1024).toFixed(1)} MB`;
return `${(size / 1024 / 1024 / 1024).toFixed(1)} GB`;
}
</script>
<Modal bind:show size="lg" className="bg-white dark:bg-gray-900 rounded-3xl overflow-hidden">
<div class="flex h-[min(74vh,46rem)] min-h-[28rem] flex-col">
<header
class="flex items-center justify-between gap-3 border-b border-gray-100 px-5 py-4 dark:border-gray-800"
>
<div>
<h2 class="text-base font-semibold text-gray-900 dark:text-white">工作区文件</h2>
<p class="mt-0.5 text-xs text-gray-500">这里是当前用户 Agent 生成和修改的文件</p>
</div>
<div class="flex items-center gap-2">
<button
type="button"
class="rounded-xl border border-gray-200 px-3 py-2 text-xs font-medium text-gray-700 transition hover:bg-gray-50 disabled:opacity-50 dark:border-gray-700 dark:text-gray-200 dark:hover:bg-gray-800"
on:click={() =>
saveDownload(
'archive',
path,
path === '.' ? 'workspace.tar.gz' : `${pathParts.at(-1)}.tar.gz`
)}
disabled={loading || Boolean(downloading)}
>
{downloading === path ? '打包中…' : '打包下载'}
</button>
<button
type="button"
class="rounded-xl p-2 text-gray-500 transition hover:bg-gray-100 hover:text-gray-900 dark:hover:bg-gray-800 dark:hover:text-white"
on:click={() => (show = false)}
aria-label="关闭"
>
<svg
viewBox="0 0 24 24"
class="size-5"
fill="none"
stroke="currentColor"
stroke-width="1.8"
>
<path d="M6 6l12 12M18 6 6 18" stroke-linecap="round" />
</svg>
</button>
</div>
</header>
<nav
class="flex min-h-12 items-center gap-1 overflow-x-auto border-b border-gray-100 px-5 dark:border-gray-800"
>
{#each breadcrumbs as crumb, index}
{#if index > 0}<span class="text-gray-300 dark:text-gray-700">/</span>{/if}
<button
type="button"
class="shrink-0 rounded-lg px-2 py-1 text-xs {index === breadcrumbs.length - 1
? 'font-medium text-gray-900 dark:text-white'
: 'text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-800'}"
on:click={() => loadFiles(crumb.path)}
>
{crumb.label}
</button>
{/each}
<button
type="button"
class="ml-auto shrink-0 rounded-lg p-2 text-gray-400 transition hover:bg-gray-100 hover:text-gray-700 dark:hover:bg-gray-800 dark:hover:text-gray-200"
on:click={() => loadFiles(path)}
aria-label="刷新"
>
<svg
viewBox="0 0 24 24"
class="size-4"
fill="none"
stroke="currentColor"
stroke-width="1.8"
>
<path
d="M20 11a8 8 0 1 0-2.34 5.66M20 4v7h-7"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</button>
</nav>
<div class="min-h-0 flex-1 overflow-y-auto px-3 py-3">
{#if error}
<div
class="mx-2 mb-3 rounded-xl bg-red-50 px-3 py-2 text-sm text-red-700 dark:bg-red-950/40 dark:text-red-300"
>
{error}
</div>
{/if}
{#if loading}
<div class="flex h-40 items-center justify-center text-sm text-gray-400">
正在读取工作区…
</div>
{:else if entries.length === 0}
<div class="flex h-40 flex-col items-center justify-center text-gray-400">
<svg
viewBox="0 0 24 24"
class="mb-3 size-9"
fill="none"
stroke="currentColor"
stroke-width="1.5"
>
<path d="M3.5 6.5h6l2 2h9v10h-17z" stroke-linejoin="round" />
</svg>
<span class="text-sm">这个目录还是空的</span>
</div>
{:else}
<div class="space-y-1">
{#each entries as entry}
<div
class="group flex items-center gap-3 rounded-xl px-3 py-2.5 transition hover:bg-gray-50 dark:hover:bg-gray-800/70"
>
<div class={entry.kind === 'directory' ? 'text-amber-500' : 'text-gray-400'}>
{#if entry.kind === 'directory'}
<svg viewBox="0 0 24 24" class="size-5" fill="currentColor">
<path d="M3 5.5h7l2 2h9v11H3z" />
</svg>
{:else}
<svg
viewBox="0 0 24 24"
class="size-5"
fill="none"
stroke="currentColor"
stroke-width="1.7"
>
<path d="M6 2.8h8l4 4v14.4H6zM14 2.8v4h4" stroke-linejoin="round" />
</svg>
{/if}
</div>
<button
type="button"
class="min-w-0 flex-1 text-left"
on:click={() =>
entry.kind === 'directory'
? loadFiles(entry.path)
: saveDownload('download', entry.path, entry.name)}
disabled={entry.kind === 'symlink'}
>
<div class="truncate text-sm font-medium text-gray-800 dark:text-gray-100">
{entry.name}
</div>
<div class="mt-0.5 text-[11px] text-gray-400">
{entry.kind === 'directory'
? '文件夹'
: entry.kind === 'symlink'
? '符号链接'
: formatSize(entry.size)}
<span class="mx-1.5">·</span>
{new Date(entry.modified_at * 1000).toLocaleString()}
</div>
</button>
{#if entry.kind === 'file'}
<button
type="button"
class="rounded-lg p-2 text-gray-400 opacity-0 transition hover:bg-white hover:text-gray-900 group-hover:opacity-100 dark:hover:bg-gray-700 dark:hover:text-white"
on:click={() => saveDownload('download', entry.path, entry.name)}
disabled={Boolean(downloading)}
aria-label={`下载 ${entry.name}`}
>
<svg
viewBox="0 0 24 24"
class="size-4"
fill="none"
stroke="currentColor"
stroke-width="1.8"
>
<path
d="M12 3v12m0 0 4-4m-4 4-4-4M5 20h14"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</button>
{/if}
</div>
{/each}
</div>
{/if}
</div>
</div>
</Modal>