Files
connectai/src/sidebarProvider.ts
T

1954 lines
86 KiB
TypeScript

import * as vscode from 'vscode';
import * as fs from 'fs';
import * as path from 'path';
import {
_getBrainDir,
findBrainFiles,
buildApiUrl,
getActiveBrainProfile,
getBrainProfiles,
logError,
logInfo,
resolveEngine,
summarizeText
} from './utils';
import { getConfig } from './config';
import { AgentExecutor, ChatMessage } from './agent';
import { BridgeInterface } from './bridge';
interface LastVisibleChatSnapshot {
history: ChatMessage[];
brainProfileId: string;
sessionId: string | null;
timestamp: number;
negativePrompt?: string;
}
interface ChatSession {
id: string;
title: string;
timestamp: number;
history: ChatMessage[];
brainProfileId: string;
negativePrompt?: string;
}
/**
* Sidebar UI Provider implementing BridgeInterface for BridgeServer
*/
export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeInterface {
public static readonly viewType = 'g1nation-v2-view';
private static readonly activeSessionStateKey = 'g1nation.activeSessionId';
private static readonly lastVisibleChatStateKey = 'g1nation.lastVisibleChat';
private static readonly blankChatStateKey = 'g1nation.blankChatActive';
private static readonly lastAgentStateKey = 'g1nation.lastAgentPath';
private _view?: vscode.WebviewView;
public brainEnabled = true;
private _currentSessionBrainId: string | null = null;
private _currentNegativePrompt: string = '';
constructor(
private readonly _extensionUri: vscode.Uri,
private readonly _context: vscode.ExtensionContext,
private readonly _agent: AgentExecutor
) {
this._agent.setHistoryChangeListener((history) => {
void this._persistLastVisibleChat(history);
});
}
public resolveWebviewView(
webviewView: vscode.WebviewView,
context: vscode.WebviewViewResolveContext,
_token: vscode.CancellationToken,
) {
this._view = webviewView;
webviewView.webview.options = {
enableScripts: true,
localResourceRoots: [this._extensionUri]
};
webviewView.webview.html = this._getHtml(webviewView.webview);
this._agent.setWebview(webviewView.webview);
void this._restoreActiveSessionIntoView();
webviewView.webview.onDidReceiveMessage(async (data) => {
switch (data.type) {
case 'prompt':
case 'promptWithFile':
await this._context.globalState.update(SidebarChatProvider.blankChatStateKey, false);
await this._handlePrompt(data);
// After prompt, save the session automatically
await this._saveCurrentSession();
break;
case 'ready':
await this._sendBrainStatus();
await this._sendBrainProfiles();
await this._sendSessionList();
await this._sendModels();
await this._sendConfig();
await this._restoreActiveSessionIntoView();
break;
case 'toggleMultiAgent':
await vscode.workspace.getConfiguration('g1nation').update('multiAgentEnabled', data.value, vscode.ConfigurationTarget.Global);
break;
case 'getModels':
await this._sendModels();
break;
case 'getAgents':
await this._sendAgentsList();
break;
case 'createAgent':
await this._createAgent();
break;
case 'newChat':
this._currentSessionId = null;
this._currentSessionBrainId = getActiveBrainProfile().id;
this._agent.resetConversation();
await this._context.globalState.update(SidebarChatProvider.activeSessionStateKey, null);
await this._context.globalState.update(SidebarChatProvider.lastVisibleChatStateKey, null);
await this._context.globalState.update(SidebarChatProvider.blankChatStateKey, true);
this.clearChat();
await this._sendBrainStatus();
break;
case 'stopGeneration':
this._agent.stop();
break;
case 'loadSession':
await this._loadSession(data.id);
break;
case 'deleteSession':
await this._deleteSession(data.id);
break;
case 'openSettings':
vscode.commands.executeCommand('workbench.action.openSettings', 'g1nation');
break;
case 'manageBrains':
await this._manageBrains();
break;
case 'syncBrain':
await this.syncBrain();
await this._sendBrainStatus();
break;
case 'addMessage':
this._view?.webview.postMessage({ type: 'addMessage', role: data.role, value: data.value, rationale: data.rationale });
break;
case 'addBrain':
await this._addBrainProfile();
break;
case 'setBrainProfile':
await this._setActiveBrainProfile(data.id);
break;
case 'getAgentContent':
await this._sendAgentContent(data.path);
break;
case 'updateAgent':
await this._updateAgent(data.path, data.content, data.negativePrompt);
break;
case 'refreshModels':
await this._sendModels();
break;
case 'model':
await vscode.workspace.getConfiguration('g1nation').update('defaultModel', data.value, vscode.ConfigurationTarget.Global);
logInfo(`Default model updated to: ${data.value}`);
break;
case 'proactiveTrigger':
await this._handleProactiveSuggestion(data.context);
break;
case 'exportResponse':
const workspacePath = vscode.workspace.workspaceFolders?.[0].uri.fsPath || '';
const defaultPath = path.join(workspacePath, 'g1_response.md');
const uri = await vscode.window.showSaveDialog({
defaultUri: vscode.Uri.file(defaultPath),
filters: { 'Markdown': ['md'] }
});
if (uri) {
await vscode.workspace.fs.writeFile(uri, Buffer.from(data.text, 'utf8'));
vscode.window.showInformationMessage(`✅ Exported to ${path.basename(uri.fsPath)}`);
}
break;
case 'approveAction':
await this._agent.approveTransaction();
break;
case 'rejectAction':
await this._agent.rejectTransaction();
break;
}
});
}
private _currentSessionId: string | null = null;
private async _restoreActiveSessionIntoView() {
if (!this._view) return;
const blankChatActive = this._context.globalState.get<boolean>(SidebarChatProvider.blankChatStateKey, false);
if (blankChatActive) {
return;
}
const activeSessionId = this._currentSessionId || this._context.globalState.get<string | null>(SidebarChatProvider.activeSessionStateKey, null);
if (activeSessionId) {
const loaded = await this._loadSession(activeSessionId, true);
if (loaded) return;
await this._context.globalState.update(SidebarChatProvider.activeSessionStateKey, null);
}
const currentHistory = this._agent.getHistory();
if (currentHistory.length > 0) {
this._view.webview.postMessage({ type: 'restoreHistory', value: currentHistory });
await this._persistLastVisibleChat(currentHistory);
return;
}
const snapshot = this._context.globalState.get<LastVisibleChatSnapshot | null>(SidebarChatProvider.lastVisibleChatStateKey, null);
if (snapshot?.history?.length) {
this._currentSessionId = snapshot.sessionId || null;
this._currentSessionBrainId = snapshot.brainProfileId || getActiveBrainProfile().id;
this._currentNegativePrompt = snapshot.negativePrompt || '';
await this._setActiveBrainProfile(this._currentSessionBrainId, true);
this._agent.setHistory(snapshot.history);
this._view.webview.postMessage({
type: 'restoreHistory',
value: snapshot.history,
negativePrompt: this._currentNegativePrompt
});
}
}
private async _persistLastVisibleChat(history: ChatMessage[] = this._agent.getHistory()) {
if (history.length === 0) {
await this._context.globalState.update(SidebarChatProvider.lastVisibleChatStateKey, null);
return;
}
const snapshot: LastVisibleChatSnapshot = {
history,
brainProfileId: this._currentSessionBrainId || getActiveBrainProfile().id,
sessionId: this._currentSessionId,
timestamp: Date.now(),
negativePrompt: this._currentNegativePrompt
};
await this._context.globalState.update(SidebarChatProvider.lastVisibleChatStateKey, snapshot);
}
private async _saveCurrentSession() {
const history = this._agent.getHistory();
if (history.length === 0) return;
let sessions = this._getSessions();
const firstMsg = history.find(m => m.role === 'user')?.content;
const title = typeof firstMsg === 'string' ? firstMsg.substring(0, 50).replace(/\n/g, ' ') + (firstMsg.length > 50 ? '...' : '') : 'New Chat';
const brainProfileId = this._currentSessionBrainId || getActiveBrainProfile().id;
if (!this._currentSessionId) {
this._currentSessionId = Date.now().toString();
sessions.unshift({
id: this._currentSessionId,
title,
timestamp: Date.now(),
history,
brainProfileId,
negativePrompt: this._currentNegativePrompt
});
} else {
const idx = sessions.findIndex(s => s.id === this._currentSessionId);
if (idx >= 0) {
sessions[idx].history = history;
sessions[idx].timestamp = Date.now();
sessions[idx].brainProfileId = brainProfileId;
sessions[idx].negativePrompt = this._currentNegativePrompt;
if (!sessions[idx].title || sessions[idx].title === 'New Chat') {
sessions[idx].title = title;
}
} else {
sessions.unshift({
id: this._currentSessionId,
title,
timestamp: Date.now(),
history,
brainProfileId,
negativePrompt: this._currentNegativePrompt
});
}
}
// Keep only last 50 sessions
if (sessions.length > 50) sessions = sessions.slice(0, 50);
await this._putSessions(sessions);
await this._context.globalState.update(SidebarChatProvider.activeSessionStateKey, this._currentSessionId);
await this._persistLastVisibleChat(history);
await this._sendSessionList();
}
private async _sendSessionList() {
if (!this._view) return;
const sessions = this._getSessions();
const list = sessions.map(s => ({
id: s.id,
title: s.title,
timestamp: s.timestamp,
brainProfileId: s.brainProfileId || '',
messageCount: s.history.length,
history: s.history
}));
this._view.webview.postMessage({ type: 'sessionList', value: list });
}
private async _loadSession(id: string, skipSessionListRefresh: boolean = false): Promise<boolean> {
if (!id) {
logError('Session load requested without an id.');
this._view?.webview.postMessage({ type: 'error', value: 'Chat session id is missing.' });
return false;
}
const sessions = this._getSessions();
const session = sessions.find(s => s.id === id);
if (session) {
const history = Array.isArray(session.history) ? session.history : [];
if (history.length === 0) {
logError('Session load failed because history is empty or invalid.', { id });
this._view?.webview.postMessage({ type: 'error', value: 'This chat session has no saved messages.' });
return false;
}
this._agent.stop();
this._currentSessionId = id;
this._currentNegativePrompt = session.negativePrompt || '';
const sessionBrainId = session.brainProfileId || getActiveBrainProfile().id;
await this._setActiveBrainProfile(sessionBrainId, true);
this._agent.setHistory(history);
await this._context.globalState.update(SidebarChatProvider.activeSessionStateKey, id);
await this._context.globalState.update(SidebarChatProvider.blankChatStateKey, false);
await this._persistLastVisibleChat(history);
this._view?.webview.postMessage({
type: 'sessionLoaded',
value: {
id,
title: session.title || 'Chat Session',
history,
negativePrompt: this._currentNegativePrompt
}
});
if (!skipSessionListRefresh) {
await this._sendSessionList();
}
logInfo('Chat session loaded.', { id, messages: history.length });
return true;
}
logError('Session load failed because id was not found.', { id, sessionCount: sessions.length });
this._view?.webview.postMessage({ type: 'error', value: 'Chat session was not found.' });
return false;
}
private async _deleteSession(id: string) {
let sessions = this._getSessions();
sessions = sessions.filter(s => s.id !== id);
await this._putSessions(sessions);
if (this._currentSessionId === id) {
this._currentSessionId = null;
this._agent.resetConversation();
await this._context.globalState.update(SidebarChatProvider.activeSessionStateKey, null);
await this._context.globalState.update(SidebarChatProvider.lastVisibleChatStateKey, null);
await this._context.globalState.update(SidebarChatProvider.blankChatStateKey, true);
this.clearChat();
}
await this._sendSessionList();
}
private _getSessions(): ChatSession[] {
const rawSessions = this._context.globalState.get<any[]>('chat_sessions', []) || [];
return rawSessions
.map((session, index): ChatSession | null => {
const history = Array.isArray(session?.history)
? session.history.filter((message: any) =>
message
&& (message.role === 'user' || message.role === 'assistant' || message.role === 'system')
&& message.content !== undefined
)
: [];
if (!session?.id || history.length === 0) return null;
const firstMsg = history.find((message: ChatMessage) => message.role === 'user')?.content;
const fallbackTitle = typeof firstMsg === 'string'
? firstMsg.substring(0, 50).replace(/\n/g, ' ') + (firstMsg.length > 50 ? '...' : '')
: `Chat ${index + 1}`;
return {
id: String(session.id),
title: String(session.title || fallbackTitle),
timestamp: typeof session.timestamp === 'number' ? session.timestamp : Date.now(),
history,
brainProfileId: String(session.brainProfileId || getActiveBrainProfile().id),
negativePrompt: String(session.negativePrompt || '')
};
})
.filter((session): session is ChatSession => !!session)
.sort((a, b) => b.timestamp - a.timestamp)
.slice(0, 50);
}
private async _putSessions(sessions: ChatSession[]) {
await this._context.globalState.update('chat_sessions', sessions.slice(0, 50));
}
private async _sendConfig() {
if (!this._view) return;
const config = getConfig();
this._view.webview.postMessage({
type: 'configUpdate',
value: {
multiAgentEnabled: config.multiAgentEnabled
}
});
}
private async _sendBrainStatus() {
if (!this._view) return;
const activeBrain = getActiveBrainProfile();
const brainDir = activeBrain.localBrainPath;
const files = findBrainFiles(brainDir);
this._view.webview.postMessage({
type: 'brainStatus',
value: {
count: files.length,
path: brainDir,
name: activeBrain.name,
description: activeBrain.description || '',
repo: activeBrain.secondBrainRepo || ''
}
});
}
private async _sendBrainProfiles() {
if (!this._view) return;
const activeBrain = getActiveBrainProfile();
this._currentSessionBrainId = this._currentSessionBrainId || activeBrain.id;
const profiles = getBrainProfiles().map((profile) => ({
id: profile.id,
name: profile.name,
path: profile.localBrainPath,
description: profile.description || '',
repo: profile.secondBrainRepo || ''
}));
this._view.webview.postMessage({
type: 'brainProfiles',
value: {
activeBrainId: activeBrain.id,
profiles
}
});
}
private async _setActiveBrainProfile(profileId: string, silent: boolean = false) {
const profiles = getBrainProfiles();
const nextProfile = profiles.find((profile) => profile.id === profileId) || profiles[0];
if (!nextProfile) return;
await vscode.workspace.getConfiguration('g1nation').update('activeBrainId', nextProfile.id, vscode.ConfigurationTarget.Global);
this._currentSessionBrainId = nextProfile.id;
await this._sendBrainProfiles();
await this._sendBrainStatus();
if (!silent) {
this.injectSystemMessage(`**[Brain Switched]** ${nextProfile.name}\n\`${nextProfile.localBrainPath}\``);
}
}
private async _manageBrains() {
const activeBrain = getActiveBrainProfile();
const choice = await vscode.window.showQuickPick([
{
label: 'Add Brain Folder',
description: 'Create a new brain profile from a local folder'
},
{
label: 'Open Active Brain Folder',
description: activeBrain.localBrainPath
},
{
label: 'Open Brain Settings',
description: 'Edit names, paths, repos, and descriptions'
}
], {
placeHolder: `Active Brain: ${activeBrain.name} (${activeBrain.localBrainPath})`
});
if (!choice) return;
if (choice.label === 'Add Brain Folder') {
await this._addBrainProfile();
return;
}
if (choice.label === 'Open Active Brain Folder') {
if (!fs.existsSync(activeBrain.localBrainPath)) {
fs.mkdirSync(activeBrain.localBrainPath, { recursive: true });
}
await vscode.commands.executeCommand('revealFileInOS', vscode.Uri.file(activeBrain.localBrainPath));
return;
}
vscode.commands.executeCommand('workbench.action.openSettings', 'g1nation.brainProfiles');
}
private async _addBrainProfile() {
const selected = await vscode.window.showOpenDialog({
canSelectFiles: false,
canSelectFolders: true,
canSelectMany: false,
openLabel: 'Use as Brain'
});
const folder = selected?.[0]?.fsPath;
if (!folder) return;
const defaultName = path.basename(folder) || 'New Brain';
const name = await vscode.window.showInputBox({
prompt: 'Name this brain profile',
value: defaultName,
validateInput: (value) => value.trim() ? null : 'Brain name is required.'
});
if (!name) return;
const description = await vscode.window.showInputBox({
prompt: 'Optional description shown in the G1nation sidebar',
value: ''
});
const repo = await vscode.window.showInputBox({
prompt: 'Optional Second Brain Git repository URL',
value: ''
});
// Read raw settings directly to avoid virtual default-brain (injected in memory by getConfig())
// being saved into the settings file and corrupting the profile list on next load.
const cfg = vscode.workspace.getConfiguration('g1nation');
const existingRaw: any[] = cfg.get<any[]>('brainProfiles', []) || [];
const idBase = name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'brain';
let id = idBase;
let suffix = 2;
while (existingRaw.some((p: any) => p.id === id)) {
id = `${idBase}-${suffix++}`;
}
const newProfile = {
id,
name: name.trim(),
localBrainPath: folder,
secondBrainRepo: (repo || '').trim(),
description: (description || '').trim()
};
const nextProfiles = [...existingRaw, newProfile];
await cfg.update('brainProfiles', nextProfiles, vscode.ConfigurationTarget.Global);
await cfg.update('activeBrainId', id, vscode.ConfigurationTarget.Global);
this._currentSessionBrainId = id;
// Directly post the freshly-built profile list to the webview.
// cfg.update() is async and VSCode's config cache may not reflect the new value
// immediately, so we avoid re-reading via getBrainProfiles() here.
if (this._view) {
this._view.webview.postMessage({
type: 'brainProfiles',
value: {
activeBrainId: id,
profiles: nextProfiles.map((p: any) => ({
id: p.id || '',
name: p.name || '',
path: p.localBrainPath || '',
description: p.description || '',
repo: p.secondBrainRepo || ''
}))
}
});
}
await this._sendBrainStatus();
this.injectSystemMessage(`**[Brain Added]** ${name.trim()}\n\`${folder}\``);
}
// --- BridgeInterface Methods ---
public injectSystemMessage(msg: string): void {
this._view?.webview.postMessage({ type: 'streamStart' });
this._view?.webview.postMessage({ type: 'streamChunk', value: msg });
this._view?.webview.postMessage({ type: 'streamEnd' });
}
public getHistoryText(): string {
return "Conversation history placeholder for evaluation.";
}
public sendPromptFromExtension(prompt: string): void {
if (this._view) {
this._view.show?.(true);
this._view.webview.postMessage({ type: 'injectPrompt', value: prompt });
}
}
public findBrainFiles(dir: string): string[] {
return findBrainFiles(dir);
}
// --- End BridgeInterface ---
public focusInput() {
this._view?.webview.postMessage({ type: 'focusInput' });
}
public clearChat() {
this._view?.webview.postMessage({ type: 'clearChat' });
}
public async syncBrain() {
const activeBrain = getActiveBrainProfile();
const brainDir = activeBrain.localBrainPath;
if (!fs.existsSync(brainDir)) {
vscode.window.showErrorMessage("Second Brain directory not found.");
return;
}
vscode.window.withProgress({
location: vscode.ProgressLocation.Notification,
title: "G1nation: Syncing Second Brain...",
cancellable: false
}, async () => {
try {
const { execSync } = require('child_process');
execSync(`git add .`, { cwd: brainDir });
execSync(`git commit -m "[G1-Sync] Manual knowledge update"`, { cwd: brainDir });
execSync(`git push`, { cwd: brainDir });
vscode.window.showInformationMessage("Second Brain synced successfully.");
} catch (err: any) {
vscode.window.showInformationMessage("Brain Synced: Local knowledge is up-to-date (no changes to push).");
}
});
}
private _getAgentsDir(): string {
const defaultPath = 'E:\\Wiki\\Agent\\.agent\\skills';
if (fs.existsSync(defaultPath)) return defaultPath;
const workspaceFolders = vscode.workspace.workspaceFolders;
if (workspaceFolders) {
const localPath = path.join(workspaceFolders[0].uri.fsPath, '.agent', 'skills');
if (!fs.existsSync(localPath)) {
fs.mkdirSync(localPath, { recursive: true });
}
return localPath;
}
return '';
}
private async _sendAgentsList() {
if (!this._view) return;
const dir = this._getAgentsDir();
const agents = [];
if (dir && fs.existsSync(dir)) {
const files = fs.readdirSync(dir);
for (const f of files) {
if (f.endsWith('.md')) {
agents.push({ name: f.replace('.md', ''), path: path.join(dir, f) });
}
}
}
const lastPath = this._context.globalState.get<string>(SidebarChatProvider.lastAgentStateKey, 'none');
this._view.webview.postMessage({ type: 'agentsList', value: agents, selected: lastPath });
}
private async _handleProactiveSuggestion(context: string) {
if (!this._view) return;
let suggestion = '';
switch (context) {
case 'settings_exploration':
suggestion = '💡 **Tip:** 모델 설정을 최적화하여 답변 속도를 2배 이상 높일 수 있습니다. 설정에서 `Max Context Size`를 조정해보세요!';
break;
case 'brain_sync_exploration':
suggestion = '🧠 **Knowledge Sync:** 최근에 수정한 파일이 지식 베이스에 반영되지 않았나요? 지금 동기화 버튼을 눌러 최신 정보를 업데이트하세요.';
break;
case 'agent_selection_exploration':
suggestion = '🤖 **Agent Skills:** 특정 언어나 프레임워크에 특화된 에이전트 스킬을 선택하면 더 정확한 코드를 생성할 수 있습니다.';
break;
default:
suggestion = '💡 새로운 기능을 발견하셨나요? 궁금한 점이 있다면 언제든 물어보세요!';
}
this._view.webview.postMessage({ type: 'streamStart' });
this._view.webview.postMessage({ type: 'streamChunk', value: `\n\n> [!TIP]\n> ${suggestion}\n` });
this._view.webview.postMessage({ type: 'streamEnd' });
}
private async _createAgent() {
const name = await vscode.window.showInputBox({
prompt: 'Name of the new Agent (e.g., frontend_expert)',
placeHolder: 'Agent name...'
});
if (!name) return;
const safeName = name.trim().replace(/[^a-zA-Z0-9_\\-\\u3131-\\uD79D]/g, '_');
if (!safeName) return;
const dir = this._getAgentsDir();
if (!dir) {
vscode.window.showErrorMessage('Agent directory could not be determined.');
return;
}
const filePath = path.join(dir, `${safeName}.md`);
if (!fs.existsSync(filePath)) {
fs.writeFileSync(filePath, `# Agent Persona: ${safeName}\\n\\nAdd your instructions here...\\n`, 'utf8');
}
const doc = await vscode.workspace.openTextDocument(filePath);
await vscode.window.showTextDocument(doc);
await this._sendAgentsList();
}
private async _sendAgentContent(agentPath: string) {
if (!this._view || !agentPath || agentPath === 'none') return;
if (fs.existsSync(agentPath)) {
const content = fs.readFileSync(agentPath, 'utf8');
const negativePrompt = this._context.globalState.get<string>(`negativePrompt:${agentPath}`, '');
this._view.webview.postMessage({
type: 'agentContent',
value: content,
negativePrompt: negativePrompt
});
await this._context.globalState.update(SidebarChatProvider.lastAgentStateKey, agentPath);
}
}
private async _updateAgent(agentPath: string, content: string, negativePrompt?: string) {
if (!agentPath || agentPath === 'none') return;
try {
fs.writeFileSync(agentPath, content, 'utf8');
if (negativePrompt !== undefined) {
await this._context.globalState.update(`negativePrompt:${agentPath}`, negativePrompt);
}
vscode.window.showInformationMessage('Agent skill updated successfully.');
} catch (err: any) {
vscode.window.showErrorMessage(`Failed to update agent: ${err.message}`);
}
}
private async _handlePrompt(data: any) {
if (!this._view) return;
const { value, model, internet, files, agentFile, negativePrompt } = data;
this._currentNegativePrompt = negativePrompt || '';
this._currentSessionBrainId = getActiveBrainProfile().id;
let agentSkillContext = undefined;
if (agentFile && fs.existsSync(agentFile)) {
agentSkillContext = fs.readFileSync(agentFile, 'utf8');
}
try {
await this._agent.handlePrompt(value, model, {
internetEnabled: internet,
visionContent: files,
agentSkillContext,
negativePrompt
});
} catch (error: any) {
logError('Prompt handling failed in sidebar provider.', { error: error?.message || String(error), promptPreview: summarizeText(value || '', 200) });
this._view.webview.postMessage({ type: 'error', value: error.message });
}
}
private async _sendModels() {
if (!this._view) return;
try {
const config = getConfig();
const url = config.ollamaUrl;
let defaultModel = config.defaultModel;
let models: string[] = [];
const primaryEngine = resolveEngine(url);
const engines = primaryEngine === 'lmstudio' ? ['lmstudio', 'ollama'] as const : ['ollama', 'lmstudio'] as const;
for (const engine of engines) {
const modelsUrl = buildApiUrl(url, engine, 'models');
try {
logInfo('Model discovery started.', { engine, modelsUrl });
const res = await fetch(modelsUrl, { signal: AbortSignal.timeout(5000) });
const rawText = await res.text();
if (!res.ok) {
logError('Model discovery returned non-OK status.', { engine, modelsUrl, status: res.status, body: summarizeText(rawText, 300) });
continue;
}
const data = rawText ? JSON.parse(rawText) as any : {};
models = engine === 'lmstudio'
? (data.data || []).map((m: any) => m.id)
: (data.models || []).map((m: any) => m.name);
if (models.length > 0) {
logInfo('Model discovery succeeded.', { engine, count: models.length, modelsPreview: models.slice(0, 5) });
break;
}
} catch (e: any) {
logError('Model discovery failed.', { engine, modelsUrl, error: e?.message || String(e) });
}
}
if (models.length === 0) {
models = defaultModel ? [defaultModel] : [];
this._view.webview.postMessage({ type: 'engineStatus', value: { online: false, url } });
} else {
this._view.webview.postMessage({ type: 'engineStatus', value: { online: true, url } });
}
const baseModel = defaultModel?.replace(/:\d+$/, '');
if (baseModel && baseModel !== defaultModel && models.includes(baseModel)) {
defaultModel = baseModel;
await vscode.workspace.getConfiguration('g1nation').update('defaultModel', defaultModel, vscode.ConfigurationTarget.Global);
}
if (models.length > 0 && (!defaultModel || !models.includes(defaultModel))) {
// [State Persistence Fix] 저장된 모델이 목록에 없을 때:
// 즉시 강제 리셋하는 대신, 현재 모델 목록의 첫 번째를 '폴백 후보'로만 사용.
// 단, defaultModel이 완전히 없는 경우에만 실제로 저장함.
if (!defaultModel) {
defaultModel = models[0];
await vscode.workspace.getConfiguration('g1nation').update('defaultModel', defaultModel, vscode.ConfigurationTarget.Global);
} else {
// 저장된 모델명은 유지하고, UI에는 첫 번째 모델을 보여주되
// 설정은 건드리지 않아 다음 번에 같은 모델이 다시 로드될 경우 복원 가능하도록 함
logInfo('Saved model not found in current list, using first available as fallback.', { saved: defaultModel, fallback: models[0] });
defaultModel = models[0];
}
}
const defaultIdx = models.indexOf(defaultModel);
if (defaultIdx > 0) {
models.splice(defaultIdx, 1);
models.unshift(defaultModel);
}
this._view.webview.postMessage({ type: 'modelsList', value: { models, selected: defaultModel } });
} catch (err) {
logError('Model list update failed.', err);
const fallbackModel = getConfig().defaultModel;
this._view.webview.postMessage({ type: 'modelsList', value: { models: fallbackModel ? [fallbackModel] : [], selected: fallbackModel } });
}
}
private _getHtml(webview: vscode.Webview): string {
return `<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<title>G1nation</title>
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<style>
:root {
--bg: #0d1117;
--bg-secondary: #0d1117;
--surface: #161b22;
--border: #30363d;
--border-bright: #484f58;
--text-primary: #c9d1d9;
--text-bright: #ffffff;
--text-dim: #8b949e;
--accent: #58a6ff;
--accent-glow: rgba(88, 166, 255, 0.15);
--success: #238636;
--warning: #d29922;
--error: #f85149;
--code-bg: #161b22;
--table-header-bg: #161b22;
--table-row-hover: #21262d;
--input-bg: #0d1117;
}
body.vscode-light {
--bg: #ffffff;
--bg-secondary: #f6f8fa;
--surface: #ffffff;
--border: #d0d7de;
--border-bright: #afb8c1;
--text-primary: #24292f;
--text-bright: #111118;
--text-dim: #57606a;
--accent: #0969da;
--accent-glow: rgba(9, 105, 218, 0.1);
--success: #1a7f37;
--warning: #9a6700;
--error: #cf222e;
--code-bg: #f6f8fa;
--table-header-bg: #f6f8fa;
--table-row-hover: #f3f4f6;
--input-bg: #ffffff;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body {
height: 100%;
font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Segoe UI', Helvetica, Arial, sans-serif;
font-size: 13px;
line-height: 1.6;
background: var(--bg);
color: var(--text-primary);
display: flex;
flex-direction: column;
overflow: hidden;
}
/* --- Header --- */
.header {
display: flex;
flex-direction: column;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border);
flex-shrink: 0;
z-index: 100;
}
.header-top {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 14px;
}
.header-controls {
display: flex;
align-items: center;
gap: 6px;
padding: 0 14px 10px;
flex-wrap: wrap;
}
.brand { font-weight: 700; font-size: 14px; color: var(--text-bright); letter-spacing: -0.5px; display: flex; align-items: center; gap: 8px; }
.logo { width: 22px; height: 22px; background: var(--accent); color: #fff; border-radius: 6px; display: flex; align-items: center; justify-content: center; font-size: 14px; font-weight: 900; }
.chat {
flex: 1;
overflow-y: auto;
padding: 16px 14px;
display: flex;
flex-direction: column;
gap: 24px;
scroll-behavior: smooth;
}
/* --- Messages --- */
.msg {
display: flex;
flex-direction: column;
gap: 8px;
position: relative;
animation: msgIn 0.3s cubic-bezier(0.16, 1, 0.3, 1);
}
@keyframes msgIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }
.msg-head { display: flex; align-items: center; gap: 8px; font-weight: 600; font-size: 11px; color: var(--text-dim); }
.av { width: 22px; height: 22px; border-radius: 6px; display: flex; align-items: center; justify-content: center; font-size: 12px; }
.icon-btn:hover { background: var(--border); color: var(--text-bright); }
.icon-btn.active { color: var(--accent); border-color: var(--accent); background: rgba(187, 134, 252, 0.1); }
/* Tooltip System */
[data-tooltip] { position: relative; }
[data-tooltip]::after {
content: attr(data-tooltip);
position: absolute; bottom: -30px; left: 50%; transform: translateX(-50%);
background: #333; color: #fff; padding: 4px 8px; border-radius: 4px;
font-size: 10px; white-space: nowrap; opacity: 0; pointer-events: none;
transition: all 0.2s ease; z-index: 100; box-shadow: 0 4px 10px rgba(0,0,0,0.3);
}
[data-tooltip]:hover::after { opacity: 1; bottom: -35px; }
.header-controls { display: flex; gap: 8px; margin-left: auto; }
#promptInput::placeholder { color: var(--accent); opacity: 0.6; font-weight: 500; }
.msg-body {
padding-left: 30px;
color: var(--text-primary);
font-size: 13.5px;
word-break: break-word;
}
.msg-user .msg-body {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
padding: 10px 14px;
margin-left: 30px;
white-space: pre-wrap;
}
/* --- Markdown Style --- */
.markdown-body h1, .markdown-body h2, .markdown-body h3 {
color: var(--text-bright);
margin: 1.5em 0 0.8em;
font-weight: 600;
line-height: 1.3;
}
.markdown-body h1 { font-size: 1.5em; border-bottom: 1px solid var(--border); padding-bottom: 0.3em; color: var(--accent); }
.markdown-body h2 { font-size: 1.3em; border-bottom: 1px solid var(--border); padding-bottom: 0.3em; }
.markdown-body table {
width: 100%;
border-collapse: collapse;
margin: 1.2em 0;
font-size: 12px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 8px;
overflow: hidden;
}
.markdown-body th { background: var(--table-header-bg); color: var(--accent); font-weight: 600; text-align: left; padding: 10px 12px; border: 1px solid var(--border); }
.markdown-body td { padding: 8px 12px; border: 1px solid var(--border); color: var(--text-primary); }
.markdown-body tr:nth-child(even) { background: rgba(255, 255, 255, 0.02); }
.markdown-body pre { background: var(--code-bg); border: 1px solid var(--border); border-radius: 8px; padding: 14px; overflow-x: auto; margin: 12px 0; }
.markdown-body code { font-family: 'SF Mono', monospace; font-size: 11.5px; background: rgba(175, 184, 193, 0.2); padding: 0.2em 0.4em; border-radius: 4px; }
/* --- UI Elements --- */
.msg-actions {
position: absolute; bottom: -12px; right: 0; display: flex; gap: 4px; opacity: 0; transition: 0.2s; z-index: 20;
}
.msg:hover .msg-actions { opacity: 1; }
.action-btn {
background: var(--bg-secondary); border: 1px solid var(--border);
color: var(--text-dim); padding: 4px 10px; border-radius: 6px; font-size: 10px; cursor: pointer; transition: 0.2s;
display: flex; align-items: center; gap: 4px;
}
.action-btn:hover { color: var(--text-bright); border-color: var(--accent); background: var(--accent-glow); }
.icon-btn {
background: var(--surface); border: 1px solid var(--border); color: var(--text-dim); width: 28px; height: 28px;
border-radius: 6px; display: flex; align-items: center; justify-content: center; cursor: pointer; transition: 0.2s; font-size: 13px;
}
.icon-btn:hover { color: var(--text-bright); border-color: var(--accent); background: var(--accent-glow); }
.icon-btn.active { color: var(--accent); border-color: var(--accent); background: var(--accent-glow); }
select { background: var(--surface); color: var(--text-primary); border: 1px solid var(--border); padding: 4px 8px; border-radius: 6px; font-size: 10.5px; cursor: pointer; }
/* --- Input & Attachments --- */
.input-wrap {
padding: 12px 14px 16px; background: var(--bg); border-top: 1px solid var(--border); flex-shrink: 0;
}
.input-box {
background: var(--input-bg); border: 1px solid var(--border); border-radius: 12px; padding: 10px 14px;
display: flex; flex-direction: column; gap: 8px; transition: 0.2s;
position: relative; /* Toast 위치 앙커 */
}
.input-box:focus-within { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-glow); }
textarea {
width: 100%; background: transparent; border: none; color: var(--text-bright);
font-family: inherit; font-size: 13.5px; resize: none; outline: none; min-height: 24px; max-height: 160px;
}
.attachment-preview {
display: none; gap: 8px; padding-bottom: 8px; border-bottom: 1px solid var(--border); flex-wrap: wrap;
}
.attachment-preview.visible { display: flex; }
.file-chip {
display: flex; align-items: center; gap: 6px; background: var(--surface); border: 1px solid var(--border);
padding: 4px 8px; border-radius: 6px; font-size: 11px; color: var(--text-primary);
}
.file-chip .remove { cursor: pointer; color: var(--text-dim); }
.file-chip .remove:hover { color: var(--error); }
.input-footer { display: flex; align-items: center; justify-content: space-between; }
.footer-left { display: flex; align-items: center; gap: 8px; }
.send-btn {
background: var(--accent); color: #fff; border: none; padding: 6px 14px; border-radius: 6px;
font-weight: 600; font-size: 12px; cursor: pointer;
}
.send-btn:disabled { opacity: 0.3; cursor: not-allowed; }
.footer-right { display: flex; align-items: center; gap: 6px; }
.cancel-btn {
background: transparent; color: var(--text-dim); border: 1px solid var(--border);
padding: 6px 10px; border-radius: 6px; font-weight: 600; font-size: 11px;
cursor: pointer; align-items: center; gap: 4px; transition: all 0.15s ease;
}
.cancel-btn:hover { background: rgba(248, 81, 73, 0.12); color: var(--error); border-color: var(--error); }
.stop-btn {
background: rgba(248, 81, 73, 0.15); color: var(--error); border: 1px solid var(--error);
padding: 6px 14px; border-radius: 6px; font-weight: 700; font-size: 12px;
cursor: pointer; display: inline-flex; align-items: center; gap: 5px;
animation: stopPulse 1.2s ease-in-out infinite;
}
.stop-btn:hover { background: rgba(248, 81, 73, 0.3); }
@keyframes stopPulse {
0%, 100% { box-shadow: 0 0 0 0 rgba(248,81,73,0.4); }
50% { box-shadow: 0 0 0 5px rgba(248,81,73,0); }
}
/* --- Drag and Drop Overlay --- */
body.drag-over::after {
content: '📂 파일을 여기에 놓으세요';
position: fixed; top: 0; left: 0; width: 100%; height: 100%;
background: rgba(88, 166, 255, 0.2);
backdrop-filter: blur(4px);
border: 2px dashed var(--accent);
display: flex; align-items: center; justify-content: center;
color: var(--text-bright); font-size: 18px; font-weight: 700;
z-index: 9999; pointer-events: none;
animation: fadeIn 0.2s ease-out;
}
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
/* --- Toast Notification --- */
.toast-notif {
position: absolute; bottom: calc(100% + 8px); left: 50%;
transform: translateX(-50%) translateY(4px);
background: var(--surface); border: 1px solid var(--border-bright);
color: var(--text-primary); font-size: 11px; font-weight: 600;
padding: 7px 14px; border-radius: 20px; white-space: nowrap;
opacity: 0; pointer-events: none;
transition: opacity 0.2s ease, transform 0.2s ease;
z-index: 500; box-shadow: 0 4px 16px rgba(0,0,0,0.3);
}
.toast-notif.toast-visible { opacity: 1; transform: translateX(-50%) translateY(0); }
.toast-notif.toast-warn { border-color: var(--error); color: var(--error); background: rgba(248,81,73,0.08); }
.toast-notif.toast-success { border-color: var(--success); color: var(--success); background: rgba(35,134,54,0.08); }
/* --- Overlays & Others --- */
.thinking-bar { height: 2px; background: transparent; position: relative; overflow: hidden; margin-top: -1px; }
.thinking-bar.active {
display: block;
background: linear-gradient(90deg, transparent, #2196f3, #bb86fc, transparent);
background-size: 200% 100%;
animation: thinking 1.5s infinite linear;
}
@keyframes thinking { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } }
.history-overlay {
position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.8);
backdrop-filter: blur(10px); z-index: 1000; display: none; flex-direction: column; padding: 20px;
}
.history-overlay.visible { display: flex; }
.stream-active::after {
content: ''; display: inline-block; width: 6px; height: 14px; background: var(--accent);
margin-left: 4px; animation: blink 0.8s step-end infinite; vertical-align: middle;
}
@keyframes blink { 0%, 100% { opacity: 1; } 50% { opacity: 0; } }
.welcome { text-align: center; padding: 40px 20px; color: var(--text-dim); }
.welcome-logo { font-size: 48px; color: var(--accent); margin-bottom: 16px; opacity: 0.8; }
.welcome-title { font-size: 20px; font-weight: 700; color: var(--text-bright); margin-bottom: 8px; }
/* --- History List --- */
.history-item {
padding: 12px; border-radius: 8px; background: var(--surface); border: 1px solid var(--border);
margin-bottom: 10px; cursor: pointer; transition: 0.2s;
}
.history-item:hover { border-color: var(--accent); background: var(--accent-glow); }
/* --- Approval UI --- */
.approval-box {
display: flex;
flex-direction: column;
gap: 12px;
margin: 15px 0;
padding: 16px;
background: var(--bg-secondary);
border: 1px solid var(--border-bright);
border-radius: 12px;
animation: msgIn 0.3s ease-out;
}
.approval-title { font-weight: 700; color: var(--accent); font-size: 12px; margin-bottom: 4px; display: flex; align-items: center; gap: 6px; }
.approval-btns { display: flex; gap: 10px; }
.btn-approve { flex: 1; background: var(--success); color: white; border: none; padding: 10px; border-radius: 8px; cursor: pointer; font-weight: 700; font-size: 12px; transition: 0.2s; }
.btn-approve:hover { filter: brightness(1.1); transform: translateY(-1px); }
.btn-reject { flex: 1; background: var(--error); color: white; border: none; padding: 10px; border-radius: 8px; cursor: pointer; font-weight: 700; font-size: 12px; transition: 0.2s; }
.btn-reject:hover { filter: brightness(1.1); transform: translateY(-1px); }
/* --- Physics & Micro-interactions --- */
button {
transition: all 0.15s cubic-bezier(0.4, 0, 0.2, 1);
user-select: none;
outline: none;
}
button:active {
transform: scale(0.96);
filter: brightness(0.9);
}
/* --- Hierarchical Grouping --- */
.input-group {
background: rgba(255, 255, 255, 0.02);
border-radius: 12px;
padding: 8px;
margin-top: 10px;
border: 1px solid rgba(255, 255, 255, 0.05);
display: flex;
gap: 8px;
}
/* --- Storytelling Stepper --- */
.stepper-container {
display: none;
margin: 12px 16px;
padding: 12px;
background: rgba(var(--accent-rgb), 0.05);
border-radius: 8px;
border: 1px solid rgba(var(--accent-rgb), 0.1);
animation: slideIn 0.3s ease-out;
}
.stepper-container.active { display: block; }
.steps {
display: flex;
justify-content: space-between;
position: relative;
}
.step {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
flex: 1;
}
.step-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--text-dim);
transition: 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275);
}
.step.active .step-dot {
background: var(--accent);
box-shadow: 0 0 12px var(--accent);
transform: scale(1.5);
}
.step.complete .step-dot {
background: var(--success);
box-shadow: 0 0 8px var(--success);
}
.step-label {
font-size: 9px;
color: var(--text-dim);
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.step.active .step-label { color: var(--accent); }
.step.complete .step-label { color: var(--success); }
/* --- Rationale View (Thought Process) --- */
.rationale-container {
margin: 12px 0;
padding: 16px;
background: rgba(255, 255, 255, 0.03);
border-left: 3px solid var(--accent);
border-radius: 4px 12px 12px 4px;
font-size: 12px;
line-height: 1.5;
animation: slideIn 0.3s ease-out;
backdrop-filter: blur(10px);
}
.rationale-header {
font-weight: 800;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--accent);
margin-bottom: 12px;
display: flex;
align-items: center;
gap: 8px;
font-size: 11px;
}
.rationale-section {
margin-bottom: 10px;
}
.rationale-label {
display: flex;
align-items: center;
gap: 6px;
font-weight: 700;
color: var(--text-bright);
margin-bottom: 4px;
font-size: 11px;
}
.rationale-content {
color: var(--text-dim);
padding-left: 20px;
}
@keyframes slideIn {
from { opacity: 0; transform: translateX(-10px); }
to { opacity: 1; transform: translateX(0); }
}
</style>
</head>
<body>
<div class="header">
<div class="header-top">
<div class="brand"><div class="logo">✦</div> G1nation</div>
<div style="display: flex; gap: 6px;">
<button class="icon-btn" id="newChatBtn" title="New Chat">+</button>
<button class="icon-btn" id="settingsBtn" title="Settings">⚙️</button>
</div>
</div>
<div class="header-controls">
<div id="statusDot" style="width:6px; height:6px; border-radius:50%; background:var(--text-dim);"></div>
<select id="modelSel" title="Select Model"></select>
<select id="brainSel" title="Select Brain"></select>
<select id="agentSel" title="Select Agentic Skill"></select>
<button class="icon-btn" id="editAgentBtn" title="Edit Agent Skill">📝</button>
<button class="icon-btn" id="addAgentBtn" title="Create Agent">+</button>
<button class="icon-btn" id="multiAgentBtn" data-tooltip="Multi-Agent Mode">🤖</button>
<button class="icon-btn" id="internetBtn" data-tooltip="Internet Access">🌐</button>
<button class="icon-btn" id="brainBtn" data-tooltip="Sync Knowledge">🧠</button>
<button class="icon-btn" id="historyBtn" data-tooltip="View History">📜</button>
</div>
</div>
<div id="historyOverlay" class="history-overlay">
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:20px;">
<h2 style="color:var(--text-bright);">Chat History</h2>
<button class="icon-btn" id="closeHistoryBtn">✕</button>
</div>
<div id="historyList" style="flex:1; overflow-y:auto;"></div>
</div>
<div class="thinking-bar" id="thinkingBar"></div>
<div id="stepper" class="stepper-container">
<div class="steps">
<div class="step" id="step-analyze"><div class="step-dot"></div><div class="step-label">Analyze</div></div>
<div class="step" id="step-plan"><div class="step-dot"></div><div class="step-label">Plan</div></div>
<div class="step" id="step-execute"><div class="step-dot"></div><div class="step-label">Execute</div></div>
<div class="step" id="step-verify"><div class="step-dot"></div><div class="step-label">Verify</div></div>
</div>
</div>
<div class="chat" id="chat">
<div class="welcome">
<div class="welcome-logo">✦</div>
<div class="welcome-title">Welcome to G1nation</div>
<p>Your premium local AI assistant.<br>Ready to analyze projects and build reports.</p>
</div>
</div>
<div class="input-wrap">
<div id="agentConfigPanel" style="display:none; padding-bottom:8px; flex-direction: column; gap: 8px;">
<div style="font-size: 10px; color: var(--text-dim); margin-bottom: -4px;">Agent Persona/Instructions</div>
<textarea id="agentPrompt" rows="5" placeholder="Agent Persona & Instructions..." style="font-size:11.5px; padding:8px; border-radius:8px; border:1px solid var(--border); background:var(--input-bg); color:var(--text-bright); width:100%; resize:vertical; font-family:inherit; outline:none;"></textarea>
<div style="font-size: 10px; color: var(--text-dim); margin-bottom: -4px;">Negative Prompt (Strict Rules)</div>
<textarea id="negativePrompt" rows="2" placeholder="What NOT to do..." style="font-size:11.5px; padding:8px; border-radius:8px; border:1px solid var(--border); background:var(--input-bg); color:var(--text-bright); width:100%; resize:vertical; font-family:inherit; outline:none;"></textarea>
<button id="updateAgentBtn" style="background: var(--surface); border: 1px solid var(--border); color: var(--text-primary); padding: 6px; font-size: 10px; border-radius: 6px; cursor: pointer; transition: 0.2s;">Update Agent Skill</button>
</div>
<div class="input-box">
<div id="attachPreview" class="attachment-preview"></div>
<textarea id="input" rows="1" placeholder="Type your request..."></textarea>
<div class="input-footer">
<div class="footer-left">
<button class="icon-btn" id="attachBtn" title="Attach Files">📎</button>
<span id="statusLabel" style="font-size:10px; color:var(--text-dim);">Ready</span>
</div>
<div class="footer-right">
<button id="cancelBtn" class="cancel-btn" title="Clear draft" style="display:none;">✕ Clear</button>
<button id="stopBtn" class="stop-btn" title="Stop generation" style="display:none;">■ Stop</button>
<button id="sendBtn" class="send-btn">Send</button>
</div>
</div>
<div id="toastNotif" class="toast-notif"></div>
</div>
<div class="input-group">
<button class="action-btn" style="flex:1" id="inputNewChatBtn">New Chat</button>
<button class="action-btn" style="flex:1" id="inputSyncBtn">Sync Knowledge</button>
</div>
</div>
<input type="file" id="fileInput" multiple hidden accept="image/*,.txt,.md,.pdf,.csv,.json,.js,.ts,.py,.java,.rs,.go">
</div>
<script>
const vscode = acquireVsCodeApi();
const chat = document.getElementById('chat');
const input = document.getElementById('input');
const sendBtn = document.getElementById('sendBtn');
const stopBtn = document.getElementById('stopBtn');
const cancelBtn = document.getElementById('cancelBtn');
const toastNotif = document.getElementById('toastNotif');
const thinkingBar = document.getElementById('thinkingBar');
const statusLabel = document.getElementById('statusLabel');
const stepper = document.getElementById('stepper');
// --- Draft State Management ---
let isDraftActive = false;
let _toastTimer = null;
function showToast(msg, type = 'info') {
toastNotif.textContent = msg;
toastNotif.className = 'toast-notif toast-' + type + ' toast-visible';
if (_toastTimer) clearTimeout(_toastTimer);
_toastTimer = setTimeout(() => {
toastNotif.classList.remove('toast-visible');
}, 2500);
}
function setDraftActive(active) {
isDraftActive = active;
cancelBtn.style.display = active ? 'inline-flex' : 'none';
}
// 생성 중/완료 시 Send ⇔ Stop 전환
function setGenerating(generating) {
if (generating) {
sendBtn.style.display = 'none';
stopBtn.style.display = 'inline-flex';
// 생성 중에는 Clear 버튼 숨김
cancelBtn.style.display = 'none';
} else {
stopBtn.style.display = 'none';
sendBtn.style.display = 'inline-flex';
sendBtn.disabled = false;
// Draft 상태에 따라 Clear 버튼 복원
if (isDraftActive) cancelBtn.style.display = 'inline-flex';
}
}
function clearDraft() {
// Step 1: 상태 초기화 (Draft State Reset)
setDraftActive(false);
// Step 2: UI 반영 (Input + Attachments 초기화)
input.value = '';
input.style.height = 'auto';
pendingFiles = [];
renderAttachments();
input.focus();
// Step 3: Toast 알림으로 즉각적 피드백
showToast('✕ 작성 내용이 초기화되었습니다.', 'warn');
Sound.warn();
}
// --- Sound Manager ---
const Sound = {
ctx: null,
init() { if (!this.ctx) this.ctx = new (window.AudioContext || window.webkitAudioContext)(); },
play(freq, type, dur) {
try {
this.init();
const osc = this.ctx.createOscillator();
const gain = this.ctx.createGain();
osc.type = type;
osc.frequency.setValueAtTime(freq, this.ctx.currentTime);
gain.gain.setValueAtTime(0.05, this.ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.01, this.ctx.currentTime + dur);
osc.connect(gain);
gain.connect(this.ctx.destination);
osc.start();
osc.stop(this.ctx.currentTime + dur);
} catch(e) {}
},
success() { this.play(880, 'sine', 0.1); setTimeout(() => this.play(1109, 'sine', 0.15), 80); },
warn() { this.play(440, 'triangle', 0.3); }
};
function setStep(stepId, state = 'active') {
stepper.classList.add('active');
const step = document.getElementById('step-' + stepId);
if (step) {
if (state === 'active') {
document.querySelectorAll('.step').forEach(s => s.classList.remove('active'));
step.classList.add('active');
} else if (state === 'complete') {
step.classList.remove('active');
step.classList.add('complete');
}
}
}
function resetStepper() {
stepper.classList.remove('active');
document.querySelectorAll('.step').forEach(s => {
s.classList.remove('active');
s.classList.remove('complete');
});
}
const modelSel = document.getElementById('modelSel');
const brainSel = document.getElementById('brainSel');
const historyOverlay = document.getElementById('historyOverlay');
const historyList = document.getElementById('historyList');
const statusDot = document.getElementById('statusDot');
const attachBtn = document.getElementById('attachBtn');
const fileInput = document.getElementById('fileInput');
const attachPreview = document.getElementById('attachPreview');
const agentSel = document.getElementById('agentSel');
const editAgentBtn = document.getElementById('editAgentBtn');
const addAgentBtn = document.getElementById('addAgentBtn');
const agentConfigPanel = document.getElementById('agentConfigPanel');
const agentPrompt = document.getElementById('agentPrompt');
const negativePrompt = document.getElementById('negativePrompt');
const updateAgentBtn = document.getElementById('updateAgentBtn');
let streamBody = null;
let internetEnabled = false;
let pendingFiles = [];
let editMode = false;
function fmt(text) { return marked.parse(text || ''); }
function copyToClipboard(text, btn) {
const textarea = document.createElement('textarea');
textarea.value = text; textarea.style.position = 'fixed'; textarea.style.opacity = '0';
document.body.appendChild(textarea); textarea.select();
try {
if (document.execCommand('copy')) {
btn.innerText = '✅ Copied!'; setTimeout(() => { btn.innerText = '📋 Copy'; }, 2000);
}
} catch (err) { console.error('Copy failed', err); }
document.body.removeChild(textarea);
}
window.approve = () => {
const box = document.querySelector('.approval-box');
if (box) box.remove();
vscode.postMessage({ type: 'approveAction' });
};
window.reject = () => {
const box = document.querySelector('.approval-box');
if (box) box.remove();
vscode.postMessage({ type: 'rejectAction' });
};
function exportToMD(text) {
vscode.postMessage({ type: 'exportResponse', text: text });
}
function addMsg(text, role, rationale) {
const isUser = role === 'user';
const msgEl = document.createElement('div');
msgEl.className = 'msg ' + (isUser ? 'msg-user' : 'msg-ai');
msgEl._raw = text;
// If rationale exists and it's an AI message, add the Rationale View
if (!isUser && rationale && (rationale.problem || rationale.goal || rationale.reasoning)) {
const ratDiv = document.createElement('div');
ratDiv.className = 'rationale-container';
let ratHtml = '<div class="rationale-header"><span>🧠</span> Thought Process</div>';
if (rationale.problem) {
ratHtml += '<div class="rationale-section"><div class="rationale-label"><span>⚠️</span> Problem</div><div class="rationale-content">' + rationale.problem + '</div></div>';
}
if (rationale.goal) {
ratHtml += '<div class="rationale-section"><div class="rationale-label"><span>💡</span> Goal</div><div class="rationale-content">' + rationale.goal + '</div></div>';
}
if (rationale.reasoning) {
ratHtml += '<div class="rationale-section"><div class="rationale-label"><span>✅</span> Rationale</div><div class="rationale-content">' + rationale.reasoning + '</div></div>';
}
ratDiv.innerHTML = ratHtml;
chat.appendChild(ratDiv);
}
const head = document.createElement('div');
head.className = 'msg-head';
head.innerHTML = isUser ? '<div class="av av-user">U</div> You' : '<div class="av av-ai">✦</div> G1nation';
const body = document.createElement('div');
body.className = 'msg-body markdown-body';
if (isUser) {
body.innerText = text;
} else {
body.innerHTML = fmt(text);
}
const actions = document.createElement('div');
actions.className = 'msg-actions';
const copyBtn = document.createElement('button');
copyBtn.className = 'action-btn'; copyBtn.innerText = '📋 Copy';
copyBtn.onclick = (e) => { e.stopPropagation(); copyToClipboard(msgEl._raw, copyBtn); };
const exportBtn = document.createElement('button');
exportBtn.className = 'action-btn'; exportBtn.innerText = '💾 Export';
exportBtn.onclick = (e) => { e.stopPropagation(); exportToMD(msgEl._raw); };
actions.appendChild(copyBtn);
actions.appendChild(exportBtn);
msgEl.appendChild(head); msgEl.appendChild(body);
msgEl.appendChild(actions);
chat.appendChild(msgEl); chat.scrollTop = chat.scrollHeight;
return { body, msgEl };
}
window.addEventListener('message', e => {
const msg = e.data;
switch(msg.type) {
case 'addMessage':
addMsg(msg.value, msg.role, msg.rationale);
break;
case 'streamStart':
thinkingBar.classList.remove('active');
if (document.querySelector('.welcome')) document.querySelector('.welcome').remove();
const res = addMsg('', 'assistant');
streamBody = res.body; streamBody._parent = res.msgEl; streamBody._parent._raw = '';
streamBody.classList.add('stream-active');
break;
case 'streamChunk':
if (streamBody) {
streamBody._parent._raw += msg.value;
streamBody.innerHTML = fmt(streamBody._parent._raw);
chat.scrollTop = chat.scrollHeight;
}
break;
case 'streamEnd':
if (streamBody) streamBody.classList.remove('stream-active');
streamBody = null;
// \uc0dd\uc131 \uc644\ub8cc \uc2dc Stop \ubc84\ud2bc \uc228\uae30\uace0 Send \ubcf5\uad50
setGenerating(false);
resetStepper();
Sound.success();
break;
case 'restoreHistory':
case 'sessionLoaded':
const historyData = msg.type === 'sessionLoaded' ? msg.value : msg;
const history = Array.isArray(historyData.history) ? historyData.history : (Array.isArray(historyData) ? historyData : []);
if (history && history.length > 0) {
chat.innerHTML = '';
history.forEach(m => addMsg(m.content, m.role === 'assistant' ? 'assistant' : 'user', m.rationale));
}
if (historyData.negativePrompt !== undefined) {
negativePrompt.value = historyData.negativePrompt;
}
historyOverlay.classList.remove('visible');
break;
case 'clearChat':
chat.innerHTML = '<div class="welcome"><div class="welcome-logo">✦</div><div class="welcome-title">Welcome to G1nation</div><p>Your premium local AI assistant.<br>Ready to analyze projects and build reports.</p></div>';
break;
case 'focusInput':
input.focus();
break;
case 'modelsList': {
modelSel.innerHTML = '';
// [State Persistence - Tier 2] LocalStorage에서 마지막 선택 모델 복원 시도
const _savedModel = localStorage.getItem('g1nation_last_model');
// 서버 추천 모델 vs 로컬 저장 모델 중 우선순위 결정
// LocalStorage에 저장된 값이 현재 목록에 있으면 그것을 우선 사용 (Tier 2 우선)
const _preferredModel = (_savedModel && msg.value.models.includes(_savedModel))
? _savedModel
: msg.value.selected;
msg.value.models.forEach(m => {
const o = document.createElement('option'); o.value = m; o.innerText = m;
if (m === _preferredModel) o.selected = true;
modelSel.appendChild(o);
});
// LocalStorage에 저장된 모델이 실제로 적용된 경우, 백엔드 설정도 동기화
if (_savedModel && _savedModel !== msg.value.selected && msg.value.models.includes(_savedModel)) {
vscode.postMessage({ type: 'model', value: _savedModel });
}
if (typeof updateInputPlaceholder === 'function') updateInputPlaceholder();
statusLabel.innerText = \`Model: \${_preferredModel}\`;
break;
}
case 'brainProfiles':
brainSel.innerHTML = '';
msg.value.profiles.forEach(p => {
const o = document.createElement('option'); o.value = p.id; o.innerText = p.name;
if (p.id === msg.value.activeBrainId) o.selected = true;
brainSel.appendChild(o);
});
const addOpt = document.createElement('option');
addOpt.value = 'new'; addOpt.innerText = '+ Add New Brain...';
brainSel.appendChild(addOpt);
break;
case 'sessionList':
historyList.innerHTML = '';
msg.value.forEach(s => {
const el = document.createElement('div'); el.className = 'history-item';
el.innerHTML = \`<div style="font-weight:600; color:var(--text-bright); mb-2">\${s.title}</div><div style="font-size:10px; color:var(--text-dim)">\${new Date(s.timestamp).toLocaleString()} · \${s.messageCount} msgs</div>\`;
el.onclick = () => vscode.postMessage({ type: 'loadSession', id: s.id });
historyList.appendChild(el);
});
break;
case 'engineStatus':
statusDot.style.background = msg.value.online ? 'var(--success)' : 'var(--error)';
break;
case 'autoContinue':
statusLabel.innerText = msg.value; thinkingBar.classList.add('active');
if (msg.value.includes('Analyzing')) setStep('analyze');
if (msg.value.includes('Planning')) setStep('plan');
if (msg.value.includes('Executing')) setStep('execute');
setTimeout(() => { thinkingBar.classList.remove('active'); }, 3000);
break;
case 'agentsList':
agentSel.innerHTML = '<option value="none">No Agent</option>';
msg.value.forEach(a => {
const o = document.createElement('option'); o.value = a.path; o.innerText = a.name;
if (a.path === msg.selected) o.selected = true;
agentSel.appendChild(o);
});
if (msg.selected && msg.selected !== 'none') {
vscode.postMessage({ type: 'getAgentContent', path: msg.selected });
}
break;
case 'agentContent':
agentPrompt.value = msg.value;
negativePrompt.value = msg.negativePrompt || '';
break;
case 'error':
thinkingBar.classList.remove('active'); sendBtn.disabled = false;
addMsg(msg.value, 'error');
break;
case 'requiresApproval':
const box = document.createElement('div');
box.className = 'approval-box';
box.innerHTML = '<div class="approval-title"><span>🛡️</span> 작업 승인 대기 중 (Action Approval Required)</div>' +
'<div style="font-size: 11px; color: var(--text-dim); margin-bottom: 8px;">위의 변경 사항을 프로젝트에 반영할까요?</div>' +
'<div class="approval-btns">' +
' <button class="btn-approve" onclick="approve()">승인 (Approve)</button>' +
' <button class="btn-reject" onclick="reject()">롤백 (Rollback)</button>' +
'</div>';
chat.appendChild(box);
chat.scrollTop = chat.scrollHeight;
break;
}
});
function renderAttachments() {
attachPreview.innerHTML = '';
if (pendingFiles.length === 0) { attachPreview.classList.remove('visible'); return; }
attachPreview.classList.add('visible');
pendingFiles.forEach((f, i) => {
const chip = document.createElement('div'); chip.className = 'file-chip';
chip.innerHTML = \`<span>📎</span> \${f.name} <span class="remove" onclick="removeFile(\${i})">✕</span>\`;
attachPreview.appendChild(chip);
});
}
window.removeFile = (i) => {
pendingFiles.splice(i, 1);
renderAttachments();
// 파일 삭제 후 Draft State 재평가
setDraftActive(input.value.trim().length > 0 || pendingFiles.length > 0);
};
function processFiles(files) {
if (!files || files.length === 0) return;
Array.from(files).forEach(file => {
const reader = new FileReader();
reader.onload = () => {
const base64 = reader.result.split(',')[1];
pendingFiles.push({ name: file.name, type: file.type, data: base64 });
renderAttachments();
setDraftActive(true);
};
reader.readAsDataURL(file);
});
showToast(`\${files.length} .`, 'success');
Sound.success();
}
attachBtn.onclick = () => fileInput.click();
fileInput.onchange = () => {
processFiles(fileInput.files);
fileInput.value = '';
};
// --- Drag and Drop Implementation ---
['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
document.body.addEventListener(eventName, e => {
e.preventDefault();
e.stopPropagation();
}, false);
});
['dragenter', 'dragover'].forEach(eventName => {
document.body.addEventListener(eventName, () => {
document.body.classList.add('drag-over');
}, false);
});
['dragleave', 'drop'].forEach(eventName => {
document.body.addEventListener(eventName, () => {
document.body.classList.remove('drag-over');
}, false);
});
document.body.addEventListener('drop', e => {
const dt = e.dataTransfer;
const files = dt.files;
processFiles(files);
}, false);
function send() {
const val = input.value.trim();
if (!val && pendingFiles.length === 0) return;
addMsg(val || (pendingFiles.length > 0 ? \`[Sent \${pendingFiles.length} files]\` : ''), 'user');
vscode.postMessage({
type: 'prompt',
value: val,
model: modelSel.value,
internet: internetEnabled,
files: pendingFiles.length > 0 ? pendingFiles : undefined,
agentFile: agentSel.value === 'none' ? undefined : agentSel.value,
negativePrompt: negativePrompt.value.trim() || undefined
});
input.value = ''; input.style.height = 'auto'; pendingFiles = []; renderAttachments();
// 전송 완료 후 Draft State 리셋 + Stop 버튼 표시
setDraftActive(false);
setGenerating(true);
thinkingBar.classList.add('active');
}
sendBtn.onclick = send;
input.addEventListener('keydown', e => {
if (e.key === 'Enter' && !e.shiftKey) {
if (e.isComposing) return;
e.preventDefault();
send();
}
});
input.addEventListener('input', () => {
input.style.height = 'auto';
input.style.height = input.scrollHeight + 'px';
// Draft State: 내용이 있으면 cancelBtn 표시
setDraftActive(input.value.trim().length > 0 || pendingFiles.length > 0);
});
cancelBtn.onclick = () => clearDraft();
stopBtn.onclick = () => {
vscode.postMessage({ type: 'stopGeneration' });
setGenerating(false);
thinkingBar.classList.remove('active');
showToast('■ 생성이 중단되었습니다.', 'warn');
Sound.warn();
};
const startNewChat = () => { Sound.play(660, 'sine', 0.1); vscode.postMessage({ type: 'newChat' }); };
document.getElementById('newChatBtn').onclick = startNewChat;
document.getElementById('inputNewChatBtn').onclick = startNewChat;
document.getElementById('settingsBtn').onclick = () => vscode.postMessage({ type: 'openSettings' });
document.getElementById('internetBtn').onclick = () => {
internetEnabled = !internetEnabled; document.getElementById('internetBtn').classList.toggle('active', internetEnabled);
};
let multiAgentEnabled = true;
document.getElementById('multiAgentBtn').onclick = () => {
multiAgentEnabled = !multiAgentEnabled;
vscode.postMessage({ type: 'toggleMultiAgent', value: multiAgentEnabled });
document.getElementById('multiAgentBtn').classList.toggle('active', multiAgentEnabled);
};
const syncBrain = () => { Sound.play(550, 'sine', 0.1); vscode.postMessage({ type: 'syncBrain' }); };
document.getElementById('brainBtn').onclick = syncBrain;
document.getElementById('inputSyncBtn').onclick = syncBrain;
document.getElementById('historyBtn').onclick = () => vscode.postMessage({ type: 'getSessions' });
document.getElementById('historyBtn').addEventListener('click', () => historyOverlay.classList.add('visible'));
document.getElementById('closeHistoryBtn').onclick = () => historyOverlay.classList.remove('visible');
const updateInputPlaceholder = () => {
promptInput.placeholder = \`Ask \${modelSel.value}...\`;
};
modelSel.onchange = () => {
const _selectedModel = modelSel.value;
// [State Persistence - Tier 2] 모델 변경 시 LocalStorage에 즉시 저장 (클라이언트 측 지속성)
try {
localStorage.setItem('g1nation_last_model', _selectedModel);
} catch(e) {
console.warn('[G1nation] LocalStorage 저장 실패:', e);
}
// [State Persistence - Tier 1] VS Code 전역 설정에 동기화 (영구 저장)
vscode.postMessage({ type: 'model', value: _selectedModel });
updateInputPlaceholder();
// 상태 레이블 즉시 업데이트
statusLabel.innerText = \`Model: \${_selectedModel}\`;
};
brainSel.onchange = () => {
if (brainSel.value === 'new') {
vscode.postMessage({ type: 'addBrain' });
} else {
vscode.postMessage({ type: 'setBrainProfile', id: brainSel.value });
}
};
// Handle initial state and state updates from extension
window.addEventListener('message', e => {
const msg = e.data;
if (msg.type === 'configUpdate') {
multiAgentEnabled = msg.value.multiAgentEnabled;
document.getElementById('multiAgentBtn').classList.toggle('active', multiAgentEnabled);
}
});
agentSel.onchange = () => {
if (agentSel.value !== 'none') {
vscode.postMessage({ type: 'getAgentContent', path: agentSel.value });
if (editMode) agentConfigPanel.style.display = 'flex';
} else {
agentConfigPanel.style.display = 'none';
editMode = false;
editAgentBtn.classList.remove('active');
agentPrompt.value = '';
negativePrompt.value = '';
}
};
editAgentBtn.onclick = () => {
if (agentSel.value === 'none') return;
editMode = !editMode;
editAgentBtn.classList.toggle('active', editMode);
agentConfigPanel.style.display = editMode ? 'flex' : 'none';
};
updateAgentBtn.onclick = () => {
if (agentSel.value !== 'none') {
vscode.postMessage({
type: 'updateAgent',
path: agentSel.value,
content: agentPrompt.value,
negativePrompt: negativePrompt.value.trim()
});
}
};
addAgentBtn.onclick = () => vscode.postMessage({ type: 'createAgent' });
vscode.postMessage({ type: 'getModels' });
vscode.postMessage({ type: 'getAgents' });
vscode.postMessage({ type: 'ready' });
// --- Proactive Behavioral Tracking ---
let hoverTimer = null;
const trackBehavior = (elementId, context) => {
const el = document.getElementById(elementId);
if (!el) return;
el.addEventListener('mouseenter', () => {
hoverTimer = setTimeout(() => {
vscode.postMessage({ type: 'proactiveTrigger', context: context });
}, 5000); // 5 seconds threshold
});
el.addEventListener('mouseleave', () => {
if (hoverTimer) clearTimeout(hoverTimer);
});
};
trackBehavior('settingsBtn', 'settings_exploration');
trackBehavior('brainBtn', 'brain_sync_exploration');
trackBehavior('agentSel', 'agent_selection_exploration');
</script>
</body>
</html>`;
}
}