This commit is contained in:
Mikan
2026-06-21 04:11:38 +03:00
parent bd85e186dc
commit 4dee4fb0a8
19 changed files with 1423 additions and 115 deletions

View File

@@ -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)}
/>

View File

@@ -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;

View 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>
);
}

View File

@@ -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>

View File

@@ -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

View File

@@ -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);
}

View File

@@ -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.",

View File

@@ -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": "Что-то пошло не так.",

View File

@@ -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 =====

View File

@@ -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)}

View File

@@ -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;
}

View File

@@ -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"}