Integrate backend APIs and move review to center editor tab

This commit is contained in:
2026-02-24 14:22:39 +03:00
parent fe67753f74
commit 91a0a50b04
13 changed files with 1010 additions and 152 deletions

23
src/core/ApiHttpClient.js Normal file
View File

@@ -0,0 +1,23 @@
export class ApiHttpClient {
constructor(baseUrl = null) {
const envBase = window.__API_BASE_URL__ || null;
this.baseUrl = (baseUrl || envBase || "http://localhost:8081").replace(/\/$/, "");
}
async request(path, options = {}) {
const response = await fetch(`${this.baseUrl}${path}`, {
...options,
headers: {
"Content-Type": "application/json",
...(options.headers || {})
}
});
const isJson = (response.headers.get("content-type") || "").includes("application/json");
const body = isJson ? await response.json() : null;
if (!response.ok) {
const desc = body?.desc || body?.detail || `HTTP ${response.status}`;
throw new Error(desc);
}
return body;
}
}

45
src/core/ChatClientApi.js Normal file
View File

@@ -0,0 +1,45 @@
import { ApiHttpClient } from "./ApiHttpClient.js";
export class ChatClientApi {
constructor(http = new ApiHttpClient(), pollMs = 700, timeoutMs = 120000) {
this.http = http;
this.pollMs = pollMs;
this.timeoutMs = timeoutMs;
}
async createDialog(ragSessionId) {
const response = await this.http.request("/api/chat/dialogs", {
method: "POST",
body: JSON.stringify({ rag_session_id: ragSessionId })
});
return response.dialog_session_id;
}
async sendMessage(payload) {
const queued = await this.http.request("/api/chat/messages", {
method: "POST",
body: JSON.stringify({
dialog_session_id: payload.dialog_session_id,
rag_session_id: payload.rag_session_id,
message: payload.message,
attachments: payload.attachments || []
})
});
const taskId = queued.task_id;
const started = Date.now();
while (Date.now() - started < this.timeoutMs) {
const status = await this.http.request(`/api/tasks/${encodeURIComponent(taskId)}`);
if (status.status === "done") return status;
if (status.status === "error") {
throw new Error(status.error?.desc || "Task failed");
}
await this.#sleep(this.pollMs);
}
throw new Error("Task polling timeout");
}
#sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
}

View File

@@ -37,6 +37,45 @@ export class FileSaveService {
return { mode: "download", path: normalizedPath };
}
async saveExistingFile(projectStore, path, content) {
const normalizedPath = PathUtils.normalizeRelative(path);
if (projectStore.rootHandle) {
await this.#writeWithRootHandle(projectStore.rootHandle, normalizedPath, content);
return { mode: "inplace", path: normalizedPath };
}
const knownHandle = projectStore.fileHandles.get(normalizedPath) || this.fallbackHandles.get(normalizedPath);
if (knownHandle && typeof knownHandle.createWritable === "function") {
await this.#writeWithFileHandle(knownHandle, content);
return { mode: "inplace", path: normalizedPath };
}
throw new Error("Нет доступа к существующему файлу для записи без выбора новой директории.");
}
async saveNewFileWithPicker(projectStore, suggestedPath, content) {
if (typeof window.showSaveFilePicker !== "function") {
throw new Error("showSaveFilePicker is not supported");
}
const normalizedPath = PathUtils.normalizeRelative(suggestedPath);
const pickerOptions = {
suggestedName: PathUtils.basename(normalizedPath),
id: this.#buildProjectSaveId(projectStore)
};
if (projectStore.rootHandle) pickerOptions.startIn = projectStore.rootHandle;
const handle = await window.showSaveFilePicker(pickerOptions);
await this.#writeWithFileHandle(handle, content);
const resolvedPath = await this.#resolveRelativePath(projectStore.rootHandle, handle);
if (!resolvedPath) {
throw new Error("Выберите путь внутри директории проекта.");
}
this.fallbackHandles.set(resolvedPath, handle);
return { mode: "save_as", path: resolvedPath };
}
async deleteFile(projectStore, path) {
const normalizedPath = PathUtils.normalizeRelative(path);
if (!projectStore.rootHandle) return false;
@@ -89,4 +128,11 @@ export class FileSaveService {
const normalized = projectName.toLowerCase().replaceAll(/[^a-z0-9_-]/g, "-").replaceAll(/-+/g, "-");
return `save-${normalized || "project"}`;
}
async #resolveRelativePath(rootHandle, fileHandle) {
if (!rootHandle || typeof rootHandle.resolve !== "function") return fileHandle?.name || "";
const parts = await rootHandle.resolve(fileHandle);
if (!Array.isArray(parts) || !parts.length) return "";
return PathUtils.normalizeRelative(parts.join("/"));
}
}

