первй коммит

This commit is contained in:
2026-02-27 21:26:26 +03:00
parent e400b44732
commit ca00e6bbc8
19 changed files with 2701 additions and 257 deletions

View File

@@ -1,23 +1,115 @@
export class ApiHttpClient {
constructor(baseUrl = null) {
const envBase = window.__API_BASE_URL__ || null;
this.baseUrl = (baseUrl || envBase || "http://localhost:8081").replace(/\/$/, "");
const resolved = (baseUrl || envBase || "http://localhost:15000").replace(/\/$/, "");
this.baseUrl = resolved;
this.autoFallbackEnabled = !baseUrl && !envBase;
this.fallbackBaseUrls = this.#buildFallbackBaseUrls(resolved);
}
async request(path, options = {}) {
const response = await fetch(`${this.baseUrl}${path}`, {
...options,
headers: {
"Content-Type": "application/json",
...(options.headers || {})
const attempted = new Set();
let lastError = null;
const candidates = [this.baseUrl, ...this.fallbackBaseUrls];
const method = String(options.method || "GET").toUpperCase();
for (const base of candidates) {
if (!base || attempted.has(base)) continue;
attempted.add(base);
try {
const response = await fetch(`${base}${path}`, {
...options,
headers: {
"Content-Type": "application/json",
...(options.headers || {})
}
});
const body = await this.#readResponseBody(response);
if (!response.ok) {
const backendMessage = this.#extractBackendMessage(body);
const fallbackMessage = `HTTP ${response.status}`;
const error = new Error(backendMessage || fallbackMessage);
error.name = "ApiHttpError";
error.status = response.status;
error.path = path;
error.method = method;
error.responseBody = body;
error.backendMessage = backendMessage || "";
throw error;
}
if (base !== this.baseUrl) {
this.baseUrl = base;
this.fallbackBaseUrls = this.#buildFallbackBaseUrls(base);
}
return body;
} catch (error) {
lastError = error;
if (!this.autoFallbackEnabled || !this.#isNetworkError(error)) {
throw error;
}
}
});
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;
throw lastError || new Error("HTTP request failed");
}
resolveUrl(path) {
return `${this.baseUrl}${path}`;
}
#isNetworkError(error) {
const message = String(error?.message || "");
return (
message.includes("Failed to fetch") ||
message.includes("NetworkError") ||
message.includes("Load failed") ||
message.includes("fetch")
);
}
#buildFallbackBaseUrls(currentBase) {
if (!this.autoFallbackEnabled) return [];
let parsed;
try {
parsed = new URL(currentBase);
} catch {
return [];
}
const host = parsed.hostname || "localhost";
const protocol = parsed.protocol || "http:";
const preferredPorts = ["15000", "8081", "8000"];
return preferredPorts
.filter((port) => port !== parsed.port)
.map((port) => `${protocol}//${host}:${port}`);
}
async #readResponseBody(response) {
const contentType = (response.headers.get("content-type") || "").toLowerCase();
if (contentType.includes("application/json")) {
try {
return await response.json();
} catch {
return null;
}
}
const text = await response.text();
const trimmed = String(text || "").trim();
if (!trimmed) return null;
try {
return JSON.parse(trimmed);
} catch {
return { detail: trimmed };
}
}
#extractBackendMessage(body) {
if (!body || typeof body !== "object") return "";
const message =
body?.error?.desc ||
body?.error?.message ||
body?.desc ||
body?.detail ||
body?.message ||
"";
return typeof message === "string" ? message.trim() : "";
}
}

View File

