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

This commit is contained in:
2026-04-09 15:42:42 +03:00
commit c664209746
28 changed files with 2616 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
node_modules
out
*.vsix
+13
View File
@@ -0,0 +1,13 @@
{
"version": "0.2.0",
"configurations": [
{
"name": "Run Extension",
"type": "extensionHost",
"request": "launch",
"args": ["--extensionDevelopmentPath=${workspaceFolder}"],
"outFiles": ["${workspaceFolder}/out/**/*.js"],
"preLaunchTask": "${defaultBuildTask}"
}
]
}
+22
View File
@@ -0,0 +1,22 @@
{
"version": "2.0.0",
"tasks": [
{
"type": "npm",
"script": "watch",
"problemMatcher": "$tsc-watch",
"isBackground": true,
"presentation": { "reveal": "silent" },
"group": {
"kind": "build",
"isDefault": true
}
},
{
"type": "npm",
"script": "compile",
"problemMatcher": "$tsc",
"group": "build"
}
]
}
+8
View File
@@ -0,0 +1,8 @@
.vscode/**
.vscode-test/**
src/**
.gitignore
**/tsconfig.json
**/*.map
node_modules/**
!node_modules/@types/**
+32
View File
@@ -0,0 +1,32 @@
# Chat Area (расширение VS Code / Cursor)
## 1. Назначение и цели
Расширение добавляет в боковую панель область в виде **чата**: фиксированный заголовок (очистка и меню), лента сообщений и поле ввода с кнопкой «Отправить». Сообщения обрабатываются через **заглушку бэкенда** (эхо-ответ); реальная интеграция с API подключается позже.
## 2. Архитектура и workflow
- **Activity Bar** → контейнер представлений `chat-area-sidebar`**WebviewView** `chat-area.chatView`.
- **Extension Host** ([`src/extension.ts`](src/extension.ts)): провайдер веб-представления, хранение истории сообщений в памяти, при `send` вызов [`src/backendStub.ts`](src/backendStub.ts) и рассылка списка сообщений в Webview через `postMessage`.
- **Webview** ([`media/chat.html`](media/chat.html) + CSS/JS): вёрстка 80/20 (лента / ввод), кнопки, меню «⋯»; события уходят в расширение через `vscode.postMessage`.
## 3. Настройка
- **Node.js** (LTS), **npm**.
- Переменные окружения и внешние сервисы на этапе заглушек **не требуются**.
## 4. Запуск (разработка)
```bash
cd plugin
npm install
npm run compile
```
В VS Code / Cursor: **Run → Start Debugging** (или **F5**), выбрать конфигурацию **Run Extension**. В окне Extension Development Host открыть боковую панель **Chat** и вкладку **Чат**.
## 5. Тесты
Автотесты в проекте не настроены. Проверка вручную: **F5** → отправка сообщения (ожидается ответ `[stub] Echo: …`), **Очистить**, пункты меню «⋯» (заглушка — информационное сообщение).
Упаковка в `.vsix` (при установленном `@vscode/vsce`): `npx @vscode/vsce package`.
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>

After

Width:  |  Height:  |  Size: 261 B

+415
View File
@@ -0,0 +1,415 @@
* {
box-sizing: border-box;
}
html,
body {
height: 100%;
margin: 0;
padding: 0;
overflow: hidden;
font-family: var(--vscode-font-family);
font-size: var(--vscode-font-size);
color: var(--vscode-foreground);
background: var(--vscode-sideBar-background);
}
.chat-root {
display: flex;
flex-direction: column;
height: 100vh;
min-height: 200px;
overflow: hidden;
}
.chat-header {
flex-shrink: 0;
display: flex;
align-items: center;
align-content: flex-start;
flex-wrap: wrap;
gap: 8px;
min-height: 44px;
padding: 8px;
border-bottom: 1px solid var(--vscode-widget-border, rgba(128, 128, 128, 0.35));
background: var(--vscode-sideBar-background);
}
.header-controls {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 6px;
flex: 0 0 auto;
}
.process-select-wrap {
display: flex;
flex-direction: column;
gap: 4px;
}
.process-select-label {
font-size: 0.78em;
opacity: 0.8;
}
.process-select-wrap select {
min-width: 120px;
font-family: inherit;
font-size: inherit;
padding: 4px 8px;
border: 1px solid var(--vscode-dropdown-border, var(--vscode-widget-border, rgba(128, 128, 128, 0.35)));
background: var(--vscode-dropdown-background, var(--vscode-input-background));
color: var(--vscode-dropdown-foreground, var(--vscode-input-foreground));
border-radius: 2px;
}
.process-select-wrap select:focus {
outline: 1px solid var(--vscode-focusBorder);
outline-offset: 1px;
}
.header-session {
flex: 1 1 320px;
min-width: 220px;
display: flex;
align-items: flex-start;
gap: 6px;
padding: 4px 8px;
border: 1px solid var(--vscode-widget-border, rgba(128, 128, 128, 0.35));
border-radius: 4px;
background: var(--vscode-sideBar-background);
color: var(--vscode-descriptionForeground, var(--vscode-disabledForeground));
font-size: 0.88em;
line-height: 1.25;
cursor: pointer;
}
.header-session:hover {
border-color: var(--vscode-focusBorder, rgba(128, 128, 128, 0.6));
color: var(--vscode-foreground);
}
.header-session:focus {
outline: 1px solid var(--vscode-focusBorder, rgba(128, 128, 128, 0.6));
outline-offset: 1px;
}
.header-session.copied {
border-color: var(--vscode-testing-iconPassed, #73c991);
color: var(--vscode-testing-iconPassed, #73c991);
}
.header-session-label {
flex-shrink: 0;
font-weight: 600;
}
.header-session-value {
min-width: 0;
white-space: normal;
overflow-wrap: anywhere;
word-break: break-word;
font-family: var(--vscode-editor-font-family, var(--vscode-font-family));
}
.header-spacer {
flex: 999 1 auto;
}
.btn {
font-family: inherit;
font-size: var(--vscode-font-size);
padding: 4px 10px;
cursor: pointer;
border: 1px solid var(--vscode-button-border, transparent);
background: var(--vscode-button-secondaryBackground);
color: var(--vscode-button-secondaryForeground);
border-radius: 2px;
}
.btn:hover {
background: var(--vscode-button-secondaryHoverBackground);
}
.btn-primary {
background: var(--vscode-button-background);
color: var(--vscode-button-foreground);
}
.btn-primary:hover {
background: var(--vscode-button-hoverBackground);
}
.btn-icon {
min-width: 32px;
padding: 4px 8px;
font-size: 1.25rem;
line-height: 1;
}
.menu-wrap {
position: relative;
margin-left: auto;
}
.menu-dropdown {
position: absolute;
top: 100%;
right: 0;
margin-top: 4px;
min-width: 200px;
padding: 4px 0;
background: var(--vscode-menu-background);
color: var(--vscode-menu-foreground);
border: 1px solid var(--vscode-menu-border);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
z-index: 10;
}
.menu-item {
display: block;
width: 100%;
text-align: left;
padding: 6px 12px;
border: none;
background: transparent;
color: inherit;
font-family: inherit;
font-size: inherit;
cursor: pointer;
}
.menu-item:hover {
background: var(--vscode-menu-selectionBackground);
color: var(--vscode-menu-selectionForeground);
}
.chat-body {
flex: 1;
min-height: 0;
overflow: hidden;
}
.messages-pane {
height: 100%;
min-height: 0;
padding: 8px;
display: flex;
flex-direction: column;
gap: 8px;
overflow-y: auto;
}
.status-blocks {
display: flex;
flex-direction: column;
gap: 6px;
}
.status-block {
border: 1px solid var(--vscode-widget-border, transparent);
border-radius: 6px;
background: color-mix(in srgb, var(--vscode-sideBar-background) 88%, var(--vscode-editor-inactiveSelectionBackground) 12%);
color: color-mix(in srgb, var(--vscode-foreground) 62%, transparent);
}
.status-block-title {
padding: 6px 8px 4px;
font-size: 0.86em;
font-weight: 600;
opacity: 0.85;
border-bottom: 1px solid color-mix(in srgb, var(--vscode-widget-border, rgba(128, 128, 128, 0.35)) 70%, transparent);
}
.status-block-body {
max-height: 12.5em;
overflow-y: auto;
padding: 4px 8px 6px;
font-size: 0.84em;
line-height: 1.25;
white-space: pre-wrap;
word-break: break-word;
}
.status-block-line + .status-block-line {
margin-top: 2px;
}
.messages {
display: flex;
flex-direction: column;
gap: 8px;
overflow: visible;
}
.chat-footer {
flex-shrink: 0;
border-top: 1px solid var(--vscode-widget-border, rgba(128, 128, 128, 0.35));
background: var(--vscode-sideBar-background);
}
.input-pane {
display: flex;
flex-direction: column;
padding: 8px;
gap: 8px;
}
.input-pane textarea {
min-height: 72px;
max-height: 140px;
resize: vertical;
font-family: inherit;
font-size: inherit;
padding: 8px;
border: 1px solid var(--vscode-input-border, var(--vscode-widget-border));
background: var(--vscode-input-background);
color: var(--vscode-input-foreground);
border-radius: 2px;
}
.input-pane textarea:focus {
outline: 1px solid var(--vscode-focusBorder);
outline-offset: -1px;
}
.input-actions {
flex-shrink: 0;
display: flex;
justify-content: flex-end;
}
.task-status {
padding: 8px 10px;
border: 1px solid var(--vscode-widget-border, transparent);
border-radius: 6px;
background: var(--vscode-editor-inactiveSelectionBackground, rgba(128, 128, 128, 0.12));
}
.task-status-label,
.rag-label {
font-weight: 600;
margin-bottom: 2px;
}
.task-status-detail,
.rag-detail,
.rag-metrics {
font-size: 0.92em;
opacity: 0.88;
}
.task-progress {
margin-top: 8px;
height: 6px;
border-radius: 999px;
overflow: hidden;
background: var(--vscode-editorGroup-border, rgba(128, 128, 128, 0.15));
}
.task-progress-bar {
height: 100%;
width: 0%;
background: var(--vscode-progressBar-background, var(--vscode-button-background));
}
.rag-status {
flex-shrink: 0;
display: flex;
flex-direction: column;
gap: 8px;
padding: 8px 10px;
border: 1px solid var(--vscode-widget-border, transparent);
border-radius: 6px;
background: color-mix(in srgb, var(--vscode-sideBar-background) 82%, var(--vscode-editor-inactiveSelectionBackground) 18%);
}
.rag-status-main {
display: flex;
gap: 8px;
align-items: flex-start;
}
.rag-dot {
width: 10px;
height: 10px;
border-radius: 999px;
margin-top: 4px;
background: var(--vscode-disabledForeground);
flex-shrink: 0;
}
.rag-dot.state-idle {
background: var(--vscode-disabledForeground);
}
.rag-dot.state-indexing {
background: var(--vscode-charts-yellow, #d7ba7d);
}
.rag-dot.state-ready {
background: var(--vscode-testing-iconPassed, #73c991);
}
.rag-dot.state-error {
background: var(--vscode-testing-iconFailed, #f14c4c);
}
.rag-text {
min-width: 0;
}
.msg {
max-width: 95%;
padding: 8px 10px;
border-radius: 6px;
white-space: pre-wrap;
word-break: break-word;
}
.msg-user {
align-self: flex-end;
background: var(--vscode-textBlockQuote-background);
border: 1px solid var(--vscode-widget-border, transparent);
}
.msg-assistant {
align-self: flex-start;
background: var(--vscode-editor-inactiveSelectionBackground, rgba(128, 128, 128, 0.15));
border: 1px solid var(--vscode-widget-border, transparent);
}
.msg-error {
align-self: stretch;
background: color-mix(in srgb, var(--vscode-inputValidation-errorBackground, rgba(241, 76, 76, 0.18)) 90%, transparent);
border: 1px solid var(--vscode-inputValidation-errorBorder, rgba(241, 76, 76, 0.7));
}
.msg-status {
align-self: stretch;
padding: 0;
border: none;
background: transparent;
color: var(--vscode-descriptionForeground, var(--vscode-disabledForeground));
font-size: 0.9em;
opacity: 0.9;
line-height: 1.12;
}
.msg-role {
font-size: 0.75em;
opacity: 0.8;
margin-bottom: 4px;
}
.msg-text,
.msg-markdown {
white-space: pre-wrap;
word-break: break-word;
}
.msg-markdown {
font-family: var(--vscode-editor-font-family, var(--vscode-font-family));
}
+76
View File
@@ -0,0 +1,76 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src {{CSP}}; script-src {{CSP}};" />
<link href="{{CSS_URI}}" rel="stylesheet" />
</head>
<body>
<div class="chat-root">
<header class="chat-header">
<div class="header-controls">
<button type="button" class="btn" id="btn-clear" title="Новая сессия">Новая сессия</button>
<label class="process-select-wrap" for="process-version">
<span class="process-select-label">Процесс</span>
<select id="process-version" aria-label="Версия процесса агента">
<option value="v1">v1</option>
<option value="v2" selected>v2</option>
</select>
</label>
</div>
<div
class="header-session"
id="header-rag-session"
role="button"
tabindex="0"
title="Кликните, чтобы скопировать RAG session id"
aria-label="RAG session id"
>
<span class="header-session-label">RAG:</span>
<span class="header-session-value" id="header-rag-session-value"></span>
</div>
<div class="header-spacer"></div>
<div class="menu-wrap">
<button type="button" class="btn btn-icon" id="btn-menu" title="Меню" aria-haspopup="true" aria-expanded="false"></button>
<div class="menu-dropdown" id="menu-dropdown" hidden>
<button type="button" class="menu-item" data-action="settings">Настройки (заглушка)</button>
<button type="button" class="menu-item" data-action="about">О плагине (заглушка)</button>
</div>
</div>
</header>
<div class="chat-body">
<div class="messages-pane" id="feed-scroll">
<div class="status-blocks" id="status-blocks"></div>
<div class="messages" id="messages"></div>
</div>
</div>
<div class="chat-footer">
<div class="input-pane">
<div class="task-status" id="task-status" hidden>
<div class="task-status-label" id="task-status-label"></div>
<div class="task-status-detail" id="task-status-detail"></div>
<div class="task-progress" id="task-progress" hidden>
<div class="task-progress-bar" id="task-progress-bar"></div>
</div>
</div>
<textarea id="input" rows="3" placeholder="Сообщение…" aria-label="Текст сообщения"></textarea>
<div class="input-actions">
<button type="button" class="btn btn-primary" id="btn-send">Отправить</button>
</div>
<div class="rag-status" id="rag-status">
<div class="rag-status-main">
<span class="rag-dot" id="rag-dot"></span>
<div class="rag-text">
<div class="rag-label" id="rag-label">RAG не готов</div>
<div class="rag-detail" id="rag-detail">Ожидается индексация проекта.</div>
</div>
</div>
<div class="rag-metrics" id="rag-metrics"></div>
</div>
</div>
</div>
</div>
<script src="{{JS_URI}}"></script>
</body>
</html>
+238
View File
@@ -0,0 +1,238 @@
(function () {
const vscode = acquireVsCodeApi();
const feedScrollEl = document.getElementById("feed-scroll");
const headerRagSessionEl = document.getElementById("header-rag-session");
const headerRagSessionValueEl = document.getElementById("header-rag-session-value");
const processVersionEl = document.getElementById("process-version");
const statusBlocksEl = document.getElementById("status-blocks");
const messagesEl = document.getElementById("messages");
const inputEl = document.getElementById("input");
const btnSend = document.getElementById("btn-send");
const btnClear = document.getElementById("btn-clear");
const btnMenu = document.getElementById("btn-menu");
const menuDropdown = document.getElementById("menu-dropdown");
const taskStatusEl = document.getElementById("task-status");
const taskStatusLabelEl = document.getElementById("task-status-label");
const taskStatusDetailEl = document.getElementById("task-status-detail");
const taskProgressEl = document.getElementById("task-progress");
const taskProgressBarEl = document.getElementById("task-progress-bar");
const ragDotEl = document.getElementById("rag-dot");
const ragLabelEl = document.getElementById("rag-label");
const ragDetailEl = document.getElementById("rag-detail");
const ragMetricsEl = document.getElementById("rag-metrics");
let currentRagSessionId = "";
function renderStatusBlocks(items) {
statusBlocksEl.innerHTML = "";
(items || []).forEach(function (block) {
const wrap = document.createElement("div");
wrap.className = "status-block";
const title = document.createElement("div");
title.className = "status-block-title";
title.textContent = block.title || block.id || "Status";
wrap.appendChild(title);
const body = document.createElement("div");
body.className = "status-block-body";
(block.lines || []).forEach(function (line) {
const item = document.createElement("div");
item.className = "status-block-line";
item.textContent = line || "";
body.appendChild(item);
});
wrap.appendChild(body);
statusBlocksEl.appendChild(wrap);
});
}
function renderMessages(items) {
messagesEl.innerHTML = "";
(items || []).forEach(function (m) {
const div = document.createElement("div");
var roleClass = "msg-assistant";
if (m.role === "user") {
roleClass = "msg-user";
} else if (m.role === "status") {
roleClass = "msg-status";
} else if (m.role === "error") {
roleClass = "msg-error";
}
div.className = "msg " + roleClass;
const text = document.createElement("div");
text.className = m.kind === "markdown" ? "msg-markdown" : "msg-text";
text.textContent = m.text || "";
if (m.role !== "status") {
const role = document.createElement("div");
role.className = "msg-role";
role.textContent =
m.role === "user" ? "Вы" : m.role === "error" ? "Ошибка" : "Ответ";
div.appendChild(role);
}
div.appendChild(text);
messagesEl.appendChild(div);
});
}
function renderTaskStatus(status) {
var visible = Boolean(status && status.visible);
taskStatusEl.hidden = !visible;
if (!visible) {
taskProgressEl.hidden = true;
taskProgressBarEl.style.width = "0%";
return;
}
taskStatusLabelEl.textContent = status.label || "Статус";
taskStatusDetailEl.textContent = status.detail || "";
if (typeof status.progress === "number" && isFinite(status.progress)) {
taskProgressEl.hidden = false;
taskProgressBarEl.style.width = Math.max(0, Math.min(100, status.progress)) + "%";
} else {
taskProgressEl.hidden = true;
taskProgressBarEl.style.width = "0%";
}
}
function renderRagStatus(status) {
var state = (status && status.state) || "idle";
var sessionId = (status && status.ragSessionId) || "";
currentRagSessionId = sessionId;
ragDotEl.className = "rag-dot state-" + state;
ragLabelEl.textContent = (status && status.label) || "RAG не готов";
ragDetailEl.textContent =
(status && status.detail) || "Ожидается индексация проекта.";
if (headerRagSessionEl && headerRagSessionValueEl) {
headerRagSessionValueEl.textContent = sessionId || "—";
headerRagSessionEl.title = sessionId
? "Кликните, чтобы скопировать RAG session id"
: "RAG session is not created yet.";
}
var metrics = [];
if (status) {
metrics.push("indexed: " + (status.indexedFiles || 0));
metrics.push("failed: " + (status.failedFiles || 0));
metrics.push("cache hit: " + (status.cacheHitFiles || 0));
metrics.push("cache miss: " + (status.cacheMissFiles || 0));
}
ragMetricsEl.textContent = metrics.join(" | ");
}
function renderState(state) {
renderStatusBlocks(state.statusBlocks);
renderMessages(state.messages);
renderTaskStatus(state.taskStatus);
renderRagStatus(state.ragStatus);
if (processVersionEl && state && state.processVersion) {
processVersionEl.value = state.processVersion;
}
btnSend.disabled = false;
inputEl.disabled = false;
btnClear.disabled = Boolean(state.busy);
if (feedScrollEl) {
feedScrollEl.scrollTop = feedScrollEl.scrollHeight;
}
}
window.addEventListener("message", function (event) {
const msg = event.data;
if (msg && msg.type === "state") {
renderState(msg.payload || {});
}
});
function send() {
const text = (inputEl.value || "").trim();
if (!text) {
return;
}
vscode.postMessage({ type: "send", text: text });
inputEl.value = "";
}
btnSend.addEventListener("click", send);
inputEl.addEventListener("keydown", function (e) {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
send();
}
});
btnClear.addEventListener("click", function () {
vscode.postMessage({ type: "clear" });
});
processVersionEl.addEventListener("change", function () {
vscode.postMessage({
type: "set-process-version",
value: processVersionEl.value || "v2",
});
});
async function copyRagSessionId() {
if (!currentRagSessionId) {
return;
}
try {
if (navigator.clipboard && typeof navigator.clipboard.writeText === "function") {
await navigator.clipboard.writeText(currentRagSessionId);
} else {
const helper = document.createElement("textarea");
helper.value = currentRagSessionId;
helper.style.position = "fixed";
helper.style.opacity = "0";
document.body.appendChild(helper);
helper.focus();
helper.select();
document.execCommand("copy");
document.body.removeChild(helper);
}
if (headerRagSessionEl) {
headerRagSessionEl.classList.add("copied");
window.setTimeout(function () {
headerRagSessionEl.classList.remove("copied");
}, 1200);
}
} catch (error) {
console.error("Failed to copy RAG session id", error);
}
}
headerRagSessionEl.addEventListener("click", function () {
void copyRagSessionId();
});
headerRagSessionEl.addEventListener("keydown", function (event) {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
void copyRagSessionId();
}
});
btnMenu.addEventListener("click", function (e) {
e.stopPropagation();
const open = menuDropdown.hidden;
menuDropdown.hidden = !open;
btnMenu.setAttribute("aria-expanded", open ? "true" : "false");
});
document.addEventListener("click", function () {
if (!menuDropdown.hidden) {
menuDropdown.hidden = true;
btnMenu.setAttribute("aria-expanded", "false");
}
});
menuDropdown.addEventListener("click", function (e) {
e.stopPropagation();
const t = e.target;
if (t && t.classList && t.classList.contains("menu-item")) {
const action = t.getAttribute("data-action") || "";
vscode.postMessage({ type: "menu", action: action });
menuDropdown.hidden = true;
btnMenu.setAttribute("aria-expanded", "false");
}
});
vscode.postMessage({ type: "ready" });
})();
+58
View File
@@ -0,0 +1,58 @@
{
"name": "chat-area",
"version": "0.0.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "chat-area",
"version": "0.0.1",
"devDependencies": {
"@types/node": "^20.10.0",
"@types/vscode": "^1.85.0",
"typescript": "^5.3.0"
},
"engines": {
"vscode": "^1.85.0"
}
},
"node_modules/@types/node": {
"version": "20.19.37",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.37.tgz",
"integrity": "sha512-8kzdPJ3FsNsVIurqBs7oodNnCEVbni9yUEkaHbgptDACOPW04jimGagZ51E6+lXUwJjgnBw+hyko/lkFWCldqw==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
}
},
"node_modules/@types/vscode": {
"version": "1.110.0",
"resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.110.0.tgz",
"integrity": "sha512-AGuxUEpU4F4mfuQjxPPaQVyuOMhs+VT/xRok1jiHVBubHK7lBRvCuOMZG0LKUwxncrPorJ5qq/uil3IdZBd5lA==",
"dev": true,
"license": "MIT"
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT"
}
}
}
+64
View File
@@ -0,0 +1,64 @@
{
"name": "chat-area",
"displayName": "Chat Area",
"description": "Боковая панель чата с заглушкой бэкенда",
"version": "0.0.1",
"engines": {
"vscode": "^1.85.0"
},
"categories": ["Other"],
"activationEvents": ["onView:chat-area.chatView"],
"main": "./out/extension.js",
"contributes": {
"configuration": {
"title": "Chat Area",
"properties": {
"chatArea.agentBaseUrl": {
"type": "string",
"default": "http://127.0.0.1:15000",
"description": "Base URL of the backend agent API."
},
"chatArea.maxIndexedFileSizeKb": {
"type": "number",
"default": 256,
"minimum": 1,
"description": "Maximum file size in KB to include in snapshot indexing."
},
"chatArea.excludeGlob": {
"type": "string",
"default": "**/{node_modules,.git,out,dist,build,.next,.turbo,.idea,.vscode}/**",
"description": "Glob pattern excluded from snapshot indexing."
}
}
},
"viewsContainers": {
"activitybar": [
{
"id": "chat-area-sidebar",
"title": "Chat",
"icon": "media/chat-icon.svg"
}
]
},
"views": {
"chat-area-sidebar": [
{
"type": "webview",
"id": "chat-area.chatView",
"name": "Чат"
}
]
}
},
"scripts": {
"vscode:prepublish": "npm run compile",
"compile": "tsc -p ./",
"watch": "tsc -watch -p ./",
"package": "npx --yes @vscode/vsce package"
},
"devDependencies": {
"@types/node": "^20.10.0",
"@types/vscode": "^1.85.0",
"typescript": "^5.3.0"
}
}
+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;
}
}
}
+12
View File
@@ -0,0 +1,12 @@
{
"compilerOptions": {
"module": "commonjs",
"target": "ES2020",
"outDir": "out",
"lib": ["ES2020"],
"sourceMap": true,
"rootDir": "src",
"strict": true
},
"exclude": ["node_modules", ".vscode-test"]
}