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
+18 -2
View File
@@ -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} />
+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>
+17
View File
@@ -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

+104
View File
@@ -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)
+33
View File
@@ -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'])