chore: bump version to 2.80.27 and update core features
This commit is contained in:
+136
-44
@@ -59,6 +59,19 @@ export interface AgentExecutorOptions {
|
||||
start: () => void;
|
||||
end: () => void;
|
||||
};
|
||||
/**
|
||||
* Optional native LM Studio chat streamer. When provided AND the active engine is LM Studio,
|
||||
* chat completions are streamed via @lmstudio/sdk's WebSocket transport instead of the
|
||||
* OpenAI-compatible REST endpoint. Falls back to REST when omitted or when the streamer
|
||||
* itself fails (e.g. SDK reachability error).
|
||||
*/
|
||||
lmStudioStreamer?: import('./lmstudio/streamer').IChatStreamer;
|
||||
/**
|
||||
* Optional pending-approval queue. When provided, dry-run transactions are also published
|
||||
* into a queue that drives the Approval Panel webview + status bar badge. The existing
|
||||
* inline `requiresApproval` chat message is preserved for backwards compatibility.
|
||||
*/
|
||||
approvalQueue?: import('./features/approval/approvalQueue').ApprovalQueue;
|
||||
}
|
||||
|
||||
// --- Agent Roles & Workflows ---
|
||||
@@ -135,6 +148,15 @@ export class AgentExecutor {
|
||||
.replace(/<rationale>[\s\S]*?<\/rationale>/gi, '')
|
||||
.replace(/^\s*\[PROBLEM\][\s\S]*?\[GOAL\][\s\S]*?\[REASONING\][^\n]*(?:\n+|$)/i, '')
|
||||
.replace(/^\s*\[PROBLEM\][\s\S]*?(?:\n\s*\n|$)/i, '')
|
||||
.replace(/(?:<think(?:ing)?>|<analysis>)[\s\S]*?(?:<\/think(?:ing)?>|<\/analysis>)/gi, '')
|
||||
// Harmony / GPT-OSS-style channel markers: keep only the `final`
|
||||
// channel; drop everything else (thought, analysis, commentary).
|
||||
// The closing form varies by model: `<channel|>`, `<|channel|>`,
|
||||
// `<|end|>`, `<|return|>`. Match conservatively.
|
||||
.replace(/<\|?channel\|?>\s*(?:thought|analysis|commentary|reasoning)\b[\s\S]*?<\|?channel\|?>/gi, '')
|
||||
.replace(/<\|?channel\|?>\s*(?:thought|analysis|commentary|reasoning)\b[\s\S]*?(?=<\|?channel\|?>\s*final\b)/gi, '')
|
||||
.replace(/<\|?channel\|?>\s*final\b\s*(?:<\|?message\|?>)?/gi, '')
|
||||
.replace(/<\|?(?:end|return|start|message)\|?>/gi, '')
|
||||
.trim();
|
||||
}
|
||||
|
||||
@@ -453,61 +475,91 @@ export class AgentExecutor {
|
||||
logError('AI request timed out.', { timeoutMs: timeout, model: actualModel, loopDepth });
|
||||
this.abortController?.abort();
|
||||
}, timeout);
|
||||
const request = await this.createStreamingRequest({
|
||||
baseUrl: ollamaUrl,
|
||||
modelName: actualModel,
|
||||
reqMessages: messagesForRequest,
|
||||
temperature
|
||||
});
|
||||
const { response, engine, apiUrl } = request;
|
||||
if (this.isStaleRun(runId)) return;
|
||||
|
||||
const engine = resolveEngine(ollamaUrl);
|
||||
const useLmStudioSdk = engine === 'lmstudio' && !!this.options.lmStudioStreamer;
|
||||
let apiUrl = '';
|
||||
let aiResponseText = '';
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) throw new Error("Response body is not readable.");
|
||||
let buffer = '';
|
||||
|
||||
if (loopDepth === 0) {
|
||||
this.webview.postMessage({ type: 'streamStart' });
|
||||
this.options.onStreamLifecycle?.start();
|
||||
}
|
||||
|
||||
let buffer = '';
|
||||
const decoder = new TextDecoder();
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
if (this.isStaleRun(runId)) return;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() || '';
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed === 'data: [DONE]') continue;
|
||||
try {
|
||||
const raw = trimmed.startsWith('data: ') ? trimmed.slice(6) : trimmed;
|
||||
const json = JSON.parse(raw);
|
||||
const token = engine === 'lmstudio' ? json.choices?.[0]?.delta?.content || '' : json.message?.content || json.response || '';
|
||||
if (token) {
|
||||
aiResponseText += token;
|
||||
}
|
||||
} catch (e: any) {
|
||||
logError('Failed to parse streaming chunk.', { engine, apiUrl, chunk: summarizeText(trimmed, 300), error: e?.message || String(e) });
|
||||
}
|
||||
if (useLmStudioSdk) {
|
||||
apiUrl = `${ollamaUrl} (sdk)`;
|
||||
logInfo('Streaming chat via LM Studio SDK.', { model: actualModel });
|
||||
try {
|
||||
const stream = this.options.lmStudioStreamer!.stream({
|
||||
modelName: actualModel,
|
||||
messages: messagesForRequest.map((m) => ({ role: m.role, content: m.content })),
|
||||
temperature,
|
||||
signal: this.abortController.signal,
|
||||
});
|
||||
for await (const { token } of stream) {
|
||||
if (this.isStaleRun(runId)) return;
|
||||
if (token) aiResponseText += token;
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (err?.name === 'AbortError' || this.abortController.signal.aborted) {
|
||||
logInfo('Generation aborted by user.');
|
||||
} else {
|
||||
logError('LM Studio SDK chat failed.', { engine, error: err?.message ?? String(err) });
|
||||
this.webview?.postMessage({ type: 'error', value: `LM Studio: ${err?.message ?? err}` });
|
||||
}
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (err.name === 'AbortError') {
|
||||
logInfo('Generation aborted by user.');
|
||||
} else {
|
||||
logError('Stream reading error.', { engine, apiUrl, error: err?.message || String(err) });
|
||||
this.webview?.postMessage({ type: 'error', value: `Connection lost: ${err.message}` });
|
||||
} else {
|
||||
const request = await this.createStreamingRequest({
|
||||
baseUrl: ollamaUrl,
|
||||
modelName: actualModel,
|
||||
reqMessages: messagesForRequest,
|
||||
temperature
|
||||
});
|
||||
const { response, apiUrl: restApiUrl } = request;
|
||||
apiUrl = restApiUrl;
|
||||
if (this.isStaleRun(runId)) return;
|
||||
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) throw new Error("Response body is not readable.");
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
if (this.isStaleRun(runId)) return;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() || '';
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed === 'data: [DONE]') continue;
|
||||
try {
|
||||
const raw = trimmed.startsWith('data: ') ? trimmed.slice(6) : trimmed;
|
||||
const json = JSON.parse(raw);
|
||||
const token = engine === 'lmstudio' ? json.choices?.[0]?.delta?.content || '' : json.message?.content || json.response || '';
|
||||
if (token) {
|
||||
aiResponseText += token;
|
||||
}
|
||||
} catch (e: any) {
|
||||
logError('Failed to parse streaming chunk.', { engine, apiUrl, chunk: summarizeText(trimmed, 300), error: e?.message || String(e) });
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (err.name === 'AbortError') {
|
||||
logInfo('Generation aborted by user.');
|
||||
} else {
|
||||
logError('Stream reading error.', { engine, apiUrl, error: err?.message || String(err) });
|
||||
this.webview?.postMessage({ type: 'error', value: `Connection lost: ${err.message}` });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Final buffer processing
|
||||
if (buffer.trim() && buffer.trim() !== 'data: [DONE]') {
|
||||
// Final buffer processing (REST SSE only — SDK has no trailing buffer)
|
||||
if (!useLmStudioSdk && buffer.trim() && buffer.trim() !== 'data: [DONE]') {
|
||||
try {
|
||||
const trimmed = buffer.trim();
|
||||
const raw = trimmed.startsWith('data: ') ? trimmed.slice(6) : trimmed;
|
||||
@@ -717,13 +769,35 @@ export class AgentExecutor {
|
||||
|
||||
private async callAgent(role: AgentRole, prompt: string, modelName: string, options: any): Promise<string> {
|
||||
const persona = AGENT_PROMPTS[role];
|
||||
const { ollamaUrl, timeout } = getConfig();
|
||||
const { ollamaUrl } = getConfig();
|
||||
|
||||
const messages: ChatMessage[] = [
|
||||
{ role: 'system', content: persona },
|
||||
{ role: 'user', content: prompt }
|
||||
];
|
||||
|
||||
const engine = resolveEngine(ollamaUrl);
|
||||
let responseText = '';
|
||||
|
||||
if (engine === 'lmstudio' && this.options.lmStudioStreamer) {
|
||||
try {
|
||||
const stream = this.options.lmStudioStreamer.stream({
|
||||
modelName,
|
||||
messages: messages.map((m) => ({ role: m.role, content: m.content })),
|
||||
temperature: 0.3,
|
||||
signal: this.abortController?.signal,
|
||||
});
|
||||
for await (const { token } of stream) {
|
||||
if (token) responseText += token;
|
||||
}
|
||||
return responseText;
|
||||
} catch (err: any) {
|
||||
if (err?.name === 'AbortError' || this.abortController?.signal.aborted) return responseText;
|
||||
logError('LM Studio SDK callAgent stream failed.', { role, error: err?.message ?? String(err) });
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
const request = await this.createStreamingRequest({
|
||||
baseUrl: ollamaUrl,
|
||||
modelName: modelName,
|
||||
@@ -731,7 +805,6 @@ export class AgentExecutor {
|
||||
temperature: 0.3 // Use lower temperature for planning and research
|
||||
});
|
||||
|
||||
let responseText = '';
|
||||
const reader = request.response.body?.getReader();
|
||||
if (!reader) throw new Error("Agent response body is not readable.");
|
||||
|
||||
@@ -2304,6 +2377,25 @@ export class AgentExecutor {
|
||||
if (config.dryRun) {
|
||||
report.push(`\n⚠️ **Dry Run Mode Active**: 위 변경 사항을 확인하고 [승인] 또는 [롤백]을 선택해주세요.`);
|
||||
this.webview?.postMessage({ type: 'requiresApproval' });
|
||||
// Mirror the inline-chat approval into the queue feeding the dedicated panel + status bar.
|
||||
const queue = this.options.approvalQueue;
|
||||
if (queue) {
|
||||
const recorded = this.transactionManager.getRecordedFiles();
|
||||
queue.enqueue(
|
||||
{
|
||||
id: `txn-${Date.now()}`,
|
||||
kind: 'transaction',
|
||||
title: 'Pending file changes',
|
||||
summary: `${recorded.length}개 파일 변경 대기 중`,
|
||||
files: recorded.map(r => r.path),
|
||||
createdAt: Date.now(),
|
||||
},
|
||||
{
|
||||
approve: () => this.approveTransaction(),
|
||||
reject: () => this.rejectTransaction(),
|
||||
}
|
||||
);
|
||||
}
|
||||
// Do NOT commit yet
|
||||
} else {
|
||||
this.transactionManager.commit();
|
||||
|
||||
+50
-1
@@ -9,6 +9,12 @@ import {
|
||||
} from './utils';
|
||||
import { getConfig } from './config';
|
||||
import { IAIService, IBrainService, AIService, BrainService } from './core/services';
|
||||
import {
|
||||
ISkillInjectionService,
|
||||
FileSystemSkillInjectionService,
|
||||
SkillInjectionError,
|
||||
} from './skills/skillInjectionService';
|
||||
import { resolveAgentSkillsDir } from './lib/paths';
|
||||
|
||||
export interface BridgeInterface {
|
||||
injectSystemMessage(msg: string): void;
|
||||
@@ -27,15 +33,25 @@ export class BridgeServer {
|
||||
private server: http.Server | null = null;
|
||||
private aiService: IAIService;
|
||||
private brainService: IBrainService;
|
||||
private skillService: ISkillInjectionService;
|
||||
|
||||
constructor(
|
||||
private provider: BridgeInterface,
|
||||
aiService?: IAIService,
|
||||
brainService?: IBrainService
|
||||
brainService?: IBrainService,
|
||||
skillService?: ISkillInjectionService
|
||||
) {
|
||||
// 의존성 주입 (DIP): 기본값 제공 및 외부 주입 허용
|
||||
this.aiService = aiService || new AIService();
|
||||
this.brainService = brainService || new BrainService();
|
||||
this.skillService = skillService || new FileSystemSkillInjectionService({
|
||||
resolveSkillsDir: resolveAgentSkillsDir,
|
||||
onInjected: (result, req) => {
|
||||
this.provider.injectSystemMessage(
|
||||
`**[Skill]** Injected agent skill: ${req.displayName || result.safeName}`
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
public start(port: number = 4825) {
|
||||
@@ -64,6 +80,8 @@ export class BridgeServer {
|
||||
this.processEvaluateHistory(res);
|
||||
} else if (method === 'POST' && url === '/api/brain-inject') {
|
||||
this.handlePost(req, res, this.processBrainInject.bind(this));
|
||||
} else if (method === 'POST' && url === '/api/skill-inject') {
|
||||
this.handlePost(req, res, this.processSkillInject.bind(this));
|
||||
} else {
|
||||
res.writeHead(404);
|
||||
res.end();
|
||||
@@ -175,4 +193,35 @@ export class BridgeServer {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ success: true, rawOutput: result }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Inject an agent skill (markdown-only — see SkillInjectionService doc) from
|
||||
* an external tool. Routing-only: validation, file IO, and telemetry live in
|
||||
* the service.
|
||||
*/
|
||||
private async processSkillInject(data: any, res: http.ServerResponse) {
|
||||
try {
|
||||
const result = await this.skillService.inject({
|
||||
name: data?.name,
|
||||
content: data?.content ?? data?.markdown,
|
||||
displayName: data?.displayName,
|
||||
description: data?.description,
|
||||
source: data?.source,
|
||||
});
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({
|
||||
success: true,
|
||||
safeName: result.safeName,
|
||||
filePath: result.filePath,
|
||||
}));
|
||||
} catch (e: any) {
|
||||
const status = e instanceof SkillInjectionError && (e.code === 'INVALID_NAME' || e.code === 'EMPTY_CONTENT')
|
||||
? 400
|
||||
: 500;
|
||||
const code = e instanceof SkillInjectionError ? e.code : 'UNKNOWN';
|
||||
logError('Skill inject endpoint failed.', { code, error: e?.message ?? String(e) });
|
||||
res.writeHead(status, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: e?.message ?? String(e), code }));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,6 +122,11 @@ export class TransactionManager {
|
||||
public isActive(): boolean {
|
||||
return this.isTransactionActive;
|
||||
}
|
||||
|
||||
/** Snapshot of file paths currently recorded in the active transaction. */
|
||||
public getRecordedFiles(): { path: string; type: 'modified' | 'created' | 'deleted' }[] {
|
||||
return Array.from(this.backups.values()).map(b => ({ path: b.path, type: b.type }));
|
||||
}
|
||||
}
|
||||
|
||||
// Export a singleton instance if needed, or instantiate per AgentExecutor
|
||||
|
||||
+212
-2
@@ -21,8 +21,22 @@ import { initAstraPathResolver } from './core/astraPath';
|
||||
import { LMStudioClient } from './lmstudio/client';
|
||||
import { ActivityTracker } from './lmstudio/activityTracker';
|
||||
import { ModelLifecycleManager } from './lmstudio/lifecycleManager';
|
||||
import { LMStudioStreamer } from './lmstudio/streamer';
|
||||
import { NodeSystemSpecsProvider, HeuristicModelMemoryEstimator } from './system/specs';
|
||||
import { ApprovalQueue } from './features/approval/approvalQueue';
|
||||
import { ApprovalPanelProvider } from './features/approval/approvalPanelProvider';
|
||||
import { ApprovalStatusBar } from './features/approval/approvalStatusBar';
|
||||
import { FileSystemProjectScaffolder } from './scaffolder/projectScaffolder';
|
||||
import type { ProjectTemplateId } from './scaffolder/templates';
|
||||
import { TelegramHttpClient } from './integrations/telegram/telegramClient';
|
||||
import { TelegramBot } from './integrations/telegram/telegramBot';
|
||||
import { AIService } from './core/services';
|
||||
import { SettingsPanelProvider } from './features/settings/settingsPanelProvider';
|
||||
|
||||
let _lifecycleManager: ModelLifecycleManager | undefined;
|
||||
let _telegramBot: TelegramBot | undefined;
|
||||
|
||||
const TELEGRAM_TOKEN_SECRET_KEY = 'g1nation.telegram.botToken';
|
||||
|
||||
/**
|
||||
* Astra Extension Entry Point
|
||||
@@ -52,6 +66,9 @@ export async function activate(context: vscode.ExtensionContext) {
|
||||
const initialUrl = getConfig().ollamaUrl;
|
||||
const activityTracker = new ActivityTracker();
|
||||
const lmStudioClient = new LMStudioClient(initialUrl);
|
||||
const systemSpecs = new NodeSystemSpecsProvider();
|
||||
const memoryEstimator = new HeuristicModelMemoryEstimator();
|
||||
logInfo('System specs detected.', { summary: systemSpecs.get().summary });
|
||||
const lifecycle = new ModelLifecycleManager({
|
||||
client: lmStudioClient,
|
||||
activity: activityTracker,
|
||||
@@ -64,6 +81,8 @@ export async function activate(context: vscode.ExtensionContext) {
|
||||
},
|
||||
notifyError: (msg) => provider?.postLmStudioError(msg),
|
||||
initialEngine: resolveEngine(initialUrl),
|
||||
systemSpecs,
|
||||
memoryEstimator,
|
||||
});
|
||||
_lifecycleManager = lifecycle;
|
||||
context.subscriptions.push({ dispose: () => activityTracker.dispose() });
|
||||
@@ -79,18 +98,48 @@ export async function activate(context: vscode.ExtensionContext) {
|
||||
})
|
||||
);
|
||||
|
||||
// 3. Initialize Agent Executor (with stream lifecycle hooks)
|
||||
// Keep the sidebar's model dropdown in sync when defaultModel / ollamaUrl is
|
||||
// changed from elsewhere (Settings panel, raw settings.json, …). Without
|
||||
// this the user sees a desync: Settings shows the new model, sidebar still
|
||||
// shows the old one until a manual refresh.
|
||||
context.subscriptions.push(
|
||||
vscode.workspace.onDidChangeConfiguration((e) => {
|
||||
const touchedModel = e.affectsConfiguration('g1nation.defaultModel');
|
||||
const touchedUrl = e.affectsConfiguration('g1nation.ollamaUrl');
|
||||
if (!touchedModel && !touchedUrl) return;
|
||||
// _sendModels is best-effort; the provider may not have a webview
|
||||
// attached yet during very early activation.
|
||||
void provider?._sendModels(touchedUrl);
|
||||
})
|
||||
);
|
||||
|
||||
// 3. Initialize Approval subsystem (queue + panel webview + status bar badge)
|
||||
const approvalQueue = new ApprovalQueue();
|
||||
const approvalPanel = new ApprovalPanelProvider(context.extensionUri, approvalQueue);
|
||||
const approvalStatusBar = new ApprovalStatusBar(approvalQueue);
|
||||
context.subscriptions.push(
|
||||
vscode.window.registerWebviewViewProvider(ApprovalPanelProvider.viewType, approvalPanel),
|
||||
approvalStatusBar,
|
||||
{ dispose: () => approvalQueue.dispose() },
|
||||
vscode.commands.registerCommand(ApprovalStatusBar.focusCommand, () => approvalPanel.focus()),
|
||||
);
|
||||
|
||||
// 4. Initialize Agent Executor (with stream lifecycle hooks + LM Studio SDK streamer + approval queue)
|
||||
const lmStudioStreamer = new LMStudioStreamer(lmStudioClient);
|
||||
const agent = new AgentExecutor(context, {
|
||||
onStreamLifecycle: {
|
||||
start: () => lifecycle.onStreamStart(),
|
||||
end: () => lifecycle.onStreamEnd(),
|
||||
},
|
||||
lmStudioStreamer,
|
||||
approvalQueue,
|
||||
});
|
||||
|
||||
// 4. Initialize Sidebar Provider
|
||||
provider = new SidebarChatProvider(context.extensionUri, context, agent, {
|
||||
lifecycle,
|
||||
activity: activityTracker,
|
||||
loadedModels: () => lmStudioClient.listLoadedCached(),
|
||||
});
|
||||
context.subscriptions.push(
|
||||
vscode.window.registerWebviewViewProvider(SidebarChatProvider.viewType, provider)
|
||||
@@ -120,7 +169,164 @@ export async function activate(context: vscode.ExtensionContext) {
|
||||
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand('g1nation.syncBrain', async () => {
|
||||
await provider.syncBrain();
|
||||
await provider!.syncBrain();
|
||||
})
|
||||
);
|
||||
|
||||
// Telegram Bot integration — opt-in (g1nation.telegram.enabled), token in SecretStorage.
|
||||
let _cachedTelegramToken = (await context.secrets.get(TELEGRAM_TOKEN_SECRET_KEY)) || '';
|
||||
const telegramClient = new TelegramHttpClient({
|
||||
getToken: () => _cachedTelegramToken,
|
||||
});
|
||||
const telegramAi = new AIService();
|
||||
const telegramBot = new TelegramBot({
|
||||
client: telegramClient,
|
||||
handle: async (text, chatId) => {
|
||||
const cfg = vscode.workspace.getConfiguration('g1nation');
|
||||
const allowed = cfg.get<number[]>('telegram.allowedChatIds', []) || [];
|
||||
if (allowed.length > 0 && !allowed.includes(chatId)) {
|
||||
logInfo('Telegram message from unallowed chat ignored.', { chatId });
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const reply = await telegramAi.call(text);
|
||||
return (reply && reply.trim()) ? reply : '(빈 응답)';
|
||||
} catch (e: any) {
|
||||
return `⚠️ Astra error: ${e?.message ?? e}`;
|
||||
}
|
||||
},
|
||||
});
|
||||
_telegramBot = telegramBot;
|
||||
|
||||
const refreshTelegramBot = async () => {
|
||||
const enabled = vscode.workspace.getConfiguration('g1nation').get<boolean>('telegram.enabled', false);
|
||||
const tokenPresent = !!_cachedTelegramToken.trim();
|
||||
if (enabled && tokenPresent) {
|
||||
telegramBot.start();
|
||||
} else if (telegramBot.isRunning()) {
|
||||
await telegramBot.stop();
|
||||
}
|
||||
};
|
||||
void refreshTelegramBot();
|
||||
|
||||
context.subscriptions.push(
|
||||
{ dispose: () => { void telegramBot.stop(); } },
|
||||
vscode.workspace.onDidChangeConfiguration(async (e) => {
|
||||
if (e.affectsConfiguration('g1nation.telegram.enabled')) {
|
||||
await refreshTelegramBot();
|
||||
}
|
||||
}),
|
||||
context.secrets.onDidChange(async (e) => {
|
||||
if (e.key !== TELEGRAM_TOKEN_SECRET_KEY) return;
|
||||
_cachedTelegramToken = (await context.secrets.get(TELEGRAM_TOKEN_SECRET_KEY)) || '';
|
||||
await refreshTelegramBot();
|
||||
}),
|
||||
vscode.commands.registerCommand('g1nation.telegram.setBotToken', async () => {
|
||||
const token = await vscode.window.showInputBox({
|
||||
prompt: 'Telegram bot token (BotFather에서 발급, 형식: 123456:ABC...)',
|
||||
placeHolder: '123456789:AA...',
|
||||
password: true,
|
||||
ignoreFocusOut: true,
|
||||
validateInput: (v) => /^\d+:[A-Za-z0-9_-]{20,}$/.test((v || '').trim())
|
||||
? null
|
||||
: '형식이 올바르지 않습니다 (숫자ID:문자열).',
|
||||
});
|
||||
if (!token) return;
|
||||
await context.secrets.store(TELEGRAM_TOKEN_SECRET_KEY, token.trim());
|
||||
vscode.window.showInformationMessage(
|
||||
'Telegram bot token이 저장되었습니다. settings에서 g1nation.telegram.enabled = true 로 켜세요.'
|
||||
);
|
||||
}),
|
||||
vscode.commands.registerCommand('g1nation.telegram.clearBotToken', async () => {
|
||||
await context.secrets.delete(TELEGRAM_TOKEN_SECRET_KEY);
|
||||
vscode.window.showInformationMessage('Telegram bot token이 삭제되었습니다.');
|
||||
}),
|
||||
vscode.commands.registerCommand('g1nation.telegram.testConnection', async () => {
|
||||
const token = (await context.secrets.get(TELEGRAM_TOKEN_SECRET_KEY)) || '';
|
||||
if (!token) {
|
||||
vscode.window.showErrorMessage('먼저 "Astra: Set Telegram Bot Token" 명령으로 토큰을 등록하세요.');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const me = await telegramClient.getMe();
|
||||
vscode.window.showInformationMessage(
|
||||
`Telegram 연결 성공: @${me.username || me.first_name} (id ${me.id})`
|
||||
);
|
||||
} catch (e: any) {
|
||||
vscode.window.showErrorMessage(`Telegram 연결 실패: ${e?.message ?? e}`);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
// Astra Settings webview — single entry point for user-facing config (Phase 5-A: Telegram only).
|
||||
const settingsPanel = new SettingsPanelProvider({
|
||||
extensionUri: context.extensionUri,
|
||||
secrets: context.secrets,
|
||||
telegramClient,
|
||||
telegramBot,
|
||||
});
|
||||
context.subscriptions.push(
|
||||
vscode.window.registerWebviewViewProvider(SettingsPanelProvider.viewType, settingsPanel),
|
||||
// Refresh the settings UI whenever any g1nation.* config changes (toggle, allowedChatIds, …).
|
||||
vscode.workspace.onDidChangeConfiguration((e) => {
|
||||
if (e.affectsConfiguration('g1nation')) void settingsPanel.refresh();
|
||||
}),
|
||||
// Same for SecretStorage updates (token saved/cleared from elsewhere).
|
||||
context.secrets.onDidChange((e) => {
|
||||
if (e.key === TELEGRAM_TOKEN_SECRET_KEY) void settingsPanel.refresh();
|
||||
}),
|
||||
vscode.commands.registerCommand('g1nation.settings.focus', () => settingsPanel.focus()),
|
||||
vscode.commands.registerCommand('g1nation.settings.diagnose', async () => {
|
||||
// Diagnostic helper: shows whether the view is registered + opens it if so.
|
||||
// Useful when the user reports "Set 버튼이 안 먹는다" and we want to confirm
|
||||
// the new build is actually loaded.
|
||||
try {
|
||||
await settingsPanel.focus();
|
||||
vscode.window.showInformationMessage('Astra Settings 패널이 열렸습니다. 사이드바 Settings 항목을 확인하세요.');
|
||||
} catch (e: any) {
|
||||
vscode.window.showErrorMessage(`Settings 패널 열기 실패 (확장 reload가 필요할 수 있음): ${e?.message ?? e}`);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
// Project Scaffolder — Astra의 Developer 빠른 시작 명령
|
||||
const scaffolder = new FileSystemProjectScaffolder();
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand('g1nation.scaffoldProject', async () => {
|
||||
const folders = vscode.workspace.workspaceFolders;
|
||||
if (!folders || folders.length === 0) {
|
||||
vscode.window.showErrorMessage('워크스페이스 폴더를 먼저 여세요.');
|
||||
return;
|
||||
}
|
||||
const name = await vscode.window.showInputBox({
|
||||
placeHolder: '프로젝트 이름 (영문/숫자/_/-, 2~40자)',
|
||||
prompt: 'Astra가 워크스페이스 안에 만들 프로젝트 폴더 이름',
|
||||
validateInput: (v) => /^[a-zA-Z0-9_-]{2,40}$/.test(v.trim()) ? null : '영문/숫자/_/- 만, 2~40자',
|
||||
});
|
||||
if (!name) return;
|
||||
const picked = await vscode.window.showQuickPick(
|
||||
scaffolder.listTemplates().map(t => ({ label: t.label, detail: t.detail, id: t.id })),
|
||||
{ placeHolder: '템플릿 선택' }
|
||||
);
|
||||
if (!picked) return;
|
||||
|
||||
const result = await scaffolder.scaffold({
|
||||
name: name.trim(),
|
||||
template: picked.id as ProjectTemplateId,
|
||||
rootDir: folders[0].uri.fsPath,
|
||||
});
|
||||
if (!result.ok) {
|
||||
vscode.window.showErrorMessage(`프로젝트 생성 실패: ${result.error}`);
|
||||
return;
|
||||
}
|
||||
const action = await vscode.window.showInformationMessage(
|
||||
`✅ ${name} 생성 완료 — ${result.projectPath}`,
|
||||
'폴더 열기',
|
||||
'닫기'
|
||||
);
|
||||
if (action === '폴더 열기') {
|
||||
await vscode.commands.executeCommand('revealFileInOS', vscode.Uri.file(result.projectPath));
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
@@ -133,6 +339,10 @@ export async function activate(context: vscode.ExtensionContext) {
|
||||
|
||||
export async function deactivate() {
|
||||
HealthCheckMonitor.dispose();
|
||||
if (_telegramBot) {
|
||||
try { await _telegramBot.stop(); } catch (e) { logError('Telegram bot stop during deactivate failed.', e); }
|
||||
_telegramBot = undefined;
|
||||
}
|
||||
if (_lifecycleManager) {
|
||||
try {
|
||||
await _lifecycleManager.disposeAndUnload(2000);
|
||||
|
||||
@@ -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;
|
||||
@@ -0,0 +1,240 @@
|
||||
import type { ITelegramClient, TelegramClientError } from './telegramClient';
|
||||
import type { TelegramUpdate } from './types';
|
||||
import { logError, logInfo } from '../../utils';
|
||||
|
||||
/**
|
||||
* TelegramBot — long-polling loop with explicit lifecycle.
|
||||
*
|
||||
* Why this is split from the HTTP client:
|
||||
* - The HTTP client knows how to make a single request; the bot knows how to
|
||||
* coordinate a polling loop, an offset cursor, error backoff, and a clean
|
||||
* shutdown via AbortController.
|
||||
* - This separation makes the bot mock-friendly — tests inject a stub client
|
||||
* that returns scripted update arrays without touching the network.
|
||||
*
|
||||
* Behavior:
|
||||
* - On `start()`: kicks off an async loop that calls `client.getUpdates()`
|
||||
* with a 25s timeout. The loop catches per-iteration errors so a single
|
||||
* network blip cannot tear down the bot.
|
||||
* - On `stop()`: aborts any in-flight request and exits the loop. Idempotent.
|
||||
* - On `aborted` errors during a normal shutdown: silently exits.
|
||||
* - On `no-token`/`api(401)` (token revoked / wrong): logs once, stops.
|
||||
* - On generic `network` errors: exponential backoff capped at 30s.
|
||||
*
|
||||
* The handler signature is `(text, chatId) => Promise<string | null>`. Returning
|
||||
* null suppresses the reply (e.g. for ignored messages).
|
||||
*/
|
||||
|
||||
export type TelegramMessageHandler = (text: string, chatId: number) => Promise<string | null>;
|
||||
|
||||
export interface TelegramBotDeps {
|
||||
client: ITelegramClient;
|
||||
handle: TelegramMessageHandler;
|
||||
/** Long-poll seconds passed to getUpdates. Default 25. */
|
||||
pollTimeoutSec?: number;
|
||||
/** Per-call wait when an error happens. Capped at maxBackoffMs. Default 1000ms. */
|
||||
initialBackoffMs?: number;
|
||||
/** Default 30000ms. */
|
||||
maxBackoffMs?: number;
|
||||
/** Optional sleep override for tests (defaults to setTimeout-based). */
|
||||
sleep?: (ms: number) => Promise<void>;
|
||||
}
|
||||
|
||||
const defaultSleep = (ms: number) =>
|
||||
new Promise<void>((resolve) => {
|
||||
const t = setTimeout(resolve, ms);
|
||||
// Avoid blocking node exit if the bot is the only thing keeping the loop alive.
|
||||
if (typeof t === 'object' && t && 'unref' in t) (t as any).unref();
|
||||
});
|
||||
|
||||
export interface EnrolledChat {
|
||||
chatId: number;
|
||||
username?: string;
|
||||
firstName?: string;
|
||||
}
|
||||
|
||||
export class TelegramBot {
|
||||
private _running = false;
|
||||
private _abort: AbortController | undefined;
|
||||
private _offset: number | undefined;
|
||||
private _loopPromise: Promise<void> | undefined;
|
||||
private _enrollPending: {
|
||||
resolve: (chat: EnrolledChat) => void;
|
||||
reject: (err: Error) => void;
|
||||
} | undefined;
|
||||
|
||||
constructor(private readonly _deps: TelegramBotDeps) {}
|
||||
|
||||
isRunning(): boolean { return this._running; }
|
||||
|
||||
/**
|
||||
* Wait for the next incoming message and resolve with its chat info.
|
||||
*
|
||||
* Used by the settings wizard: the user clicks "내 chat ID 자동 등록", we
|
||||
* arm this one-shot capture, and the very next incoming Telegram message
|
||||
* is intercepted (no AI reply, no handler call) and yielded back as the
|
||||
* captured chat. The bot keeps polling normally afterwards.
|
||||
*
|
||||
* Only one enrollment can be pending at a time — calling this while
|
||||
* already armed rejects the prior pending promise.
|
||||
*/
|
||||
enrollNextChat(timeoutMs: number = 120000): Promise<EnrolledChat> {
|
||||
if (this._enrollPending) {
|
||||
this._enrollPending.reject(new Error('Superseded by a new enrollment request.'));
|
||||
this._enrollPending = undefined;
|
||||
}
|
||||
return new Promise<EnrolledChat>((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
if (this._enrollPending && this._enrollPending.resolve === wrappedResolve) {
|
||||
this._enrollPending = undefined;
|
||||
reject(new Error(`No message received within ${Math.round(timeoutMs / 1000)}s.`));
|
||||
}
|
||||
}, timeoutMs);
|
||||
if (typeof timer === 'object' && timer && 'unref' in timer) (timer as any).unref();
|
||||
|
||||
const wrappedResolve = (chat: EnrolledChat) => {
|
||||
clearTimeout(timer);
|
||||
resolve(chat);
|
||||
};
|
||||
const wrappedReject = (err: Error) => {
|
||||
clearTimeout(timer);
|
||||
reject(err);
|
||||
};
|
||||
this._enrollPending = { resolve: wrappedResolve, reject: wrappedReject };
|
||||
});
|
||||
}
|
||||
|
||||
/** Cancel any pending enrollment without resolving it. */
|
||||
cancelEnrollment(): void {
|
||||
if (!this._enrollPending) return;
|
||||
this._enrollPending.reject(new Error('Enrollment cancelled.'));
|
||||
this._enrollPending = undefined;
|
||||
}
|
||||
|
||||
/** Idempotent: starts the long-poll loop if not already running. */
|
||||
start(): void {
|
||||
if (this._running) return;
|
||||
this._running = true;
|
||||
this._abort = new AbortController();
|
||||
this._loopPromise = this._loop().catch((e) => {
|
||||
logError('Telegram bot loop crashed unexpectedly.', { error: e?.message ?? String(e) });
|
||||
});
|
||||
logInfo('Telegram bot started.');
|
||||
}
|
||||
|
||||
/** Idempotent: aborts the in-flight call and waits for the loop to exit. */
|
||||
async stop(): Promise<void> {
|
||||
if (!this._running) return;
|
||||
this._running = false;
|
||||
try { this._abort?.abort(); } catch { /* noop */ }
|
||||
const p = this._loopPromise;
|
||||
this._abort = undefined;
|
||||
this._loopPromise = undefined;
|
||||
if (this._enrollPending) {
|
||||
this._enrollPending.reject(new Error('Bot stopped before enrollment completed.'));
|
||||
this._enrollPending = undefined;
|
||||
}
|
||||
if (p) {
|
||||
try { await p; } catch { /* swallow — already logged */ }
|
||||
}
|
||||
logInfo('Telegram bot stopped.');
|
||||
}
|
||||
|
||||
private async _loop(): Promise<void> {
|
||||
const { client, handle } = this._deps;
|
||||
const pollTimeoutSec = this._deps.pollTimeoutSec ?? 25;
|
||||
const initialBackoff = this._deps.initialBackoffMs ?? 1000;
|
||||
const maxBackoff = this._deps.maxBackoffMs ?? 30000;
|
||||
const sleep = this._deps.sleep ?? defaultSleep;
|
||||
|
||||
let backoff = initialBackoff;
|
||||
|
||||
while (this._running) {
|
||||
const signal = this._abort?.signal;
|
||||
try {
|
||||
const updates = await client.getUpdates({
|
||||
offset: this._offset,
|
||||
timeoutSec: pollTimeoutSec,
|
||||
signal,
|
||||
});
|
||||
backoff = initialBackoff; // reset on success
|
||||
|
||||
for (const update of updates) {
|
||||
if (!this._running) break;
|
||||
this._offset = update.update_id + 1;
|
||||
await this._processUpdate(update, handle);
|
||||
}
|
||||
} catch (e: any) {
|
||||
if (!this._running) break;
|
||||
const err = e as TelegramClientError;
|
||||
if (err?.kind === 'aborted') break;
|
||||
if (err?.kind === 'no-token') {
|
||||
logError('Telegram bot stopping: token not configured.');
|
||||
this._running = false;
|
||||
break;
|
||||
}
|
||||
if (err?.kind === 'api' && (err.statusCode === 401 || err.statusCode === 404)) {
|
||||
logError('Telegram bot stopping: invalid token (HTTP 401/404).', { statusCode: err.statusCode });
|
||||
this._running = false;
|
||||
break;
|
||||
}
|
||||
// Generic network / api errors: log and back off.
|
||||
logError('Telegram poll error; backing off.', { backoff, error: e?.message ?? String(e) });
|
||||
await sleep(backoff);
|
||||
backoff = Math.min(backoff * 2, maxBackoff);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async _processUpdate(update: TelegramUpdate, handle: TelegramMessageHandler): Promise<void> {
|
||||
const msg = update.message ?? update.edited_message;
|
||||
if (!msg) return;
|
||||
const text = msg.text?.trim();
|
||||
const chatId = msg.chat?.id;
|
||||
if (!text || typeof chatId !== 'number') return;
|
||||
|
||||
// Enrollment intercept: if the settings wizard armed enrollNextChat(),
|
||||
// hand this update off and skip the normal AI handler. We still send a
|
||||
// friendly acknowledgement so the user knows enrollment worked.
|
||||
if (this._enrollPending) {
|
||||
const pending = this._enrollPending;
|
||||
this._enrollPending = undefined;
|
||||
pending.resolve({
|
||||
chatId,
|
||||
username: msg.from?.username,
|
||||
firstName: msg.from?.first_name,
|
||||
});
|
||||
try {
|
||||
await this._deps.client.sendMessage({
|
||||
chatId,
|
||||
text: '✅ 채널이 등록되었습니다. 이제부터 메시지를 보낼 수 있어요.',
|
||||
signal: this._abort?.signal,
|
||||
});
|
||||
} catch (e: any) {
|
||||
logError('Telegram enrollment ack send failed.', { chatId, error: e?.message ?? String(e) });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let reply: string | null = null;
|
||||
try {
|
||||
reply = await handle(text, chatId);
|
||||
} catch (e: any) {
|
||||
logError('Telegram message handler threw.', { chatId, error: e?.message ?? String(e) });
|
||||
reply = `⚠️ Astra 처리 중 오류: ${e?.message ?? e}`;
|
||||
}
|
||||
|
||||
if (reply == null || !reply.trim()) return;
|
||||
try {
|
||||
await this._deps.client.sendMessage({
|
||||
chatId,
|
||||
text: reply,
|
||||
signal: this._abort?.signal,
|
||||
});
|
||||
} catch (e: any) {
|
||||
// Sending the reply failed — log and move on. Don't tear down the
|
||||
// loop because of a single send failure.
|
||||
logError('Telegram reply send failed.', { chatId, error: e?.message ?? String(e) });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import type {
|
||||
TelegramApiResponse,
|
||||
TelegramMessage,
|
||||
TelegramUpdate,
|
||||
TelegramUser,
|
||||
} from './types';
|
||||
import { TELEGRAM_MAX_TEXT_LENGTH } from './types';
|
||||
import { logError, logInfo } from '../../utils';
|
||||
|
||||
/**
|
||||
* Thin HTTP wrapper around the Telegram Bot API.
|
||||
*
|
||||
* Only the three endpoints the bot loop needs are exposed:
|
||||
* - getMe() — token validity probe (used by the "test connection" command)
|
||||
* - getUpdates() — long-polling driver
|
||||
* - sendMessage() — outbound replies
|
||||
*
|
||||
* Design notes:
|
||||
* - Uses native `fetch` so we don't pull axios in just for this integration.
|
||||
* - All errors are normalized to `TelegramClientError` so callers can branch
|
||||
* on `kind` (`network` / `api` / `aborted`) without inspecting raw fetch
|
||||
* internals.
|
||||
* - The `signal` parameter is honored on every call — long-polling depends on
|
||||
* this for clean shutdown when the bot is disabled or the extension
|
||||
* deactivates.
|
||||
* - Tokens are passed by reference (`getToken: () => string | undefined`)
|
||||
* instead of stored, so rotating the SecretStorage value takes effect on
|
||||
* the next request without rebuilding the client.
|
||||
*/
|
||||
|
||||
export type TelegramClientErrorKind = 'network' | 'api' | 'aborted' | 'no-token';
|
||||
|
||||
export class TelegramClientError extends Error {
|
||||
constructor(
|
||||
public readonly kind: TelegramClientErrorKind,
|
||||
message: string,
|
||||
public readonly statusCode?: number
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'TelegramClientError';
|
||||
}
|
||||
}
|
||||
|
||||
export interface SendMessageOptions {
|
||||
chatId: number;
|
||||
text: string;
|
||||
/** Defaults to "Markdown" for clean formatting; pass null to disable. */
|
||||
parseMode?: 'Markdown' | 'MarkdownV2' | 'HTML' | null;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
export interface GetUpdatesOptions {
|
||||
offset?: number;
|
||||
/** Long-poll seconds. 0 = short poll. Telegram caps at 50; we default to 25. */
|
||||
timeoutSec?: number;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
export interface ITelegramClient {
|
||||
getMe(signal?: AbortSignal): Promise<TelegramUser>;
|
||||
getUpdates(opts: GetUpdatesOptions): Promise<TelegramUpdate[]>;
|
||||
sendMessage(opts: SendMessageOptions): Promise<TelegramMessage>;
|
||||
}
|
||||
|
||||
export interface TelegramHttpClientDeps {
|
||||
/** Returns the current bot token (or empty when not configured). */
|
||||
getToken: () => string | undefined;
|
||||
/** Optional fetch override for tests. */
|
||||
fetchImpl?: typeof fetch;
|
||||
}
|
||||
|
||||
export class TelegramHttpClient implements ITelegramClient {
|
||||
private readonly _fetch: typeof fetch;
|
||||
|
||||
constructor(private readonly _deps: TelegramHttpClientDeps) {
|
||||
this._fetch = _deps.fetchImpl ?? fetch;
|
||||
}
|
||||
|
||||
async getMe(signal?: AbortSignal): Promise<TelegramUser> {
|
||||
return this._call<TelegramUser>('getMe', undefined, signal);
|
||||
}
|
||||
|
||||
async getUpdates(opts: GetUpdatesOptions): Promise<TelegramUpdate[]> {
|
||||
const body: Record<string, unknown> = {
|
||||
timeout: Math.max(0, Math.min(opts.timeoutSec ?? 25, 50)),
|
||||
};
|
||||
if (typeof opts.offset === 'number') body.offset = opts.offset;
|
||||
return this._call<TelegramUpdate[]>('getUpdates', body, opts.signal);
|
||||
}
|
||||
|
||||
async sendMessage(opts: SendMessageOptions): Promise<TelegramMessage> {
|
||||
const text = truncateForTelegram(opts.text);
|
||||
const body: Record<string, unknown> = {
|
||||
chat_id: opts.chatId,
|
||||
text,
|
||||
disable_web_page_preview: true,
|
||||
};
|
||||
const parseMode = opts.parseMode === undefined ? 'Markdown' : opts.parseMode;
|
||||
if (parseMode) body.parse_mode = parseMode;
|
||||
return this._call<TelegramMessage>('sendMessage', body, opts.signal);
|
||||
}
|
||||
|
||||
private async _call<T>(
|
||||
method: string,
|
||||
body: Record<string, unknown> | undefined,
|
||||
signal?: AbortSignal
|
||||
): Promise<T> {
|
||||
const token = (this._deps.getToken() || '').trim();
|
||||
if (!token) {
|
||||
throw new TelegramClientError('no-token', 'Telegram bot token is not configured.');
|
||||
}
|
||||
|
||||
const url = `https://api.telegram.org/bot${token}/${method}`;
|
||||
let response: Response;
|
||||
try {
|
||||
response = await this._fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
signal,
|
||||
});
|
||||
} catch (e: any) {
|
||||
if (e?.name === 'AbortError' || signal?.aborted) {
|
||||
throw new TelegramClientError('aborted', 'Request aborted.');
|
||||
}
|
||||
const msg = e?.message ?? String(e);
|
||||
logError('Telegram API network error.', { method, error: msg });
|
||||
throw new TelegramClientError('network', `Network error calling ${method}: ${msg}`);
|
||||
}
|
||||
|
||||
let parsed: TelegramApiResponse<T>;
|
||||
try {
|
||||
parsed = (await response.json()) as TelegramApiResponse<T>;
|
||||
} catch (e: any) {
|
||||
throw new TelegramClientError('api', `Telegram API returned non-JSON for ${method}: ${e?.message ?? e}`);
|
||||
}
|
||||
|
||||
if (!parsed.ok) {
|
||||
logError('Telegram API error response.', { method, error_code: parsed.error_code, description: parsed.description });
|
||||
throw new TelegramClientError('api', `${method} failed: ${parsed.description}`, parsed.error_code);
|
||||
}
|
||||
|
||||
logInfo('Telegram API call succeeded.', { method, status: response.status });
|
||||
return parsed.result;
|
||||
}
|
||||
}
|
||||
|
||||
/** Truncate text to Telegram's 4096-char limit, preserving a trailing ellipsis hint. */
|
||||
export function truncateForTelegram(text: string): string {
|
||||
if (typeof text !== 'string') return '';
|
||||
if (text.length <= TELEGRAM_MAX_TEXT_LENGTH) return text;
|
||||
const ellipsis = '\n\n... (truncated)';
|
||||
return text.slice(0, TELEGRAM_MAX_TEXT_LENGTH - ellipsis.length) + ellipsis;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* Subset of the Telegram Bot API types we actually consume.
|
||||
*
|
||||
* Source: https://core.telegram.org/bots/api
|
||||
*
|
||||
* Only fields the bot reads or writes are typed — leaving the rest as `unknown`
|
||||
* keeps the surface narrow and the JSON parsing strict.
|
||||
*/
|
||||
|
||||
export interface TelegramUser {
|
||||
id: number;
|
||||
is_bot: boolean;
|
||||
first_name: string;
|
||||
last_name?: string;
|
||||
username?: string;
|
||||
}
|
||||
|
||||
export interface TelegramChat {
|
||||
id: number;
|
||||
type: 'private' | 'group' | 'supergroup' | 'channel' | string;
|
||||
title?: string;
|
||||
username?: string;
|
||||
first_name?: string;
|
||||
}
|
||||
|
||||
export interface TelegramMessage {
|
||||
message_id: number;
|
||||
date: number;
|
||||
chat: TelegramChat;
|
||||
from?: TelegramUser;
|
||||
text?: string;
|
||||
}
|
||||
|
||||
export interface TelegramUpdate {
|
||||
update_id: number;
|
||||
message?: TelegramMessage;
|
||||
edited_message?: TelegramMessage;
|
||||
}
|
||||
|
||||
export interface TelegramApiSuccess<T> {
|
||||
ok: true;
|
||||
result: T;
|
||||
}
|
||||
|
||||
export interface TelegramApiError {
|
||||
ok: false;
|
||||
error_code: number;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export type TelegramApiResponse<T> = TelegramApiSuccess<T> | TelegramApiError;
|
||||
|
||||
/** Maximum bytes per Telegram message payload (the API caps text at 4096 chars). */
|
||||
export const TELEGRAM_MAX_TEXT_LENGTH = 4096;
|
||||
@@ -0,0 +1,35 @@
|
||||
import { resolveEngine, buildApiUrl, logError, logInfo } from '../utils';
|
||||
|
||||
/**
|
||||
* Discover the model list exposed by the local AI engine at `baseUrl`.
|
||||
*
|
||||
* Same wire format as the sidebar's `_sendModels` (which still owns the
|
||||
* sidebar-specific caching/UI logic) — extracted here so the settings panel
|
||||
* can fetch the same list without depending on the sidebar provider.
|
||||
*
|
||||
* Returns an empty array on any failure (offline engine, parse error, etc.).
|
||||
* Callers should treat the result as a hint, not a hard list.
|
||||
*/
|
||||
export async function discoverModels(baseUrl: string, timeoutMs: number = 5000): Promise<string[]> {
|
||||
const url = (baseUrl || '').trim();
|
||||
if (!url) return [];
|
||||
const engine = resolveEngine(url);
|
||||
const modelsUrl = buildApiUrl(url, engine, 'models');
|
||||
try {
|
||||
const res = await fetch(modelsUrl, { signal: AbortSignal.timeout(timeoutMs) });
|
||||
if (!res.ok) {
|
||||
logInfo('discoverModels: non-OK status', { engine, modelsUrl, status: res.status });
|
||||
return [];
|
||||
}
|
||||
const text = await res.text();
|
||||
if (!text) return [];
|
||||
const data = JSON.parse(text) as any;
|
||||
const list: string[] = engine === 'lmstudio'
|
||||
? (data.data || []).map((m: any) => m.id)
|
||||
: (data.models || []).map((m: any) => m.name);
|
||||
return list.filter((m): m is string => typeof m === 'string' && m.length > 0);
|
||||
} catch (e: any) {
|
||||
logError('discoverModels failed.', { engine, modelsUrl, error: e?.message ?? String(e) });
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import * as vscode from 'vscode';
|
||||
|
||||
/**
|
||||
* Centralized path resolver for ConnectAI.
|
||||
*
|
||||
* Why this module exists:
|
||||
* - Brain / agent-skills / workspace paths are read from many places (utils, sidebar,
|
||||
* bridge, agent). Embedding the same `~`-expansion + abs-path-only check in each
|
||||
* call site makes them drift over time.
|
||||
* - New external integrations (skill-inject, future detached-company mode) need a
|
||||
* single source of truth so they can't accidentally write outside the sandboxed
|
||||
* user folders.
|
||||
*
|
||||
* Conventions:
|
||||
* - All exported functions return absolute, normalized paths (or empty string if
|
||||
* the user has not configured a value AND no fallback exists).
|
||||
* - Relative-path inputs are silently rejected (returned as empty) to avoid
|
||||
* surprising writes inside random workspaces.
|
||||
* - This module never throws and never creates directories — callers ensure
|
||||
* existence on their own (`fs.mkdirSync(..., { recursive: true })`).
|
||||
*/
|
||||
|
||||
/** Expand a leading `~` / `~/` to the user's home directory. Pure function. */
|
||||
export function expandTilde(raw: string): string {
|
||||
const trimmed = (raw || '').trim();
|
||||
if (!trimmed) return '';
|
||||
if (trimmed === '~') return os.homedir();
|
||||
if (trimmed.startsWith('~/')) return path.join(os.homedir(), trimmed.slice(2));
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a user-supplied path string. Returns an empty string for any input
|
||||
* that is empty, blank, or non-absolute after `~` expansion. Relative paths are
|
||||
* intentionally rejected — see module header.
|
||||
*/
|
||||
export function resolvePathInput(raw: string): string {
|
||||
const expanded = expandTilde(raw);
|
||||
if (!expanded) return '';
|
||||
if (!path.isAbsolute(expanded)) return '';
|
||||
return path.normalize(expanded);
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort read of a string config value. Returns empty string when VS Code
|
||||
* config is unavailable (e.g. unit tests not mocking workspace) so callers can
|
||||
* fall through to defaults without try/catch noise.
|
||||
*/
|
||||
function _safeGetConfigString(section: string, key: string): string {
|
||||
try {
|
||||
return vscode.workspace.getConfiguration(section).get<string>(key, '') || '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Active brain directory.
|
||||
*
|
||||
* Resolution order:
|
||||
* 1. VS Code config `g1nation.localBrainPath` (after `~` + abs-path normalization).
|
||||
* 2. The first configured brain profile's `localBrainPath` (handled by callers).
|
||||
* 3. Empty string — caller decides on a default (utils.ts already has the
|
||||
* profile-aware logic; this function is only for the simple-path case).
|
||||
*
|
||||
* Note: this intentionally does NOT consult `g1nation.brainProfiles` — the
|
||||
* profile-aware resolver lives in [src/utils.ts](../utils.ts) (`_getBrainDir`)
|
||||
* and depends on the active-brain selection. Use this function only when you
|
||||
* need a plain folder path without profile semantics (e.g. external HTTP
|
||||
* endpoints injecting into the user's primary brain).
|
||||
*/
|
||||
export function resolveBrainDirFromConfig(): string {
|
||||
const raw = _safeGetConfigString('g1nation', 'localBrainPath');
|
||||
return resolvePathInput(raw);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the agent-skills directory used by `[.agent/skills/*.md]` markdown
|
||||
* skill files (the per-workspace agent skill bank that the sidebar's
|
||||
* `_sendAgentsList` and `_createAgent` operate on).
|
||||
*
|
||||
* Resolution order:
|
||||
* 1. The first VS Code workspace folder + `/.agent/skills/` (creating the
|
||||
* folder is the caller's responsibility).
|
||||
* 2. Empty string when no workspace is open — callers must short-circuit.
|
||||
*
|
||||
* The legacy default `E:\Wiki\Agent\.agent\skills` from sidebarProvider.ts is
|
||||
* preserved as a fall-through hint for the original author's machine.
|
||||
*/
|
||||
export function resolveAgentSkillsDir(): string {
|
||||
const legacy = 'E:\\Wiki\\Agent\\.agent\\skills';
|
||||
try {
|
||||
const fs = require('fs') as typeof import('fs');
|
||||
if (fs.existsSync(legacy)) return legacy;
|
||||
} catch { /* fs unavailable in some isolated tests */ }
|
||||
|
||||
const folders = vscode.workspace.workspaceFolders;
|
||||
if (folders && folders.length > 0) {
|
||||
return path.join(folders[0].uri.fsPath, '.agent', 'skills');
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true iff `child` is the same as `parent` or a descendant of it
|
||||
* (after path normalization). Used to harden file writes against `..` traversal.
|
||||
*
|
||||
* Both paths must be absolute.
|
||||
*/
|
||||
export function isInside(parent: string, child: string): boolean {
|
||||
if (!parent || !child) return false;
|
||||
const p = path.resolve(parent);
|
||||
const c = path.resolve(child);
|
||||
if (c === p) return true;
|
||||
return c.startsWith(p + path.sep);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick the best `ConfigurationTarget` to write a key to: write into whichever
|
||||
* scope already holds the value, falling back to Global.
|
||||
*
|
||||
* Why this matters: VS Code's `getConfiguration().get(key)` resolves through
|
||||
* Folder → Workspace → User → default. If a Workspace value is set and we
|
||||
* blindly write to Global, every subsequent read keeps returning the stale
|
||||
* Workspace value — which is exactly the "sidebar shows e2b but Settings
|
||||
* shows e4b" bug.
|
||||
*
|
||||
* Returns the section's effective inspect record alongside the target so
|
||||
* callers can debug or surface conflicts to the user.
|
||||
*/
|
||||
export function pickConfigTarget(section: string, key: string): {
|
||||
target: vscode.ConfigurationTarget;
|
||||
inspect: ReturnType<vscode.WorkspaceConfiguration['inspect']>;
|
||||
} {
|
||||
const cfg = vscode.workspace.getConfiguration(section);
|
||||
const inspect = cfg.inspect(key);
|
||||
if (inspect?.workspaceFolderValue !== undefined) {
|
||||
return { target: vscode.ConfigurationTarget.WorkspaceFolder, inspect };
|
||||
}
|
||||
if (inspect?.workspaceValue !== undefined) {
|
||||
return { target: vscode.ConfigurationTarget.Workspace, inspect };
|
||||
}
|
||||
return { target: vscode.ConfigurationTarget.Global, inspect };
|
||||
}
|
||||
+33
-1
@@ -1,10 +1,14 @@
|
||||
import { LMStudioClient as SDKClient } from '@lmstudio/sdk';
|
||||
import { LMStudioClient as SDKClient, LLM } from '@lmstudio/sdk';
|
||||
import { logError, logInfo } from '../utils';
|
||||
|
||||
export interface ILMStudioClient {
|
||||
load(modelKey: string, signal?: AbortSignal): Promise<void>;
|
||||
unload(modelKey: string): Promise<void>;
|
||||
listLoaded(): Promise<string[]>;
|
||||
/** Like listLoaded() but caches the result for `ttlMs` to avoid hammering the SDK. */
|
||||
listLoadedCached(ttlMs?: number): Promise<string[]>;
|
||||
/** Resolve a chat-ready handle for an already-loaded (or just-loaded) model. */
|
||||
getModelHandle(modelKey: string): Promise<LLM>;
|
||||
isReachable(): Promise<boolean>;
|
||||
setBaseUrl(httpBaseUrl: string): void;
|
||||
}
|
||||
@@ -36,6 +40,8 @@ export function httpToWebSocketUrl(httpBaseUrl: string): string | undefined {
|
||||
export class LMStudioClient implements ILMStudioClient {
|
||||
private _sdk: SDKClient | undefined;
|
||||
private _wsUrl: string | undefined;
|
||||
private _loadedCache: { value: string[]; expiresAt: number } | undefined;
|
||||
private static readonly DEFAULT_LOADED_CACHE_TTL_MS = 5000;
|
||||
|
||||
constructor(httpBaseUrl: string) {
|
||||
this.setBaseUrl(httpBaseUrl);
|
||||
@@ -46,6 +52,7 @@ export class LMStudioClient implements ILMStudioClient {
|
||||
if (ws !== this._wsUrl) {
|
||||
this._wsUrl = ws;
|
||||
this._sdk = undefined;
|
||||
this._loadedCache = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,6 +66,7 @@ export class LMStudioClient implements ILMStudioClient {
|
||||
async load(modelKey: string, signal?: AbortSignal): Promise<void> {
|
||||
try {
|
||||
await this.getSdk().llm.load(modelKey, signal ? { signal } : undefined);
|
||||
this._loadedCache = undefined;
|
||||
logInfo('LM Studio model loaded.', { modelKey });
|
||||
} catch (e: any) {
|
||||
const msg = e?.message ?? String(e);
|
||||
@@ -69,6 +77,7 @@ export class LMStudioClient implements ILMStudioClient {
|
||||
async unload(modelKey: string): Promise<void> {
|
||||
try {
|
||||
await this.getSdk().llm.unload(modelKey);
|
||||
this._loadedCache = undefined;
|
||||
logInfo('LM Studio model unloaded.', { modelKey });
|
||||
} catch (e: any) {
|
||||
const msg = e?.message ?? String(e);
|
||||
@@ -88,6 +97,29 @@ export class LMStudioClient implements ILMStudioClient {
|
||||
}
|
||||
}
|
||||
|
||||
async listLoadedCached(ttlMs: number = LMStudioClient.DEFAULT_LOADED_CACHE_TTL_MS): Promise<string[]> {
|
||||
const now = Date.now();
|
||||
if (this._loadedCache && this._loadedCache.expiresAt > now) {
|
||||
return this._loadedCache.value.slice();
|
||||
}
|
||||
try {
|
||||
const value = await this.listLoaded();
|
||||
this._loadedCache = { value, expiresAt: now + ttlMs };
|
||||
return value.slice();
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async getModelHandle(modelKey: string): Promise<LLM> {
|
||||
try {
|
||||
return await this.getSdk().llm.model(modelKey);
|
||||
} catch (e: any) {
|
||||
const msg = e?.message ?? String(e);
|
||||
throw new LMStudioLifecycleError(`Failed to acquire LM Studio model handle "${modelKey}": ${msg}`, e);
|
||||
}
|
||||
}
|
||||
|
||||
async isReachable(): Promise<boolean> {
|
||||
try {
|
||||
await this.getSdk().llm.listLoaded();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { ILMStudioClient } from './client';
|
||||
import type { IActivityTracker } from './activityTracker';
|
||||
import type { EngineKind } from '../utils';
|
||||
import type { ISystemSpecsProvider, IModelMemoryEstimator } from '../system/specs';
|
||||
import { logError, logInfo } from '../utils';
|
||||
|
||||
export type LifecycleState = 'idle' | 'loading' | 'loaded' | 'streaming' | 'unloading';
|
||||
@@ -19,6 +20,15 @@ export interface LifecycleManagerDeps {
|
||||
switchDebounceMs?: number;
|
||||
/** Initial engine. Default 'lmstudio'. */
|
||||
initialEngine?: EngineKind;
|
||||
/**
|
||||
* Optional pre-load memory budget check. When both are provided, a warn-only
|
||||
* advisory is emitted via `notifyError` (and a structured log line) before
|
||||
* attempting to load a model that the heuristic predicts will not fit.
|
||||
* The load is **not** blocked — the user may have a quantization the
|
||||
* estimator does not recognize.
|
||||
*/
|
||||
systemSpecs?: ISystemSpecsProvider;
|
||||
memoryEstimator?: IModelMemoryEstimator;
|
||||
}
|
||||
|
||||
export class ModelLifecycleManager {
|
||||
@@ -207,6 +217,38 @@ export class ModelLifecycleManager {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Warn-only RAM budget check. If the heuristic estimator says the model is
|
||||
* unlikely to fit, surface a non-blocking advisory and log it. The load
|
||||
* still proceeds — the heuristic can be wrong (unrecognized quantization,
|
||||
* sparse / MoE models) and the user may have explicit intent.
|
||||
*/
|
||||
private checkMemoryBudget(modelKey: string): void {
|
||||
const specsProvider = this.deps.systemSpecs;
|
||||
const estimator = this.deps.memoryEstimator;
|
||||
if (!specsProvider || !estimator) return;
|
||||
try {
|
||||
const specs = specsProvider.get();
|
||||
const requiredGB = estimator.estimate(modelKey);
|
||||
if (requiredGB > specs.safeModelBudgetGB) {
|
||||
const msg =
|
||||
`Model "${modelKey}" estimated at ~${requiredGB.toFixed(1)}GB ` +
|
||||
`exceeds your safe RAM budget of ${specs.safeModelBudgetGB}GB. ` +
|
||||
`If load fails, try a smaller quantization (q4 / q5).`;
|
||||
logInfo('LM Studio pre-load memory advisory.', {
|
||||
model: modelKey,
|
||||
requiredGB: Number(requiredGB.toFixed(2)),
|
||||
budgetGB: specs.safeModelBudgetGB,
|
||||
totalRamGB: Number(specs.totalRamGB.toFixed(2)),
|
||||
});
|
||||
this.deps.notifyError?.(msg);
|
||||
}
|
||||
} catch (e: any) {
|
||||
// Diagnostic-only; never block a load on advisory failures.
|
||||
logError('Memory budget check failed.', { error: e?.message ?? String(e) });
|
||||
}
|
||||
}
|
||||
|
||||
private async doSwitch(modelKey: string): Promise<void> {
|
||||
if (this.disposed) return;
|
||||
if (this.engine !== 'lmstudio') return;
|
||||
@@ -225,6 +267,8 @@ export class ModelLifecycleManager {
|
||||
this.currentModel = null;
|
||||
}
|
||||
|
||||
this.checkMemoryBudget(modelKey);
|
||||
|
||||
this.state = 'loading';
|
||||
this.currentModel = modelKey;
|
||||
const ac = new AbortController();
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { ILMStudioClient } from './client';
|
||||
import { LMStudioLifecycleError } from './client';
|
||||
import { logError, logInfo } from '../utils';
|
||||
|
||||
export interface ChatStreamMessage {
|
||||
role: 'user' | 'assistant' | 'system';
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface ChatStreamRequest {
|
||||
modelName: string;
|
||||
messages: ChatStreamMessage[];
|
||||
temperature: number;
|
||||
maxTokens?: number;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
export interface IChatStreamer {
|
||||
/** Token-level streaming for an LM Studio chat completion via the WebSocket SDK. */
|
||||
stream(req: ChatStreamRequest): AsyncIterable<{ token: string }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adapter that streams LM Studio chat completions via @lmstudio/sdk's `model.respond()`,
|
||||
* replacing the manual fetch + SSE parser path used for the OpenAI-compatible REST endpoint.
|
||||
*
|
||||
* Benefits over the REST path:
|
||||
* - No SSE parsing (no `data: [DONE]` / partial-chunk fragility).
|
||||
* - Reuses the same WebSocket the lifecycle manager already opened — handle lookup is cheap
|
||||
* if the model is already loaded, and load-on-first-use is implicit when it isn't.
|
||||
* - First-class `signal` support for user-cancel and abort propagation.
|
||||
*/
|
||||
export class LMStudioStreamer implements IChatStreamer {
|
||||
constructor(private readonly client: ILMStudioClient) {}
|
||||
|
||||
async *stream(req: ChatStreamRequest): AsyncIterable<{ token: string }> {
|
||||
const trimmedModel = (req.modelName || '').trim();
|
||||
if (!trimmedModel) {
|
||||
throw new LMStudioLifecycleError('LMStudioStreamer.stream called without a model name.');
|
||||
}
|
||||
|
||||
const model = await this.client.getModelHandle(trimmedModel);
|
||||
logInfo('LM Studio SDK chat stream started.', { model: trimmedModel, messageCount: req.messages.length });
|
||||
|
||||
const prediction = (model as any).respond(req.messages, {
|
||||
temperature: req.temperature,
|
||||
maxTokens: req.maxTokens ?? 4096,
|
||||
signal: req.signal,
|
||||
});
|
||||
|
||||
try {
|
||||
for await (const fragment of prediction as AsyncIterable<{ content: string }>) {
|
||||
if (req.signal?.aborted) return;
|
||||
const token = fragment?.content ?? '';
|
||||
if (token) yield { token };
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (req.signal?.aborted) return;
|
||||
if (err?.name === 'AbortError') return;
|
||||
logError('LM Studio SDK chat stream failed.', { model: trimmedModel, error: err?.message ?? String(err) });
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { findTemplate, ProjectTemplate, TEMPLATES, ProjectTemplateId } from './templates';
|
||||
import { isInside } from '../lib/paths';
|
||||
import { logError, logInfo } from '../utils';
|
||||
|
||||
/**
|
||||
* Project scaffolder.
|
||||
*
|
||||
* Mirrors Connect_origin's Developer-agent quick-start (3 templates: static /
|
||||
* vite-vanilla / vite-react), refactored as:
|
||||
* - templates as data (see templates.ts) so adding new ones is a one-file change,
|
||||
* - service interface + filesystem implementation for testability,
|
||||
* - validation in the service (not the command handler) so callers can't bypass.
|
||||
*/
|
||||
|
||||
export interface ScaffoldRequest {
|
||||
/** User-supplied project name. Will be re-validated; service rejects bad input. */
|
||||
name: string;
|
||||
template: ProjectTemplateId;
|
||||
/** Absolute parent directory. The project will land at `<rootDir>/<name>/`. */
|
||||
rootDir: string;
|
||||
}
|
||||
|
||||
export type ScaffoldResult =
|
||||
| { ok: true; projectPath: string; filesWritten: string[] }
|
||||
| { ok: false; error: string; code: ScaffoldErrorCode };
|
||||
|
||||
export type ScaffoldErrorCode =
|
||||
| 'INVALID_NAME'
|
||||
| 'NO_ROOT_DIR'
|
||||
| 'ROOT_NOT_ABSOLUTE'
|
||||
| 'ALREADY_EXISTS'
|
||||
| 'UNKNOWN_TEMPLATE'
|
||||
| 'WRITE_FAILED';
|
||||
|
||||
export interface IProjectScaffolder {
|
||||
scaffold(req: ScaffoldRequest): Promise<ScaffoldResult>;
|
||||
listTemplates(): ProjectTemplate[];
|
||||
}
|
||||
|
||||
const NAME_RE = /^[a-zA-Z0-9_-]{2,40}$/;
|
||||
|
||||
/** Conservative project-name validator. Same constraints as Connect_origin's command UI. */
|
||||
export function validateProjectName(raw: string): string | null {
|
||||
if (typeof raw !== 'string') return null;
|
||||
const trimmed = raw.trim();
|
||||
return NAME_RE.test(trimmed) ? trimmed : null;
|
||||
}
|
||||
|
||||
export interface ScaffolderDeps {
|
||||
/** Optional fs override for tests. Defaults to node:fs. */
|
||||
fsImpl?: Pick<typeof fs, 'existsSync' | 'mkdirSync' | 'writeFileSync'>;
|
||||
}
|
||||
|
||||
export class FileSystemProjectScaffolder implements IProjectScaffolder {
|
||||
private readonly _fs: NonNullable<ScaffolderDeps['fsImpl']>;
|
||||
|
||||
constructor(deps: ScaffolderDeps = {}) {
|
||||
this._fs = deps.fsImpl ?? fs;
|
||||
}
|
||||
|
||||
listTemplates(): ProjectTemplate[] {
|
||||
return TEMPLATES.slice();
|
||||
}
|
||||
|
||||
async scaffold(req: ScaffoldRequest): Promise<ScaffoldResult> {
|
||||
const name = validateProjectName(req.name);
|
||||
if (!name) {
|
||||
return { ok: false, error: '프로젝트 이름은 영문/숫자/_/- 만 허용되며 2~40자여야 합니다.', code: 'INVALID_NAME' };
|
||||
}
|
||||
if (!req.rootDir) {
|
||||
return { ok: false, error: '대상 폴더가 지정되지 않았습니다. 워크스페이스를 먼저 여세요.', code: 'NO_ROOT_DIR' };
|
||||
}
|
||||
if (!path.isAbsolute(req.rootDir)) {
|
||||
return { ok: false, error: '대상 폴더는 절대 경로여야 합니다.', code: 'ROOT_NOT_ABSOLUTE' };
|
||||
}
|
||||
const template = findTemplate(req.template);
|
||||
if (!template) {
|
||||
return { ok: false, error: `알 수 없는 템플릿: ${req.template}`, code: 'UNKNOWN_TEMPLATE' };
|
||||
}
|
||||
|
||||
const projectPath = path.join(req.rootDir, name);
|
||||
if (this._fs.existsSync(projectPath)) {
|
||||
return { ok: false, error: `이미 존재하는 폴더입니다: ${projectPath}`, code: 'ALREADY_EXISTS' };
|
||||
}
|
||||
|
||||
const fileMap = template.files(name);
|
||||
const filesWritten: string[] = [];
|
||||
try {
|
||||
this._fs.mkdirSync(projectPath, { recursive: true });
|
||||
for (const [rel, contents] of Object.entries(fileMap)) {
|
||||
const target = path.join(projectPath, rel);
|
||||
if (!isInside(projectPath, target)) {
|
||||
// Defense in depth — a template author can't smuggle "../" into a path.
|
||||
throw new Error(`Template path escapes project root: ${rel}`);
|
||||
}
|
||||
this._fs.mkdirSync(path.dirname(target), { recursive: true });
|
||||
this._fs.writeFileSync(target, contents, 'utf8');
|
||||
filesWritten.push(target);
|
||||
}
|
||||
} catch (e: any) {
|
||||
const msg = e?.message ?? String(e);
|
||||
logError('Project scaffold failed.', { name, template: template.id, error: msg });
|
||||
return { ok: false, error: `생성 실패: ${msg}`, code: 'WRITE_FAILED' };
|
||||
}
|
||||
|
||||
logInfo('Project scaffolded.', { name, template: template.id, projectPath, fileCount: filesWritten.length });
|
||||
return { ok: true, projectPath, filesWritten };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* Scaffolder template catalog.
|
||||
*
|
||||
* Templates are pure data — `(projectName) => { [relativePath]: contents }`. New
|
||||
* templates are added by appending to `TEMPLATES`; the rest of the scaffolder
|
||||
* (validation, IO, command UX) does not need to change.
|
||||
*
|
||||
* This intentionally mirrors the static/vite-vanilla/vite-react options that
|
||||
* Connect_origin's Developer agent ships, but split out of the giant inline
|
||||
* function so each template body is grep-friendly.
|
||||
*/
|
||||
|
||||
export type ProjectTemplateId = 'static' | 'vite-vanilla' | 'vite-react';
|
||||
|
||||
export interface ProjectTemplate {
|
||||
id: ProjectTemplateId;
|
||||
label: string;
|
||||
detail: string;
|
||||
/** Returns map of `relativePath -> fileContents`. Project name is already sanitized. */
|
||||
files(name: string): Record<string, string>;
|
||||
}
|
||||
|
||||
const README = (name: string, template: string) =>
|
||||
`# ${name}\n\n` +
|
||||
`Astra의 Project Scaffolder가 ${new Date().toISOString().slice(0, 10)}에 \`${template}\` 템플릿으로 생성한 프로젝트입니다.\n`;
|
||||
|
||||
export const TEMPLATES: ProjectTemplate[] = [
|
||||
{
|
||||
id: 'static',
|
||||
label: 'static',
|
||||
detail: 'index.html 한 장 (Tailwind CDN)',
|
||||
files: (name) => ({
|
||||
'site/index.html':
|
||||
`<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>${name}</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
</head>
|
||||
<body class="bg-zinc-950 text-zinc-100 min-h-screen flex items-center justify-center">
|
||||
<main class="text-center space-y-4">
|
||||
<h1 class="text-4xl font-bold">${name}</h1>
|
||||
<p class="text-zinc-400">Astra · Project Scaffolder</p>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
`,
|
||||
'README.md': README(name, 'static'),
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: 'vite-vanilla',
|
||||
label: 'vite-vanilla',
|
||||
detail: 'Vite + 순수 JS',
|
||||
files: (name) => ({
|
||||
'site/package.json': JSON.stringify({
|
||||
name,
|
||||
private: true,
|
||||
type: 'module',
|
||||
scripts: { dev: 'vite', build: 'vite build', preview: 'vite preview' },
|
||||
devDependencies: { vite: '^5.0.0' },
|
||||
}, null, 2) + '\n',
|
||||
'site/index.html':
|
||||
`<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>${name}</title>
|
||||
</head>
|
||||
<body>
|
||||
<h1>${name}</h1>
|
||||
<script type="module" src="/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
`,
|
||||
'site/main.js':
|
||||
`document.querySelector('h1').addEventListener('click', () => {
|
||||
console.log('hi from ${name}');
|
||||
});
|
||||
`,
|
||||
'README.md': README(name, 'vite-vanilla'),
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: 'vite-react',
|
||||
label: 'vite-react',
|
||||
detail: 'Vite + React + TypeScript',
|
||||
files: (name) => ({
|
||||
'site/package.json': JSON.stringify({
|
||||
name,
|
||||
private: true,
|
||||
type: 'module',
|
||||
scripts: { dev: 'vite', build: 'tsc && vite build', preview: 'vite preview' },
|
||||
dependencies: { react: '^18.3.0', 'react-dom': '^18.3.0' },
|
||||
devDependencies: {
|
||||
'@types/react': '^18.3.0',
|
||||
'@types/react-dom': '^18.3.0',
|
||||
'@vitejs/plugin-react': '^4.3.0',
|
||||
typescript: '^5.4.0',
|
||||
vite: '^5.0.0',
|
||||
},
|
||||
}, null, 2) + '\n',
|
||||
'site/tsconfig.json': JSON.stringify({
|
||||
compilerOptions: {
|
||||
target: 'ES2020',
|
||||
useDefineForClassFields: true,
|
||||
lib: ['ES2020', 'DOM', 'DOM.Iterable'],
|
||||
module: 'ESNext',
|
||||
skipLibCheck: true,
|
||||
moduleResolution: 'bundler',
|
||||
allowImportingTsExtensions: true,
|
||||
resolveJsonModule: true,
|
||||
isolatedModules: true,
|
||||
noEmit: true,
|
||||
jsx: 'react-jsx',
|
||||
strict: true,
|
||||
},
|
||||
include: ['src'],
|
||||
}, null, 2) + '\n',
|
||||
'site/vite.config.ts':
|
||||
`import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
export default defineConfig({ plugins: [react()] });
|
||||
`,
|
||||
'site/index.html':
|
||||
`<!doctype html>
|
||||
<html lang="ko">
|
||||
<head><meta charset="utf-8"><title>${name}</title></head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
`,
|
||||
'site/src/main.tsx':
|
||||
`import React from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
|
||||
function App() {
|
||||
return <h1>${name}</h1>;
|
||||
}
|
||||
|
||||
createRoot(document.getElementById('root')!).render(<App />);
|
||||
`,
|
||||
'README.md': README(name, 'vite-react'),
|
||||
}),
|
||||
},
|
||||
];
|
||||
|
||||
export function findTemplate(id: string): ProjectTemplate | undefined {
|
||||
return TEMPLATES.find(t => t.id === id);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import * as vscode from 'vscode';
|
||||
import * as path from 'path';
|
||||
import { SidebarChatProvider } from '../sidebarProvider';
|
||||
import { getActiveBrainProfile, logInfo } from '../utils';
|
||||
import { pickConfigTarget } from '../lib/paths';
|
||||
|
||||
/**
|
||||
* Handles chat-domain messages: prompts, model selection, sessions, streaming control,
|
||||
@@ -58,7 +59,20 @@ export async function handleChatMessage(provider: SidebarChatProvider, data: any
|
||||
await provider._deleteSession(data.id);
|
||||
return true;
|
||||
case 'openSettings':
|
||||
vscode.commands.executeCommand('workbench.action.openSettings', 'g1nation');
|
||||
// Route the sidebar gear button to Astra's own settings webview.
|
||||
// Falls back to VS Code Settings if the view hasn't registered yet
|
||||
// (e.g. during the very first activation pass) and surfaces any
|
||||
// unexpected error so the user isn't stuck with a silent button.
|
||||
try {
|
||||
await vscode.commands.executeCommand('g1nation.settings.focus');
|
||||
} catch (e: any) {
|
||||
logInfo('openSettings: settings.focus failed, falling back to VS Code Settings.', { error: e?.message ?? String(e) });
|
||||
try {
|
||||
await vscode.commands.executeCommand('workbench.action.openSettings', 'g1nation');
|
||||
} catch (e2: any) {
|
||||
vscode.window.showErrorMessage(`Astra Settings 열기 실패: ${e2?.message ?? e2}`);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
case 'addMessage':
|
||||
provider._view?.webview.postMessage({ type: 'addMessage', role: data.role, value: data.value, rationale: data.rationale });
|
||||
@@ -66,11 +80,16 @@ export async function handleChatMessage(provider: SidebarChatProvider, data: any
|
||||
case 'refreshModels':
|
||||
await provider._sendModels(true);
|
||||
return true;
|
||||
case 'model':
|
||||
await vscode.workspace.getConfiguration('g1nation').update('defaultModel', data.value, vscode.ConfigurationTarget.Global);
|
||||
logInfo(`Default model updated to: ${data.value}`);
|
||||
case 'model': {
|
||||
// Write to whichever scope already holds the value so a stale
|
||||
// Workspace override doesn't shadow our Global update — that was
|
||||
// the "sidebar shows e2b but Settings shows e4b" desync.
|
||||
const { target } = pickConfigTarget('g1nation', 'defaultModel');
|
||||
await vscode.workspace.getConfiguration('g1nation').update('defaultModel', data.value, target);
|
||||
logInfo(`Default model updated to: ${data.value}`, { target });
|
||||
provider._lmStudio?.lifecycle.onModelSelected(data.value);
|
||||
return true;
|
||||
}
|
||||
case 'proactiveTrigger':
|
||||
await provider._handleProactiveSuggestion(data.context);
|
||||
return true;
|
||||
|
||||
+12
-1
@@ -26,6 +26,8 @@ import { handleAgentMessage } from './sidebar/agentHandlers';
|
||||
export interface SidebarLmStudioDeps {
|
||||
lifecycle: ModelLifecycleManager;
|
||||
activity: IActivityTracker;
|
||||
/** Returns the list of model identifiers currently loaded in LM Studio (cached). */
|
||||
loadedModels: () => Promise<string[]>;
|
||||
}
|
||||
|
||||
interface LastVisibleChatSnapshot {
|
||||
@@ -1899,7 +1901,16 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
|
||||
models.unshift(defaultModel);
|
||||
}
|
||||
|
||||
this._view.webview.postMessage({ type: 'modelsList', value: { models, selected: defaultModel } });
|
||||
let loadedModels: string[] = [];
|
||||
if (resolveEngine(url) === 'lmstudio' && this._lmStudio) {
|
||||
try {
|
||||
loadedModels = await this._lmStudio.loadedModels();
|
||||
} catch (e) {
|
||||
logInfo('LM Studio loadedModels probe failed (non-fatal).', { error: (e as any)?.message ?? String(e) });
|
||||
}
|
||||
}
|
||||
|
||||
this._view.webview.postMessage({ type: 'modelsList', value: { models, selected: defaultModel, loadedModels } });
|
||||
} catch (err) {
|
||||
logError('Model list update failed.', err);
|
||||
} finally {
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { isInside } from '../lib/paths';
|
||||
import { logError, logInfo } from '../utils';
|
||||
|
||||
/**
|
||||
* External-tool skill injection.
|
||||
*
|
||||
* Connect_origin's `/api/skill-inject` writes Python tool scripts into a
|
||||
* per-agent `tools/` folder, on the assumption the agent can `<run_command>`
|
||||
* them. ConnectAI doesn't run Python tools — its agent skills are markdown
|
||||
* documents loaded by the sidebar (`<workspace>/.agent/skills/<name>.md`).
|
||||
*
|
||||
* So this service injects **markdown skills** (the ConnectAI primitive), not
|
||||
* .py scripts. The endpoint shape stays similar (name / displayName /
|
||||
* description / content / source) so the same external integrations
|
||||
* (EZER / Agent University) can target either project with a thin adapter.
|
||||
*
|
||||
* What lands on disk per inject call:
|
||||
* .agent/skills/<safeName>.md — markdown body
|
||||
* .agent/skills/<safeName>.meta.json — injectedAt + injectedFrom + display fields
|
||||
*
|
||||
* The `.md` is what the existing sidebar `_sendAgentsList` already discovers.
|
||||
* The sidecar `.meta.json` is read by future UI surfaces (provenance badges)
|
||||
* but is invisible to the legacy loader, so back-compat is preserved.
|
||||
*/
|
||||
|
||||
export interface SkillInjectionRequest {
|
||||
/** Required. Slug-style identifier; will be sanitized to filesystem-safe form. */
|
||||
name: string;
|
||||
/** Required. The markdown body of the skill (system-prompt content). */
|
||||
content: string;
|
||||
/** Optional. Human-friendly display name. Falls back to sanitized `name`. */
|
||||
displayName?: string;
|
||||
/** Optional. One-line description shown in UI hints. */
|
||||
description?: string;
|
||||
/** Optional. External-source tag, e.g. `"ezer"` / `"agent-university"`. */
|
||||
source?: string;
|
||||
}
|
||||
|
||||
export interface SkillInjectionResult {
|
||||
/** Sanitized name actually used on disk. */
|
||||
safeName: string;
|
||||
/** Absolute path to the written markdown file. */
|
||||
filePath: string;
|
||||
/** Absolute path to the sidecar metadata file. */
|
||||
metaPath: string;
|
||||
}
|
||||
|
||||
export class SkillInjectionError extends Error {
|
||||
constructor(message: string, public readonly code: string) {
|
||||
super(message);
|
||||
this.name = 'SkillInjectionError';
|
||||
}
|
||||
}
|
||||
|
||||
export interface ISkillInjectionService {
|
||||
inject(req: SkillInjectionRequest): Promise<SkillInjectionResult>;
|
||||
}
|
||||
|
||||
export interface SkillInjectionDeps {
|
||||
/** Resolves the skills directory at the time of each call (must be absolute). */
|
||||
resolveSkillsDir: () => string;
|
||||
/** Optional fs override for unit tests; defaults to node:fs. */
|
||||
fsImpl?: Pick<typeof fs, 'existsSync' | 'mkdirSync' | 'writeFileSync'>;
|
||||
/** Optional notification hook called on successful injection. */
|
||||
onInjected?: (result: SkillInjectionResult, req: SkillInjectionRequest) => void;
|
||||
}
|
||||
|
||||
/** Conservative skill-name sanitizer. Rejects empty / overlong / shell-unfriendly inputs. */
|
||||
export function sanitizeSkillName(raw: string): string {
|
||||
if (typeof raw !== 'string') return '';
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return '';
|
||||
// Strip extensions if caller passed "foo.md" by mistake
|
||||
const noExt = trimmed.replace(/\.(md|markdown)$/i, '');
|
||||
// Allow only ASCII alphanumerics, `_`, `-`, `.` — block path separators,
|
||||
// shell metas, and unicode that could confuse filesystems.
|
||||
const safe = noExt.replace(/[^a-zA-Z0-9_.\-]/g, '_').replace(/^[._]+|[._]+$/g, '');
|
||||
return safe.slice(0, 80);
|
||||
}
|
||||
|
||||
export class FileSystemSkillInjectionService implements ISkillInjectionService {
|
||||
private readonly _fs: NonNullable<SkillInjectionDeps['fsImpl']>;
|
||||
|
||||
constructor(private readonly deps: SkillInjectionDeps) {
|
||||
this._fs = deps.fsImpl ?? fs;
|
||||
}
|
||||
|
||||
async inject(req: SkillInjectionRequest): Promise<SkillInjectionResult> {
|
||||
const safeName = sanitizeSkillName(req.name);
|
||||
if (!safeName) {
|
||||
throw new SkillInjectionError(
|
||||
'Skill name is empty after sanitization. Use ASCII letters, digits, "_", "-", or "." only.',
|
||||
'INVALID_NAME'
|
||||
);
|
||||
}
|
||||
if (typeof req.content !== 'string' || !req.content.trim()) {
|
||||
throw new SkillInjectionError('Skill content must be a non-empty markdown string.', 'EMPTY_CONTENT');
|
||||
}
|
||||
|
||||
const skillsDir = this.deps.resolveSkillsDir();
|
||||
if (!skillsDir) {
|
||||
throw new SkillInjectionError(
|
||||
'Agent skills directory is not available. Open a workspace folder first.',
|
||||
'NO_SKILLS_DIR'
|
||||
);
|
||||
}
|
||||
|
||||
const targetMd = path.join(skillsDir, `${safeName}.md`);
|
||||
const targetMeta = path.join(skillsDir, `${safeName}.meta.json`);
|
||||
|
||||
if (!isInside(skillsDir, targetMd) || !isInside(skillsDir, targetMeta)) {
|
||||
// Defense in depth: sanitizer should already block traversal, but the
|
||||
// path math runs again here in case skillsDir resolves to something
|
||||
// surprising (symlink, weird casing on Windows, etc.).
|
||||
throw new SkillInjectionError('Refusing to write outside the skills directory.', 'PATH_ESCAPE');
|
||||
}
|
||||
|
||||
try {
|
||||
if (!this._fs.existsSync(skillsDir)) {
|
||||
this._fs.mkdirSync(skillsDir, { recursive: true });
|
||||
}
|
||||
this._fs.writeFileSync(targetMd, req.content, 'utf8');
|
||||
|
||||
const meta = {
|
||||
name: safeName,
|
||||
displayName: (req.displayName || '').trim() || safeName,
|
||||
description: (req.description || '').trim() || '',
|
||||
injectedAt: new Date().toISOString(),
|
||||
injectedFrom: (req.source || '').trim() || 'external',
|
||||
};
|
||||
this._fs.writeFileSync(targetMeta, JSON.stringify(meta, null, 2), 'utf8');
|
||||
} catch (e: any) {
|
||||
const msg = e?.message ?? String(e);
|
||||
logError('Skill injection write failed.', { safeName, skillsDir, error: msg });
|
||||
throw new SkillInjectionError(`Failed to write skill files: ${msg}`, 'WRITE_FAILED');
|
||||
}
|
||||
|
||||
const result: SkillInjectionResult = { safeName, filePath: targetMd, metaPath: targetMeta };
|
||||
logInfo('Skill injected.', { safeName, source: req.source, skillsDir });
|
||||
this.deps.onInjected?.(result, req);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import * as os from 'os';
|
||||
|
||||
/**
|
||||
* System hardware specs surfaced to the rest of the app.
|
||||
*
|
||||
* All sizes are in **GiB** (1024^3 bytes). `safeModelBudgetGB` is a conservative
|
||||
* estimate of how much RAM is actually available for an LLM after the OS, the
|
||||
* editor, and the user's other apps take their cut — meant to be compared
|
||||
* against `IModelMemoryEstimator.estimate()` to warn the user before LM Studio
|
||||
* crashes on out-of-memory.
|
||||
*/
|
||||
export interface SystemSpecs {
|
||||
totalRamGB: number;
|
||||
freeRamGB: number;
|
||||
cpuModel: string;
|
||||
cpuCount: number;
|
||||
platform: NodeJS.Platform;
|
||||
arch: string;
|
||||
isAppleSilicon: boolean;
|
||||
safeModelBudgetGB: number;
|
||||
/** Human-readable one-line summary, useful for logs and toasts. */
|
||||
summary: string;
|
||||
}
|
||||
|
||||
export interface ISystemSpecsProvider {
|
||||
/** Returns the current system specs. Implementations should cache. */
|
||||
get(): SystemSpecs;
|
||||
}
|
||||
|
||||
export interface IModelMemoryEstimator {
|
||||
/**
|
||||
* Best-effort estimate of how many GB a given model identifier will need
|
||||
* to load. Returns a positive number; the caller decides what to do with it.
|
||||
*/
|
||||
estimate(modelId: string): number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Production system-specs provider. Reads `os.totalmem()`, `os.cpus()`, etc.
|
||||
* and caches the result for the process lifetime — physical RAM does not
|
||||
* change at runtime.
|
||||
*
|
||||
* The Apple Silicon ratio (0.65) is more generous than other platforms (0.5)
|
||||
* because of unified memory: GPU inference shares the same pool, so a higher
|
||||
* fraction is realistically usable for the model.
|
||||
*/
|
||||
export class NodeSystemSpecsProvider implements ISystemSpecsProvider {
|
||||
private _cache: SystemSpecs | undefined;
|
||||
|
||||
get(): SystemSpecs {
|
||||
if (this._cache) return this._cache;
|
||||
|
||||
const totalRamGB = os.totalmem() / (1024 ** 3);
|
||||
const freeRamGB = os.freemem() / (1024 ** 3);
|
||||
const cpus = os.cpus() || [];
|
||||
const cpuModel = (cpus[0]?.model || 'unknown').replace(/\s+/g, ' ').trim();
|
||||
const platform = os.platform();
|
||||
const arch = os.arch();
|
||||
const isAppleSilicon =
|
||||
platform === 'darwin' && arch === 'arm64' && /Apple\s+M/i.test(cpuModel);
|
||||
|
||||
const ratio = isAppleSilicon ? 0.65 : 0.5;
|
||||
const safeModelBudgetGB = Math.max(2, Math.floor(totalRamGB * ratio));
|
||||
|
||||
const platformLabel = platform === 'darwin' ? 'macOS' : platform;
|
||||
const siliconLabel = isAppleSilicon ? ' (Apple Silicon)' : '';
|
||||
const summary =
|
||||
`${platformLabel} · ${arch}${siliconLabel} · RAM ${totalRamGB.toFixed(0)}GB ` +
|
||||
`· CPU ${cpuModel.slice(0, 40)} (${cpus.length} cores)`;
|
||||
|
||||
this._cache = {
|
||||
totalRamGB,
|
||||
freeRamGB,
|
||||
cpuModel,
|
||||
cpuCount: cpus.length,
|
||||
platform,
|
||||
arch,
|
||||
isAppleSilicon,
|
||||
safeModelBudgetGB,
|
||||
summary,
|
||||
};
|
||||
return this._cache;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Heuristic estimator that derives memory cost from common patterns in model
|
||||
* identifiers (e.g. `gemma-2-9b-q4_K_M`).
|
||||
*
|
||||
* Approach:
|
||||
* 1. Pull the parameter count by matching `\d+B`. Default 7B if absent.
|
||||
* 2. Multiply by a per-param byte cost determined by quantization:
|
||||
* - q4 / 4-bit: 0.6 GB/B (default)
|
||||
* - q5 / 5-bit: 0.7
|
||||
* - q6 / 6-bit: 0.8
|
||||
* - q8 / 8-bit / fp8: 1.0
|
||||
* - fp16 / bf16: 2.0
|
||||
* 3. Add a 1 GB overhead for KV cache + runtime.
|
||||
*
|
||||
* This is a rough estimate, but consistently within ±20% of LM Studio's actual
|
||||
* load footprint on the popular GGUF families — good enough to gate "your 16 GB
|
||||
* machine probably can't load a 70B fp16" before LM Studio crashes.
|
||||
*/
|
||||
export class HeuristicModelMemoryEstimator implements IModelMemoryEstimator {
|
||||
estimate(modelId: string): number {
|
||||
const id = (modelId || '').toLowerCase();
|
||||
const paramMatch = id.match(/(\d+(?:\.\d+)?)\s*b\b/);
|
||||
const paramCountB = paramMatch ? parseFloat(paramMatch[1]) : 7;
|
||||
|
||||
let bytesPerParam = 0.6; // q4 default
|
||||
if (/q8|8bit|fp8/i.test(id)) bytesPerParam = 1.0;
|
||||
else if (/q6|6bit/i.test(id)) bytesPerParam = 0.8;
|
||||
else if (/q5|5bit/i.test(id)) bytesPerParam = 0.7;
|
||||
else if (/fp16|f16|bf16/i.test(id)) bytesPerParam = 2.0;
|
||||
|
||||
return paramCountB * bytesPerParam + 1.0;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user