@@ -5,9 +5,10 @@ export class ApplyEngine {
this.hashService = hashService;
}
async applyAccepted(projectStore, reviewStore, changeMap) {
async applyAccepted(projectStore, reviewStore, changeMap, onlyPaths = null) {
const changedFiles = [];
for (const path of reviewStore.acceptedPaths()) {
const paths = Array.isArray(onlyPaths) ? onlyPaths : reviewStore.acceptedPaths();
for (const path of paths) {
const change = changeMap.get(path);
const review = reviewStore.get(path);
if (!change || !review) continue;
@@ -18,9 +19,7 @@ export class ApplyEngine {
continue;
}
if (change.op === "delete") {
const confirmed = window.confirm(`Удалить файл ${path}?`);
if (!confirmed) continue;
if (change.op === "delete" && review.status === "accepted_full") {
await this.#deleteFile(projectStore.rootHandle, path);
projectStore.removeFile(path);
reviewStore.markApplied(path);
@@ -39,25 +38,23 @@ export class ApplyEngine {
}
#composeContent(change, review, currentContent) {
if (review.status === "rejected") return currentContent;
if (review.status === "accepted_full") return change.proposed_content;
if (change.op === "create") return change.proposed_content;
const localLines = currentContent.replace(/\r\n/g, "\n").split("\n");
const output = [];
for (const op of change.diffOps) {
const accepted = review.acceptedOpIds.has(op.id);
const accepted = review.acceptedBlockIds.has(op.blockId);
if (op.kind === "equal") output.push(op.oldLine);
else if (op.kind === "add" && accepted) output.push(op.newLine);
else if (op.kind === "remove" && !accepted) output.push(op.oldLine);
}
const merged = output.join("\n");
if (!merged.length) return localLines.join("\n");
return merged;
return merged.length ? merged : currentContent;
}
async #checkConflict(projectStore, change) {
const file = projectStore.files.get(change.path);
const file = projectStore.getFile(change.path);
if (change.op === "create") {
return { ok: !file, currentContent: "" };

View File

@@ -1,10 +1,12 @@
import { ApiHttpClient } from "./ApiHttpClient.js";
import { TaskEventsSseClient } from "./TaskEventsSseClient.js";
export class ChatClientApi {
constructor(http = new ApiHttpClient(), pollMs = 700, timeoutMs = 120000) {
constructor(http = new ApiHttpClient(), pollMs = 700, timeoutMs = 120000, events = null) {
this.http = http;
this.pollMs = pollMs;
this.timeoutMs = timeoutMs;
this.events = events || new TaskEventsSseClient(this.http);
}
async createDialog(ragSessionId) {
@@ -15,30 +17,118 @@ export class ChatClientApi {
return response.dialog_session_id;
}
async sendMessage(payload) {
async sendMessage(payload, handlers = {}) {
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 || []
attachments: payload.attachments || [],
mode: payload.mode || "auto",
files: payload.files || []
})
});
const taskId = queued.task_id;
const onEvent = typeof handlers.onEvent === "function" ? handlers.onEvent : null;
if (onEvent) onEvent({ kind: "queued", task_id: taskId });
const sse = this.events.open(taskId, onEvent);
try {
const firstResult = await Promise.race([
this.#pollTask(taskId, onEvent).then((payload) => ({ winner: "poll", payload })),
sse.terminal.then((payload) => ({ winner: "sse", payload }))
]);
let finalPayload = firstResult?.payload;
if (firstResult?.winner === "poll") {
const sseTerminal = await this.#awaitWithTimeout(sse.terminal, 1200);
if (sseTerminal?.kind === "result" || sseTerminal?.kind === "error") finalPayload = sseTerminal;
}
if (finalPayload?.kind === "error") throw new Error(finalPayload.message || "Task failed");
return this.#normalizeFinalResult(finalPayload, taskId);
} finally {
await sse.close();
}
}
async #pollTask(taskId, onEvent = null) {
const started = Date.now();
while (Date.now() - started < this.timeoutMs) {
const status = await this.http.request(`/api/tasks/${encodeURIComponent(taskId)}`);
const event = this.#normalizeStatusEvent(status, taskId);
if (event && onEvent) onEvent(event);
if (status.status === "done") return status;
if (status.status === "error") {
throw new Error(status.error?.desc || "Task failed");
throw new Error(status.error?.desc || status.error?.message || "Task failed");
}
await this.#sleep(this.pollMs);
}
throw new Error("Task polling timeout");
}
#normalizeStatusEvent(status, taskId) {
if (!status || typeof status !== "object") return null;
if (status.status === "done") {
const normalized = this.#normalizeFinalResult(status, taskId);
return { kind: "result", ...normalized };
}
if (status.status === "error") {
const message = status.error?.desc || status.error?.message || status.error || status.message || "Task failed";
return { kind: "error", task_id: taskId, message: String(message) };
}
return {
kind: "status",
task_id: status.task_id || taskId,
status: status.status || "in_progress",
stage: status.stage || "",
message: status.message || "",
meta: status.meta || {}
};
}
#normalizeFinalResult(payload, taskId) {
if (payload?.kind === "result") {
const resultType = payload.result_type || (Array.isArray(payload.changeset) ? "changeset" : "answer");
return {
task_id: payload.task_id || taskId,
status: payload.status || "done",
result_type: resultType,
answer: payload.answer || "",
changeset: Array.isArray(payload.changeset) ? payload.changeset : [],
meta: payload.meta || {}
};
}
const src = payload && typeof payload === "object" ? payload : {};
const resultContainer = src.result && typeof src.result === "object" ? src.result : src;
const resultType = resultContainer.result_type || (Array.isArray(resultContainer.changeset) ? "changeset" : "answer");
return {
task_id: src.task_id || taskId,
status: src.status || "done",
result_type: resultType,
answer: resultContainer.answer || "",
changeset: Array.isArray(resultContainer.changeset) ? resultContainer.changeset : [],
meta: resultContainer.meta || src.meta || {}
};
}
async #awaitWithTimeout(promise, timeoutMs) {
let timer = null;
try {
return await Promise.race([
promise,
new Promise((resolve) => {
timer = setTimeout(() => resolve(null), timeoutMs);
})
]);
} finally {
if (timer) clearTimeout(timer);
}
}
#sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}

View File

@@ -0,0 +1,58 @@
export class ErrorMessageFormatter {
buildActionMessage(action, error, fallbackProblem) {
const backendMessage = this.extractBackendMessage(error);
if (backendMessage) return backendMessage;
const explicitMessage = this.extractExplicitMessage(error);
if (explicitMessage) return explicitMessage;
const safeAction = String(action || "Не удалось выполнить действие").trim();
const safeProblem = String(fallbackProblem || "возникла ошибка").trim();
return `${safeAction}: ${safeProblem}.`;
}
extractBackendMessage(error) {
const fromDirect = this.#pickMessage(error?.backendMessage);
if (fromDirect) return fromDirect;
const body = error?.responseBody;
if (!body || typeof body !== "object") return "";
return (
this.#pickMessage(body?.error?.desc) ||
this.#pickMessage(body?.error?.message) ||
this.#pickMessage(body?.desc) ||
this.#pickMessage(body?.detail) ||
this.#pickMessage(body?.message) ||
""
);
}
extractExplicitMessage(error) {
const message = this.#pickMessage(error?.message);
if (!message) return "";
if (this.#isTechnicalTransportMessage(message)) return "";
return message;
}
#pickMessage(value) {
if (typeof value !== "string") return "";
const text = value.trim();
return text || "";
}
#isTechnicalTransportMessage(message) {
const text = String(message || "").trim();
if (!text) return true;
return (
/^HTTP\s+\d{3}$/i.test(text) ||
/failed to fetch/i.test(text) ||
/networkerror/i.test(text) ||
/load failed/i.test(text) ||
/task polling timeout/i.test(text) ||
/index polling timeout/i.test(text) ||
/^task failed$/i.test(text) ||
/^indexing failed$/i.test(text) ||
/^sse /i.test(text)
);
}
}

View File

@@ -7,47 +7,49 @@ export class FileSaveService {
async saveFile(projectStore, path, content) {
const normalizedPath = PathUtils.normalizeRelative(path);
const resolvedPath = projectStore.resolveFilePath(normalizedPath) || normalizedPath;
if (projectStore.rootHandle) {
await this.#writeWithRootHandle(projectStore.rootHandle, normalizedPath, content);
return { mode: "inplace", path: normalizedPath };
await this.#writeWithRootHandle(projectStore.rootHandle, resolvedPath, content);
return { mode: "inplace", path: resolvedPath };
}
const knownHandle = projectStore.fileHandles.get(normalizedPath) || this.fallbackHandles.get(normalizedPath);
const knownHandle = projectStore.getFileHandle(resolvedPath) || this.fallbackHandles.get(resolvedPath);
if (knownHandle && typeof knownHandle.createWritable === "function") {
await this.#writeWithFileHandle(knownHandle, content);
return { mode: "inplace", path: normalizedPath };
return { mode: "inplace", path: resolvedPath };
}
if (typeof window.showSaveFilePicker === "function") {
const pickerOptions = {
suggestedName: PathUtils.basename(normalizedPath),
suggestedName: PathUtils.basename(resolvedPath),
id: this.#buildProjectSaveId(projectStore)
};
const startInHandle = projectStore.rootHandle || knownHandle || this.fallbackHandles.get(normalizedPath);
const startInHandle = projectStore.rootHandle || knownHandle || this.fallbackHandles.get(resolvedPath);
if (startInHandle) pickerOptions.startIn = startInHandle;
const handle = await window.showSaveFilePicker(pickerOptions);
await this.#writeWithFileHandle(handle, content);
this.fallbackHandles.set(normalizedPath, handle);
return { mode: "save_as", path: normalizedPath };
this.fallbackHandles.set(resolvedPath, handle);
return { mode: "save_as", path: resolvedPath };
}
this.#downloadFile(normalizedPath, content);
return { mode: "download", path: normalizedPath };
this.#downloadFile(resolvedPath, content);
return { mode: "download", path: resolvedPath };
}
async saveExistingFile(projectStore, path, content) {
const normalizedPath = PathUtils.normalizeRelative(path);
const resolvedPath = projectStore.resolveFilePath(normalizedPath) || normalizedPath;
if (projectStore.rootHandle) {
await this.#writeWithRootHandle(projectStore.rootHandle, normalizedPath, content);
return { mode: "inplace", path: normalizedPath };
await this.#writeWithRootHandle(projectStore.rootHandle, resolvedPath, content);
return { mode: "inplace", path: resolvedPath };
}
const knownHandle = projectStore.fileHandles.get(normalizedPath) || this.fallbackHandles.get(normalizedPath);
const knownHandle = projectStore.getFileHandle(resolvedPath) || this.fallbackHandles.get(resolvedPath);
if (knownHandle && typeof knownHandle.createWritable === "function") {
await this.#writeWithFileHandle(knownHandle, content);
return { mode: "inplace", path: normalizedPath };
return { mode: "inplace", path: resolvedPath };
}
throw new Error("Нет доступа к существующему файлу для записи без выбора новой директории.");
@@ -78,8 +80,9 @@ export class FileSaveService {
async deleteFile(projectStore, path) {
const normalizedPath = PathUtils.normalizeRelative(path);
const resolvedPath = projectStore.resolveFilePath(normalizedPath) || normalizedPath;
if (!projectStore.rootHandle) return false;
const parts = normalizedPath.split("/");
const parts = resolvedPath.split("/");
const fileName = parts.pop();
let dir = projectStore.rootHandle;
@@ -91,6 +94,51 @@ export class FileSaveService {
return true;
}
async createDirectory(projectStore, path) {
const normalizedPath = PathUtils.normalizeRelative(path);
if (!projectStore.rootHandle) throw new Error("Нет доступа к директории проекта.");
const parts = normalizedPath.split("/");
let dir = projectStore.rootHandle;
for (const part of parts) {
dir = await dir.getDirectoryHandle(part, { create: true });
}
return true;
}
async deleteDirectory(projectStore, path) {
const normalizedPath = PathUtils.normalizeRelative(path);
if (!projectStore.rootHandle) throw new Error("Нет доступа к директории проекта.");
const parts = normalizedPath.split("/");
const dirName = parts.pop();
let parent = projectStore.rootHandle;
for (const part of parts) {
parent = await parent.getDirectoryHandle(part);
}
await parent.removeEntry(dirName, { recursive: true });
return true;
}
async renameDirectory(projectStore, oldPath, newPath) {
const normalizedOld = PathUtils.normalizeRelative(oldPath);
const normalizedNew = PathUtils.normalizeRelative(newPath);
if (!projectStore.rootHandle) throw new Error("Нет доступа к директории проекта.");
if (normalizedOld === normalizedNew) return true;
if (normalizedNew.startsWith(`${normalizedOld}/`)) {
throw new Error("Нельзя переместить папку внутрь самой себя.");
}
const sourceDir = await this.#getDirectoryHandleByPath(projectStore.rootHandle, normalizedOld, false);
const newParts = normalizedNew.split("/");
const newName = newParts.pop();
const newParentPath = newParts.join("/");
const targetParent = await this.#getDirectoryHandleByPath(projectStore.rootHandle, newParentPath, true);
const targetDir = await targetParent.getDirectoryHandle(newName, { create: true });
await this.#copyDirectoryRecursive(sourceDir, targetDir);
await this.deleteDirectory(projectStore, normalizedOld);
return true;
}
async #writeWithRootHandle(rootHandle, path, content) {
const parts = path.split("/");
const fileName = parts.pop();
@@ -104,6 +152,32 @@ export class FileSaveService {
await this.#writeWithFileHandle(handle, content);
}
async #getDirectoryHandleByPath(rootHandle, path, createMissing) {
if (!path) return rootHandle;
let dir = rootHandle;
const parts = path.split("/").filter(Boolean);
for (const part of parts) {
dir = await dir.getDirectoryHandle(part, { create: createMissing });
}
return dir;
}
async #copyDirectoryRecursive(sourceDir, targetDir) {
for await (const [entryName, entryHandle] of sourceDir.entries()) {
if (entryHandle.kind === "directory") {
const childTarget = await targetDir.getDirectoryHandle(entryName, { create: true });
await this.#copyDirectoryRecursive(entryHandle, childTarget);
continue;
}
const sourceFile = await entryHandle.getFile();
const targetFile = await targetDir.getFileHandle(entryName, { create: true });
const writable = await targetFile.createWritable();
await writable.write(sourceFile);
await writable.close();
}
}
async #writeWithFileHandle(handle, content) {
const writable = await handle.createWritable();
await writable.write(content);

View File

@@ -5,42 +5,389 @@ export class IndexingClientApi {
this.http = http;
this.pollMs = pollMs;
this.timeoutMs = timeoutMs;
this.sseReconnectAttempts = 5;
this.sseReconnectDelayMs = 1000;
this.sseActivityGraceMs = 15000;
}
async submitSnapshot(projectId, files) {
async submitSnapshot(projectId, files, onProgress = null) {
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);
const status = await this.#waitForRagJob(queued.rag_session_id, queued.index_job_id, onProgress);
return { ...status, rag_session_id: queued.rag_session_id };
}
async submitChanges(ragSessionId, changedFiles) {
async submitChanges(ragSessionId, changedFiles, onProgress = null) {
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);
const status = await this.#waitForRagJob(ragSessionId, queued.index_job_id, onProgress);
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)}`
);
async #waitForRagJob(ragSessionId, jobId, onProgress) {
const progressState = { done: null, total: null, failed: null, cacheHitFiles: null, cacheMissFiles: null, lastSseActivityAt: 0 };
const handleProgress = (event) => {
const done = this.#toNumber(event?.done);
const total = this.#toNumber(event?.total);
const failed = this.#toNumber(event?.failed);
const cacheHitFiles = this.#toNumber(event?.cacheHitFiles);
const cacheMissFiles = this.#toNumber(event?.cacheMissFiles);
if (event?.source === "sse") progressState.lastSseActivityAt = Date.now();
if (Number.isFinite(done)) progressState.done = done;
if (Number.isFinite(total)) progressState.total = total;
if (Number.isFinite(failed)) progressState.failed = failed;
if (Number.isFinite(cacheHitFiles)) progressState.cacheHitFiles = cacheHitFiles;
if (Number.isFinite(cacheMissFiles)) progressState.cacheMissFiles = cacheMissFiles;
if (typeof onProgress === "function") onProgress(event);
};
const sse = this.#openSseProgress(ragSessionId, jobId, handleProgress);
try {
const firstResult = await Promise.race([
this.#pollRagJob(
ragSessionId,
jobId,
handleProgress,
() => this.#hasRecentSseActivity(progressState.lastSseActivityAt)
).then((payload) => ({ winner: "poll", payload })),
sse.terminal.then((payload) => ({ winner: "sse", payload }))
]);
let result = firstResult?.payload;
// If poll finished first, give SSE a short grace window to deliver terminal/progress events.
if (firstResult?.winner === "poll") {
const sseResult = await this.#awaitWithTimeout(sse.terminal, 1500);
if (sseResult?.status === "done") {
result = sseResult;
} else if (sseResult?.status === "error" && result?.status !== "done") {
result = sseResult;
}
}
if (result?.status === "error") {
throw new Error(result.error?.desc || result.error || "Indexing failed");
}
if (result?.status === "done") {
return {
...result,
indexed_files: this.#toNumber(result.indexed_files) ?? this.#toNumber(result.done) ?? progressState.done ?? 0,
failed_files: this.#toNumber(result.failed_files) ?? this.#toNumber(result.failed) ?? progressState.failed ?? 0,
cache_hit_files:
this.#toNumber(result.cache_hit_files) ?? this.#toNumber(result.cacheHitFiles) ?? progressState.cacheHitFiles ?? 0,
cache_miss_files:
this.#toNumber(result.cache_miss_files) ?? this.#toNumber(result.cacheMissFiles) ?? progressState.cacheMissFiles ?? 0
};
}
return result || {
status: "done",
indexed_files: progressState.done ?? 0,
failed_files: progressState.failed ?? 0,
cache_hit_files: progressState.cacheHitFiles ?? 0,
cache_miss_files: progressState.cacheMissFiles ?? 0
};
} catch (error) {
if (this.#isProgressComplete(progressState)) {
return {
status: "done",
indexed_files: progressState.done ?? 0,
failed_files: progressState.failed ?? 0,
cache_hit_files: progressState.cacheHitFiles ?? 0,
cache_miss_files: progressState.cacheMissFiles ?? 0
};
}
throw error;
} finally {
await sse.close();
}
}
#openSseProgress(ragSessionId, jobId, onProgress) {
const controller = new AbortController();
let terminalResolved = false;
let resolveTerminal;
const terminal = new Promise((resolve) => {
resolveTerminal = resolve;
});
const finishTerminal = (payload) => {
if (terminalResolved) return;
terminalResolved = true;
resolveTerminal(payload);
};
const task = this.#streamRagEventsWithReconnect(
ragSessionId,
jobId,
onProgress,
controller.signal,
finishTerminal,
() => terminalResolved
)
.catch((error) => {
const message = String(error?.message || error || "");
const abortedByClient = controller.signal.aborted || /abort/i.test(message);
if (abortedByClient) return;
finishTerminal({ status: "error", error: message || "SSE stream error" });
if (typeof onProgress === "function") {
onProgress({
status: "progress",
source: "sse",
message: `SSE stream error: ${message || "stream_error"}`,
raw: message || "stream_error"
});
}
});
return {
terminal,
close: async () => {
controller.abort();
finishTerminal({ status: "aborted" });
await task;
}
};
}
async #streamRagEventsWithReconnect(ragSessionId, jobId, onProgress, signal, onTerminal, isTerminalResolved) {
let attempts = 0;
while (!signal.aborted) {
try {
const streamResult = await this.#streamRagEvents(ragSessionId, jobId, onProgress, signal, onTerminal);
if (streamResult?.hadEvents) attempts = 0;
if (signal.aborted || isTerminalResolved()) return;
if (attempts >= this.sseReconnectAttempts) {
throw new Error(`SSE stream closed and reconnect limit (${this.sseReconnectAttempts}) reached`);
}
attempts += 1;
if (typeof onProgress === "function") {
onProgress({
status: "progress",
source: "sse",
message: `SSE reconnect attempt ${attempts}/${this.sseReconnectAttempts} after stream close`,
raw: "sse_reconnect"
});
}
} catch (error) {
const message = String(error?.message || error || "");
const abortedByClient = signal.aborted || /abort/i.test(message);
if (abortedByClient || isTerminalResolved()) return;
if (attempts >= this.sseReconnectAttempts) {
throw new Error(`SSE reconnect failed after ${this.sseReconnectAttempts} attempts: ${message || "stream_error"}`);
}
attempts += 1;
if (typeof onProgress === "function") {
onProgress({
status: "progress",
source: "sse",
message: `SSE reconnect attempt ${attempts}/${this.sseReconnectAttempts}: ${message || "stream_error"}`,
raw: message || "sse_reconnect_error"
});
}
}
await this.#sleep(this.sseReconnectDelayMs);
}
}
async #streamRagEvents(ragSessionId, jobId, onProgress, signal, onTerminal) {
const path = `/api/rag/sessions/${encodeURIComponent(ragSessionId)}/jobs/${encodeURIComponent(jobId)}/events?replay=true`;
const response = await fetch(this.http.resolveUrl(path), {
method: "GET",
headers: { Accept: "text/event-stream" },
signal
});
if (!response.ok) throw new Error(`SSE HTTP ${response.status}`);
if (!response.body) throw new Error("SSE stream is empty");
const contentType = (response.headers.get("content-type") || "").toLowerCase();
if (!contentType.includes("text/event-stream")) {
throw new Error(`SSE invalid content-type: ${contentType || "unknown"}`);
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
let hadEvents = false;
const state = { eventName: "", dataLines: [] };
const dispatch = () => {
if (!state.dataLines.length) return;
const rawData = state.dataLines.join("\n");
state.dataLines = [];
const payload = this.#tryParseJson(rawData);
const normalized = this.#normalizeProgressEvent(payload, rawData, state.eventName);
state.eventName = "";
if (!normalized) return;
hadEvents = true;
normalized.source = "sse";
if (typeof onProgress === "function") onProgress(normalized);
if (normalized.status === "done") {
const indexed = this.#toNumber(
payload?.indexed_files ?? payload?.done ?? payload?.processed_files ?? payload?.completed ?? normalized.done
);
const failed = this.#toNumber(payload?.failed_files ?? payload?.failed ?? normalized.failed);
const cacheHitFiles = this.#toNumber(payload?.cache_hit_files ?? payload?.cacheHitFiles ?? normalized.cacheHitFiles);
const cacheMissFiles = this.#toNumber(payload?.cache_miss_files ?? payload?.cacheMissFiles ?? normalized.cacheMissFiles);
if (typeof onTerminal === "function") {
onTerminal({
status: "done",
indexed_files: indexed ?? 0,
failed_files: failed ?? 0,
total_files: this.#toNumber(payload?.total_files ?? payload?.total ?? normalized.total),
cache_hit_files: cacheHitFiles ?? 0,
cache_miss_files: cacheMissFiles ?? 0
});
}
} else if (normalized.status === "error" && typeof onTerminal === "function") {
onTerminal({
status: "error",
error: payload?.error || payload?.message || normalized.message || "Indexing failed"
});
}
};
while (true) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
let newlineIndex = buffer.indexOf("\n");
while (newlineIndex !== -1) {
const line = buffer.slice(0, newlineIndex).replace(/\r$/, "");
buffer = buffer.slice(newlineIndex + 1);
if (!line) {
dispatch();
newlineIndex = buffer.indexOf("\n");
continue;
}
if (line.startsWith(":")) {
newlineIndex = buffer.indexOf("\n");
continue;
}
if (line.startsWith("event:")) {
state.eventName = line.slice(6).trim();
} else if (line.startsWith("data:")) {
state.dataLines.push(line.slice(5).trimStart());
}
newlineIndex = buffer.indexOf("\n");
}
}
if (state.dataLines.length) dispatch();
return { done: true, hadEvents };
}
#normalizeProgressEvent(payload, rawData, eventName) {
const src = payload && typeof payload === "object" ? payload : {};
const status = this.#normalizeStatus(src.status || src.state || eventName || "");
const stringPayload = typeof payload === "string" ? payload.trim() : "";
const currentFile =
src.current_file ||
src.currentFile ||
src.current_file_path ||
src.current_path ||
src.file_path ||
src.filePath ||
src.relative_path ||
src.document_path ||
src.path ||
src.file ||
src.filename ||
src.name ||
(stringPayload && /[\\/]/.test(stringPayload) ? stringPayload : "");
const done = this.#toNumber(
src.done ?? src.processed ?? src.processed_files ?? src.indexed_files ?? src.completed ?? src.current ?? null
);
const total = this.#toNumber(src.total ?? src.total_files ?? src.files_total ?? src.count ?? src.max ?? null);
const failed = this.#toNumber(src.failed ?? src.failed_files ?? null);
const cacheHitFiles = this.#toNumber(src.cache_hit_files ?? src.cacheHitFiles ?? null);
const cacheMissFiles = this.#toNumber(src.cache_miss_files ?? src.cacheMissFiles ?? null);
const message = src.message || src.detail || (typeof payload === "string" ? payload : rawData);
if (!status && done == null && total == null && failed == null && cacheHitFiles == null && cacheMissFiles == null && !currentFile && !message) return null;
return { status: status || "progress", currentFile, done, total, failed, cacheHitFiles, cacheMissFiles, message, raw: payload ?? rawData };
}
#normalizeStatus(value) {
const v = String(value || "").toLowerCase();
if (!v) return "";
if (["done", "completed", "success", "finished"].includes(v)) return "done";
if (["error", "failed", "failure"].includes(v)) return "error";
return "progress";
}
#tryParseJson(value) {
try {
return JSON.parse(value);
} catch {
return value;
}
}
#toNumber(value) {
if (value == null || value === "") return null;
const num = Number(value);
return Number.isFinite(num) ? num : null;
}
async #pollRagJob(ragSessionId, jobId, onProgress = null, shouldExtendTimeout = null) {
let started = Date.now();
while (true) {
if (Date.now() - started >= this.timeoutMs) {
if (typeof shouldExtendTimeout === "function" && shouldExtendTimeout()) {
started = Date.now();
} else {
throw new Error("Index polling timeout");
}
}
let status;
try {
status = await this.http.request(
`/api/rag/sessions/${encodeURIComponent(ragSessionId)}/jobs/${encodeURIComponent(jobId)}`
);
} catch (error) {
if (typeof shouldExtendTimeout === "function" && shouldExtendTimeout()) {
await this.#sleep(this.pollMs);
continue;
}
throw error;
}
if (typeof onProgress === "function") {
const normalized = this.#normalizeProgressEvent(status, "", "");
if (normalized) {
normalized.source = "poll";
onProgress(normalized);
}
}
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");
}
async #awaitWithTimeout(promise, timeoutMs) {
let timer = null;
try {
return await Promise.race([
promise,
new Promise((resolve) => {
timer = setTimeout(() => resolve(null), timeoutMs);
})
]);
} finally {
if (timer) clearTimeout(timer);
}
}
#sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
#isProgressComplete(state) {
if (!state) return false;
if (!Number.isFinite(state.done) || !Number.isFinite(state.total) || state.total <= 0) return false;
return state.done >= state.total;
}
#hasRecentSseActivity(lastSseActivityAt) {
if (!Number.isFinite(lastSseActivityAt) || lastSseActivityAt <= 0) return false;
return Date.now() - lastSseActivityAt <= this.sseActivityGraceMs;
}
}

View File

@@ -11,6 +11,13 @@ export class IndexingClientMock {
void projectId;
const jobId = `${type}-${Date.now()}`;
await new Promise((resolve) => setTimeout(resolve, 550));
return { index_job_id: jobId, status: "done", indexed_files: count, failed_files: 0 };
return {
index_job_id: jobId,
status: "done",
indexed_files: count,
failed_files: 0,
cache_hit_files: 0,
cache_miss_files: count
};
}
}

View File

@@ -4,6 +4,7 @@ export class ProjectLimitsPolicy {
this.hardFileLimit = 10000;
this.softSizeLimitBytes = 1 * 1024 * 1024;
this.hardSizeLimitBytes = 10 * 1024 * 1024;
this.ignoredDirectoryNames = new Set(["app-data", "build", "grafana", "__pycache__"]);
}
summarizeFileList(fileList) {
@@ -12,7 +13,7 @@ export class ProjectLimitsPolicy {
for (const file of fileList) {
const relPath = (file.webkitRelativePath || file.name || "").replaceAll("\\", "/");
if (this.#isHiddenPath(relPath)) continue;
if (this.#isIgnoredPath(relPath)) continue;
totalFiles += 1;
totalBytes += Number(file.size || 0);
}
@@ -43,11 +44,11 @@ export class ProjectLimitsPolicy {
return { softWarnings, hardErrors };
}
#isHiddenPath(path) {
#isIgnoredPath(path) {
const parts = String(path || "")
.split("/")
.filter(Boolean);
return parts.some((segment) => segment.startsWith("."));
return parts.some((segment) => segment.startsWith(".") || this.ignoredDirectoryNames.has(segment));
}
#formatBytes(bytes) {

View File

@@ -4,6 +4,7 @@ export class ProjectScanner {
constructor(textPolicy, hashService) {
this.textPolicy = textPolicy;
this.hashService = hashService;
this.ignoredDirectoryNames = new Set(["app-data", "build", "grafana", "__pycache__"]);
}
async scan(rootHandle, onProgress = null) {
@@ -22,7 +23,7 @@ export class ProjectScanner {
const rel = relRaw.replaceAll("\\", "/");
try {
const path = PathUtils.normalizeRelative(rel);
if (this.#isHiddenPath(path)) continue;
if (this.#isIgnoredPath(path)) continue;
entries.push({ path, file });
} catch {
// Skip invalid paths instead of failing the whole tree.
@@ -69,7 +70,7 @@ export class ProjectScanner {
for await (const [name, handle] of dirHandle.entries()) {
const relPath = currentPath ? `${currentPath}/${name}` : name;
const normalizedRelPath = PathUtils.normalizeRelative(relPath);
if (this.#isHiddenPath(normalizedRelPath)) continue;
if (this.#isIgnoredPath(normalizedRelPath)) continue;
if (handle.kind === "directory") {
const child = { name, path: normalizedRelPath, type: "dir", children: [] };
@@ -152,10 +153,10 @@ export class ProjectScanner {
for (const child of node.children) this.#sortTree(child);
}
#isHiddenPath(path) {
#isIgnoredPath(path) {
const parts = String(path || "")
.split("/")
.filter(Boolean);
return parts.some((segment) => segment.startsWith("."));
return parts.some((segment) => segment.startsWith(".") || this.ignoredDirectoryNames.has(segment));
}
}

View File

@@ -7,6 +7,8 @@ export class ProjectStore {
this.totalFileCount = 0;
this.totalBytes = 0;
this.selectedFilePath = "";
this.filePathIndex = new Map();
this.dirPathIndex = new Map();
this.listeners = new Set();
}
@@ -27,32 +29,110 @@ export class ProjectStore {
this.totalFileCount = snapshot.totalFileCount || 0;
this.totalBytes = snapshot.totalBytes || 0;
this.selectedFilePath = "";
this.#rebuildPathIndexes();
this.#emit();
}
setSelectedFile(path) {
this.selectedFilePath = path;
const normalized = String(path || "").replaceAll("\\", "/");
const resolved = this.resolveFilePath(normalized);
this.selectedFilePath = resolved || normalized;
this.#emit();
}
upsertFile(path, content, hash) {
const normalized = path.replaceAll("\\", "/");
const resolved = this.resolveFilePath(normalized);
const targetPath = resolved || normalized;
const size = content.length;
this.files.set(normalized, { path: normalized, content, hash, size });
this.#ensureFileInTree(normalized, size);
this.files.set(targetPath, { path: targetPath, content, hash, size });
this.#setFileIndex(targetPath);
this.#ensureFileInTree(targetPath, size);
this.#emit();
}
upsertDirectory(path) {
const normalized = path.replaceAll("\\", "/");
if (!normalized) return;
this.#setDirIndex(normalized);
this.#ensureDirectoryInTree(normalized);
this.#emit();
}
removeFile(path) {
const normalized = path.replaceAll("\\", "/");
this.files.delete(normalized);
this.fileHandles.delete(normalized);
this.#removeFileFromTree(normalized);
const resolved = this.resolveFilePath(normalized) || normalized;
this.files.delete(resolved);
this.fileHandles.delete(resolved);
this.#deleteFileIndex(resolved);
this.#removeFileFromTree(resolved);
this.#emit();
}
removeDirectory(path) {
const normalized = path.replaceAll("\\", "/");
const resolved = this.resolveDirectoryPath(normalized) || normalized;
if (!resolved) return;
this.#removeDirectoryFromTree(resolved);
const prefix = `${resolved}/`;
for (const filePath of [...this.files.keys()]) {
if (filePath === resolved || filePath.startsWith(prefix)) {
this.files.delete(filePath);
this.#deleteFileIndex(filePath);
}
}
for (const filePath of [...this.fileHandles.keys()]) {
if (filePath === resolved || filePath.startsWith(prefix)) this.fileHandles.delete(filePath);
}
for (const dirPath of [...this.dirPathIndex.values()]) {
if (dirPath === resolved || dirPath.startsWith(prefix)) this.#deleteDirIndex(dirPath);
}
this.#emit();
}
resolveFilePath(path) {
const normalized = String(path || "").replaceAll("\\", "/");
return this.filePathIndex.get(normalized.toLowerCase()) || "";
}
resolveDirectoryPath(path) {
const normalized = String(path || "").replaceAll("\\", "/");
if (!normalized) return "";
return this.dirPathIndex.get(normalized.toLowerCase()) || "";
}
hasFile(path) {
return Boolean(this.resolveFilePath(path));
}
getFile(path) {
const resolved = this.resolveFilePath(path) || String(path || "").replaceAll("\\", "/");
return this.files.get(resolved) || null;
}
getFileHandle(path) {
const resolved = this.resolveFilePath(path) || String(path || "").replaceAll("\\", "/");
return this.fileHandles.get(resolved) || null;
}
hasDirectory(path) {
const normalized = path.replaceAll("\\", "/");
if (!normalized) return true;
const resolved = this.resolveDirectoryPath(normalized);
if (resolved) return true;
const parts = normalized.split("/").filter(Boolean);
if (!parts.length) return true;
let node = this.rootNode;
for (const name of parts) {
const child = node?.children?.find((item) => item.type === "dir" && item.name.toLowerCase() === name.toLowerCase());
if (!child) return false;
node = child;
}
return true;
}
getSelectedFile() {
return this.files.get(this.selectedFilePath) || null;
return this.getFile(this.selectedFilePath);
}
#ensureFileInTree(path, size) {
@@ -80,15 +160,37 @@ export class ProjectStore {
}
node.children = node.children || [];
let dir = node.children.find((child) => child.type === "dir" && child.name === name);
let dir = node.children.find((child) => child.type === "dir" && child.name.toLowerCase() === name.toLowerCase());
if (!dir) {
dir = { name, path: childPath, type: "dir", children: [] };
node.children.push(dir);
this.#setDirIndex(childPath);
}
node = dir;
}
}
#ensureDirectoryInTree(path) {
if (!this.rootNode) return;
const parts = path.split("/").filter(Boolean);
if (!parts.length) return;
let node = this.rootNode;
for (let i = 0; i < parts.length; i += 1) {
const name = parts[i];
const childPath = parts.slice(0, i + 1).join("/");
node.children = node.children || [];
let dir = node.children.find((child) => child.type === "dir" && child.name.toLowerCase() === name.toLowerCase());
if (!dir) {
dir = { name, path: childPath, type: "dir", children: [] };
node.children.push(dir);
this.#setDirIndex(childPath);
}
this.#sortNode(node);
node = dir;
}
}
#sortNode(node) {
if (!node?.children) return;
node.children.sort((a, b) => {
@@ -102,6 +204,11 @@ export class ProjectStore {
this.#removeFromNode(this.rootNode, path);
}
#removeDirectoryFromTree(path) {
if (!this.rootNode?.children) return;
this.#removeDirectoryNode(this.rootNode, path);
}
#removeFromNode(node, targetPath) {
if (!node?.children) return false;
const idx = node.children.findIndex((child) => child.type === "file" && child.path === targetPath);
@@ -116,4 +223,51 @@ export class ProjectStore {
}
return false;
}
#removeDirectoryNode(node, targetPath) {
if (!node?.children) return false;
const idx = node.children.findIndex((child) => child.type === "dir" && child.path === targetPath);
if (idx !== -1) {
node.children.splice(idx, 1);
return true;
}
for (const child of node.children) {
if (child.type !== "dir") continue;
const removed = this.#removeDirectoryNode(child, targetPath);
if (removed) return true;
}
return false;
}
#rebuildPathIndexes() {
this.filePathIndex.clear();
this.dirPathIndex.clear();
for (const path of this.files.keys()) this.#setFileIndex(path);
this.#indexDirectoryNode(this.rootNode);
}
#indexDirectoryNode(node) {
if (!node?.children) return;
for (const child of node.children) {
if (child.type !== "dir") continue;
if (child.path) this.#setDirIndex(child.path);
this.#indexDirectoryNode(child);
}
}
#setFileIndex(path) {
this.filePathIndex.set(String(path || "").toLowerCase(), path);
}
#deleteFileIndex(path) {
this.filePathIndex.delete(String(path || "").toLowerCase());
}
#setDirIndex(path) {
this.dirPathIndex.set(String(path || "").toLowerCase(), path);
}
#deleteDirIndex(path) {
this.dirPathIndex.delete(String(path || "").toLowerCase());
}
}

View File

@@ -10,8 +10,8 @@ export class ReviewStateStore {
path: item.path,
status: item.status,
op: item.op,
acceptedOpIds: new Set(),
stagedSelection: new Set()
acceptedBlockIds: new Set(),
rejectedBlockIds: new Set()
});
}
}
@@ -30,29 +30,68 @@ export class ReviewStateStore {
const s = this.get(path);
if (!s || s.status === "conflict") return;
s.status = status;
if (status === "accepted_full") {
s.rejectedBlockIds.clear();
return;
}
if (status === "rejected") {
s.acceptedBlockIds.clear();
s.rejectedBlockIds.clear();
return;
}
if (status === "pending") {
s.acceptedBlockIds.clear();
s.rejectedBlockIds.clear();
}
}
toggleSelection(path, opId) {
setAllBlocksDecision(path, blockIds, decision) {
const s = this.get(path);
if (!s || s.status === "conflict") return;
if (s.stagedSelection.has(opId)) s.stagedSelection.delete(opId);
else s.stagedSelection.add(opId);
const ids = Array.isArray(blockIds) ? blockIds : [];
if (decision === "accept") {
for (const id of ids) {
s.acceptedBlockIds.add(id);
s.rejectedBlockIds.delete(id);
}
s.status = ids.length ? "accepted_full" : "pending";
return;
}
if (decision === "reject") {
for (const id of ids) {
s.acceptedBlockIds.delete(id);
s.rejectedBlockIds.add(id);
}
s.status = "rejected";
}
}
acceptSelected(path) {
setBlockDecision(path, blockId, decision) {
const s = this.get(path);
if (!s || s.status === "conflict") return;
for (const id of s.stagedSelection) s.acceptedOpIds.add(id);
s.status = s.acceptedOpIds.size ? "accepted_partial" : "pending";
s.stagedSelection.clear();
if (decision === "accept") {
s.acceptedBlockIds.add(blockId);
s.rejectedBlockIds.delete(blockId);
s.status = "accepted_partial";
return;
}
if (decision === "reject") {
s.acceptedBlockIds.delete(blockId);
s.rejectedBlockIds.add(blockId);
s.status = s.acceptedBlockIds.size > 0 ? "accepted_partial" : "pending";
}
}
rejectSelected(path) {
const s = this.get(path);
if (!s || s.status === "conflict") return;
for (const id of s.stagedSelection) s.acceptedOpIds.delete(id);
s.status = s.acceptedOpIds.size ? "accepted_partial" : "pending";
s.stagedSelection.clear();
acceptAll() {
for (const [path] of this.items.entries()) {
this.setFileStatus(path, "accepted_full");
}
}
rejectAll() {
for (const [path] of this.items.entries()) {
this.setFileStatus(path, "rejected");
}
}
acceptedPaths() {
@@ -67,7 +106,6 @@ export class ReviewStateStore {
const s = this.get(path);
if (!s) return;
s.status = "applied";
s.stagedSelection.clear();
}
list() {

View File

@@ -0,0 +1,158 @@
import { ApiHttpClient } from "./ApiHttpClient.js";
export class TaskEventsSseClient {
constructor(http = new ApiHttpClient()) {
this.http = http;
}
open(taskId, onEvent = null) {
const controller = new AbortController();
let terminalResolved = false;
let resolveTerminal;
const terminal = new Promise((resolve) => {
resolveTerminal = resolve;
});
const finishTerminal = (payload) => {
if (terminalResolved) return;
terminalResolved = true;
resolveTerminal(payload);
};
const task = this.#streamTaskEvents(taskId, onEvent, controller.signal, finishTerminal).catch((error) => {
if (controller.signal.aborted) return;
const payload = { kind: "stream_error", task_id: taskId, message: String(error?.message || error || "SSE error") };
if (typeof onEvent === "function") onEvent(payload);
});
return {
terminal,
close: async () => {
controller.abort();
finishTerminal({ kind: "aborted", task_id: taskId });
await task;
}
};
}
async #streamTaskEvents(taskId, onEvent, signal, onTerminal) {
const response = await fetch(this.http.resolveUrl(`/api/events?task_id=${encodeURIComponent(taskId)}`), {
method: "GET",
headers: { Accept: "text/event-stream" },
signal
});
if (!response.ok) throw new Error(`SSE HTTP ${response.status}`);
if (!response.body) throw new Error("SSE stream is empty");
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
const state = { eventName: "", dataLines: [] };
const dispatch = () => {
if (!state.dataLines.length) return;
const rawData = state.dataLines.join("\n");
state.dataLines = [];
const payload = this.#tryParseJson(rawData);
const normalized = this.#normalizeEvent(payload, rawData, state.eventName, taskId);
state.eventName = "";
if (!normalized) return;
if (typeof onEvent === "function") onEvent(normalized);
if (normalized.kind === "result" || normalized.kind === "error") onTerminal(normalized);
};
while (true) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
let newlineIndex = buffer.indexOf("\n");
while (newlineIndex !== -1) {
const line = buffer.slice(0, newlineIndex).replace(/\r$/, "");
buffer = buffer.slice(newlineIndex + 1);
if (!line) {
dispatch();
newlineIndex = buffer.indexOf("\n");
continue;
}
if (line.startsWith(":")) {
newlineIndex = buffer.indexOf("\n");
continue;
}
if (line.startsWith("event:")) {
state.eventName = line.slice(6).trim();
} else if (line.startsWith("data:")) {
state.dataLines.push(line.slice(5).trimStart());
}
newlineIndex = buffer.indexOf("\n");
}
}
if (state.dataLines.length) dispatch();
}
#normalizeEvent(payload, rawData, eventName, fallbackTaskId) {
const src = payload && typeof payload === "object" ? payload : {};
const name = String(src.event || src.type || eventName || "").trim();
const taskId = src.task_id || fallbackTaskId;
if (name === "task_progress") {
return {
kind: "progress",
task_id: taskId,
stage: src.stage || "",
message: src.message || "",
meta: src.meta || {},
progress: src.progress
};
}
if (name === "task_thinking") {
return {
kind: "thinking",
task_id: taskId,
stage: src.stage || "",
message: src.message || "",
meta: src.meta || {},
heartbeat: Boolean(src.meta?.heartbeat)
};
}
if (name === "task_result") {
return {
kind: "result",
task_id: taskId,
status: src.status || "done",
result_type: src.result_type || "",
answer: src.answer || "",
changeset: Array.isArray(src.changeset) ? src.changeset : [],
meta: src.meta || {}
};
}
if (name === "task_error") {
const message = src.error?.desc || src.error?.message || src.error || src.message || rawData;
return { kind: "error", task_id: taskId, message: String(message || "Task failed"), error: src.error || null, meta: src.meta || {} };
}
if (name === "task_status") {
return {
kind: "status",
task_id: taskId,
status: src.status || "",
stage: src.stage || "",
message: src.message || "",
meta: src.meta || {}
};
}
if (src.status === "error") {
const message = src.error?.desc || src.error?.message || src.error || src.message || rawData;
return { kind: "error", task_id: taskId, message: String(message || "Task failed"), error: src.error || null, meta: src.meta || {} };
}
return null;
}
#tryParseJson(value) {
try {
return JSON.parse(value);
} catch {
return value;
}
}
}