-
Notifications
You must be signed in to change notification settings - Fork 5
feat: add AI chat sidebar for log analysis #312
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 8 commits
25d2eb2
5aa27e0
278b337
cb15e57
41cabfc
cf57ee7
557f52b
9a9c88a
24fb5eb
0814c03
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,43 @@ | ||
| export interface AiMessage { | ||
| role: 'user' | 'assistant'; | ||
| content: string; | ||
| } | ||
|
|
||
| export interface SerializedLogEntry { | ||
| timestamp: string; | ||
| level: string; | ||
| message: string; | ||
| line: number; | ||
| sourceFile: string; | ||
| meta?: string; | ||
| repeated?: string[]; | ||
| } | ||
|
|
||
| export interface SerializedLogFile { | ||
| fileName: string; | ||
| logType: string; | ||
| entryCount: number; | ||
| entries: SerializedLogEntry[]; | ||
| } | ||
|
|
||
| export interface SerializedLogContext { | ||
| files: SerializedLogFile[]; | ||
| stateFiles?: Array<{ | ||
| fileName: string; | ||
| content: string; | ||
| }>; | ||
| } | ||
|
|
||
| export interface AiStreamChunkData { | ||
| requestId: string; | ||
| chunk: string; | ||
| } | ||
|
|
||
| export interface AiStreamDoneData { | ||
| requestId: string; | ||
| } | ||
|
|
||
| export interface AiStreamErrorData { | ||
| requestId: string; | ||
| error: string; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,62 @@ | ||
| import fs from 'node:fs'; | ||
| import path from 'node:path'; | ||
| import os from 'node:os'; | ||
|
|
||
| const CONFIG_PATH = path.join(os.homedir(), '.config', 'sleuth', 'ai.json'); | ||
|
|
||
| interface AiConfig { | ||
| fmaRole: string; | ||
| model: string; | ||
| } | ||
|
|
||
| let loadedConfig: AiConfig | null = null; | ||
|
|
||
| /** | ||
| * Read AI configuration from ~/.config/sleuth/ai.json. | ||
| * | ||
| * The config file is expected to contain: | ||
| * { "fmaRole": "<account>/<role>/<session>", "model": "<bedrock-model-id>" } | ||
| * | ||
| * Values are cached after first read. Environment variables take precedence | ||
| * when set, allowing developers to override without editing the file. | ||
| */ | ||
| function loadConfig(): AiConfig { | ||
| if (loadedConfig) return loadedConfig; | ||
|
|
||
| try { | ||
| const raw = fs.readFileSync(CONFIG_PATH, 'utf-8'); | ||
| const parsed: unknown = JSON.parse(raw); | ||
|
|
||
| if ( | ||
| parsed && | ||
| typeof parsed === 'object' && | ||
| 'fmaRole' in parsed && | ||
| 'model' in parsed && | ||
| typeof (parsed as AiConfig).fmaRole === 'string' && | ||
| typeof (parsed as AiConfig).model === 'string' | ||
| ) { | ||
| loadedConfig = parsed as AiConfig; | ||
| return loadedConfig; | ||
| } | ||
| } catch { | ||
| // Config file missing or malformed — fall through to empty defaults | ||
| } | ||
|
|
||
| loadedConfig = { fmaRole: '', model: '' }; | ||
| return loadedConfig; | ||
| } | ||
|
|
||
| /** FMA role ARN, sourced from env var or config file. */ | ||
| export function getFmaRole(): string { | ||
| return process.env.SLEUTH_AI_FMA_ROLE ?? loadConfig().fmaRole; | ||
| } | ||
|
|
||
| /** Bedrock model ID, sourced from env var or config file. */ | ||
| export function getModel(): string { | ||
| return process.env.SLEUTH_AI_MODEL ?? loadConfig().model; | ||
| } | ||
|
|
||
| /** AWS region override (env-only, defaults to us-east-1). */ | ||
| export function getAwsRegion(): string { | ||
| return process.env.SLEUTH_AI_AWS_REGION ?? 'us-east-1'; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,175 @@ | ||
| import { AnthropicBedrock } from '@anthropic-ai/bedrock-sdk'; | ||
| import type { BrowserWindow } from 'electron'; | ||
| import type { | ||
| ContentBlockParam, | ||
| MessageParam, | ||
| ToolUseBlock, | ||
| } from '@anthropic-ai/sdk/resources'; | ||
|
|
||
| import { IpcEvents } from '../../ipc-events'; | ||
| import type { AiMessage, SerializedLogContext } from '../../ai-interfaces'; | ||
| import { buildSystemPrompt } from './log-context-formatter'; | ||
| import { | ||
| CODEBASE_TOOL_DEFINITIONS, | ||
| LOG_TOOL_DEFINITIONS, | ||
| REPO_CONTEXT_TOOL_DEFINITIONS, | ||
| executeTools, | ||
| } from './tools'; | ||
| import { getAwsCredentials, clearCredentialCache } from './aws-credentials'; | ||
| import { getModel, getAwsRegion } from './ai-config'; | ||
|
|
||
| export class AiService { | ||
| private client: AnthropicBedrock | null = null; | ||
| private activeRequests = new Map<string, AbortController>(); | ||
|
|
||
| private async getClient(): Promise<AnthropicBedrock> { | ||
| // Always get fresh credentials (cached internally for 10 min) | ||
| const creds = await getAwsCredentials(); | ||
|
|
||
| // Recreate client if credentials changed | ||
| this.client = new AnthropicBedrock({ | ||
| awsRegion: getAwsRegion(), | ||
| awsAccessKey: creds.accessKeyId, | ||
| awsSecretKey: creds.secretAccessKey, | ||
| awsSessionToken: creds.sessionToken, | ||
| }); | ||
|
|
||
| return this.client; | ||
| } | ||
|
|
||
| async sendMessage( | ||
| window: BrowserWindow, | ||
| requestId: string, | ||
| messages: AiMessage[], | ||
| logContext: SerializedLogContext, | ||
| codebasePaths: string[], | ||
| ): Promise<void> { | ||
| let client: AnthropicBedrock; | ||
| try { | ||
| client = await this.getClient(); | ||
| } catch (error) { | ||
| if (!window.isDestroyed()) { | ||
| window.webContents.send(IpcEvents.AI_STREAM_ERROR, { | ||
| requestId, | ||
| error: error instanceof Error ? error.message : String(error), | ||
| }); | ||
| } | ||
| return; | ||
| } | ||
|
|
||
| const controller = new AbortController(); | ||
| this.activeRequests.set(requestId, controller); | ||
|
|
||
| try { | ||
| const systemPrompt = buildSystemPrompt(logContext); | ||
|
|
||
| // Always include log tools and repo context tools; | ||
| // include codebase tools only if paths are configured | ||
| const tools = [ | ||
| ...LOG_TOOL_DEFINITIONS, | ||
| ...REPO_CONTEXT_TOOL_DEFINITIONS, | ||
| ...(codebasePaths.length > 0 ? CODEBASE_TOOL_DEFINITIONS : []), | ||
| ]; | ||
|
|
||
| let currentMessages: MessageParam[] = messages.map((m) => ({ | ||
| role: m.role, | ||
| content: m.content, | ||
| })); | ||
|
|
||
| // Tool-use loop | ||
| while (true) { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. question: is it possible for this to just get stuck and loop infinitely? Should there be a max tool usage here?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Added a cap of 20 tool iterations in |
||
| const stream = client.messages.stream( | ||
| { | ||
| model: getModel(), | ||
| max_tokens: 8192, | ||
| system: systemPrompt, | ||
| messages: currentMessages, | ||
| tools, | ||
| }, | ||
| { signal: controller.signal }, | ||
| ); | ||
|
|
||
| stream.on('text', (text) => { | ||
| if (!window.isDestroyed()) { | ||
| window.webContents.send(IpcEvents.AI_STREAM_CHUNK, { | ||
| requestId, | ||
| chunk: text, | ||
| }); | ||
| } | ||
| }); | ||
|
|
||
| const finalMessage = await stream.finalMessage(); | ||
|
|
||
| const toolUseBlocks = finalMessage.content.filter( | ||
| (b) => b.type === 'tool_use', | ||
| ); | ||
|
|
||
| if ( | ||
| finalMessage.stop_reason === 'tool_use' && | ||
| toolUseBlocks.length > 0 | ||
| ) { | ||
| // Notify renderer about tool calls | ||
| for (const block of toolUseBlocks) { | ||
| if (block.type === 'tool_use' && !window.isDestroyed()) { | ||
| window.webContents.send(IpcEvents.AI_STREAM_CHUNK, { | ||
| requestId, | ||
| chunk: `\n\n> *Using tool: ${block.name}*\n\n`, | ||
| }); | ||
| } | ||
| } | ||
|
|
||
| // Execute tools and continue conversation | ||
| currentMessages.push({ | ||
| role: 'assistant', | ||
| content: finalMessage.content as ContentBlockParam[], | ||
| }); | ||
| const toolResults = await executeTools( | ||
| toolUseBlocks as ToolUseBlock[], | ||
| codebasePaths, | ||
| logContext, | ||
| ); | ||
| currentMessages.push({ | ||
| role: 'user', | ||
| content: toolResults, | ||
| }); | ||
| } else { | ||
| // Done - no more tool calls | ||
| break; | ||
| } | ||
| } | ||
|
|
||
| if (!window.isDestroyed()) { | ||
| window.webContents.send(IpcEvents.AI_STREAM_DONE, { requestId }); | ||
| } | ||
| } catch (error) { | ||
| const msg = error instanceof Error ? error.message : String(error); | ||
|
|
||
| // If we get an auth error mid-stream, clear the credential cache | ||
| if ( | ||
| msg.includes('ExpiredToken') || | ||
| msg.includes('InvalidSignature') || | ||
| msg.includes('UnrecognizedClient') || | ||
| msg.includes('403') | ||
| ) { | ||
| clearCredentialCache(); | ||
| } | ||
|
|
||
| if (!window.isDestroyed()) { | ||
| window.webContents.send(IpcEvents.AI_STREAM_ERROR, { | ||
| requestId, | ||
| error: msg, | ||
| }); | ||
| } | ||
| } finally { | ||
| this.activeRequests.delete(requestId); | ||
| } | ||
| } | ||
|
|
||
| abort(requestId: string): void { | ||
| const controller = this.activeRequests.get(requestId); | ||
| if (controller) { | ||
| controller.abort(); | ||
| this.activeRequests.delete(requestId); | ||
| } | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: although this is valid, we generally use the full semver string