3270 lines
144 KiB
TypeScript
3270 lines
144 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';
|
|
import { buildProjectChronicleGuardContext, ProjectChronicleManager, ProjectProfile } from './features/projectChronicle';
|
|
|
|
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 static readonly chronicleProjectsStateKey = 'g1nation.chronicleProjects';
|
|
private static readonly activeChronicleProjectStateKey = 'g1nation.activeChronicleProjectId';
|
|
private _view?: vscode.WebviewView;
|
|
public brainEnabled = true;
|
|
private _currentSessionBrainId: string | null = null;
|
|
private _currentNegativePrompt: string = '';
|
|
private readonly _chronicle = new ProjectChronicleManager();
|
|
|
|
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._sendChronicleProjects();
|
|
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 'getSessions':
|
|
await this._sendSessionList();
|
|
break;
|
|
case 'getAgents':
|
|
await this._sendAgentsList();
|
|
break;
|
|
case 'getChronicleProjects':
|
|
await this._sendChronicleProjects();
|
|
break;
|
|
case 'createChronicleProject':
|
|
await this._createChronicleProject();
|
|
break;
|
|
case 'setChronicleProject':
|
|
await this._setActiveChronicleProject(data.id);
|
|
break;
|
|
case 'openChronicleFolder':
|
|
await this._openChronicleFolder();
|
|
break;
|
|
case 'getChronicleRecords':
|
|
await this._sendChronicleRecords();
|
|
break;
|
|
case 'openChronicleRecord':
|
|
await this._openChronicleRecord(data.path);
|
|
break;
|
|
case 'writeChroniclePlanning':
|
|
await this._writeChroniclePlanningFromCurrentChat();
|
|
break;
|
|
case 'writeChronicleDiscussion':
|
|
await this._writeChronicleDiscussionFromCurrentChat();
|
|
break;
|
|
case 'writeChronicleDecision':
|
|
await this._writeChronicleDecisionFromInput();
|
|
break;
|
|
case 'writeChronicleDevelopment':
|
|
await this._writeChronicleDevelopmentFromCurrentChat();
|
|
break;
|
|
case 'writeChronicleBug':
|
|
await this._writeChronicleBugFromInput();
|
|
break;
|
|
case 'writeChronicleRetrospective':
|
|
await this._writeChronicleRetrospectiveFromInput();
|
|
break;
|
|
case 'writeChronicleRecord':
|
|
await this._writeChronicleRecord(data.recordType);
|
|
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 'editBrain':
|
|
await this._editBrainProfile(data.id);
|
|
break;
|
|
case 'deleteBrain':
|
|
await this._deleteBrainProfile(data.id);
|
|
break;
|
|
case 'saveWikiRaw':
|
|
await this._saveWikiRaw();
|
|
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 'deleteAgent':
|
|
await this._deleteAgent(data.path);
|
|
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);
|
|
const currentHistory = this._agent.getHistory();
|
|
|
|
if (blankChatActive && currentHistory.length === 0) {
|
|
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);
|
|
}
|
|
|
|
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) || this._getSessionById(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 _getSessionById(id: string): ChatSession | null {
|
|
const rawSessions = this._context.globalState.get<any[]>('chat_sessions', []) || [];
|
|
const raw = rawSessions.find((session: any) => String(session?.id) === String(id));
|
|
if (!raw) return null;
|
|
|
|
const history = Array.isArray(raw.history)
|
|
? raw.history.filter((message: any) =>
|
|
message
|
|
&& (message.role === 'user' || message.role === 'assistant' || message.role === 'system')
|
|
&& message.content !== undefined
|
|
)
|
|
: [];
|
|
if (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 Session';
|
|
|
|
return {
|
|
id: String(raw.id),
|
|
title: String(raw.title || fallbackTitle),
|
|
timestamp: typeof raw.timestamp === 'number' ? raw.timestamp : Date.now(),
|
|
history,
|
|
brainProfileId: String(raw.brainProfileId || getActiveBrainProfile().id),
|
|
negativePrompt: String(raw.negativePrompt || '')
|
|
};
|
|
}
|
|
|
|
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 _postBrainProfiles(profiles: any[], activeBrainId: string) {
|
|
if (!this._view) return;
|
|
this._view.webview.postMessage({
|
|
type: 'brainProfiles',
|
|
value: {
|
|
activeBrainId,
|
|
profiles: profiles.map((p: any) => ({
|
|
id: p.id || '',
|
|
name: p.name || '',
|
|
path: p.localBrainPath || '',
|
|
description: p.description || '',
|
|
repo: p.secondBrainRepo || ''
|
|
}))
|
|
}
|
|
});
|
|
}
|
|
|
|
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;
|
|
|
|
// cfg.update() is async and VSCode's config cache may not reflect the new value
|
|
// immediately, so we post the freshly-built profile list directly.
|
|
this._postBrainProfiles(nextProfiles, id);
|
|
await this._sendBrainStatus();
|
|
this.injectSystemMessage(`**[Brain Added]** ${name.trim()}\n\`${folder}\``);
|
|
}
|
|
|
|
private async _editBrainProfile(profileId?: string) {
|
|
const currentProfiles = getBrainProfiles();
|
|
const target = currentProfiles.find((profile) => profile.id === profileId) || getActiveBrainProfile();
|
|
if (!target) return;
|
|
|
|
const name = await vscode.window.showInputBox({
|
|
prompt: 'Edit brain profile name',
|
|
value: target.name,
|
|
validateInput: (value) => value.trim() ? null : 'Brain name is required.'
|
|
});
|
|
if (!name) return;
|
|
|
|
const folder = await vscode.window.showInputBox({
|
|
prompt: 'Edit local brain folder path',
|
|
value: target.localBrainPath,
|
|
validateInput: (value) => value.trim() ? null : 'Brain folder path is required.'
|
|
});
|
|
if (!folder) return;
|
|
|
|
const description = await vscode.window.showInputBox({
|
|
prompt: 'Edit optional description shown in the G1nation sidebar',
|
|
value: target.description || ''
|
|
});
|
|
|
|
const repo = await vscode.window.showInputBox({
|
|
prompt: 'Edit optional Second Brain Git repository URL',
|
|
value: target.secondBrainRepo || ''
|
|
});
|
|
|
|
const nextProfiles = currentProfiles.map((profile) => profile.id === target.id
|
|
? {
|
|
...profile,
|
|
name: name.trim(),
|
|
localBrainPath: folder.trim(),
|
|
secondBrainRepo: (repo || '').trim(),
|
|
description: (description || '').trim()
|
|
}
|
|
: profile
|
|
);
|
|
|
|
const cfg = vscode.workspace.getConfiguration('g1nation');
|
|
await cfg.update('brainProfiles', nextProfiles, vscode.ConfigurationTarget.Global);
|
|
await cfg.update('activeBrainId', target.id, vscode.ConfigurationTarget.Global);
|
|
this._currentSessionBrainId = target.id;
|
|
this._postBrainProfiles(nextProfiles, target.id);
|
|
await this._sendBrainStatus();
|
|
this.injectSystemMessage(`**[Brain Updated]** ${name.trim()}\n\`${folder.trim()}\``);
|
|
}
|
|
|
|
private async _deleteBrainProfile(profileId?: string) {
|
|
const currentProfiles = getBrainProfiles();
|
|
const target = currentProfiles.find((profile) => profile.id === profileId) || getActiveBrainProfile();
|
|
if (!target) return;
|
|
|
|
if (currentProfiles.length <= 1) {
|
|
vscode.window.showWarningMessage('At least one brain profile is required.');
|
|
return;
|
|
}
|
|
|
|
const confirm = await vscode.window.showWarningMessage(
|
|
`Delete brain profile "${target.name}"? The folder itself will not be deleted.`,
|
|
{ modal: true },
|
|
'Delete Profile'
|
|
);
|
|
if (confirm !== 'Delete Profile') return;
|
|
|
|
const nextProfiles = currentProfiles.filter((profile) => profile.id !== target.id);
|
|
const nextActive = nextProfiles[0];
|
|
const cfg = vscode.workspace.getConfiguration('g1nation');
|
|
await cfg.update('brainProfiles', nextProfiles, vscode.ConfigurationTarget.Global);
|
|
await cfg.update('activeBrainId', nextActive.id, vscode.ConfigurationTarget.Global);
|
|
this._currentSessionBrainId = nextActive.id;
|
|
this._postBrainProfiles(nextProfiles, nextActive.id);
|
|
await this._sendBrainStatus();
|
|
this.injectSystemMessage(`**[Brain Deleted]** ${target.name}`);
|
|
}
|
|
|
|
private async _saveWikiRaw() {
|
|
const history = this._agent.getHistory();
|
|
if (history.length === 0) {
|
|
vscode.window.showWarningMessage('There is no conversation to save as wiki raw data.');
|
|
return;
|
|
}
|
|
|
|
const activeBrain = getActiveBrainProfile();
|
|
const rawDir = path.join(activeBrain.localBrainPath, 'raw-data');
|
|
if (!fs.existsSync(rawDir)) {
|
|
fs.mkdirSync(rawDir, { recursive: true });
|
|
}
|
|
|
|
const category = await vscode.window.showInputBox({
|
|
prompt: 'Wiki raw data category',
|
|
value: 'Project Notes'
|
|
});
|
|
if (category === undefined) return;
|
|
|
|
const expectedValue = await vscode.window.showInputBox({
|
|
prompt: 'Expected value or future use',
|
|
value: 'Reusable as source material for a future wiki document.'
|
|
});
|
|
if (expectedValue === undefined) return;
|
|
|
|
const timestamp = new Date();
|
|
const slug = this._slugify(history.find((message) => message.role === 'user')?.content || 'conversation');
|
|
const fileName = `${this._formatTimestampForFile(timestamp)}-${slug}.md`;
|
|
const filePath = path.join(rawDir, fileName);
|
|
const markdown = this._buildWikiRawMarkdown(history, {
|
|
category: category.trim() || 'Project Notes',
|
|
expectedValue: expectedValue.trim() || 'Reusable as source material for a future wiki document.',
|
|
activeBrainName: activeBrain.name,
|
|
activeBrainPath: activeBrain.localBrainPath,
|
|
createdAt: timestamp.toISOString()
|
|
});
|
|
|
|
await vscode.workspace.fs.writeFile(vscode.Uri.file(filePath), Buffer.from(markdown, 'utf8'));
|
|
vscode.window.showInformationMessage(`Wiki raw data saved: ${path.basename(filePath)}`);
|
|
this.injectSystemMessage(`**[Wiki Raw Saved]** \`${filePath}\``);
|
|
}
|
|
|
|
private _buildWikiRawMarkdown(history: ChatMessage[], meta: {
|
|
category: string;
|
|
expectedValue: string;
|
|
activeBrainName: string;
|
|
activeBrainPath: string;
|
|
createdAt: string;
|
|
}): string {
|
|
const firstUserMessage = history.find((message) => message.role === 'user')?.content || '';
|
|
const latestUserMessage = [...history].reverse().find((message) => message.role === 'user')?.content || '';
|
|
const latestAssistantMessage = [...history].reverse().find((message) => message.role === 'assistant')?.content || '';
|
|
const rationaleNotes = history
|
|
.filter((message) => message.role === 'assistant' && message.rationale)
|
|
.map((message, index) => [
|
|
`### Process Note ${index + 1}`,
|
|
message.rationale?.problem ? `- Problem: ${message.rationale.problem}` : '',
|
|
message.rationale?.goal ? `- Goal: ${message.rationale.goal}` : '',
|
|
message.rationale?.reasoning ? `- Reasoning: ${message.rationale.reasoning}` : ''
|
|
].filter(Boolean).join('\n'))
|
|
.join('\n\n');
|
|
|
|
const transcript = history
|
|
.map((message, index) => [
|
|
`### ${index + 1}. ${message.role.toUpperCase()}`,
|
|
'',
|
|
message.content.trim() || '(empty)'
|
|
].join('\n'))
|
|
.join('\n\n');
|
|
|
|
return [
|
|
'---',
|
|
`title: "${this._escapeYamlString(this._summarizeForTitle(firstUserMessage))}"`,
|
|
`category: "${this._escapeYamlString(meta.category)}"`,
|
|
`created_at: "${meta.createdAt}"`,
|
|
`source: "ConnectAI conversation"`,
|
|
`brain: "${this._escapeYamlString(meta.activeBrainName)}"`,
|
|
'status: raw',
|
|
'---',
|
|
'',
|
|
`# ${this._summarizeForTitle(firstUserMessage)}`,
|
|
'',
|
|
'## Category',
|
|
meta.category,
|
|
'',
|
|
'## What This Contains',
|
|
this._summarizeTextForWiki(latestAssistantMessage || firstUserMessage),
|
|
'',
|
|
'## Expected Value',
|
|
meta.expectedValue,
|
|
'',
|
|
'## Discovery Process',
|
|
rationaleNotes || 'No explicit rationale metadata was captured. Use the transcript below to reconstruct the reasoning path.',
|
|
'',
|
|
'## Conclusion',
|
|
[
|
|
'This conclusion was derived from the latest user request and assistant response.',
|
|
'',
|
|
`- Latest user request: ${this._summarizeTextForWiki(latestUserMessage)}`,
|
|
`- Latest assistant conclusion: ${this._summarizeTextForWiki(latestAssistantMessage)}`
|
|
].join('\n'),
|
|
'',
|
|
'## Coding Implementation Notes',
|
|
'Use this section to turn the raw transcript into a wiki-ready implementation note. Capture which files changed, why they changed, and what verification was run.',
|
|
'',
|
|
'## Source Brain',
|
|
`- Name: ${meta.activeBrainName}`,
|
|
`- Path: ${meta.activeBrainPath}`,
|
|
'',
|
|
'## Raw Conversation Transcript',
|
|
transcript,
|
|
''
|
|
].join('\n');
|
|
}
|
|
|
|
private _formatTimestampForFile(date: Date): string {
|
|
return date.toISOString().replace(/[:.]/g, '-').replace('T', '_').replace('Z', '');
|
|
}
|
|
|
|
private _slugify(value: string): string {
|
|
const slug = value
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9가-힣]+/g, '-')
|
|
.replace(/^-|-$/g, '')
|
|
.slice(0, 48);
|
|
return slug || 'conversation';
|
|
}
|
|
|
|
private _summarizeForTitle(value: string): string {
|
|
const normalized = value.replace(/\s+/g, ' ').trim();
|
|
if (!normalized) return 'ConnectAI Conversation Raw Data';
|
|
return normalized.length > 80 ? `${normalized.slice(0, 80)}...` : normalized;
|
|
}
|
|
|
|
private _summarizeTextForWiki(value: string): string {
|
|
const normalized = value.replace(/\s+/g, ' ').trim();
|
|
if (!normalized) return 'Not captured.';
|
|
return normalized.length > 500 ? `${normalized.slice(0, 500)}...` : normalized;
|
|
}
|
|
|
|
private _escapeYamlString(value: string): string {
|
|
return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
|
}
|
|
|
|
// --- 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 _getChronicleProjects(): ProjectProfile[] {
|
|
const raw = this._context.globalState.get<ProjectProfile[]>(SidebarChatProvider.chronicleProjectsStateKey, []) || [];
|
|
const valid = raw.filter((profile: ProjectProfile) =>
|
|
profile
|
|
&& typeof profile.projectId === 'string'
|
|
&& typeof profile.projectName === 'string'
|
|
&& typeof profile.recordRoot === 'string'
|
|
);
|
|
|
|
if (valid.length > 0) return valid;
|
|
|
|
const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath;
|
|
if (!workspaceRoot) return [];
|
|
|
|
const now = new Date().toISOString();
|
|
const projectName = path.basename(workspaceRoot) || 'Current Project';
|
|
return [{
|
|
projectId: this._slugify(projectName),
|
|
projectName,
|
|
projectRoot: workspaceRoot,
|
|
recordRoot: path.join(workspaceRoot, 'docs', 'records', projectName),
|
|
description: 'Auto-detected current workspace project.',
|
|
corePurpose: 'Capture project planning, decisions, development notes, bugs, and retrospectives as Markdown.',
|
|
targetUsers: ['Project developer'],
|
|
avoidDirections: ['Do not tightly couple records to chat execution internals.'],
|
|
detailLevel: 'standard',
|
|
createdAt: now,
|
|
updatedAt: now
|
|
}];
|
|
}
|
|
|
|
private async _putChronicleProjects(projects: ProjectProfile[]) {
|
|
await this._context.globalState.update(SidebarChatProvider.chronicleProjectsStateKey, projects);
|
|
}
|
|
|
|
private _getActiveChronicleProject(): ProjectProfile | null {
|
|
const projects = this._getChronicleProjects();
|
|
if (projects.length === 0) return null;
|
|
const activeId = this._context.globalState.get<string>(SidebarChatProvider.activeChronicleProjectStateKey, '');
|
|
return projects.find(project => project.projectId === activeId) || projects[0];
|
|
}
|
|
|
|
private async _sendChronicleProjects() {
|
|
if (!this._view) return;
|
|
const projects = this._getChronicleProjects();
|
|
const active = this._getActiveChronicleProject();
|
|
this._view.webview.postMessage({
|
|
type: 'chronicleProjects',
|
|
value: {
|
|
activeProjectId: active?.projectId || '',
|
|
projects: projects.map(project => ({
|
|
id: project.projectId,
|
|
name: project.projectName,
|
|
root: project.projectRoot || '',
|
|
recordRoot: project.recordRoot,
|
|
description: project.description || ''
|
|
}))
|
|
}
|
|
});
|
|
}
|
|
|
|
private async _createChronicleProject() {
|
|
const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || '';
|
|
const defaultName = workspaceRoot ? path.basename(workspaceRoot) : 'New Project';
|
|
|
|
const projectName = await vscode.window.showInputBox({
|
|
prompt: 'Project name for Chronicle records',
|
|
value: defaultName,
|
|
validateInput: (value) => value.trim() ? null : 'Project name is required.'
|
|
});
|
|
if (!projectName) return;
|
|
|
|
const description = await vscode.window.showInputBox({
|
|
prompt: 'One-line project description',
|
|
value: 'Project planning, decisions, development logs, and bug records.'
|
|
});
|
|
if (description === undefined) return;
|
|
|
|
const projectRoot = await vscode.window.showInputBox({
|
|
prompt: 'Project root path',
|
|
value: workspaceRoot,
|
|
validateInput: (value) => value.trim() ? null : 'Project root is required.'
|
|
});
|
|
if (!projectRoot) return;
|
|
|
|
const defaultRecordRoot = path.join(projectRoot.trim(), 'docs', 'records', projectName.trim());
|
|
const recordRoot = await vscode.window.showInputBox({
|
|
prompt: 'Markdown record folder path',
|
|
value: defaultRecordRoot,
|
|
validateInput: (value) => value.trim() ? null : 'Record folder path is required.'
|
|
});
|
|
if (!recordRoot) return;
|
|
|
|
const corePurpose = await vscode.window.showInputBox({
|
|
prompt: 'Core project purpose or guardrail',
|
|
value: 'Keep project knowledge traceable through Markdown records.'
|
|
});
|
|
if (corePurpose === undefined) return;
|
|
|
|
const detailChoice = await vscode.window.showQuickPick([
|
|
{ label: 'standard', description: 'Request, question intent, decisions, planning, development, and bugs' },
|
|
{ label: 'simple', description: 'Request summary, decisions, and implementation result' },
|
|
{ label: 'detailed', description: 'Standard records plus alternatives, lessons, and retrospectives' }
|
|
], {
|
|
placeHolder: 'Chronicle record detail level'
|
|
});
|
|
if (!detailChoice) return;
|
|
|
|
const now = new Date().toISOString();
|
|
const projects = this._getChronicleProjects();
|
|
const idBase = this._slugify(projectName.trim());
|
|
let projectId = idBase;
|
|
let suffix = 2;
|
|
while (projects.some(project => project.projectId === projectId)) {
|
|
projectId = `${idBase}-${suffix++}`;
|
|
}
|
|
|
|
const profile: ProjectProfile = {
|
|
projectId,
|
|
projectName: projectName.trim(),
|
|
projectRoot: projectRoot.trim(),
|
|
recordRoot: recordRoot.trim(),
|
|
description: description.trim(),
|
|
corePurpose: corePurpose.trim(),
|
|
targetUsers: ['Project developer'],
|
|
avoidDirections: ['Do not mix records across projects.', 'Do not tightly couple records to core agent execution.'],
|
|
detailLevel: detailChoice.label as ProjectProfile['detailLevel'],
|
|
createdAt: now,
|
|
updatedAt: now
|
|
};
|
|
|
|
this._chronicle.ensureProject(profile);
|
|
const nextProjects = [...projects.filter(project => project.projectId !== profile.projectId), profile];
|
|
await this._putChronicleProjects(nextProjects);
|
|
await this._context.globalState.update(SidebarChatProvider.activeChronicleProjectStateKey, profile.projectId);
|
|
await this._sendChronicleProjects();
|
|
this.injectSystemMessage(`**[Designer Project Created]** ${profile.projectName}\n\`${profile.recordRoot}\``);
|
|
}
|
|
|
|
private async _setActiveChronicleProject(projectId: string) {
|
|
if (!projectId || projectId === 'new') {
|
|
await this._createChronicleProject();
|
|
return;
|
|
}
|
|
|
|
const target = this._getChronicleProjects().find(project => project.projectId === projectId);
|
|
if (!target) return;
|
|
await this._context.globalState.update(SidebarChatProvider.activeChronicleProjectStateKey, target.projectId);
|
|
await this._sendChronicleProjects();
|
|
await this._sendChronicleRecords();
|
|
this.injectSystemMessage(`**[Designer Project Selected]** ${target.projectName}\n\`${target.recordRoot}\``);
|
|
}
|
|
|
|
private async _openChronicleFolder() {
|
|
const profile = this._getActiveChronicleProject();
|
|
if (!profile) {
|
|
vscode.window.showWarningMessage('No Chronicle project is selected.');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
this._chronicle.ensureProject(profile);
|
|
await vscode.commands.executeCommand('revealFileInOS', vscode.Uri.file(profile.recordRoot));
|
|
} catch (err: any) {
|
|
vscode.window.showErrorMessage(`Failed to open Chronicle folder: ${err.message}`);
|
|
}
|
|
}
|
|
|
|
private async _sendChronicleRecords() {
|
|
if (!this._view) return;
|
|
const profile = this._getActiveChronicleProject();
|
|
if (!profile) {
|
|
this._view.webview.postMessage({ type: 'chronicleRecords', value: [] });
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const records = this._chronicle.listRecords(profile).map(record => ({
|
|
section: record.section,
|
|
fileName: record.fileName,
|
|
path: record.filePath,
|
|
relativePath: record.relativePath,
|
|
updatedAt: record.updatedAt
|
|
}));
|
|
this._view.webview.postMessage({ type: 'chronicleRecords', value: records });
|
|
} catch (err: any) {
|
|
vscode.window.showErrorMessage(`Failed to list Chronicle records: ${err.message}`);
|
|
}
|
|
}
|
|
|
|
private async _openChronicleRecord(recordPath: string) {
|
|
const profile = this._getActiveChronicleProject();
|
|
if (!profile || !recordPath) {
|
|
vscode.window.showWarningMessage('Select a Chronicle record first.');
|
|
return;
|
|
}
|
|
|
|
const root = path.resolve(profile.recordRoot);
|
|
const target = path.resolve(recordPath);
|
|
if (!target.startsWith(`${root}${path.sep}`) || path.extname(target) !== '.md') {
|
|
vscode.window.showErrorMessage('Selected Chronicle record path is not valid.');
|
|
return;
|
|
}
|
|
|
|
if (!fs.existsSync(target)) {
|
|
vscode.window.showErrorMessage('Selected Chronicle record no longer exists.');
|
|
await this._sendChronicleRecords();
|
|
return;
|
|
}
|
|
|
|
const doc = await vscode.workspace.openTextDocument(target);
|
|
await vscode.window.showTextDocument(doc);
|
|
}
|
|
|
|
private async _writeChroniclePlanningFromCurrentChat() {
|
|
const profile = this._getActiveChronicleProject();
|
|
if (!profile) {
|
|
vscode.window.showWarningMessage('Select or create a Designer project first.');
|
|
return;
|
|
}
|
|
|
|
const history = this._agent.getHistory();
|
|
const latestUser = [...history].reverse().find(message => message.role === 'user')?.content || '';
|
|
const latestAssistant = [...history].reverse().find(message => message.role === 'assistant')?.content || '';
|
|
const featureName = await vscode.window.showInputBox({
|
|
prompt: 'Feature name for the planning document',
|
|
value: this._summarizeForTitle(latestUser || 'Project Chronicle Feature')
|
|
});
|
|
if (!featureName) return;
|
|
|
|
try {
|
|
const createdAt = new Date().toISOString();
|
|
const result = this._chronicle.writePlanning(profile, {
|
|
featureName: featureName.trim(),
|
|
purpose: 'Record the reason, scope, direction, and success criteria before implementation.',
|
|
background: this._summarizeTextForWiki(latestAssistant || latestUser),
|
|
userIntent: this._summarizeTextForWiki(latestUser),
|
|
sourceRequest: latestUser || 'No user request captured in the current chat.',
|
|
scope: [
|
|
'Create a project-specific planning record.',
|
|
'Capture user intent and implementation direction.',
|
|
'Keep the record independent from chat execution internals.'
|
|
],
|
|
outOfScope: [
|
|
'Full automatic transcript capture.',
|
|
'External database integration.',
|
|
'Git automation.'
|
|
],
|
|
developmentDirection: 'Use Project Chronicle as a low-dependency Markdown record layer.',
|
|
dependencyStrategy: 'Use local filesystem writes through the independent projectChronicle module.',
|
|
expectedValue: 'Future work can understand why this feature exists and what decisions shaped it.',
|
|
successCriteria: [
|
|
'The planning document is created under the selected project record folder.',
|
|
'The document includes user intent, scope, out-of-scope items, and success criteria.'
|
|
],
|
|
developerInstruction: 'Use this document as the implementation guardrail for the next development step.',
|
|
createdAt
|
|
});
|
|
this._chronicle.appendTimeline(profile, [`Planning record created: ${result.relativePath}`], createdAt);
|
|
vscode.window.showInformationMessage(`Chronicle planning saved: ${result.relativePath}`);
|
|
await this._sendChronicleRecords();
|
|
this.injectSystemMessage(`**[Chronicle Planning Saved]** \`${result.filePath}\``);
|
|
} catch (err: any) {
|
|
vscode.window.showErrorMessage(`Failed to write planning record: ${err.message}`);
|
|
}
|
|
}
|
|
|
|
private async _writeChronicleDiscussionFromCurrentChat() {
|
|
const profile = this._getActiveChronicleProject();
|
|
if (!profile) {
|
|
vscode.window.showWarningMessage('Select or create a Designer project first.');
|
|
return;
|
|
}
|
|
|
|
const history = this._agent.getHistory();
|
|
const latestUser = [...history].reverse().find(message => message.role === 'user')?.content || '';
|
|
const latestAssistant = [...history].reverse().find(message => message.role === 'assistant')?.content || '';
|
|
const title = await vscode.window.showInputBox({
|
|
prompt: 'Discussion title',
|
|
value: this._summarizeForTitle(latestUser || 'Project Discussion')
|
|
});
|
|
if (!title) return;
|
|
|
|
const question = await vscode.window.showInputBox({
|
|
prompt: 'AI question to record (optional)',
|
|
value: ''
|
|
});
|
|
if (question === undefined) return;
|
|
|
|
let questions: any[] = [];
|
|
if (question.trim()) {
|
|
const reason = await vscode.window.showInputBox({
|
|
prompt: 'Why was this question asked?',
|
|
value: 'To avoid writing records to the wrong project or making an unclear design decision.'
|
|
});
|
|
if (reason === undefined) return;
|
|
|
|
const impact = await vscode.window.showInputBox({
|
|
prompt: 'How does this question affect the decision?',
|
|
value: 'It determines the correct project context, scope, or implementation path.'
|
|
});
|
|
if (impact === undefined) return;
|
|
|
|
questions = [{
|
|
question: question.trim(),
|
|
reason: reason.trim(),
|
|
expectedInformation: 'Information needed to clarify project context, scope, or decision direction.',
|
|
impactOnDecision: impact.trim()
|
|
}];
|
|
}
|
|
|
|
try {
|
|
const createdAt = new Date().toISOString();
|
|
const result = this._chronicle.writeDiscussion(profile, {
|
|
title: title.trim(),
|
|
userRequest: this._summarizeTextForWiki(latestUser || 'No user request captured in the current chat.'),
|
|
interpretedIntent: 'Capture the discussion as a reusable project knowledge record instead of leaving it only in chat history.',
|
|
questions,
|
|
discussions: [
|
|
this._summarizeTextForWiki(latestAssistant || latestUser || 'No discussion detail captured yet.')
|
|
],
|
|
decisions: [],
|
|
createdAt
|
|
});
|
|
this._chronicle.appendTimeline(profile, [`Discussion record created: ${result.relativePath}`], createdAt);
|
|
vscode.window.showInformationMessage(`Chronicle discussion saved: ${result.relativePath}`);
|
|
await this._sendChronicleRecords();
|
|
this.injectSystemMessage(`**[Chronicle Discussion Saved]** \`${result.filePath}\``);
|
|
} catch (err: any) {
|
|
vscode.window.showErrorMessage(`Failed to write discussion record: ${err.message}`);
|
|
}
|
|
}
|
|
|
|
private async _writeChronicleDecisionFromInput() {
|
|
const profile = this._getActiveChronicleProject();
|
|
if (!profile) {
|
|
vscode.window.showWarningMessage('Select or create a Designer project first.');
|
|
return;
|
|
}
|
|
|
|
const title = await vscode.window.showInputBox({
|
|
prompt: 'Decision title',
|
|
value: 'Use independent Markdown record module'
|
|
});
|
|
if (!title) return;
|
|
|
|
const decision = await vscode.window.showInputBox({
|
|
prompt: 'Decision',
|
|
value: 'Implement this behavior as an independent Project Chronicle module.'
|
|
});
|
|
if (decision === undefined) return;
|
|
|
|
const reason = await vscode.window.showInputBox({
|
|
prompt: 'Decision reason',
|
|
value: 'To reduce coupling and keep project records portable.'
|
|
});
|
|
if (reason === undefined) return;
|
|
|
|
const alternatives = await vscode.window.showInputBox({
|
|
prompt: 'Rejected alternatives (comma-separated)',
|
|
value: 'Integrate with Second Brain, integrate directly into Agent execution'
|
|
});
|
|
if (alternatives === undefined) return;
|
|
|
|
try {
|
|
const createdAt = new Date().toISOString();
|
|
const adrNumber = this._chronicle.nextAdrNumber(profile);
|
|
const result = this._chronicle.writeDecision(profile, {
|
|
title: title.trim(),
|
|
status: 'accepted',
|
|
context: 'A project record needs to capture not only what changed, but why the direction was chosen.',
|
|
decision: decision.trim(),
|
|
reason: reason.trim(),
|
|
alternatives: alternatives.split(',').map(item => item.trim()).filter(Boolean),
|
|
consequences: [
|
|
'Records can evolve independently from chat and agent internals.',
|
|
'Future automation can emit chronicle events without owning the core execution path.'
|
|
],
|
|
createdAt
|
|
}, adrNumber);
|
|
this._chronicle.appendTimeline(profile, [`Decision record created: ${result.relativePath}`], createdAt);
|
|
vscode.window.showInformationMessage(`Chronicle decision saved: ${result.relativePath}`);
|
|
await this._sendChronicleRecords();
|
|
this.injectSystemMessage(`**[Chronicle Decision Saved]** \`${result.filePath}\``);
|
|
} catch (err: any) {
|
|
vscode.window.showErrorMessage(`Failed to write decision record: ${err.message}`);
|
|
}
|
|
}
|
|
|
|
private async _writeChronicleDevelopmentFromCurrentChat() {
|
|
const profile = this._getActiveChronicleProject();
|
|
if (!profile) {
|
|
vscode.window.showWarningMessage('Select or create a Designer project first.');
|
|
return;
|
|
}
|
|
|
|
const history = this._agent.getHistory();
|
|
const latestUser = [...history].reverse().find(message => message.role === 'user')?.content || '';
|
|
const latestAssistant = [...history].reverse().find(message => message.role === 'assistant')?.content || '';
|
|
const featureName = await vscode.window.showInputBox({
|
|
prompt: 'Feature name for the development log',
|
|
value: this._summarizeForTitle(latestUser || 'Implementation Log')
|
|
});
|
|
if (!featureName) return;
|
|
|
|
try {
|
|
const createdAt = new Date().toISOString();
|
|
const result = this._chronicle.writeDevelopmentLog(profile, {
|
|
featureName: featureName.trim(),
|
|
purpose: 'Record the actual implementation outcome for later maintenance.',
|
|
implementationSummary: this._summarizeTextForWiki(latestAssistant || 'Implementation summary was not captured from chat yet.'),
|
|
architecture: 'Project Chronicle records are written through an independent Markdown module.',
|
|
changedFiles: ['Capture exact changed files after verification.'],
|
|
dependencyNotes: 'Keep dependencies limited to TypeScript, Node fs/path, and VS Code extension APIs.',
|
|
bugs: [],
|
|
lessons: [
|
|
'Write implementation notes as soon as a stable development step finishes.',
|
|
'Keep generated records project-specific.'
|
|
],
|
|
createdAt
|
|
});
|
|
this._chronicle.appendTimeline(profile, [`Development log created: ${result.relativePath}`], createdAt);
|
|
vscode.window.showInformationMessage(`Chronicle development log saved: ${result.relativePath}`);
|
|
await this._sendChronicleRecords();
|
|
this.injectSystemMessage(`**[Chronicle Development Saved]** \`${result.filePath}\``);
|
|
} catch (err: any) {
|
|
vscode.window.showErrorMessage(`Failed to write development record: ${err.message}`);
|
|
}
|
|
}
|
|
|
|
private async _writeChronicleBugFromInput() {
|
|
const profile = this._getActiveChronicleProject();
|
|
if (!profile) {
|
|
vscode.window.showWarningMessage('Select or create a Designer project first.');
|
|
return;
|
|
}
|
|
|
|
const title = await vscode.window.showInputBox({
|
|
prompt: 'Bug title',
|
|
value: 'record-generation-issue'
|
|
});
|
|
if (!title) return;
|
|
|
|
const symptom = await vscode.window.showInputBox({
|
|
prompt: 'Bug symptom',
|
|
value: 'Describe what failed or looked wrong.'
|
|
});
|
|
if (symptom === undefined) return;
|
|
|
|
const cause = await vscode.window.showInputBox({
|
|
prompt: 'Bug cause',
|
|
value: 'Cause is not confirmed yet.'
|
|
});
|
|
if (cause === undefined) return;
|
|
|
|
const fix = await vscode.window.showInputBox({
|
|
prompt: 'Fix',
|
|
value: 'Describe the fix or mitigation.'
|
|
});
|
|
if (fix === undefined) return;
|
|
|
|
try {
|
|
const createdAt = new Date().toISOString();
|
|
const bugNumber = this._chronicle.nextBugNumber(profile);
|
|
const result = this._chronicle.writeBug(profile, {
|
|
title: title.trim(),
|
|
symptom: symptom.trim(),
|
|
cause: cause.trim(),
|
|
fix: fix.trim(),
|
|
prevention: 'Validate project selection, record path, and write permissions before generating files.',
|
|
createdAt
|
|
}, bugNumber);
|
|
this._chronicle.appendTimeline(profile, [`Bug record created: ${result.relativePath}`], createdAt);
|
|
vscode.window.showInformationMessage(`Chronicle bug record saved: ${result.relativePath}`);
|
|
await this._sendChronicleRecords();
|
|
this.injectSystemMessage(`**[Chronicle Bug Saved]** \`${result.filePath}\``);
|
|
} catch (err: any) {
|
|
vscode.window.showErrorMessage(`Failed to write bug record: ${err.message}`);
|
|
}
|
|
}
|
|
|
|
private async _writeChronicleRetrospectiveFromInput() {
|
|
const profile = this._getActiveChronicleProject();
|
|
if (!profile) {
|
|
vscode.window.showWarningMessage('Select or create a Designer project first.');
|
|
return;
|
|
}
|
|
|
|
const title = await vscode.window.showInputBox({
|
|
prompt: 'Retrospective title',
|
|
value: 'Project Chronicle Guard iteration'
|
|
});
|
|
if (!title) return;
|
|
|
|
const summary = await vscode.window.showInputBox({
|
|
prompt: 'Work summary',
|
|
value: 'Completed an incremental development step and recorded the outcome.'
|
|
});
|
|
if (summary === undefined) return;
|
|
|
|
const wentWell = await vscode.window.showInputBox({
|
|
prompt: 'What went well? (comma-separated)',
|
|
value: 'Kept the feature independent, Generated Markdown records, Preserved project context'
|
|
});
|
|
if (wentWell === undefined) return;
|
|
|
|
const toImprove = await vscode.window.showInputBox({
|
|
prompt: 'What should improve? (comma-separated)',
|
|
value: 'More automatic question intent capture, Richer record editing UI'
|
|
});
|
|
if (toImprove === undefined) return;
|
|
|
|
const nextActions = await vscode.window.showInputBox({
|
|
prompt: 'Next actions (comma-separated)',
|
|
value: 'Add tests, Improve Designer UI, Add event-based record capture'
|
|
});
|
|
if (nextActions === undefined) return;
|
|
|
|
try {
|
|
const createdAt = new Date().toISOString();
|
|
const result = this._chronicle.writeRetrospective(profile, {
|
|
title: title.trim(),
|
|
summary: summary.trim(),
|
|
wentWell: wentWell.split(',').map(item => item.trim()).filter(Boolean),
|
|
toImprove: toImprove.split(',').map(item => item.trim()).filter(Boolean),
|
|
nextActions: nextActions.split(',').map(item => item.trim()).filter(Boolean),
|
|
createdAt
|
|
});
|
|
this._chronicle.appendTimeline(profile, [`Retrospective created: ${result.relativePath}`], createdAt);
|
|
vscode.window.showInformationMessage(`Chronicle retrospective saved: ${result.relativePath}`);
|
|
await this._sendChronicleRecords();
|
|
this.injectSystemMessage(`**[Chronicle Retrospective Saved]** \`${result.filePath}\``);
|
|
} catch (err: any) {
|
|
vscode.window.showErrorMessage(`Failed to write retrospective record: ${err.message}`);
|
|
}
|
|
}
|
|
|
|
private async _writeChronicleRecord(recordType: string) {
|
|
switch (recordType) {
|
|
case 'planning':
|
|
await this._writeChroniclePlanningFromCurrentChat();
|
|
break;
|
|
case 'discussion':
|
|
await this._writeChronicleDiscussionFromCurrentChat();
|
|
break;
|
|
case 'decision':
|
|
await this._writeChronicleDecisionFromInput();
|
|
break;
|
|
case 'development':
|
|
await this._writeChronicleDevelopmentFromCurrentChat();
|
|
break;
|
|
case 'bug':
|
|
await this._writeChronicleBugFromInput();
|
|
break;
|
|
case 'retrospective':
|
|
await this._writeChronicleRetrospectiveFromInput();
|
|
break;
|
|
default:
|
|
vscode.window.showWarningMessage('Select a Chronicle record type first.');
|
|
}
|
|
}
|
|
|
|
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 _deleteAgent(agentPath: string) {
|
|
if (!agentPath || agentPath === 'none') return;
|
|
|
|
try {
|
|
const agentsDir = path.resolve(this._getAgentsDir());
|
|
const targetPath = path.resolve(agentPath);
|
|
if (!targetPath.startsWith(`${agentsDir}${path.sep}`) || path.extname(targetPath) !== '.md') {
|
|
vscode.window.showErrorMessage('Selected agent skill path is not valid.');
|
|
return;
|
|
}
|
|
|
|
if (!fs.existsSync(targetPath)) {
|
|
await this._context.globalState.update(SidebarChatProvider.lastAgentStateKey, 'none');
|
|
await this._sendAgentsList();
|
|
return;
|
|
}
|
|
|
|
const confirm = await vscode.window.showWarningMessage(
|
|
`Delete agent skill "${path.basename(targetPath, '.md')}"?`,
|
|
{ modal: true },
|
|
'Delete Skill'
|
|
);
|
|
if (confirm !== 'Delete Skill') return;
|
|
|
|
fs.unlinkSync(targetPath);
|
|
await this._context.globalState.update(SidebarChatProvider.lastAgentStateKey, 'none');
|
|
await this._sendAgentsList();
|
|
this._view?.webview.postMessage({ type: 'agentDeleted' });
|
|
vscode.window.showInformationMessage('Agent skill deleted successfully.');
|
|
} catch (err: any) {
|
|
vscode.window.showErrorMessage(`Failed to delete agent: ${err.message}`);
|
|
}
|
|
}
|
|
|
|
private async _handlePrompt(data: any) {
|
|
if (!this._view) return;
|
|
|
|
const { value, model, internet, files, agentFile, negativePrompt, designerGuard, secondBrainTrace, secondBrainTraceDebug, brainProfileId } = data;
|
|
this._currentNegativePrompt = negativePrompt || '';
|
|
const selectedBrainId = typeof brainProfileId === 'string' && brainProfileId && brainProfileId !== 'new'
|
|
? brainProfileId
|
|
: getActiveBrainProfile().id;
|
|
this._currentSessionBrainId = selectedBrainId;
|
|
|
|
let agentSkillContext = undefined;
|
|
if (agentFile && fs.existsSync(agentFile)) {
|
|
agentSkillContext = fs.readFileSync(agentFile, 'utf8');
|
|
}
|
|
|
|
const designerContext = designerGuard !== false ? this._buildDesignerGuardContext() : undefined;
|
|
|
|
try {
|
|
await this._agent.handlePrompt(value, model, {
|
|
internetEnabled: internet,
|
|
visionContent: files,
|
|
agentSkillContext,
|
|
negativePrompt,
|
|
designerContext,
|
|
secondBrainTraceEnabled: secondBrainTrace !== false,
|
|
secondBrainTraceDebug: !!secondBrainTraceDebug,
|
|
brainProfileId: selectedBrainId
|
|
});
|
|
} 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 _buildDesignerGuardContext(): string {
|
|
return buildProjectChronicleGuardContext(this._getActiveChronicleProject());
|
|
}
|
|
|
|
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;
|
|
--control-bg: #161b22;
|
|
--control-bg-hover: #21262d;
|
|
--control-active-bg: rgba(88, 166, 255, 0.14);
|
|
--shadow-soft: 0 8px 24px rgba(0, 0, 0, 0.18);
|
|
}
|
|
|
|
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;
|
|
--control-bg: #ffffff;
|
|
--control-bg-hover: #f6f8fa;
|
|
--control-active-bg: rgba(9, 105, 218, 0.1);
|
|
--shadow-soft: 0 8px 20px rgba(31, 35, 40, 0.08);
|
|
}
|
|
|
|
* { 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 12px 6px;
|
|
gap: 10px;
|
|
}
|
|
|
|
.header-controls {
|
|
display: grid;
|
|
grid-template-columns: minmax(0, 1fr);
|
|
gap: 8px;
|
|
padding: 0 12px 12px;
|
|
}
|
|
|
|
.header-actions,
|
|
.tool-group,
|
|
.select-stack,
|
|
.select-line,
|
|
.status-pill {
|
|
display: flex;
|
|
align-items: center;
|
|
}
|
|
|
|
.header-actions { gap: 6px; flex-shrink: 0; flex-wrap: wrap; justify-content: flex-end; }
|
|
.tool-group {
|
|
gap: 4px;
|
|
padding: 3px;
|
|
border: 1px solid var(--border);
|
|
border-radius: 8px;
|
|
background: rgba(255, 255, 255, 0.02);
|
|
}
|
|
.select-stack { flex-direction: column; gap: 6px; min-width: 0; }
|
|
.select-line { gap: 6px; width: 100%; min-width: 0; }
|
|
.paired-row {
|
|
display: grid;
|
|
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
|
gap: 8px;
|
|
width: 100%;
|
|
align-items: center;
|
|
}
|
|
.control-row {
|
|
display: grid;
|
|
grid-template-columns: minmax(0, 1fr) auto;
|
|
gap: 6px;
|
|
width: 100%;
|
|
align-items: center;
|
|
}
|
|
|
|
.brand { font-weight: 700; font-size: 14px; color: var(--text-bright); letter-spacing: 0; display: flex; align-items: center; gap: 8px; min-width: 0; }
|
|
.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; }
|
|
|
|
.status-pill {
|
|
height: 28px;
|
|
gap: 6px;
|
|
padding: 0 9px;
|
|
border: 1px solid var(--border);
|
|
border-radius: 6px;
|
|
background: var(--control-bg);
|
|
color: var(--text-dim);
|
|
font-size: 10px;
|
|
font-weight: 700;
|
|
text-transform: uppercase;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.status-dot {
|
|
width: 7px;
|
|
height: 7px;
|
|
border-radius: 50%;
|
|
background: var(--text-dim);
|
|
box-shadow: 0 0 0 2px rgba(139, 148, 158, 0.12);
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.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);
|
|
}
|
|
|
|
.msg-user {
|
|
align-items: flex-end;
|
|
}
|
|
|
|
.msg-ai {
|
|
align-items: flex-start;
|
|
}
|
|
|
|
@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); }
|
|
.msg-user .msg-head { flex-direction: row-reverse; }
|
|
.av { width: 22px; height: 22px; border-radius: 6px; display: flex; align-items: center; justify-content: center; font-size: 12px; }
|
|
|
|
/* 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; }
|
|
|
|
#input::placeholder { color: var(--accent); opacity: 0.6; font-weight: 500; }
|
|
|
|
|
|
.msg-body {
|
|
color: var(--text-primary);
|
|
font-size: 13.5px;
|
|
word-break: break-word;
|
|
max-width: min(88%, 760px);
|
|
}
|
|
|
|
.msg-user .msg-body {
|
|
background: var(--surface);
|
|
border: 1px solid var(--border);
|
|
border-radius: 12px;
|
|
padding: 10px 14px;
|
|
white-space: pre-wrap;
|
|
text-align: left;
|
|
}
|
|
|
|
.msg-ai .msg-body {
|
|
padding-left: 30px;
|
|
width: 100%;
|
|
max-width: 100%;
|
|
}
|
|
|
|
/* --- 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.45em; border-bottom: 1px solid var(--border); padding-bottom: 0.3em; }
|
|
.markdown-body h3 { font-size: 1.2em; }
|
|
.markdown-body p { margin: 0.75em 0 1.1em; }
|
|
.markdown-body ol, .markdown-body ul { margin: 0.7em 0 1.1em; padding-left: 1.45em; }
|
|
.markdown-body li { margin: 0.35em 0 0.65em; }
|
|
.markdown-body li + li { margin-top: 0.55em; }
|
|
.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(--control-bg);
|
|
border: 1px solid var(--border);
|
|
color: var(--text-dim);
|
|
min-width: 28px;
|
|
height: 28px;
|
|
padding: 0 8px;
|
|
border-radius: 6px;
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
cursor: pointer;
|
|
transition: border-color 0.15s ease, background 0.15s ease, color 0.15s ease, box-shadow 0.15s ease;
|
|
font-size: 12px;
|
|
font-weight: 700;
|
|
line-height: 1;
|
|
flex: 0 0 auto;
|
|
}
|
|
.icon-btn:hover { color: var(--text-bright); border-color: var(--border-bright); background: var(--control-bg-hover); box-shadow: var(--shadow-soft); }
|
|
.icon-btn.active { color: var(--accent); border-color: var(--accent); background: var(--control-active-bg); }
|
|
|
|
.select-wrap {
|
|
position: relative;
|
|
min-width: 0;
|
|
flex: 1 1 0;
|
|
}
|
|
|
|
.select-wrap::after {
|
|
content: '';
|
|
position: absolute;
|
|
right: 10px;
|
|
top: 50%;
|
|
width: 6px;
|
|
height: 6px;
|
|
border-right: 1.5px solid var(--text-dim);
|
|
border-bottom: 1.5px solid var(--text-dim);
|
|
transform: translateY(-65%) rotate(45deg);
|
|
pointer-events: none;
|
|
}
|
|
|
|
select {
|
|
width: 100%;
|
|
min-width: 0;
|
|
height: 30px;
|
|
background: var(--control-bg);
|
|
color: var(--text-primary);
|
|
border: 1px solid var(--border);
|
|
padding: 0 28px 0 10px;
|
|
border-radius: 6px;
|
|
font-size: 11px;
|
|
font-weight: 600;
|
|
cursor: pointer;
|
|
outline: none;
|
|
appearance: none;
|
|
transition: border-color 0.15s ease, background 0.15s ease, box-shadow 0.15s ease;
|
|
}
|
|
|
|
select:hover { border-color: var(--border-bright); background: var(--control-bg-hover); }
|
|
select:focus { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-glow); }
|
|
|
|
/* --- 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); }
|
|
|
|
.panel {
|
|
display: none;
|
|
flex-direction: column;
|
|
gap: 8px;
|
|
padding-bottom: 10px;
|
|
}
|
|
|
|
.field-label {
|
|
color: var(--text-dim);
|
|
font-size: 10px;
|
|
font-weight: 700;
|
|
line-height: 1.2;
|
|
}
|
|
|
|
.panel textarea {
|
|
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;
|
|
}
|
|
|
|
.panel textarea:focus {
|
|
border-color: var(--accent);
|
|
box-shadow: 0 0 0 3px var(--accent-glow);
|
|
}
|
|
|
|
.secondary-btn {
|
|
height: 30px;
|
|
background: var(--control-bg);
|
|
border: 1px solid var(--border);
|
|
color: var(--text-primary);
|
|
padding: 0 10px;
|
|
font-size: 11px;
|
|
font-weight: 700;
|
|
border-radius: 6px;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.secondary-btn:hover {
|
|
border-color: var(--accent);
|
|
background: var(--control-active-bg);
|
|
color: var(--text-bright);
|
|
}
|
|
|
|
/* --- 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); }
|
|
|
|
@media (min-width: 360px) {
|
|
.header-controls {
|
|
grid-template-columns: minmax(0, 1fr);
|
|
align-items: start;
|
|
}
|
|
}
|
|
@media (max-width: 520px) {
|
|
.paired-row {
|
|
grid-template-columns: minmax(0, 1fr);
|
|
}
|
|
.header-top {
|
|
align-items: flex-start;
|
|
}
|
|
}
|
|
@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 class="header-actions">
|
|
<button class="icon-btn" id="newChatBtn" data-tooltip="New Chat">New</button>
|
|
<button class="icon-btn" id="saveWikiRawBtn" data-tooltip="Save Wiki Raw">Wiki</button>
|
|
<button class="icon-btn active" id="designerGuardBtn" data-tooltip="Chronicle Guard Mode: Auto">Guard</button>
|
|
<button class="icon-btn active" id="brainTraceBtn" data-tooltip="Second Brain Trace Mode">Trace</button>
|
|
<button class="icon-btn" id="brainTraceDebugBtn" data-tooltip="Second Brain Debug JSON">Dbg</button>
|
|
<button class="icon-btn" id="multiAgentBtn" data-tooltip="Multi-Agent Mode">MA</button>
|
|
<button class="icon-btn" id="internetBtn" data-tooltip="Internet Access">Web</button>
|
|
<button class="icon-btn" id="historyBtn" data-tooltip="View History">Log</button>
|
|
<button class="icon-btn" id="settingsBtn" data-tooltip="Settings">Set</button>
|
|
</div>
|
|
</div>
|
|
<div class="header-controls">
|
|
<div class="select-stack">
|
|
<div class="select-line">
|
|
<div class="status-pill"><span id="statusDot" class="status-dot"></span><span id="engineStatusText">Engine</span></div>
|
|
<div class="select-wrap"><select id="modelSel" title="Select Model"></select></div>
|
|
</div>
|
|
<div class="paired-row">
|
|
<div class="control-row">
|
|
<div class="select-wrap"><select id="brainSel" title="Select Brain"></select></div>
|
|
<div class="tool-group" aria-label="Brain actions">
|
|
<button class="icon-btn" id="addBrainBtn" data-tooltip="Add Brain">Add</button>
|
|
<button class="icon-btn" id="editBrainBtn" data-tooltip="Edit Brain">Edit</button>
|
|
<button class="icon-btn" id="deleteBrainBtn" data-tooltip="Delete Brain">Del</button>
|
|
<button class="icon-btn" id="brainBtn" data-tooltip="Sync Knowledge">Sync</button>
|
|
</div>
|
|
</div>
|
|
<div class="control-row">
|
|
<div class="select-wrap"><select id="agentSel" title="Select Agentic Skill"></select></div>
|
|
<div class="tool-group" aria-label="Agent actions">
|
|
<button class="icon-btn" id="addAgentBtn" data-tooltip="Create Agent">Add</button>
|
|
<button class="icon-btn" id="editAgentBtn" data-tooltip="Edit Agent Skill">Edit</button>
|
|
<button class="icon-btn" id="deleteAgentBtn" data-tooltip="Delete Agent Skill">Del</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="control-row">
|
|
<div class="select-wrap"><select id="designerSel" title="Select Designer Project"></select></div>
|
|
<div class="tool-group" aria-label="Designer actions">
|
|
<button class="icon-btn" id="addDesignerBtn" data-tooltip="Create Designer Project">Add</button>
|
|
<button class="icon-btn" id="openDesignerBtn" data-tooltip="Open Record Folder">Open</button>
|
|
</div>
|
|
</div>
|
|
<div class="control-row">
|
|
<div class="select-wrap">
|
|
<select id="chronicleRecordTypeSel" title="Select Chronicle Record Type">
|
|
<option value="planning">Planning</option>
|
|
<option value="discussion">Discussion</option>
|
|
<option value="decision">Decision</option>
|
|
<option value="development">Development</option>
|
|
<option value="bug">Bug</option>
|
|
<option value="retrospective">Retrospective</option>
|
|
</select>
|
|
</div>
|
|
<div class="tool-group" aria-label="Chronicle write actions">
|
|
<button class="icon-btn" id="writeChronicleBtn" data-tooltip="Write Selected Record">Write</button>
|
|
</div>
|
|
</div>
|
|
<div class="control-row">
|
|
<div class="select-wrap"><select id="chronicleRecordSel" title="Select Chronicle Record"></select></div>
|
|
<div class="tool-group" aria-label="Chronicle record actions">
|
|
<button class="icon-btn" id="refreshChronicleRecordsBtn" data-tooltip="Refresh Records">Ref</button>
|
|
<button class="icon-btn" id="openChronicleRecordBtn" data-tooltip="Open Selected Record">Open</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</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" class="panel">
|
|
<div class="field-label">Agent Persona/Instructions</div>
|
|
<textarea id="agentPrompt" rows="5" placeholder="Agent Persona & Instructions..."></textarea>
|
|
|
|
<div class="field-label">Negative Prompt (Strict Rules)</div>
|
|
<textarea id="negativePrompt" rows="2" placeholder="What NOT to do..."></textarea>
|
|
|
|
<button id="updateAgentBtn" class="secondary-btn">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');
|
|
|
|
// [State Persistence - Tier 0] 즉시 복원 (Instant Restore from WebView State)
|
|
const previousState = vscode.getState();
|
|
if (previousState && previousState.history && previousState.history.length > 0) {
|
|
console.log('[G1nation] Restoring from Webview State...');
|
|
renderHistory(previousState.history);
|
|
}
|
|
|
|
function saveWebviewState(history) {
|
|
const current = vscode.getState() || {};
|
|
vscode.setState({ ...current, history });
|
|
}
|
|
|
|
function saveUiState() {
|
|
const current = vscode.getState() || {};
|
|
vscode.setState({ ...current, designerGuardEnabled, secondBrainTraceEnabled, secondBrainTraceDebug });
|
|
}
|
|
|
|
function renderHistory(history) {
|
|
if (!history || history.length === 0) return;
|
|
chat.innerHTML = '';
|
|
history.forEach(m => {
|
|
if (!m) return;
|
|
// Only skip truly internal system messages, keep assistant thoughts
|
|
if (m.role === 'system' && m.internal) return;
|
|
addMsg(m.content, m.role === 'assistant' ? 'assistant' : 'user', m.rationale);
|
|
});
|
|
chat.scrollTop = chat.scrollHeight;
|
|
}
|
|
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 engineStatusText = document.getElementById('engineStatusText');
|
|
const attachBtn = document.getElementById('attachBtn');
|
|
const fileInput = document.getElementById('fileInput');
|
|
const attachPreview = document.getElementById('attachPreview');
|
|
const agentSel = document.getElementById('agentSel');
|
|
const designerSel = document.getElementById('designerSel');
|
|
const chronicleRecordTypeSel = document.getElementById('chronicleRecordTypeSel');
|
|
const chronicleRecordSel = document.getElementById('chronicleRecordSel');
|
|
const editAgentBtn = document.getElementById('editAgentBtn');
|
|
const addAgentBtn = document.getElementById('addAgentBtn');
|
|
const deleteAgentBtn = document.getElementById('deleteAgentBtn');
|
|
const addBrainBtn = document.getElementById('addBrainBtn');
|
|
const editBrainBtn = document.getElementById('editBrainBtn');
|
|
const deleteBrainBtn = document.getElementById('deleteBrainBtn');
|
|
const saveWikiRawBtn = document.getElementById('saveWikiRawBtn');
|
|
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 designerGuardEnabled = true;
|
|
let secondBrainTraceEnabled = true;
|
|
let secondBrainTraceDebug = false;
|
|
let pendingFiles = [];
|
|
let editMode = false;
|
|
if (previousState && typeof previousState.designerGuardEnabled === 'boolean') {
|
|
designerGuardEnabled = previousState.designerGuardEnabled;
|
|
}
|
|
if (previousState && typeof previousState.secondBrainTraceEnabled === 'boolean') {
|
|
secondBrainTraceEnabled = previousState.secondBrainTraceEnabled;
|
|
}
|
|
if (previousState && typeof previousState.secondBrainTraceDebug === 'boolean') {
|
|
secondBrainTraceDebug = previousState.secondBrainTraceDebug;
|
|
}
|
|
const initialGuardBtn = document.getElementById('designerGuardBtn');
|
|
initialGuardBtn.classList.toggle('active', designerGuardEnabled);
|
|
initialGuardBtn.setAttribute('data-tooltip', designerGuardEnabled ? 'Chronicle Guard Mode: On' : 'Chronicle Guard Mode: Off');
|
|
const initialTraceBtn = document.getElementById('brainTraceBtn');
|
|
initialTraceBtn.classList.toggle('active', secondBrainTraceEnabled);
|
|
initialTraceBtn.setAttribute('data-tooltip', secondBrainTraceEnabled ? 'Second Brain Trace Mode: On' : 'Second Brain Trace Mode: Off');
|
|
const initialTraceDebugBtn = document.getElementById('brainTraceDebugBtn');
|
|
initialTraceDebugBtn.classList.toggle('active', secondBrainTraceDebug);
|
|
initialTraceDebugBtn.setAttribute('data-tooltip', secondBrainTraceDebug ? 'Second Brain Debug JSON: On' : 'Second Brain Debug JSON: Off');
|
|
|
|
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;
|
|
|
|
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);
|
|
// Update state for non-streamed messages
|
|
const s = vscode.getState() || { history: [] };
|
|
s.history.push({ role: msg.role === 'assistant' ? 'assistant' : 'user', content: msg.value, rationale: msg.rationale });
|
|
saveWebviewState(s.history);
|
|
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');
|
|
// Update state after stream finishes
|
|
const state = vscode.getState() || { history: [] };
|
|
state.history.push({ role: 'assistant', content: streamBody._parent._raw });
|
|
saveWebviewState(state.history);
|
|
}
|
|
streamBody = null;
|
|
// 생성 완료 시 Stop 버튼 숨기고 Send 복구
|
|
setGenerating(false);
|
|
resetStepper();
|
|
Sound.success();
|
|
break;
|
|
case 'restoreHistory':
|
|
case 'sessionLoaded':
|
|
const historyPayload = msg.type === 'sessionLoaded' ? msg.value : msg.value;
|
|
const history = Array.isArray(historyPayload)
|
|
? historyPayload
|
|
: (Array.isArray(historyPayload?.history) ? historyPayload.history : []);
|
|
|
|
if (history && history.length > 0) {
|
|
renderHistory(history);
|
|
saveWebviewState(history);
|
|
}
|
|
if (historyPayload?.negativePrompt !== undefined) {
|
|
negativePrompt.value = historyPayload.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.setAttribute('role', 'button');
|
|
el.tabIndex = 0;
|
|
el.dataset.sessionId = s.id;
|
|
el.innerHTML = \`<div style="font-weight:600; color:var(--text-bright); margin-bottom:2px;">\${s.title}</div><div style="font-size:10px; color:var(--text-dim)">\${new Date(s.timestamp).toLocaleString()} · \${s.messageCount} msgs</div>\`;
|
|
const load = () => {
|
|
if (!el.dataset.sessionId) return;
|
|
vscode.postMessage({ type: 'loadSession', id: el.dataset.sessionId });
|
|
};
|
|
el.addEventListener('click', load);
|
|
el.addEventListener('keydown', event => {
|
|
if (event.key === 'Enter' || event.key === ' ') {
|
|
event.preventDefault();
|
|
load();
|
|
}
|
|
});
|
|
historyList.appendChild(el);
|
|
});
|
|
break;
|
|
case 'engineStatus':
|
|
statusDot.style.background = msg.value.online ? 'var(--success)' : 'var(--error)';
|
|
engineStatusText.innerText = msg.value.online ? 'Online' : 'Offline';
|
|
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 'chronicleProjects':
|
|
designerSel.innerHTML = '';
|
|
msg.value.projects.forEach(p => {
|
|
const o = document.createElement('option');
|
|
o.value = p.id;
|
|
o.innerText = p.name;
|
|
o.title = p.recordRoot;
|
|
if (p.id === msg.value.activeProjectId) o.selected = true;
|
|
designerSel.appendChild(o);
|
|
});
|
|
const newDesignerOpt = document.createElement('option');
|
|
newDesignerOpt.value = 'new';
|
|
newDesignerOpt.innerText = '+ Add Designer Project...';
|
|
designerSel.appendChild(newDesignerOpt);
|
|
vscode.postMessage({ type: 'getChronicleRecords' });
|
|
break;
|
|
case 'chronicleRecords':
|
|
chronicleRecordSel.innerHTML = '';
|
|
if (!msg.value || msg.value.length === 0) {
|
|
const emptyRecordOpt = document.createElement('option');
|
|
emptyRecordOpt.value = '';
|
|
emptyRecordOpt.innerText = 'No records yet';
|
|
chronicleRecordSel.appendChild(emptyRecordOpt);
|
|
break;
|
|
}
|
|
msg.value.forEach(record => {
|
|
const o = document.createElement('option');
|
|
o.value = record.path;
|
|
o.innerText = record.relativePath;
|
|
o.title = record.path;
|
|
chronicleRecordSel.appendChild(o);
|
|
});
|
|
break;
|
|
case 'agentContent':
|
|
agentPrompt.value = msg.value;
|
|
negativePrompt.value = msg.negativePrompt || '';
|
|
break;
|
|
case 'agentDeleted':
|
|
agentConfigPanel.style.display = 'none';
|
|
editMode = false;
|
|
editAgentBtn.classList.remove('active');
|
|
agentPrompt.value = '';
|
|
negativePrompt.value = '';
|
|
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;
|
|
|
|
// ⭐ Kodari PD 가이드 반영: Input 요소의 상태를 드롭된 파일로 강제 동기화
|
|
if (files && files.length > 0) {
|
|
fileInput.files = files; // Input의 files 속성 업데이트
|
|
console.log(\`✅ [DnD] Input 상태 동기화 성공: \${files[0].name} 외 \${files.length - 1}개\`);
|
|
}
|
|
|
|
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,
|
|
brainProfileId: brainSel.value && brainSel.value !== 'new' ? brainSel.value : undefined,
|
|
negativePrompt: negativePrompt.value.trim() || undefined,
|
|
designerGuard: designerGuardEnabled,
|
|
secondBrainTrace: secondBrainTraceEnabled,
|
|
secondBrainTraceDebug
|
|
});
|
|
input.value = ''; input.style.height = 'auto'; pendingFiles = []; renderAttachments();
|
|
// 전송 완료 후 Draft State 리셋 + Stop 버튼 표시
|
|
setDraftActive(false);
|
|
setGenerating(true);
|
|
thinkingBar.classList.add('active');
|
|
|
|
// Save state after sending
|
|
const currentState = vscode.getState() || { history: [] };
|
|
currentState.history.push({ role: 'user', content: val });
|
|
saveWebviewState(currentState.history);
|
|
}
|
|
|
|
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);
|
|
};
|
|
document.getElementById('designerGuardBtn').onclick = () => {
|
|
designerGuardEnabled = !designerGuardEnabled;
|
|
const btn = document.getElementById('designerGuardBtn');
|
|
btn.classList.toggle('active', designerGuardEnabled);
|
|
btn.setAttribute('data-tooltip', designerGuardEnabled ? 'Chronicle Guard Mode: On' : 'Chronicle Guard Mode: Off');
|
|
saveUiState();
|
|
};
|
|
document.getElementById('brainTraceBtn').onclick = () => {
|
|
secondBrainTraceEnabled = !secondBrainTraceEnabled;
|
|
const btn = document.getElementById('brainTraceBtn');
|
|
btn.classList.toggle('active', secondBrainTraceEnabled);
|
|
btn.setAttribute('data-tooltip', secondBrainTraceEnabled ? 'Second Brain Trace Mode: On' : 'Second Brain Trace Mode: Off');
|
|
saveUiState();
|
|
};
|
|
document.getElementById('brainTraceDebugBtn').onclick = () => {
|
|
secondBrainTraceDebug = !secondBrainTraceDebug;
|
|
const btn = document.getElementById('brainTraceDebugBtn');
|
|
btn.classList.toggle('active', secondBrainTraceDebug);
|
|
btn.setAttribute('data-tooltip', secondBrainTraceDebug ? 'Second Brain Debug JSON: On' : 'Second Brain Debug JSON: Off');
|
|
saveUiState();
|
|
};
|
|
|
|
let multiAgentEnabled = false;
|
|
const setMultiAgentUi = (enabled) => {
|
|
multiAgentEnabled = enabled;
|
|
const btn = document.getElementById('multiAgentBtn');
|
|
btn.classList.toggle('active', enabled);
|
|
btn.setAttribute('data-tooltip', enabled ? 'Multi-Agent Mode: On' : 'Multi-Agent Mode: Off');
|
|
};
|
|
document.getElementById('multiAgentBtn').onclick = () => {
|
|
setMultiAgentUi(!multiAgentEnabled);
|
|
vscode.postMessage({ type: 'toggleMultiAgent', value: multiAgentEnabled });
|
|
};
|
|
|
|
const syncBrain = () => { Sound.play(550, 'sine', 0.1); vscode.postMessage({ type: 'syncBrain' }); };
|
|
document.getElementById('brainBtn').onclick = syncBrain;
|
|
saveWikiRawBtn.onclick = () => vscode.postMessage({ type: 'saveWikiRaw' });
|
|
addBrainBtn.onclick = () => vscode.postMessage({ type: 'addBrain' });
|
|
editBrainBtn.onclick = () => {
|
|
if (!brainSel.value || brainSel.value === 'new') return;
|
|
vscode.postMessage({ type: 'editBrain', id: brainSel.value });
|
|
};
|
|
deleteBrainBtn.onclick = () => {
|
|
if (!brainSel.value || brainSel.value === 'new') return;
|
|
vscode.postMessage({ type: 'deleteBrain', id: brainSel.value });
|
|
};
|
|
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 = () => {
|
|
if (typeof input !== 'undefined' && input) {
|
|
input.placeholder = \`Ask \${modelSel ? modelSel.value : 'AI'}...\`;
|
|
}
|
|
};
|
|
|
|
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 });
|
|
}
|
|
};
|
|
|
|
designerSel.onchange = () => {
|
|
if (designerSel.value === 'new') {
|
|
vscode.postMessage({ type: 'createChronicleProject' });
|
|
} else {
|
|
vscode.postMessage({ type: 'setChronicleProject', id: designerSel.value });
|
|
vscode.postMessage({ type: 'getChronicleRecords' });
|
|
}
|
|
};
|
|
|
|
// Handle initial state and state updates from extension
|
|
window.addEventListener('message', e => {
|
|
const msg = e.data;
|
|
if (msg.type === 'configUpdate') {
|
|
setMultiAgentUi(!!msg.value.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' });
|
|
deleteAgentBtn.onclick = () => {
|
|
if (agentSel.value === 'none') return;
|
|
vscode.postMessage({ type: 'deleteAgent', path: agentSel.value });
|
|
};
|
|
|
|
document.getElementById('addDesignerBtn').onclick = () => vscode.postMessage({ type: 'createChronicleProject' });
|
|
document.getElementById('openDesignerBtn').onclick = () => vscode.postMessage({ type: 'openChronicleFolder' });
|
|
document.getElementById('writeChronicleBtn').onclick = () => vscode.postMessage({
|
|
type: 'writeChronicleRecord',
|
|
recordType: chronicleRecordTypeSel.value
|
|
});
|
|
document.getElementById('refreshChronicleRecordsBtn').onclick = () => vscode.postMessage({ type: 'getChronicleRecords' });
|
|
document.getElementById('openChronicleRecordBtn').onclick = () => {
|
|
if (!chronicleRecordSel.value) return;
|
|
vscode.postMessage({ type: 'openChronicleRecord', path: chronicleRecordSel.value });
|
|
};
|
|
|
|
vscode.postMessage({ type: 'getModels' });
|
|
vscode.postMessage({ type: 'getAgents' });
|
|
vscode.postMessage({ type: 'getChronicleProjects' });
|
|
vscode.postMessage({ type: 'getChronicleRecords' });
|
|
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>`;
|
|
}
|
|
}
|