Add Games File

This commit is contained in:
Ivan Anikin
2026-05-19 13:42:41 +07:00
parent 7908479e0c
commit 12e7e22c1a
3672 changed files with 125922 additions and 0 deletions
+30
View File
@@ -0,0 +1,30 @@
# Identity Prompt Lab
Легковесный WebUI для тестирования гипотез по сборке system prompt для персонажей FunnyGirl.
## Запуск
```bash
cd /Users/ivananikin/Projets/FunnyGirl/IdentityPromptLab
node server.mjs
```
Открой:
```text
http://localhost:4173
```
Можно также открыть `index.html` напрямую, но реальные API-запросы удобнее гонять через `server.mjs`, потому что он проксирует OpenAI-compatible `/chat/completions`.
## Что внутри
- Свободные безымянные поля: каждое поле становится отдельным блоком итогового system prompt.
- Добавление, удаление и перестановка полей.
- Предпросмотр итогового system prompt.
- Чат для быстрых тестов.
- `Mock mode` для проверки промптов без API.
- DeepSeek/OpenAI-compatible настройки: `API base URL`, `API key`, `model`, `temperature`, `max tokens`.
- Локальное сохранение пресетов в `localStorage`.
API key хранится только в localStorage текущего браузера.
+450
View File
@@ -0,0 +1,450 @@
const STORAGE_KEY = "funnygirl.identityPromptLab.v2";
const runtimeDefaults = {
model: "deepseek-chat",
temperature: 0.7,
maxTokens: 700,
apiBase: "https://api.deepseek.com",
apiKey: "",
mockMode: true,
};
const defaultPresets = [
{
id: "blank",
title: "Blank",
fields: [""],
...runtimeDefaults,
},
{
id: "kira-test",
title: "Kira test",
fields: [
"Identity:\n- Name: Кира\n- Age: 22",
"Ты не называешь себя ассистентом и не объясняешь, что являешься языковой моделью. Ты отвечаешь как живой собеседник внутри текущей сцены.",
"Ты говоришь естественно, короткими живыми фразами. Если эмоция важнее факта, сначала отвечаешь на эмоцию.",
"Respond only with a valid JSON object. Match this response shape exactly:\n{\n \"reply\": \"Text response goes here.\"\n}",
],
...runtimeDefaults,
},
];
const el = {
presetSelect: document.querySelector("#presetSelect"),
savePresetButton: document.querySelector("#savePresetButton"),
newPresetButton: document.querySelector("#newPresetButton"),
themeToggle: document.querySelector("#themeToggle"),
tabs: [...document.querySelectorAll(".tab")],
panels: [...document.querySelectorAll(".tab-panel")],
promptFields: document.querySelector("#promptFields"),
addFieldButton: document.querySelector("#addFieldButton"),
clearFieldsButton: document.querySelector("#clearFieldsButton"),
chatTitle: document.querySelector("#chatTitle"),
promptPreview: document.querySelector("#promptPreview"),
promptStats: document.querySelector("#promptStats"),
messages: document.querySelector("#messages"),
chatForm: document.querySelector("#chatForm"),
messageInput: document.querySelector("#messageInput"),
sendButton: document.querySelector("#sendButton"),
clearChatButton: document.querySelector("#clearChatButton"),
copyPromptButton: document.querySelector("#copyPromptButton"),
temperatureValue: document.querySelector("#temperatureValue"),
};
const runtimeFields = {
model: document.querySelector("#modelInput"),
temperature: document.querySelector("#temperatureInput"),
maxTokens: document.querySelector("#maxTokensInput"),
apiBase: document.querySelector("#apiBaseInput"),
apiKey: document.querySelector("#apiKeyInput"),
mockMode: document.querySelector("#mockModeInput"),
};
let state = loadState();
let selectedId = state.selectedId || state.presets[0].id;
let chat = [];
function loadState() {
try {
const saved = JSON.parse(localStorage.getItem(STORAGE_KEY));
if (saved?.presets?.length) {
return normalizeState(saved);
}
} catch {
localStorage.removeItem(STORAGE_KEY);
}
return {
presets: structuredClone(defaultPresets),
selectedId: defaultPresets[0].id,
theme: "light",
};
}
function normalizeState(nextState) {
return {
theme: nextState.theme || "light",
selectedId: nextState.selectedId,
presets: nextState.presets.map((preset) => ({
id: preset.id,
title: preset.title || "Untitled",
fields: Array.isArray(preset.fields) && preset.fields.length ? preset.fields : [""],
model: preset.model || runtimeDefaults.model,
temperature: Number.isFinite(Number(preset.temperature)) ? Number(preset.temperature) : runtimeDefaults.temperature,
maxTokens: Number.isFinite(Number(preset.maxTokens)) ? Number(preset.maxTokens) : runtimeDefaults.maxTokens,
apiBase: normalizeApiBase(preset.apiBase),
apiKey: preset.apiKey || "",
mockMode: preset.mockMode ?? runtimeDefaults.mockMode,
})),
};
}
function normalizeApiBase(apiBase) {
const cleanBase = String(apiBase || runtimeDefaults.apiBase).replace(/\/+$/, "");
return cleanBase === "https://api.deepseek.com/v1" ? runtimeDefaults.apiBase : cleanBase;
}
function saveState() {
state.selectedId = selectedId;
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
}
function currentPreset() {
return state.presets.find((preset) => preset.id === selectedId) || state.presets[0];
}
function setTheme(theme) {
state.theme = theme;
document.documentElement.dataset.theme = theme;
saveState();
}
function renderPresetSelect() {
el.presetSelect.innerHTML = "";
state.presets.forEach((preset) => {
const option = document.createElement("option");
option.value = preset.id;
option.textContent = preset.title;
el.presetSelect.append(option);
});
el.presetSelect.value = selectedId;
}
function loadPresetToForm(preset) {
Object.entries(runtimeFields).forEach(([key, input]) => {
if (input.type === "checkbox") {
input.checked = Boolean(preset[key]);
} else {
input.value = preset[key] ?? "";
}
});
renderPromptFields(preset.fields);
el.chatTitle.textContent = preset.title;
updatePreview();
}
function renderPromptFields(fields) {
el.promptFields.innerHTML = "";
fields.forEach((value, index) => {
el.promptFields.append(createPromptField(value, index));
});
}
function createPromptField(value, index) {
const wrapper = document.createElement("article");
wrapper.className = "prompt-field";
const textarea = document.createElement("textarea");
textarea.rows = 6;
textarea.value = value || "";
textarea.placeholder = "Заполни блок system prompt...";
textarea.dataset.index = String(index);
textarea.addEventListener("input", () => {
persistFormToPreset();
updatePreview();
});
const controls = document.createElement("div");
controls.className = "field-controls";
controls.append(
makeFieldButton("↑", "Move up", () => moveField(index, -1)),
makeFieldButton("↓", "Move down", () => moveField(index, 1)),
makeFieldButton("×", "Remove", () => removeField(index)),
);
wrapper.append(textarea, controls);
return wrapper;
}
function makeFieldButton(text, title, onClick) {
const button = document.createElement("button");
button.type = "button";
button.textContent = text;
button.title = title;
button.addEventListener("click", onClick);
return button;
}
function readRuntimeForm() {
const next = {};
Object.entries(runtimeFields).forEach(([key, input]) => {
if (input.type === "checkbox") {
next[key] = input.checked;
} else if (input.type === "number" || input.type === "range") {
next[key] = Number(input.value);
} else {
next[key] = input.value;
}
});
return next;
}
function readPromptFields() {
const values = [...el.promptFields.querySelectorAll("textarea")].map((textarea) => textarea.value);
return values.length ? values : [""];
}
function persistFormToPreset() {
const index = state.presets.findIndex((preset) => preset.id === selectedId);
if (index === -1) return;
state.presets[index] = {
...state.presets[index],
...readRuntimeForm(),
fields: readPromptFields(),
};
saveState();
}
function buildPrompt() {
return readPromptFields()
.map((value) => value.trim())
.filter(Boolean)
.join("\n\n");
}
function updatePreview() {
const prompt = buildPrompt();
el.promptPreview.textContent = prompt;
el.promptStats.textContent = `${prompt.length} chars`;
el.temperatureValue.textContent = Number(runtimeFields.temperature.value || 0).toFixed(2);
}
function addPromptField(value = "") {
persistFormToPreset();
const preset = currentPreset();
preset.fields = [...readPromptFields(), value];
renderPromptFields(preset.fields);
saveState();
updatePreview();
el.promptFields.querySelector("textarea:last-child")?.focus();
}
function clearPromptFields() {
currentPreset().fields = [""];
renderPromptFields(currentPreset().fields);
saveState();
updatePreview();
}
function removeField(index) {
const values = readPromptFields();
values.splice(index, 1);
currentPreset().fields = values.length ? values : [""];
renderPromptFields(currentPreset().fields);
saveState();
updatePreview();
}
function moveField(index, direction) {
const values = readPromptFields();
const target = index + direction;
if (target < 0 || target >= values.length) return;
[values[index], values[target]] = [values[target], values[index]];
currentPreset().fields = values;
renderPromptFields(values);
saveState();
updatePreview();
}
function addMessage(role, content) {
chat.push({ role, content });
const node = document.createElement("div");
node.className = `message ${role}`;
node.textContent = content;
el.messages.append(node);
el.messages.scrollTop = el.messages.scrollHeight;
}
function showThinking() {
const node = document.createElement("div");
node.className = "message assistant thinking";
node.id = "thinkingMessage";
const spinner = document.createElement("span");
spinner.className = "spinner";
spinner.setAttribute("aria-hidden", "true");
const text = document.createElement("span");
text.textContent = "LLM думает";
node.append(spinner, text);
el.messages.append(node);
el.messages.scrollTop = el.messages.scrollHeight;
}
function hideThinking() {
document.querySelector("#thinkingMessage")?.remove();
}
function mockReply(message) {
const prompt = buildPrompt().toLowerCase();
const short = prompt.includes("корот") || prompt.includes("short");
const prefix = short ? "Слышу." : "Слышу тебя.";
const tail = message.endsWith("?")
? "Я бы уточнила сначала, что именно для тебя здесь важнее."
: "Я отвечаю по текущим свободным полям, без API вызова.";
return `${prefix} ${tail}`;
}
function parseAssistantContent(content) {
try {
const parsed = JSON.parse(content);
return parsed.reply || content;
} catch {
return content;
}
}
async function sendToModel(message) {
if (runtimeFields.mockMode.checked) {
await new Promise((resolve) => setTimeout(resolve, 280));
return mockReply(message);
}
const body = {
apiBase: runtimeFields.apiBase.value.trim(),
apiKey: runtimeFields.apiKey.value.trim(),
model: runtimeFields.model.value.trim(),
temperature: Number(runtimeFields.temperature.value),
max_tokens: Number(runtimeFields.maxTokens.value),
messages: [
{ role: "system", content: buildPrompt() },
...chat.map((item) => ({ role: item.role === "system" ? "assistant" : item.role, content: item.content })),
],
};
let response;
try {
response = await fetch("/api/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
} catch (error) {
throw new Error("Local server is not reachable. Restart it with: node server.mjs");
}
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || "Request failed");
}
return data.content;
}
el.tabs.forEach((tab) => {
tab.addEventListener("click", () => {
const target = tab.dataset.tab;
el.tabs.forEach((item) => item.classList.toggle("is-active", item === tab));
el.panels.forEach((panel) => panel.classList.toggle("is-active", panel.dataset.panel === target));
});
});
Object.values(runtimeFields).forEach((input) => {
input.addEventListener("input", () => {
persistFormToPreset();
updatePreview();
});
});
el.addFieldButton.addEventListener("click", () => addPromptField());
el.clearFieldsButton.addEventListener("click", clearPromptFields);
el.presetSelect.addEventListener("change", () => {
persistFormToPreset();
selectedId = el.presetSelect.value;
chat = [];
el.messages.innerHTML = "";
loadPresetToForm(currentPreset());
saveState();
});
el.savePresetButton.addEventListener("click", () => {
persistFormToPreset();
addMessage("system", `Preset "${currentPreset().title}" saved locally.`);
});
el.newPresetButton.addEventListener("click", () => {
persistFormToPreset();
const title = prompt("Preset name", "New preset");
if (!title) return;
const id = `${title.toLowerCase().replace(/[^a-zа-я0-9]+/gi, "-")}-${Date.now()}`;
state.presets.push({ ...currentPreset(), ...readRuntimeForm(), id, title, fields: [""] });
selectedId = id;
renderPresetSelect();
loadPresetToForm(currentPreset());
saveState();
});
el.clearChatButton.addEventListener("click", () => {
chat = [];
el.messages.innerHTML = "";
});
el.copyPromptButton.addEventListener("click", async () => {
await navigator.clipboard.writeText(buildPrompt());
addMessage("system", "System prompt copied.");
});
el.themeToggle.addEventListener("click", () => {
setTheme(state.theme === "dark" ? "light" : "dark");
});
el.chatForm.addEventListener("submit", async (event) => {
event.preventDefault();
const message = el.messageInput.value.trim();
if (!message) return;
persistFormToPreset();
addMessage("user", message);
el.messageInput.value = "";
el.sendButton.disabled = true;
el.sendButton.textContent = "Wait";
showThinking();
try {
const content = await sendToModel(message);
hideThinking();
addMessage("assistant", parseAssistantContent(content));
} catch (error) {
hideThinking();
addMessage("system", error.message);
} finally {
el.sendButton.disabled = false;
el.sendButton.textContent = "Send";
}
});
el.messageInput.addEventListener("keydown", (event) => {
if (event.key !== "Enter" || event.shiftKey || event.metaKey || event.ctrlKey || event.altKey) {
return;
}
event.preventDefault();
el.chatForm.requestSubmit();
});
setTheme(state.theme || "light");
renderPresetSelect();
loadPresetToForm(currentPreset());
addMessage("system", "Mock mode is on. Add API key and disable Mock mode for real DeepSeek calls.");
+111
View File
@@ -0,0 +1,111 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Identity Prompt Lab</title>
<link rel="stylesheet" href="./styles.css" />
</head>
<body>
<main class="app-shell">
<section class="sidebar" aria-label="Identity presets and settings">
<header class="brand-row">
<div>
<p class="eyebrow">FunnyGirl</p>
<h1>Identity Prompt Lab</h1>
</div>
<button id="themeToggle" class="icon-button" type="button" title="Theme">◐</button>
</header>
<div class="preset-bar">
<label for="presetSelect">Preset</label>
<div class="preset-actions">
<select id="presetSelect"></select>
<button id="savePresetButton" type="button" title="Save preset">Save</button>
<button id="newPresetButton" type="button" title="New preset">New</button>
</div>
</div>
<nav class="tabs" aria-label="Prompt sections">
<button class="tab is-active" data-tab="fields" type="button">Fields</button>
<button class="tab" data-tab="runtime" type="button">Runtime</button>
</nav>
<form id="settingsForm" class="settings-form">
<section class="tab-panel is-active" data-panel="fields">
<div class="field-list-toolbar">
<button id="addFieldButton" type="button">Add field</button>
<button id="clearFieldsButton" type="button">Clear fields</button>
</div>
<div id="promptFields" class="prompt-fields"></div>
</section>
<section class="tab-panel" data-panel="runtime">
<div class="field-grid two">
<label>
Model
<input id="modelInput" name="model" autocomplete="off" />
</label>
<label>
Max tokens
<input id="maxTokensInput" name="maxTokens" type="number" min="1" max="8192" />
</label>
</div>
<label>
Temperature
<input id="temperatureInput" name="temperature" type="range" min="0" max="2" step="0.05" />
<span id="temperatureValue" class="range-value"></span>
</label>
<label>
API base URL
<input id="apiBaseInput" name="apiBase" autocomplete="off" placeholder="https://api.deepseek.com" />
</label>
<label>
API key
<input id="apiKeyInput" name="apiKey" type="password" autocomplete="off" placeholder="Stored only in this browser" />
</label>
<div class="toggle-row">
<label class="checkbox-label">
<input id="mockModeInput" name="mockMode" type="checkbox" />
Mock mode
</label>
</div>
</section>
</form>
</section>
<section class="workspace">
<section class="chat-panel" aria-label="Chat">
<header class="panel-header">
<div>
<p class="eyebrow">Live test</p>
<h2 id="chatTitle">Prompt test</h2>
</div>
<div class="toolbar">
<button id="clearChatButton" type="button" title="Clear chat">Clear</button>
<button id="copyPromptButton" type="button" title="Copy prompt">Copy prompt</button>
</div>
</header>
<div id="messages" class="messages" aria-live="polite"></div>
<form id="chatForm" class="composer">
<textarea id="messageInput" rows="2" placeholder="Напиши тестовую реплику..." required></textarea>
<button id="sendButton" type="submit">Send</button>
</form>
</section>
<aside class="prompt-panel" aria-label="Prompt preview">
<header class="panel-header">
<div>
<p class="eyebrow">Generated</p>
<h2>System prompt</h2>
</div>
<span id="promptStats" class="stats"></span>
</header>
<pre id="promptPreview"></pre>
</aside>
</section>
</main>
<script type="module" src="./app.js"></script>
</body>
</html>
+119
View File
@@ -0,0 +1,119 @@
import { createServer } from "node:http";
import { readFile } from "node:fs/promises";
import { extname, join, normalize } from "node:path";
import { fileURLToPath } from "node:url";
const root = fileURLToPath(new URL(".", import.meta.url));
const port = Number(process.env.PORT || 4173);
const mime = {
".html": "text/html; charset=utf-8",
".css": "text/css; charset=utf-8",
".js": "text/javascript; charset=utf-8",
".json": "application/json; charset=utf-8",
};
function readBody(request) {
return new Promise((resolve, reject) => {
let body = "";
request.on("data", (chunk) => {
body += chunk;
if (body.length > 2_000_000) {
reject(new Error("Request body is too large"));
request.destroy();
}
});
request.on("end", () => resolve(body));
request.on("error", reject);
});
}
function sendJson(response, status, payload) {
response.writeHead(status, { "Content-Type": "application/json; charset=utf-8" });
response.end(JSON.stringify(payload));
}
async function handleChat(request, response) {
try {
const payload = JSON.parse(await readBody(request));
const apiBase = String(payload.apiBase || "").replace(/\/+$/, "");
const apiKey = String(payload.apiKey || "");
const model = String(payload.model || "");
if (!apiBase || !apiKey || !model) {
sendJson(response, 400, { error: "API base, API key, and model are required." });
return;
}
const upstreamBody = {
model,
messages: payload.messages || [],
temperature: payload.temperature,
max_tokens: payload.max_tokens,
};
if (payload.response_format) {
upstreamBody.response_format = payload.response_format;
}
const upstream = await fetch(`${apiBase}/chat/completions`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify(upstreamBody),
});
const data = await upstream.json().catch(() => ({}));
if (!upstream.ok) {
sendJson(response, upstream.status, { error: data?.error?.message || "Model request failed." });
return;
}
sendJson(response, 200, { content: data?.choices?.[0]?.message?.content || "" });
} catch (error) {
const cause = error.cause?.message ? ` (${error.cause.message})` : "";
sendJson(response, 500, { error: `${error.message}${cause}` });
}
}
async function handleStatic(request, response) {
const url = new URL(request.url || "/", `http://${request.headers.host}`);
const cleanPath = normalize(url.pathname === "/" ? "/index.html" : url.pathname).replace(/^(\.\.[/\\])+/, "");
const filePath = join(root, cleanPath);
if (!filePath.startsWith(root)) {
response.writeHead(403);
response.end("Forbidden");
return;
}
try {
const content = await readFile(filePath);
response.writeHead(200, { "Content-Type": mime[extname(filePath)] || "application/octet-stream" });
response.end(content);
} catch {
response.writeHead(404);
response.end("Not found");
}
}
const server = createServer((request, response) => {
if (request.method === "POST" && request.url === "/api/chat") {
handleChat(request, response);
return;
}
if (request.method === "GET" || request.method === "HEAD") {
handleStatic(request, response);
return;
}
response.writeHead(405);
response.end("Method not allowed");
});
server.listen(port, () => {
console.log(`Identity Prompt Lab: http://localhost:${port}`);
});
+452
View File
@@ -0,0 +1,452 @@
:root {
color-scheme: light;
--bg: #f4f2ed;
--panel: #fffdfa;
--panel-strong: #f8f4ec;
--text: #1f2528;
--muted: #667075;
--line: #ded8ce;
--accent: #357a78;
--accent-strong: #195f61;
--accent-soft: #d8eeeb;
--danger: #a84444;
--shadow: 0 18px 44px rgba(31, 37, 40, 0.08);
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
:root[data-theme="dark"] {
color-scheme: dark;
--bg: #171918;
--panel: #222624;
--panel-strong: #292e2b;
--text: #f2f0ea;
--muted: #adb6b1;
--line: #3c433f;
--accent: #6fb8aa;
--accent-strong: #a0d8cc;
--accent-soft: #203b38;
--danger: #ff9f9f;
--shadow: 0 18px 44px rgba(0, 0, 0, 0.22);
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
background: var(--bg);
color: var(--text);
}
button,
input,
select,
textarea {
font: inherit;
}
button {
border: 1px solid var(--line);
background: var(--panel);
color: var(--text);
border-radius: 8px;
min-height: 36px;
padding: 0 12px;
cursor: pointer;
}
button:hover {
border-color: var(--accent);
}
button:disabled {
cursor: not-allowed;
opacity: 0.6;
}
select,
input,
textarea {
width: 100%;
border: 1px solid var(--line);
border-radius: 8px;
background: var(--panel);
color: var(--text);
padding: 10px 11px;
outline: none;
}
textarea {
resize: vertical;
line-height: 1.45;
}
select:focus,
input:focus,
textarea:focus {
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-soft);
}
.app-shell {
display: grid;
grid-template-columns: minmax(360px, 440px) 1fr;
gap: 16px;
height: 100vh;
padding: 16px;
overflow: hidden;
}
.sidebar,
.chat-panel,
.prompt-panel {
background: var(--panel);
border: 1px solid var(--line);
border-radius: 8px;
box-shadow: var(--shadow);
}
.sidebar {
display: flex;
flex-direction: column;
height: calc(100vh - 32px);
min-height: 0;
overflow: hidden;
}
.brand-row,
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 16px;
border-bottom: 1px solid var(--line);
}
h1,
h2,
p {
margin: 0;
}
h1 {
font-size: 22px;
line-height: 1.1;
}
h2 {
font-size: 18px;
}
.eyebrow {
color: var(--accent-strong);
font-size: 12px;
font-weight: 700;
letter-spacing: 0;
text-transform: uppercase;
}
.icon-button {
width: 38px;
padding: 0;
font-size: 18px;
}
.preset-bar {
display: grid;
gap: 8px;
padding: 14px 16px;
border-bottom: 1px solid var(--line);
}
.preset-bar > label,
.settings-form label {
display: grid;
gap: 7px;
color: var(--muted);
font-size: 13px;
font-weight: 650;
}
.preset-actions {
display: grid;
grid-template-columns: 1fr auto auto;
gap: 8px;
}
.tabs {
display: grid;
grid-template-columns: repeat(2, 1fr);
padding: 10px;
gap: 8px;
border-bottom: 1px solid var(--line);
}
.tab {
background: transparent;
}
.tab.is-active {
background: var(--accent-soft);
border-color: var(--accent);
color: var(--accent-strong);
}
.settings-form {
flex: 1;
min-height: 0;
overflow: auto;
padding: 16px;
}
.tab-panel {
display: none;
gap: 14px;
}
.tab-panel.is-active {
display: grid;
}
.field-grid {
display: grid;
gap: 12px;
}
.field-grid.two {
grid-template-columns: 1fr 1fr;
}
.field-list-toolbar {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.prompt-fields {
display: grid;
gap: 12px;
}
.prompt-field {
display: grid;
grid-template-columns: 1fr auto;
align-items: stretch;
gap: 8px;
}
.prompt-field textarea {
min-height: 132px;
}
.field-controls {
display: grid;
grid-template-rows: repeat(3, 36px);
gap: 6px;
}
.field-controls button {
width: 38px;
padding: 0;
font-size: 17px;
line-height: 1;
}
.toggle-row {
display: flex;
align-items: center;
gap: 18px;
flex-wrap: wrap;
}
.checkbox-label {
display: inline-flex !important;
grid-template-columns: auto 1fr;
align-items: center;
gap: 8px !important;
}
.checkbox-label input {
width: 18px;
height: 18px;
}
.range-value,
.stats {
color: var(--muted);
font-size: 12px;
}
.workspace {
display: grid;
grid-template-columns: minmax(420px, 1fr) minmax(360px, 0.82fr);
gap: 16px;
min-height: 0;
min-width: 0;
}
.chat-panel,
.prompt-panel {
display: flex;
flex-direction: column;
height: calc(100vh - 32px);
min-height: 0;
min-width: 0;
}
.toolbar {
display: flex;
gap: 8px;
}
.messages {
flex: 1;
min-height: 0;
overflow: auto;
padding: 18px;
display: flex;
flex-direction: column;
gap: 12px;
}
.message {
max-width: 82%;
border-radius: 8px;
padding: 11px 13px;
line-height: 1.45;
white-space: pre-wrap;
border: 1px solid var(--line);
}
.message.user {
align-self: flex-end;
background: var(--accent);
border-color: var(--accent);
color: #ffffff;
}
.message.assistant {
align-self: flex-start;
background: var(--panel-strong);
}
.message.thinking {
display: inline-flex;
align-items: center;
gap: 9px;
color: var(--muted);
}
.spinner {
width: 16px;
height: 16px;
border: 2px solid var(--line);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 0.8s linear infinite;
flex: 0 0 auto;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.message.system {
align-self: center;
max-width: 92%;
color: var(--muted);
background: transparent;
border-style: dashed;
font-size: 13px;
}
.composer {
display: grid;
grid-template-columns: 1fr auto;
align-items: end;
gap: 10px;
padding: 16px;
border-top: 1px solid var(--line);
}
.composer button {
min-width: 84px;
background: var(--accent);
border-color: var(--accent);
color: white;
}
.prompt-panel pre {
flex: 1;
min-height: 0;
margin: 0;
padding: 16px;
overflow: auto;
white-space: pre-wrap;
line-height: 1.48;
font-size: 13px;
}
@media (max-width: 1160px) {
.app-shell,
.workspace {
grid-template-columns: 1fr;
}
.app-shell {
height: auto;
min-height: 100vh;
overflow: visible;
}
.sidebar,
.prompt-panel {
height: auto;
min-height: auto;
}
.chat-panel {
height: calc(100vh - 32px);
min-height: 520px;
}
.prompt-panel {
max-height: 620px;
}
.messages {
min-height: 0;
}
}
@media (max-width: 620px) {
.app-shell {
padding: 8px;
}
.field-grid.two,
.field-list-toolbar,
.preset-actions,
.composer {
grid-template-columns: 1fr;
}
.prompt-field {
grid-template-columns: 1fr;
}
.field-controls {
grid-template-columns: repeat(3, 38px);
grid-template-rows: 36px;
justify-content: end;
}
.message {
max-width: 100%;
}
}