Improve Jupyter workspace bootstrap
This commit is contained in:
@@ -8,6 +8,7 @@ import {
|
||||
} from "@/lib/claw-auth";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
const CLAW_API_URL = process.env.CLAW_API_URL ?? "http://127.0.0.1:8765";
|
||||
|
||||
export async function GET(req: Request) {
|
||||
const account = await getCurrentAccount();
|
||||
@@ -19,15 +20,26 @@ export async function GET(req: Request) {
|
||||
const sessionId = normalizeSessionId(url.searchParams.get("session_id"));
|
||||
const onlineDocs = await readOnlineDocMap(account.id);
|
||||
if (!requestedPath && sessionId) {
|
||||
const remoteFiles = await listRemoteSessionFiles(
|
||||
account.id,
|
||||
sessionId,
|
||||
onlineDocs,
|
||||
);
|
||||
return Response.json({
|
||||
session_id: sessionId,
|
||||
input: await listSessionFiles(account.id, sessionId, "input", onlineDocs),
|
||||
output: await listSessionFiles(
|
||||
account.id,
|
||||
sessionId,
|
||||
"output",
|
||||
onlineDocs,
|
||||
),
|
||||
input: [
|
||||
...(await listSessionFiles(account.id, sessionId, "input", onlineDocs)),
|
||||
...remoteFiles.input,
|
||||
],
|
||||
output: [
|
||||
...(await listSessionFiles(
|
||||
account.id,
|
||||
sessionId,
|
||||
"output",
|
||||
onlineDocs,
|
||||
)),
|
||||
...remoteFiles.output,
|
||||
],
|
||||
});
|
||||
}
|
||||
if (!requestedPath) {
|
||||
@@ -35,23 +47,38 @@ export async function GET(req: Request) {
|
||||
if (!latestSessionId) {
|
||||
return Response.json({ session_id: null, input: [], output: [] });
|
||||
}
|
||||
const remoteFiles = await listRemoteSessionFiles(
|
||||
account.id,
|
||||
latestSessionId,
|
||||
onlineDocs,
|
||||
);
|
||||
return Response.json({
|
||||
session_id: latestSessionId,
|
||||
input: await listSessionFiles(
|
||||
account.id,
|
||||
latestSessionId,
|
||||
"input",
|
||||
onlineDocs,
|
||||
),
|
||||
output: await listSessionFiles(
|
||||
account.id,
|
||||
latestSessionId,
|
||||
"output",
|
||||
onlineDocs,
|
||||
),
|
||||
input: [
|
||||
...(await listSessionFiles(
|
||||
account.id,
|
||||
latestSessionId,
|
||||
"input",
|
||||
onlineDocs,
|
||||
)),
|
||||
...remoteFiles.input,
|
||||
],
|
||||
output: [
|
||||
...(await listSessionFiles(
|
||||
account.id,
|
||||
latestSessionId,
|
||||
"output",
|
||||
onlineDocs,
|
||||
)),
|
||||
...remoteFiles.output,
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
if (requestedPath.startsWith("jupyter://")) {
|
||||
return downloadRemoteFile(account.id, requestedPath);
|
||||
}
|
||||
|
||||
const accountRoot = path.resolve(accountBaseRoot(account.id));
|
||||
const filePath = path.resolve(requestedPath);
|
||||
if (!filePath.startsWith(`${accountRoot}${path.sep}`)) {
|
||||
@@ -75,6 +102,123 @@ export async function GET(req: Request) {
|
||||
}
|
||||
}
|
||||
|
||||
async function listRemoteSessionFiles(
|
||||
accountId: string,
|
||||
sessionId: string,
|
||||
onlineDocs: Record<string, OnlineDocRecord>,
|
||||
) {
|
||||
try {
|
||||
const target = new URL(`${CLAW_API_URL}/api/jupyter/files`);
|
||||
target.searchParams.set("account_id", accountId);
|
||||
target.searchParams.set("session_id", sessionId);
|
||||
const response = await fetch(target, { cache: "no-store" });
|
||||
if (!response.ok) return { input: [], output: [] };
|
||||
const payload = (await response.json()) as {
|
||||
input?: RemoteSessionFile[];
|
||||
output?: RemoteSessionFile[];
|
||||
};
|
||||
return {
|
||||
input: mapRemoteFiles(payload.input, sessionId, "input", onlineDocs),
|
||||
output: mapRemoteFiles(payload.output, sessionId, "output", onlineDocs),
|
||||
};
|
||||
} catch {
|
||||
return { input: [], output: [] };
|
||||
}
|
||||
}
|
||||
|
||||
type RemoteSessionFile = {
|
||||
name?: unknown;
|
||||
path?: unknown;
|
||||
kind?: unknown;
|
||||
size?: unknown;
|
||||
modified_at?: unknown;
|
||||
};
|
||||
|
||||
function mapRemoteFiles(
|
||||
files: RemoteSessionFile[] | undefined,
|
||||
sessionId: string,
|
||||
kind: "input" | "output",
|
||||
onlineDocs: Record<string, OnlineDocRecord>,
|
||||
) {
|
||||
if (!Array.isArray(files)) return [];
|
||||
return files
|
||||
.map((file) => {
|
||||
const remotePath =
|
||||
typeof file.path === "string" && file.path.startsWith("/")
|
||||
? file.path
|
||||
: null;
|
||||
if (!remotePath) return null;
|
||||
const name =
|
||||
typeof file.name === "string" && file.name.trim()
|
||||
? file.name.trim()
|
||||
: path.basename(remotePath);
|
||||
const uri = toJupyterFileUri(sessionId, remotePath);
|
||||
const onlineDoc = onlineDocs[uri];
|
||||
return {
|
||||
name,
|
||||
path: uri,
|
||||
kind,
|
||||
source: "jupyter",
|
||||
size: typeof file.size === "number" ? file.size : 0,
|
||||
modified_at:
|
||||
typeof file.modified_at === "string"
|
||||
? file.modified_at
|
||||
: new Date().toISOString(),
|
||||
download_url: `/api/claw/files?path=${encodeURIComponent(uri)}`,
|
||||
online_doc_url: cleanOnlineDocUrl(onlineDoc?.url) ?? null,
|
||||
online_doc_title: onlineDoc?.title ?? null,
|
||||
online_doc_kind: onlineDoc?.kind ?? null,
|
||||
online_doc_updated_at: onlineDoc?.updated_at ?? null,
|
||||
};
|
||||
})
|
||||
.filter((file): file is NonNullable<typeof file> => Boolean(file));
|
||||
}
|
||||
|
||||
async function downloadRemoteFile(accountId: string, uri: string) {
|
||||
const parsed = parseJupyterFileUri(uri);
|
||||
if (!parsed) {
|
||||
return Response.json({ error: "无效的远端文件路径" }, { status: 400 });
|
||||
}
|
||||
const target = new URL(`${CLAW_API_URL}/api/jupyter/file`);
|
||||
target.searchParams.set("account_id", accountId);
|
||||
target.searchParams.set("session_id", parsed.sessionId);
|
||||
target.searchParams.set("path", parsed.remotePath);
|
||||
const response = await fetch(target, { cache: "no-store" });
|
||||
if (!response.ok) {
|
||||
const payload = await response.text();
|
||||
return new Response(payload, {
|
||||
status: response.status,
|
||||
headers: {
|
||||
"content-type":
|
||||
response.headers.get("content-type") ?? "application/json",
|
||||
},
|
||||
});
|
||||
}
|
||||
return new Response(response.body, {
|
||||
status: response.status,
|
||||
headers: {
|
||||
"content-type":
|
||||
response.headers.get("content-type") ?? "application/octet-stream",
|
||||
"content-disposition":
|
||||
response.headers.get("content-disposition") ??
|
||||
`attachment; filename="${encodeURIComponent(path.basename(parsed.remotePath))}"`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function toJupyterFileUri(sessionId: string, remotePath: string) {
|
||||
return `jupyter://${sessionId}${remotePath}`;
|
||||
}
|
||||
|
||||
function parseJupyterFileUri(uri: string) {
|
||||
const match = uri.match(/^jupyter:\/\/([^/]+)(\/.*)$/u);
|
||||
if (!match) return null;
|
||||
return {
|
||||
sessionId: match[1],
|
||||
remotePath: match[2],
|
||||
};
|
||||
}
|
||||
|
||||
async function listSessionFiles(
|
||||
accountId: string,
|
||||
sessionId: string,
|
||||
|
||||
@@ -32,7 +32,14 @@ import {
|
||||
WrenchIcon,
|
||||
} from "lucide-react";
|
||||
import { Popover as PopoverPrimitive } from "radix-ui";
|
||||
import type { ComponentProps, FC, KeyboardEvent, ReactNode } from "react";
|
||||
import type {
|
||||
ComponentProps,
|
||||
Dispatch,
|
||||
FC,
|
||||
KeyboardEvent,
|
||||
ReactNode,
|
||||
SetStateAction,
|
||||
} from "react";
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
@@ -359,6 +366,20 @@ type JupyterWorkspaceStatus = {
|
||||
error?: string;
|
||||
};
|
||||
|
||||
type WorkspaceSwitchProgress = {
|
||||
percent: number;
|
||||
label: string;
|
||||
};
|
||||
|
||||
const WORKSPACE_SWITCH_STEPS = [
|
||||
"登录 Jupyter",
|
||||
"初始化目录",
|
||||
"创建 Python 环境",
|
||||
"配置 pip 源",
|
||||
"同步 Skill",
|
||||
"链接工作区",
|
||||
];
|
||||
|
||||
function WorkspaceSwitchDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
@@ -376,6 +397,9 @@ function WorkspaceSwitchDialog({
|
||||
const [status, setStatus] = useState<JupyterWorkspaceStatus | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [message, setMessage] = useState("");
|
||||
const [progress, setProgress] = useState<WorkspaceSwitchProgress | null>(
|
||||
null,
|
||||
);
|
||||
const fieldId = useId();
|
||||
const effectiveSessionId =
|
||||
sessionId ??
|
||||
@@ -405,21 +429,19 @@ function WorkspaceSwitchDialog({
|
||||
|
||||
async function submit() {
|
||||
setMessage("");
|
||||
if (!effectiveSessionId) {
|
||||
setMessage("请先发送一条消息创建 session,再切换工作区。");
|
||||
return;
|
||||
}
|
||||
const sessionIdForRequest = ensureWorkspaceSessionId(effectiveSessionId);
|
||||
if (!jupyterUrl.trim() || !password) {
|
||||
setMessage("请填写 Jupyter 地址和密码。");
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
const progressTimer = startWorkspaceProgress(setProgress);
|
||||
try {
|
||||
const response = await fetch("/api/claw/jupyter", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
session_id: effectiveSessionId,
|
||||
session_id: sessionIdForRequest,
|
||||
base_url: jupyterUrl.trim(),
|
||||
password,
|
||||
workspace_root: workspaceRoot.trim() || "/root/zk_agent_workspaces",
|
||||
@@ -432,11 +454,15 @@ function WorkspaceSwitchDialog({
|
||||
setMessage(payload.detail ?? payload.error ?? "切换工作区失败");
|
||||
return;
|
||||
}
|
||||
writeActiveSessionId(sessionIdForRequest);
|
||||
setStatus(payload as JupyterWorkspaceStatus);
|
||||
setProgress({ percent: 100, label: "切换完成" });
|
||||
setMessage("工作区切换成功,后续工具会在远端 Jupyter 工作区执行。");
|
||||
setPassword("");
|
||||
} finally {
|
||||
window.clearInterval(progressTimer);
|
||||
setSubmitting(false);
|
||||
window.setTimeout(() => setProgress(null), 1200);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -475,6 +501,7 @@ function WorkspaceSwitchDialog({
|
||||
value={jupyterUrl}
|
||||
onChange={(event) => setJupyterUrl(event.target.value)}
|
||||
placeholder="https://...-jupyter.../lab"
|
||||
disabled={submitting}
|
||||
/>
|
||||
</label>
|
||||
<label className="grid gap-1.5" htmlFor={`${fieldId}-password`}>
|
||||
@@ -485,6 +512,7 @@ function WorkspaceSwitchDialog({
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
placeholder="Jupyter 登录密码"
|
||||
disabled={submitting}
|
||||
/>
|
||||
</label>
|
||||
<label className="grid gap-1.5" htmlFor={`${fieldId}-root`}>
|
||||
@@ -493,8 +521,25 @@ function WorkspaceSwitchDialog({
|
||||
id={`${fieldId}-root`}
|
||||
value={workspaceRoot}
|
||||
onChange={(event) => setWorkspaceRoot(event.target.value)}
|
||||
disabled={submitting}
|
||||
/>
|
||||
</label>
|
||||
{progress ? (
|
||||
<div className="rounded-md border bg-muted/30 px-3 py-2">
|
||||
<div className="mb-2 flex items-center justify-between gap-3 text-xs">
|
||||
<span className="text-muted-foreground">{progress.label}</span>
|
||||
<span className="font-medium tabular-nums">
|
||||
{Math.round(progress.percent)}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-1.5 overflow-hidden rounded-full bg-muted">
|
||||
<div
|
||||
className="h-full rounded-full bg-primary transition-all duration-500"
|
||||
style={{ width: `${progress.percent}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{message ? (
|
||||
<div className="rounded-md bg-muted px-3 py-2 text-muted-foreground">
|
||||
{message}
|
||||
@@ -518,6 +563,43 @@ function WorkspaceSwitchDialog({
|
||||
);
|
||||
}
|
||||
|
||||
function ensureWorkspaceSessionId(current: string | null) {
|
||||
if (current) return current;
|
||||
const generated =
|
||||
typeof crypto !== "undefined" && "randomUUID" in crypto
|
||||
? `__LOCALID_${crypto.randomUUID().replace(/-/g, "").slice(0, 8)}`
|
||||
: `__LOCALID_${Math.random().toString(36).slice(2, 10)}`;
|
||||
writeActiveSessionId(generated);
|
||||
scheduleSessionListRefresh();
|
||||
return generated;
|
||||
}
|
||||
|
||||
function startWorkspaceProgress(
|
||||
setProgress: Dispatch<SetStateAction<WorkspaceSwitchProgress | null>>,
|
||||
) {
|
||||
setProgress({ percent: 6, label: WORKSPACE_SWITCH_STEPS[0] });
|
||||
return window.setInterval(() => {
|
||||
setProgress((current) => {
|
||||
const previous = current ?? {
|
||||
percent: 6,
|
||||
label: WORKSPACE_SWITCH_STEPS[0],
|
||||
};
|
||||
const nextPercent = Math.min(
|
||||
92,
|
||||
previous.percent + (previous.percent < 55 ? 9 : 4),
|
||||
);
|
||||
const stepIndex = Math.min(
|
||||
WORKSPACE_SWITCH_STEPS.length - 1,
|
||||
Math.floor((nextPercent / 100) * WORKSPACE_SWITCH_STEPS.length),
|
||||
);
|
||||
return {
|
||||
percent: nextPercent,
|
||||
label: WORKSPACE_SWITCH_STEPS[stepIndex],
|
||||
};
|
||||
});
|
||||
}, 1200);
|
||||
}
|
||||
|
||||
const ThreadMessage: FC = () => {
|
||||
const role = useAuiState((s) => s.message.role);
|
||||
const isEditing = useAuiState((s) => s.message.composer.isEditing);
|
||||
|
||||
Reference in New Issue
Block a user