Первый коммит
This commit is contained in:
@@ -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}/**"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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}` : ""}`;
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { createHash } from "crypto";
|
||||
|
||||
export class ContentHashService {
|
||||
hash(content: string): string {
|
||||
return createHash("sha256").update(content).digest("hex");
|
||||
}
|
||||
}
|
||||
@@ -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 = "";
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user