Files
zk-data-agent/docker/web/WorkspaceFiles.svelte

300 lines
8.8 KiB
Svelte
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>