Add Games File
This commit is contained in:
@@ -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 текущего браузера.
|
||||
@@ -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.");
|
||||
@@ -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>
|
||||
@@ -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}`);
|
||||
});
|
||||
@@ -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%;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user