Add LLM-powered assistant for MQTT topic interaction (OpenAI & Gemini) (#1028)

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: thomasnordquist <7721625+thomasnordquist@users.noreply.github.com>
Co-authored-by: Thomas Nordquist <thomasnordquist@users.noreply.github.com>
This commit is contained in:
Copilot
2026-01-27 03:06:04 +01:00
committed by GitHub
parent 029057e5ca
commit 207ded39ab
10 changed files with 1616 additions and 1 deletions

View File

@@ -0,0 +1,477 @@
/**
* AI Assistant Component
* Provides an interactive AI chat interface for topic exploration
*/
import React, { useState, useCallback, useRef, useEffect } from 'react'
import {
Box,
Paper,
TextField,
IconButton,
Typography,
Chip,
CircularProgress,
Collapse,
Alert,
Button,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Select,
MenuItem,
FormControl,
InputLabel,
} from '@mui/material'
import { Theme } from '@mui/material/styles'
import { withStyles } from '@mui/styles'
import SendIcon from '@mui/icons-material/Send'
import SmartToyIcon from '@mui/icons-material/SmartToy'
import ExpandMoreIcon from '@mui/icons-material/ExpandMore'
import ExpandLessIcon from '@mui/icons-material/ExpandLess'
import SettingsIcon from '@mui/icons-material/Settings'
import ClearIcon from '@mui/icons-material/Clear'
import { getLLMService, LLMMessage, LLMProvider } from '../../services/llmService'
interface Props {
node?: any
classes: any
}
interface ChatMessage {
role: 'user' | 'assistant' | 'system'
content: string
timestamp: Date
}
function AIAssistant(props: Props) {
const { node, classes } = props
const [expanded, setExpanded] = useState(false)
const [messages, setMessages] = useState<ChatMessage[]>([])
const [inputValue, setInputValue] = useState('')
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [configDialogOpen, setConfigDialogOpen] = useState(false)
const [apiKey, setApiKey] = useState('')
const [provider, setProvider] = useState<LLMProvider>('openai')
const messagesEndRef = useRef<HTMLDivElement>(null)
const llmService = getLLMService()
useEffect(() => {
// Initialize provider from service
setProvider(llmService.getProvider())
}, [])
const scrollToBottom = () => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
}
useEffect(() => {
scrollToBottom()
}, [messages])
const handleSendMessage = useCallback(
async (messageText?: string) => {
const text = messageText || inputValue.trim()
if (!text) return
// Check if API key is configured
if (!llmService.hasApiKey()) {
setError(`Please configure your ${provider === 'gemini' ? 'Gemini' : 'OpenAI'} API key first`)
setConfigDialogOpen(true)
return
}
setInputValue('')
setError(null)
setLoading(true)
// Add user message to UI
const userMessage: ChatMessage = {
role: 'user',
content: text,
timestamp: new Date(),
}
setMessages((prev) => [...prev, userMessage])
try {
// Generate topic context if available
const topicContext = node ? llmService.generateTopicContext(node) : undefined
// Send to LLM
const response = await llmService.sendMessage(text, topicContext)
// Add assistant response to UI
const assistantMessage: ChatMessage = {
role: 'assistant',
content: response,
timestamp: new Date(),
}
setMessages((prev) => [...prev, assistantMessage])
} catch (err: unknown) {
const error = err as { message?: string }
setError(error.message || 'Failed to get response')
} finally {
setLoading(false)
}
},
[inputValue, node, llmService]
)
const handleKeyPress = (event: React.KeyboardEvent) => {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault()
handleSendMessage()
}
}
const handleSuggestionClick = (suggestion: string) => {
handleSendMessage(suggestion)
}
const handleClearChat = () => {
setMessages([])
llmService.clearHistory()
setError(null)
}
const handleSaveApiKey = () => {
if (apiKey.trim()) {
llmService.saveApiKey(apiKey.trim())
llmService.saveProvider(provider)
setConfigDialogOpen(false)
setApiKey('')
setError(null)
// Reset the service to use new config
window.location.reload()
}
}
const suggestions = node ? llmService.getQuickSuggestions(node) : []
// Check if API key is available (from localStorage or environment)
const hasApiKey = llmService.hasApiKey()
// Don't render the component at all if no API key is available
if (!hasApiKey) {
return null
}
return (
<Box className={classes.root}>
{/* Header */}
<Box className={classes.header} onClick={() => setExpanded(!expanded)}>
<Box className={classes.headerLeft}>
<SmartToyIcon className={classes.icon} />
<Typography variant="subtitle2" className={classes.title}>
AI Assistant
</Typography>
<Chip label="Beta" size="small" color="primary" className={classes.betaChip} />
</Box>
<Box className={classes.headerRight}>
<IconButton
size="small"
onClick={(e) => {
e.stopPropagation()
setConfigDialogOpen(true)
}}
className={classes.iconButton}
>
<SettingsIcon fontSize="small" />
</IconButton>
{expanded ? <ExpandLessIcon /> : <ExpandMoreIcon />}
</Box>
</Box>
{/* Chat Interface */}
<Collapse in={expanded}>
<Box className={classes.content}>
{/* Error Alert */}
{error && (
<Alert severity="error" className={classes.alert} onClose={() => setError(null)}>
{error}
</Alert>
)}
{/* Quick Suggestions */}
{messages.length === 0 && suggestions.length > 0 && (
<Box className={classes.suggestions}>
<Typography variant="caption" color="textSecondary" className={classes.suggestionsTitle}>
Quick questions:
</Typography>
<Box className={classes.suggestionChips}>
{suggestions.slice(0, 4).map((suggestion, idx) => (
<Chip
key={idx}
label={suggestion}
size="small"
onClick={() => handleSuggestionClick(suggestion)}
className={classes.suggestionChip}
/>
))}
</Box>
</Box>
)}
{/* Messages */}
<Box className={classes.messages}>
{messages.length === 0 && !error && (
<Box className={classes.emptyState}>
<SmartToyIcon className={classes.emptyIcon} />
<Typography variant="body2" color="textSecondary" align="center">
Ask me anything about this topic!
</Typography>
</Box>
)}
{messages.map((msg, idx) => (
<Box
key={idx}
className={msg.role === 'user' ? classes.userMessage : classes.assistantMessage}
>
<Typography variant="body2" className={classes.messageText}>
{msg.content}
</Typography>
<Typography variant="caption" color="textSecondary" className={classes.messageTime}>
{msg.timestamp.toLocaleTimeString()}
</Typography>
</Box>
))}
{loading && (
<Box className={classes.loadingBox}>
<CircularProgress size={20} />
<Typography variant="caption" color="textSecondary" sx={{ ml: 1 }}>
Thinking...
</Typography>
</Box>
)}
<div ref={messagesEndRef} />
</Box>
{/* Input */}
<Box className={classes.inputContainer}>
{messages.length > 0 && (
<IconButton size="small" onClick={handleClearChat} className={classes.clearButton}>
<ClearIcon fontSize="small" />
</IconButton>
)}
<TextField
fullWidth
size="small"
placeholder="Ask about this topic..."
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyPress={handleKeyPress}
disabled={loading}
className={classes.input}
multiline
maxRows={3}
/>
<IconButton
color="primary"
onClick={() => handleSendMessage()}
disabled={!inputValue.trim() || loading}
className={classes.sendButton}
>
<SendIcon />
</IconButton>
</Box>
</Box>
</Collapse>
{/* Configuration Dialog */}
<Dialog open={configDialogOpen} onClose={() => setConfigDialogOpen(false)} maxWidth="sm" fullWidth>
<DialogTitle>AI Assistant Configuration</DialogTitle>
<DialogContent>
<FormControl fullWidth margin="normal">
<InputLabel>AI Provider</InputLabel>
<Select
value={provider}
label="AI Provider"
onChange={(e) => setProvider(e.target.value as LLMProvider)}
>
<MenuItem value="openai">OpenAI (GPT-3.5 Turbo)</MenuItem>
<MenuItem value="gemini">Google Gemini (Flash)</MenuItem>
</Select>
</FormControl>
<Typography variant="body2" color="textSecondary" paragraph sx={{ mt: 2 }}>
{provider === 'openai' ? (
<>
Get your OpenAI API key from{' '}
<a href="https://platform.openai.com/api-keys" target="_blank" rel="noopener noreferrer">
OpenAI's platform
</a>
.
</>
) : (
<>
Get your Gemini API key from{' '}
<a href="https://aistudio.google.com/app/apikey" target="_blank" rel="noopener noreferrer">
Google AI Studio
</a>
.
</>
)}
</Typography>
<TextField
fullWidth
label={`${provider === 'openai' ? 'OpenAI' : 'Gemini'} API Key`}
type="password"
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
placeholder={provider === 'openai' ? 'sk-...' : 'AIza...'}
margin="normal"
helperText="Your API key is stored locally and never sent to our servers"
/>
</DialogContent>
<DialogActions>
<Button onClick={() => setConfigDialogOpen(false)}>Cancel</Button>
<Button onClick={handleSaveApiKey} variant="contained" disabled={!apiKey.trim()}>
Save
</Button>
</DialogActions>
</Dialog>
</Box>
)
}
const styles = (theme: Theme) => ({
root: {
marginTop: theme.spacing(2),
border: `1px solid ${theme.palette.divider}`,
borderRadius: theme.shape.borderRadius,
overflow: 'hidden',
},
header: {
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
padding: theme.spacing(1.5, 2),
backgroundColor: theme.palette.action.hover,
cursor: 'pointer',
'&:hover': {
backgroundColor: theme.palette.action.selected,
},
},
headerLeft: {
display: 'flex',
alignItems: 'center',
gap: theme.spacing(1),
},
headerRight: {
display: 'flex',
alignItems: 'center',
gap: theme.spacing(0.5),
},
icon: {
color: theme.palette.primary.main,
},
title: {
fontWeight: 600,
},
betaChip: {
height: '20px',
fontSize: '0.7rem',
},
iconButton: {
padding: theme.spacing(0.5),
},
content: {
padding: theme.spacing(2),
display: 'flex',
flexDirection: 'column' as 'column',
gap: theme.spacing(1.5),
},
alert: {
marginBottom: theme.spacing(1),
},
suggestions: {
marginBottom: theme.spacing(1),
},
suggestionsTitle: {
display: 'block',
marginBottom: theme.spacing(0.5),
},
suggestionChips: {
display: 'flex',
flexWrap: 'wrap' as 'wrap',
gap: theme.spacing(0.5),
},
suggestionChip: {
cursor: 'pointer',
'&:hover': {
backgroundColor: theme.palette.primary.main,
color: theme.palette.primary.contrastText,
},
},
messages: {
maxHeight: '300px',
overflowY: 'auto' as 'auto',
display: 'flex',
flexDirection: 'column' as 'column',
gap: theme.spacing(1),
},
emptyState: {
display: 'flex',
flexDirection: 'column' as 'column',
alignItems: 'center',
justifyContent: 'center',
padding: theme.spacing(4),
gap: theme.spacing(1),
},
emptyIcon: {
fontSize: '3rem',
color: theme.palette.action.disabled,
},
userMessage: {
alignSelf: 'flex-end',
maxWidth: '80%',
padding: theme.spacing(1, 1.5),
backgroundColor: theme.palette.primary.main,
color: theme.palette.primary.contrastText,
borderRadius: theme.spacing(1.5),
borderBottomRightRadius: theme.spacing(0.5),
},
assistantMessage: {
alignSelf: 'flex-start',
maxWidth: '80%',
padding: theme.spacing(1, 1.5),
backgroundColor: theme.palette.action.hover,
borderRadius: theme.spacing(1.5),
borderBottomLeftRadius: theme.spacing(0.5),
},
messageText: {
whiteSpace: 'pre-wrap' as 'pre-wrap',
wordBreak: 'break-word' as 'break-word',
},
messageTime: {
display: 'block',
marginTop: theme.spacing(0.5),
fontSize: '0.65rem',
},
loadingBox: {
display: 'flex',
alignItems: 'center',
padding: theme.spacing(1),
},
inputContainer: {
display: 'flex',
gap: theme.spacing(1),
alignItems: 'flex-end',
},
clearButton: {
padding: theme.spacing(0.5),
},
input: {
flex: 1,
},
sendButton: {
padding: theme.spacing(1),
},
})
export default withStyles(styles)(AIAssistant)

