import * as vscode from 'vscode'; import * as path from 'path'; import * as fs from 'fs'; import type { ITelegramClient } from '../../integrations/telegram/telegramClient'; import type { TelegramBot } from '../../integrations/telegram/telegramBot'; import { logError, logInfo } from '../../utils'; import { discoverModels } from '../../lib/discoverModels'; import { pickConfigTarget } from '../../lib/paths'; /** * Astra Settings webview. * * Replaces the old `'openSettings'` shortcut (which dumped the user into the * raw VS Code settings UI for `g1nation`) with a feature-aware panel. Phase * 5-A only ships the Telegram section — other sections are stubs that link * back to VS Code Settings until 5-B fills them in. * * Why a webview instead of contributing more to VS Code Settings: * - Token input must be password-masked AND end up in SecretStorage, which * VS Code Settings cannot do. * - Chat ID auto-detection needs an interactive flow with feedback ("polling * for next message…") that VS Code Settings cannot host. * - We want one place that knows "is the bot connected right now?" — VS * Code Settings cannot show live status. * * State flow (uni-directional, like a tiny redux): * * TelegramBot / SecretStorage / WorkspaceConfig ──┐ * ├──► provider.refreshState() ──► postMessage({type:'state', ...}) ──► webview rerenders * webview button click ──postMessage(action)──────┘ * * The webview is rendered every time we `refreshState()` so we don't need * incremental DOM diffing — just a small string template. */ const TELEGRAM_TOKEN_SECRET_KEY = 'g1nation.telegram.botToken'; export interface SettingsPanelDeps { extensionUri: vscode.Uri; /** Used by features that store data in workspaceState / globalState (Google OAuth tokens etc). */ context: vscode.ExtensionContext; secrets: vscode.SecretStorage; /** Returns the live Telegram client so we can call getMe for "test connection". */ telegramClient: ITelegramClient; /** Returns the live bot instance for enrollNextChat. */ telegramBot: TelegramBot; } interface SettingsState { telegram: { hasToken: boolean; enabled: boolean; connected: boolean; botName?: string; allowedChatIds: number[]; enrolling: boolean; lastError?: string; lastSuccess?: string; }; connection: { ollamaUrl: string; defaultModel: string; requestTimeout: number; availableModels: string[]; modelsLoading: boolean; }; memory: { memoryEnabled: boolean; memoryShortTermMessages: number; memoryMediumTermSessions: number; memoryLongTermFiles: number; }; brain: { activeBrainId: string; activeBrainName: string; activeBrainPath: string; profileCount: number; autoPushBrain: boolean; }; advanced: { dryRun: boolean; multiAgentEnabled: boolean; maxAutoSteps: number; maxContextSize: number; chatTemperature: number; chunkedSwitchTokens: number; chunkedMaxSections: number; polishPersonaOverride: string; }; datacollect: { /** 'local' | 'nas' — 어느 Bridge 인스턴스를 호출할지. */ bridgeTarget: string; bridgeUrl: string; /** NAS 경량 Bridge URL (nas 타깃일 때). */ bridgeNasUrl: string; /** NAS Bridge 의 x-bridge-token (nas 타깃일 때 헤더로 전송). */ bridgeNasToken: string; /** Empty → results saved to the Bridge's WIKI_RAW_PATH default. */ savePath: string; crawlDepth: number; maxPages: number; synthesisTemperature: number; }; google: { clientId: string; /** secret 자체는 client 에 echo 안 함 — *설정 여부* 만. true 면 input placeholder 가 "저장됨" 으로 바뀜. */ hasClientSecret: boolean; calendarId: string; defaultEventDurationMinutes: number; /** iCal URL 도 capability 토큰성이라 *설정 여부* 만 전송. 사용자가 새 값 입력 시 덮어씀. */ hasIcalUrl: boolean; icalDaysAhead: number; /** OAuth 연결 상태 — globalState 의 refresh token 존재 여부 + 누가 연결됐는지. */ connected: boolean; connectedAs?: string; connectedAt?: string; lastIcalFetchAt?: string; }; /** * Cloud LLM providers (OpenRouter / Anthropic / Gemini). API key 자체는 echo 안 함 — * hasApiKey boolean 만 전송. enabled 와 defaultModel 은 settings 에서 직접 읽음. */ providers: { openrouter: { enabled: boolean; hasApiKey: boolean; defaultModel: string }; anthropic: { enabled: boolean; hasApiKey: boolean; defaultModel: string }; gemini: { enabled: boolean; hasApiKey: boolean; defaultModel: string }; }; devilAgent: { enabled: boolean; }; /** Sectional banner shown when config.update fails (e.g. reload required). */ bannerError?: string; } export class SettingsPanelProvider implements vscode.WebviewViewProvider { public static readonly viewType = 'g1nation-settings-panel'; private _view?: vscode.WebviewView; private _panel?: vscode.WebviewPanel; private _enrolling = false; private _lastError?: string; private _lastSuccess?: string; private _botName?: string; private _bannerError?: string; private _modelsCache: { url: string; models: string[]; expiresAt: number } | undefined; private _modelsLoading = false; private static readonly MODELS_CACHE_TTL_MS = 30000; constructor(private readonly _deps: SettingsPanelDeps) {} public resolveWebviewView(view: vscode.WebviewView): void { this._view = view; this._setupWebview(view.webview); view.onDidDispose(() => { this._view = undefined; }); void this._refreshState(); void this._fetchModelsAndRefresh(); } /** * Common webview wiring shared between sidebar `WebviewView` and floating * `WebviewPanel` paths. Sets options, message handler, and initial HTML. */ private _setupWebview(webview: vscode.Webview): void { webview.options = { enableScripts: true, localResourceRoots: [this._deps.extensionUri], }; webview.onDidReceiveMessage((msg) => { void this._handleMessage(msg); }); webview.html = this._renderShell(webview); } public async focus(): Promise { await this.openAsPanel(); } /** * Open the settings UI as a stand-alone editor panel (Column 3 by default). * Astra's sidebar view container was removed in 2.81 — all three webviews * (Chat, Approvals, Settings) now live in the editor area. */ public async openAsPanel(column: vscode.ViewColumn = vscode.ViewColumn.Three): Promise { if (this._panel) { this._panel.reveal(column); return this._panel; } const panel = vscode.window.createWebviewPanel( SettingsPanelProvider.viewType, 'Astra Settings', column, { enableScripts: true, localResourceRoots: [this._deps.extensionUri], retainContextWhenHidden: true } ); this._panel = panel; this._setupWebview(panel.webview); panel.onDidDispose(() => { this._panel = undefined; }); await this._refreshState(); void this._fetchModelsAndRefresh(); return panel; } /** Re-pull state from sources of truth and broadcast to the webview. */ public async refresh(): Promise { await this._refreshState(); // If the cached URL drifted from the live config, refetch models so the // dropdown stays in sync with the sidebar (which may have triggered an // engine switch). const liveUrl = (vscode.workspace.getConfiguration('g1nation').get('ollamaUrl', '') || '').trim(); if (this._modelsCache && this._modelsCache.url !== liveUrl) { this._modelsCache = undefined; void this._fetchModelsAndRefresh(); } } private async _handleMessage(msg: any): Promise { if (!msg || typeof msg.type !== 'string') return; try { switch (msg.type) { case 'ready': await this._refreshState(); return; case 'telegram.saveToken': await this._handleSaveToken(String(msg.token ?? '')); return; case 'telegram.clearToken': await this._deps.secrets.delete(TELEGRAM_TOKEN_SECRET_KEY); this._botName = undefined; this._lastError = undefined; await this._refreshState(); return; case 'telegram.toggleEnabled': await this._safeConfigUpdate('telegram.enabled', !!msg.enabled); return; // onDidChangeConfiguration listener triggers a refresh case 'telegram.testConnection': await this._handleTestConnection(); return; case 'telegram.enroll': await this._handleEnroll(); return; case 'telegram.cancelEnroll': this._deps.telegramBot.cancelEnrollment(); this._enrolling = false; await this._refreshState(); return; case 'telegram.removeChatId': await this._handleRemoveChatId(Number(msg.chatId)); return; case 'connection.update': await this._handleConnectionUpdate(msg); return; case 'memory.update': await this._handleMemoryUpdate(msg); return; case 'brain.update': await this._handleBrainUpdate(msg); return; case 'advanced.update': await this._handleAdvancedUpdate(msg); return; case 'datacollect.update': await this._handleDatacollectUpdate(msg); return; case 'google.update': await this._handleGoogleUpdate(msg); return; case 'google.connect': await vscode.commands.executeCommand('g1nation.calendar.connectOAuth'); await this._refreshState(); return; case 'google.disconnect': await this._handleGoogleDisconnect(); return; case 'google.icalRefresh': await this._handleGoogleIcalRefresh(); return; case 'providers.update': await this._handleProvidersUpdate(msg); return; case 'devilAgent.toggle': await this._safeConfigUpdate('devilAgent.enabled', !!msg.enabled); return; case 'openVscodeSettings': await vscode.commands.executeCommand('workbench.action.openSettings', 'g1nation'); return; default: logInfo('SettingsPanel: unknown message', { type: msg.type }); } } catch (e: any) { this._lastError = e?.message ?? String(e); logError('SettingsPanel message failed.', { type: msg?.type, error: this._lastError }); await this._refreshState(); } } private async _handleSaveToken(token: string): Promise { const trimmed = token.trim(); if (!/^\d+:[A-Za-z0-9_-]{20,}$/.test(trimmed)) { this._lastError = 'Token 형식이 올바르지 않습니다 (예: 123456789:AAH...).'; this._lastSuccess = undefined; await this._refreshState(); return; } await this._deps.secrets.store(TELEGRAM_TOKEN_SECRET_KEY, trimmed); this._lastError = undefined; this._lastSuccess = '토큰이 저장되었습니다. 자동 연결 테스트 중…'; await this._refreshState(); // Auto-test so the user gets immediate feedback. await this._handleTestConnection(); } private async _handleTestConnection(): Promise { this._lastError = undefined; try { const me = await this._deps.telegramClient.getMe(); this._botName = me.username ? `@${me.username}` : me.first_name || `id ${me.id}`; this._lastSuccess = `연결 성공: ${this._botName}`; vscode.window.setStatusBarMessage(`Telegram 연결 성공: ${this._botName}`, 3000); } catch (e: any) { this._botName = undefined; this._lastError = e?.message ?? String(e); this._lastSuccess = undefined; } await this._refreshState(); } /** * Update a g1nation config value with a friendly error path. VS Code throws * `Unable to write to User Settings because is not a registered * configuration` when a stale extension build is loaded — we translate * that into a panel banner suggesting the reload. */ private async _safeConfigUpdate(key: string, value: unknown): Promise { try { const { target } = pickConfigTarget('g1nation', key); await vscode.workspace .getConfiguration('g1nation') .update(key, value, target); this._bannerError = undefined; return true; } catch (e: any) { const msg = e?.message ?? String(e); if (/not a registered configuration/i.test(msg)) { this._bannerError = '설정 저장 실패: 확장이 새 설정 정의를 아직 못 읽었습니다. ' + '"Developer: Reload Window" 를 실행한 뒤 다시 시도하세요. ' + '(또는 .vsix 재설치)'; } else { this._bannerError = `설정 저장 실패: ${msg}`; } logError('SettingsPanel config.update failed.', { key, error: msg }); await this._refreshState(); return false; } } private async _handleEnroll(): Promise { const cfg = vscode.workspace.getConfiguration('g1nation'); const token = (await this._deps.secrets.get(TELEGRAM_TOKEN_SECRET_KEY)) || ''; if (!token.trim()) { this._lastError = '먼저 Bot Token을 저장하세요.'; await this._refreshState(); return; } // The bot must be running to receive the next message; auto-enable if needed. if (!cfg.get('telegram.enabled', false)) { const ok = await this._safeConfigUpdate('telegram.enabled', true); if (!ok) return; // banner already shown } // The settings change above triggers refreshTelegramBot via // onDidChangeConfiguration, but that happens on a later microtask. // Start the bot now so enrollNextChat actually has a polling loop // that can intercept the next inbound update — otherwise the user // sees "no response" until the listener catches up. if (!this._deps.telegramBot.isRunning()) { this._deps.telegramBot.start(); } this._enrolling = true; this._lastError = undefined; this._lastSuccess = '봇 폴링 시작됨. 텔레그램에서 봇에게 아무 메시지나 한 번 보내주세요.'; await this._refreshState(); try { const enrolled = await this._deps.telegramBot.enrollNextChat(); const existing = cfg.get('telegram.allowedChatIds', []) || []; const merged = existing.includes(enrolled.chatId) ? existing : [...existing, enrolled.chatId]; await this._safeConfigUpdate('telegram.allowedChatIds', merged); const label = enrolled.username ? `@${enrolled.username}` : (enrolled.firstName || `id ${enrolled.chatId}`); this._lastSuccess = `채널 등록 완료: ${label} (id ${enrolled.chatId})`; vscode.window.showInformationMessage(`Telegram 채널이 등록되었습니다: ${label} (id ${enrolled.chatId}).`); } catch (e: any) { this._lastError = e?.message ?? String(e); } finally { this._enrolling = false; await this._refreshState(); } } private async _handleRemoveChatId(chatId: number): Promise { if (!Number.isFinite(chatId)) return; const cfg = vscode.workspace.getConfiguration('g1nation'); const existing = cfg.get('telegram.allowedChatIds', []) || []; const next = existing.filter((id) => id !== chatId); await this._safeConfigUpdate('telegram.allowedChatIds', next); } private async _handleConnectionUpdate(msg: any): Promise { if (typeof msg.ollamaUrl === 'string') { const ok = await this._safeConfigUpdate('ollamaUrl', msg.ollamaUrl.trim()); if (ok) this._modelsCache = undefined; // URL changed → invalidate model list } if (typeof msg.defaultModel === 'string') { await this._safeConfigUpdate('defaultModel', msg.defaultModel.trim()); } if (typeof msg.requestTimeout === 'number' && Number.isFinite(msg.requestTimeout)) { await this._safeConfigUpdate('requestTimeout', Math.max(1, Math.floor(msg.requestTimeout))); } if (msg.refreshModels) { this._modelsCache = undefined; await this._fetchModelsAndRefresh(); } } /** * Fetch the model list and broadcast (with a `loading` flag while in flight) * so the settings panel's dropdown stays in sync with the sidebar's. * Cached for `MODELS_CACHE_TTL_MS` per `ollamaUrl` to avoid hammering the * engine while the panel is open. */ private async _fetchModelsAndRefresh(): Promise { const cfg = vscode.workspace.getConfiguration('g1nation'); const url = (cfg.get('ollamaUrl', '') || '').trim(); const now = Date.now(); const cached = this._modelsCache; if (cached && cached.url === url && cached.expiresAt > now) return; this._modelsLoading = true; await this._refreshState(); try { const models = await discoverModels(url); this._modelsCache = { url, models, expiresAt: Date.now() + SettingsPanelProvider.MODELS_CACHE_TTL_MS, }; } finally { this._modelsLoading = false; await this._refreshState(); } } private async _handleMemoryUpdate(msg: any): Promise { if (typeof msg.memoryEnabled === 'boolean') { await this._safeConfigUpdate('memoryEnabled', msg.memoryEnabled); } const numericFields: Array = [ 'memoryShortTermMessages', 'memoryMediumTermSessions', 'memoryLongTermFiles', ]; for (const f of numericFields) { const v = (msg as any)[f]; if (typeof v === 'number' && Number.isFinite(v)) { await this._safeConfigUpdate(f, Math.max(0, Math.floor(v))); } } } private async _handleBrainUpdate(msg: any): Promise { if (typeof msg.activeBrainId === 'string') { await this._safeConfigUpdate('activeBrainId', msg.activeBrainId); } if (typeof msg.autoPushBrain === 'boolean') { await this._safeConfigUpdate('autoPushBrain', msg.autoPushBrain); } } // ────────────── Google (Calendar + Sheets) ────────────── // Settings 패널이 보여주는 모든 Google 필드는 `g1nation.google.*` configuration 으로 // 저장. secret(Client Secret / iCal URL / refresh token)은 *값 자체를 client 에 echo // 안 함* — 설정 여부만 true/false 로. 사용자가 새 값을 입력 시 덮어쓰는 단방향. private _buildGoogleState(): SettingsState['google'] { const ctx = this._deps.context; const { readCalendarConfig } = require('../calendar') as typeof import('../calendar'); const cur = readCalendarConfig(ctx); return { clientId: cur.clientId ?? '', hasClientSecret: !!cur.clientSecret, calendarId: cur.calendarId ?? 'primary', defaultEventDurationMinutes: cur.defaultDurationMinutes ?? 60, hasIcalUrl: !!cur.icalUrl, icalDaysAhead: cur.daysAhead ?? 14, connected: !!cur.refreshToken, connectedAs: cur.connectedAs, connectedAt: cur.connectedAt, lastIcalFetchAt: cur.lastFetchAt, }; } private async _handleGoogleUpdate(msg: any): Promise { const { writeCalendarConfig } = require('../calendar') as typeof import('../calendar'); // 필드별로 *명시적으로 전달된 것만* 패치. undefined / 누락은 무시. const patch: any = {}; if (typeof msg.clientId === 'string') patch.clientId = msg.clientId.trim() || undefined; if (typeof msg.clientSecret === 'string') patch.clientSecret = msg.clientSecret.trim() || undefined; if (typeof msg.calendarId === 'string') patch.calendarId = msg.calendarId.trim() || 'primary'; if (typeof msg.defaultEventDurationMinutes === 'number' && Number.isFinite(msg.defaultEventDurationMinutes)) { patch.defaultDurationMinutes = Math.max(5, Math.min(720, Math.floor(msg.defaultEventDurationMinutes))); } if (typeof msg.icalUrl === 'string') patch.icalUrl = msg.icalUrl.trim(); if (typeof msg.icalDaysAhead === 'number' && Number.isFinite(msg.icalDaysAhead)) { patch.daysAhead = Math.max(1, Math.min(90, Math.floor(msg.icalDaysAhead))); } if (Object.keys(patch).length === 0) return; await writeCalendarConfig(this._deps.context, patch); this._lastSuccess = '저장되었습니다.'; this._lastError = undefined; await this._refreshState(); } private async _handleGoogleDisconnect(): Promise { const { writeCalendarConfig } = require('../calendar') as typeof import('../calendar'); await writeCalendarConfig(this._deps.context, { refreshToken: undefined, accessToken: undefined, accessTokenExpiresAt: undefined, connectedAs: undefined, connectedAt: undefined, }); this._lastSuccess = 'OAuth 연결을 해제했습니다. https://myaccount.google.com/permissions 에서 권한 회수 권장.'; this._lastError = undefined; await this._refreshState(); } private async _handleGoogleIcalRefresh(): Promise { try { const { refreshCalendarCache } = require('../calendar') as typeof import('../calendar'); const r = await refreshCalendarCache(this._deps.context); if (r.ok) { this._lastSuccess = `iCal 새로고침 완료 — ${r.count}개 일정 동기화`; this._lastError = undefined; } else { this._lastError = r.error || 'iCal 새로고침 실패'; this._lastSuccess = undefined; } } catch (e: any) { this._lastError = e?.message ?? String(e); this._lastSuccess = undefined; } await this._refreshState(); } // ────────────── Cloud LLM Providers ────────────── // OpenRouter / Anthropic / Gemini API key + enable 토글. API key 는 Secret Storage 만. // settings 패널은 *값 자체는 안 보여줌* (hasApiKey boolean 만). 사용자가 새로 입력 시 덮어씀. private async _buildProvidersState(): Promise { const { readProviderStatus } = require('../providers') as typeof import('../providers'); const ctx = this._deps.context; const [or, an, ge] = await Promise.all([ readProviderStatus(ctx, 'openrouter'), readProviderStatus(ctx, 'anthropic'), readProviderStatus(ctx, 'gemini'), ]); return { openrouter: or, anthropic: an, gemini: ge }; } private async _handleProvidersUpdate(msg: any): Promise { const { writeProviderConfig } = require('../providers') as typeof import('../providers'); const id = msg.providerId; if (id !== 'openrouter' && id !== 'anthropic' && id !== 'gemini') return; const patch: any = {}; if (typeof msg.enabled === 'boolean') patch.enabled = msg.enabled; if (typeof msg.apiKey === 'string') patch.apiKey = msg.apiKey; if (typeof msg.defaultModel === 'string') patch.defaultModel = msg.defaultModel; if (Object.keys(patch).length === 0) return; await writeProviderConfig(this._deps.context, id, patch); this._lastSuccess = `${id} 저장 완료`; this._lastError = undefined; await this._refreshState(); } private async _handleAdvancedUpdate(msg: any): Promise { if (typeof msg.dryRun === 'boolean') { await this._safeConfigUpdate('dryRun', msg.dryRun); } if (typeof msg.multiAgentEnabled === 'boolean') { await this._safeConfigUpdate('multiAgentEnabled', msg.multiAgentEnabled); } if (typeof msg.maxAutoSteps === 'number' && Number.isFinite(msg.maxAutoSteps)) { await this._safeConfigUpdate('maxAutoSteps', Math.max(1, Math.floor(msg.maxAutoSteps))); } if (typeof msg.maxContextSize === 'number' && Number.isFinite(msg.maxContextSize)) { await this._safeConfigUpdate('maxContextSize', Math.max(1000, Math.floor(msg.maxContextSize))); } if (typeof msg.chatTemperature === 'number' && Number.isFinite(msg.chatTemperature)) { await this._safeConfigUpdate('chatTemperature', Math.max(0, Math.min(2, msg.chatTemperature))); } if (typeof msg.chunkedSwitchTokens === 'number' && Number.isFinite(msg.chunkedSwitchTokens)) { await this._safeConfigUpdate('chunkedSwitchTokens', Math.max(1000, Math.floor(msg.chunkedSwitchTokens))); } if (typeof msg.chunkedMaxSections === 'number' && Number.isFinite(msg.chunkedMaxSections)) { await this._safeConfigUpdate('chunkedMaxSections', Math.max(1, Math.min(10, Math.floor(msg.chunkedMaxSections)))); } if (typeof msg.polishPersonaOverride === 'string') { // 빈 문자열도 유효한 값 (default persona 로 되돌리기). trim 으로 공백만 입력 무력화. await this._safeConfigUpdate('polishPersonaOverride', msg.polishPersonaOverride.trim()); } } // ────────────── Datacollect (slash 명령) ────────────── // /research·/benchmark·/youtube 가 호출하는 Bridge URL 과, 결과물 저장 위치. // savePath 가 비어 있으면 Bridge 의 WIKI_RAW_PATH 환경변수가 저장 위치를 결정한다. private async _handleDatacollectUpdate(msg: any): Promise { if (typeof msg.bridgeTarget === 'string') { const t = msg.bridgeTarget.trim() === 'nas' ? 'nas' : 'local'; await this._safeConfigUpdate('datacollectBridgeTarget', t); } if (typeof msg.bridgeUrl === 'string') { await this._safeConfigUpdate('datacollectBridgeUrl', msg.bridgeUrl.trim()); } if (typeof msg.bridgeNasUrl === 'string') { await this._safeConfigUpdate('datacollectBridgeNasUrl', msg.bridgeNasUrl.trim()); } if (typeof msg.bridgeNasToken === 'string') { await this._safeConfigUpdate('datacollectBridgeNasToken', msg.bridgeNasToken.trim()); } if (typeof msg.savePath === 'string') { await this._safeConfigUpdate('datacollectSavePath', msg.savePath.trim()); } if (typeof msg.crawlDepth === 'number' && Number.isFinite(msg.crawlDepth)) { await this._safeConfigUpdate('datacollectCrawlDepth', Math.max(0, Math.min(3, Math.floor(msg.crawlDepth)))); } if (typeof msg.maxPages === 'number' && Number.isFinite(msg.maxPages)) { await this._safeConfigUpdate('datacollectMaxPages', Math.max(1, Math.min(20, Math.floor(msg.maxPages)))); } if (typeof msg.synthesisTemperature === 'number' && Number.isFinite(msg.synthesisTemperature)) { await this._safeConfigUpdate('datacollectSynthesisTemperature', Math.max(0, Math.min(2, msg.synthesisTemperature))); } } private async _refreshState(): Promise { if (!this._view && !this._panel) return; const cfg = vscode.workspace.getConfiguration('g1nation'); const token = (await this._deps.secrets.get(TELEGRAM_TOKEN_SECRET_KEY)) || ''; const profiles = (cfg.get('brainProfiles', []) || []) as Array<{ id?: string; name?: string; localBrainPath?: string; }>; const activeBrainId = cfg.get('activeBrainId', '') || ''; const activeProfile = profiles.find((p) => p.id === activeBrainId) || profiles[0]; const state: SettingsState = { telegram: { hasToken: !!token.trim(), enabled: cfg.get('telegram.enabled', false), connected: !!this._botName && this._deps.telegramBot.isRunning(), botName: this._botName, allowedChatIds: cfg.get('telegram.allowedChatIds', []) || [], enrolling: this._enrolling, lastError: this._lastError, lastSuccess: this._lastSuccess, }, connection: { ollamaUrl: cfg.get('ollamaUrl', '') || '', defaultModel: cfg.get('defaultModel', '') || '', requestTimeout: cfg.get('requestTimeout', 300) ?? 300, availableModels: this._modelsCache?.models ?? [], modelsLoading: this._modelsLoading, }, memory: { memoryEnabled: cfg.get('memoryEnabled', true), memoryShortTermMessages: cfg.get('memoryShortTermMessages', 8) ?? 8, memoryMediumTermSessions: cfg.get('memoryMediumTermSessions', 5) ?? 5, memoryLongTermFiles: cfg.get('memoryLongTermFiles', 6) ?? 6, }, brain: { activeBrainId, activeBrainName: activeProfile?.name || '(없음)', activeBrainPath: activeProfile?.localBrainPath || '', profileCount: profiles.length, autoPushBrain: cfg.get('autoPushBrain', false), }, advanced: { dryRun: cfg.get('dryRun', false), multiAgentEnabled: cfg.get('multiAgentEnabled', false), maxAutoSteps: cfg.get('maxAutoSteps', 50) ?? 50, maxContextSize: cfg.get('maxContextSize', 32000) ?? 32000, chatTemperature: cfg.get('chatTemperature', 0.3) ?? 0.3, chunkedSwitchTokens: cfg.get('chunkedSwitchTokens', 50000) ?? 50000, chunkedMaxSections: cfg.get('chunkedMaxSections', 3) ?? 3, polishPersonaOverride: cfg.get('polishPersonaOverride', '') ?? '', }, datacollect: { bridgeTarget: cfg.get('datacollectBridgeTarget', 'local') || 'local', bridgeUrl: cfg.get('datacollectBridgeUrl', '') || '', bridgeNasUrl: cfg.get('datacollectBridgeNasUrl', '') || '', bridgeNasToken: cfg.get('datacollectBridgeNasToken', '') || '', savePath: cfg.get('datacollectSavePath', '') || '', crawlDepth: cfg.get('datacollectCrawlDepth', 1) ?? 1, maxPages: cfg.get('datacollectMaxPages', 8) ?? 8, synthesisTemperature: cfg.get('datacollectSynthesisTemperature', 0.1) ?? 0.1, }, google: this._buildGoogleState(), providers: await this._buildProvidersState(), devilAgent: { enabled: cfg.get('devilAgent.enabled', false) }, bannerError: this._bannerError, }; const payload = { type: 'state', value: state }; // Broadcast to whichever surface(s) are currently open. this._view?.webview.postMessage(payload); this._panel?.webview.postMessage(payload); } private _renderShell(webview: vscode.Webview): string { const mediaRoot = vscode.Uri.joinPath(this._deps.extensionUri, 'media'); const stylesUri = webview.asWebviewUri(vscode.Uri.joinPath(mediaRoot, 'settings-panel.css')).toString(); const scriptUri = webview.asWebviewUri(vscode.Uri.joinPath(mediaRoot, 'settings-panel.js')).toString(); const tplPath = path.join(this._deps.extensionUri.fsPath, 'media', 'settings-panel.html'); const tpl = fs.readFileSync(tplPath, 'utf8'); return tpl .replace('__STYLES_URI__', stylesUri) .replace('__SCRIPT_URI__', scriptUri); } } export const SETTINGS_TELEGRAM_TOKEN_SECRET_KEY = TELEGRAM_TOKEN_SECRET_KEY;