Initial SQL-only 1C adapter baseline

This commit is contained in:
2026-07-22 03:03:47 +03:00
commit e2503b77e7
545 changed files with 184711 additions and 0 deletions
+566
View File
@@ -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}`);
}
})();
+220
View File
@@ -0,0 +1,220 @@
:root {
color-scheme: light;
font-family: Arial, Helvetica, sans-serif;
font-size: 14px;
}
body {
margin: 0;
background: #f2f3f7;
color: #111827;
min-height: 100vh;
}
.shell {
max-width: 1400px;
min-height: 100vh;
margin: 0 auto;
padding: 16px;
display: grid;
grid-template-columns: 320px minmax(0, 1fr);
gap: 12px;
align-items: start;
}
.panel {
background: #ffffff;
border: 1px solid #d1d5db;
border-radius: 8px;
padding: 12px;
}
.sidebar {
position: sticky;
top: 16px;
}
.chat-area {
display: grid;
gap: 12px;
min-height: calc(100vh - 32px);
}
.section + .section {
margin-top: 12px;
}
.row {
display: flex;
gap: 10px;
align-items: center;
flex-wrap: wrap;
margin: 6px 0;
}
label {
display: grid;
gap: 4px;
font-size: 13px;
color: #374151;
}
input[type="text"],
select,
textarea {
min-width: 220px;
border: 1px solid #9ca3af;
border-radius: 6px;
padding: 7px 8px;
}
textarea {
width: 100%;
resize: vertical;
}
button {
min-height: 34px;
}
.button {
border: 1px solid #9ca3af;
background: #ffffff;
border-radius: 6px;
color: #111827;
padding: 8px 12px;
}
.button-primary {
background: #111827;
color: #ffffff;
border-color: #111827;
}
.button-danger {
background: #b91c1c;
color: #ffffff;
border-color: #b91c1c;
}
.status {
font-size: 12px;
color: #4b5563;
margin-top: 6px;
}
.chat-panel {
display: grid;
gap: 10px;
}
.messages {
min-height: 320px;
max-height: 64vh;
border: 1px solid #d1d5db;
border-radius: 8px;
padding: 10px;
background: #fafafa;
overflow: auto;
}
.message {
margin-bottom: 10px;
padding: 8px;
border-radius: 6px;
border: 1px solid #e5e7eb;
}
.message.user {
border-left: 3px solid #2563eb;
background: #eff6ff;
}
.message.assistant {
border-left: 3px solid #059669;
background: #ecfdf5;
}
.message.system {
border-left: 3px solid #d97706;
background: #fffbeb;
}
.message .meta {
font-size: 11px;
color: #6b7280;
margin-bottom: 6px;
}
.mono {
white-space: pre-wrap;
overflow: auto;
max-height: 220px;
background: #0f172a;
color: #f8fafc;
padding: 10px;
border-radius: 6px;
}
.composer {
display: grid;
gap: 8px;
}
.composer-actions {
display: flex;
gap: 8px;
}
.chat-panel {
display: flex;
min-height: 0;
align-items: stretch;
}
.inline-toggle {
display: flex;
flex-direction: row;
align-items: center;
}
.journal {
margin-top: 8px;
border: 1px dashed #d1d5db;
border-radius: 6px;
padding: 6px 8px;
background: #f8fafc;
}
.journal > summary {
cursor: pointer;
font-size: 12px;
color: #4b5563;
margin-bottom: 6px;
}
.journal-title {
margin-top: 8px;
font-size: 11px;
color: #374151;
font-weight: 600;
}
select,
input[type="text"] {
width: 100%;
}
@media (max-width: 1024px) {
.shell {
grid-template-columns: 1fr;
}
.sidebar {
position: static;
}
.chat-area {
min-height: auto;
}
}
+81
View File
@@ -0,0 +1,81 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>1С Agent</title>
<link rel="stylesheet" href="/ui/assets/style.css" />
</head>
<body>
<main class="shell">
<aside class="sidebar panel" aria-label="Боковая панель настроек">
<h1>1С Agent</h1>
<p>Раздел настроек</p>
<section class="section">
<h2>Соединение</h2>
<div class="row">
<label>
API URL
<input id="apiBase" type="text" value="" />
</label>
</div>
<button id="checkHealth" class="button button-primary">Проверить /v1/health</button>
<div class="status" id="healthStatus">Инициализация…</div>
</section>
<section class="section">
<h2>Контекст</h2>
<label>
Новое имя проекта
<input id="projectName" type="text" value="" placeholder="Название проекта" />
</label>
<label>
Проект
<select id="projectSelect"></select>
</label>
<div class="row">
<button id="renameProject" class="button">Переименовать</button>
<button id="refreshProjects" class="button">Обновить</button>
<button id="createProject" class="button button-primary">Новый проект</button>
<button id="deleteProject" class="button button-danger" type="button">Удалить проект</button>
</div>
<label>
Новое имя чата
<input id="chatName" type="text" value="" placeholder="Название чата" />
</label>
<label>
Чат
<select id="chatSelect"></select>
</label>
<div class="row">
<button id="renameChat" class="button">Переименовать</button>
<button id="createChat" class="button button-primary">Новый чат</button>
<button id="clearMessages" class="button" type="button">Очистить экран</button>
<button id="clearChatMemory" class="button button-danger" type="button">Очистить чат полностью</button>
</div>
</section>
</aside>
<section class="chat-area">
<section class="panel">
<h2>Чат</h2>
<form id="chatForm" class="composer">
<textarea id="messageInput" rows="2" placeholder="Введите вопрос по 1С"></textarea>
<div class="composer-actions">
<button id="sendButton" class="button button-primary" type="submit">Отправить</button>
</div>
</form>
</section>
<section class="panel chat-panel">
<div id="messages" class="messages" aria-live="polite"></div>
</section>
</section>
</main>
<script src="/ui/assets/app.js"></script>
</body>
</html>