Первый коммит

This commit is contained in:
2026-04-09 15:42:42 +03:00
commit c664209746
28 changed files with 2616 additions and 0 deletions
+28
View File
@@ -0,0 +1,28 @@
import * as vscode from "vscode";
const SECTION = "chatArea";
export class AgentConfig {
getBaseUrl(): string {
const value = vscode.workspace
.getConfiguration(SECTION)
.get<string>("agentBaseUrl", "http://127.0.0.1:15000");
return value.replace(/^http:\/\/localhost(?=[:/]|$)/i, "http://127.0.0.1").replace(/\/+$/, "");
}
getMaxIndexedFileSizeBytes(): number {
const kb = vscode.workspace
.getConfiguration(SECTION)
.get<number>("maxIndexedFileSizeKb", 256);
return Math.max(1, kb) * 1024;
}
getExcludeGlob(): string {
return vscode.workspace
.getConfiguration(SECTION)
.get<string>(
"excludeGlob",
"**/{node_modules,.git,out,dist,build,.next,.turbo,.idea,.vscode}/**"
);
}
}
+60
View File
@@ -0,0 +1,60 @@
import { AgentConfig } from "./AgentConfig";
export class AgentHttpClient {
constructor(private readonly config: AgentConfig) {}
buildUrl(path: string): string {
return `${this.config.getBaseUrl()}${path}`;
}
async requestJson<T>(path: string, init?: RequestInit): Promise<T> {
const url = this.buildUrl(path);
let response: Response;
try {
response = await fetch(url, {
...init,
headers: {
Accept: "application/json",
"Content-Type": "application/json",
...(init?.headers || {}),
},
});
} catch (error) {
throw new Error(this.describeNetworkError(url, error));
}
if (!response.ok) {
throw new Error(`HTTP ${response.status} for ${url}: ${await this.readError(response)}`);
}
return (await response.json()) as T;
}
private async readError(response: Response): Promise<string> {
try {
const payload = (await response.json()) as {
code?: string;
desc?: string;
detail?: string;
message?: string;
};
return (
payload.desc ||
payload.detail ||
payload.message ||
payload.code ||
`HTTP ${response.status}`
);
} catch {
return `HTTP ${response.status}`;
}
}
private describeNetworkError(url: string, error: unknown): string {
const message = error instanceof Error ? error.message : String(error);
const cause =
error instanceof Error && "cause" in error
? String((error as Error & { cause?: unknown }).cause || "")
: "";
const details = [message, cause].filter(Boolean).join(" | ");
return `Network request failed for ${url}${details ? `: ${details}` : ""}`;
}
}
+185
View File
@@ -0,0 +1,185 @@
import { AgentHttpClient } from "./AgentHttpClient";
import {
AgentSessionResult,
AgentSessionCreateResponse,
ChangeItem,
FileSnapshot,
RagJobResponse,
RagProgressEvent,
SendChatPayload,
SendChatResult,
TaskArtifact,
TaskEvent,
TaskQueuedResponse,
TaskResultResponse,
} from "./contracts";
import { RagJobEventsStream } from "./RagJobEventsStream";
import { TaskEventsStream } from "./TaskEventsStream";
export class ChatApiClient {
constructor(
private readonly http: AgentHttpClient,
private readonly events: TaskEventsStream,
private readonly ragEvents: RagJobEventsStream
) {}
async createSession(
projectId: string,
files: FileSnapshot[],
onProgress: (event: RagProgressEvent) => void
): Promise<AgentSessionResult> {
const response = await this.http.requestJson<AgentSessionCreateResponse>(
"/api/agent/sessions",
{
method: "POST",
body: JSON.stringify({ project_id: projectId, files }),
}
);
const sse = this.ragEvents.open(response.rag_session_id, response.index_job_id, onProgress);
try {
const result = await Promise.race([
this.pollIndexJob(response.rag_session_id, response.index_job_id, onProgress),
sse.terminal.then((event) =>
this.normalizeIndexTerminal(response.rag_session_id, response.index_job_id, event)
),
]);
return {
sessionId: response.session_id,
ragSessionId: response.rag_session_id,
indexedFiles: result.indexed_files,
failedFiles: result.failed_files,
cacheHitFiles: result.cache_hit_files,
cacheMissFiles: result.cache_miss_files,
};
} finally {
sse.close();
}
}
async sendMessage(
payload: SendChatPayload,
onEvent: (event: TaskEvent) => void
): Promise<SendChatResult> {
const queued = await this.http.requestJson<TaskQueuedResponse>(
"/api/agent/requests",
{
method: "POST",
body: JSON.stringify({
session_id: payload.sessionId,
message: payload.message,
process_version: payload.processVersion,
}),
}
);
onEvent({ kind: "queued", task_id: queued.request_id, status: queued.status });
const sse = this.events.open(queued.request_id, onEvent);
try {
const result = await this.pollTask(queued.request_id, onEvent);
return this.normalizeResult(result, queued.request_id);
} finally {
sse.close();
}
}
private async pollTask(
taskId: string,
onEvent: (event: TaskEvent) => void
): Promise<TaskResultResponse> {
while (true) {
const result = await this.http.requestJson<TaskResultResponse>(
`/api/agent/requests/${encodeURIComponent(taskId)}`
);
if (result.status === "done" || result.status === "error") {
return result;
}
onEvent({
kind: "status",
task_id: taskId,
status: result.status,
message: result.status,
});
await this.sleep(700);
}
}
private normalizeResult(result: TaskResultResponse, taskId: string): SendChatResult {
if (result.status === "error") {
const message = result.error?.desc || result.error?.message || "Не удалось обработать сообщение.";
throw new Error(message);
}
const changeset = this.normalizeChangeset(result.changeset);
const applyChangeset = Boolean(result.apply_changeset);
return {
taskId,
resultType: changeset.length > 0 ? "changeset" : "answer",
answer: result.answer || "",
artifacts: this.normalizeArtifacts([]),
changeset,
applyChangeset,
};
}
private normalizeArtifacts(items: TaskArtifact[] | undefined): TaskArtifact[] {
return Array.isArray(items) ? items : [];
}
private normalizeChangeset(items: ChangeItem[] | undefined): ChangeItem[] {
return Array.isArray(items) ? items : [];
}
private async pollIndexJob(
ragSessionId: string,
jobId: string,
onProgress: (event: RagProgressEvent) => void
): Promise<RagJobResponse> {
while (true) {
const status = await this.http.requestJson<RagJobResponse>(
`/api/rag/sessions/${encodeURIComponent(
ragSessionId
)}/jobs/${encodeURIComponent(jobId)}`
);
onProgress({
status: status.status === "error" ? "error" : status.status === "done" ? "done" : "progress",
message: status.status,
done: status.indexed_files,
total: null,
failed: status.failed_files,
cacheHitFiles: status.cache_hit_files,
cacheMissFiles: status.cache_miss_files,
});
if (status.status === "done") {
return status;
}
if (status.status === "error") {
throw new Error(status.error?.desc || status.error?.message || "Индексация завершилась ошибкой.");
}
await this.sleep(700);
}
}
private normalizeIndexTerminal(
ragSessionId: string,
jobId: string,
event: RagProgressEvent | null
): RagJobResponse {
if (!event) {
throw new Error("Поток индексации завершился без результата.");
}
if (event.status === "error") {
throw new Error(event.message || "Индексация завершилась ошибкой.");
}
return {
rag_session_id: ragSessionId,
index_job_id: jobId,
status: "done",
indexed_files: event.done || 0,
failed_files: event.failed || 0,
cache_hit_files: event.cacheHitFiles || 0,
cache_miss_files: event.cacheMissFiles || 0,
};
}
private sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
}
+139
View File
@@ -0,0 +1,139 @@
import { AgentHttpClient } from "./AgentHttpClient";
import { RagProgressEvent } from "./contracts";
export class RagJobEventsStream {
constructor(private readonly http: AgentHttpClient) {}
open(
ragSessionId: string,
jobId: string,
onEvent: (event: RagProgressEvent) => void
) {
const controller = new AbortController();
const terminal = this.stream(ragSessionId, jobId, onEvent, controller.signal);
return {
terminal,
close: () => controller.abort(),
};
}
private async stream(
ragSessionId: string,
jobId: string,
onEvent: (event: RagProgressEvent) => void,
signal: AbortSignal
): Promise<RagProgressEvent | null> {
const response = await fetch(
this.http.buildUrl(
`/api/rag/sessions/${encodeURIComponent(
ragSessionId
)}/jobs/${encodeURIComponent(jobId)}/events?replay=true`
),
{ headers: { Accept: "text/event-stream" }, signal }
);
if (!response.ok || !response.body) {
throw new Error(`SSE HTTP ${response.status}`);
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
let eventName = "";
let dataLines: string[] = [];
let terminal: RagProgressEvent | null = null;
const dispatch = () => {
if (!dataLines.length) {
return;
}
const payload = this.tryParse(dataLines.join("\n"));
dataLines = [];
const event = this.normalizeEvent(payload, eventName);
eventName = "";
if (!event) {
return;
}
onEvent(event);
if (event.status === "done" || event.status === "error") {
terminal = event;
}
};
while (!terminal) {
const chunk = await reader.read();
if (chunk.done) {
break;
}
buffer += decoder.decode(chunk.value, { stream: true });
let newline = buffer.indexOf("\n");
while (newline !== -1) {
const line = buffer.slice(0, newline).replace(/\r$/, "");
buffer = buffer.slice(newline + 1);
if (!line) {
dispatch();
} else if (line.startsWith("event:")) {
eventName = line.slice(6).trim();
} else if (line.startsWith("data:")) {
dataLines.push(line.slice(5).trimStart());
}
newline = buffer.indexOf("\n");
}
}
return terminal;
}
private normalizeEvent(payload: unknown, eventName: string): RagProgressEvent | null {
const src = payload && typeof payload === "object" ? payload : {};
const data = src as Record<string, unknown>;
const status = this.normalizeStatus(String(data.status || data.state || eventName || ""));
const message = String(data.message || data.detail || "");
const done = this.toNumber(data.done ?? data.processed_files ?? data.indexed_files);
const total = this.toNumber(data.total ?? data.total_files);
const failed = this.toNumber(data.failed ?? data.failed_files);
const cacheHitFiles = this.toNumber(data.cache_hit_files ?? data.cacheHitFiles);
const cacheMissFiles = this.toNumber(data.cache_miss_files ?? data.cacheMissFiles);
if (!status && !message && done == null && total == null) {
return null;
}
return {
status: status || "progress",
message,
done,
total,
failed,
cacheHitFiles,
cacheMissFiles,
currentFilePath: String(data.current_file_path || ""),
currentFileName: String(data.current_file_name || ""),
};
}
private normalizeStatus(value: string): RagProgressEvent["status"] | "" {
const normalized = value.toLowerCase();
if (["done", "completed", "terminal"].includes(normalized)) {
return "done";
}
if (["error", "failed"].includes(normalized)) {
return "error";
}
if (normalized) {
return "progress";
}
return "";
}
private tryParse(value: string): unknown {
try {
return JSON.parse(value);
} catch {
return value;
}
}
private toNumber(value: unknown): number | null {
if (value == null || value === "") {
return null;
}
const number = Number(value);
return Number.isFinite(number) ? number : null;
}
}
+136
View File
@@ -0,0 +1,136 @@
import { AgentHttpClient } from "./AgentHttpClient";
import { TaskEvent } from "./contracts";
export class TaskEventsStream {
constructor(private readonly http: AgentHttpClient) {}
open(taskId: string, onEvent: (event: TaskEvent) => void) {
const controller = new AbortController();
const terminal = this.stream(taskId, onEvent, controller.signal);
return {
terminal,
close: () => controller.abort(),
};
}
private async stream(
taskId: string,
onEvent: (event: TaskEvent) => void,
signal: AbortSignal
): Promise<TaskEvent | null> {
const response = await fetch(
this.http.buildUrl(`/api/agent/streams/${encodeURIComponent(taskId)}`),
{ headers: { Accept: "text/event-stream" }, signal }
);
if (!response.ok || !response.body) {
throw new Error(`SSE HTTP ${response.status}`);
}
return this.readEvents(taskId, response.body, onEvent);
}
private async readEvents(
taskId: string,
body: ReadableStream<Uint8Array>,
onEvent: (event: TaskEvent) => void
): Promise<TaskEvent | null> {
const reader = body.getReader();
const decoder = new TextDecoder();
let buffer = "";
let eventName = "";
let dataLines: string[] = [];
let terminal: TaskEvent | null = null;
const dispatch = () => {
if (!dataLines.length) {
return;
}
const payload = this.tryParse(dataLines.join("\n"));
dataLines = [];
const normalized = this.normalizeEvent(payload, eventName, taskId);
eventName = "";
if (!normalized) {
return;
}
onEvent(normalized);
if (normalized.kind === "result" || normalized.kind === "error") {
terminal = normalized;
}
};
while (!terminal) {
const chunk = await reader.read();
if (chunk.done) {
break;
}
buffer += decoder.decode(chunk.value, { stream: true });
let newline = buffer.indexOf("\n");
while (newline !== -1) {
const line = buffer.slice(0, newline).replace(/\r$/, "");
buffer = buffer.slice(newline + 1);
if (!line) {
dispatch();
} else if (line.startsWith("event:")) {
eventName = line.slice(6).trim();
} else if (line.startsWith("data:")) {
dataLines.push(line.slice(5).trimStart());
}
newline = buffer.indexOf("\n");
}
}
return terminal;
}
private normalizeEvent(payload: unknown, eventName: string, taskId: string): TaskEvent | null {
const src = payload && typeof payload === "object" ? payload : {};
const name = String((src as Record<string, unknown>).type || eventName || "");
const resolvedTaskId = String((src as Record<string, unknown>).request_id || taskId);
if (name === "status") {
const payloadMeta = ((src as Record<string, unknown>).payload || {}) as Record<
string,
unknown
>;
return {
...this.baseEvent("status", resolvedTaskId, src),
meta: payloadMeta,
};
}
if (name === "user") {
return {
...this.baseEvent("result", resolvedTaskId, src),
result_type: "answer",
answer: String((src as Record<string, unknown>).text || ""),
artifacts: [],
};
}
if (name === "system") {
return this.baseEvent("thinking", resolvedTaskId, src);
}
return null;
}
private baseEvent(
kind: TaskEvent["kind"],
taskId: string,
src: object
): TaskEvent {
const data = src as Record<string, unknown>;
return {
kind,
task_id: taskId,
status: String(data.status || ""),
stage: String(data.source || ""),
message: String(data.text || ""),
progress: (data.payload as Record<string, unknown> | undefined)?.progress,
meta: (data.payload as Record<string, unknown>) || {},
};
}
private tryParse(value: string): unknown {
try {
return JSON.parse(value);
} catch {
return value;
}
}
}
+118
View File
@@ -0,0 +1,118 @@
export interface FileSnapshot {
path: string;
content: string;
content_hash: string;
}
export interface AgentSessionCreateResponse {
session_id: string;
rag_session_id: string;
index_job_id: string;
status: string;
created_at: string;
}
export interface AgentSessionResult {
sessionId: string;
ragSessionId: string;
indexedFiles: number;
failedFiles: number;
cacheHitFiles: number;
cacheMissFiles: number;
}
export interface TaskQueuedResponse {
request_id: string;
session_id: string;
status: string;
stream_url: string;
}
export interface PatchHunk {
type?: "append_end" | "replace_between" | "replace_line_equals";
new_text?: string;
start_anchor?: string;
end_anchor?: string;
old_line?: string;
}
export interface ChangeItem {
op?: "create" | "update" | "delete";
path?: string;
base_hash?: string | null;
proposed_content?: string | null;
reason?: string;
hunks?: PatchHunk[];
}
export interface TaskResultResponse {
request_id: string;
session_id: string;
status: string;
process_version: string;
answer?: string | null;
changeset?: ChangeItem[];
apply_changeset?: boolean;
error?: { desc?: string; message?: string } | null;
}
export interface TaskArtifact {
artifact_type: string;
title: string;
content?: string;
format?: string;
template_id?: string | null;
source_refs?: string[];
}
export interface TaskEvent {
kind: "queued" | "progress" | "thinking" | "status" | "result" | "error";
task_id: string;
status?: string;
stage?: string;
message?: string;
progress?: unknown;
meta?: Record<string, unknown>;
heartbeat?: boolean;
result_type?: string;
answer?: string;
artifacts?: TaskArtifact[];
}
export interface SendChatPayload {
sessionId: string;
message: string;
processVersion: string;
}
export interface SendChatResult {
taskId: string;
resultType: string;
answer: string;
artifacts: TaskArtifact[];
changeset: ChangeItem[];
applyChangeset: boolean;
}
export interface RagJobResponse {
rag_session_id: string;
index_job_id: string;
status: string;
indexed_files: number;
failed_files: number;
cache_hit_files: number;
cache_miss_files: number;
error?: { desc?: string; message?: string } | null;
}
export interface RagProgressEvent {
status: "progress" | "done" | "error";
message: string;
done: number | null;
total: number | null;
failed: number | null;
cacheHitFiles: number | null;
cacheMissFiles: number | null;
currentFilePath?: string;
currentFileName?: string;
}
+23
View File
@@ -0,0 +1,23 @@
/**
* Заглушка бэкенда: имитирует задержку сети и возвращает эхо-ответ.
* Позже заменить на реальный HTTP-клиент.
*/
function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/**
* Отправляет текст на «бэкенд» и возвращает ответ (заглушка).
*
* @param text - сообщение пользователя
* @returns текст ответа ассистента
*/
export async function sendMessage(text: string): Promise<string> {
const trimmed = text.trim();
await delay(400);
if (!trimmed) {
return "[stub] Пустое сообщение.";
}
return `[stub] Echo: ${trimmed}`;
}
+367
View File
@@ -0,0 +1,367 @@
import * as vscode from "vscode";
import { createHash } from "crypto";
import * as path from "path";
import { ChatApiClient } from "../agent/ChatApiClient";
import { ChangeItem, TaskArtifact, TaskEvent } from "../agent/contracts";
import { RagStatusController } from "../rag/RagStatusController";
import { ChatSessionStore } from "./ChatSessionStore";
interface StatusBlockMeta {
id: string;
title: string;
lines: string[];
append?: boolean;
}
export class ChatController {
private agentSessionId = "";
private activeRagSessionId = "";
private readonly encoder = new TextEncoder();
constructor(
private readonly store: ChatSessionStore,
private readonly chatApi: ChatApiClient,
private readonly rag: RagStatusController
) {}
initialize(): void {
this.rag.initialize();
this.store.setProcessVersion("v2");
this.applyProcessMode("v2");
}
async handleReady(): Promise<void> {
this.applyProcessMode(this.getProcessVersion());
}
async sendMessage(text: string): Promise<void> {
const message = text.trim();
if (message.length === 0) {
return;
}
this.store.addMessage({ role: "user", text: message });
this.store.clearStatusBlocks();
this.store.setBusy(true);
const processVersion = this.getProcessVersion();
try {
await this.ensureSessionReady(false, processVersion);
const result = await this.sendMessageWithSessionRecovery(message, processVersion);
const answer = this.resolveResultText(result.resultType, result.answer, result.artifacts, result.changeset);
const applyText = result.changeset.length > 0
? (result.applyChangeset
? await this.applyChangeset(result.changeset)
: "changeset не применен: apply_changeset=false.")
: "";
const finalText = [answer, applyText].filter((item) => String(item || "").trim().length > 0).join("\n\n");
this.store.addMessage({
role: "assistant",
text: finalText || "Ответ пустой.",
kind: this.detectMessageKind(result.resultType, result.artifacts),
});
} catch (error) {
this.store.addMessage({
role: "error",
text: "Ошибка: " + String((error as Error)?.message || error),
});
this.store.clearTaskStatus();
} finally {
this.store.setBusy(false);
}
}
private async sendMessageWithSessionRecovery(message: string, processVersion: string) {
try {
return await this.chatApi.sendMessage(
{
sessionId: this.agentSessionId,
message,
processVersion,
},
(event) => this.handleTaskEvent(event)
);
} catch (error) {
if (this.isMissingSessionError(error) === false) {
throw error;
}
await this.ensureSessionReady(true, processVersion);
return await this.chatApi.sendMessage(
{
sessionId: this.agentSessionId,
message,
processVersion,
},
(event) => this.handleTaskEvent(event)
);
}
}
setProcessVersion(value: string): void {
const normalized = value === "v1" ? "v1" : "v2";
this.store.setProcessVersion(normalized);
this.applyProcessMode(normalized);
}
private isMissingSessionError(error: unknown): boolean {
const text = String((error as Error)?.message || error || "").toLowerCase();
return text.includes("session_not_found") || text.includes("agent session not found");
}
async startNewSession(): Promise<void> {
this.agentSessionId = "";
this.activeRagSessionId = "";
this.store.clearMessages();
this.store.clearStatusBlocks();
this.store.clearTaskStatus();
this.store.setBusy(true);
this.rag.resetSession();
try {
const processVersion = this.getProcessVersion();
await this.ensureSessionReady(true, processVersion);
this.store.addMessage({
role: "assistant",
text:
processVersion === "v1"
? "Новая сессия создана. История общения сброшена."
: "Новая сессия создана. История общения сброшена, индексация проекта завершена.",
});
} catch (error) {
this.store.addMessage({
role: "error",
text: "Ошибка новой сессии: " + String((error as Error)?.message || error),
});
} finally {
this.store.setBusy(false);
}
}
private getProcessVersion(): string {
return this.store.getState().processVersion || "v2";
}
private applyProcessMode(processVersion: string): void {
this.agentSessionId = "";
this.activeRagSessionId = "";
this.rag.resetSession();
if (processVersion === "v1") {
this.rag.showDisabledState();
return;
}
this.rag.showIndexedModeState(Boolean(vscode.workspace.workspaceFolders?.length));
}
async handleMenu(action: string): Promise<void> {
if (action === "settings") {
await vscode.commands.executeCommand("workbench.action.openSettings", "chatArea.agentBaseUrl");
return;
}
if (action === "about") {
await vscode.window.showInformationMessage(
"Chat Area: чат с автоматическим созданием agent session и индексацией проекта."
);
}
}
private handleTaskEvent(event: TaskEvent): void {
const statusBlock = this.extractStatusBlock(event);
if (statusBlock) {
if (statusBlock.append) {
this.store.appendStatusBlockLines(statusBlock.id, statusBlock.title, statusBlock.lines);
} else {
this.store.replaceStatusBlock(statusBlock);
}
}
if (event.kind === "result" || event.kind === "error") {
this.store.clearTaskStatus();
}
}
private extractStatusBlock(event: TaskEvent): StatusBlockMeta | null {
const raw = event.meta?.status_block;
if (!raw || typeof raw !== "object") {
return null;
}
const data = raw as Record<string, unknown>;
const id = String(data.id || "").trim();
const title = String(data.title || "").trim();
const lines = Array.isArray(data.lines)
? data.lines.map((item) => String(item || "").trim()).filter((item) => item.length > 0)
: [];
if (id.length === 0 || title.length === 0 || lines.length === 0) {
return null;
}
return { id, title, lines, append: Boolean(data.append) };
}
private formatChangeset(items: ChangeItem[]): string {
if (items.length === 0) {
return "Получен changeset без файлов.";
}
return items
.slice(0, 20)
.map((item) => ("- " + (item.op || "change") + " " + (item.path || "")).trim())
.join("\n");
}
private resolveResultText(
resultType: string,
answer: string,
artifacts: TaskArtifact[],
changeset: ChangeItem[]
): string {
if (resultType === "changeset") {
return this.formatChangeset(changeset);
}
if (answer && answer.trim()) {
return answer;
}
const primaryArtifact = artifacts[0];
if (primaryArtifact?.content) {
return primaryArtifact.content;
}
if (primaryArtifact?.title) {
return primaryArtifact.title + "\n\nАртефакт подготовлен и сохранен на стороне backend.";
}
return "";
}
private detectMessageKind(resultType: string, artifacts: TaskArtifact[]): "plain" | "markdown" {
if (resultType === "documentation") {
return "markdown";
}
if (artifacts.some((item) => String(item.format || "").toLowerCase() === "markdown")) {
return "markdown";
}
return "plain";
}
private async ensureSessionReady(
forceRecreate = false,
processVersion = this.getProcessVersion()
): Promise<void> {
if (forceRecreate) {
this.rag.resetSession();
}
const session =
processVersion === "v1"
? forceRecreate
? await this.rag.createLightweightSession()
: await this.rag.ensureLightweightSessionReady()
: forceRecreate
? await this.rag.createNewSession()
: await this.rag.ensureSessionReady();
this.agentSessionId = session.agentSessionId;
this.activeRagSessionId = session.ragSessionId;
this.store.setRagStatus({ ragSessionId: this.activeRagSessionId });
}
private async applyChangeset(items: ChangeItem[]): Promise<string> {
if (items.length === 0) {
return "apply_changeset=true, но changeset пуст.";
}
const folder = vscode.workspace.workspaceFolders?.[0];
if (!folder) {
return "changeset не применен: workspace не открыт.";
}
let applied = 0;
let skipped = 0;
const errors: string[] = [];
for (const item of items) {
const op = String(item.op || "").toLowerCase();
const target = this.resolveWorkspaceUri(folder.uri, String(item.path || ""));
if (!target) {
skipped += 1;
errors.push("skip " + (item.path || "<empty>") + ": invalid path");
continue;
}
try {
if (op === "create") {
const content = String(item.proposed_content || "");
if (content.length === 0) {
skipped += 1;
errors.push("skip create " + item.path + ": empty proposed_content");
continue;
}
await this.ensureParentDir(target);
await vscode.workspace.fs.writeFile(target, this.encoder.encode(content));
applied += 1;
continue;
}
if (op === "update") {
const content = String(item.proposed_content || "");
if (content.length === 0) {
skipped += 1;
errors.push("skip update " + item.path + ": empty proposed_content");
continue;
}
if ((await this.verifyBaseHash(target, item.base_hash)) === false) {
skipped += 1;
errors.push("skip update " + item.path + ": base_hash mismatch");
continue;
}
await this.ensureParentDir(target);
await vscode.workspace.fs.writeFile(target, this.encoder.encode(content));
applied += 1;
continue;
}
if (op === "delete") {
if ((await this.verifyBaseHash(target, item.base_hash)) === false) {
skipped += 1;
errors.push("skip delete " + item.path + ": base_hash mismatch");
continue;
}
await vscode.workspace.fs.delete(target, { useTrash: false, recursive: false });
applied += 1;
continue;
}
skipped += 1;
errors.push("skip " + item.path + ": unsupported op '" + op + "'");
} catch (error) {
skipped += 1;
errors.push("skip " + item.path + ": " + String((error as Error)?.message || error));
}
}
const lines = ["changeset apply: applied=" + applied + ", skipped=" + skipped];
for (const err of errors.slice(0, 10)) {
lines.push("- " + err);
}
return lines.join("\n");
}
private resolveWorkspaceUri(root: vscode.Uri, relPath: string): vscode.Uri | null {
const cleaned = relPath.replace(/\\/g, "/").trim();
if (cleaned.length === 0 || path.posix.isAbsolute(cleaned)) {
return null;
}
const normalized = path.posix.normalize(cleaned);
if (normalized.startsWith("../") || normalized === "..") {
return null;
}
const parts = normalized.split("/").filter(Boolean);
return vscode.Uri.joinPath(root, ...parts);
}
private async ensureParentDir(target: vscode.Uri): Promise<void> {
const parent = vscode.Uri.file(path.dirname(target.fsPath));
await vscode.workspace.fs.createDirectory(parent);
}
private async verifyBaseHash(target: vscode.Uri, expected: unknown): Promise<boolean> {
const expectedHash = String(expected || "").trim();
if (expectedHash.length === 0) {
return true;
}
try {
const bytes = await vscode.workspace.fs.readFile(target);
const actual = createHash("sha256").update(bytes).digest("hex");
return actual === expectedHash;
} catch {
return false;
}
}
}
+100
View File
@@ -0,0 +1,100 @@
import * as vscode from "vscode";
import {
ChatMessage,
RagStatusViewModel,
StatusBlockViewModel,
TaskStatusViewModel,
WebviewState,
} from "./models";
type Listener = (state: WebviewState) => void;
export class ChatSessionStore {
private readonly listeners = new Set<Listener>();
private state: WebviewState = {
messages: [],
statusBlocks: [],
taskStatus: { visible: false, label: "", detail: "", progress: null },
processVersion: "v2",
ragStatus: {
state: "idle",
label: "RAG: подготовка",
detail: "Процесс v2 использует индекс документации проекта.",
ragSessionId: "",
indexedFiles: 0,
failedFiles: 0,
cacheHitFiles: 0,
cacheMissFiles: 0,
canReindex: false,
},
busy: false,
};
getState(): WebviewState {
return this.state;
}
subscribe(listener: Listener): vscode.Disposable {
this.listeners.add(listener);
listener(this.state);
return new vscode.Disposable(() => this.listeners.delete(listener));
}
setBusy(busy: boolean): void {
this.update({ busy });
}
addMessage(message: ChatMessage): void {
this.update({ messages: [...this.state.messages, message] });
}
clearMessages(): void {
this.update({ messages: [] });
}
clearStatusBlocks(): void {
this.update({ statusBlocks: [] });
}
replaceStatusBlock(block: StatusBlockViewModel): void {
const items = this.state.statusBlocks.slice();
const index = items.findIndex((item) => item.id === block.id);
if (index >= 0) {
items[index] = { ...block, lines: block.lines.slice() };
} else {
items.push({ ...block, lines: block.lines.slice() });
}
this.update({ statusBlocks: items });
}
appendStatusBlockLines(id: string, title: string, lines: string[]): void {
const current = this.state.statusBlocks.find((item) => item.id === id);
const nextLines = [...(current?.lines || []), ...lines];
this.replaceStatusBlock({ id, title, lines: nextLines });
}
setTaskStatus(status: Partial<TaskStatusViewModel>): void {
this.update({ taskStatus: { ...this.state.taskStatus, ...status } });
}
clearTaskStatus(): void {
this.update({
taskStatus: { visible: false, label: "", detail: "", progress: null },
});
}
setRagStatus(status: Partial<RagStatusViewModel>): void {
this.update({ ragStatus: { ...this.state.ragStatus, ...status } });
}
setProcessVersion(processVersion: string): void {
this.update({ processVersion });
}
private update(patch: Partial<WebviewState>): void {
this.state = { ...this.state, ...patch };
for (const listener of this.listeners) {
listener(this.state);
}
}
}
+58
View File
@@ -0,0 +1,58 @@
import * as fs from "fs";
import * as path from "path";
import * as vscode from "vscode";
import { ChatSessionStore } from "./ChatSessionStore";
import { WebviewMessage, WebviewState } from "./models";
const VIEW_TYPE = "chat-area.chatView";
export class ChatViewProvider implements vscode.WebviewViewProvider {
private view?: vscode.WebviewView;
private handler?: (message: WebviewMessage) => Promise<void>;
constructor(
private readonly extensionUri: vscode.Uri,
private readonly store: ChatSessionStore
) {}
setMessageHandler(handler: (message: WebviewMessage) => Promise<void>): void {
this.handler = handler;
}
resolveWebviewView(view: vscode.WebviewView): void {
this.view = view;
view.webview.options = {
enableScripts: true,
localResourceRoots: [vscode.Uri.joinPath(this.extensionUri, "media")],
};
view.webview.html = this.getHtml(view.webview);
view.webview.onDidReceiveMessage((message: WebviewMessage) => {
void this.handler?.(message);
});
this.publish(this.store.getState());
}
bind(): vscode.Disposable {
return this.store.subscribe((state) => this.publish(state));
}
getViewType(): string {
return VIEW_TYPE;
}
private publish(state: WebviewState): void {
this.view?.webview.postMessage({ type: "state", payload: state });
}
private getHtml(webview: vscode.Webview): string {
const base = vscode.Uri.joinPath(this.extensionUri, "media");
const css = webview.asWebviewUri(vscode.Uri.joinPath(base, "chat.css"));
const js = webview.asWebviewUri(vscode.Uri.joinPath(base, "chat.js"));
const htmlPath = path.join(this.extensionUri.fsPath, "media", "chat.html");
return fs
.readFileSync(htmlPath, "utf8")
.replace(/\{\{CSP\}\}/g, webview.cspSource)
.replace(/\{\{CSS_URI\}\}/g, String(css))
.replace(/\{\{JS_URI\}\}/g, String(js));
}
}
+28
View File
@@ -0,0 +1,28 @@
import { ChatController } from "./ChatController";
import { WebviewMessage } from "./models";
export class WebviewMessageRouter {
constructor(private readonly controller: ChatController) {}
async handle(message: WebviewMessage): Promise<void> {
switch (message.type) {
case "ready":
await this.controller.handleReady();
break;
case "send":
await this.controller.sendMessage(String(message.text || ""));
break;
case "clear":
await this.controller.startNewSession();
break;
case "menu":
await this.controller.handleMenu(String(message.action || ""));
break;
case "set-process-version":
this.controller.setProcessVersion(String(message.value || "v2"));
break;
default:
break;
}
}
}
+48
View File
@@ -0,0 +1,48 @@
export type ChatRole = "user" | "assistant" | "error" | "status";
export interface ChatMessage {
role: ChatRole;
text: string;
kind?: "plain" | "markdown";
}
export interface TaskStatusViewModel {
visible: boolean;
label: string;
detail: string;
progress: number | null;
}
export interface RagStatusViewModel {
state: "idle" | "indexing" | "ready" | "error";
label: string;
detail: string;
ragSessionId?: string;
indexedFiles: number;
failedFiles: number;
cacheHitFiles: number;
cacheMissFiles: number;
canReindex: boolean;
}
export interface StatusBlockViewModel {
id: string;
title: string;
lines: string[];
}
export interface WebviewState {
messages: ChatMessage[];
statusBlocks: StatusBlockViewModel[];
taskStatus: TaskStatusViewModel;
ragStatus: RagStatusViewModel;
processVersion: string;
busy: boolean;
}
export interface WebviewMessage {
type: string;
text?: string;
action?: string;
value?: string;
}
+37
View File
@@ -0,0 +1,37 @@
import * as vscode from "vscode";
import { AgentConfig } from "./agent/AgentConfig";
import { AgentHttpClient } from "./agent/AgentHttpClient";
import { ChatApiClient } from "./agent/ChatApiClient";
import { RagJobEventsStream } from "./agent/RagJobEventsStream";
import { TaskEventsStream } from "./agent/TaskEventsStream";
import { ChatController } from "./chat/ChatController";
import { ChatSessionStore } from "./chat/ChatSessionStore";
import { ChatViewProvider } from "./chat/ChatViewProvider";
import { WebviewMessageRouter } from "./chat/WebviewMessageRouter";
import { ContentHashService } from "./rag/ContentHashService";
import { RagStatusController } from "./rag/RagStatusController";
import { WorkspaceSnapshotService } from "./rag/WorkspaceSnapshotService";
export function activate(context: vscode.ExtensionContext): void {
const store = new ChatSessionStore();
const config = new AgentConfig();
const http = new AgentHttpClient(config);
const chatApi = new ChatApiClient(http, new TaskEventsStream(http), new RagJobEventsStream(http));
const snapshots = new WorkspaceSnapshotService(config, new ContentHashService());
const rag = new RagStatusController(store, snapshots, chatApi);
const controller = new ChatController(store, chatApi, rag);
const provider = new ChatViewProvider(context.extensionUri, store);
const router = new WebviewMessageRouter(controller);
provider.setMessageHandler((message) => router.handle(message));
controller.initialize();
context.subscriptions.push(
provider.bind(),
vscode.window.registerWebviewViewProvider(provider.getViewType(), provider, {
webviewOptions: { retainContextWhenHidden: true },
})
);
}
export function deactivate(): void {}
+7
View File
@@ -0,0 +1,7 @@
import { createHash } from "crypto";
export class ContentHashService {
hash(content: string): string {
return createHash("sha256").update(content).digest("hex");
}
}
+277
View File
@@ -0,0 +1,277 @@
import { ChatApiClient } from "../agent/ChatApiClient";
import { RagProgressEvent } from "../agent/contracts";
import { ChatSessionStore } from "../chat/ChatSessionStore";
import { WorkspaceSnapshotService } from "./WorkspaceSnapshotService";
export interface SessionBootstrapState {
agentSessionId: string;
ragSessionId: string;
}
type SessionMode = "none" | "lightweight" | "rag";
export class RagStatusController {
private static readonly BLOCK_ID = "rag_index";
private static readonly BLOCK_TITLE = "Индексация в RAG";
private agentSessionId = "";
private ragSessionId = "";
private sessionMode: SessionMode = "none";
private inFlight?: Promise<SessionBootstrapState>;
private blockLines: string[] = [];
private lastProgressPath = "";
constructor(
private readonly store: ChatSessionStore,
private readonly snapshots: WorkspaceSnapshotService,
private readonly chatApi: ChatApiClient
) {}
getCurrentAgentSessionId(): string {
return this.agentSessionId;
}
getCurrentSessionId(): string {
return this.ragSessionId;
}
initialize(): void {
return;
}
async ensureSessionReady(): Promise<SessionBootstrapState> {
if (this.sessionMode === "rag" && this.agentSessionId && this.ragSessionId) {
return {
agentSessionId: this.agentSessionId,
ragSessionId: this.ragSessionId,
};
}
return this.createNewSession();
}
async ensureLightweightSessionReady(): Promise<SessionBootstrapState> {
if (this.sessionMode === "lightweight" && this.agentSessionId) {
return {
agentSessionId: this.agentSessionId,
ragSessionId: "",
};
}
return this.createLightweightSession();
}
async createNewSession(): Promise<SessionBootstrapState> {
if (!this.inFlight) {
this.inFlight = this.bootstrapSession().finally(() => {
this.inFlight = undefined;
});
}
return this.inFlight;
}
async createLightweightSession(): Promise<SessionBootstrapState> {
if (!this.inFlight) {
this.inFlight = this.bootstrapLightweightSession().finally(() => {
this.inFlight = undefined;
});
}
return this.inFlight;
}
resetSession(): void {
this.agentSessionId = "";
this.ragSessionId = "";
this.sessionMode = "none";
this.store.setRagStatus({
ragSessionId: "",
indexedFiles: 0,
failedFiles: 0,
cacheHitFiles: 0,
cacheMissFiles: 0,
});
}
showDisabledState(): void {
this.store.setRagStatus({
state: "idle",
label: "RAG: не используется",
detail: "Процесс v1 работает без индексации проекта.",
ragSessionId: "",
indexedFiles: 0,
failedFiles: 0,
cacheHitFiles: 0,
cacheMissFiles: 0,
canReindex: false,
});
}
showIndexedModeState(workspaceOpen: boolean): void {
if (!workspaceOpen) {
this.store.setRagStatus({
state: "error",
label: "RAG: workspace не открыт",
detail: "Открой папку проекта в Cursor, чтобы запустить индексацию.",
ragSessionId: "",
indexedFiles: 0,
failedFiles: 0,
cacheHitFiles: 0,
cacheMissFiles: 0,
canReindex: false,
});
return;
}
this.store.setRagStatus({
state: "idle",
label: "RAG: подготовка",
detail: "При первом запросе будет создана новая сессия и запущена индексация.",
ragSessionId: "",
indexedFiles: 0,
failedFiles: 0,
cacheHitFiles: 0,
cacheMissFiles: 0,
canReindex: true,
});
}
private async bootstrapSession(): Promise<SessionBootstrapState> {
this.resetProgressTracking();
this.store.setRagStatus({
state: "indexing",
label: "RAG: новая сессия",
detail: "Создаю agent session и индексирую проект.",
ragSessionId: this.ragSessionId,
canReindex: false,
});
const snapshot = await this.snapshots.createSnapshot();
this.store.setRagStatus({
state: "indexing",
label: "RAG: индексация",
detail: `Отправлено файлов: ${snapshot.files.length}`,
ragSessionId: this.ragSessionId,
indexedFiles: 0,
failedFiles: 0,
cacheHitFiles: 0,
cacheMissFiles: 0,
canReindex: false,
});
const result = await this.chatApi.createSession(
snapshot.projectId,
snapshot.files,
(event) => this.handleProgress(event)
);
this.agentSessionId = result.sessionId;
this.ragSessionId = result.ragSessionId;
this.sessionMode = "rag";
this.lastProgressPath = "";
this.store.setRagStatus({
state: "ready",
label: "RAG: готов",
detail: `Индексировано: ${result.indexedFiles}`,
ragSessionId: this.ragSessionId,
indexedFiles: result.indexedFiles,
failedFiles: result.failedFiles,
cacheHitFiles: result.cacheHitFiles,
cacheMissFiles: result.cacheMissFiles,
canReindex: true,
});
this.pushBlockLine(
this.buildCompletedText(
"Полная индексация завершена",
result.indexedFiles,
result.failedFiles,
result.cacheHitFiles,
result.cacheMissFiles
)
);
return {
agentSessionId: this.agentSessionId,
ragSessionId: this.ragSessionId,
};
}
private async bootstrapLightweightSession(): Promise<SessionBootstrapState> {
this.resetProgressTracking();
this.showDisabledState();
const result = await this.chatApi.createSession(
this.snapshots.resolveProjectId(),
[],
() => undefined
);
this.agentSessionId = result.sessionId;
this.ragSessionId = "";
this.sessionMode = "lightweight";
return {
agentSessionId: this.agentSessionId,
ragSessionId: "",
};
}
private handleProgress(event: RagProgressEvent): void {
const detail = event.message || this.buildProgressText(event.done, event.total);
const currentPath = String(event.currentFilePath || event.currentFileName || "").trim();
if (currentPath && currentPath !== this.lastProgressPath) {
this.lastProgressPath = currentPath;
this.pushBlockLine(currentPath);
}
this.store.setRagStatus({
state: event.status === "error" ? "error" : "indexing",
label: event.status === "error" ? "RAG: ошибка" : "RAG: индексация",
detail,
ragSessionId: this.ragSessionId,
indexedFiles: event.done || 0,
failedFiles: event.failed || 0,
cacheHitFiles: event.cacheHitFiles || 0,
cacheMissFiles: event.cacheMissFiles || 0,
canReindex: false,
});
if (event.status === "error") {
this.pushBlockLine(`Ошибка: ${detail}`);
}
}
private buildProgressText(done: number | null, total: number | null): string {
if (done != null && total != null) {
return `Обработано ${done} из ${total}`;
}
if (done != null) {
return `Обработано файлов: ${done}`;
}
return "Идет индексация проекта.";
}
private buildCompletedText(
label: string,
indexed: number,
failed: number,
cacheHit: number,
cacheMiss: number
): string {
const reuse = this.buildReuseRate(cacheHit, cacheMiss);
return `${label}. indexed=${indexed}, failed=${failed}, cache_hit=${cacheHit}, cache_miss=${cacheMiss}, reuse=${reuse}`;
}
private buildReuseRate(cacheHit: number, cacheMiss: number): string {
const total = cacheHit + cacheMiss;
if (total <= 0) {
return "0%";
}
return `${Math.round((cacheHit / total) * 100)}%`;
}
private pushBlockLine(line: string): void {
const text = line.trim();
if (!text) {
return;
}
this.blockLines = [...this.blockLines, text];
this.store.replaceStatusBlock({
id: RagStatusController.BLOCK_ID,
title: RagStatusController.BLOCK_TITLE,
lines: this.blockLines,
});
}
private resetProgressTracking(): void {
this.blockLines = [];
this.lastProgressPath = "";
}
}
+63
View File
@@ -0,0 +1,63 @@
import * as vscode from "vscode";
import { AgentConfig } from "../agent/AgentConfig";
import { FileSnapshot } from "../agent/contracts";
import { ContentHashService } from "./ContentHashService";
export interface WorkspaceSnapshot {
projectId: string;
files: FileSnapshot[];
}
export class WorkspaceSnapshotService {
private readonly decoder = new TextDecoder("utf-8", { fatal: true });
constructor(
private readonly config: AgentConfig,
private readonly hashes: ContentHashService
) {}
async createSnapshot(): Promise<WorkspaceSnapshot> {
const folder = vscode.workspace.workspaceFolders?.[0];
if (!folder) {
throw new Error("Открой workspace в Cursor перед запуском индексации.");
}
const uris = await vscode.workspace.findFiles(
new vscode.RelativePattern(folder, "{docs/**,.analysis/**}"),
this.config.getExcludeGlob()
);
const files: FileSnapshot[] = [];
for (const uri of uris) {
const stat = await vscode.workspace.fs.stat(uri);
if (stat.type !== vscode.FileType.File) {
continue;
}
if (stat.size > this.config.getMaxIndexedFileSizeBytes()) {
continue;
}
const bytes = await vscode.workspace.fs.readFile(uri);
const content = this.decodeText(bytes);
if (content == null) {
continue;
}
files.push({
path: vscode.workspace.asRelativePath(uri, false),
content,
content_hash: this.hashes.hash(content),
});
}
return { projectId: folder.uri.fsPath, files };
}
resolveProjectId(): string {
const folder = vscode.workspace.workspaceFolders?.[0];
return folder?.uri.fsPath || "chat-area-v1";
}
private decodeText(bytes: Uint8Array): string | null {
try {
return this.decoder.decode(bytes);
} catch {
return null;
}
}
}