View File

@@ -0,0 +1,46 @@
import { ApiHttpClient } from "./ApiHttpClient.js";
export class IndexingClientApi {
constructor(http = new ApiHttpClient(), pollMs = 700, timeoutMs = 120000) {
this.http = http;
this.pollMs = pollMs;
this.timeoutMs = timeoutMs;
}
async submitSnapshot(projectId, files) {
const queued = await this.http.request("/api/rag/sessions", {
method: "POST",
body: JSON.stringify({ project_id: projectId, files })
});
const status = await this.#pollRagJob(queued.rag_session_id, queued.index_job_id);
return { ...status, rag_session_id: queued.rag_session_id };
}
async submitChanges(ragSessionId, changedFiles) {
const queued = await this.http.request(`/api/rag/sessions/${encodeURIComponent(ragSessionId)}/changes`, {
method: "POST",
body: JSON.stringify({ changed_files: changedFiles })
});
const status = await this.#pollRagJob(ragSessionId, queued.index_job_id);
return { ...status, rag_session_id: ragSessionId };
}
async #pollRagJob(ragSessionId, jobId) {
const started = Date.now();
while (Date.now() - started < this.timeoutMs) {
const status = await this.http.request(
`/api/rag/sessions/${encodeURIComponent(ragSessionId)}/jobs/${encodeURIComponent(jobId)}`
);
if (status.status === "done") return status;
if (status.status === "error") {
throw new Error(status.error?.desc || "Indexing failed");
}
await this.#sleep(this.pollMs);
}
throw new Error("Index polling timeout");
}
#sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
}

View File

@@ -4,11 +4,20 @@ export class MarkdownRenderer {
const lines = source.replace(/\r\n/g, "\n").split("\n");
let html = "";
let inCode = false;
let codeFenceLang = "";
for (const line of lines) {
if (line.startsWith("```")) {
const fenceLang = this.#sanitizeCodeLang(line.slice(3).trim().toLowerCase());
inCode = !inCode;
html += inCode ? "<pre><code>" : "</code></pre>";
if (inCode) {
codeFenceLang = fenceLang;
const className = codeFenceLang ? ` class="language-${codeFenceLang}"` : "";
html += `<pre><code${className}>`;
} else {
html += "</code></pre>";
codeFenceLang = "";
}
continue;
}
@@ -57,4 +66,10 @@ export class MarkdownRenderer {
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}
#sanitizeCodeLang(value) {
return String(value || "")
.replace(/[^a-z0-9_-]/g, "")
.slice(0, 24);
}
}

View File

@@ -0,0 +1,56 @@
export class MermaidRenderer {
constructor() {
this.mermaid = null;
this.loadPromise = null;
}
async render(container) {
if (!container) return;
const codeNodes = [...container.querySelectorAll("pre > code.language-mermaid")];
if (!codeNodes.length) return;
const mermaid = await this.#ensureLoaded();
if (!mermaid) return;
for (const code of codeNodes) {
const pre = code.parentElement;
if (!pre) continue;
const diagram = document.createElement("div");
diagram.className = "mermaid";
diagram.textContent = code.textContent || "";
pre.replaceWith(diagram);
}
const nodes = [...container.querySelectorAll(".mermaid")];
if (!nodes.length) return;
try {
await mermaid.run({ nodes, suppressErrors: true });
} catch {
for (const node of nodes) {
if (node.querySelector("svg")) continue;
node.classList.add("mermaid-error");
}
}
}
async #ensureLoaded() {
if (this.mermaid) return this.mermaid;
if (this.loadPromise) return this.loadPromise;
this.loadPromise = import("https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs")
.then((module) => {
const mermaid = module.default || module;
mermaid.initialize({
startOnLoad: false,
theme: "dark",
securityLevel: "loose"
});
this.mermaid = mermaid;
return mermaid;
})
.catch(() => null);
return this.loadPromise;
}
}

