fix
This commit is contained in:
@@ -1,8 +1,9 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { adminApi } from "@/api";
|
||||
import { adminApi, uiApi } from "@/api";
|
||||
import type { LlmLog, SettingsOut } from "@/types";
|
||||
import { useUiStore } from "@/store/ui";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Input } from "@/components/ui/Input";
|
||||
import { Card, CardBody, CardHeader } from "@/components/ui/Card";
|
||||
@@ -63,6 +64,12 @@ export function AdminPanelPage() {
|
||||
setValues(s.values);
|
||||
setSaved(true);
|
||||
setTimeout(() => setSaved(false), 2000);
|
||||
// If the admin changed the logo URL, refresh the public-UI cache so
|
||||
// the navbar/favicon update live without a full page reload.
|
||||
if ("ui.logo_url" in payload) {
|
||||
uiApi.resetCache();
|
||||
await useUiStore.getState().load(true);
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.detail || t("errors.unknown"));
|
||||
} finally {
|
||||
@@ -441,6 +448,40 @@ export function AdminPanelPage() {
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader
|
||||
title={t("admin.ui_settings")}
|
||||
subtitle={t("admin.ui_settings_desc")}
|
||||
/>
|
||||
<CardBody className="space-y-3">
|
||||
<Input
|
||||
label={t("admin.ui_logo_url")}
|
||||
value={values["ui.logo_url"] || ""}
|
||||
onChange={(e) => setValues({ ...values, "ui.logo_url": e.target.value })}
|
||||
placeholder="/logo.png"
|
||||
/>
|
||||
<p className="text-xs text-ink-500">{t("admin.ui_logo_url_hint")}</p>
|
||||
{/* Live preview so the admin sees the configured logo before saving. */}
|
||||
<div className="flex items-center gap-3 pt-1">
|
||||
<span className="text-xs text-ink-400">{t("admin.ui_logo_preview")}:</span>
|
||||
<div className="w-10 h-10 rounded border border-ink-700 bg-ink-900 flex items-center justify-center overflow-hidden">
|
||||
{values["ui.logo_url"] ? (
|
||||
<img
|
||||
src={values["ui.logo_url"]}
|
||||
alt="preview"
|
||||
className="w-8 h-8 object-contain"
|
||||
onError={(e) => {
|
||||
(e.currentTarget as HTMLImageElement).style.display = "none";
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<span className="text-[10px] text-ink-500">—</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
{saved && <span className="text-sm text-green-400 self-center">{t("admin.saved")}</span>}
|
||||
<Button onClick={save} disabled={saving}>
|
||||
|
||||
@@ -1,18 +1,38 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useAuthStore } from "@/store/auth";
|
||||
import { useUiStore } from "@/store/ui";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { BookOpen, Sparkles, Cog, Globe } from "lucide-react";
|
||||
|
||||
export function HomePage() {
|
||||
const { t } = useTranslation();
|
||||
const { user } = useAuthStore();
|
||||
const logoUrl = useUiStore((s) => s.logoUrl);
|
||||
|
||||
return (
|
||||
<div className="max-w-5xl mx-auto px-4 py-12">
|
||||
<div className="text-center mb-12">
|
||||
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-accent-500/10 border border-accent-500/30 mb-4">
|
||||
<BookOpen className="text-accent-500" size={32} />
|
||||
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-accent-500/10 border border-accent-500/30 mb-4 overflow-hidden">
|
||||
{logoUrl ? (
|
||||
<img
|
||||
src={logoUrl}
|
||||
alt="logo"
|
||||
className="w-12 h-12 rounded object-contain"
|
||||
onError={(e) => {
|
||||
// Fall back to the BookOpen icon if the configured logo URL
|
||||
// fails to load (e.g. typo in admin settings, dead link).
|
||||
(e.currentTarget as HTMLImageElement).style.display = "none";
|
||||
const sib = (e.currentTarget as HTMLImageElement).nextElementSibling as HTMLElement | null;
|
||||
if (sib) sib.style.display = "block";
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
<BookOpen
|
||||
className="text-accent-500"
|
||||
size={32}
|
||||
style={{ display: logoUrl ? "none" : "block" }}
|
||||
/>
|
||||
</div>
|
||||
<h1 className="text-4xl font-serif font-bold text-ink-100 mb-3">{t("app.title")}</h1>
|
||||
<p className="text-ink-400 max-w-2xl mx-auto">{t("app.subtitle")}</p>
|
||||
|
||||
@@ -11,7 +11,7 @@ import { Card, CardBody } from "@/components/ui/Card";
|
||||
import { Modal } from "@/components/ui/Modal";
|
||||
import { GlossaryModal } from "@/components/world/GlossaryModal";
|
||||
import { CharacterSheet } from "@/components/world/CharacterSheet";
|
||||
import { Send, BookOpen, User, Pencil, Clock, Zap, Loader2 } from "lucide-react";
|
||||
import { Send, BookOpen, User, Pencil, Clock, Zap, Loader2, RefreshCw } from "lucide-react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
|
||||
export function SessionPage() {
|
||||
@@ -28,6 +28,7 @@ export function SessionPage() {
|
||||
const [iterating, setIterating] = useState(false);
|
||||
const [status, setStatus] = useState<string>("");
|
||||
const [error, setError] = useState("");
|
||||
const [lastFailedAction, setLastFailedAction] = useState<string | null>(null);
|
||||
const [glossaryOpen, setGlossaryOpen] = useState(false);
|
||||
const [glossary, setGlossary] = useState<GlossaryEntry[]>([]);
|
||||
const [triggers, setTriggers] = useState<Trigger[]>([]);
|
||||
@@ -61,27 +62,105 @@ export function SessionPage() {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
}, [messages, status]);
|
||||
|
||||
const runIteration = async () => {
|
||||
if (!id || !actionText.trim() || iterating) return;
|
||||
// Auto-generate intro scene for fresh sessions (no messages yet).
|
||||
const introTriggeredRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!id || introTriggeredRef.current) return;
|
||||
if (messages.length === 0 && !iterating && !error) {
|
||||
introTriggeredRef.current = true;
|
||||
runIntro();
|
||||
}
|
||||
}, [id, messages.length, iterating, error]);
|
||||
|
||||
const runIntro = async () => {
|
||||
if (!id) return;
|
||||
setIterating(true);
|
||||
setStatus(t("session.status_writing_scene"));
|
||||
setError("");
|
||||
try {
|
||||
await fetchEventSource(`/api/sessions/${id}/intro`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({ session_id: id }),
|
||||
openWhenHidden: true,
|
||||
onmessage(ev) {
|
||||
const eventName = ev.event;
|
||||
let data: any = {};
|
||||
try { data = JSON.parse(ev.data || "{}"); } catch { data = {}; }
|
||||
if (eventName === "status") {
|
||||
const msg = data.message || "";
|
||||
if (msg === "writing_scene") setStatus(t("session.status_writing_scene"));
|
||||
else setStatus(msg);
|
||||
} else if (eventName === "step_complete") {
|
||||
const stepMsg: Message = {
|
||||
id: data.message_id || `intro-${Date.now()}`,
|
||||
seq: data.seq || 1,
|
||||
role: "assistant",
|
||||
kind: "narrative_step",
|
||||
content: data.narrative || "",
|
||||
payload: { options: data.options || [], world_time: data.world_time, kind: "intro" },
|
||||
is_pinned: true,
|
||||
hidden: false,
|
||||
created_at: new Date().toISOString(),
|
||||
};
|
||||
setMessages((prev) => [...prev, stepMsg]);
|
||||
if (world && data.world_time) {
|
||||
setWorld({ ...world, current_time: data.world_time, state: data.state || world.state });
|
||||
}
|
||||
} else if (eventName === "error") {
|
||||
setError(data.message || t("session.error_iter"));
|
||||
}
|
||||
},
|
||||
onclose() {
|
||||
setIterating(false);
|
||||
setStatus("");
|
||||
},
|
||||
onerror(err) {
|
||||
setIterating(false);
|
||||
setStatus("");
|
||||
setError(String(err) || t("session.error_iter"));
|
||||
throw err;
|
||||
},
|
||||
});
|
||||
} catch (err: any) {
|
||||
setIterating(false);
|
||||
setStatus("");
|
||||
setError(err.message || t("session.error_iter"));
|
||||
}
|
||||
};
|
||||
|
||||
const runIteration = async (overrideAction?: string) => {
|
||||
if (!id || iterating) return;
|
||||
const rawAction = overrideAction ?? actionText;
|
||||
if (!rawAction.trim()) return;
|
||||
setError("");
|
||||
setLastFailedAction(null);
|
||||
setIterating(true);
|
||||
setStatus(t("session.status_planning"));
|
||||
const action = actionText.trim();
|
||||
setActionText("");
|
||||
const action = rawAction.trim();
|
||||
if (overrideAction === undefined) setActionText("");
|
||||
|
||||
// Optimistic: show user action immediately
|
||||
const optimisticUserMsg: Message = {
|
||||
id: `tmp-${Date.now()}`,
|
||||
seq: messages.length + 1,
|
||||
role: "user",
|
||||
kind: "player_action",
|
||||
content: action,
|
||||
payload: {},
|
||||
is_pinned: true,
|
||||
hidden: false,
|
||||
created_at: new Date().toISOString(),
|
||||
};
|
||||
setMessages((prev) => [...prev, optimisticUserMsg]);
|
||||
// Optimistic: show user action immediately (skip on retry if already shown)
|
||||
const alreadyShown = messages.some(
|
||||
(m) => m.kind === "player_action" && m.content === action && m.id?.startsWith("tmp-")
|
||||
);
|
||||
if (!alreadyShown) {
|
||||
const optimisticUserMsg: Message = {
|
||||
id: `tmp-${Date.now()}`,
|
||||
seq: messages.length + 1,
|
||||
role: "user",
|
||||
kind: "player_action",
|
||||
content: action,
|
||||
payload: {},
|
||||
is_pinned: true,
|
||||
hidden: false,
|
||||
created_at: new Date().toISOString(),
|
||||
};
|
||||
setMessages((prev) => [...prev, optimisticUserMsg]);
|
||||
}
|
||||
|
||||
try {
|
||||
await fetchEventSource(`/api/sessions/${id}/iterate`, {
|
||||
@@ -132,6 +211,7 @@ export function SessionPage() {
|
||||
}
|
||||
} else if (eventName === "error") {
|
||||
setError(data.message || t("session.error_iter"));
|
||||
setLastFailedAction(action);
|
||||
} else if (eventName === "done") {
|
||||
// Final reload to get fresh seq/order
|
||||
load();
|
||||
@@ -147,6 +227,7 @@ export function SessionPage() {
|
||||
setIterating(false);
|
||||
setStatus("");
|
||||
setError(String(err) || t("session.error_iter"));
|
||||
setLastFailedAction(action);
|
||||
throw err; // stop retry
|
||||
},
|
||||
});
|
||||
@@ -237,27 +318,45 @@ export function SessionPage() {
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<Textarea
|
||||
value={actionText}
|
||||
onChange={(e) => setActionText(e.target.value)}
|
||||
placeholder={t("session.action_placeholder")}
|
||||
disabled={iterating}
|
||||
rows={2}
|
||||
className="min-h-[44px]"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
|
||||
e.preventDefault();
|
||||
runIteration();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button onClick={runIteration} disabled={iterating || !actionText.trim()} className="self-end">
|
||||
<Send size={14} className="mr-1" />
|
||||
{iterating ? t("session.sending") : t("session.send")}
|
||||
</Button>
|
||||
</div>
|
||||
{error && <p className="text-xs text-red-400">{error}</p>}
|
||||
{lastFailedAction ? (
|
||||
<div className="flex gap-2 items-center">
|
||||
<div className="flex-1 text-sm text-red-300 bg-red-950/40 border border-red-800/60 rounded-lg px-3 py-2">
|
||||
{error || t("session.error_iter")}
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => runIteration(lastFailedAction)}
|
||||
disabled={iterating}
|
||||
className="self-end"
|
||||
>
|
||||
<Loader2 className={iterating ? "animate-spin mr-1" : "hidden"} size={14} />
|
||||
{!iterating && <RefreshCw size={14} className="mr-1" />}
|
||||
{iterating ? t("session.sending") : t("session.retry")}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex gap-2">
|
||||
<Textarea
|
||||
value={actionText}
|
||||
onChange={(e) => setActionText(e.target.value)}
|
||||
placeholder={t("session.action_placeholder")}
|
||||
disabled={iterating}
|
||||
rows={2}
|
||||
className="min-h-[44px]"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
|
||||
e.preventDefault();
|
||||
runIteration();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button onClick={() => runIteration()} disabled={iterating || !actionText.trim()} className="self-end">
|
||||
<Send size={14} className="mr-1" />
|
||||
{iterating ? t("session.sending") : t("session.send")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{error && !lastFailedAction && <p className="text-xs text-red-400">{error}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,29 +1,54 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import axios from "axios";
|
||||
import { worldsApi, sessionsApi } from "@/api";
|
||||
import { useAuthStore } from "@/store/auth";
|
||||
import type { World } from "@/types";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Input, Textarea } from "@/components/ui/Input";
|
||||
import { Card, CardBody, CardHeader } from "@/components/ui/Card";
|
||||
import { Play } from "lucide-react";
|
||||
import { Play, Save, RotateCcw, Send, Sparkles, Loader2 } from "lucide-react";
|
||||
|
||||
interface EditorChatMessage {
|
||||
role: "user" | "assistant";
|
||||
text: string;
|
||||
}
|
||||
|
||||
interface ChatReply {
|
||||
ai_message: string;
|
||||
definition: Record<string, any> | null;
|
||||
changed: boolean;
|
||||
}
|
||||
|
||||
export function WorldEditPage() {
|
||||
const { t } = useTranslation();
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { token } = useAuthStore();
|
||||
|
||||
const [world, setWorld] = useState<World | null>(null);
|
||||
const [name, setName] = useState("");
|
||||
const [definitionText, setDefinitionText] = useState("");
|
||||
const [stateText, setStateText] = useState("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
// AI chat state
|
||||
const [chatMessages, setChatMessages] = useState<EditorChatMessage[]>([]);
|
||||
const [chatInput, setChatInput] = useState("");
|
||||
const [chatLoading, setChatLoading] = useState(false);
|
||||
const [pendingDefinition, setPendingDefinition] = useState<Record<string, any> | null>(null);
|
||||
const chatScrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
if (!id) return;
|
||||
try {
|
||||
const w = await worldsApi.get(id);
|
||||
setWorld(w);
|
||||
setName(w.name);
|
||||
setDefinitionText(JSON.stringify(w.definition, null, 2));
|
||||
setStateText(JSON.stringify(w.state, null, 2));
|
||||
} catch (err: any) {
|
||||
@@ -34,6 +59,10 @@ export function WorldEditPage() {
|
||||
})();
|
||||
}, [id]);
|
||||
|
||||
useEffect(() => {
|
||||
chatScrollRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
}, [chatMessages, chatLoading]);
|
||||
|
||||
const save = async () => {
|
||||
if (!id || !world) return;
|
||||
setError("");
|
||||
@@ -42,14 +71,18 @@ export function WorldEditPage() {
|
||||
const definition = JSON.parse(definitionText);
|
||||
const state = JSON.parse(stateText);
|
||||
const updated = await worldsApi.update(id, {
|
||||
name,
|
||||
definition,
|
||||
state,
|
||||
current_time: world.current_time,
|
||||
status: world.status === "draft" ? "ready" : world.status,
|
||||
});
|
||||
setWorld(updated);
|
||||
// Reset chat (definition changed -> stale context)
|
||||
setChatMessages([]);
|
||||
setPendingDefinition(null);
|
||||
} catch (err: any) {
|
||||
setError(err.message || t("errors.unknown"));
|
||||
setError(err.message || err.response?.data?.detail || t("errors.unknown"));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
@@ -65,13 +98,58 @@ export function WorldEditPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const sendChatMessage = async () => {
|
||||
if (!id || !chatInput.trim() || chatLoading) return;
|
||||
const msg = chatInput.trim();
|
||||
setChatInput("");
|
||||
setChatMessages((prev) => [...prev, { role: "user", text: msg }]);
|
||||
setChatLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const { data } = await axios.post<ChatReply>(
|
||||
`/api/worlds/${id}/chat`,
|
||||
{ message: msg },
|
||||
{ headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` } }
|
||||
);
|
||||
setChatMessages((prev) => [...prev, { role: "assistant", text: data.ai_message }]);
|
||||
if (data.definition) {
|
||||
setPendingDefinition(data.definition);
|
||||
setDefinitionText(JSON.stringify(data.definition, null, 2));
|
||||
}
|
||||
} catch (err: any) {
|
||||
const detail = err.response?.data?.detail || err.message || t("errors.unknown");
|
||||
setChatMessages((prev) => [...prev, { role: "assistant", text: `⚠️ ${detail}` }]);
|
||||
} finally {
|
||||
setChatLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const resetChat = async () => {
|
||||
if (!id) return;
|
||||
try {
|
||||
await axios.post(`/api/worlds/${id}/chat/reset`, {}, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
setChatMessages([]);
|
||||
setPendingDefinition(null);
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.detail || t("errors.unknown"));
|
||||
}
|
||||
};
|
||||
|
||||
const applyPendingDefinition = () => {
|
||||
if (!pendingDefinition) return;
|
||||
setDefinitionText(JSON.stringify(pendingDefinition, null, 2));
|
||||
setPendingDefinition(null);
|
||||
};
|
||||
|
||||
if (loading) return <div className="p-8 text-center text-ink-400">{t("common.loading")}</div>;
|
||||
if (!world) return <div className="p-8 text-center text-red-400">{error || t("common.not_found")}</div>;
|
||||
|
||||
return (
|
||||
<div className="max-w-5xl mx-auto px-4 py-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h1 className="text-xl font-serif text-ink-100">
|
||||
<div className="max-w-7xl mx-auto px-4 py-6">
|
||||
<div className="flex items-center justify-between mb-4 gap-2">
|
||||
<h1 className="text-xl font-serif text-ink-100 flex-1 min-w-0 truncate">
|
||||
{t("worlds.edit")}: {world.name}
|
||||
</h1>
|
||||
<div className="flex gap-2">
|
||||
@@ -87,29 +165,131 @@ export function WorldEditPage() {
|
||||
|
||||
{error && <p className="text-sm text-red-400 mb-4">{error}</p>}
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
<Card>
|
||||
<CardHeader title="World definition (JSON)" subtitle="setting, rules, schema, plot_rails, initial_state" />
|
||||
<CardBody>
|
||||
<textarea
|
||||
value={definitionText}
|
||||
onChange={(e) => setDefinitionText(e.target.value)}
|
||||
className="w-full h-[60vh] px-3 py-2 rounded bg-ink-950 border border-ink-700 text-ink-100 font-mono text-xs"
|
||||
spellCheck={false}
|
||||
/>
|
||||
</CardBody>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader title="Live world state (JSON)" subtitle="current player/NPC/inventory/time" />
|
||||
<CardBody>
|
||||
<textarea
|
||||
value={stateText}
|
||||
onChange={(e) => setStateText(e.target.value)}
|
||||
className="w-full h-[60vh] px-3 py-2 rounded bg-ink-950 border border-ink-700 text-ink-100 font-mono text-xs"
|
||||
spellCheck={false}
|
||||
{/* World name row */}
|
||||
<Card className="mb-4">
|
||||
<CardHeader title={t("worlds.name")} />
|
||||
<CardBody>
|
||||
<div className="flex gap-2 items-center">
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button onClick={save} disabled={saving || !name.trim()}>
|
||||
{saving ? <Loader2 size={14} className="animate-spin mr-1" /> : <Save size={14} className="mr-1" />}
|
||||
{saving ? t("common.loading") : t("common.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
||||
{/* AI chat panel (1 col) */}
|
||||
<Card className="lg:col-span-1 flex flex-col">
|
||||
<CardHeader
|
||||
title={
|
||||
<span className="flex items-center gap-2">
|
||||
<Sparkles size={14} className="text-accent-400" />
|
||||
{t("worlds.editor_chat_title")}
|
||||
</span> as any
|
||||
}
|
||||
subtitle={t("worlds.editor_chat_desc")}
|
||||
/>
|
||||
<CardBody className="flex-1 flex flex-col min-h-0">
|
||||
<div className="flex-1 overflow-y-auto space-y-3 mb-3 max-h-[55vh]">
|
||||
{chatMessages.length === 0 && (
|
||||
<div className="text-xs text-ink-500 italic text-center py-6">
|
||||
{t("worlds.editor_chat_empty")}
|
||||
</div>
|
||||
)}
|
||||
{chatMessages.map((m, i) => (
|
||||
<div key={i} className={`flex ${m.role === "user" ? "justify-end" : "justify-start"}`}>
|
||||
<div
|
||||
className={`max-w-[90%] rounded-xl p-2.5 text-xs whitespace-pre-wrap ${
|
||||
m.role === "user"
|
||||
? "bg-accent-500/20 border border-accent-500/40 text-ink-100"
|
||||
: "bg-ink-800 border border-ink-700 text-ink-100"
|
||||
}`}
|
||||
>
|
||||
<div className="text-[10px] text-ink-400 mb-1">
|
||||
{m.role === "user" ? t("worlds.editor_you") : "AI"}
|
||||
</div>
|
||||
{m.text}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{chatLoading && (
|
||||
<div className="flex justify-start">
|
||||
<div className="bg-ink-800 border border-ink-700 rounded-xl p-2.5 text-ink-400 text-xs pulse-soft flex items-center gap-2">
|
||||
<Loader2 size={12} className="animate-spin" />
|
||||
{t("common.loading")}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div ref={chatScrollRef} />
|
||||
</div>
|
||||
|
||||
{pendingDefinition && (
|
||||
<div className="mb-3 p-2 rounded-lg bg-accent-500/10 border border-accent-500/40 text-xs text-accent-200 flex items-center justify-between gap-2">
|
||||
<span>{t("worlds.editor_pending_defn")}</span>
|
||||
<Button size="sm" variant="secondary" onClick={applyPendingDefinition}>
|
||||
{t("worlds.editor_apply")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Textarea
|
||||
value={chatInput}
|
||||
onChange={(e) => setChatInput(e.target.value)}
|
||||
placeholder={t("worlds.editor_chat_ph")}
|
||||
disabled={chatLoading}
|
||||
rows={2}
|
||||
className="min-h-[60px] flex-1"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
|
||||
e.preventDefault();
|
||||
sendChatMessage();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="flex flex-col gap-1">
|
||||
<Button size="sm" onClick={sendChatMessage} disabled={chatLoading || !chatInput.trim()}>
|
||||
<Send size={14} />
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={resetChat} disabled={chatLoading} title={t("worlds.editor_reset")}>
|
||||
<RotateCcw size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
{/* JSON editors (2 cols) */}
|
||||
<div className="lg:col-span-2 grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Card>
|
||||
<CardHeader title="World definition (JSON)" subtitle="setting, rules, schema, plot_rails, initial_state" />
|
||||
<CardBody>
|
||||
<textarea
|
||||
value={definitionText}
|
||||
onChange={(e) => setDefinitionText(e.target.value)}
|
||||
className="w-full h-[55vh] px-3 py-2 rounded bg-ink-950 border border-ink-700 text-ink-100 font-mono text-xs"
|
||||
spellCheck={false}
|
||||
/>
|
||||
</CardBody>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader title="Live world state (JSON)" subtitle="current player/NPC/inventory/time" />
|
||||
<CardBody>
|
||||
<textarea
|
||||
value={stateText}
|
||||
onChange={(e) => setStateText(e.target.value)}
|
||||
className="w-full h-[55vh] px-3 py-2 rounded bg-ink-950 border border-ink-700 text-ink-100 font-mono text-xs"
|
||||
spellCheck={false}
|
||||
/>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex justify-end gap-2">
|
||||
|
||||
Reference in New Issue
Block a user