View File

@@ -19,6 +19,7 @@ import DeleteIcon from '@mui/icons-material/Delete'
import DeleteSweepIcon from '@mui/icons-material/DeleteSweep'
import Info from '@mui/icons-material/Info'
import SimpleBreadcrumb from './SimpleBreadcrumb'
import AIAssistant from './AIAssistant'
interface Props {
node?: q.TreeNode<any>
@@ -195,7 +196,10 @@ function DetailsTab(props: Props) {
</Box>
</Box>
)}
{/* AI Assistant - Always available when a node is selected */}
{node && <AIAssistant node={node} />}
{/* About Section - always visible at bottom */}
<Box className={classes.aboutSection}>
<Button
@@ -235,6 +239,11 @@ const styles = (theme: Theme) => ({
paddingTop: theme.spacing(2),
borderTop: `1px solid ${theme.palette.divider}`,
},
aboutSection: {
marginTop: theme.spacing(3),
paddingTop: theme.spacing(2),
borderTop: `1px solid ${theme.palette.divider}`,
},
// Topic section
topicSection: {
display: 'flex',

View File

@@ -0,0 +1,507 @@
/**
* LLM Service for interacting with topics
* Provides AI assistance to help users understand and interact with MQTT topics
*/
import axios, { AxiosInstance } from 'axios'
export interface LLMMessage {
role: 'system' | 'user' | 'assistant'
content: string
}
export type LLMProvider = 'openai' | 'gemini'
export interface LLMServiceConfig {
apiKey?: string
apiEndpoint?: string
model?: string
provider?: LLMProvider
neighboringTopicsTokenLimit?: number
}
export class LLMService {
private axiosInstance: AxiosInstance
private model: string
private provider: LLMProvider
private conversationHistory: LLMMessage[] = []
private neighboringTopicsTokenLimit: number
constructor(config: LLMServiceConfig = {}) {
const apiKey = config.apiKey || this.getApiKeyFromStorage() || this.getApiKeyFromEnv()
this.provider = config.provider || this.getProviderFromStorage() || this.getProviderFromEnv() || 'openai'
this.neighboringTopicsTokenLimit = config.neighboringTopicsTokenLimit || this.getNeighboringTopicsTokenLimitFromEnv() || 100
// Set default endpoint and model based on provider
let baseURL = config.apiEndpoint
if (!baseURL) {
baseURL = this.provider === 'gemini'
? 'https://generativelanguage.googleapis.com/v1beta'
: 'https://api.openai.com/v1'
}
this.model = config.model || (this.provider === 'gemini' ? 'gemini-1.5-flash-latest' : 'gpt-3.5-turbo')
// Configure headers based on provider
const headers: Record<string, string> = {
'Content-Type': 'application/json',
}
if (apiKey) {
if (this.provider === 'gemini') {
// Gemini uses API key as query parameter, not header
// Will be added to URL in sendMessage
} else {
headers.Authorization = `Bearer ${apiKey}`
}
}
this.axiosInstance = axios.create({
baseURL,
headers,
timeout: 30000,
})
// Initialize with system message that sets MQTT and automation context
this.conversationHistory.push({
role: 'system',
content: `You are an expert AI assistant specializing in MQTT (Message Queuing Telemetry Transport) protocol and home/industrial automation systems.
**Your Core Expertise:**
- MQTT protocol: topics, QoS levels, retained messages, wildcards, last will and testament
- IoT and smart home ecosystems: devices, sensors, actuators, and controllers
- Home automation platforms: Home Assistant, openHAB, Node-RED, MQTT brokers
- Common MQTT topic patterns and naming conventions (e.g., zigbee2mqtt, tasmota, homie)
- Data formats: JSON payloads, binary data, sensor readings, state messages
- Time-series data analysis and pattern recognition
- Troubleshooting connectivity, message delivery, and data quality issues
**Your Communication Style:**
- Be concise and practical - focus on actionable insights
- Use clear technical language appropriate for users familiar with MQTT
- When analyzing data, identify patterns, anomalies, or potential issues
- Suggest practical next steps or automations when relevant
- Reference common MQTT ecosystems and standards when applicable
**Context You Receive:**
Users will ask about specific MQTT topics and their data. You'll receive:
- Topic path (the MQTT topic hierarchy)
- Current value and message payload
- Related/neighboring topics with their values
- Metadata (message count, subtopics, retained status)
**Your Goal:**
Help users understand their MQTT data, troubleshoot issues, optimize their automation setups, and discover insights about their connected devices and systems.`,
})
}
private getApiKeyFromStorage(): string | undefined {
if (typeof window !== 'undefined' && window.localStorage) {
return window.localStorage.getItem('llm_api_key') || undefined
}
return undefined
}
private getApiKeyFromEnv(): string | undefined {
if (typeof process !== 'undefined' && process.env) {
// Try provider-specific env vars first, then fall back to generic
if (this.provider === 'gemini') {
return process.env.GEMINI_API_KEY || process.env.LLM_API_KEY
} else {
return process.env.OPENAI_API_KEY || process.env.LLM_API_KEY
}
}
return undefined
}
private getProviderFromStorage(): LLMProvider | undefined {
if (typeof window !== 'undefined' && window.localStorage) {
const provider = window.localStorage.getItem('llm_provider')
return provider === 'gemini' || provider === 'openai' ? provider : undefined
}
return undefined
}
private getProviderFromEnv(): LLMProvider | undefined {
if (typeof process !== 'undefined' && process.env) {
const provider = process.env.LLM_PROVIDER
return provider === 'gemini' || provider === 'openai' ? provider : undefined
}
return undefined
}
private getNeighboringTopicsTokenLimitFromEnv(): number | undefined {
if (typeof process !== 'undefined' && process.env) {
const limit = parseInt(process.env.LLM_NEIGHBORING_TOPICS_TOKEN_LIMIT || '', 10)
return isNaN(limit) ? undefined : limit
}
return undefined
}
/**
* Save API key to local storage
*/
public saveApiKey(apiKey: string): void {
if (typeof window !== 'undefined' && window.localStorage) {
window.localStorage.setItem('llm_api_key', apiKey)
}
}
/**
* Save provider to local storage
*/
public saveProvider(provider: LLMProvider): void {
if (typeof window !== 'undefined' && window.localStorage) {
window.localStorage.setItem('llm_provider', provider)
}
}
/**
* Clear API key from local storage
*/
public clearApiKey(): void {
if (typeof window !== 'undefined' && window.localStorage) {
window.localStorage.removeItem('llm_api_key')
}
}
/**
* Check if API key is configured
*/
public hasApiKey(): boolean {
return !!this.getApiKeyFromStorage()
}
/**
* Get current provider
*/
public getProvider(): LLMProvider {
return this.provider
}
/**
* Estimate tokens in text (rough approximation: ~4 characters per token)
*/
private estimateTokens(text: string): number {
// Simple estimation: average ~4 characters per token
// This is a rough approximation for both OpenAI and Gemini
return Math.ceil(text.length / 4)
}
/**
* Truncate text to fit within token limit
* Returns object with truncated text and flag indicating if truncation occurred
*/
private truncateToTokenLimit(text: string, tokenLimit: number): { text: string; truncated: boolean } {
const estimatedTokens = this.estimateTokens(text)
if (estimatedTokens <= tokenLimit) {
return { text, truncated: false }
}
// Truncate to approximate character count
const maxChars = tokenLimit * 4
if (text.length <= maxChars) {
return { text, truncated: false }
}
return {
text: text.substring(0, maxChars - 3) + '...',
truncated: true
}
}
/**
* Escape string for single-line representation (no newlines)
* Encodes newlines and other special characters similar to JSON string encoding
*/
private escapeToSingleLine(text: string): string {
return text
.replace(/\\/g, '\\\\') // Escape backslashes first
.replace(/\n/g, '\\n') // Encode newlines
.replace(/\r/g, '\\r') // Encode carriage returns
.replace(/\t/g, '\\t') // Encode tabs
.replace(/"/g, '\\"') // Escape quotes
}
/**
* Format value for LLM context (machine-friendly, single-line)
*/
private formatValueForContext(value: any, tokenLimit: number, markTruncation: boolean = true): string {
let valueStr: string
if (typeof value === 'object' && value !== null) {
// For objects, use JSON.stringify which handles escaping
valueStr = JSON.stringify(value)
} else {
valueStr = String(value)
}
// Escape to single line
const escaped = this.escapeToSingleLine(valueStr)
// Truncate if needed
const result = this.truncateToTokenLimit(escaped, tokenLimit)
if (result.truncated && markTruncation) {
return `[TRUNCATED] ${result.text}`
}
return result.text
}
/**
* Generate context from topic data including neighboring topics
*/
public generateTopicContext(topic: {
path?: () => string
message?: any
messages?: number
childTopicCount?: () => number
type?: string
parent?: any
edgeCollection?: any
}): string {
const context = []
if (topic.path) {
context.push(`Topic: ${topic.path()}`)
}
// Add current value with preview (allow more tokens for main topic - 200 tokens)
if (topic.message?.payload) {
const [value] = topic.message.payload.format(topic.type)
if (value !== null && value !== undefined) {
// Main topic value can contain newlines, format for LLM
const formattedValue = this.formatValueForContext(value, 200, true)
context.push(`Value: ${formattedValue}`)
}
// Add retained status if true
if (topic.message.retain) {
context.push(`Retained: true`)
}
}
// Add neighboring topics (siblings and children) up to token limit
// Full topic paths with single-line previews
const neighbors: string[] = []
let neighborsTokenCount = 0
const tokenLimit = this.neighboringTopicsTokenLimit
// Helper function to add a neighbor if within token limit
const addNeighbor = (fullPath: string, value: any): boolean => {
// Format value as single-line preview (no newlines)
const preview = this.formatValueForContext(value, 20, false) // 20 tokens per neighbor
const neighborEntry = ` ${fullPath}: ${preview}`
const tokens = this.estimateTokens(neighborEntry)
if (neighborsTokenCount + tokens <= tokenLimit) {
neighbors.push(neighborEntry)
neighborsTokenCount += tokens
return true
}
return false
}
// Get parent path for constructing full paths
const parentPath = topic.parent?.path ? topic.parent.path() : ''
// Get siblings from parent
if (topic.parent && topic.parent.edgeCollection) {
const siblings = topic.parent.edgeCollection.edges || []
for (const edge of siblings) {
if (neighborsTokenCount >= tokenLimit) break
if (edge.name && edge.node && edge.node.message?.payload) {
const [siblingValue] = edge.node.message.payload.format(edge.node.type)
if (siblingValue !== null && siblingValue !== undefined) {
const fullPath = parentPath ? `${parentPath}/${edge.name}` : edge.name
if (!addNeighbor(fullPath, siblingValue)) {
break
}
}
}
}
}
// Get children
const currentPath = topic.path ? topic.path() : ''
if (topic.edgeCollection?.edges && neighborsTokenCount < tokenLimit) {
const children = topic.edgeCollection.edges || []
for (const edge of children) {
if (neighborsTokenCount >= tokenLimit) break
if (edge.name && edge.node && edge.node.message?.payload) {
const [childValue] = edge.node.message.payload.format(edge.node.type)
if (childValue !== null && childValue !== undefined) {
const fullPath = currentPath ? `${currentPath}/${edge.name}` : edge.name
if (!addNeighbor(fullPath, childValue)) {
break
}
}
}
}
}
if (neighbors.length > 0) {
context.push(`\nRelated Topics (${neighbors.length}):`)
context.push(neighbors.join('\n'))
}
// Add metadata
if (topic.messages) {
context.push(`\nMessages: ${topic.messages}`)
}
if (topic.childTopicCount) {
const childCount = topic.childTopicCount()
if (childCount > 0) {
context.push(`Subtopics: ${childCount}`)
}
}
return context.join('\n')
}
/**
* Send a message to the LLM and get a response
*/
public async sendMessage(userMessage: string, topicContext?: string): Promise<string> {
try {
// Add topic context if provided
let messageContent = userMessage
if (topicContext) {
messageContent = `Context:\n${topicContext}\n\nUser Question: ${userMessage}`
}
// Add user message to history
this.conversationHistory.push({
role: 'user',
content: messageContent,
})
let assistantMessage: string
if (this.provider === 'gemini') {
// Gemini API format
const apiKey = this.getApiKeyFromStorage()
const contents = this.conversationHistory
.filter(msg => msg.role !== 'system')
.map(msg => ({
role: msg.role === 'assistant' ? 'model' : 'user',
parts: [{ text: msg.content }],
}))
// Prepend system message as first user message for Gemini
const systemMsg = this.conversationHistory.find(msg => msg.role === 'system')
if (systemMsg && contents.length > 0) {
contents[0].parts.unshift({ text: systemMsg.content })
}
const response = await this.axiosInstance.post(
`/models/${this.model}:generateContent?key=${apiKey}`,
{
contents,
generationConfig: {
temperature: 0.7,
maxOutputTokens: 500,
},
}
)
if (!response.data.candidates || response.data.candidates.length === 0) {
throw new Error('No response from AI assistant')
}
assistantMessage = response.data.candidates[0].content.parts[0].text
} else {
// OpenAI API format
const response = await this.axiosInstance.post('/chat/completions', {
model: this.model,
messages: this.conversationHistory,
temperature: 0.7,
max_tokens: 500,
})
if (!response.data.choices || response.data.choices.length === 0) {
throw new Error('No response from AI assistant')
}
assistantMessage = response.data.choices[0].message.content
}
// Add assistant response to history
this.conversationHistory.push({
role: 'assistant',
content: assistantMessage,
})
// Keep conversation history manageable (last 10 messages + system)
if (this.conversationHistory.length > 11) {
this.conversationHistory = [
this.conversationHistory[0], // Keep system message
...this.conversationHistory.slice(-10), // Keep last 10 messages
]
}
return assistantMessage
} catch (error: unknown) {
console.error('LLM Service Error:', error)
const err = error as { response?: { status?: number; data?: any }; code?: string; message?: string }
if (err.response?.status === 401 || err.response?.status === 403) {
throw new Error('Invalid API key. Please check your configuration.')
} else if (err.response?.status === 429) {
throw new Error('Rate limit exceeded. Please try again later.')
} else if (err.code === 'ECONNABORTED') {
throw new Error('Request timeout. Please try again.')
} else {
throw new Error(err.message || 'Failed to get response from AI assistant.')
}
}
}
/**
* Clear conversation history
*/
public clearHistory(): void {
this.conversationHistory = [this.conversationHistory[0]] // Keep only system message
}
/**
* Get quick suggestions based on topic
*/
public getQuickSuggestions(topic: { message?: { payload?: any }; childTopicCount?: () => number; messages?: number }): string[] {
const suggestions = []
if (topic.message?.payload) {
suggestions.push('Explain this data structure')
suggestions.push('What does this value mean?')
}
if (topic.childTopicCount && topic.childTopicCount() > 0) {
suggestions.push('Summarize all subtopics')
}
if (topic.messages > 1) {
suggestions.push('Analyze message patterns')
}
suggestions.push('What can I do with this topic?')
return suggestions
}
}
// Export a singleton instance
let llmServiceInstance: LLMService | null = null
export function getLLMService(): LLMService {
if (!llmServiceInstance) {
llmServiceInstance = new LLMService()
}
return llmServiceInstance
}
export function resetLLMService(): void {
llmServiceInstance = null
}