This commit is contained in:
Mikan
2026-06-21 09:24:42 +03:00
parent 7cbe8da103
commit c45ab1ddd5
24 changed files with 1438 additions and 148 deletions

View File

@@ -123,7 +123,10 @@ export function SettingsPanel() {
const [draft, setDraft] = useState<Record<string, string>>({});
// Collapsed state: Set of group ids that are collapsed. Default: ALL
// groups collapsed (the user clicks to expand the one they want to edit).
const allGroupIds = useMemo(() => GROUPS.map((g) => g.id).concat(["text_replacements"]), []);
const allGroupIds = useMemo(
() => GROUPS.map((g) => g.id).concat(["text_replacements", "name_banks"]),
[],
);
const [collapsed, setCollapsed] = useState<Set<string>>(() => new Set(allGroupIds));
const toggleCollapsed = (id: string) => {
@@ -323,6 +326,14 @@ export function SettingsPanel() {
onToggle={() => toggleCollapsed("text_replacements")}
/>
)}
{/* Render the Name Banks card after the UI Settings group so
the visual order is: … → UI Settings → Name Banks. */}
{g.id === "ui" && (
<NameBanksCard
collapsed={collapsed.has("name_banks")}
onToggle={() => toggleCollapsed("name_banks")}
/>
)}
</Fragment>
);
})}
@@ -807,3 +818,211 @@ function TextReplacementsCard({ rawValue, onSave, collapsed, onToggle }: TextRep
</Card>
);
}
// ============================================================================
// Name Banks card — manages the character name banks per language.
// ============================================================================
interface NameBanksCardProps {
collapsed: boolean;
onToggle: () => void;
}
/**
* Card for managing the character name banks (English + Russian). Each
* language shows a list of names with a × button to remove, plus an
* input + "Add" button to add a new name. The "Save" button calls
* PUT /api/admin/names/{language} with the full updated list.
*/
function NameBanksCard({ collapsed, onToggle }: NameBanksCardProps) {
const { t } = useTranslation();
const pushToast = useToastStore((s) => s.push);
return (
<Card
title={
<button
type="button"
onClick={onToggle}
className="flex items-center gap-2 text-left"
aria-expanded={!collapsed}
>
<span className="text-xs text-fg-muted w-3 inline-block">
{collapsed ? "▶" : "▼"}
</span>
<span>{t("admin.name_banks_title")}</span>
</button>
}
description={!collapsed ? t("admin.name_banks_help") : undefined}
>
{!collapsed && (
<div className="space-y-4">
<NameBankEditor language="en" title={t("admin.name_banks_en")} pushToast={pushToast} />
<NameBankEditor language="ru" title={t("admin.name_banks_ru")} pushToast={pushToast} />
</div>
)}
</Card>
);
}
interface NameBankEditorProps {
language: string;
title: string;
pushToast: (kind: "info" | "success" | "error" | "warning", msg: string) => void;
}
function NameBankEditor({ language, title, pushToast }: NameBankEditorProps) {
const { t } = useTranslation();
const [names, setNames] = useState<string[]>([]);
const [loading, setLoading] = useState(true);
const [input, setInput] = useState("");
const [saving, setSaving] = useState(false);
// Fetch the current name bank on mount.
useEffect(() => {
let cancelled = false;
setLoading(true);
AdminApi.getNameBank(language)
.then((res) => {
if (cancelled) return;
setNames(res.names || []);
})
.catch((err) => {
if (cancelled) return;
const msg = err instanceof Error ? err.message : t("admin.name_banks_load_failed");
pushToast("error", msg);
})
.finally(() => !cancelled && setLoading(false));
return () => {
cancelled = true;
};
}, [language, pushToast, t]);
const handleAdd = async () => {
const value = input.trim();
if (!value) return;
if (names.includes(value)) {
pushToast("info", t("admin.name_banks_already_exists"));
return;
}
setInput("");
// Optimistic update — append locally, then call the API. If the API
// call fails, we revert by refetching.
const next = [...names, value];
setNames(next);
try {
const res = await AdminApi.addName(language, value);
setNames(res.names || next);
} catch (err) {
pushToast("error", err instanceof Error ? err.message : t("admin.name_banks_add_failed"));
// Revert by refetching.
try {
const fresh = await AdminApi.getNameBank(language);
setNames(fresh.names || []);
} catch {
/* give up */
}
}
};
const handleRemove = async (name: string) => {
const prev = names;
const next = names.filter((n) => n !== name);
setNames(next);
try {
const res = await AdminApi.removeName(language, name);
setNames(res.names || next);
} catch (err) {
pushToast("error", err instanceof Error ? err.message : t("admin.name_banks_remove_failed"));
setNames(prev);
}
};
const handleSave = async () => {
setSaving(true);
try {
const res = await AdminApi.updateNameBank(language, names);
setNames(res.names || names);
pushToast("success", t("admin.settings_saved"));
} catch (err) {
pushToast("error", err instanceof Error ? err.message : t("admin.settings_save_failed"));
} finally {
setSaving(false);
}
};
return (
<div className="space-y-2">
<div className="flex items-center justify-between">
<h4 className="text-sm font-semibold text-fg">{title}</h4>
<span className="text-xs text-fg-muted">
{loading ? "…" : `(${names.length})`}
</span>
</div>
{loading ? (
<div className="flex items-center gap-2 text-xs text-fg-muted">
<Spinner size="sm" /> {t("common.loading")}
</div>
) : (
<>
{names.length === 0 ? (
<p className="text-xs text-fg-muted">{t("admin.name_banks_empty")}</p>
) : (
<div className="flex flex-wrap gap-1.5">
{names.map((n) => (
<span
key={n}
className="inline-flex items-center gap-1 rounded-md border border-fg-dim/30 bg-bg-soft px-2 py-0.5 text-xs text-fg"
>
{n}
<button
type="button"
onClick={() => void handleRemove(n)}
className="text-fg-muted hover:text-err"
aria-label={t("common.delete")}
>
×
</button>
</span>
))}
</div>
)}
<div className="flex items-center gap-2">
<input
type="text"
className="input flex-1"
placeholder={t("admin.name_banks_add_placeholder")}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
void handleAdd();
}
}}
autoComplete="off"
/>
<Button
type="button"
size="sm"
variant="secondary"
onClick={() => void handleAdd()}
disabled={!input.trim()}
>
{t("admin.name_banks_add")}
</Button>
<Button
type="button"
size="sm"
onClick={() => void handleSave()}
loading={saving}
disabled={saving}
>
{t("common.save")}
</Button>
</div>
</>
)}
</div>
);
}

View File

