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.");