fix: pin Writing toolbar and Entities header, fix Entities form alignment and red hint text, and make Settings category bar horizontal
This commit is contained in:
@@ -20,7 +20,7 @@ import {
|
|||||||
} from "@pubscript/ui";
|
} from "@pubscript/ui";
|
||||||
import type { ProviderKind, ThinkingEffort } from "@pubscript/ai";
|
import type { ProviderKind, ThinkingEffort } from "@pubscript/ai";
|
||||||
|
|
||||||
type SettingsTab = "appearance" | "writing" | "ai" | "server";
|
type SettingsTab = "appearance" | "writing" | "providers" | "agents" | "server";
|
||||||
|
|
||||||
const ACCENT_PRESETS = [
|
const ACCENT_PRESETS = [
|
||||||
"#7c8bf5",
|
"#7c8bf5",
|
||||||
@@ -290,14 +290,36 @@ export function Settings() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="ps-content ps-page-container" aria-label="Settings">
|
<section className="ps-content ps-page-container" aria-label="Settings">
|
||||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 14, flexWrap: "wrap", gap: 10 }}>
|
<div
|
||||||
<h2 style={{ margin: 0 }}>Settings</h2>
|
style={{
|
||||||
<div className="ps-tabs" role="tablist" aria-label="Settings Categories" style={{ display: "flex", gap: 6, flexWrap: "wrap" }}>
|
position: "sticky",
|
||||||
|
top: 0,
|
||||||
|
zIndex: 20,
|
||||||
|
background: "var(--ps-bg)",
|
||||||
|
paddingBottom: 12,
|
||||||
|
marginBottom: 20,
|
||||||
|
borderBottom: "1px solid var(--ps-border)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h2 style={{ margin: "0 0 12px 0" }}>Settings</h2>
|
||||||
|
<div
|
||||||
|
role="tablist"
|
||||||
|
aria-label="Settings Categories"
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 8,
|
||||||
|
flexWrap: "wrap",
|
||||||
|
width: "100%",
|
||||||
|
}}
|
||||||
|
>
|
||||||
{(
|
{(
|
||||||
[
|
[
|
||||||
{ id: "appearance", label: "🎨 Appearance & Themes" },
|
{ id: "appearance", label: "🎨 Appearance & Themes" },
|
||||||
{ id: "writing", label: "📁 Projects & Writing" },
|
{ id: "writing", label: "📁 Projects & Writing" },
|
||||||
{ id: "ai", label: "✨ AI Providers & Agents" },
|
{ id: "providers", label: "🔌 AI Providers" },
|
||||||
|
{ id: "agents", label: "🤖 AI Agents" },
|
||||||
{ id: "server", label: "🖧 Server & System" },
|
{ id: "server", label: "🖧 Server & System" },
|
||||||
] as const
|
] as const
|
||||||
).map((tab) => {
|
).map((tab) => {
|
||||||
@@ -310,18 +332,22 @@ export function Settings() {
|
|||||||
aria-selected={isActive}
|
aria-selected={isActive}
|
||||||
onClick={() => setActiveTab(tab.id)}
|
onClick={() => setActiveTab(tab.id)}
|
||||||
style={{
|
style={{
|
||||||
padding: "7px 14px",
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 6,
|
||||||
|
padding: "8px 16px",
|
||||||
borderRadius: 8,
|
borderRadius: 8,
|
||||||
border: isActive
|
border: isActive
|
||||||
? "1.5px solid var(--ps-accent)"
|
? "1.5px solid var(--ps-accent)"
|
||||||
: "1px solid var(--ps-border)",
|
: "1px solid var(--ps-border)",
|
||||||
background: isActive
|
background: isActive
|
||||||
? "color-mix(in srgb, var(--ps-accent) 16%, var(--ps-surface-2))"
|
? "color-mix(in srgb, var(--ps-accent) 18%, var(--ps-surface-2))"
|
||||||
: "var(--ps-surface)",
|
: "var(--ps-surface)",
|
||||||
color: "var(--ps-text)",
|
color: "var(--ps-text)",
|
||||||
fontWeight: isActive ? 600 : 500,
|
fontWeight: isActive ? 600 : 500,
|
||||||
fontSize: 13,
|
fontSize: 13.5,
|
||||||
cursor: "pointer",
|
cursor: "pointer",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{tab.label}
|
{tab.label}
|
||||||
@@ -734,291 +760,292 @@ export function Settings() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* TAB 3: AI PROVIDERS & AGENTS */}
|
{/* TAB 3: AI PROVIDERS */}
|
||||||
{activeTab === "ai" && (
|
{activeTab === "providers" && (
|
||||||
<>
|
<div className="ps-settings-group">
|
||||||
<div className="ps-settings-group">
|
<h3>AI Providers & Connection</h3>
|
||||||
<h3>AI Providers & Connection</h3>
|
<p className="ps-settings-desc" style={{ marginBottom: 12 }}>
|
||||||
<p className="ps-settings-desc" style={{ marginBottom: 12 }}>
|
Configure local AI servers (Ollama, llama.cpp, LM Studio) or cloud endpoints (OpenAI, Anthropic, Google Gemini, OpenRouter).
|
||||||
Configure local AI servers (Ollama, llama.cpp, LM Studio) or cloud endpoints (OpenAI, Anthropic, Google Gemini, OpenRouter).
|
</p>
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="ps-ai-settings-card">
|
<div className="ps-ai-settings-card">
|
||||||
<div className="ps-ai-settings-card-head">
|
<div className="ps-ai-settings-card-head">
|
||||||
<div style={{ display: "flex", gap: 8, alignItems: "center", flexWrap: "wrap" }}>
|
<div style={{ display: "flex", gap: 8, alignItems: "center", flexWrap: "wrap" }}>
|
||||||
<Select
|
|
||||||
value={currentProvider.id}
|
|
||||||
onChange={(e) => {
|
|
||||||
const id = e.target.value;
|
|
||||||
setSelectedProviderId(id);
|
|
||||||
const found = s.aiProviders.find((p) => p.id === id);
|
|
||||||
if (found) {
|
|
||||||
set({
|
|
||||||
activeProviderId: id,
|
|
||||||
activeModel: found.defaultModel || found.models[0] || "",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
options={s.aiProviders.map((p) => ({
|
|
||||||
value: p.id,
|
|
||||||
label: `${p.name} (${p.kind})`,
|
|
||||||
}))}
|
|
||||||
/>
|
|
||||||
{s.activeProviderId === currentProvider.id ? (
|
|
||||||
<Badge variant="success">Active Provider</Badge>
|
|
||||||
) : (
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
size="sm"
|
|
||||||
onClick={() =>
|
|
||||||
set({
|
|
||||||
activeProviderId: currentProvider.id,
|
|
||||||
activeModel: currentProvider.defaultModel || currentProvider.models[0] || "",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
Set Active
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => void handleProbeModels()}
|
|
||||||
disabled={probing}
|
|
||||||
>
|
|
||||||
{probing ? "Detecting…" : "↻ Load / Detect Models"}
|
|
||||||
</Button>
|
|
||||||
<Button variant="secondary" size="sm" onClick={handleAddProvider}>
|
|
||||||
+ Add Provider
|
|
||||||
</Button>
|
|
||||||
{s.aiProviders.length > 1 && (
|
|
||||||
<Button
|
|
||||||
variant="danger"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => {
|
|
||||||
deleteProvider(currentProvider.id);
|
|
||||||
toast.info("Provider removed");
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Delete
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="ps-ai-settings-grid">
|
|
||||||
<Input
|
|
||||||
label="Provider Name"
|
|
||||||
value={currentProvider.name}
|
|
||||||
onChange={(e) => updateProvider(currentProvider.id, { name: e.target.value })}
|
|
||||||
/>
|
|
||||||
<Select
|
<Select
|
||||||
label="Provider API Type"
|
value={currentProvider.id}
|
||||||
value={currentProvider.kind}
|
|
||||||
onChange={(e) =>
|
|
||||||
updateProvider(currentProvider.id, { kind: e.target.value as ProviderKind })
|
|
||||||
}
|
|
||||||
options={[
|
|
||||||
{ value: "openai-compatible", label: "OpenAI-Compatible (Ollama / llama.cpp / LM Studio / OpenAI)" },
|
|
||||||
{ value: "anthropic", label: "Anthropic (Claude)" },
|
|
||||||
{ value: "google", label: "Google (Gemini)" },
|
|
||||||
{ value: "openrouter", label: "OpenRouter" },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
label="Base URL / Endpoint"
|
|
||||||
value={currentProvider.baseUrl}
|
|
||||||
placeholder="http://localhost:11434/v1"
|
|
||||||
onChange={(e) => updateProvider(currentProvider.id, { baseUrl: e.target.value })}
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
label="API Key (leave blank for local Ollama / llama.cpp)"
|
|
||||||
type="password"
|
|
||||||
value={currentProvider.apiKey}
|
|
||||||
placeholder="sk-..."
|
|
||||||
onChange={(e) => updateProvider(currentProvider.id, { apiKey: e.target.value })}
|
|
||||||
/>
|
|
||||||
<Select
|
|
||||||
label="Default Model"
|
|
||||||
value={currentProvider.defaultModel}
|
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const model = e.target.value;
|
const id = e.target.value;
|
||||||
updateProvider(currentProvider.id, { defaultModel: model });
|
setSelectedProviderId(id);
|
||||||
if (s.activeProviderId === currentProvider.id) {
|
const found = s.aiProviders.find((p) => p.id === id);
|
||||||
set({ activeModel: model });
|
if (found) {
|
||||||
}
|
set({
|
||||||
}}
|
activeProviderId: id,
|
||||||
options={currentProvider.models.map((m) => ({ value: m, label: m }))}
|
activeModel: found.defaultModel || found.models[0] || "",
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
label="Custom Model ID (comma-separated list)"
|
|
||||||
value={currentProvider.models.join(", ")}
|
|
||||||
onChange={(e) => {
|
|
||||||
const list = e.target.value
|
|
||||||
.split(",")
|
|
||||||
.map((x) => x.trim())
|
|
||||||
.filter(Boolean);
|
|
||||||
if (list.length > 0) {
|
|
||||||
updateProvider(currentProvider.id, {
|
|
||||||
models: list,
|
|
||||||
defaultModel: list.includes(currentProvider.defaultModel)
|
|
||||||
? currentProvider.defaultModel
|
|
||||||
: list[0]!,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
options={s.aiProviders.map((p) => ({
|
||||||
|
value: p.id,
|
||||||
|
label: `${p.name} (${p.kind})`,
|
||||||
|
}))}
|
||||||
/>
|
/>
|
||||||
</div>
|
{s.activeProviderId === currentProvider.id ? (
|
||||||
</div>
|
<Badge variant="success">Active Provider</Badge>
|
||||||
</div>
|
) : (
|
||||||
|
<Button
|
||||||
<div className="ps-settings-group">
|
variant="secondary"
|
||||||
<h3>AI Agents & Role Prompts</h3>
|
size="sm"
|
||||||
<p className="ps-settings-desc" style={{ marginBottom: 12 }}>
|
onClick={() =>
|
||||||
Customize temperature, context window (ctx), max output tokens, reasoning effort, and system prompts for each AI co-writing agent.
|
set({
|
||||||
</p>
|
activeProviderId: currentProvider.id,
|
||||||
|
activeModel: currentProvider.defaultModel || currentProvider.models[0] || "",
|
||||||
<div className="ps-ai-settings-card">
|
|
||||||
<div className="ps-ai-settings-card-head">
|
|
||||||
<div style={{ display: "flex", gap: 8, alignItems: "center", flexWrap: "wrap" }}>
|
|
||||||
<Select
|
|
||||||
value={currentAgent.id}
|
|
||||||
onChange={(e) => {
|
|
||||||
const id = e.target.value;
|
|
||||||
setSelectedAgentId(id);
|
|
||||||
set({ activeAgentId: id });
|
|
||||||
}}
|
|
||||||
options={s.aiAgents.map((a) => ({
|
|
||||||
value: a.id,
|
|
||||||
label: `${a.name} (${a.role})`,
|
|
||||||
}))}
|
|
||||||
/>
|
|
||||||
<Badge variant="accent">{`Temp: ${currentAgent.temperature}`}</Badge>
|
|
||||||
<Badge variant="neutral">{`Ctx: ${currentAgent.contextWindow.toLocaleString()} tok`}</Badge>
|
|
||||||
</div>
|
|
||||||
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
|
|
||||||
{DEFAULT_AI_AGENTS.some((a) => a.id === currentAgent.id) && (
|
|
||||||
<Button variant="secondary" size="sm" onClick={handleResetAgentPrompt}>
|
|
||||||
Reset Defaults
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<Button variant="secondary" size="sm" onClick={handleAddAgent}>
|
|
||||||
+ New Agent
|
|
||||||
</Button>
|
|
||||||
{!DEFAULT_AI_AGENTS.some((a) => a.id === currentAgent.id) && (
|
|
||||||
<Button
|
|
||||||
variant="danger"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => {
|
|
||||||
deleteAgent(currentAgent.id);
|
|
||||||
toast.info("Agent deleted");
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Delete Agent
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="ps-ai-settings-grid">
|
|
||||||
<Input
|
|
||||||
label="Agent Name"
|
|
||||||
value={currentAgent.name}
|
|
||||||
onChange={(e) => updateAgent(currentAgent.id, { name: e.target.value })}
|
|
||||||
/>
|
|
||||||
<div className="ps-field">
|
|
||||||
<label className="ps-field-label">
|
|
||||||
Temperature ({currentAgent.temperature.toFixed(2)})
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="range"
|
|
||||||
min="0"
|
|
||||||
max="2"
|
|
||||||
step="0.05"
|
|
||||||
value={currentAgent.temperature}
|
|
||||||
onChange={(e) =>
|
|
||||||
updateAgent(currentAgent.id, { temperature: Number(e.target.value) })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<span className="ps-field-hint">
|
|
||||||
Lower (0.2) = precise editing · Higher (0.8+) = creative brainstorming
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<Select
|
|
||||||
label="Context Window (ctx tokens)"
|
|
||||||
value={String(currentAgent.contextWindow)}
|
|
||||||
onChange={(e) =>
|
|
||||||
updateAgent(currentAgent.id, { contextWindow: Number(e.target.value) })
|
|
||||||
}
|
|
||||||
options={[
|
|
||||||
{ value: "4096", label: "4,096 tokens (4K)" },
|
|
||||||
{ value: "8192", label: "8,192 tokens (8K)" },
|
|
||||||
{ value: "16384", label: "16,384 tokens (16K)" },
|
|
||||||
{ value: "32768", label: "32,768 tokens (32K)" },
|
|
||||||
{ value: "65536", label: "65,536 tokens (64K)" },
|
|
||||||
{ value: "131072", label: "131,072 tokens (128K)" },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
<Select
|
|
||||||
label="Max Output Tokens"
|
|
||||||
value={String(currentAgent.maxTokens)}
|
|
||||||
onChange={(e) =>
|
|
||||||
updateAgent(currentAgent.id, { maxTokens: Number(e.target.value) })
|
|
||||||
}
|
|
||||||
options={[
|
|
||||||
{ value: "512", label: "512 tokens" },
|
|
||||||
{ value: "1024", label: "1,024 tokens" },
|
|
||||||
{ value: "2048", label: "2,048 tokens" },
|
|
||||||
{ value: "4096", label: "4,096 tokens" },
|
|
||||||
{ value: "8192", label: "8,192 tokens" },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
<div className="ps-field">
|
|
||||||
<label className="ps-field-label">Extended Thinking / Reasoning</label>
|
|
||||||
<label style={{ display: "flex", alignItems: "center", gap: 8, paddingTop: 6 }}>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={currentAgent.thinkingEnabled}
|
|
||||||
onChange={(e) =>
|
|
||||||
updateAgent(currentAgent.id, { thinkingEnabled: e.target.checked })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<span>Enable reasoning tokens for supported models</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
{currentAgent.thinkingEnabled && (
|
|
||||||
<Select
|
|
||||||
label="Reasoning Effort"
|
|
||||||
value={currentAgent.thinkingEffort}
|
|
||||||
onChange={(e) =>
|
|
||||||
updateAgent(currentAgent.id, {
|
|
||||||
thinkingEffort: e.target.value as ThinkingEffort,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
options={[
|
>
|
||||||
{ value: "low", label: "Low" },
|
Set Active
|
||||||
{ value: "medium", label: "Medium" },
|
</Button>
|
||||||
{ value: "high", label: "High" },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
|
||||||
<div className="ps-field" style={{ marginTop: 12, marginBottom: 0 }}>
|
<Button
|
||||||
<label className="ps-field-label">System Prompt & Instructions</label>
|
variant="secondary"
|
||||||
<textarea
|
size="sm"
|
||||||
className="ps-input"
|
onClick={() => void handleProbeModels()}
|
||||||
rows={5}
|
disabled={probing}
|
||||||
value={currentAgent.systemPrompt}
|
>
|
||||||
onChange={(e) => updateAgent(currentAgent.id, { systemPrompt: e.target.value })}
|
{probing ? "Detecting…" : "↻ Load / Detect Models"}
|
||||||
placeholder="System instructions for this agent role…"
|
</Button>
|
||||||
/>
|
<Button variant="secondary" size="sm" onClick={handleAddProvider}>
|
||||||
|
+ Add Provider
|
||||||
|
</Button>
|
||||||
|
{s.aiProviders.length > 1 && (
|
||||||
|
<Button
|
||||||
|
variant="danger"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
deleteProvider(currentProvider.id);
|
||||||
|
toast.info("Provider removed");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="ps-ai-settings-grid">
|
||||||
|
<Input
|
||||||
|
label="Provider Name"
|
||||||
|
value={currentProvider.name}
|
||||||
|
onChange={(e) => updateProvider(currentProvider.id, { name: e.target.value })}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
label="Provider API Type"
|
||||||
|
value={currentProvider.kind}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateProvider(currentProvider.id, { kind: e.target.value as ProviderKind })
|
||||||
|
}
|
||||||
|
options={[
|
||||||
|
{ value: "openai-compatible", label: "OpenAI-Compatible (Ollama / llama.cpp / LM Studio / OpenAI)" },
|
||||||
|
{ value: "anthropic", label: "Anthropic (Claude)" },
|
||||||
|
{ value: "google", label: "Google (Gemini)" },
|
||||||
|
{ value: "openrouter", label: "OpenRouter" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Base URL / Endpoint"
|
||||||
|
value={currentProvider.baseUrl}
|
||||||
|
placeholder="http://localhost:11434/v1"
|
||||||
|
onChange={(e) => updateProvider(currentProvider.id, { baseUrl: e.target.value })}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="API Key (leave blank for local Ollama / llama.cpp)"
|
||||||
|
type="password"
|
||||||
|
value={currentProvider.apiKey}
|
||||||
|
placeholder="sk-..."
|
||||||
|
onChange={(e) => updateProvider(currentProvider.id, { apiKey: e.target.value })}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
label="Default Model"
|
||||||
|
value={currentProvider.defaultModel}
|
||||||
|
onChange={(e) => {
|
||||||
|
const model = e.target.value;
|
||||||
|
updateProvider(currentProvider.id, { defaultModel: model });
|
||||||
|
if (s.activeProviderId === currentProvider.id) {
|
||||||
|
set({ activeModel: model });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
options={currentProvider.models.map((m) => ({ value: m, label: m }))}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Custom Model ID (comma-separated list)"
|
||||||
|
value={currentProvider.models.join(", ")}
|
||||||
|
onChange={(e) => {
|
||||||
|
const list = e.target.value
|
||||||
|
.split(",")
|
||||||
|
.map((x) => x.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
if (list.length > 0) {
|
||||||
|
updateProvider(currentProvider.id, {
|
||||||
|
models: list,
|
||||||
|
defaultModel: list.includes(currentProvider.defaultModel)
|
||||||
|
? currentProvider.defaultModel
|
||||||
|
: list[0]!,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* TAB 4: AI AGENTS */}
|
||||||
|
{activeTab === "agents" && (
|
||||||
|
<div className="ps-settings-group">
|
||||||
|
<h3>AI Agents & Role Prompts</h3>
|
||||||
|
<p className="ps-settings-desc" style={{ marginBottom: 12 }}>
|
||||||
|
Customize temperature, context window (ctx), max output tokens, reasoning effort, and system prompts for each AI co-writing agent.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="ps-ai-settings-card">
|
||||||
|
<div className="ps-ai-settings-card-head">
|
||||||
|
<div style={{ display: "flex", gap: 8, alignItems: "center", flexWrap: "wrap" }}>
|
||||||
|
<Select
|
||||||
|
value={currentAgent.id}
|
||||||
|
onChange={(e) => {
|
||||||
|
const id = e.target.value;
|
||||||
|
setSelectedAgentId(id);
|
||||||
|
set({ activeAgentId: id });
|
||||||
|
}}
|
||||||
|
options={s.aiAgents.map((a) => ({
|
||||||
|
value: a.id,
|
||||||
|
label: `${a.name} (${a.role})`,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
<Badge variant="accent">{`Temp: ${currentAgent.temperature}`}</Badge>
|
||||||
|
<Badge variant="neutral">{`Ctx: ${currentAgent.contextWindow.toLocaleString()} tok`}</Badge>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
|
||||||
|
{DEFAULT_AI_AGENTS.some((a) => a.id === currentAgent.id) && (
|
||||||
|
<Button variant="secondary" size="sm" onClick={handleResetAgentPrompt}>
|
||||||
|
Reset Defaults
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button variant="secondary" size="sm" onClick={handleAddAgent}>
|
||||||
|
+ New Agent
|
||||||
|
</Button>
|
||||||
|
{!DEFAULT_AI_AGENTS.some((a) => a.id === currentAgent.id) && (
|
||||||
|
<Button
|
||||||
|
variant="danger"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
deleteAgent(currentAgent.id);
|
||||||
|
toast.info("Agent deleted");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Delete Agent
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="ps-ai-settings-grid">
|
||||||
|
<Input
|
||||||
|
label="Agent Name"
|
||||||
|
value={currentAgent.name}
|
||||||
|
onChange={(e) => updateAgent(currentAgent.id, { name: e.target.value })}
|
||||||
|
/>
|
||||||
|
<div className="ps-field">
|
||||||
|
<label className="ps-field-label">
|
||||||
|
Temperature ({currentAgent.temperature.toFixed(2)})
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="0"
|
||||||
|
max="2"
|
||||||
|
step="0.05"
|
||||||
|
value={currentAgent.temperature}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateAgent(currentAgent.id, { temperature: Number(e.target.value) })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<span className="ps-field-hint">
|
||||||
|
Lower (0.2) = precise editing · Higher (0.8+) = creative brainstorming
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Select
|
||||||
|
label="Context Window (ctx tokens)"
|
||||||
|
value={String(currentAgent.contextWindow)}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateAgent(currentAgent.id, { contextWindow: Number(e.target.value) })
|
||||||
|
}
|
||||||
|
options={[
|
||||||
|
{ value: "4096", label: "4,096 tokens (4K)" },
|
||||||
|
{ value: "8192", label: "8,192 tokens (8K)" },
|
||||||
|
{ value: "16384", label: "16,384 tokens (16K)" },
|
||||||
|
{ value: "32768", label: "32,768 tokens (32K)" },
|
||||||
|
{ value: "65536", label: "65,536 tokens (64K)" },
|
||||||
|
{ value: "131072", label: "131,072 tokens (128K)" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
label="Max Output Tokens"
|
||||||
|
value={String(currentAgent.maxTokens)}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateAgent(currentAgent.id, { maxTokens: Number(e.target.value) })
|
||||||
|
}
|
||||||
|
options={[
|
||||||
|
{ value: "512", label: "512 tokens" },
|
||||||
|
{ value: "1024", label: "1,024 tokens" },
|
||||||
|
{ value: "2048", label: "2,048 tokens" },
|
||||||
|
{ value: "4096", label: "4,096 tokens" },
|
||||||
|
{ value: "8192", label: "8,192 tokens" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<div className="ps-field">
|
||||||
|
<label className="ps-field-label">Extended Thinking / Reasoning</label>
|
||||||
|
<label style={{ display: "flex", alignItems: "center", gap: 8, paddingTop: 6 }}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={currentAgent.thinkingEnabled}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateAgent(currentAgent.id, { thinkingEnabled: e.target.checked })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<span>Enable reasoning tokens for supported models</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{currentAgent.thinkingEnabled && (
|
||||||
|
<Select
|
||||||
|
label="Reasoning Effort"
|
||||||
|
value={currentAgent.thinkingEffort}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateAgent(currentAgent.id, {
|
||||||
|
thinkingEffort: e.target.value as ThinkingEffort,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
options={[
|
||||||
|
{ value: "low", label: "Low" },
|
||||||
|
{ value: "medium", label: "Medium" },
|
||||||
|
{ value: "high", label: "High" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="ps-field" style={{ marginTop: 12, marginBottom: 0 }}>
|
||||||
|
<label className="ps-field-label">System Prompt & Instructions</label>
|
||||||
|
<textarea
|
||||||
|
className="ps-input"
|
||||||
|
rows={5}
|
||||||
|
value={currentAgent.systemPrompt}
|
||||||
|
onChange={(e) => updateAgent(currentAgent.id, { systemPrompt: e.target.value })}
|
||||||
|
placeholder="System instructions for this agent role…"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* TAB 4: SERVER & SYSTEM */}
|
{/* TAB 4: SERVER & SYSTEM */}
|
||||||
|
|||||||
@@ -127,138 +127,144 @@ export function EntitiesView() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="ps-entities-view" style={{ padding: "20px 24px", overflowY: "auto" }}>
|
<div className="ps-entities-view">
|
||||||
<div className="ps-entity-toolbar">
|
<div className="ps-entities-pinned-header">
|
||||||
<div className="ps-entity-tabs">
|
<div className="ps-entity-toolbar">
|
||||||
{TABS.map((t) => (
|
<div className="ps-entity-tabs">
|
||||||
<div key={t.id} className={"ps-entity-tab-wrap" + (t.id === tab ? " active" : "")}>
|
{TABS.map((t) => (
|
||||||
<button
|
<div key={t.id} className={"ps-entity-tab-wrap" + (t.id === tab ? " active" : "")}>
|
||||||
type="button"
|
|
||||||
className={"ps-entity-tab" + (t.id === tab ? " active" : "")}
|
|
||||||
onClick={() => setTab(t.id)}
|
|
||||||
>
|
|
||||||
{t.label}
|
|
||||||
</button>
|
|
||||||
{t.createKind && (
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="ps-entity-tab-add"
|
className={"ps-entity-tab" + (t.id === tab ? " active" : "")}
|
||||||
title={t.createLabel ?? `Add to ${t.label}`}
|
onClick={() => setTab(t.id)}
|
||||||
aria-label={t.createLabel ?? `Add to ${t.label}`}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
void handleCreateKind(t.createKind!, t.id);
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
+
|
{t.label}
|
||||||
</button>
|
</button>
|
||||||
)}
|
{t.createKind && (
|
||||||
</div>
|
<button
|
||||||
))}
|
type="button"
|
||||||
</div>
|
className="ps-entity-tab-add"
|
||||||
|
title={t.createLabel ?? `Add to ${t.label}`}
|
||||||
<div className="ps-entity-new-group">
|
aria-label={t.createLabel ?? `Add to ${t.label}`}
|
||||||
{activeTabDef.createKind ? (
|
onClick={(e) => {
|
||||||
<Button
|
e.stopPropagation();
|
||||||
variant="primary"
|
void handleCreateKind(t.createKind!, t.id);
|
||||||
size="sm"
|
}}
|
||||||
onClick={() => void handleCreateKind(activeTabDef.createKind!)}
|
>
|
||||||
>
|
+
|
||||||
{activeTabDef.createLabel}
|
</button>
|
||||||
</Button>
|
)}
|
||||||
) : tab === "all" ? (
|
|
||||||
<>
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => void handleCreateKind("character", "people")}
|
|
||||||
>
|
|
||||||
+ Person
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => void handleCreateKind("world", "places")}
|
|
||||||
>
|
|
||||||
+ World Item
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => void handleCreateKind("note", "notes")}
|
|
||||||
>
|
|
||||||
+ Note
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="ps-entity-search">
|
|
||||||
<Input
|
|
||||||
className="ps-entity-search-input"
|
|
||||||
placeholder="Search people, places, world items, and notes…"
|
|
||||||
value={query}
|
|
||||||
onChange={(ev) => setQuery(ev.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{tab === "consistency" ? (
|
|
||||||
<ConsistencyPanel
|
|
||||||
issues={issues}
|
|
||||||
onOpenEntity={(id) => {
|
|
||||||
const e = entities.find((x) => x.id === id);
|
|
||||||
if (e) {
|
|
||||||
setDraft(e);
|
|
||||||
setOpen(true);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
) : visible.length === 0 ? (
|
|
||||||
<div className="ps-emptystate">
|
|
||||||
<div className="ps-emptystate-title">No entities in this view yet</div>
|
|
||||||
<p className="ps-muted">
|
|
||||||
{activeTabDef.createKind
|
|
||||||
? `Click "${activeTabDef.createLabel}" or the + button on the tab to create one.`
|
|
||||||
: "Use the + buttons on the People, Places & World, or Notes tabs to add story entities."}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="ps-entity-grid">
|
|
||||||
{visible.map((e) => (
|
|
||||||
<button
|
|
||||||
key={e.id}
|
|
||||||
type="button"
|
|
||||||
className="ps-entity-card"
|
|
||||||
onClick={() => openEntity(e)}
|
|
||||||
>
|
|
||||||
<div className="ps-entity-card-top">
|
|
||||||
<span className="ps-entity-type-badge">{badgeFor(e)}</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="ps-entity-card-name">{entityLabel(e)}</div>
|
))}
|
||||||
<div className="ps-entity-card-sub">{subFor(e)}</div>
|
</div>
|
||||||
{tagsFor(e).length > 0 && (
|
|
||||||
<div className="ps-entity-card-tags">
|
|
||||||
{tagsFor(e).slice(0, 4).map((t) => (
|
|
||||||
<span key={t} className="ps-entity-tag">
|
|
||||||
{t}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<EntityEditor
|
<div className="ps-entity-new-group">
|
||||||
entity={draft}
|
{activeTabDef.createKind ? (
|
||||||
entities={entities}
|
<Button
|
||||||
open={open}
|
variant="primary"
|
||||||
onClose={() => setOpen(false)}
|
size="sm"
|
||||||
onSave={save}
|
onClick={() => void handleCreateKind(activeTabDef.createKind!)}
|
||||||
onDelete={remove}
|
>
|
||||||
/>
|
{activeTabDef.createLabel}
|
||||||
|
</Button>
|
||||||
|
) : tab === "all" ? (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => void handleCreateKind("character", "people")}
|
||||||
|
>
|
||||||
|
+ Person
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => void handleCreateKind("world", "places")}
|
||||||
|
>
|
||||||
|
+ World Item
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => void handleCreateKind("note", "notes")}
|
||||||
|
>
|
||||||
|
+ Note
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="ps-entity-search">
|
||||||
|
<Input
|
||||||
|
className="ps-entity-search-input"
|
||||||
|
placeholder="Search people, places, world items, and notes…"
|
||||||
|
value={query}
|
||||||
|
onChange={(ev) => setQuery(ev.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="ps-entities-body">
|
||||||
|
<div className="ps-entities-main">
|
||||||
|
{tab === "consistency" ? (
|
||||||
|
<ConsistencyPanel
|
||||||
|
issues={issues}
|
||||||
|
onOpenEntity={(id) => {
|
||||||
|
const e = entities.find((x) => x.id === id);
|
||||||
|
if (e) {
|
||||||
|
setDraft(e);
|
||||||
|
setOpen(true);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : visible.length === 0 ? (
|
||||||
|
<div className="ps-emptystate">
|
||||||
|
<div className="ps-emptystate-title">No entities in this view yet</div>
|
||||||
|
<p className="ps-muted">
|
||||||
|
{activeTabDef.createKind
|
||||||
|
? `Click "${activeTabDef.createLabel}" or the + button on the tab to create one.`
|
||||||
|
: "Use the + buttons on the People, Places & World, or Notes tabs to add story entities."}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="ps-entity-grid">
|
||||||
|
{visible.map((e) => (
|
||||||
|
<button
|
||||||
|
key={e.id}
|
||||||
|
type="button"
|
||||||
|
className={`ps-entity-card${open && draft?.id === e.id ? " is-selected" : ""}`}
|
||||||
|
onClick={() => openEntity(e)}
|
||||||
|
>
|
||||||
|
<div className="ps-entity-card-top">
|
||||||
|
<span className="ps-entity-type-badge">{badgeFor(e)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="ps-entity-card-name">{entityLabel(e)}</div>
|
||||||
|
<div className="ps-entity-card-sub">{subFor(e)}</div>
|
||||||
|
{tagsFor(e).length > 0 && (
|
||||||
|
<div className="ps-entity-card-tags">
|
||||||
|
{tagsFor(e).slice(0, 4).map((t) => (
|
||||||
|
<span key={t} className="ps-entity-tag">
|
||||||
|
{t}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<EntityEditor
|
||||||
|
entity={draft}
|
||||||
|
entities={entities}
|
||||||
|
open={open}
|
||||||
|
onClose={() => setOpen(false)}
|
||||||
|
onSave={save}
|
||||||
|
onDelete={remove}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ export function EntityEditor({
|
|||||||
|
|
||||||
const isExisting = entities.some((e) => e.id === draft.id);
|
const isExisting = entities.some((e) => e.id === draft.id);
|
||||||
const isCharacterDraft = isCharacter(draft);
|
const isCharacterDraft = isCharacter(draft);
|
||||||
const canDelete = isExisting && isCharacterDraft;
|
const canDelete = isExisting;
|
||||||
|
|
||||||
const title = isCharacterDraft
|
const title = isCharacterDraft
|
||||||
? (draft.name || "Edit character")
|
? (draft.name || "Edit character")
|
||||||
|
|||||||
@@ -9,8 +9,6 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import type { Entity, FieldDef } from "@pubscript/core";
|
import type { Entity, FieldDef } from "@pubscript/core";
|
||||||
import { ENTITY_FIELDS } from "@pubscript/core";
|
import { ENTITY_FIELDS } from "@pubscript/core";
|
||||||
import { Input } from "@pubscript/ui";
|
|
||||||
import { Select } from "@pubscript/ui";
|
|
||||||
|
|
||||||
/** A short display label for an entity, regardless of kind. */
|
/** A short display label for an entity, regardless of kind. */
|
||||||
export function entityLabel(e: Entity): string {
|
export function entityLabel(e: Entity): string {
|
||||||
@@ -23,7 +21,9 @@ export function entityLabel(e: Entity): string {
|
|||||||
/** Read a field value from an entity, regardless of where it is stored. */
|
/** Read a field value from an entity, regardless of where it is stored. */
|
||||||
export function getFieldValue(e: Entity, key: string): string | string[] {
|
export function getFieldValue(e: Entity, key: string): string | string[] {
|
||||||
if ("fields" in e) {
|
if ("fields" in e) {
|
||||||
|
if (key === "name") return e.name;
|
||||||
const v = e.fields[key];
|
const v = e.fields[key];
|
||||||
|
if (Array.isArray(v)) return v;
|
||||||
return typeof v === "string" ? v : "";
|
return typeof v === "string" ? v : "";
|
||||||
}
|
}
|
||||||
const v = (e as unknown as Record<string, unknown>)[key];
|
const v = (e as unknown as Record<string, unknown>)[key];
|
||||||
@@ -34,6 +34,9 @@ export function getFieldValue(e: Entity, key: string): string | string[] {
|
|||||||
/** Return a copy of `e` with `key` set to `value`. */
|
/** Return a copy of `e` with `key` set to `value`. */
|
||||||
export function setFieldValue(e: Entity, key: string, value: string | string[]): Entity {
|
export function setFieldValue(e: Entity, key: string, value: string | string[]): Entity {
|
||||||
if ("fields" in e) {
|
if ("fields" in e) {
|
||||||
|
if (key === "name" && typeof value === "string") {
|
||||||
|
return { ...e, name: value };
|
||||||
|
}
|
||||||
return { ...e, fields: { ...e.fields, [key]: value } };
|
return { ...e, fields: { ...e.fields, [key]: value } };
|
||||||
}
|
}
|
||||||
return { ...e, [key]: value };
|
return { ...e, [key]: value };
|
||||||
@@ -52,69 +55,66 @@ interface EntityFieldsProps {
|
|||||||
entities?: Entity[];
|
entities?: Entity[];
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderField(
|
function renderFieldControl(
|
||||||
field: FieldDef,
|
field: FieldDef,
|
||||||
value: string | string[],
|
value: string | string[],
|
||||||
onChange: (v: string | string[]) => void,
|
onChange: (v: string | string[]) => void,
|
||||||
|
currentEntityId: string,
|
||||||
entities?: Entity[],
|
entities?: Entity[],
|
||||||
) {
|
) {
|
||||||
const label = (
|
|
||||||
<label className="ps-field-label">
|
|
||||||
{field.label}
|
|
||||||
{field.hint ? <span className="ps-field-hint">{field.hint}</span> : null}
|
|
||||||
</label>
|
|
||||||
);
|
|
||||||
|
|
||||||
switch (field.type) {
|
switch (field.type) {
|
||||||
case "textarea":
|
case "textarea":
|
||||||
return (
|
return (
|
||||||
<div className="ps-field-span-2">
|
<textarea
|
||||||
{label}
|
className="ps-textarea ps-input"
|
||||||
<textarea
|
rows={3}
|
||||||
className="ps-textarea ps-input"
|
value={typeof value === "string" ? value : ""}
|
||||||
value={typeof value === "string" ? value : ""}
|
onChange={(ev) => onChange(ev.target.value)}
|
||||||
onChange={(ev) => onChange(ev.target.value)}
|
/>
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
case "select":
|
case "select":
|
||||||
return (
|
return (
|
||||||
<div>
|
<select
|
||||||
{label}
|
className="ps-select"
|
||||||
<Select
|
value={typeof value === "string" ? value : ""}
|
||||||
value={typeof value === "string" ? value : ""}
|
onChange={(ev) => onChange(ev.target.value)}
|
||||||
options={(field.options ?? []).map((o) => ({ value: o, label: o }))}
|
>
|
||||||
onChange={(ev) => onChange(ev.target.value)}
|
{(field.options ?? []).map((o) => (
|
||||||
/>
|
<option key={o} value={o}>
|
||||||
</div>
|
{o}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
);
|
);
|
||||||
case "list":
|
case "list":
|
||||||
return <TagField value={Array.isArray(value) ? value : []} onChange={onChange} />;
|
return <TagField value={Array.isArray(value) ? value : []} onChange={onChange} />;
|
||||||
case "link": {
|
case "link": {
|
||||||
const opts = (entities ?? [])
|
const opts = (entities ?? [])
|
||||||
.filter((e) => e.id !== value)
|
.filter((e) => e.id !== currentEntityId && !("body" in e))
|
||||||
.map((e) => ({ value: e.id, label: entityLabel(e) }));
|
.map((e) => ({ value: e.id, label: entityLabel(e) }));
|
||||||
return (
|
return (
|
||||||
<div>
|
<select
|
||||||
{label}
|
className="ps-select"
|
||||||
<Select
|
value={typeof value === "string" ? value : ""}
|
||||||
value={typeof value === "string" ? value : ""}
|
onChange={(ev) => onChange(ev.target.value)}
|
||||||
options={[{ value: "", label: "— none —" }, ...opts]}
|
>
|
||||||
onChange={(ev) => onChange(ev.target.value)}
|
<option value="">— None —</option>
|
||||||
/>
|
{opts.map((o) => (
|
||||||
</div>
|
<option key={o.value} value={o.value}>
|
||||||
|
{o.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
case "text":
|
case "text":
|
||||||
return (
|
return (
|
||||||
<div className="ps-field-span-2">
|
<input
|
||||||
{label}
|
type="text"
|
||||||
<Input
|
className="ps-input"
|
||||||
className="ps-input"
|
value={typeof value === "string" ? value : ""}
|
||||||
value={typeof value === "string" ? value : ""}
|
onChange={(ev) => onChange(ev.target.value)}
|
||||||
onChange={(ev) => onChange(ev.target.value)}
|
/>
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -139,25 +139,27 @@ function TagField({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="ps-tag-field">
|
<div className="ps-tag-field">
|
||||||
<div className="ps-tag-list">
|
{value.length > 0 && (
|
||||||
{value.map((t, i) => (
|
<div className="ps-tag-list">
|
||||||
<span key={`${t}-${i}`} className="ps-tag-chip">
|
{value.map((t, i) => (
|
||||||
{t}
|
<span key={`${t}-${i}`} className="ps-tag-chip">
|
||||||
<button
|
{t}
|
||||||
type="button"
|
<button
|
||||||
className="ps-tag-remove"
|
type="button"
|
||||||
aria-label={`Remove ${t}`}
|
className="ps-tag-remove"
|
||||||
onClick={() => onChange(value.filter((_, idx) => idx !== i))}
|
aria-label={`Remove ${t}`}
|
||||||
>
|
onClick={() => onChange(value.filter((_, idx) => idx !== i))}
|
||||||
×
|
>
|
||||||
</button>
|
×
|
||||||
</span>
|
</button>
|
||||||
))}
|
</span>
|
||||||
</div>
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<input
|
<input
|
||||||
className="ps-input"
|
className="ps-input"
|
||||||
value={draft}
|
value={draft}
|
||||||
placeholder="Add tag + Enter"
|
placeholder="Add item + press Enter"
|
||||||
onChange={(ev) => setDraft(ev.target.value)}
|
onChange={(ev) => setDraft(ev.target.value)}
|
||||||
onKeyDown={(ev) => {
|
onKeyDown={(ev) => {
|
||||||
if (ev.key === "Enter") {
|
if (ev.key === "Enter") {
|
||||||
@@ -173,18 +175,31 @@ function TagField({
|
|||||||
export function EntityFields({ entity, onChange, entities }: EntityFieldsProps) {
|
export function EntityFields({ entity, onChange, entities }: EntityFieldsProps) {
|
||||||
const fields = fieldsFor(entity);
|
const fields = fieldsFor(entity);
|
||||||
|
|
||||||
|
const handleFieldChange = (key: string, val: string | string[]) => {
|
||||||
|
const updated = setFieldValue(entity, key, val);
|
||||||
|
onChange(updated);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="ps-entity-fields">
|
<div className="ps-entity-fields">
|
||||||
{fields.map((field) => (
|
{fields.map((field) => {
|
||||||
<div key={field.key} className="ps-field">
|
const isCompact = field.type === "select" || field.type === "link";
|
||||||
{renderField(
|
return (
|
||||||
field,
|
<div
|
||||||
getFieldValue(entity, field.key),
|
key={field.key}
|
||||||
(v) => onChange({ [field.key]: v } as Partial<Entity>),
|
className={`ps-field${isCompact ? " ps-field-half" : " ps-field-full"}`}
|
||||||
entities,
|
>
|
||||||
)}
|
<label className="ps-field-label">{field.label}</label>
|
||||||
</div>
|
{renderFieldControl(
|
||||||
))}
|
field,
|
||||||
|
getFieldValue(entity, field.key),
|
||||||
|
(v) => handleFieldChange(field.key, v),
|
||||||
|
entity.id,
|
||||||
|
entities,
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,19 +8,37 @@
|
|||||||
* (`--ps-*`) and field/button/drawer/tabs/badge classes.
|
* (`--ps-*`) and field/button/drawer/tabs/badge classes.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/* Full-width container for the entity engine (overrides .ps-content narrowness). */
|
/* Full-width container for the entity engine (pinned header + scrollable body). */
|
||||||
.ps-entities-view {
|
.ps-entities-view {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
background: var(--ps-bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.ps-entities-view > .ps-tabs {
|
.ps-entities-pinned-header {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 20;
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 16px 24px 12px;
|
||||||
|
background: var(--ps-bg);
|
||||||
|
border-bottom: 1px solid var(--ps-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ps-entities-body {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow: hidden;
|
min-height: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ps-entities-main {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 18px 24px 64px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---- Search bar + new-entity actions ---- */
|
/* ---- Search bar + new-entity actions ---- */
|
||||||
@@ -28,9 +46,13 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
margin-bottom: 20px;
|
margin-bottom: 0;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
.ps-entity-search .ps-field {
|
||||||
|
flex: 1;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.ps-entity-search-input {
|
.ps-entity-search-input {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
@@ -64,7 +86,8 @@
|
|||||||
font-family: var(--ps-font-ui);
|
font-family: var(--ps-font-ui);
|
||||||
}
|
}
|
||||||
|
|
||||||
.ps-entity-card:hover {
|
.ps-entity-card:hover,
|
||||||
|
.ps-entity-card.is-selected {
|
||||||
border-color: var(--ps-accent);
|
border-color: var(--ps-accent);
|
||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.08);
|
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.08);
|
||||||
@@ -161,20 +184,46 @@
|
|||||||
font-family: var(--ps-font-ui);
|
font-family: var(--ps-font-ui);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---- Entity editor drawer ---- */
|
/* ---- Entity editor drawer (docked right rail) ---- */
|
||||||
.ps-entity-editor {
|
.ps-entity-editor {
|
||||||
|
width: min(460px, 45vw);
|
||||||
|
flex-shrink: 0;
|
||||||
|
height: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 8px;
|
gap: 0;
|
||||||
|
background: var(--ps-surface);
|
||||||
|
border-left: 1px solid var(--ps-border);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ps-entity-editor-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 14px 18px;
|
||||||
|
border-bottom: 1px solid var(--ps-border);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ps-entity-editor-body {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 16px 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ps-entity-editor-actions {
|
.ps-entity-editor-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
margin-top: 12px;
|
padding: 12px 18px;
|
||||||
padding-top: 16px;
|
|
||||||
border-top: 1px solid var(--ps-border);
|
border-top: 1px solid var(--ps-border);
|
||||||
|
background: var(--ps-surface);
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ps-spacer {
|
.ps-spacer {
|
||||||
@@ -185,14 +234,24 @@
|
|||||||
margin-right: auto;
|
margin-right: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---- Generic entity fields ---- */
|
/* ---- Generic entity fields (clean 2-column grid for selects, full-width for text/textarea) ---- */
|
||||||
.ps-entity-fields {
|
.ps-entity-fields {
|
||||||
display: flex;
|
display: grid;
|
||||||
flex-direction: column;
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ps-entity-fields .ps-field {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ps-field-full,
|
||||||
.ps-field-span-2 {
|
.ps-field-span-2 {
|
||||||
grid-column: span 2;
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ps-field-half {
|
||||||
|
grid-column: span 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ps-textarea {
|
.ps-textarea {
|
||||||
@@ -312,15 +371,19 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ps-relationship-add > .ps-field {
|
||||||
|
margin-bottom: 0;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 110px;
|
||||||
|
}
|
||||||
|
|
||||||
.ps-relationship-target-select,
|
.ps-relationship-target-select,
|
||||||
.ps-relationship-kind-select {
|
.ps-relationship-kind-select {
|
||||||
flex: 1;
|
width: 100%;
|
||||||
min-width: 120px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.ps-relationship-note {
|
.ps-relationship-note {
|
||||||
flex: 2;
|
width: 100%;
|
||||||
min-width: 120px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---- Misc ---- */
|
/* ---- Misc ---- */
|
||||||
@@ -406,14 +469,7 @@
|
|||||||
min-width: 120px;
|
min-width: 120px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---- Entity editor drawer header/body ---- */
|
.ps-entity-Editor-title,
|
||||||
.ps-entity-editor-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
margin-bottom: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ps-entity-editor-header h2 {
|
.ps-entity-editor-header h2 {
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@@ -421,14 +477,6 @@
|
|||||||
color: var(--ps-text);
|
color: var(--ps-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.ps-entity-editor-body {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 16px;
|
|
||||||
overflow-y: auto;
|
|
||||||
padding: 4px 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ps-icon-btn {
|
.ps-icon-btn {
|
||||||
border: none;
|
border: none;
|
||||||
background: none;
|
background: none;
|
||||||
|
|||||||
@@ -5,15 +5,26 @@
|
|||||||
* line-number gutter, focus mode, and status bar.
|
* line-number gutter, focus mode, and status bar.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
.ps-writing-slot {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
.ps-writing {
|
.ps-writing {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* OnlyOffice / Word-inspired icon formatting ribbon */
|
/* OnlyOffice / Word-inspired icon formatting ribbon (pinned at top) */
|
||||||
.ps-writing-toolbar {
|
.ps-writing-toolbar {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
@@ -23,7 +34,8 @@
|
|||||||
border-bottom: 1px solid var(--ps-border);
|
border-bottom: 1px solid var(--ps-border);
|
||||||
font-family: var(--ps-font-ui);
|
font-family: var(--ps-font-ui);
|
||||||
flex: none;
|
flex: none;
|
||||||
z-index: 5;
|
flex-shrink: 0;
|
||||||
|
z-index: 30;
|
||||||
}
|
}
|
||||||
:root[data-density="compact"] .ps-writing-toolbar {
|
:root[data-density="compact"] .ps-writing-toolbar {
|
||||||
padding: 4px 10px;
|
padding: 4px 10px;
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ export const ENTITY_FIELDS: Record<string, EntityKindDef> = {
|
|||||||
{ key: "name", label: "Name", type: "text" },
|
{ key: "name", label: "Name", type: "text" },
|
||||||
{ key: "role", label: "Role", type: "select", options: ["protagonist", "antagonist", "supporting", "minor", "narrator"] },
|
{ key: "role", label: "Role", type: "select", options: ["protagonist", "antagonist", "supporting", "minor", "narrator"] },
|
||||||
{ key: "status", label: "Status", type: "select", options: ["active", "inactive", "dead", "unseen"] },
|
{ key: "status", label: "Status", type: "select", options: ["active", "inactive", "dead", "unseen"] },
|
||||||
{ key: "home", label: "Home", type: "link", hint: "linked location" },
|
{ key: "home", label: "Home Location", type: "link" },
|
||||||
{ key: "bio", label: "Bio", type: "textarea" },
|
{ key: "bio", label: "Bio", type: "textarea" },
|
||||||
{ key: "appearance", label: "Appearance", type: "textarea" },
|
{ key: "appearance", label: "Appearance", type: "textarea" },
|
||||||
{ key: "voice", label: "Voice", type: "textarea" },
|
{ key: "voice", label: "Voice", type: "textarea" },
|
||||||
|
|||||||
@@ -431,7 +431,7 @@ body {
|
|||||||
}
|
}
|
||||||
.ps-input.is-error, .ps-select.is-error { border-color: var(--ps-danger); }
|
.ps-input.is-error, .ps-select.is-error { border-color: var(--ps-danger); }
|
||||||
.ps-field-hint { font-size: 12px; color: var(--ps-text-muted); margin: 0; }
|
.ps-field-hint { font-size: 12px; color: var(--ps-text-muted); margin: 0; }
|
||||||
.ps-field-hint:only-child { color: var(--ps-danger); }
|
.ps-field-hint.is-error { color: var(--ps-danger); }
|
||||||
|
|
||||||
/* ---- Modal ---- */
|
/* ---- Modal ---- */
|
||||||
.ps-modal-root { position: fixed; inset: 0; z-index: 1000; font-family: var(--ps-font-ui); }
|
.ps-modal-root { position: fixed; inset: 0; z-index: 1000; font-family: var(--ps-font-ui); }
|
||||||
|
|||||||
Reference in New Issue
Block a user