Первый коммит
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
node_modules
|
||||
out
|
||||
*.vsix
|
||||
Vendored
+13
@@ -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}"
|
||||
}
|
||||
]
|
||||
}
|
||||
Vendored
+22
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
.vscode/**
|
||||
.vscode-test/**
|
||||
src/**
|
||||
.gitignore
|
||||
**/tsconfig.json
|
||||
**/*.map
|
||||
node_modules/**
|
||||
!node_modules/@types/**
|
||||
@@ -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`.
|
||||
@@ -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
@@ -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));
|
||||
}
|
||||
@@ -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
@@ -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" });
|
||||
})();
|
||||
Generated
+58
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"target": "ES2020",
|
||||
"outDir": "out",
|
||||
"lib": ["ES2020"],
|
||||
"sourceMap": true,
|
||||
"rootDir": "src",
|
||||
"strict": true
|
||||
},
|
||||
"exclude": ["node_modules", ".vscode-test"]
|
||||
}
|
||||
Reference in New Issue
Block a user