@@ -6,18 +6,20 @@ import { Button } from "@/components/ui/Button";
export interface ActionInputProps {
onSubmit: (action: string) => void;
submitting: boolean;
suggestedActions: string[];
onSuggestedClick?: (action: string) => void;
placeholder?: string;
className?: string;
autoFocus?: boolean;
}
/**
* Action input field (textarea + Send button). The suggested-action chips
* used to also live here — they were removed to avoid duplicating the chips
* that already appear under the last GM message. This component now only
* renders the text input + send button.
*/
export function ActionInput({
onSubmit,
submitting,
suggestedActions,
onSuggestedClick,
placeholder,
className,
autoFocus = false,
@@ -33,29 +35,8 @@ export function ActionInput({
setText("");
};
const handleSuggested = (action: string) => {
if (submitting) return;
if (onSuggestedClick) onSuggestedClick(action);
else onSubmit(action);
};
return (
<div className={cn("space-y-2", className)}>
{suggestedActions.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{suggestedActions.map((a, i) => (
<button
key={`${a}-${i}`}
type="button"
onClick={() => handleSuggested(a)}
disabled={submitting}
className="badge bg-bg-soft text-fg hover:bg-bg-card hover:text-accent disabled:opacity-50"
>
{a}
</button>
))}
</div>
)}
<form onSubmit={handleSubmit} className="flex items-end gap-2">
<textarea
value={text}

View File

@@ -1,4 +1,4 @@
import { useEffect, useRef } from "react";
import { useEffect, useRef, type UIEvent } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import { cn } from "@/lib/cn";
@@ -8,33 +8,109 @@ import { ToolCallBubble } from "./ToolCallBubble";
export interface ChatViewProps {
className?: string;
/** Called when the user scrolls to (or near) the top of the chat —
* the parent should call `loadMoreHistory(worldId)` to fetch older
* steps. */
onScrollTop?: () => void;
/** When true, render the "Loading more…" indicator at the top. */
loadingMore?: boolean;
/** Action selected for the most recent step (used to highlight +
* grey-out the rest). String value = the chosen action; "custom"
* means a free-text action was used (grey out all). */
selectedActionForLastStep?: string | "custom" | null;
/** Called when the user clicks one of the suggested-action chips on
* the most recent step. */
onSuggestedClick?: (action: string) => void;
/** Whether the chat is currently submitting (disables chip clicks). */
submitting?: boolean;
}
export function ChatView({ className }: ChatViewProps) {
export function ChatView({
className,
onScrollTop,
loadingMore,
selectedActionForLastStep,
onSuggestedClick,
submitting,
}: ChatViewProps) {
const { t } = useTranslation();
const world = useSessionStore((s) => s.world);
const recentSteps = useSessionStore((s) => s.recentSteps);
const streamMessages = useSessionStore((s) => s.streamMessages);
const streamingText = useSessionStore((s) => s.streamingText);
const submitting = useSessionStore((s) => s.submitting);
const submittingStore = useSessionStore((s) => s.submitting);
const error = useSessionStore((s) => s.error);
const pendingPlayerAction = useSessionStore((s) => s.pendingPlayerAction);
const currentPhaseLabel = useSessionStore((s) => s.currentPhaseLabel);
const introScene = world?.intro_scene || null;
const hasIntro = Boolean(introScene);
const noSteps = recentSteps.length === 0;
// Empty state: no intro scene AND no steps AND nothing streaming.
const empty = noSteps && !submitting && streamMessages.length === 0 && !hasIntro;
// Show intro scene as the first chat message when there are no steps yet
// (and we're not currently streaming a new response that would replace it).
const showIntro = hasIntro && noSteps && !streamingText;
const empty =
recentSteps.length === 0 &&
!submittingStore &&
streamMessages.length === 0 &&
!hasIntro &&
!pendingPlayerAction;
const scrollRef = useRef<HTMLDivElement>(null);
const bottomRef = useRef<HTMLDivElement>(null);
// Track the previous scroll height so we can preserve the user's
// position when older steps are prepended.
const prevScrollHeightRef = useRef<number | null>(null);
// Track whether the user is near the bottom (so we auto-scroll only
// when they are).
const nearBottomRef = useRef(true);
// Auto-scroll to bottom when new content arrives — but only if the
// user is already near the bottom (so we don't yank them away from
// older messages they're reading).
useEffect(() => {
if (!nearBottomRef.current) return;
bottomRef.current?.scrollIntoView({ behavior: "smooth", block: "end" });
}, [recentSteps, streamMessages, streamingText, submitting, showIntro]);
}, [recentSteps, streamMessages, streamingText, submittingStore, pendingPlayerAction, introScene]);
// When older steps are prepended, preserve the user's scroll position
// (keep the previously-visible content in view).
useEffect(() => {
const el = scrollRef.current;
if (!el || prevScrollHeightRef.current == null) return;
const newHeight = el.scrollHeight;
const diff = newHeight - prevScrollHeightRef.current;
if (diff > 0) {
el.scrollTop = el.scrollTop + diff;
}
prevScrollHeightRef.current = null;
}, [recentSteps]);
const handleScroll = (e: UIEvent<HTMLDivElement>) => {
const el = e.currentTarget;
const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
nearBottomRef.current = distanceFromBottom < 80;
// Trigger pagination when the user is within 60px of the top.
if (el.scrollTop < 60 && onScrollTop) {
// Save the current scroll height so we can restore position after
// the new steps are prepended.
prevScrollHeightRef.current = el.scrollHeight;
onScrollTop();
}
};
const lastStepId = recentSteps.length > 0 ? recentSteps[recentSteps.length - 1].id : null;
return (
<div className={cn("flex flex-col gap-3 overflow-y-auto p-3", className)}>
<div
ref={scrollRef}
onScroll={handleScroll}
className={cn("flex flex-col gap-3 overflow-y-auto p-3", className)}
>
{loadingMore && (
<div className="flex items-center justify-center gap-2 py-2 text-xs text-fg-muted">
<span className="inline-block h-3 w-3 animate-spin rounded-full border-2 border-fg-muted border-t-transparent" />
{t("play.loading_more")}
</div>
)}
{empty && (
<div className="m-auto max-w-md text-center text-sm text-fg-muted py-8 space-y-2">
<p>{t("play.no_intro_scene")}</p>
@@ -49,7 +125,12 @@ export function ChatView({ className }: ChatViewProps) {
</div>
)}
{showIntro && introScene && (
{/* Intro scene is ALWAYS shown as the first chat message (when
present). Previously this only rendered when there were no
recent_steps, which meant it disappeared after the first
iteration — that broke the user's mental model of the chat
history. */}
{hasIntro && introScene && (
<div className="max-w-[90%] rounded-lg bg-bg-card px-3 py-2">
<p className="text-xs font-semibold text-fg-muted mb-0.5">
{t("play.game_master")}
@@ -59,24 +140,59 @@ export function ChatView({ className }: ChatViewProps) {
)}
{recentSteps.map((step) => (
<StepBlock key={step.id} step={step} />
<StepBlock
key={step.id}
step={step}
isLastStep={step.id === lastStepId}
selectedAction={step.id === lastStepId ? selectedActionForLastStep ?? null : null}
onSuggestedClick={onSuggestedClick}
submitting={submitting ?? submittingStore}
/>
))}
{/* Pending player action — shown immediately when the user clicks
a suggested action or sends custom text, before the GM
responds. */}
{pendingPlayerAction && (
<div className="ml-auto max-w-[85%] rounded-lg bg-accent/15 px-3 py-2 text-right">
<p className="text-xs font-semibold text-accent mb-0.5">
{t("play.you")}
</p>
<p className="whitespace-pre-wrap text-sm text-fg">{pendingPlayerAction}</p>
</div>
)}
{streamMessages.length > 0 && (
<div className="space-y-2 border-l-2 border-accent/40 pl-3">
{streamMessages
.filter((m) => m.kind === "tool_call" || m.kind === "phase_start" || m.kind === "warning" || m.kind === "error" || m.kind === "trigger_fired" || m.kind === "summary_generated")
.filter((m) =>
m.kind === "tool_call" ||
m.kind === "status" ||
m.kind === "phase_start" ||
m.kind === "warning" ||
m.kind === "error" ||
m.kind === "trigger_fired" ||
m.kind === "summary_generated",
)
.map((m) => {
if (m.kind === "tool_call" && m.tool) {
return (
<ToolCallBubble
key={m.id}
tool={m.tool}
args={m.toolArgs}
result={m.toolResult}
success={m.toolSuccess ?? false}
/>
);
}
if (m.kind === "status" && m.message) {
return (
<p key={m.id} className="text-xs text-fg-muted italic">
{m.message}
</p>
);
}
if (m.kind === "phase_start") {
return (
<p key={m.id} className="text-xs text-fg-muted">
@@ -123,8 +239,12 @@ export function ChatView({ className }: ChatViewProps) {
</p>
</div>
)}
{submitting && !streamingText && (
<p className="text-xs text-fg-muted italic">{t("play.streaming")}</p>
{/* No streaming text yet but we're mid-stream — show a
friendly phase label so the user knows what's happening. */}
{submittingStore && !streamingText && (
<p className="text-xs text-fg-muted italic">
{currentPhaseLabel ? phaseLabelToText(currentPhaseLabel, t) : t("play.streaming")}
</p>
)}
</div>
)}
@@ -138,8 +258,33 @@ export function ChatView({ className }: ChatViewProps) {
);
}
function StepBlock({ step }: { step: Step }) {
/** Map a backend phase label to a localized "X…" status string. */
function phaseLabelToText(label: string, t: (k: string) => string): string {
switch (label) {
case "planning":
return t("play.phase_planning");
case "writing":
return t("play.phase_writing");
case "sending":
return t("play.phase_sending");
default:
return `${label}`;
}
}
interface StepBlockProps {
step: Step;
isLastStep: boolean;
selectedAction: string | "custom" | null;
onSuggestedClick?: (action: string) => void;
submitting: boolean;
}
function StepBlock({ step, isLastStep, selectedAction, onSuggestedClick, submitting }: StepBlockProps) {
const { t } = useTranslation();
const suggestions = step.suggested_actions || [];
const interactive = isLastStep && selectedAction == null && !submitting && onSuggestedClick;
return (
<div className="space-y-2">
{step.player_action && (
@@ -155,13 +300,41 @@ function StepBlock({ step }: { step: Step }) {
{t("play.game_master")}
</p>
<p className="whitespace-pre-wrap text-sm text-fg">{step.scene_text}</p>
{step.suggested_actions && step.suggested_actions.length > 0 && (
{suggestions.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1">
{step.suggested_actions.map((a, i) => (
<span key={i} className="badge bg-bg-soft text-fg-muted">
{a}
</span>
))}
{suggestions.map((a, i) => {
const isSelected = selectedAction === a;
const isGreyed = selectedAction != null && !isSelected;
// Older steps: render as static badges. Last step: render
// as interactive buttons (until one is selected).
if (interactive) {
return (
<button
key={`${a}-${i}`}
type="button"
onClick={() => onSuggestedClick?.(a)}
className="badge bg-bg-soft text-fg hover:bg-accent/15 hover:text-accent transition-colors"
>
{a}
</button>
);
}
return (
<span
key={`${a}-${i}`}
className={cn(
"badge",
isSelected
? "bg-accent/20 text-accent ring-1 ring-accent/40"
: isGreyed
? "bg-bg-soft text-fg-dim opacity-50"
: "bg-bg-soft text-fg-muted",
)}
>
{a}
</span>
);
})}
</div>
)}
</div>

View File

@@ -1,34 +1,74 @@
import { useTranslation } from "react-i18next";
import { cn } from "@/lib/cn";
export type SseStatusKind = "idle" | "connecting" | "open" | "error" | "closed";
export interface SseStatusProps {
status: "idle" | "connecting" | "open" | "error" | "closed";
status: SseStatusKind;
/** Friendly current phase label (e.g. "Planning…"). When provided and
* status is "open", the indicator shows the phase label instead of a
* generic "Connected" string. */
phaseLabel?: string | null;
className?: string;
}
const STATUS_STYLES = {
idle: { dot: "bg-fg-dim", label: "sse.disconnected" },
connecting: { dot: "bg-warn animate-pulse", label: "sse.connecting" },
open: { dot: "bg-ok animate-pulse", label: "sse.connected" },
error: { dot: "bg-err", label: "sse.error" },
closed: { dot: "bg-fg-dim", label: "sse.disconnected" },
} as const;
/**
* SSE connection status indicator.
*
* Behaviour (per FE-6 spec):
* - The "Disconnected" / idle dot is hidden entirely — it's not useful
* to the user. We only render this component when the stream is in a
* non-idle state (connecting, open, error) OR when a phase label is
* being shown.
* - The error state ("Connection error") is shown briefly while the SSE
* client is retrying — once it reconnects, the indicator flips back to
* the phase label / "Connected".
* - In production mode the backend filters `tool_call` / `llm_call_*`
* events, so the user only sees friendly phase labels: "Reading…",
* "Planning…", "Writing…", "Processing…".
*/
export function SseStatus({ status, phaseLabel, className }: SseStatusProps) {
const { t } = useTranslation();
// Idle / closed → don't render at all (no useful info for the user).
if (status === "idle" || status === "closed") {
// Still render a phase label if we have one (e.g. while the
// streaming area is mid-stream but the SSE channel briefly closed).
if (!phaseLabel) return null;
return (
<span
className={cn("inline-flex items-center gap-1.5 text-xs text-fg-muted", className)}
role="status"
>
<span className="h-2 w-2 rounded-full bg-accent animate-pulse" />
<span>{phaseLabel}</span>
</span>
);
}
const dotClass =
status === "open"
? "bg-ok animate-pulse"
: status === "connecting"
? "bg-warn animate-pulse"
: "bg-err";
const label =
status === "error"
? t("sse.reconnecting")
: phaseLabel
? phaseLabel
: status === "open"
? t("sse.connected")
: t("sse.connecting");
export function SseStatus({ status, className }: SseStatusProps) {
const s = STATUS_STYLES[status];
return (
<span
className={cn("inline-flex items-center gap-1.5 text-xs text-fg-muted", className)}
role="status"
>
<span className={cn("h-2 w-2 rounded-full", s.dot)} />
{/* Status text is fixed for now; could be i18n'd if needed */}
<span>
{status === "idle" && "—"}
{status === "connecting" && "Connecting…"}
{status === "open" && "Connected"}
{status === "error" && "Connection error"}
{status === "closed" && "Disconnected"}
</span>
<span className={cn("h-2 w-2 rounded-full", dotClass)} />
<span>{label}</span>
</span>
);
}

View File

@@ -4,27 +4,37 @@ import { cn } from "@/lib/cn";
export interface ToolCallBubbleProps {
tool: string;
/** Raw arguments object (debug mode only). */
args?: unknown;
result: unknown;
success: boolean;
className?: string;
}
export function ToolCallBubble({ tool, result, success, className }: ToolCallBubbleProps) {
function safeStringify(v: unknown): string {
if (typeof v === "string") return v;
try {
return JSON.stringify(v, null, 2);
} catch {
return String(v);
}
}
export function ToolCallBubble({ tool, args, result, success, className }: ToolCallBubbleProps) {
const { t } = useTranslation();
const resultPreview = useMemo(() => {
try {
const str = typeof result === "string" ? result : JSON.stringify(result);
if (str.length <= 200) return str;
return str.slice(0, 200) + "…";
} catch {
return String(result);
}
}, [result]);
const resultStr = useMemo(() => safeStringify(result), [result]);
const argsStr = useMemo(
() => (args == null ? "" : safeStringify(args)),
[args],
);
return (
<div
className={cn(
"rounded-md border px-2.5 py-1.5 text-xs",
// max-w-full + overflow-hidden keep the bubble within the chat
// container; the inner <pre> uses overflow-x-auto so long JSON
// scrolls horizontally instead of breaking the layout.
"max-w-full overflow-hidden rounded-md border px-2.5 py-1.5 text-xs",
success
? "border-ok/30 bg-ok/5 text-fg"
: "border-err/30 bg-err/5 text-fg",
@@ -33,13 +43,32 @@ export function ToolCallBubble({ tool, result, success, className }: ToolCallBub
role="status"
>
<div className="flex items-center gap-1.5">
<span className={cn("h-1.5 w-1.5 rounded-full", success ? "bg-ok" : "bg-err")} />
<span className="font-mono font-medium text-fg-muted">
<span className={cn("h-1.5 w-1.5 shrink-0 rounded-full", success ? "bg-ok" : "bg-err")} />
<span className="shrink-0 font-mono font-medium text-fg-muted">
{t("builder.tool_call")}:
</span>
<span className="font-mono text-fg">{tool}</span>
<span className="truncate font-mono text-fg">{tool}</span>
</div>
<p className="mt-1 break-words font-mono text-[10px] text-fg-dim">{resultPreview}</p>
{/* Arguments (debug only — production backend filters tool_call
events entirely, so this only renders in debug mode). */}
{argsStr && (
<details className="mt-1">
<summary className="cursor-pointer text-[10px] font-mono text-fg-dim">
args
</summary>
<pre className="mt-1 max-h-40 overflow-auto rounded bg-bg-soft/60 p-1.5 font-mono text-[10px] text-fg-dim break-all whitespace-pre-wrap">
{argsStr}
</pre>
</details>
)}
<details className="mt-1">
<summary className="cursor-pointer text-[10px] font-mono text-fg-dim">
result
</summary>
<pre className="mt-1 max-h-40 overflow-auto rounded bg-bg-soft/60 p-1.5 font-mono text-[10px] text-fg-dim break-all whitespace-pre-wrap">
{resultStr}
</pre>
</details>
</div>
);
}

View File

@@ -177,7 +177,7 @@ export function WorldBuilder({ className }: WorldBuilderProps) {
...s,
phase: "error",
sseStatus: "error",
logs: [...s.logs, { text: `[error] ${d?.message || "Stream error"}`, kind: "error" as const }],
logs: [...s.logs, { text: `[error] ${d?.message || t("builder.build_failed")}`, kind: "error" as const }],
}));
pushToast("error", d?.message || t("builder.build_failed"));
controllerRef.current?.close();
@@ -197,14 +197,27 @@ export function WorldBuilder({ className }: WorldBuilderProps) {
const d = event.data as { step?: number | string; message?: string };
const stepVal = d.step;
const isSkip = typeof stepVal === "string" && stepVal.startsWith("skipping_");
// Localize the stage label and message via the i18n keys
// `builder.step_label.<stage>` and `builder.step_msg.<stage>`.
// Falls back to the raw values if no translation exists.
const stageKey = typeof stepVal === "string" || typeof stepVal === "number"
? `builder.step_label.${stepVal}`
: "builder.step_label.unknown";
const msgKey = typeof stepVal === "string" || typeof stepVal === "number"
? `builder.step_msg.${stepVal}`
: "builder.step_label.unknown";
const stageLabel = t(stageKey);
const stageText = stageLabel === stageKey ? String(stepVal ?? "?") : stageLabel;
const msgText = t(msgKey);
const finalMsg = msgText === msgKey ? (d.message || "") : msgText;
setState((s) => ({
...s,
step: stepVal,
message: d.message,
message: finalMsg,
logs: [
...s.logs,
{
text: `[${stepVal ?? "?"}] ${d.message || ""}`,
text: `[${stageText}] ${finalMsg}`,
kind: isSkip ? ("skip" as const) : ("info" as const),
},
],
@@ -250,11 +263,17 @@ export function WorldBuilder({ className }: WorldBuilderProps) {
break;
case "intro_scene_chunk": {
const d = event.data as { text: string };
// Store the streamed intro scene text so we can persist it on
// the world via the redirect — but do NOT render it as large
// text on the builder page. The user will see it on the edit
// page after the redirect.
setState((s) => ({ ...s, introScene: s.introScene + d.text }));
break;
}
case "intro_scene_complete": {
const d = event.data as { text: string };
// Store but do not prominently display — the user will see it
// on the edit page.
setState((s) => ({ ...s, introScene: d.text }));
break;
}
@@ -348,12 +367,15 @@ export function WorldBuilder({ className }: WorldBuilderProps) {
// On done, navigate to the EDIT page (not play) — the user should
// review the world, generate the intro scene, then click Play when ready.
// We use a very short delay (just long enough for the success toast to
// register) so the user isn't left looking at a "done" state that
// renders the intro scene as large text.
useEffect(() => {
if (state.phase === "done" && createdWorldIdRef.current) {
const id = createdWorldIdRef.current;
const timer = window.setTimeout(() => {
navigate(`/worlds/${id}/edit`);
}, 800);
}, 150);
return () => window.clearTimeout(timer);
}
}, [state.phase, navigate]);
@@ -531,14 +553,12 @@ export function WorldBuilder({ className }: WorldBuilderProps) {
message={state.message}
/>
)}
{state.introScene && (
<div>
<p className="label">{t("builder.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>
)}
{/* Note: the intro scene text is intentionally NOT rendered
here. Storing it in `state.introScene` keeps it available
for debugging, but displaying it briefly before the
redirect to the edit page caused a jarring flash of
large text. The user will see the intro scene on the
edit page after the redirect. */}
{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>

View File

@@ -3,6 +3,7 @@ import { useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { cn } from "@/lib/cn";
import { AdminApi, WorldsApi, toErrorMessage } from "@/lib/api";
import { displayGameTime } from "@/lib/formatTime";
import { useAuthStore } from "@/stores/authStore";
import { useToastStore } from "@/stores/toastStore";
import type { WorldListItem, WorldStatus } from "@/types";
@@ -45,9 +46,13 @@ export function WorldCard({ world, onDelete, onRestored, onPermanentlyDeleted, c
const isArchived = world.status === "archived";
const isDraft = world.status === "draft";
const isAdmin = !!user?.is_admin;
// Prefer the human-readable time string ("Day 1, 08:00"); fall back to the
// raw `current_time` value when the backend doesn't provide the human form.
const displayedTime = world.current_time_human || world.current_time;
// The world list endpoint does NOT include `current_time_human` —
// compute the human-readable form client-side via the world's language.
const displayedTime = displayGameTime(
world.current_time,
world.current_time_human,
world.language,
);
const handleRestore = async () => {
setRestoring(true);

View File

@@ -138,7 +138,28 @@
"schema_generated": "World schema generated",
"environment_generated": "Environment generated",
"entities_generated": "Entities generated",
"retry_disabled": "Cannot retry — no world was created yet."
"retry_disabled": "Cannot retry — no world was created yet.",
"step_label": {
"generating_schema": "generating_schema",
"skipping_schema": "skipping_schema",
"generating_environment": "generating_environment",
"skipping_environment": "skipping_environment",
"generating_entities": "generating_entities",
"skipping_entities": "skipping_entities",
"generating_intro": "generating_intro",
"skipping_intro": "skipping_intro",
"unknown": "?"
},
"step_msg": {
"generating_schema": "Generating world schema…",
"skipping_schema": "Schemas already exist, skipping…",
"generating_environment": "Generating environment…",
"skipping_environment": "Environment already set, skipping…",
"generating_entities": "Generating entities…",
"skipping_entities": "Entities already exist, skipping…",
"generating_intro": "Generating intro scene…",
"skipping_intro": "Intro scene already exists, skipping…"
}
},
"editor": {
"title": "World Editor",
@@ -223,7 +244,12 @@
"trigger_fired": "Trigger fired",
"summary_generated": "Summary generated",
"load_failed": "Failed to load session.",
"no_actions_yet": "Take an action to begin."
"no_actions_yet": "Take an action to begin.",
"loading_more": "Loading more…",
"phase_reading": "Reading…",
"phase_planning": "Planning…",
"phase_writing": "Writing…",
"phase_sending": "Processing…"
},
"sse": {
"connecting": "Connecting…",
@@ -333,6 +359,17 @@
"text_replacements_from": "From",
"text_replacements_to": "To",
"text_replacements_add": "Add rule",
"name_banks_title": "Name Banks",
"name_banks_help": "Manage the character name banks used by the random-name button in the world builder. Names are stored per language.",
"name_banks_en": "English Names",
"name_banks_ru": "Russian Names",
"name_banks_empty": "No names yet. Add one below.",
"name_banks_add": "Add",
"name_banks_add_placeholder": "New name…",
"name_banks_already_exists": "That name is already in the bank.",
"name_banks_load_failed": "Failed to load name bank.",
"name_banks_add_failed": "Failed to add name.",
"name_banks_remove_failed": "Failed to remove name.",
"setting_desc": {
"llm.api_url": "Chat completions endpoint URL for the LLM provider.",
"llm.api_key": "API key for the LLM provider (stored as string).",

View File

@@ -138,7 +138,28 @@
"schema_generated": "Схема мира сгенерирована",
"environment_generated": "Окружение сгенерировано",
"entities_generated": "Сущности сгенерированы",
"retry_disabled": "Нельзя повторить — мир ещё не создан."
"retry_disabled": "Нельзя повторить — мир ещё не создан.",
"step_label": {
"generating_schema": "generating_schema",
"skipping_schema": "skipping_schema",
"generating_environment": "generating_environment",
"skipping_environment": "skipping_environment",
"generating_entities": "generating_entities",
"skipping_entities": "skipping_entities",
"generating_intro": "generating_intro",
"skipping_intro": "skipping_intro",
"unknown": "?"
},
"step_msg": {
"generating_schema": "Генерация схемы мира…",
"skipping_schema": "Схема уже существует, пропуск…",
"generating_environment": "Генерация окружения…",
"skipping_environment": "Окружение уже задано, пропуск…",
"generating_entities": "Генерация сущностей…",
"skipping_entities": "Сущности уже существуют, пропуск…",
"generating_intro": "Генерация вступительной сцены…",
"skipping_intro": "Вступительная сцена уже существует, пропуск…"
}
},
"editor": {
"title": "Редактор мира",
@@ -223,7 +244,12 @@
"trigger_fired": "Сработал триггер",
"summary_generated": "Сгенерирована сводка",
"load_failed": "Не удалось загрузить сессию.",
"no_actions_yet": "Сделайте действие, чтобы начать."
"no_actions_yet": "Сделайте действие, чтобы начать.",
"loading_more": "Загрузка ещё…",
"phase_reading": "Чтение…",
"phase_planning": "Планирование…",
"phase_writing": "Написание…",
"phase_sending": "Обработка…"
},
"sse": {
"connecting": "Подключение…",
@@ -333,6 +359,17 @@
"text_replacements_from": "С",
"text_replacements_to": "На",
"text_replacements_add": "Добавить правило",
"name_banks_title": "Банки имён",
"name_banks_help": "Управление банками имён персонажей для кнопки случайного имени в мастере создания мира. Имена хранятся по языкам.",
"name_banks_en": "Английские имена",
"name_banks_ru": "Русские имена",
"name_banks_empty": "Имён пока нет. Добавьте ниже.",
"name_banks_add": "Добавить",
"name_banks_add_placeholder": "Новое имя…",
"name_banks_already_exists": "Такое имя уже есть в банке.",
"name_banks_load_failed": "Не удалось загрузить банк имён.",
"name_banks_add_failed": "Не удалось добавить имя.",
"name_banks_remove_failed": "Не удалось удалить имя.",
"setting_desc": {
"llm.api_url": "URL endpoint chat completions провайдера LLM.",
"llm.api_key": "API-ключ провайдера LLM (хранится строкой).",

View File

@@ -10,12 +10,14 @@ import type {
EmbeddingsProbeResult,
EmbeddingsTestResult,
HealthResponse,
HistoryResponse,
IterateResponse,
LlmLog,
LlmLogDetail,
LlmTestResult,
LlmToolsTestResult,
LoginPayload,
NameBankResponse,
Paginated,
PresetListItem,
PublicSettings,
@@ -332,6 +334,18 @@ export const SessionsApi = {
rollback: (worldId: string) =>
request<void>(`/sessions/worlds/${worldId}/rollback`, { method: "POST" }),
/**
* Fetch older chat history with pagination (for scroll-up loading).
* Endpoint: GET /api/sessions/worlds/{id}/history?before={seq}&limit=20.
* Returns `{steps, has_more, oldest_sequence}`. The `steps` array is
* ordered oldest-first (same as `recent_steps` in the state response).
*/
getHistory: (worldId: string, before?: number, limit = 20) =>
request<HistoryResponse>(
`/sessions/worlds/${worldId}/history`,
{ query: { before, limit } },
),
// ---- World editor: accept / reject proposed changes & answer clarifications ----
/** Accept proposed changes from the world_editor stream. */
applyChanges: (worldId: string) =>
@@ -473,6 +487,29 @@ export const AdminApi = {
},
hardDeleteWorld: (id: string) =>
request<{ ok: boolean; deleted: string }>(`/admin/worlds/${id}`, { method: "DELETE" }),
// ---- Name banks ----
/** GET /api/admin/names/{language} → {language, names, count}. */
getNameBank: (language: string) =>
request<NameBankResponse>(`/admin/names/${encodeURIComponent(language)}`),
/** PUT /api/admin/names/{language} body {names: [...]} → updates entire bank. */
updateNameBank: (language: string, names: string[]) =>
request<NameBankResponse>(
`/admin/names/${encodeURIComponent(language)}`,
{ method: "PUT", body: { names } },
),
/** POST /api/admin/names/{language}/add body {name} → adds one name. */
addName: (language: string, name: string) =>
request<NameBankResponse>(
`/admin/names/${encodeURIComponent(language)}/add`,
{ method: "POST", body: { name } },
),
/** DELETE /api/admin/names/{language}/{name} → removes one name. */
removeName: (language: string, name: string) =>
request<NameBankResponse>(
`/admin/names/${encodeURIComponent(language)}/${encodeURIComponent(name)}`,
{ method: "DELETE" },
),
};
export const MiscApi = {

View File

@@ -0,0 +1,55 @@
/**
* Format a game time string as a human-readable localized string. Mirrors
* the backend `app.core.time_utils.format_time_human` so the frontend can
* render the same string for worlds loaded via endpoints that do NOT
* include a pre-computed `current_time_human` (e.g. GET /api/worlds/{id}).
*
* Supported input formats (matches the backend `GameTime.parse` regex):
* - "day_1_hour_8" → "Day 1, 08:00" / "День 1, 08:00"
* - "day_3_hour_14_min_30" → "Day 3, 14:30" / "День 3, 14:30"
* - "year_2_day_5_hour_12" → "Year 2, Day 5, 12:00" / "Год 2, День 5, 12:00"
* - "year_2_day_5_hour_12_min_0" → "Year 2, Day 5, 12:00" / "Год 2, День 5, 12:00"
*
* If the input doesn't match the regex, the original string is returned
* unchanged (same behaviour as the backend).
*/
const TIME_RE = /^(?:year_(\d+)_)?day_(\d+)_hour_(\d+)(?:_min_(\d+))?$/;
export function formatGameTime(timeStr: string | null | undefined, language: string): string {
if (!timeStr) return "";
const m = TIME_RE.exec(timeStr.trim());
if (!m) return timeStr;
const year = m[1] ? parseInt(m[1], 10) : 1;
const day = parseInt(m[2], 10);
const hour = parseInt(m[3], 10);
const minute = m[4] ? parseInt(m[4], 10) : 0;
const isRu = language === "ru";
const parts: string[] = [];
if (year !== 1) {
parts.push(isRu ? `Год ${year}` : `Year ${year}`);
}
parts.push(isRu ? `День ${day}` : `Day ${day}`);
if (minute) {
parts.push(`${String(hour).padStart(2, "0")}:${String(minute).padStart(2, "0")}`);
} else {
parts.push(`${String(hour).padStart(2, "0")}:00`);
}
return parts.join(", ");
}
/**
* Resolve the best display string for a game time value. Prefers a
* pre-computed `current_time_human` (provided by the session-state
* endpoint); falls back to computing it client-side via `formatGameTime`
* using the world's language.
*/
export function displayGameTime(
timeStr: string | null | undefined,
humanStr: string | null | undefined,
language: string | null | undefined,
): string {
if (humanStr) return humanStr;
if (!timeStr) return "";
return formatGameTime(timeStr, language || "en");
}

View File

@@ -127,6 +127,7 @@ export const KNOWN_EVENTS = [
"phase_start",
"phase_end",
"tool_call",
"status",
"llm_call_start",
"llm_call_end",
"scene_chunk",

View File

@@ -5,6 +5,7 @@ import { useSessionStore } from "@/stores/sessionStore";
import { useToastStore } from "@/stores/toastStore";
import { useUiSettingsStore, selectHeaderTitle } from "@/stores/uiSettingsStore";
import { toErrorMessage } from "@/lib/api";
import { displayGameTime } from "@/lib/formatTime";
import { Button } from "@/components/ui/Button";
import { Card } from "@/components/ui/Card";
import { Spinner } from "@/components/ui/Spinner";
@@ -29,14 +30,27 @@ export function PlayPage() {
const error = useSessionStore((s) => s.error);
const submitting = useSessionStore((s) => s.submitting);
const sseStatus = useSessionStore((s) => s.sseStatus);
const currentPhaseLabel = useSessionStore((s) => s.currentPhaseLabel);
const fetchState = useSessionStore((s) => s.fetchState);
const sendAction = useSessionStore((s) => s.sendAction);
const retry = useSessionStore((s) => s.retry);
const rollback = useSessionStore((s) => s.rollback);
const reset = useSessionStore((s) => s.reset);
const loadMoreHistory = useSessionStore((s) => s.loadMoreHistory);
const hasMoreHistory = useSessionStore((s) => s.hasMoreHistory);
const loadingMore = useSessionStore((s) => s.loadingMore);
const [rollbackOpen, setRollbackOpen] = useState(false);
const [redirected, setRedirected] = useState(false);
/**
* Tracks the user's selection for the most recent step's suggested
* actions:
* - string → the chosen action text (highlight that one, grey the rest)
* - "custom" → a free-text action was used (grey out all)
* - null → no selection yet (suggestions are interactive)
* Cleared when a new step arrives (i.e. when the GM responds).
*/
const [selectedAction, setSelectedAction] = useState<string | "custom" | null>(null);
useEffect(() => {
if (!id) return;
@@ -56,6 +70,16 @@ export function PlayPage() {
}
}, [id, world, redirected, navigate, pushToast, t]);
// When a new step arrives (recentSteps count increases), clear the
// selected-action state so the new step's suggestions are interactive.
const prevStepsLenRef = useState<{ len: number }>(() => ({ len: 0 }))[0];
useEffect(() => {
if (recentSteps.length > prevStepsLenRef.len) {
setSelectedAction(null);
}
prevStepsLenRef.len = recentSteps.length;
}, [recentSteps.length, prevStepsLenRef]);
// Page title: "{world.name} | {headerTitle}"
useEffect(() => {
if (world) {
@@ -111,18 +135,49 @@ export function PlayPage() {
),
);
// Compute the localized, human-readable game time. Prefers the
// backend-provided `current_time_human` (only available on the session
// state endpoint); falls back to computing it client-side.
const displayedTime = displayGameTime(
world.current_time,
world.current_time_human,
world.language,
);
// Friendly phase label for the status indicator. When the stream is
// open, prefer the current phase label from the store; otherwise show
// a "Reading…" hint while we wait for the first phase_start event.
const phaseLabel = currentPhaseLabel
? phaseLabelToText(currentPhaseLabel, t)
: submitting
? t("play.phase_reading")
: null;
const handleSend = (action: string) => {
// Custom text → grey out all suggested actions for the last step.
setSelectedAction("custom");
void sendAction(id, action, "manual").catch((err) => {
pushToast("error", toErrorMessage(err, "Failed"));
setSelectedAction(null);
});
};
const handleSuggested = (action: string) => {
// Highlight the clicked action, grey out the rest. The chat view
// also shows the action immediately as a pending player bubble
// (driven by the session store's pendingPlayerAction).
setSelectedAction(action);
void sendAction(id, action, "suggested").catch((err) => {
pushToast("error", toErrorMessage(err, "Failed"));
setSelectedAction(null);
});
};
const handleScrollTop = () => {
if (!hasMoreHistory || loadingMore) return;
void loadMoreHistory(id);
};
const handleRetry = () => {
void retry(id).catch((err) => {
pushToast("error", toErrorMessage(err, "Failed"));
@@ -139,6 +194,12 @@ export function PlayPage() {
}
};
// The nextActions from the store are the suggested actions for the
// most recent step. We pass them to the ChatView via the step block
// (the last step already carries its own suggested_actions); the
// ActionInput no longer renders them.
void nextActions;
return (
<div className="mx-auto flex h-[calc(100vh-3.5rem)] max-w-7xl flex-col lg:flex-row gap-3 p-3">
{/* Environment panel */}
@@ -211,24 +272,23 @@ export function PlayPage() {
</Link>
</div>
<p className="text-xs text-fg-muted">
{(() => {
// Prefer the human-readable form; fall back to the raw
// current_time string when the backend doesn't supply it
// (e.g. older session state cached locally).
const time = world.current_time_human || world.current_time;
return time ? `${t("worlds.current_time")}: ${time}` : "";
})()}
{displayedTime ? `${t("worlds.current_time")}: ${displayedTime}` : ""}
</p>
</div>
<SseStatus status={sseStatus} />
<SseStatus status={sseStatus} phaseLabel={phaseLabel} />
</header>
<ChatView className="flex-1 min-h-0" />
<ChatView
className="flex-1 min-h-0"
onScrollTop={handleScrollTop}
loadingMore={loadingMore}
selectedActionForLastStep={selectedAction}
onSuggestedClick={handleSuggested}
submitting={submitting}
/>
<footer className="border-t border-fg-dim/20 p-3">
<ActionInput
onSubmit={handleSend}
onSuggestedClick={handleSuggested}
submitting={submitting}
suggestedActions={nextActions}
placeholder={t("play.action_placeholder")}
/>
</footer>
@@ -256,6 +316,20 @@ export function PlayPage() {
);
}
/** Map a backend phase label to a localized "X…" status string. */
function phaseLabelToText(label: string, t: (k: string) => string): string {
switch (label) {
case "planning":
return t("play.phase_planning");
case "writing":
return t("play.phase_writing");
case "sending":
return t("play.phase_sending");
default:
return `${label}`;
}
}
/**
* Normalize the plot_rails field — the backend may return either an
* old-style array of PlotRail objects or a new-style container object

View File

@@ -2,6 +2,7 @@ import { useCallback, useEffect } from "react";
import { useParams, useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { WorldsApi } from "@/lib/api";
import { displayGameTime } from "@/lib/formatTime";
import { useWorldsStore } from "@/stores/worldsStore";
import { useToastStore } from "@/stores/toastStore";
import { useUiSettingsStore, selectHeaderTitle } from "@/stores/uiSettingsStore";
@@ -86,8 +87,14 @@ export function WorldEditPage() {
// world to status "ready" with intro_scene set, and IntroSceneGenerator
// then returns null on its next render.
const showIntroGenerator = world.status === "draft" || !world.intro_scene;
// Prefer the human-readable time string; fall back to the raw value.
const displayedTime = world.current_time_human || world.current_time;
// The world detail endpoint (GET /api/worlds/{id}) does NOT include
// `current_time_human` — only the session state endpoint does. Compute
// the human-readable form client-side via the world's language.
const displayedTime = displayGameTime(
world.current_time,
world.current_time_human,
world.language,
);
return (
<div className="mx-auto max-w-7xl space-y-4 p-4">

View File

@@ -16,6 +16,7 @@ interface StreamMessage {
kind:
| "scene_chunk"
| "tool_call"
| "status"
| "llm_call_start"
| "llm_call_end"
| "phase_start"
@@ -30,16 +31,25 @@ interface StreamMessage {
| "suggested_actions";
text?: string;
tool?: string;
toolArgs?: unknown;
toolResult?: unknown;
toolSuccess?: boolean;
phase?: string;
phase?: string | number;
phaseName?: string;
step?: number;
totalSteps?: number;
message?: string;
/** Type tag for status events ("tool" | "phase" | …). */
statusType?: string;
actions?: string[];
}
interface PendingStep {
stepId: string;
sequenceNumber: number | null;
playerAction: string | null;
}
interface SessionStateStore {
world: World | null;
environment: Environment | null;
@@ -48,17 +58,46 @@ interface SessionStateStore {
loading: boolean;
error: string | null;
// History pagination
/** True when there are (likely) older steps that can be loaded by
* scrolling up. Set optimistically on fetchState (when state returns
* the maximum of 10 steps), and reconciled with the real value when
* loadMoreHistory is called. */
hasMoreHistory: boolean;
loadingMore: boolean;
/** Sequence number of the oldest step currently in `recentSteps`.
* Used as the `before` cursor for the next pagination call. */
oldestSequence: number | null;
// Streaming state
sseStatus: SseStatus;
streamingText: string;
streamingStepId: string | null;
streamMessages: StreamMessage[];
submitting: boolean;
/** Player action that has been submitted but not yet reflected in
* `recentSteps`. Rendered as a player bubble above the streaming area
* so the user gets immediate feedback. Cleared when the new step is
* appended (on `done`). */
pendingPlayerAction: string | null;
/** Set on `iteration_complete`. Carries the step_id + sequence_number
* of the step the backend just persisted, so we can append a fully
* formed Step object locally on `done` without refetching. */
pendingStep: PendingStep | null;
/** Friendly current phase name for the status indicator (e.g.
* "Planning…"). Cleared when streaming ends. */
currentPhaseLabel: string | null;
/** Set to true once any `tool_call` event arrives — implies the
* backend is running in debug mode. */
debugMode: boolean;
// SSE controllers
_controller: SseController | null;
/** Saved worldId for the active stream. */
_streamWorldId: string | null;
fetchState: (worldId: string) => Promise<void>;
loadMoreHistory: (worldId: string) => Promise<void>;
sendAction: (worldId: string, action: string, actionSource: "manual" | "suggested") => Promise<void>;
retry: (worldId: string) => Promise<void>;
rollback: (worldId: string) => Promise<void>;
@@ -72,6 +111,27 @@ function uid(): string {
return Math.random().toString(36).slice(2) + Date.now().toString(36);
}
/** Build a localized phase label from a phase_start event payload. */
function phaseLabel(phase: string | number | undefined, name: string | undefined): string | null {
if (name) return name;
if (phase == null) return null;
// Friendly fallbacks for production mode (where backend sends phase
// numbers 1/2/3 with names planning/writing/sending).
switch (String(phase)) {
case "1":
case "planning":
return "planning";
case "2":
case "writing":
return "writing";
case "3":
case "sending":
return "sending";
default:
return String(phase);
}
}
function handleEvent(state: SessionStateStore, event: SseEvent, worldId: string): void {
// Mutable copy via set call
const pushMessage = (m: StreamMessage) => {
@@ -109,24 +169,43 @@ function handleEvent(state: SessionStateStore, event: SseEvent, worldId: string)
break;
}
case "phase_start": {
const d = event.data as { phase: string; name: string };
pushMessage({ id: uid(), kind: "phase_start", phase: d.phase, phaseName: d.name });
const d = event.data as { phase: string | number; name?: string };
const label = phaseLabel(d.phase, d.name);
pushMessage({ id: uid(), kind: "phase_start", phase: d.phase, phaseName: label ?? undefined });
if (label) patch({ currentPhaseLabel: label });
break;
}
case "phase_end": {
const d = event.data as { phase: string; duration_ms: number };
const d = event.data as { phase: string | number; duration_ms: number };
pushMessage({ id: uid(), kind: "phase_end", phase: d.phase, message: `${d.duration_ms}ms` });
break;
}
case "tool_call": {
// tool_call events only arrive in debug mode (the backend filters
// them out entirely in production). Mark debugMode=true so the UI
// can show the raw bubble.
const d = event.data as { tool: string; arguments: unknown; result: unknown; is_success: boolean };
pushMessage({
id: uid(),
kind: "tool_call",
tool: d.tool,
toolArgs: d.arguments,
toolResult: d.result,
toolSuccess: d.is_success,
});
patch({ debugMode: true });
break;
}
case "status": {
// Production-mode transformed tool_call events carry a friendly
// message string + a type tag.
const d = event.data as { message?: string; type?: string };
pushMessage({
id: uid(),
kind: "status",
message: d?.message,
statusType: d?.type,
});
break;
}
case "llm_call_start": {
@@ -174,17 +253,72 @@ function handleEvent(state: SessionStateStore, event: SseEvent, worldId: string)
break;
}
case "iteration_complete": {
const d = event.data as { step_id?: string; sequence_number?: number };
// Save the new step's id + sequence_number so we can build a local
// Step object on `done` without refetching everything.
const cur = useSessionStore.getState();
useSessionStore.setState({
pendingStep: {
stepId: d.step_id || cur.streamingStepId || cur.pendingStep?.stepId || "",
sequenceNumber: d.sequence_number ?? cur.pendingStep?.sequenceNumber ?? null,
playerAction: cur.pendingPlayerAction,
},
});
pushMessage({ id: uid(), kind: "iteration_complete" });
break;
}
case "done": {
// Refresh session state from REST
void useSessionStore.getState().fetchState(worldId);
// Append the newly-persisted step to recentSteps locally (built
// from the streaming text + the pending player action), instead
// of refetching state (which would replace the whole list and
// discard any older steps the user already paginated in).
const cur = useSessionStore.getState();
const pending = cur.pendingStep;
const streamingText = cur.streamingText;
const nextActions = cur.nextActions;
if (pending && (streamingText || pending.playerAction)) {
const newStep: Step = {
id: pending.stepId || uid(),
sequence_number: pending.sequenceNumber ?? (cur.recentSteps.at(-1)?.sequence_number ?? 0) + 1,
player_action: pending.playerAction,
scene_text: streamingText,
suggested_actions: nextActions,
created_at: new Date().toISOString(),
};
const nextSteps = [...cur.recentSteps, newStep];
useSessionStore.setState({
recentSteps: nextSteps,
// Update pagination cursor — the new step is now the newest.
// The oldest step is unchanged so oldestSequence stays the same.
});
}
// Refresh env/world/nextActions in the background WITHOUT
// replacing recentSteps. We do this by calling fetchState and
// then merging: keep our recentSteps, take everything else.
void (async () => {
try {
const data: SessionState = await SessionsApi.state(worldId);
useSessionStore.setState((s) => ({
world: data.world,
environment: data.environment,
nextActions: data.next_actions,
// Only replace recentSteps if our local list is empty (e.g.
// we never built a pending step for some reason) — otherwise
// preserve the user's full paginated history.
recentSteps: s.recentSteps.length === 0 ? data.recent_steps : s.recentSteps,
}));
} catch {
/* background refresh failure is non-fatal */
}
})();
patch({
sseStatus: "closed",
submitting: false,
streamingText: "",
streamingStepId: null,
pendingPlayerAction: null,
pendingStep: null,
currentPhaseLabel: null,
});
// Close the controller
const c = useSessionStore.getState()._controller;
@@ -208,24 +342,38 @@ export const useSessionStore = create<SessionStateStore>((set, get) => ({
loading: false,
error: null,
hasMoreHistory: false,
loadingMore: false,
oldestSequence: null,
sseStatus: "idle",
streamingText: "",
streamingStepId: null,
streamMessages: [],
submitting: false,
pendingPlayerAction: null,
pendingStep: null,
currentPhaseLabel: null,
debugMode: false,
_controller: null,
_streamWorldId: null,
fetchState: async (worldId) => {
set({ loading: true, error: null });
try {
const data: SessionState = await SessionsApi.state(worldId);
const steps = data.recent_steps;
set({
world: data.world,
environment: data.environment,
recentSteps: data.recent_steps,
recentSteps: steps,
nextActions: data.next_actions,
loading: false,
// State endpoint returns up to 10 steps. If we got 10, optimistically
// assume there are older steps to paginate in.
hasMoreHistory: steps.length >= 10,
oldestSequence: steps.length > 0 ? steps[0].sequence_number : null,
});
} catch (err) {
set({
@@ -235,22 +383,67 @@ export const useSessionStore = create<SessionStateStore>((set, get) => ({
}
},
loadMoreHistory: async (worldId) => {
const { oldestSequence, loadingMore } = get();
if (loadingMore) return;
if (oldestSequence == null) return;
set({ loadingMore: true });
try {
const res = await SessionsApi.getHistory(worldId, oldestSequence, 20);
if (res.steps.length > 0) {
set((s) => ({
recentSteps: [...res.steps, ...s.recentSteps],
oldestSequence: res.oldest_sequence ?? res.steps[0].sequence_number,
hasMoreHistory: res.has_more,
loadingMore: false,
}));
} else {
set({ hasMoreHistory: false, loadingMore: false });
}
} catch (err) {
set({ loadingMore: false });
useToastStore.getState().push("error", toErrorMessage(err, "Failed to load history"));
}
},
sendAction: async (worldId, action, actionSource) => {
set({ submitting: true, error: null, streamingText: "", streamMessages: [] });
set({
submitting: true,
error: null,
streamingText: "",
streamMessages: [],
// Show the player's action immediately as a pending bubble.
pendingPlayerAction: action,
pendingStep: null,
currentPhaseLabel: null,
});
try {
const res = await SessionsApi.iterate(worldId, action, actionSource);
set({ streamingStepId: res.step_id, sseStatus: "connecting" });
get().subscribeIterate(worldId, res.step_id);
} catch (err) {
const msg = toErrorMessage(err, "Failed to send action");
set({ submitting: false, error: msg });
set({
submitting: false,
error: msg,
pendingPlayerAction: null,
pendingStep: null,
});
useToastStore.getState().push("error", msg);
throw err;
}
},
retry: async (worldId) => {
set({ submitting: true, error: null, streamingText: "", streamMessages: [] });
set({
submitting: true,
error: null,
streamingText: "",
streamMessages: [],
pendingPlayerAction: null,
pendingStep: null,
currentPhaseLabel: null,
});
try {
const res = await SessionsApi.retry(worldId);
set({ streamingStepId: res.step_id, sseStatus: "connecting" });
@@ -292,13 +485,20 @@ export const useSessionStore = create<SessionStateStore>((set, get) => ({
onClose: () => set({ sseStatus: "closed" }),
onEvent: (event) => handleEvent(get(), event, worldId),
});
set({ _controller: controller });
set({ _controller: controller, _streamWorldId: worldId });
},
closeStream: () => {
const c = get()._controller;
if (c) c.close();
set({ _controller: null, sseStatus: "closed", submitting: false });
set({
_controller: null,
sseStatus: "closed",
submitting: false,
pendingPlayerAction: null,
pendingStep: null,
currentPhaseLabel: null,
});
},
reset: () => {
@@ -311,12 +511,20 @@ export const useSessionStore = create<SessionStateStore>((set, get) => ({
nextActions: [],
loading: false,
error: null,
hasMoreHistory: false,
loadingMore: false,
oldestSequence: null,
sseStatus: "idle",
streamingText: "",
streamingStepId: null,
streamMessages: [],
submitting: false,
pendingPlayerAction: null,
pendingStep: null,
currentPhaseLabel: null,
debugMode: false,
_controller: null,
_streamWorldId: null,
});
},
}));

View File

@@ -202,6 +202,20 @@ export interface SessionState {
next_actions: string[];
}
/** Response from GET /api/sessions/worlds/{id}/history. */
export interface HistoryResponse {
steps: Step[];
has_more: boolean;
oldest_sequence: number | null;
}
/** Response from the admin name-bank endpoints. */
export interface NameBankResponse {
language: string;
names: string[];
count: number;
}
export interface IterateResponse {
stream_url: string;
step_id: string;

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/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/AdminRecoverPage.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/formatTime.ts","./src/lib/sse.ts","./src/pages/AdminPage.tsx","./src/pages/AdminRecoverPage.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"}