300 lines
8.8 KiB
Svelte
300 lines
8.8 KiB
Svelte
<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>
|