chore: bump version to 2.80.27 and update core features

This commit is contained in:
g1nation
2026-05-09 01:16:12 +09:00
parent 5ffb472d22
commit 3220a126fd
41 changed files with 4457 additions and 72 deletions
@@ -0,0 +1,515 @@
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;
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;
};
/** 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> {
// Reveal the Astra activity-bar container so a focus() doesn't silently
// no-op against a collapsed sidebar.
try {
await vscode.commands.executeCommand('workbench.view.extension.g1nation-sidebar');
} catch {
// Older VS Code versions may not expose this command.
}
try {
await vscode.commands.executeCommand(`${SettingsPanelProvider.viewType}.focus`);
} catch (e: any) {
// The view-focus command is auto-generated only when VS Code parsed
// the package.json `views` entry. If a stale .vsix is installed
// (or the user hasn't reloaded after a fresh install) the command
// is missing and we hit `command not found`. Fall back to a
// floating panel so the user still gets the same UI.
if (this._isCommandNotFound(e)) {
logInfo('Settings view command missing — opening as floating panel.');
await this.openAsPanel();
return;
}
throw e;
}
}
/**
* Open the same settings UI as a stand-alone editor panel. Used when the
* sidebar `WebviewView` isn't registered yet (e.g. user installed a fresh
* .vsix without reloading) — keeps the feature reachable without forcing
* the user back through `vsce package` cycles.
*/
public async openAsPanel(): Promise<void> {
if (this._panel) {
this._panel.reveal(vscode.ViewColumn.Active);
return;
}
const panel = vscode.window.createWebviewPanel(
'g1nation-settings-panel-floating',
'Astra Settings',
vscode.ViewColumn.Active,
{ 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();
}
private _isCommandNotFound(e: unknown): boolean {
const msg = (e as any)?.message ?? String(e ?? '');
return /command\s+'.+'\s+not found/i.test(msg);
}
/** 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 '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);
}
}
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,
},
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;