Integrate backend APIs and move review to center editor tab
This commit is contained in:
23
src/core/ApiHttpClient.js
Normal file
23
src/core/ApiHttpClient.js
Normal 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
45
src/core/ChatClientApi.js
Normal 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));
|
||||
}
|
||||
}
|
||||
@@ -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("/"));
|
||||
}
|
||||
}
|
||||
|
||||
46
src/core/IndexingClientApi.js
Normal file
46
src/core/IndexingClientApi.js
Normal 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));
|
||||
}
|
||||
}
|
||||
@@ -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('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
#sanitizeCodeLang(value) {
|
||||
return String(value || "")
|
||||
.replace(/[^a-z0-9_-]/g, "")
|
||||
.slice(0, 24);
|
||||
}
|
||||
}
|
||||
|
||||
56
src/core/MermaidRenderer.js
Normal file
56
src/core/MermaidRenderer.js
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user