feat: add remote workspaces and verified deliverables
This commit is contained in:
@@ -1,9 +1,11 @@
|
||||
<script lang="ts">
|
||||
import { models } from '$lib/stores';
|
||||
import WorkspaceFiles from './WorkspaceFiles.svelte';
|
||||
|
||||
export let selectedModels = [''];
|
||||
export let disabled = false;
|
||||
export let showSetDefault = true;
|
||||
let showWorkspace = false;
|
||||
|
||||
const strengths = [
|
||||
{ key: 'light', label: '轻度' },
|
||||
@@ -67,7 +69,7 @@
|
||||
? 'bg-white text-gray-900 shadow-sm dark:bg-gray-700 dark:text-white'
|
||||
: 'text-gray-500 hover:text-gray-800 dark:text-gray-400 dark:hover:text-gray-200'}"
|
||||
on:click={() => chooseMode('work')}
|
||||
disabled={disabled}
|
||||
{disabled}
|
||||
title="长链路、多步骤工作"
|
||||
>
|
||||
Work
|
||||
@@ -85,10 +87,24 @@
|
||||
? 'bg-gray-900 text-white dark:bg-white dark:text-gray-900'
|
||||
: 'text-gray-500 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-800'}"
|
||||
on:click={() => chooseStrength(item.key)}
|
||||
disabled={disabled}
|
||||
{disabled}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-xl border border-gray-200/80 p-2 text-gray-500 transition hover:bg-gray-100 hover:text-gray-900 dark:border-gray-700/80 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-white"
|
||||
on:click={() => (showWorkspace = true)}
|
||||
title="工作区文件"
|
||||
aria-label="工作区文件"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" class="size-4" fill="none" stroke="currentColor" stroke-width="1.8">
|
||||
<path d="M3.5 6.5h6l2 2h9v10h-17z" stroke-linejoin="round" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<WorkspaceFiles bind:show={showWorkspace} />
|
||||
|
||||
@@ -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>
|
||||
@@ -0,0 +1,17 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" role="img" aria-label="K1412 Agent">
|
||||
<defs>
|
||||
<linearGradient id="surface" x1="48" y1="32" x2="464" y2="480" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#171922"/>
|
||||
<stop offset="1" stop-color="#08090d"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="accent" x1="152" y1="112" x2="384" y2="400" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#a78bfa"/>
|
||||
<stop offset="0.55" stop-color="#7c3aed"/>
|
||||
<stop offset="1" stop-color="#4f46e5"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect x="24" y="24" width="464" height="464" rx="128" fill="url(#surface)"/>
|
||||
<path d="M154 116v280" fill="none" stroke="#f8fafc" stroke-width="58" stroke-linecap="round"/>
|
||||
<path d="M357 125 190 256l167 131" fill="none" stroke="url(#accent)" stroke-width="62" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<circle cx="403" cy="99" r="24" fill="#c4b5fd"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 944 B |
@@ -0,0 +1,104 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from collections.abc import AsyncIterator
|
||||
from typing import Annotated
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from fastapi.responses import StreamingResponse
|
||||
from open_webui.utils.auth import get_verified_user
|
||||
from open_webui.utils.headers import include_user_info_headers
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
GATEWAY_URL = os.getenv("K1412_GATEWAY_URL", "http://gateway:8001").rstrip("/")
|
||||
GATEWAY_KEY = os.getenv("K1412_GATEWAY_KEY", "")
|
||||
REQUEST_TIMEOUT = httpx.Timeout(120, connect=10)
|
||||
STREAM_TIMEOUT = httpx.Timeout(1800, connect=10)
|
||||
VerifiedUser = Annotated[object, Depends(get_verified_user)]
|
||||
|
||||
|
||||
def gateway_headers(user) -> dict[str, str]:
|
||||
if not GATEWAY_KEY:
|
||||
raise HTTPException(status_code=503, detail="Workspace service is not configured")
|
||||
return include_user_info_headers(
|
||||
{"Authorization": f"Bearer {GATEWAY_KEY}"},
|
||||
user,
|
||||
)
|
||||
|
||||
|
||||
async def gateway_error(response: httpx.Response) -> HTTPException:
|
||||
try:
|
||||
payload = response.json()
|
||||
detail = payload.get("detail", payload)
|
||||
except Exception:
|
||||
detail = (await response.aread()).decode("utf-8", errors="replace")[:2000]
|
||||
return HTTPException(status_code=response.status_code, detail=detail)
|
||||
|
||||
|
||||
@router.get("/files")
|
||||
async def list_workspace_files(
|
||||
user: VerifiedUser,
|
||||
path: str = Query(default=".", max_length=4096),
|
||||
):
|
||||
async with httpx.AsyncClient(timeout=REQUEST_TIMEOUT) as client:
|
||||
response = await client.get(
|
||||
f"{GATEWAY_URL}/v1/files",
|
||||
params={"path": path},
|
||||
headers=gateway_headers(user),
|
||||
)
|
||||
if response.status_code >= 400:
|
||||
raise await gateway_error(response)
|
||||
return response.json()
|
||||
|
||||
|
||||
async def close_after_stream(response: httpx.Response, client: httpx.AsyncClient) -> AsyncIterator[bytes]:
|
||||
try:
|
||||
async for chunk in response.aiter_bytes():
|
||||
yield chunk
|
||||
finally:
|
||||
await response.aclose()
|
||||
await client.aclose()
|
||||
|
||||
|
||||
async def stream_workspace_response(endpoint: str, path: str, user) -> StreamingResponse:
|
||||
client = httpx.AsyncClient(timeout=STREAM_TIMEOUT)
|
||||
request = client.build_request(
|
||||
"GET",
|
||||
f"{GATEWAY_URL}{endpoint}",
|
||||
params={"path": path},
|
||||
headers=gateway_headers(user),
|
||||
)
|
||||
response = await client.send(request, stream=True)
|
||||
if response.status_code >= 400:
|
||||
error = await gateway_error(response)
|
||||
await response.aclose()
|
||||
await client.aclose()
|
||||
raise error
|
||||
headers = {
|
||||
key: value
|
||||
for key, value in response.headers.items()
|
||||
if key.lower() in {"content-disposition", "content-length"}
|
||||
}
|
||||
return StreamingResponse(
|
||||
close_after_stream(response, client),
|
||||
media_type=response.headers.get("content-type", "application/octet-stream"),
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/download")
|
||||
async def download_workspace_file(
|
||||
user: VerifiedUser,
|
||||
path: str = Query(min_length=1, max_length=4096),
|
||||
):
|
||||
return await stream_workspace_response("/v1/files/download", path, user)
|
||||
|
||||
|
||||
@router.get("/archive")
|
||||
async def archive_workspace_files(
|
||||
user: VerifiedUser,
|
||||
path: str = Query(default=".", max_length=4096),
|
||||
):
|
||||
return await stream_workspace_response("/v1/files/archive", path, user)
|
||||
@@ -0,0 +1,33 @@
|
||||
diff --git a/backend/open_webui/env.py b/backend/open_webui/env.py
|
||||
index 3d85f30..524f623 100644
|
||||
--- a/backend/open_webui/env.py
|
||||
+++ b/backend/open_webui/env.py
|
||||
@@ -769,10 +769,13 @@ TRUSTED_SIGNATURE_KEY = os.getenv('TRUSTED_SIGNATURE_KEY', '')
|
||||
####################################
|
||||
|
||||
WEBUI_NAME = os.getenv('WEBUI_NAME', 'Open WebUI')
|
||||
-if WEBUI_NAME != 'Open WebUI':
|
||||
+K1412_REMOVE_UPSTREAM_BRANDING = (
|
||||
+ os.getenv('K1412_REMOVE_UPSTREAM_BRANDING', 'false').lower() == 'true'
|
||||
+)
|
||||
+if WEBUI_NAME != 'Open WebUI' and not K1412_REMOVE_UPSTREAM_BRANDING:
|
||||
WEBUI_NAME += ' (Open WebUI)'
|
||||
|
||||
-WEBUI_FAVICON_URL = 'https://openwebui.com/favicon.png'
|
||||
+WEBUI_FAVICON_URL = os.getenv('WEBUI_FAVICON_URL', '/static/favicon.png')
|
||||
WEBUI_BUILD_HASH = os.getenv('WEBUI_BUILD_HASH', 'dev-build')
|
||||
TRUSTED_SIGNATURE_KEY = os.getenv('TRUSTED_SIGNATURE_KEY', '')
|
||||
|
||||
diff --git a/src/lib/constants.ts b/src/lib/constants.ts
|
||||
index 2926448..d1a4c47 100644
|
||||
--- a/src/lib/constants.ts
|
||||
+++ b/src/lib/constants.ts
|
||||
@@ -1,7 +1,7 @@
|
||||
import { browser, dev } from '$app/environment';
|
||||
// import { version } from '../../package.json';
|
||||
|
||||
-export const APP_NAME = 'Open WebUI';
|
||||
+export const APP_NAME = 'K1412 Agent';
|
||||
|
||||
export const WEBUI_HOSTNAME = browser ? (dev ? `${location.hostname}:8080` : ``) : '';
|
||||
export const WEBUI_BASE_URL = browser ? (dev ? `http://${WEBUI_HOSTNAME}` : ``) : ``;
|
||||
@@ -0,0 +1,18 @@
|
||||
diff --git a/backend/open_webui/main.py b/backend/open_webui/main.py
|
||||
index 6bab8a6..2a04f66 100644
|
||||
--- a/backend/open_webui/main.py
|
||||
+++ b/backend/open_webui/main.py
|
||||
@@ -495,5 +495,6 @@ from open_webui.routers import (
|
||||
images,
|
||||
knowledge,
|
||||
+ k1412_workspace,
|
||||
memories,
|
||||
models,
|
||||
notes,
|
||||
@@ -1423,5 +1424,6 @@ app.include_router(retrieval.router, prefix='/api/v1/retrieval', tags=['retrieva
|
||||
|
||||
app.include_router(configs.router, prefix='/api/v1/configs', tags=['configs'])
|
||||
+app.include_router(k1412_workspace.router, prefix='/api/v1/k1412/workspace', tags=['workspace'])
|
||||
|
||||
app.include_router(auths.router, prefix='/api/v1/auths', tags=['auths'])
|
||||
app.include_router(users.router, prefix='/api/v1/users', tags=['users'])
|
||||
Reference in New Issue
Block a user