451 lines
14 KiB
JavaScript
451 lines
14 KiB
JavaScript
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.");
|