rebase
This commit is contained in:
@@ -213,6 +213,21 @@ export function SettingsPanel() {
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/*
|
||||
Hidden decoy input to absorb the browser password manager's attention.
|
||||
Browsers that ignore `autocomplete="off"` on visible fields will
|
||||
still key off the first password-typed input in a form, so we plant
|
||||
a hidden one here to prevent the api_key fields below from being
|
||||
offered as saveable passwords.
|
||||
*/}
|
||||
<input
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
style={{ display: "none" }}
|
||||
aria-hidden="true"
|
||||
tabIndex={-1}
|
||||
readOnly
|
||||
/>
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-fg">{t("admin.tab_settings")}</h2>
|
||||
<p className="mt-1 text-xs text-fg-muted">
|
||||
@@ -302,6 +317,7 @@ function SettingField({ settingKey, description, value, onChange }: SettingField
|
||||
<select
|
||||
id={`setting-${settingKey}`}
|
||||
className="input"
|
||||
autoComplete="off"
|
||||
value={value === "true" ? "true" : value === "false" ? "false" : value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
>
|
||||
@@ -320,6 +336,7 @@ function SettingField({ settingKey, description, value, onChange }: SettingField
|
||||
<select
|
||||
id={`setting-${settingKey}`}
|
||||
className="input"
|
||||
autoComplete="off"
|
||||
value={value || "offline_hash"}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
>
|
||||
@@ -340,6 +357,7 @@ function SettingField({ settingKey, description, value, onChange }: SettingField
|
||||
type="number"
|
||||
step={1}
|
||||
min={0}
|
||||
autoComplete="off"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
@@ -356,6 +374,7 @@ function SettingField({ settingKey, description, value, onChange }: SettingField
|
||||
step={0.01}
|
||||
min={0}
|
||||
max={2}
|
||||
autoComplete="off"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
@@ -363,12 +382,17 @@ function SettingField({ settingKey, description, value, onChange }: SettingField
|
||||
}
|
||||
|
||||
if (ft === "secret") {
|
||||
// Use type="text" + autoComplete="off" so the browser does NOT
|
||||
// try to save / fill these as passwords (they're API keys, not
|
||||
// credentials). The backend masks the saved value with `***` so the
|
||||
// real key is never echoed back.
|
||||
return (
|
||||
<Input
|
||||
id={`setting-${settingKey}`}
|
||||
label={label}
|
||||
hint={hint}
|
||||
type="password"
|
||||
type="text"
|
||||
autoComplete="off"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={t("admin.api_key")}
|
||||
@@ -381,6 +405,7 @@ function SettingField({ settingKey, description, value, onChange }: SettingField
|
||||
id={`setting-${settingKey}`}
|
||||
label={label}
|
||||
hint={hint}
|
||||
autoComplete="off"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
|
||||
@@ -353,6 +353,11 @@ function RecreateCollectionsCard() {
|
||||
function TestResultCard({ result }: { result: Record<string, unknown> }) {
|
||||
const { t } = useTranslation();
|
||||
const ok = result.ok === true;
|
||||
const hasToolCallsField = "has_tool_calls" in result;
|
||||
const hasToolCalls = result.has_tool_calls === true;
|
||||
const rawResponse = result.raw_response;
|
||||
const toolCalls = result.tool_calls;
|
||||
|
||||
return (
|
||||
<div className={`mt-3 rounded-md border p-3 text-xs ${ok ? "border-ok/30 bg-ok/5" : "border-err/30 bg-err/5"}`}>
|
||||
<p className={`font-semibold ${ok ? "text-ok" : "text-err"}`}>
|
||||
@@ -393,15 +398,75 @@ function TestResultCard({ result }: { result: Record<string, unknown> }) {
|
||||
first_5_values: [{(result.first_5_values as number[]).slice(0, 5).map((v) => typeof v === "number" ? v.toFixed(4) : String(v)).join(", ")}]
|
||||
</p>
|
||||
)}
|
||||
{result.tool_calls != null && (
|
||||
<pre className="mt-2 max-h-48 overflow-auto whitespace-pre-wrap rounded bg-bg-soft p-2">
|
||||
{JSON.stringify(result.tool_calls, null, 2)}
|
||||
</pre>
|
||||
|
||||
{/* LLM-tools-test specific rendering */}
|
||||
{hasToolCallsField && hasToolCalls && (
|
||||
<div className="mt-2">
|
||||
<p className="font-semibold text-ok">{t("admin.tool_calls_detected")}</p>
|
||||
{Array.isArray(toolCalls) ? (
|
||||
<ul className="mt-1 space-y-1">
|
||||
{(toolCalls as Array<Record<string, unknown>>).map((tc, i) => (
|
||||
<li key={i} className="rounded border border-fg-dim/20 bg-bg-soft p-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{typeof tc.name === "string" && (
|
||||
<span className="badge bg-accent/15 text-accent">{tc.name}</span>
|
||||
)}
|
||||
{typeof tc.id === "string" && (
|
||||
<span className="font-mono text-fg-muted">#{tc.id}</span>
|
||||
)}
|
||||
</div>
|
||||
{tc.arguments != null && (
|
||||
<pre className="mt-1 max-h-40 overflow-auto whitespace-pre-wrap text-[10px] font-mono text-fg-muted">
|
||||
{safeStringify(tc.arguments)}
|
||||
</pre>
|
||||
)}
|
||||
{tc.function != null && typeof tc.function === "object" && (
|
||||
<pre className="mt-1 max-h-40 overflow-auto whitespace-pre-wrap text-[10px] font-mono text-fg-muted">
|
||||
{safeStringify(tc.function)}
|
||||
</pre>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : toolCalls != null ? (
|
||||
<pre className="mt-1 max-h-48 overflow-auto whitespace-pre-wrap rounded bg-bg-soft p-2">
|
||||
{safeStringify(toolCalls)}
|
||||
</pre>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasToolCallsField && !hasToolCalls && (
|
||||
<div className="mt-2 rounded border border-warn/30 bg-warn/5 p-2 text-warn">
|
||||
<p className="font-semibold">{t("admin.no_tool_calls_warning_title")}</p>
|
||||
<p className="mt-1 text-fg">{t("admin.no_tool_calls_warning")}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Show the raw LLM message in a collapsible details section */}
|
||||
{rawResponse != null && (
|
||||
<details className="mt-2 rounded border border-fg-dim/20 bg-bg-soft p-2">
|
||||
<summary className="cursor-pointer text-xs text-fg-muted">
|
||||
{t("admin.raw_response")}
|
||||
</summary>
|
||||
<pre className="mt-2 max-h-72 overflow-auto whitespace-pre-wrap text-[10px] font-mono text-fg-muted">
|
||||
{safeStringify(rawResponse)}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function safeStringify(value: unknown): string {
|
||||
if (typeof value === "string") return value;
|
||||
try {
|
||||
return JSON.stringify(value, null, 2);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
/** Extract a human-readable error message from a test result object. */
|
||||
function extractErr(result: Record<string, unknown>): string {
|
||||
const e = result.error;
|
||||
|
||||
303
frontend/src/components/worlds/IntroSceneGenerator.tsx
Normal file
303
frontend/src/components/worlds/IntroSceneGenerator.tsx
Normal file
@@ -0,0 +1,303 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { SessionsApi, toErrorMessage } from "@/lib/api";
|
||||
import { subscribeSse, type SseController, type SseEvent } from "@/lib/sse";
|
||||
import { useToastStore } from "@/stores/toastStore";
|
||||
import type { World } from "@/types";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Card } from "@/components/ui/Card";
|
||||
import { Spinner } from "@/components/ui/Spinner";
|
||||
import { PhaseProgress } from "@/components/sessions/PhaseProgress";
|
||||
import { SseStatus } from "@/components/sessions/SseStatus";
|
||||
|
||||
interface IntroState {
|
||||
phase: "idle" | "starting" | "streaming" | "done" | "error";
|
||||
phases: Array<{ phase: string; name?: string; done?: boolean }>;
|
||||
currentPhase?: string;
|
||||
step?: number;
|
||||
totalSteps?: number;
|
||||
message?: string;
|
||||
introScene: string;
|
||||
logs: string[];
|
||||
sseStatus: "idle" | "connecting" | "open" | "error" | "closed";
|
||||
errorMessage?: string;
|
||||
}
|
||||
|
||||
const INITIAL_STATE: IntroState = {
|
||||
phase: "idle",
|
||||
phases: [],
|
||||
introScene: "",
|
||||
logs: [],
|
||||
sseStatus: "idle",
|
||||
};
|
||||
|
||||
export interface IntroSceneGeneratorProps {
|
||||
world: World;
|
||||
/** Called when the world data should be refreshed (e.g. after intro is generated). */
|
||||
onWorldUpdated?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* "Generate Intro Scene" component for draft worlds. Calls
|
||||
* POST /api/worlds/{id}/generate-intro to obtain a stream URL, then
|
||||
* subscribes to the SSE stream and shows progress / the generated scene.
|
||||
* On `done`, refreshes world data (status should now be "ready").
|
||||
*/
|
||||
export function IntroSceneGenerator({ world, onWorldUpdated }: IntroSceneGeneratorProps) {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const pushToast = useToastStore((s) => s.push);
|
||||
const [state, setState] = useState<IntroState>(INITIAL_STATE);
|
||||
const controllerRef = useRef<SseController | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
controllerRef.current?.close();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleEvent = useCallback(
|
||||
(event: SseEvent) => {
|
||||
switch (event.event) {
|
||||
case "ping":
|
||||
break;
|
||||
case "error": {
|
||||
const d = event.data as { message?: string };
|
||||
const msg = d?.message || t("editor.intro_failed");
|
||||
setState((s) => ({
|
||||
...s,
|
||||
phase: "error",
|
||||
sseStatus: "error",
|
||||
errorMessage: msg,
|
||||
logs: [...s.logs, `[error] ${msg}`],
|
||||
}));
|
||||
pushToast("error", msg);
|
||||
controllerRef.current?.close();
|
||||
break;
|
||||
}
|
||||
case "warning": {
|
||||
const d = event.data as { message?: string };
|
||||
setState((s) => ({ ...s, logs: [...s.logs, `[warn] ${d?.message || ""}`] }));
|
||||
break;
|
||||
}
|
||||
case "step": {
|
||||
const d = event.data as { step?: number; message?: string };
|
||||
setState((s) => ({
|
||||
...s,
|
||||
step: d.step,
|
||||
message: d.message,
|
||||
logs: [...s.logs, `[${d.step ?? "?"}] ${d.message || ""}`],
|
||||
}));
|
||||
break;
|
||||
}
|
||||
case "progress": {
|
||||
const d = event.data as { phase?: string; step?: number; total_steps?: number; message?: string };
|
||||
setState((s) => ({
|
||||
...s,
|
||||
currentPhase: d.phase,
|
||||
step: d.step,
|
||||
totalSteps: d.total_steps,
|
||||
message: d.message,
|
||||
}));
|
||||
break;
|
||||
}
|
||||
case "phase_start": {
|
||||
const d = event.data as { phase: string; name?: string };
|
||||
setState((s) => ({
|
||||
...s,
|
||||
currentPhase: d.phase,
|
||||
phases: [
|
||||
...s.phases.filter((p) => p.phase !== d.phase),
|
||||
{ phase: d.phase, name: d.name, done: false },
|
||||
],
|
||||
}));
|
||||
break;
|
||||
}
|
||||
case "phase_end": {
|
||||
const d = event.data as { phase: string };
|
||||
setState((s) => ({
|
||||
...s,
|
||||
phases: s.phases.map((p) => (p.phase === d.phase ? { ...p, done: true } : p)),
|
||||
}));
|
||||
break;
|
||||
}
|
||||
case "intro_scene_chunk": {
|
||||
const d = event.data as { text?: string };
|
||||
setState((s) => ({ ...s, introScene: s.introScene + (d?.text || "") }));
|
||||
break;
|
||||
}
|
||||
case "intro_scene_complete": {
|
||||
const d = event.data as { text?: string };
|
||||
setState((s) => ({
|
||||
...s,
|
||||
introScene: d?.text || s.introScene,
|
||||
logs: [...s.logs, t("editor.intro_complete")],
|
||||
}));
|
||||
break;
|
||||
}
|
||||
case "done": {
|
||||
setState((s) => ({
|
||||
...s,
|
||||
phase: "done",
|
||||
sseStatus: "closed",
|
||||
}));
|
||||
controllerRef.current?.close();
|
||||
pushToast("success", t("editor.world_ready"));
|
||||
// Refresh world data — status should now be "ready".
|
||||
onWorldUpdated?.();
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
},
|
||||
[pushToast, t, onWorldUpdated],
|
||||
);
|
||||
|
||||
const start = async () => {
|
||||
setState({
|
||||
...INITIAL_STATE,
|
||||
phase: "starting",
|
||||
sseStatus: "connecting",
|
||||
});
|
||||
try {
|
||||
const res = await SessionsApi.generateIntro(world.id);
|
||||
const streamUrl = res.stream_url || SessionsApi.introStreamUrl(world.id);
|
||||
setState((s) => ({ ...s, phase: "streaming" }));
|
||||
let connectionLostToastShown = false;
|
||||
const c = subscribeSse(streamUrl, {
|
||||
onOpen: () => {
|
||||
connectionLostToastShown = false;
|
||||
setState((s) => ({ ...s, sseStatus: "open" }));
|
||||
},
|
||||
onError: () => {
|
||||
setState((s) => ({ ...s, sseStatus: "error" }));
|
||||
if (!connectionLostToastShown) {
|
||||
connectionLostToastShown = true;
|
||||
pushToast("warning", t("sse.reconnecting"));
|
||||
}
|
||||
},
|
||||
onClose: () => setState((s) => ({ ...s, sseStatus: "closed" })),
|
||||
onEvent: handleEvent,
|
||||
});
|
||||
controllerRef.current?.close();
|
||||
controllerRef.current = c;
|
||||
} catch (err) {
|
||||
setState((s) => ({
|
||||
...s,
|
||||
phase: "error",
|
||||
sseStatus: "error",
|
||||
errorMessage: toErrorMessage(err, t("editor.intro_failed")),
|
||||
}));
|
||||
pushToast("error", toErrorMessage(err, t("editor.intro_failed")));
|
||||
}
|
||||
};
|
||||
|
||||
const retry = () => {
|
||||
controllerRef.current?.close();
|
||||
controllerRef.current = null;
|
||||
setState(INITIAL_STATE);
|
||||
void start();
|
||||
};
|
||||
|
||||
// Hide the generator once the world is ready.
|
||||
if (world.status === "ready") return null;
|
||||
|
||||
const busy = state.phase === "starting" || state.phase === "streaming";
|
||||
|
||||
return (
|
||||
<Card title={t("editor.generate_intro_title")}>
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm text-fg-muted">{t("editor.generate_intro_help")}</p>
|
||||
|
||||
{state.phase === "idle" && (
|
||||
<Button onClick={() => void start()} size="lg">
|
||||
{t("editor.generate_intro_button")}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{busy && (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 text-sm text-fg-muted">
|
||||
<Spinner size="sm" /> {t("editor.intro_generating")}
|
||||
</div>
|
||||
<SseStatus status={state.sseStatus} />
|
||||
</div>
|
||||
{state.phases.length > 0 && (
|
||||
<PhaseProgress
|
||||
phases={state.phases}
|
||||
currentPhase={state.currentPhase}
|
||||
step={state.step}
|
||||
totalSteps={state.totalSteps}
|
||||
message={state.message}
|
||||
/>
|
||||
)}
|
||||
{state.introScene && (
|
||||
<div>
|
||||
<p className="label">{t("editor.intro_scene")}</p>
|
||||
<p className="whitespace-pre-wrap rounded-md border border-fg-dim/20 bg-bg-soft p-3 text-sm text-fg">
|
||||
{state.introScene}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{state.logs.length > 0 && (
|
||||
<details className="rounded-md border border-fg-dim/20 bg-bg-soft p-2">
|
||||
<summary className="cursor-pointer text-xs text-fg-muted">
|
||||
Logs ({state.logs.length})
|
||||
</summary>
|
||||
<pre className="mt-2 max-h-48 overflow-auto text-[10px] text-fg-dim">
|
||||
{state.logs.join("\n")}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{state.phase === "done" && (
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm text-ok">{t("editor.world_ready")}</p>
|
||||
{state.introScene && (
|
||||
<div>
|
||||
<p className="label">{t("editor.intro_scene")}</p>
|
||||
<p className="whitespace-pre-wrap rounded-md border border-fg-dim/20 bg-bg-soft p-3 text-sm text-fg">
|
||||
{state.introScene}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<Button variant="primary" onClick={() => navigate(`/worlds/${world.id}/play`)}>
|
||||
{t("worlds.play")}
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={retry}>
|
||||
{t("editor.regenerate_intro")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{state.phase === "error" && (
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm text-err">
|
||||
{state.errorMessage || t("editor.intro_failed")}
|
||||
</p>
|
||||
{state.logs.length > 0 && (
|
||||
<details className="rounded-md border border-fg-dim/20 bg-bg-soft p-2" open>
|
||||
<summary className="cursor-pointer text-xs text-fg-muted">
|
||||
Logs ({state.logs.length})
|
||||
</summary>
|
||||
<pre className="mt-2 max-h-48 overflow-auto text-[10px] text-fg-dim">
|
||||
{state.logs.join("\n")}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
<Button variant="secondary" onClick={retry}>
|
||||
{t("common.retry")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { cn } from "@/lib/cn";
|
||||
@@ -72,6 +72,10 @@ export function WorldBuilder({ className }: WorldBuilderProps) {
|
||||
sseStatus: "idle",
|
||||
});
|
||||
const [controller, setController] = useState<SseController | null>(null);
|
||||
// Mirror of `controller` that can be read inside stale closures (e.g. the
|
||||
// SSE onEvent handler captured at subscription time) without forcing a
|
||||
// re-subscribe on every controller change.
|
||||
const controllerRef = useRef<SseController | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
@@ -130,7 +134,7 @@ export function WorldBuilder({ className }: WorldBuilderProps) {
|
||||
logs: [...s.logs, `[error] ${d?.message || "Stream error"}`],
|
||||
}));
|
||||
pushToast("error", d?.message || t("builder.build_failed"));
|
||||
controller?.close();
|
||||
controllerRef.current?.close();
|
||||
break;
|
||||
}
|
||||
case "warning": {
|
||||
@@ -201,7 +205,7 @@ export function WorldBuilder({ className }: WorldBuilderProps) {
|
||||
phase: "done",
|
||||
sseStatus: "closed",
|
||||
}));
|
||||
controller?.close();
|
||||
controllerRef.current?.close();
|
||||
pushToast("success", t("builder.build_complete"));
|
||||
break;
|
||||
}
|
||||
@@ -209,7 +213,7 @@ export function WorldBuilder({ className }: WorldBuilderProps) {
|
||||
break;
|
||||
}
|
||||
},
|
||||
[controller, pushToast, t],
|
||||
[pushToast, t],
|
||||
);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
@@ -266,6 +270,7 @@ export function WorldBuilder({ className }: WorldBuilderProps) {
|
||||
onEvent: handleEvent,
|
||||
});
|
||||
setController(c);
|
||||
controllerRef.current = c;
|
||||
// Save world id for redirect on done
|
||||
createdWorldIdRef.current = res.world_id;
|
||||
} catch (err) {
|
||||
@@ -292,6 +297,43 @@ export function WorldBuilder({ className }: WorldBuilderProps) {
|
||||
}
|
||||
}, [state.phase, navigate]);
|
||||
|
||||
/**
|
||||
* Re-subscribe to the builder SSE stream for the previously-created
|
||||
* world (without re-POSTing the create form). Useful when the build
|
||||
* failed mid-stream and the world_id is known.
|
||||
*/
|
||||
const handleRetry = () => {
|
||||
const id = createdWorldIdRef.current;
|
||||
if (!id) return;
|
||||
controller?.close();
|
||||
setState({
|
||||
phase: "building",
|
||||
phases: [],
|
||||
introScene: "",
|
||||
logs: [],
|
||||
sseStatus: "connecting",
|
||||
});
|
||||
const streamUrl = SessionsApi.builderStreamUrl(id);
|
||||
let connectionLostToastShown = false;
|
||||
const c = subscribeSse(streamUrl, {
|
||||
onOpen: () => {
|
||||
connectionLostToastShown = false;
|
||||
setState((s) => ({ ...s, sseStatus: "open" }));
|
||||
},
|
||||
onError: () => {
|
||||
setState((s) => ({ ...s, sseStatus: "error" }));
|
||||
if (!connectionLostToastShown) {
|
||||
connectionLostToastShown = true;
|
||||
pushToast("warning", t("sse.reconnecting"));
|
||||
}
|
||||
},
|
||||
onClose: () => setState((s) => ({ ...s, sseStatus: "closed" })),
|
||||
onEvent: handleEvent,
|
||||
});
|
||||
setController(c);
|
||||
controllerRef.current = c;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn("grid gap-4 lg:grid-cols-3", className)}>
|
||||
<Card title={t("builder.step_choose_mode")} className="lg:col-span-1">
|
||||
@@ -433,9 +475,19 @@ export function WorldBuilder({ className }: WorldBuilderProps) {
|
||||
{state.phase === "error" && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm text-err">{t("builder.build_failed")}</p>
|
||||
<Button variant="secondary" onClick={() => setState({ ...state, phase: "form" })}>
|
||||
{t("common.back")}
|
||||
</Button>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handleRetry}
|
||||
disabled={!createdWorldIdRef.current}
|
||||
title={!createdWorldIdRef.current ? t("builder.retry_disabled") : undefined}
|
||||
>
|
||||
{t("common.retry")}
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={() => setState({ ...state, phase: "form" })}>
|
||||
{t("common.back")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -43,6 +43,7 @@ export function WorldCard({ world, onDelete, onRestored, onPermanentlyDeleted, c
|
||||
|
||||
const isReady = world.status === "ready";
|
||||
const isArchived = world.status === "archived";
|
||||
const isDraft = world.status === "draft";
|
||||
const isAdmin = !!user?.is_admin;
|
||||
|
||||
const handleRestore = async () => {
|
||||
@@ -135,6 +136,34 @@ export function WorldCard({ world, onDelete, onRestored, onPermanentlyDeleted, c
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
) : isDraft ? (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="primary"
|
||||
onClick={() => navigate(`/worlds/${world.id}/edit`)}
|
||||
fullWidth
|
||||
>
|
||||
{t("worlds.continue_setup")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => navigate(`/worlds/${world.id}/edit`)}
|
||||
>
|
||||
{t("worlds.edit")}
|
||||
</Button>
|
||||
{onDelete && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => onDelete(world)}
|
||||
aria-label={t("common.delete")}
|
||||
>
|
||||
🗑
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { WorldsApi, SessionsApi } from "@/lib/api";
|
||||
import { WorldsApi, SessionsApi, toErrorMessage } from "@/lib/api";
|
||||
import { subscribeSse, type SseController, type SseEvent } from "@/lib/sse";
|
||||
import { useToastStore } from "@/stores/toastStore";
|
||||
import type { World } from "@/types";
|
||||
@@ -14,12 +14,28 @@ import { SseStatus } from "@/components/sessions/SseStatus";
|
||||
|
||||
type EditorPhase = "idle" | "streaming" | "awaiting_clarification" | "changes_proposed" | "done" | "error";
|
||||
|
||||
interface DiffItem {
|
||||
path?: string;
|
||||
op?: string;
|
||||
old?: unknown;
|
||||
new?: unknown;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface LogEntry {
|
||||
id: string;
|
||||
kind: "comment" | "clarification" | "change_proposed" | "info" | "error";
|
||||
text: string;
|
||||
options?: string[];
|
||||
diff?: unknown;
|
||||
/** For clarification entries: whether the user has already answered. */
|
||||
answered?: boolean;
|
||||
/** For clarification entries: the answer text the user submitted. */
|
||||
answerText?: string;
|
||||
/** For change_proposed entries: the user's decision ("accept" | "reject" | undefined). */
|
||||
decision?: "accept" | "reject";
|
||||
/** Whether the accept/reject request is in-flight. */
|
||||
deciding?: boolean;
|
||||
}
|
||||
|
||||
export interface WorldEditorProps {
|
||||
@@ -65,6 +81,11 @@ export function WorldEditor({ world, onWorldUpdated, className }: WorldEditorPro
|
||||
}
|
||||
}, [world.id, onWorldUpdated]);
|
||||
|
||||
/** Update a single log entry by id. */
|
||||
const patchLog = useCallback((id: string, patch: Partial<LogEntry>) => {
|
||||
setLogs((prev) => prev.map((l) => (l.id === id ? { ...l, ...patch } : l)));
|
||||
}, []);
|
||||
|
||||
const handleEvent = useCallback(
|
||||
(event: SseEvent) => {
|
||||
switch (event.event) {
|
||||
@@ -99,7 +120,12 @@ export function WorldEditor({ world, onWorldUpdated, className }: WorldEditorPro
|
||||
setPhase("awaiting_clarification");
|
||||
setLogs((l) => [
|
||||
...l,
|
||||
{ id: uid(), kind: "clarification", text: d.question, options: d.options },
|
||||
{
|
||||
id: uid(),
|
||||
kind: "clarification",
|
||||
text: d.question,
|
||||
options: d.options,
|
||||
},
|
||||
]);
|
||||
break;
|
||||
}
|
||||
@@ -108,17 +134,27 @@ export function WorldEditor({ world, onWorldUpdated, className }: WorldEditorPro
|
||||
setPhase("changes_proposed");
|
||||
setLogs((l) => [
|
||||
...l,
|
||||
{ id: uid(), kind: "change_proposed", text: d.comment, diff: d.diff },
|
||||
{
|
||||
id: uid(),
|
||||
kind: "change_proposed",
|
||||
text: d.comment,
|
||||
diff: d.diff,
|
||||
},
|
||||
]);
|
||||
break;
|
||||
}
|
||||
case "apply_changes": {
|
||||
// Emitted by the backend after a successful POST /apply.
|
||||
// The stream continues; the LLM may emit more events.
|
||||
setPhase("streaming");
|
||||
void refreshWorld();
|
||||
pushToast("success", t("editor.changes_applied"));
|
||||
break;
|
||||
}
|
||||
case "discard_changes": {
|
||||
setPhase("streaming");
|
||||
setLogs((l) => [...l, { id: uid(), kind: "info", text: t("editor.changes_discarded") }]);
|
||||
pushToast("info", t("editor.changes_discarded"));
|
||||
break;
|
||||
}
|
||||
case "done": {
|
||||
@@ -166,8 +202,47 @@ export function WorldEditor({ world, onWorldUpdated, className }: WorldEditorPro
|
||||
controllerRef.current = c;
|
||||
} catch (err) {
|
||||
setPhase("error");
|
||||
const msg = err instanceof Error ? err.message : "Failed";
|
||||
pushToast("error", msg);
|
||||
pushToast("error", toErrorMessage(err, t("editor.streaming")));
|
||||
}
|
||||
};
|
||||
|
||||
/** Submit a clarification answer. */
|
||||
const handleAnswer = async (entryId: string, text: string) => {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) return;
|
||||
patchLog(entryId, { answered: true, answerText: trimmed });
|
||||
try {
|
||||
await SessionsApi.answerClarification(world.id, trimmed);
|
||||
// The stream will continue; backend emits more events.
|
||||
} catch (err) {
|
||||
patchLog(entryId, { answered: false, answerText: undefined });
|
||||
pushToast("error", toErrorMessage(err, t("editor.answer_failed")));
|
||||
}
|
||||
};
|
||||
|
||||
/** Accept proposed changes. */
|
||||
const handleAccept = async (entryId: string) => {
|
||||
patchLog(entryId, { deciding: true });
|
||||
try {
|
||||
await SessionsApi.applyChanges(world.id);
|
||||
patchLog(entryId, { deciding: false, decision: "accept" });
|
||||
// The backend will emit an `apply_changes` SSE event when it processes
|
||||
// the change — that handler refreshes the world and shows the toast.
|
||||
} catch (err) {
|
||||
patchLog(entryId, { deciding: false });
|
||||
pushToast("error", toErrorMessage(err, t("editor.apply_failed")));
|
||||
}
|
||||
};
|
||||
|
||||
/** Reject proposed changes. */
|
||||
const handleReject = async (entryId: string) => {
|
||||
patchLog(entryId, { deciding: true });
|
||||
try {
|
||||
await SessionsApi.discardChanges(world.id);
|
||||
patchLog(entryId, { deciding: false, decision: "reject" });
|
||||
} catch (err) {
|
||||
patchLog(entryId, { deciding: false });
|
||||
pushToast("error", toErrorMessage(err, t("editor.discard_failed")));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -180,8 +255,7 @@ export function WorldEditor({ world, onWorldUpdated, className }: WorldEditorPro
|
||||
onWorldUpdated?.(updated);
|
||||
pushToast("success", t("editor.json_saved"));
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : "Failed";
|
||||
pushToast("error", msg);
|
||||
pushToast("error", toErrorMessage(err, t("editor.json_saved")));
|
||||
} finally {
|
||||
setSubmittingJson(false);
|
||||
}
|
||||
@@ -213,7 +287,13 @@ export function WorldEditor({ world, onWorldUpdated, className }: WorldEditorPro
|
||||
{logs.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
{logs.map((log) => (
|
||||
<LogEntryView key={log.id} entry={log} />
|
||||
<LogEntryView
|
||||
key={log.id}
|
||||
entry={log}
|
||||
onAnswer={(text) => void handleAnswer(log.id, text)}
|
||||
onAccept={() => void handleAccept(log.id)}
|
||||
onReject={() => void handleReject(log.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
@@ -250,46 +330,219 @@ export function WorldEditor({ world, onWorldUpdated, className }: WorldEditorPro
|
||||
);
|
||||
}
|
||||
|
||||
function LogEntryView({ entry }: { entry: LogEntry }) {
|
||||
const { t } = useTranslation();
|
||||
interface LogEntryViewProps {
|
||||
entry: LogEntry;
|
||||
onAnswer: (text: string) => void;
|
||||
onAccept: () => void;
|
||||
onReject: () => void;
|
||||
}
|
||||
|
||||
function LogEntryView({ entry, onAnswer, onAccept, onReject }: LogEntryViewProps) {
|
||||
if (entry.kind === "clarification") {
|
||||
return (
|
||||
<div className="rounded-md border border-warn/30 bg-warn/5 p-3">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-warn">
|
||||
{t("editor.clarification")}
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-fg whitespace-pre-wrap">{entry.text}</p>
|
||||
{entry.options && entry.options.length > 0 && (
|
||||
<ul className="mt-2 list-disc pl-5 text-sm text-fg-muted">
|
||||
{entry.options.map((o, i) => (
|
||||
<li key={i}>{o}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
<ClarificationCard
|
||||
entry={entry}
|
||||
onAnswer={onAnswer}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (entry.kind === "change_proposed") {
|
||||
return (
|
||||
<div className="rounded-md border border-accent/30 bg-accent/5 p-3">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-accent">
|
||||
{t("editor.change_proposed")}
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-fg">{entry.text}</p>
|
||||
{entry.diff != null && (
|
||||
<pre className="mt-2 max-h-40 overflow-auto rounded bg-bg-soft p-2 text-[10px] font-mono text-fg-muted">
|
||||
{JSON.stringify(entry.diff, null, 2)}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
<ChangeProposedCard
|
||||
entry={entry}
|
||||
onAccept={onAccept}
|
||||
onReject={onReject}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (entry.kind === "error") {
|
||||
return <p className="text-xs text-err">{entry.text}</p>;
|
||||
}
|
||||
return <p className="text-xs text-fg-muted">{entry.text}</p>;
|
||||
}
|
||||
|
||||
function ClarificationCard({
|
||||
entry,
|
||||
onAnswer,
|
||||
}: {
|
||||
entry: LogEntry;
|
||||
onAnswer: (text: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [text, setText] = useState("");
|
||||
const hasOptions = Array.isArray(entry.options) && entry.options.length > 0;
|
||||
|
||||
return (
|
||||
<div className="rounded-md border border-warn/30 bg-warn/5 p-3">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-warn">
|
||||
{t("editor.clarification")}
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-fg whitespace-pre-wrap">{entry.text}</p>
|
||||
|
||||
{entry.answered ? (
|
||||
<p className="mt-2 rounded bg-bg-soft p-2 text-xs text-fg-muted">
|
||||
<span className="font-semibold text-fg">{t("editor.your_answer")}:</span>{" "}
|
||||
{entry.answerText}
|
||||
</p>
|
||||
) : hasOptions ? (
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{entry.options!.map((opt, i) => (
|
||||
<Button
|
||||
key={i}
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => onAnswer(opt)}
|
||||
>
|
||||
{opt}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-2 flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
className="input flex-1"
|
||||
placeholder={t("editor.answer_placeholder")}
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
if (text.trim()) onAnswer(text);
|
||||
}
|
||||
}}
|
||||
autoComplete="off"
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => text.trim() && onAnswer(text)}
|
||||
disabled={!text.trim()}
|
||||
>
|
||||
{t("common.submit")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChangeProposedCard({
|
||||
entry,
|
||||
onAccept,
|
||||
onReject,
|
||||
}: {
|
||||
entry: LogEntry;
|
||||
onAccept: () => void;
|
||||
onReject: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const diffItems = normalizeDiff(entry.diff);
|
||||
|
||||
return (
|
||||
<div className="rounded-md border border-accent/30 bg-accent/5 p-3">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-accent">
|
||||
{t("editor.change_proposed")}
|
||||
</p>
|
||||
{entry.text && <p className="mt-1 text-sm text-fg whitespace-pre-wrap">{entry.text}</p>}
|
||||
|
||||
{diffItems.length > 0 && (
|
||||
<ul className="mt-2 space-y-1.5">
|
||||
{diffItems.map((item, i) => (
|
||||
<li key={i} className="rounded border border-fg-dim/20 bg-bg-soft p-2 text-xs">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{item.op && (
|
||||
<span className={cn("badge", OP_BADGE_CLASS[item.op] || "bg-bg-soft text-fg-muted")}>
|
||||
{item.op}
|
||||
</span>
|
||||
)}
|
||||
{item.path && (
|
||||
<code className="font-mono text-fg">{item.path}</code>
|
||||
)}
|
||||
</div>
|
||||
{item.new !== undefined && (
|
||||
<pre className="mt-1 max-h-32 overflow-auto whitespace-pre-wrap break-all text-[10px] font-mono text-fg-muted">
|
||||
{safeStringify(item.new)}
|
||||
</pre>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{entry.diff != null && diffItems.length === 0 && (
|
||||
<pre className="mt-2 max-h-48 overflow-auto rounded bg-bg-soft p-2 text-[10px] font-mono text-fg-muted">
|
||||
{safeStringify(entry.diff)}
|
||||
</pre>
|
||||
)}
|
||||
|
||||
{entry.decision ? (
|
||||
<p className="mt-2 text-xs text-fg-muted">
|
||||
{entry.decision === "accept"
|
||||
? `✓ ${t("editor.changes_applied")}`
|
||||
: `✗ ${t("editor.changes_discarded")}`}
|
||||
</p>
|
||||
) : (
|
||||
<div className="mt-3 flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="primary"
|
||||
onClick={onAccept}
|
||||
loading={entry.deciding}
|
||||
disabled={entry.deciding}
|
||||
>
|
||||
{t("editor.accept")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={onReject}
|
||||
loading={entry.deciding}
|
||||
disabled={entry.deciding}
|
||||
>
|
||||
{t("editor.reject")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const OP_BADGE_CLASS: Record<string, string> = {
|
||||
add: "bg-ok/15 text-ok",
|
||||
remove: "bg-err/15 text-err",
|
||||
replace: "bg-accent/15 text-accent",
|
||||
set: "bg-accent/15 text-accent",
|
||||
append: "bg-accent/15 text-accent",
|
||||
inc: "bg-ok/15 text-ok",
|
||||
dec: "bg-warn/15 text-warn",
|
||||
};
|
||||
|
||||
/** Coerce a diff payload into an array of {path, op, new} items. */
|
||||
function normalizeDiff(diff: unknown): DiffItem[] {
|
||||
if (diff == null) return [];
|
||||
if (Array.isArray(diff)) return diff.filter((d) => d && typeof d === "object") as DiffItem[];
|
||||
if (typeof diff === "object") {
|
||||
const obj = diff as Record<string, unknown>;
|
||||
// Some backends return {ops: [...]} or {changes: [...]}.
|
||||
if (Array.isArray(obj.ops)) return obj.ops.filter((d) => d && typeof d === "object") as DiffItem[];
|
||||
if (Array.isArray(obj.changes)) return obj.changes.filter((d) => d && typeof d === "object") as DiffItem[];
|
||||
// Single op object.
|
||||
return [obj as DiffItem];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function safeStringify(value: unknown): string {
|
||||
if (typeof value === "string") return value;
|
||||
try {
|
||||
return JSON.stringify(value, null, 2);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
function uid(): string {
|
||||
return Math.random().toString(36).slice(2) + Date.now().toString(36);
|
||||
}
|
||||
|
||||
@@ -89,6 +89,7 @@
|
||||
"status_failed": "Failed",
|
||||
"status_archived": "Archived",
|
||||
"restore": "Restore",
|
||||
"continue_setup": "Continue setup",
|
||||
"restored": "World restored.",
|
||||
"restore_failed": "Failed to restore world.",
|
||||
"delete_permanent": "Delete permanently",
|
||||
@@ -123,7 +124,8 @@
|
||||
"llm_call": "LLM call",
|
||||
"schema_generated": "World schema generated",
|
||||
"environment_generated": "Environment generated",
|
||||
"entities_generated": "Entities generated"
|
||||
"entities_generated": "Entities generated",
|
||||
"retry_disabled": "Cannot retry — no world was created yet."
|
||||
},
|
||||
"editor": {
|
||||
"title": "World Editor",
|
||||
@@ -142,7 +144,24 @@
|
||||
"changes_discarded": "Changes discarded.",
|
||||
"edit_world": "Edit world",
|
||||
"submit_instruction": "Submitting…",
|
||||
"streaming": "Streaming…"
|
||||
"streaming": "Streaming…",
|
||||
"accept": "Accept",
|
||||
"reject": "Reject",
|
||||
"your_answer": "Your answer",
|
||||
"answer_placeholder": "Type your answer…",
|
||||
"apply_failed": "Failed to apply changes.",
|
||||
"discard_failed": "Failed to discard changes.",
|
||||
"answer_failed": "Failed to submit answer.",
|
||||
"cannot_play_draft": "Generate the intro scene first.",
|
||||
"generate_intro_title": "Generate Intro Scene",
|
||||
"generate_intro_help": "This world is still a draft. Generate the intro scene to make it playable.",
|
||||
"generate_intro_button": "Generate Intro Scene",
|
||||
"intro_generating": "Generating intro scene…",
|
||||
"intro_complete": "Intro scene generated.",
|
||||
"intro_failed": "Failed to generate intro scene.",
|
||||
"intro_scene": "Intro scene",
|
||||
"world_ready": "World is ready! You can now play.",
|
||||
"regenerate_intro": "Regenerate intro scene"
|
||||
},
|
||||
"play": {
|
||||
"title": "Play",
|
||||
@@ -171,7 +190,7 @@
|
||||
"rollback": "Rollback one step",
|
||||
"rollback_confirm": "Rollback the last step?",
|
||||
"rolled_back": "Rolled back one step.",
|
||||
"not_ready": "World is not ready yet. Complete world creation first.",
|
||||
"not_ready": "This world is not ready yet. Generate the intro scene first.",
|
||||
"no_steps_to_retry": "No steps to retry yet.",
|
||||
"no_steps_to_rollback": "No steps to rollback yet.",
|
||||
"streaming": "AI is responding…",
|
||||
@@ -259,7 +278,11 @@
|
||||
"icons_upload": "Upload",
|
||||
"icons_uploaded": "Icon uploaded.",
|
||||
"icons_upload_failed": "Failed to upload icon.",
|
||||
"choose_file": "Choose file"
|
||||
"choose_file": "Choose file",
|
||||
"tool_calls_detected": "Tool calls detected",
|
||||
"no_tool_calls_warning_title": "No tool calls returned",
|
||||
"no_tool_calls_warning": "Model did not return tool calls. This may mean the model doesn't support function calling, or uses a non-standard format.",
|
||||
"raw_response": "Raw LLM response"
|
||||
},
|
||||
"errors": {
|
||||
"generic": "Something went wrong.",
|
||||
|
||||
@@ -89,6 +89,7 @@
|
||||
"status_failed": "Ошибка",
|
||||
"status_archived": "В архиве",
|
||||
"restore": "Восстановить",
|
||||
"continue_setup": "Продолжить настройку",
|
||||
"restored": "Мир восстановлен.",
|
||||
"restore_failed": "Не удалось восстановить мир.",
|
||||
"delete_permanent": "Удалить навсегда",
|
||||
@@ -123,7 +124,8 @@
|
||||
"llm_call": "Вызов LLM",
|
||||
"schema_generated": "Схема мира сгенерирована",
|
||||
"environment_generated": "Окружение сгенерировано",
|
||||
"entities_generated": "Сущности сгенерированы"
|
||||
"entities_generated": "Сущности сгенерированы",
|
||||
"retry_disabled": "Нельзя повторить — мир ещё не создан."
|
||||
},
|
||||
"editor": {
|
||||
"title": "Редактор мира",
|
||||
@@ -142,7 +144,24 @@
|
||||
"changes_discarded": "Изменения отменены.",
|
||||
"edit_world": "Редактировать мир",
|
||||
"submit_instruction": "Отправка…",
|
||||
"streaming": "Поток…"
|
||||
"streaming": "Поток…",
|
||||
"accept": "Принять",
|
||||
"reject": "Отклонить",
|
||||
"your_answer": "Ваш ответ",
|
||||
"answer_placeholder": "Введите ответ…",
|
||||
"apply_failed": "Не удалось применить изменения.",
|
||||
"discard_failed": "Не удалось отклонить изменения.",
|
||||
"answer_failed": "Не удалось отправить ответ.",
|
||||
"cannot_play_draft": "Сначала сгенерируйте вступительную сцену.",
|
||||
"generate_intro_title": "Сгенерировать вступительную сцену",
|
||||
"generate_intro_help": "Этот мир всё ещё черновик. Сгенерируйте вступительную сцену, чтобы сделать его играбельным.",
|
||||
"generate_intro_button": "Сгенерировать вступительную сцену",
|
||||
"intro_generating": "Генерация вступительной сцены…",
|
||||
"intro_complete": "Вступительная сцена сгенерирована.",
|
||||
"intro_failed": "Не удалось сгенерировать вступительную сцену.",
|
||||
"intro_scene": "Вступительная сцена",
|
||||
"world_ready": "Мир готов! Теперь можно играть.",
|
||||
"regenerate_intro": "Перегенерировать вступительную сцену"
|
||||
},
|
||||
"play": {
|
||||
"title": "Игра",
|
||||
@@ -171,7 +190,7 @@
|
||||
"rollback": "Откатить один шаг",
|
||||
"rollback_confirm": "Откатить последний шаг?",
|
||||
"rolled_back": "Шаг откатан.",
|
||||
"not_ready": "Мир ещё не готов. Сначала завершите создание мира.",
|
||||
"not_ready": "Этот мир ещё не готов. Сначала сгенерируйте вступительную сцену.",
|
||||
"no_steps_to_retry": "Нет шагов для повтора.",
|
||||
"no_steps_to_rollback": "Нет шагов для отката.",
|
||||
"streaming": "AI отвечает…",
|
||||
@@ -259,7 +278,11 @@
|
||||
"icons_upload": "Загрузить",
|
||||
"icons_uploaded": "Иконка загружена.",
|
||||
"icons_upload_failed": "Не удалось загрузить иконку.",
|
||||
"choose_file": "Выбрать файл"
|
||||
"choose_file": "Выбрать файл",
|
||||
"tool_calls_detected": "Обнаружены вызовы инструментов",
|
||||
"no_tool_calls_warning_title": "Вызовы инструментов не возвращены",
|
||||
"no_tool_calls_warning": "Модель не вернула вызовы инструментов. Это может означать, что модель не поддерживает function calling или использует нестандартный формат.",
|
||||
"raw_response": "Полный ответ модели"
|
||||
},
|
||||
"errors": {
|
||||
"generic": "Что-то пошло не так.",
|
||||
|
||||
@@ -311,6 +311,14 @@ export const WorldsApi = {
|
||||
};
|
||||
|
||||
// ===== Sessions API =====
|
||||
export interface GenerateIntroResponse {
|
||||
stream_url: string;
|
||||
}
|
||||
|
||||
export interface SimpleOkResponse {
|
||||
ok: boolean;
|
||||
}
|
||||
|
||||
export const SessionsApi = {
|
||||
state: (worldId: string) =>
|
||||
request<SessionState>(`/sessions/worlds/${worldId}/state`),
|
||||
@@ -323,6 +331,30 @@ export const SessionsApi = {
|
||||
request<RetryResponse>(`/sessions/worlds/${worldId}/retry`, { method: "POST" }),
|
||||
rollback: (worldId: string) =>
|
||||
request<void>(`/sessions/worlds/${worldId}/rollback`, { method: "POST" }),
|
||||
|
||||
// ---- World editor: accept / reject proposed changes & answer clarifications ----
|
||||
/** Accept proposed changes from the world_editor stream. */
|
||||
applyChanges: (worldId: string) =>
|
||||
request<SimpleOkResponse>(`/sessions/worlds/${worldId}/apply`, { method: "POST" }),
|
||||
/** Reject proposed changes from the world_editor stream. */
|
||||
discardChanges: (worldId: string) =>
|
||||
request<SimpleOkResponse>(`/sessions/worlds/${worldId}/discard`, { method: "POST" }),
|
||||
/** Answer a clarification question from the world_editor stream. */
|
||||
answerClarification: (worldId: string, text: string) =>
|
||||
request<SimpleOkResponse>(`/sessions/worlds/${worldId}/answer`, {
|
||||
method: "POST",
|
||||
body: { text },
|
||||
}),
|
||||
|
||||
// ---- Intro scene generation ----
|
||||
/**
|
||||
* Triggers intro scene regeneration for a draft world. Returns the SSE
|
||||
* stream URL to subscribe to. (Backend endpoint is on /api/worlds but
|
||||
* lives in the sessions API surface for grouping.)
|
||||
*/
|
||||
generateIntro: (worldId: string) =>
|
||||
request<GenerateIntroResponse>(`/worlds/${worldId}/generate-intro`, { method: "POST" }),
|
||||
|
||||
// SSE stream URLs (used by SSE client)
|
||||
iterateStreamUrl: (worldId: string, stepId: string) =>
|
||||
buildUrl(`/sessions/worlds/${worldId}/iterate/stream`, { step_id: stepId }),
|
||||
@@ -330,6 +362,9 @@ export const SessionsApi = {
|
||||
buildUrl(`/sessions/worlds/${worldId}/builder/stream`),
|
||||
editorStreamUrl: (worldId: string, instruction: string) =>
|
||||
buildUrl(`/sessions/worlds/${worldId}/editor/stream`, { instruction }),
|
||||
/** SSE URL for the intro scene generator stream. */
|
||||
introStreamUrl: (worldId: string) =>
|
||||
buildUrl(`/sessions/worlds/${worldId}/intro/stream`),
|
||||
};
|
||||
|
||||
// ===== Presets API =====
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { useEffect } from "react";
|
||||
import { useCallback, useEffect } from "react";
|
||||
import { useParams, useNavigate } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { WorldsApi } from "@/lib/api";
|
||||
import { useWorldsStore } from "@/stores/worldsStore";
|
||||
import { useToastStore } from "@/stores/toastStore";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Spinner } from "@/components/ui/Spinner";
|
||||
import { WorldEditor } from "@/components/worlds/WorldEditor";
|
||||
import { IntroSceneGenerator } from "@/components/worlds/IntroSceneGenerator";
|
||||
|
||||
export function WorldEditPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
@@ -26,6 +28,16 @@ export function WorldEditPage() {
|
||||
return () => setCurrentWorld(null);
|
||||
}, [id, fetchWorld, setCurrentWorld, pushToast, t]);
|
||||
|
||||
const refreshWorld = useCallback(async () => {
|
||||
if (!id) return;
|
||||
try {
|
||||
const updated = await WorldsApi.get(id);
|
||||
setCurrentWorld(updated);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}, [id, setCurrentWorld]);
|
||||
|
||||
if (!id) {
|
||||
return <p className="p-4 text-sm text-err">{t("worlds.not_found")}</p>;
|
||||
}
|
||||
@@ -53,6 +65,8 @@ export function WorldEditPage() {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isDraft = world.status === "draft";
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-7xl space-y-4 p-4">
|
||||
<header className="flex items-center justify-between">
|
||||
@@ -60,10 +74,21 @@ export function WorldEditPage() {
|
||||
<h1 className="text-xl font-semibold text-fg">{t("editor.title")}</h1>
|
||||
<p className="text-sm text-fg-muted">{world.name}</p>
|
||||
</div>
|
||||
<Button variant="secondary" onClick={() => navigate(`/worlds/${world.id}/play`)}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => navigate(`/worlds/${world.id}/play`)}
|
||||
disabled={isDraft}
|
||||
title={isDraft ? t("editor.cannot_play_draft") : undefined}
|
||||
className={isDraft ? "opacity-50 cursor-not-allowed" : ""}
|
||||
>
|
||||
{t("worlds.play")}
|
||||
</Button>
|
||||
</header>
|
||||
|
||||
{isDraft && (
|
||||
<IntroSceneGenerator world={world} onWorldUpdated={() => void refreshWorld()} />
|
||||
)}
|
||||
|
||||
<WorldEditor
|
||||
world={world}
|
||||
onWorldUpdated={(w) => setCurrentWorld(w)}
|
||||
|
||||
@@ -301,6 +301,8 @@ export interface LlmToolsTestResult {
|
||||
has_tool_calls?: boolean;
|
||||
elapsed_ms?: number;
|
||||
error?: string;
|
||||
/** Full LLM message returned by the model (for debugging when no tool_calls). */
|
||||
raw_response?: unknown;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"root":["./src/App.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/components/admin/IconsPanel.tsx","./src/components/admin/LlmLogsTable.tsx","./src/components/admin/SettingsPanel.tsx","./src/components/admin/StatsPanel.tsx","./src/components/admin/TestButtons.tsx","./src/components/admin/UsersTable.tsx","./src/components/auth/ProtectedRoute.tsx","./src/components/sessions/ActionInput.tsx","./src/components/sessions/ChatView.tsx","./src/components/sessions/PhaseProgress.tsx","./src/components/sessions/SseStatus.tsx","./src/components/sessions/ToolCallBubble.tsx","./src/components/ui/Button.tsx","./src/components/ui/Card.tsx","./src/components/ui/Input.tsx","./src/components/ui/JsonEditor.tsx","./src/components/ui/Modal.tsx","./src/components/ui/Navbar.tsx","./src/components/ui/Spinner.tsx","./src/components/ui/Textarea.tsx","./src/components/ui/Toast.tsx","./src/components/worlds/WorldBuilder.tsx","./src/components/worlds/WorldCard.tsx","./src/components/worlds/WorldEditor.tsx","./src/i18n/index.ts","./src/lib/api.ts","./src/lib/cn.ts","./src/lib/sse.ts","./src/pages/AdminPage.tsx","./src/pages/AdminRegisterPage.tsx","./src/pages/LoginPage.tsx","./src/pages/PlayPage.tsx","./src/pages/RegisterPage.tsx","./src/pages/WorldBuilderPage.tsx","./src/pages/WorldEditPage.tsx","./src/pages/WorldsListPage.tsx","./src/stores/authStore.ts","./src/stores/sessionStore.ts","./src/stores/toastStore.ts","./src/stores/uiSettingsStore.ts","./src/stores/uiStore.ts","./src/stores/worldsStore.ts","./src/types/index.ts"],"version":"5.9.3"}
|
||||
{"root":["./src/App.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/components/admin/IconsPanel.tsx","./src/components/admin/LlmLogsTable.tsx","./src/components/admin/SettingsPanel.tsx","./src/components/admin/StatsPanel.tsx","./src/components/admin/TestButtons.tsx","./src/components/admin/UsersTable.tsx","./src/components/auth/ProtectedRoute.tsx","./src/components/sessions/ActionInput.tsx","./src/components/sessions/ChatView.tsx","./src/components/sessions/PhaseProgress.tsx","./src/components/sessions/SseStatus.tsx","./src/components/sessions/ToolCallBubble.tsx","./src/components/ui/Button.tsx","./src/components/ui/Card.tsx","./src/components/ui/Input.tsx","./src/components/ui/JsonEditor.tsx","./src/components/ui/Modal.tsx","./src/components/ui/Navbar.tsx","./src/components/ui/Spinner.tsx","./src/components/ui/Textarea.tsx","./src/components/ui/Toast.tsx","./src/components/worlds/IntroSceneGenerator.tsx","./src/components/worlds/WorldBuilder.tsx","./src/components/worlds/WorldCard.tsx","./src/components/worlds/WorldEditor.tsx","./src/i18n/index.ts","./src/lib/api.ts","./src/lib/cn.ts","./src/lib/sse.ts","./src/pages/AdminPage.tsx","./src/pages/AdminRegisterPage.tsx","./src/pages/LoginPage.tsx","./src/pages/PlayPage.tsx","./src/pages/RegisterPage.tsx","./src/pages/WorldBuilderPage.tsx","./src/pages/WorldEditPage.tsx","./src/pages/WorldsListPage.tsx","./src/stores/authStore.ts","./src/stores/sessionStore.ts","./src/stores/toastStore.ts","./src/stores/uiSettingsStore.ts","./src/stores/uiStore.ts","./src/stores/worldsStore.ts","./src/types/index.ts"],"version":"5.9.3"}
|
||||
Reference in New Issue
Block a user