chore: bump version to 2.80.27 and update core features
This commit is contained in:
@@ -0,0 +1,121 @@
|
||||
import * as vscode from 'vscode';
|
||||
import { ApprovalQueue, Approval } from './approvalQueue';
|
||||
|
||||
/**
|
||||
* A small webview view that surfaces the currently pending approval, separate
|
||||
* from the chat. The provider is intentionally thin: state lives in
|
||||
* ApprovalQueue, this class only renders + relays button clicks.
|
||||
*
|
||||
* Mirroring the existing sidebar.html + media/ separation pattern would be
|
||||
* appropriate once the panel grows, but the current UI is small enough
|
||||
* (~40 lines of HTML) that an inline template keeps the diff focused.
|
||||
*/
|
||||
export class ApprovalPanelProvider implements vscode.WebviewViewProvider {
|
||||
public static readonly viewType = 'g1nation-approval-panel';
|
||||
|
||||
private _view?: vscode.WebviewView;
|
||||
private _subscription?: vscode.Disposable;
|
||||
|
||||
constructor(
|
||||
private readonly _extensionUri: vscode.Uri,
|
||||
private readonly _queue: ApprovalQueue
|
||||
) {}
|
||||
|
||||
public resolveWebviewView(view: vscode.WebviewView): void {
|
||||
this._view = view;
|
||||
view.webview.options = { enableScripts: true, localResourceRoots: [this._extensionUri] };
|
||||
view.webview.html = this._render(this._queue.current());
|
||||
|
||||
view.webview.onDidReceiveMessage((msg: { type: string; id?: string }) => {
|
||||
if (msg?.type === 'approve') void this._queue.approve(msg.id);
|
||||
else if (msg?.type === 'reject') void this._queue.reject(msg.id);
|
||||
else if (msg?.type === 'refresh') view.webview.html = this._render(this._queue.current());
|
||||
});
|
||||
|
||||
this._subscription?.dispose();
|
||||
this._subscription = this._queue.onChange(() => {
|
||||
if (this._view) this._view.webview.html = this._render(this._queue.current());
|
||||
});
|
||||
|
||||
view.onDidDispose(() => {
|
||||
this._subscription?.dispose();
|
||||
this._subscription = undefined;
|
||||
this._view = undefined;
|
||||
});
|
||||
}
|
||||
|
||||
/** Bring the panel into focus; used by the status bar badge. */
|
||||
public focus(): void {
|
||||
void vscode.commands.executeCommand(`${ApprovalPanelProvider.viewType}.focus`);
|
||||
}
|
||||
|
||||
private _render(approval: Approval | null): string {
|
||||
const csp = `default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline';`;
|
||||
const empty = !approval;
|
||||
const body = empty ? this._renderEmpty() : this._renderApproval(approval as Approval);
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta http-equiv="Content-Security-Policy" content="${csp}">
|
||||
<style>
|
||||
body { font-family: var(--vscode-font-family); font-size: 12px; padding: 12px; color: var(--vscode-foreground); }
|
||||
.empty { color: var(--vscode-descriptionForeground); padding: 24px 8px; text-align: center; }
|
||||
.card { border: 1px solid var(--vscode-panel-border); border-radius: 6px; padding: 12px; margin-bottom: 8px; background: var(--vscode-editor-background); }
|
||||
.title { font-weight: 600; margin-bottom: 4px; }
|
||||
.summary { color: var(--vscode-descriptionForeground); margin-bottom: 8px; font-size: 11px; }
|
||||
.files { margin: 8px 0; padding: 6px 8px; background: var(--vscode-textCodeBlock-background); border-radius: 4px; max-height: 160px; overflow-y: auto; }
|
||||
.files li { font-family: var(--vscode-editor-font-family); font-size: 11px; line-height: 1.6; word-break: break-all; list-style: none; }
|
||||
.files .badge { display: inline-block; min-width: 56px; padding: 1px 6px; border-radius: 3px; font-size: 10px; margin-right: 6px; text-align: center; background: var(--vscode-badge-background); color: var(--vscode-badge-foreground); }
|
||||
.actions { display: flex; gap: 6px; margin-top: 10px; }
|
||||
button { flex: 1; padding: 6px 10px; border: 1px solid var(--vscode-button-border, transparent); border-radius: 4px; cursor: pointer; font-size: 12px; }
|
||||
button.approve { background: var(--vscode-button-background); color: var(--vscode-button-foreground); }
|
||||
button.approve:hover { background: var(--vscode-button-hoverBackground); }
|
||||
button.reject { background: var(--vscode-button-secondaryBackground); color: var(--vscode-button-secondaryForeground); }
|
||||
button.reject:hover { background: var(--vscode-button-secondaryHoverBackground); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
${body}
|
||||
<script>
|
||||
const vscode = acquireVsCodeApi();
|
||||
for (const btn of document.querySelectorAll('button[data-action]')) {
|
||||
btn.addEventListener('click', () => {
|
||||
vscode.postMessage({ type: btn.dataset.action, id: btn.dataset.id });
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
private _renderEmpty(): string {
|
||||
return `<div class="empty">대기 중인 승인이 없습니다.</div>`;
|
||||
}
|
||||
|
||||
private _renderApproval(a: Approval): string {
|
||||
const filesHtml = a.files.length === 0
|
||||
? '<li style="color: var(--vscode-descriptionForeground);">파일 변경 없음</li>'
|
||||
: a.files.map(f => `<li><span class="badge">변경</span>${this._escape(f)}</li>`).join('');
|
||||
const elapsed = Math.max(0, Math.floor((Date.now() - a.createdAt) / 1000));
|
||||
return `
|
||||
<div class="card">
|
||||
<div class="title">${this._escape(a.title)}</div>
|
||||
<div class="summary">${this._escape(a.summary)} · ${elapsed}초 전</div>
|
||||
<ul class="files">${filesHtml}</ul>
|
||||
<div class="actions">
|
||||
<button class="approve" data-action="approve" data-id="${this._escape(a.id)}">승인</button>
|
||||
<button class="reject" data-action="reject" data-id="${this._escape(a.id)}">거부</button>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
private _escape(s: string): string {
|
||||
return String(s).replace(/[&<>"']/g, ch => (
|
||||
ch === '&' ? '&' :
|
||||
ch === '<' ? '<' :
|
||||
ch === '>' ? '>' :
|
||||
ch === '"' ? '"' : '''
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import * as vscode from 'vscode';
|
||||
import { logError, logInfo } from '../../utils';
|
||||
|
||||
/**
|
||||
* Pending-approval coordination for ConnectAI.
|
||||
*
|
||||
* Why this module exists:
|
||||
* - The agent already has a transaction-based "dry run" approval flow
|
||||
* (see agent.ts:2362, transactionManager.commit/rollback). The decision
|
||||
* UI lives inside the chat as an inline box, which is fine for a single
|
||||
* prompt but misses the broader problem: the user wants a stable place
|
||||
* to see "what is the agent waiting for me to approve?", with a status
|
||||
* bar badge that pulls them in.
|
||||
*
|
||||
* - Connect_origin solves this with a queue of pending actions in a
|
||||
* dashboard. ConnectAI today only ever has a single active transaction,
|
||||
* so we model the queue as **0..1 current approval** to keep the surface
|
||||
* small. Extending to N approvals later is a list change inside this
|
||||
* module — no consumer needs to switch shapes.
|
||||
*
|
||||
* Wiring (read-only summary):
|
||||
* agent.ts (dryRun) ──enqueue──▶ ApprovalQueue ──onChange──▶ ApprovalPanelProvider (webview)
|
||||
* ──onChange──▶ ApprovalStatusBar (badge)
|
||||
* webview button ──approve/reject──▶ ApprovalQueue ──invokes callback──▶ agent.approveTransaction()
|
||||
*/
|
||||
|
||||
export type ApprovalKind = 'transaction' | 'file-write' | 'file-create' | 'file-delete' | 'command';
|
||||
|
||||
export interface Approval {
|
||||
id: string;
|
||||
kind: ApprovalKind;
|
||||
title: string;
|
||||
summary: string;
|
||||
files: string[];
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
export interface ApprovalCallbacks {
|
||||
approve: () => Promise<void> | void;
|
||||
reject: () => Promise<void> | void;
|
||||
}
|
||||
|
||||
interface ApprovalEntry {
|
||||
approval: Approval;
|
||||
callbacks: ApprovalCallbacks;
|
||||
}
|
||||
|
||||
export class ApprovalQueue {
|
||||
private _current: ApprovalEntry | null = null;
|
||||
private readonly _emitter = new vscode.EventEmitter<void>();
|
||||
/** Fires whenever the current approval changes (set / approved / rejected / cleared). */
|
||||
public readonly onChange = this._emitter.event;
|
||||
|
||||
/**
|
||||
* Replace the currently pending approval, if any. The previous approval is
|
||||
* silently dropped — its callbacks are NOT invoked. This matches the
|
||||
* agent's transaction model: the new dry-run pre-empts whatever was waiting.
|
||||
*/
|
||||
enqueue(approval: Approval, callbacks: ApprovalCallbacks): void {
|
||||
if (this._current) {
|
||||
logInfo('Approval pre-empted by newer pending approval.', {
|
||||
droppedId: this._current.approval.id,
|
||||
newId: approval.id,
|
||||
});
|
||||
}
|
||||
this._current = { approval, callbacks };
|
||||
logInfo('Approval enqueued.', { id: approval.id, kind: approval.kind, fileCount: approval.files.length });
|
||||
this._emitter.fire();
|
||||
}
|
||||
|
||||
/** Returns the currently pending approval, or null. */
|
||||
current(): Approval | null {
|
||||
return this._current?.approval ?? null;
|
||||
}
|
||||
|
||||
/** 0 or 1 with the current model — future-proof for a real queue. */
|
||||
pendingCount(): number {
|
||||
return this._current ? 1 : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Approve the pending entry whose id matches. If `id` is omitted, approve
|
||||
* the current entry. Mismatched ids are ignored to avoid stale-button
|
||||
* double-fire from the webview.
|
||||
*/
|
||||
async approve(id?: string): Promise<void> {
|
||||
const entry = this._take(id);
|
||||
if (!entry) return;
|
||||
try {
|
||||
await entry.callbacks.approve();
|
||||
} catch (e: any) {
|
||||
logError('Approval approve callback threw.', { id: entry.approval.id, error: e?.message ?? String(e) });
|
||||
}
|
||||
}
|
||||
|
||||
async reject(id?: string): Promise<void> {
|
||||
const entry = this._take(id);
|
||||
if (!entry) return;
|
||||
try {
|
||||
await entry.callbacks.reject();
|
||||
} catch (e: any) {
|
||||
logError('Approval reject callback threw.', { id: entry.approval.id, error: e?.message ?? String(e) });
|
||||
}
|
||||
}
|
||||
|
||||
/** Clear without firing callbacks (used by host on shutdown). */
|
||||
clear(): void {
|
||||
if (!this._current) return;
|
||||
this._current = null;
|
||||
this._emitter.fire();
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this._current = null;
|
||||
this._emitter.dispose();
|
||||
}
|
||||
|
||||
private _take(id?: string): ApprovalEntry | null {
|
||||
if (!this._current) return null;
|
||||
if (id !== undefined && id !== this._current.approval.id) {
|
||||
logInfo('Approval id mismatch — ignoring.', { requested: id, current: this._current.approval.id });
|
||||
return null;
|
||||
}
|
||||
const entry = this._current;
|
||||
this._current = null;
|
||||
this._emitter.fire();
|
||||
return entry;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import * as vscode from 'vscode';
|
||||
import { ApprovalQueue } from './approvalQueue';
|
||||
|
||||
/**
|
||||
* Status-bar badge that pulses while an approval is pending. Clicking it
|
||||
* focuses the Approval Panel webview view. Hidden when nothing is pending.
|
||||
*
|
||||
* Lives separately from `src/core/statusBar.ts` (agent run-status indicator)
|
||||
* because the two states change for completely different reasons and the
|
||||
* single-item statusBarManager would lose the agent-status during a long
|
||||
* dry-run review otherwise.
|
||||
*/
|
||||
export class ApprovalStatusBar implements vscode.Disposable {
|
||||
private readonly _item: vscode.StatusBarItem;
|
||||
private readonly _sub: vscode.Disposable;
|
||||
public static readonly focusCommand = 'g1nation.approval.focus';
|
||||
|
||||
constructor(private readonly _queue: ApprovalQueue) {
|
||||
this._item = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Right, 95);
|
||||
this._item.command = ApprovalStatusBar.focusCommand;
|
||||
this._item.tooltip = 'Astra: 승인 대기 중인 작업이 있습니다. 클릭해서 검토하세요.';
|
||||
this._sub = this._queue.onChange(() => this._refresh());
|
||||
this._refresh();
|
||||
}
|
||||
|
||||
private _refresh(): void {
|
||||
const count = this._queue.pendingCount();
|
||||
if (count === 0) {
|
||||
this._item.hide();
|
||||
return;
|
||||
}
|
||||
this._item.text = `$(warning) 승인 대기 ${count}`;
|
||||
this._item.backgroundColor = new vscode.ThemeColor('statusBarItem.warningBackground');
|
||||
this._item.show();
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this._sub.dispose();
|
||||
this._item.dispose();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user