commit c664209746010a3756f76168b1e823c9ad517854 Author: zosimovaa Date: Thu Apr 9 15:42:42 2026 +0300 Первый коммит diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..afe255f --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +node_modules +out +*.vsix diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..ba6ff76 --- /dev/null +++ b/.vscode/launch.json @@ -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}" + } + ] +} diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 0000000..1b588b1 --- /dev/null +++ b/.vscode/tasks.json @@ -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" + } + ] +} diff --git a/.vscodeignore b/.vscodeignore new file mode 100644 index 0000000..54fb624 --- /dev/null +++ b/.vscodeignore @@ -0,0 +1,8 @@ +.vscode/** +.vscode-test/** +src/** +.gitignore +**/tsconfig.json +**/*.map +node_modules/** +!node_modules/@types/** diff --git a/README.md b/README.md new file mode 100644 index 0000000..625b678 --- /dev/null +++ b/README.md @@ -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`. diff --git a/media/chat-icon.svg b/media/chat-icon.svg new file mode 100644 index 0000000..0e3b9db --- /dev/null +++ b/media/chat-icon.svg @@ -0,0 +1 @@ + diff --git a/media/chat.css b/media/chat.css new file mode 100644 index 0000000..8272a3a --- /dev/null +++ b/media/chat.css @@ -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)); +} diff --git a/media/chat.html b/media/chat.html new file mode 100644 index 0000000..24fe4db --- /dev/null +++ b/media/chat.html @@ -0,0 +1,76 @@ + + + + + + + + + +
+
+
+ + +
+
+ RAG: + +
+
+ +
+
+
+
+
+
+
+ +
+ + + diff --git a/media/chat.js b/media/chat.js new file mode 100644 index 0000000..40f572b --- /dev/null +++ b/media/chat.js @@ -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" }); +})(); diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..b064312 --- /dev/null +++ b/package-lock.json @@ -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" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..423d7f6 --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/src/agent/AgentConfig.ts b/src/agent/AgentConfig.ts new file mode 100644 index 0000000..3c06b9a --- /dev/null +++ b/src/agent/AgentConfig.ts @@ -0,0 +1,28 @@ +import * as vscode from "vscode"; + +const SECTION = "chatArea"; + +export class AgentConfig { + getBaseUrl(): string { + const value = vscode.workspace + .getConfiguration(SECTION) + .get("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("maxIndexedFileSizeKb", 256); + return Math.max(1, kb) * 1024; + } + + getExcludeGlob(): string { + return vscode.workspace + .getConfiguration(SECTION) + .get( + "excludeGlob", + "**/{node_modules,.git,out,dist,build,.next,.turbo,.idea,.vscode}/**" + ); + } +} diff --git a/src/agent/AgentHttpClient.ts b/src/agent/AgentHttpClient.ts new file mode 100644 index 0000000..6e0ee20 --- /dev/null +++ b/src/agent/AgentHttpClient.ts @@ -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(path: string, init?: RequestInit): Promise { + 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 { + 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}` : ""}`; + } +} diff --git a/src/agent/ChatApiClient.ts b/src/agent/ChatApiClient.ts new file mode 100644 index 0000000..ea27089 --- /dev/null +++ b/src/agent/ChatApiClient.ts @@ -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 { + const response = await this.http.requestJson( + "/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 { + const queued = await this.http.requestJson( + "/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 { + while (true) { + const result = await this.http.requestJson( + `/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 { + while (true) { + const status = await this.http.requestJson( + `/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 { + return new Promise((resolve) => setTimeout(resolve, ms)); + } +} diff --git a/src/agent/RagJobEventsStream.ts b/src/agent/RagJobEventsStream.ts new file mode 100644 index 0000000..7145b94 --- /dev/null +++ b/src/agent/RagJobEventsStream.ts @@ -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 { + 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; + 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; + } +} diff --git a/src/agent/TaskEventsStream.ts b/src/agent/TaskEventsStream.ts new file mode 100644 index 0000000..de7ea07 --- /dev/null +++ b/src/agent/TaskEventsStream.ts @@ -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 { + 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, + onEvent: (event: TaskEvent) => void + ): Promise { + 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).type || eventName || ""); + const resolvedTaskId = String((src as Record).request_id || taskId); + if (name === "status") { + const payloadMeta = ((src as Record).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).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; + return { + kind, + task_id: taskId, + status: String(data.status || ""), + stage: String(data.source || ""), + message: String(data.text || ""), + progress: (data.payload as Record | undefined)?.progress, + meta: (data.payload as Record) || {}, + }; + } + + private tryParse(value: string): unknown { + try { + return JSON.parse(value); + } catch { + return value; + } + } +} diff --git a/src/agent/contracts.ts b/src/agent/contracts.ts new file mode 100644 index 0000000..db44f2d --- /dev/null +++ b/src/agent/contracts.ts @@ -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; + 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; +} diff --git a/src/backendStub.ts b/src/backendStub.ts new file mode 100644 index 0000000..e96d7cc --- /dev/null +++ b/src/backendStub.ts @@ -0,0 +1,23 @@ +/** + * Заглушка бэкенда: имитирует задержку сети и возвращает эхо-ответ. + * Позже заменить на реальный HTTP-клиент. + */ + +function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +/** + * Отправляет текст на «бэкенд» и возвращает ответ (заглушка). + * + * @param text - сообщение пользователя + * @returns текст ответа ассистента + */ +export async function sendMessage(text: string): Promise { + const trimmed = text.trim(); + await delay(400); + if (!trimmed) { + return "[stub] Пустое сообщение."; + } + return `[stub] Echo: ${trimmed}`; +} diff --git a/src/chat/ChatController.ts b/src/chat/ChatController.ts new file mode 100644 index 0000000..61d5321 --- /dev/null +++ b/src/chat/ChatController.ts @@ -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 { + this.applyProcessMode(this.getProcessVersion()); + } + + async sendMessage(text: string): Promise { + 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 { + 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 { + 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; + 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 { + 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 { + 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 || "") + ": 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 { + const parent = vscode.Uri.file(path.dirname(target.fsPath)); + await vscode.workspace.fs.createDirectory(parent); + } + + private async verifyBaseHash(target: vscode.Uri, expected: unknown): Promise { + 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; + } + } +} diff --git a/src/chat/ChatSessionStore.ts b/src/chat/ChatSessionStore.ts new file mode 100644 index 0000000..582c962 --- /dev/null +++ b/src/chat/ChatSessionStore.ts @@ -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(); + 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): void { + this.update({ taskStatus: { ...this.state.taskStatus, ...status } }); + } + + clearTaskStatus(): void { + this.update({ + taskStatus: { visible: false, label: "", detail: "", progress: null }, + }); + } + + setRagStatus(status: Partial): void { + this.update({ ragStatus: { ...this.state.ragStatus, ...status } }); + } + + setProcessVersion(processVersion: string): void { + this.update({ processVersion }); + } + + private update(patch: Partial): void { + this.state = { ...this.state, ...patch }; + for (const listener of this.listeners) { + listener(this.state); + } + } +} diff --git a/src/chat/ChatViewProvider.ts b/src/chat/ChatViewProvider.ts new file mode 100644 index 0000000..8f7f215 --- /dev/null +++ b/src/chat/ChatViewProvider.ts @@ -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; + + constructor( + private readonly extensionUri: vscode.Uri, + private readonly store: ChatSessionStore + ) {} + + setMessageHandler(handler: (message: WebviewMessage) => Promise): 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)); + } +} diff --git a/src/chat/WebviewMessageRouter.ts b/src/chat/WebviewMessageRouter.ts new file mode 100644 index 0000000..69b4df8 --- /dev/null +++ b/src/chat/WebviewMessageRouter.ts @@ -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 { + 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; + } + } +} diff --git a/src/chat/models.ts b/src/chat/models.ts new file mode 100644 index 0000000..a8a91d3 --- /dev/null +++ b/src/chat/models.ts @@ -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; +} diff --git a/src/extension.ts b/src/extension.ts new file mode 100644 index 0000000..f254a01 --- /dev/null +++ b/src/extension.ts @@ -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 {} diff --git a/src/rag/ContentHashService.ts b/src/rag/ContentHashService.ts new file mode 100644 index 0000000..c50411d --- /dev/null +++ b/src/rag/ContentHashService.ts @@ -0,0 +1,7 @@ +import { createHash } from "crypto"; + +export class ContentHashService { + hash(content: string): string { + return createHash("sha256").update(content).digest("hex"); + } +} diff --git a/src/rag/RagStatusController.ts b/src/rag/RagStatusController.ts new file mode 100644 index 0000000..bdd8e25 --- /dev/null +++ b/src/rag/RagStatusController.ts @@ -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; + 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 { + if (this.sessionMode === "rag" && this.agentSessionId && this.ragSessionId) { + return { + agentSessionId: this.agentSessionId, + ragSessionId: this.ragSessionId, + }; + } + return this.createNewSession(); + } + + async ensureLightweightSessionReady(): Promise { + if (this.sessionMode === "lightweight" && this.agentSessionId) { + return { + agentSessionId: this.agentSessionId, + ragSessionId: "", + }; + } + return this.createLightweightSession(); + } + + async createNewSession(): Promise { + if (!this.inFlight) { + this.inFlight = this.bootstrapSession().finally(() => { + this.inFlight = undefined; + }); + } + return this.inFlight; + } + + async createLightweightSession(): Promise { + 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 { + 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 { + 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 = ""; + } +} diff --git a/src/rag/WorkspaceSnapshotService.ts b/src/rag/WorkspaceSnapshotService.ts new file mode 100644 index 0000000..66ac953 --- /dev/null +++ b/src/rag/WorkspaceSnapshotService.ts @@ -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 { + 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; + } + } +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..6c9a9b5 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "module": "commonjs", + "target": "ES2020", + "outDir": "out", + "lib": ["ES2020"], + "sourceMap": true, + "rootDir": "src", + "strict": true + }, + "exclude": ["node_modules", ".vscode-test"] +}