Files
FunnyGirl/IdentityPromptLab/app.js
T
2026-05-19 13:42:41 +07:00

451 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.");