View File

@@ -6,16 +6,16 @@ export class ProjectScanner {
this.hashService = hashService;
}
async scan(rootHandle) {
async scan(rootHandle, onProgress = null) {
const rootNode = { name: rootHandle.name, path: "", type: "dir", children: [] };
const files = new Map();
const fileHandles = new Map();
const stats = { totalFileCount: 0, totalBytes: 0 };
await this.#scanDir(rootHandle, "", rootNode, files, fileHandles, stats);
const stats = { totalFileCount: 0, totalBytes: 0, scannedCount: 0 };
await this.#scanDir(rootHandle, "", rootNode, files, fileHandles, stats, onProgress);
return { rootNode, files, fileHandles, projectName: rootHandle.name, ...stats };
}
async scanFromFileList(fileList) {
async scanFromFileList(fileList, onProgress = null) {
const entries = [];
for (const file of fileList) {
const relRaw = file.webkitRelativePath || file.name;
@@ -36,9 +36,13 @@ export class ProjectScanner {
const files = new Map();
let totalBytes = 0;
for (const entry of entries) {
for (let i = 0; i < entries.length; i += 1) {
const entry = entries[i];
const relativePath = this.#stripPrefix(entry.path, rootPrefix);
if (!relativePath) continue;
if (typeof onProgress === "function") {
onProgress({ path: relativePath, done: i + 1, total: entries.length });
}
totalBytes += Number(entry.file.size || 0);
this.#insertPath(rootNode, relativePath, entry.file.size);
@@ -61,7 +65,7 @@ export class ProjectScanner {
};
}
async #scanDir(dirHandle, currentPath, node, files, fileHandles, stats) {
async #scanDir(dirHandle, currentPath, node, files, fileHandles, stats, onProgress) {
for await (const [name, handle] of dirHandle.entries()) {
const relPath = currentPath ? `${currentPath}/${name}` : name;
const normalizedRelPath = PathUtils.normalizeRelative(relPath);
@@ -70,13 +74,17 @@ export class ProjectScanner {
if (handle.kind === "directory") {
const child = { name, path: normalizedRelPath, type: "dir", children: [] };
node.children.push(child);
await this.#scanDir(handle, normalizedRelPath, child, files, fileHandles, stats);
await this.#scanDir(handle, normalizedRelPath, child, files, fileHandles, stats, onProgress);
continue;
}
const file = await handle.getFile();
stats.totalFileCount += 1;
stats.scannedCount += 1;
stats.totalBytes += Number(file.size || 0);
if (typeof onProgress === "function") {
onProgress({ path: normalizedRelPath, done: stats.scannedCount, total: null });
}
const isSupported = this.textPolicy.isSupportedFile(normalizedRelPath, file.size);
node.children.push({ name, path: normalizedRelPath, type: "file", size: file.size, supported: isSupported });
fileHandles.set(normalizedRelPath, handle);

View File

@@ -1,6 +1,6 @@
export class TextFilePolicy {
constructor() {
this.maxSizeBytes = 5 * 1024 * 1024;
this.maxSizeBytes = 1 * 1024 * 1024;
this.allowedExtensions = new Set([
".txt", ".md", ".json", ".xml", ".yaml", ".yml", ".js", ".jsx", ".ts", ".tsx",
".css", ".html", ".py", ".java", ".go", ".rs", ".sql", ".sh", ".env", ".ini", ".toml"