647 lines
29 KiB
TypeScript
647 lines
29 KiB
TypeScript
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;
|
|
};
|
|
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<void> {
|
|
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<vscode.WebviewPanel> {
|
|
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<void> {
|
|
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<string>('ollamaUrl', '') || '').trim();
|
|
if (this._modelsCache && this._modelsCache.url !== liveUrl) {
|
|
this._modelsCache = undefined;
|
|
void this._fetchModelsAndRefresh();
|
|
}
|
|
}
|
|
|
|
private async _handleMessage(msg: any): Promise<void> {
|
|
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 '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<void> {
|
|
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<void> {
|
|
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 <key> 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<boolean> {
|
|
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<void> {
|
|
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<boolean>('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<number[]>('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<void> {
|
|
if (!Number.isFinite(chatId)) return;
|
|
const cfg = vscode.workspace.getConfiguration('g1nation');
|
|
const existing = cfg.get<number[]>('telegram.allowedChatIds', []) || [];
|
|
const next = existing.filter((id) => id !== chatId);
|
|
await this._safeConfigUpdate('telegram.allowedChatIds', next);
|
|
}
|
|
|
|
private async _handleConnectionUpdate(msg: any): Promise<void> {
|
|
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<void> {
|
|
const cfg = vscode.workspace.getConfiguration('g1nation');
|
|
const url = (cfg.get<string>('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<void> {
|
|
if (typeof msg.memoryEnabled === 'boolean') {
|
|
await this._safeConfigUpdate('memoryEnabled', msg.memoryEnabled);
|
|
}
|
|
const numericFields: Array<keyof SettingsState['memory']> = [
|
|
'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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<SettingsState['providers']> {
|
|
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<void> {
|
|
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<void> {
|
|
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)));
|
|
}
|
|
}
|
|
|
|
private async _refreshState(): Promise<void> {
|
|
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<any[]>('brainProfiles', []) || []) as Array<{
|
|
id?: string; name?: string; localBrainPath?: string;
|
|
}>;
|
|
const activeBrainId = cfg.get<string>('activeBrainId', '') || '';
|
|
const activeProfile = profiles.find((p) => p.id === activeBrainId) || profiles[0];
|
|
|
|
const state: SettingsState = {
|
|
telegram: {
|
|
hasToken: !!token.trim(),
|
|
enabled: cfg.get<boolean>('telegram.enabled', false),
|
|
connected: !!this._botName && this._deps.telegramBot.isRunning(),
|
|
botName: this._botName,
|
|
allowedChatIds: cfg.get<number[]>('telegram.allowedChatIds', []) || [],
|
|
enrolling: this._enrolling,
|
|
lastError: this._lastError,
|
|
lastSuccess: this._lastSuccess,
|
|
},
|
|
connection: {
|
|
ollamaUrl: cfg.get<string>('ollamaUrl', '') || '',
|
|
defaultModel: cfg.get<string>('defaultModel', '') || '',
|
|
requestTimeout: cfg.get<number>('requestTimeout', 300) ?? 300,
|
|
availableModels: this._modelsCache?.models ?? [],
|
|
modelsLoading: this._modelsLoading,
|
|
},
|
|
memory: {
|
|
memoryEnabled: cfg.get<boolean>('memoryEnabled', true),
|
|
memoryShortTermMessages: cfg.get<number>('memoryShortTermMessages', 8) ?? 8,
|
|
memoryMediumTermSessions: cfg.get<number>('memoryMediumTermSessions', 5) ?? 5,
|
|
memoryLongTermFiles: cfg.get<number>('memoryLongTermFiles', 6) ?? 6,
|
|
},
|
|
brain: {
|
|
activeBrainId,
|
|
activeBrainName: activeProfile?.name || '(없음)',
|
|
activeBrainPath: activeProfile?.localBrainPath || '',
|
|
profileCount: profiles.length,
|
|
autoPushBrain: cfg.get<boolean>('autoPushBrain', false),
|
|
},
|
|
advanced: {
|
|
dryRun: cfg.get<boolean>('dryRun', false),
|
|
multiAgentEnabled: cfg.get<boolean>('multiAgentEnabled', false),
|
|
maxAutoSteps: cfg.get<number>('maxAutoSteps', 50) ?? 50,
|
|
maxContextSize: cfg.get<number>('maxContextSize', 32000) ?? 32000,
|
|
},
|
|
google: this._buildGoogleState(),
|
|
providers: await this._buildProvidersState(),
|
|
devilAgent: { enabled: cfg.get<boolean>('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;
|