Initial SQL-only 1C adapter baseline
This commit is contained in:
@@ -0,0 +1,566 @@
|
||||
const els = {
|
||||
apiBase: document.getElementById("apiBase"),
|
||||
healthStatus: document.getElementById("healthStatus"),
|
||||
projectSelect: document.getElementById("projectSelect"),
|
||||
chatSelect: document.getElementById("chatSelect"),
|
||||
messages: document.getElementById("messages"),
|
||||
messageInput: document.getElementById("messageInput"),
|
||||
chatForm: document.getElementById("chatForm"),
|
||||
sendButton: document.getElementById("sendButton"),
|
||||
createProjectBtn: document.getElementById("createProject"),
|
||||
refreshProjectsBtn: document.getElementById("refreshProjects"),
|
||||
createChatBtn: document.getElementById("createChat"),
|
||||
renameProjectBtn: document.getElementById("renameProject"),
|
||||
deleteProjectBtn: document.getElementById("deleteProject"),
|
||||
renameChatBtn: document.getElementById("renameChat"),
|
||||
clearMessagesBtn: document.getElementById("clearMessages"),
|
||||
clearChatMemoryBtn: document.getElementById("clearChatMemory"),
|
||||
checkHealthBtn: document.getElementById("checkHealth"),
|
||||
projectNameInput: document.getElementById("projectName"),
|
||||
chatNameInput: document.getElementById("chatName"),
|
||||
};
|
||||
|
||||
const PROJECT_ID_KEY = "onecAgent.projectId";
|
||||
const CHAT_BY_PROJECT_KEY = "onecAgent.chatByProject";
|
||||
|
||||
const REQUEST_TIMEOUT_MS = 120000;
|
||||
const TURN_TIMEOUT_MS = 90000;
|
||||
|
||||
const state = {
|
||||
projectId: "",
|
||||
chatId: "",
|
||||
chatByProject: {},
|
||||
};
|
||||
|
||||
function loadStateFromStorage() {
|
||||
try {
|
||||
const projectId = localStorage.getItem(PROJECT_ID_KEY);
|
||||
if (projectId) {
|
||||
state.projectId = projectId;
|
||||
}
|
||||
const map = localStorage.getItem(CHAT_BY_PROJECT_KEY);
|
||||
if (map) {
|
||||
const parsed = JSON.parse(map);
|
||||
if (parsed && typeof parsed === "object") {
|
||||
state.chatByProject = parsed;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
state.projectId = "";
|
||||
state.chatByProject = {};
|
||||
}
|
||||
}
|
||||
|
||||
function persistProjectId(projectId) {
|
||||
state.projectId = projectId || "";
|
||||
try {
|
||||
if (projectId) {
|
||||
localStorage.setItem(PROJECT_ID_KEY, projectId);
|
||||
} else {
|
||||
localStorage.removeItem(PROJECT_ID_KEY);
|
||||
}
|
||||
} catch {
|
||||
// no-op if localStorage unavailable
|
||||
}
|
||||
}
|
||||
|
||||
function persistChatId(projectId, chatId) {
|
||||
const map = state.chatByProject && typeof state.chatByProject === "object" ? { ...state.chatByProject } : {};
|
||||
if (projectId && chatId) {
|
||||
map[projectId] = chatId;
|
||||
} else if (projectId) {
|
||||
delete map[projectId];
|
||||
}
|
||||
state.chatByProject = map;
|
||||
state.chatId = chatId || "";
|
||||
try {
|
||||
localStorage.setItem(CHAT_BY_PROJECT_KEY, JSON.stringify(state.chatByProject));
|
||||
} catch {
|
||||
// no-op if localStorage unavailable
|
||||
}
|
||||
}
|
||||
|
||||
function removePersistedProject(projectId) {
|
||||
persistProjectId("");
|
||||
if (!projectId) {
|
||||
return;
|
||||
}
|
||||
const map = state.chatByProject && typeof state.chatByProject === "object" ? { ...state.chatByProject } : {};
|
||||
delete map[projectId];
|
||||
state.chatByProject = map;
|
||||
try {
|
||||
localStorage.setItem(CHAT_BY_PROJECT_KEY, JSON.stringify(state.chatByProject));
|
||||
} catch {
|
||||
// no-op if localStorage unavailable
|
||||
}
|
||||
}
|
||||
|
||||
function getApiBase() {
|
||||
return (els.apiBase.value || window.location.origin).replace(/\/$/, "");
|
||||
}
|
||||
|
||||
function setStatus(message) {
|
||||
els.healthStatus.textContent = message;
|
||||
}
|
||||
|
||||
function safeStringify(value) {
|
||||
try {
|
||||
return JSON.stringify(value, null, 2);
|
||||
} catch {
|
||||
return String(value ?? {});
|
||||
}
|
||||
}
|
||||
|
||||
function formatMetaTime(requestPayload, created_at, payload) {
|
||||
const rawTime = requestPayload?.createdAt
|
||||
? requestPayload.createdAt
|
||||
: (created_at || payload?.created_at || payload?.timestamp || null);
|
||||
if (!rawTime) {
|
||||
return new Date().toLocaleTimeString();
|
||||
}
|
||||
const dt = new Date(rawTime);
|
||||
return Number.isNaN(dt.getTime()) ? String(rawTime) : dt.toLocaleTimeString("ru-RU", { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
||||
}
|
||||
|
||||
function buildExchangeLog(role, payload) {
|
||||
const transport = payload && typeof payload === "object" ? payload.transport : null;
|
||||
const sections = [];
|
||||
if (role === "user" && transport?.outbound) {
|
||||
sections.push({
|
||||
title: "Что отправили модели",
|
||||
value: transport.outbound,
|
||||
});
|
||||
}
|
||||
if (role === "assistant") {
|
||||
if (transport?.inbound) {
|
||||
sections.push({
|
||||
title: "Что получили от модели",
|
||||
value: transport.inbound,
|
||||
});
|
||||
}
|
||||
if (payload?.raw !== undefined) {
|
||||
sections.push({
|
||||
title: "Полный ответ провайдера",
|
||||
value: payload.raw,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (payload?.route !== undefined) {
|
||||
sections.push({
|
||||
title: "Маршрут модели",
|
||||
value: payload.route,
|
||||
});
|
||||
}
|
||||
if (payload?.provider !== undefined) {
|
||||
sections.push({
|
||||
title: "Провайдер",
|
||||
value: payload.provider,
|
||||
});
|
||||
}
|
||||
return sections;
|
||||
}
|
||||
|
||||
function renderMessage({ role, content, payload, requestPayload, created_at }) {
|
||||
const wrapper = document.createElement("div");
|
||||
wrapper.className = `message ${role}`;
|
||||
const meta = document.createElement("div");
|
||||
meta.className = "meta";
|
||||
const timestamp = formatMetaTime(requestPayload, created_at, payload);
|
||||
meta.textContent = `${role} • ${timestamp}`;
|
||||
const text = document.createElement("div");
|
||||
text.textContent = content || "";
|
||||
wrapper.appendChild(meta);
|
||||
wrapper.appendChild(text);
|
||||
|
||||
const logs = buildExchangeLog(role, payload || {});
|
||||
if (logs.length) {
|
||||
const journal = document.createElement("details");
|
||||
journal.className = "journal";
|
||||
journal.open = false;
|
||||
const summary = document.createElement("summary");
|
||||
summary.textContent = "Журнал обмена";
|
||||
journal.appendChild(summary);
|
||||
logs.forEach((item) => {
|
||||
const heading = document.createElement("div");
|
||||
heading.className = "journal-title";
|
||||
heading.textContent = item.title;
|
||||
const pre = document.createElement("pre");
|
||||
pre.className = "mono";
|
||||
pre.textContent = safeStringify(item.value);
|
||||
journal.appendChild(heading);
|
||||
journal.appendChild(pre);
|
||||
});
|
||||
wrapper.appendChild(journal);
|
||||
}
|
||||
els.messages.appendChild(wrapper);
|
||||
}
|
||||
|
||||
async function apiRequest(path, options = {}) {
|
||||
const controller = new AbortController();
|
||||
const { timeoutMs, ...fetchOptions } = options;
|
||||
const effectiveTimeoutMs = timeoutMs ?? REQUEST_TIMEOUT_MS;
|
||||
const timeout = setTimeout(() => controller.abort(), effectiveTimeoutMs);
|
||||
const url = `${getApiBase()}${path}`;
|
||||
let response;
|
||||
try {
|
||||
try {
|
||||
response = await fetch(url, {
|
||||
...fetchOptions,
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(fetchOptions.headers || {}),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
if (error?.name === "AbortError") {
|
||||
throw new Error(`Превышен таймаут запроса (${Math.round(effectiveTimeoutMs / 1000)} сек)`);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
const bodyText = await response.text();
|
||||
const body = bodyText ? JSON.parse(bodyText) : {};
|
||||
if (!response.ok) {
|
||||
const message = body?.error?.message || `HTTP ${response.status}`;
|
||||
throw new Error(message);
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
async function checkHealth() {
|
||||
const data = await apiRequest("/v1/health");
|
||||
setStatus(`health ok: ${data.status}, service=${data.service}`);
|
||||
return data;
|
||||
}
|
||||
|
||||
async function loadProjects() {
|
||||
const data = await apiRequest("/v1/projects");
|
||||
const projects = data.projects || [];
|
||||
els.projectSelect.innerHTML = "";
|
||||
projects.forEach((project) => {
|
||||
const option = document.createElement("option");
|
||||
option.value = project.id;
|
||||
option.textContent = project.name;
|
||||
els.projectSelect.appendChild(option);
|
||||
});
|
||||
if (!projects.length) {
|
||||
state.projectId = "";
|
||||
state.chatId = "";
|
||||
persistProjectId("");
|
||||
persistChatId("", "");
|
||||
els.projectNameInput.value = "";
|
||||
els.chatNameInput.value = "";
|
||||
els.chatSelect.innerHTML = "";
|
||||
return false;
|
||||
}
|
||||
if (!state.projectId || !projects.some((p) => p.id === state.projectId)) {
|
||||
state.projectId = projects[0].id;
|
||||
}
|
||||
persistProjectId(state.projectId);
|
||||
els.projectSelect.value = state.projectId;
|
||||
const currentProject = projects.find((project) => project.id === state.projectId);
|
||||
if (currentProject) {
|
||||
els.projectNameInput.value = currentProject.name || "";
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async function loadChats() {
|
||||
if (!state.projectId) {
|
||||
els.chatSelect.innerHTML = "";
|
||||
state.chatId = "";
|
||||
els.chatNameInput.value = "";
|
||||
return;
|
||||
}
|
||||
const chats = await apiRequest(`/v1/projects/${state.projectId}/chats`);
|
||||
const list = chats.chats || [];
|
||||
els.chatSelect.innerHTML = "";
|
||||
list.forEach((chat) => {
|
||||
const option = document.createElement("option");
|
||||
option.value = chat.id;
|
||||
option.textContent = chat.title;
|
||||
els.chatSelect.appendChild(option);
|
||||
});
|
||||
if (!list.length) {
|
||||
state.chatId = "";
|
||||
els.chatNameInput.value = "";
|
||||
els.chatSelect.innerHTML = "";
|
||||
persistChatId(state.projectId, "");
|
||||
return;
|
||||
}
|
||||
|
||||
const savedChatId = state.chatByProject[state.projectId];
|
||||
if (!state.chatId || !list.some((c) => c.id === state.chatId)) {
|
||||
state.chatId = savedChatId || "";
|
||||
}
|
||||
if (!state.chatId || !list.some((c) => c.id === state.chatId)) {
|
||||
state.chatId = list[0].id;
|
||||
}
|
||||
els.chatSelect.value = state.chatId;
|
||||
const activeChat = list.find((chat) => chat.id === state.chatId);
|
||||
if (activeChat) {
|
||||
els.chatNameInput.value = activeChat.title || "";
|
||||
}
|
||||
persistChatId(state.projectId, state.chatId);
|
||||
}
|
||||
|
||||
async function loadMessages() {
|
||||
if (!state.projectId || !state.chatId) {
|
||||
els.messages.innerHTML = "";
|
||||
const placeholder = document.createElement("div");
|
||||
placeholder.className = "message system";
|
||||
if (!state.projectId) {
|
||||
placeholder.textContent = "Нет проекта. Создайте проект вручную.";
|
||||
} else if (!state.chatId) {
|
||||
placeholder.textContent = "В выбранном проекте нет чатов. Создайте чат вручную.";
|
||||
}
|
||||
els.messages.appendChild(placeholder);
|
||||
return;
|
||||
}
|
||||
const data = await apiRequest(`/v1/projects/${state.projectId}/chats/${state.chatId}/messages?limit=200`);
|
||||
const messages = data.messages || [];
|
||||
els.messages.innerHTML = "";
|
||||
const orderedMessages = [...messages].reverse();
|
||||
orderedMessages.forEach((message) => {
|
||||
renderMessage(message);
|
||||
});
|
||||
if (!messages.length) {
|
||||
const placeholder = document.createElement("div");
|
||||
placeholder.className = "message system";
|
||||
placeholder.textContent = "История чата пуста. Напишите первый вопрос.";
|
||||
els.messages.appendChild(placeholder);
|
||||
}
|
||||
els.messages.scrollTop = 0;
|
||||
}
|
||||
|
||||
async function refreshConversation() {
|
||||
const hasProjects = await loadProjects();
|
||||
if (!hasProjects) {
|
||||
await loadMessages();
|
||||
return;
|
||||
}
|
||||
await loadChats();
|
||||
await loadMessages();
|
||||
}
|
||||
|
||||
async function createChat() {
|
||||
if (!state.projectId) {
|
||||
return;
|
||||
}
|
||||
const created = await apiRequest(`/v1/projects/${state.projectId}/chats`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
title: els.chatNameInput.value.trim() || `Веб-чат ${new Date().toLocaleTimeString("ru-RU")}`,
|
||||
}),
|
||||
});
|
||||
state.chatId = created.chat.id;
|
||||
persistChatId(state.projectId, state.chatId);
|
||||
await loadChats();
|
||||
await loadMessages();
|
||||
}
|
||||
|
||||
async function sendTurn(messageText) {
|
||||
await apiRequest(`/v1/projects/${state.projectId}/chats/${state.chatId}/turn`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
message: messageText,
|
||||
}),
|
||||
timeoutMs: TURN_TIMEOUT_MS,
|
||||
});
|
||||
await loadMessages();
|
||||
}
|
||||
|
||||
function formatTimeoutMessage(durationMs) {
|
||||
const seconds = Math.max(1, Math.round(durationMs / 1000));
|
||||
return `Ожидаем ответ модели ~${seconds} сек...`;
|
||||
}
|
||||
|
||||
function setSendingFeedback(startTs) {
|
||||
const tick = () => {
|
||||
const elapsed = Date.now() - startTs;
|
||||
if (els.sendButton.disabled) {
|
||||
setStatus(formatTimeoutMessage(elapsed));
|
||||
}
|
||||
};
|
||||
const timer = setInterval(tick, 12000);
|
||||
return () => clearInterval(timer);
|
||||
}
|
||||
|
||||
els.checkHealthBtn.addEventListener("click", async () => {
|
||||
try {
|
||||
await checkHealth();
|
||||
} catch {
|
||||
// handled in function
|
||||
}
|
||||
});
|
||||
|
||||
els.refreshProjectsBtn.addEventListener("click", async () => {
|
||||
await refreshConversation();
|
||||
});
|
||||
|
||||
els.createProjectBtn.addEventListener("click", async () => {
|
||||
const now = new Date().toLocaleString("ru-RU");
|
||||
const customName = els.projectNameInput.value.trim();
|
||||
const created = await apiRequest("/v1/projects", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
name: customName || `Проект ${now}`,
|
||||
description: "Создано из веб-интерфейса",
|
||||
}),
|
||||
});
|
||||
state.projectId = created.project.id;
|
||||
persistProjectId(state.projectId);
|
||||
persistChatId(state.projectId, "");
|
||||
await refreshConversation();
|
||||
});
|
||||
|
||||
els.createChatBtn.addEventListener("click", async () => {
|
||||
if (!state.projectId) {
|
||||
return;
|
||||
}
|
||||
await createChat();
|
||||
});
|
||||
|
||||
els.deleteProjectBtn.addEventListener("click", async () => {
|
||||
if (!state.projectId) {
|
||||
return;
|
||||
}
|
||||
const projectName = els.projectNameInput.value.trim() || "текущий проект";
|
||||
if (!window.confirm(`Удалить проект «${projectName}» и все его чаты?`)) {
|
||||
return;
|
||||
}
|
||||
await apiRequest(`/v1/projects/${state.projectId}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
removePersistedProject(state.projectId);
|
||||
state.projectId = "";
|
||||
state.chatId = "";
|
||||
els.projectNameInput.value = "";
|
||||
els.chatNameInput.value = "";
|
||||
await refreshConversation();
|
||||
});
|
||||
|
||||
els.renameProjectBtn.addEventListener("click", async () => {
|
||||
if (!state.projectId) {
|
||||
return;
|
||||
}
|
||||
const newName = els.projectNameInput.value.trim();
|
||||
if (!newName) {
|
||||
return;
|
||||
}
|
||||
await apiRequest(`/v1/projects/${state.projectId}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ name: newName }),
|
||||
});
|
||||
await refreshConversation();
|
||||
});
|
||||
|
||||
els.renameChatBtn.addEventListener("click", async () => {
|
||||
if (!state.projectId || !state.chatId) {
|
||||
return;
|
||||
}
|
||||
const newTitle = els.chatNameInput.value.trim();
|
||||
if (!newTitle) {
|
||||
return;
|
||||
}
|
||||
await apiRequest(`/v1/projects/${state.projectId}/chats/${state.chatId}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ title: newTitle }),
|
||||
});
|
||||
await refreshConversation();
|
||||
});
|
||||
|
||||
els.projectSelect.addEventListener("change", async () => {
|
||||
state.projectId = els.projectSelect.value;
|
||||
state.chatId = "";
|
||||
persistProjectId(state.projectId);
|
||||
persistChatId(state.projectId, "");
|
||||
await refreshConversation();
|
||||
});
|
||||
|
||||
els.chatSelect.addEventListener("change", async () => {
|
||||
state.chatId = els.chatSelect.value;
|
||||
persistChatId(state.projectId, state.chatId);
|
||||
await loadMessages();
|
||||
});
|
||||
|
||||
els.clearMessagesBtn.addEventListener("click", () => {
|
||||
els.messages.innerHTML = "";
|
||||
const placeholder = document.createElement("div");
|
||||
placeholder.className = "message system";
|
||||
if (!state.projectId) {
|
||||
placeholder.textContent = "Нет проекта. Создайте проект вручную.";
|
||||
} else if (!state.chatId) {
|
||||
placeholder.textContent = "В выбранном проекте нет чатов. Создайте чат вручную.";
|
||||
} else {
|
||||
placeholder.textContent = "История чата очищена в интерфейсе.";
|
||||
}
|
||||
els.messages.appendChild(placeholder);
|
||||
});
|
||||
|
||||
els.clearChatMemoryBtn.addEventListener("click", async () => {
|
||||
if (!state.projectId || !state.chatId) {
|
||||
return;
|
||||
}
|
||||
await apiRequest(`/v1/projects/${state.projectId}/chats/${state.chatId}/messages`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
await loadMessages();
|
||||
});
|
||||
|
||||
els.messageInput.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Enter" && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
if (!els.messageInput.value.trim()) {
|
||||
return;
|
||||
}
|
||||
els.chatForm.requestSubmit();
|
||||
}
|
||||
});
|
||||
|
||||
els.chatForm.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
const messageText = els.messageInput.value.trim();
|
||||
if (!messageText) {
|
||||
return;
|
||||
}
|
||||
if (!state.projectId) {
|
||||
setStatus("Сначала выберите или создайте проект.");
|
||||
return;
|
||||
}
|
||||
if (!state.chatId) {
|
||||
setStatus("Сначала выберите или создайте чат.");
|
||||
return;
|
||||
}
|
||||
|
||||
els.sendButton.disabled = true;
|
||||
setStatus("Отправляю сообщение...");
|
||||
const stopSendingFeedback = setSendingFeedback(Date.now());
|
||||
try {
|
||||
await sendTurn(messageText);
|
||||
els.messageInput.value = "";
|
||||
} catch (error) {
|
||||
setStatus(`Ошибка отправки: ${error.message}`);
|
||||
} finally {
|
||||
stopSendingFeedback();
|
||||
els.sendButton.disabled = false;
|
||||
if (!els.healthStatus.textContent.startsWith("Ошибка отправки") && !els.healthStatus.textContent.startsWith("Ожидаем")) {
|
||||
setStatus("Готов");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
els.apiBase.value = window.location.origin;
|
||||
|
||||
(async function init() {
|
||||
loadStateFromStorage();
|
||||
try {
|
||||
await checkHealth();
|
||||
await refreshConversation();
|
||||
} catch (error) {
|
||||
setStatus(`Не удалось инициализироваться: ${error.message}`);
|
||||
}
|
||||
})();
|
||||
Reference in New Issue
Block a user