mirror of
https://github.com/Dvorinka/Trackeep.git
synced 2026-07-28 21:43:50 +00:00
1007 lines
44 KiB
TypeScript
1007 lines
44 KiB
TypeScript
import { createEffect, createResource, createSignal, For, Show, onMount } from 'solid-js'
|
||
import { getApiOrigin } from '@/lib/api-url'
|
||
import { Button } from '@/components/ui/Button'
|
||
import { Input } from '@/components/ui/Input'
|
||
import { Card } from '@/components/ui/Card'
|
||
import {
|
||
Send,
|
||
CheckSquare,
|
||
FileText as FileTextIcon,
|
||
Sparkles,
|
||
ChevronDown,
|
||
Trash,
|
||
User
|
||
} from 'lucide-solid'
|
||
|
||
interface ChatMessage {
|
||
id: number
|
||
content: string
|
||
role: 'user' | 'assistant'
|
||
created_at: string
|
||
token_count?: number
|
||
context_items?: string[]
|
||
}
|
||
|
||
interface ChatSession {
|
||
id: number
|
||
title: string
|
||
message_count: number
|
||
last_message_at?: string
|
||
created_at: string
|
||
include_bookmarks: boolean
|
||
include_tasks: boolean
|
||
include_files: boolean
|
||
include_notes: boolean
|
||
}
|
||
|
||
const getToken = () => localStorage.getItem('trackeep_token') || localStorage.getItem('token') || ''
|
||
|
||
const getProviderFromModel = (modelId: string): string => {
|
||
const value = modelId.toLowerCase()
|
||
if (value.includes('mistral')) return 'mistral'
|
||
if (value.includes('grok')) return 'grok'
|
||
if (value.includes('deepseek')) return 'deepseek'
|
||
if (value.includes('ollama')) return 'ollama'
|
||
if (value.includes('openrouter')) return 'openrouter'
|
||
return 'longcat'
|
||
}
|
||
|
||
const Chat = () => {
|
||
const [activeView, setActiveView] = createSignal<'chat' | 'ai-tools'>('chat')
|
||
const [aiTool, setAiTool] = createSignal<'summarizer' | 'tasks' | 'content'>('summarizer')
|
||
const [selectedModel, setSelectedModel] = createSignal('longcat')
|
||
const [showModelPicker, setShowModelPicker] = createSignal(false)
|
||
const [availableProviders, setAvailableProviders] = createSignal<any[]>([])
|
||
const [aiSettingsData, setAISettingsData] = createSignal<any>({})
|
||
|
||
// Load AI providers and settings on mount
|
||
onMount(async () => {
|
||
parseChatDeeplink()
|
||
await loadAIProviders()
|
||
await loadAISettings()
|
||
})
|
||
|
||
const loadAIProviders = async () => {
|
||
try {
|
||
const token = localStorage.getItem('token')
|
||
const response = await fetch(`${getApiOrigin()}/api/v1/ai/providers`, {
|
||
headers: {
|
||
'Authorization': `Bearer ${token}`,
|
||
'Content-Type': 'application/json'
|
||
}
|
||
})
|
||
|
||
if (response.ok) {
|
||
const data = await response.json()
|
||
setAvailableProviders(data.providers || [])
|
||
}
|
||
} catch (error) {
|
||
console.error('Failed to load AI providers:', error)
|
||
}
|
||
}
|
||
|
||
const loadAISettings = async () => {
|
||
try {
|
||
const token = localStorage.getItem('token')
|
||
const response = await fetch(`${getApiOrigin()}/api/v1/auth/ai/settings`, {
|
||
headers: {
|
||
'Authorization': `Bearer ${token}`,
|
||
'Content-Type': 'application/json'
|
||
}
|
||
})
|
||
|
||
if (response.ok) {
|
||
const data = await response.json()
|
||
setAISettingsData(data)
|
||
|
||
// Set selected model to first enabled provider
|
||
const enabledProviders = Object.entries(data)
|
||
.filter(([_, config]: [string, any]) => config.enabled)
|
||
.map(([provider]) => provider)
|
||
|
||
if (enabledProviders.length > 0) {
|
||
setSelectedModel(enabledProviders[0])
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.error('Failed to load AI settings:', error)
|
||
}
|
||
}
|
||
|
||
// Get available AI models from providers and settings
|
||
const getAIModels = () => {
|
||
const settings = aiSettingsData()
|
||
const providers = availableProviders()
|
||
|
||
const models: any[] = []
|
||
|
||
// Add models from enabled providers
|
||
if (settings) {
|
||
Object.entries(settings).forEach(([provider, config]: [string, any]) => {
|
||
if (config.enabled) {
|
||
models.push({
|
||
id: provider,
|
||
name: getProviderDisplayName(provider),
|
||
description: getProviderDescription(provider),
|
||
provider: getProviderDisplayName(provider),
|
||
category: 'available'
|
||
})
|
||
}
|
||
})
|
||
}
|
||
|
||
// Keep disabled providers visible so users can enable them in settings
|
||
if (providers.length > 0) {
|
||
providers.forEach((provider: any) => {
|
||
if (!models.find(m => m.id === provider.id)) {
|
||
models.push({
|
||
id: provider.id,
|
||
name: getProviderDisplayName(provider.id),
|
||
description: getProviderDescription(provider.id),
|
||
provider: getProviderDisplayName(provider.id),
|
||
category: 'disabled'
|
||
})
|
||
}
|
||
})
|
||
}
|
||
|
||
return models
|
||
}
|
||
|
||
const getProviderDisplayName = (provider: string) => {
|
||
const names: Record<string, string> = {
|
||
mistral: 'Mistral AI',
|
||
longcat: 'LongCat AI',
|
||
grok: 'Grok AI',
|
||
deepseek: 'DeepSeek AI',
|
||
ollama: 'Ollama',
|
||
openrouter: 'OpenRouter'
|
||
}
|
||
return names[provider] || provider.charAt(0).toUpperCase() + provider.slice(1)
|
||
}
|
||
|
||
const getProviderDescription = (provider: string) => {
|
||
const descriptions: Record<string, string> = {
|
||
mistral: 'European AI with multilingual capabilities',
|
||
longcat: 'Fast and efficient AI models',
|
||
grok: 'xAI\'s conversational AI model',
|
||
deepseek: 'Advanced reasoning and coding',
|
||
ollama: 'Local AI models',
|
||
openrouter: 'Access to multiple AI models'
|
||
}
|
||
return descriptions[provider] || 'AI provider'
|
||
}
|
||
|
||
const fetchSessions = async () => {
|
||
try {
|
||
const token = getToken()
|
||
const response = await fetch(`${getApiOrigin()}/api/v1/chat/sessions`, {
|
||
headers: {
|
||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||
},
|
||
})
|
||
if (!response.ok) throw new Error('Failed to fetch sessions')
|
||
const data = await response.json()
|
||
return Array.isArray(data) ? data : []
|
||
} catch (error) {
|
||
console.error('Failed to fetch sessions:', error)
|
||
return [] as ChatSession[]
|
||
}
|
||
}
|
||
|
||
const [sessions, { refetch: refetchSessions }] = createResource(fetchSessions)
|
||
|
||
const [currentSessionId, setCurrentSessionId] = createSignal<string | null>(null)
|
||
const [messages, setMessages] = createSignal<ChatMessage[]>([])
|
||
const [deeplinkSessionId, setDeeplinkSessionId] = createSignal<string | null>(null)
|
||
const [deeplinkMessageId, setDeeplinkMessageId] = createSignal<number | null>(null)
|
||
const [deeplinkResolved, setDeeplinkResolved] = createSignal(false)
|
||
const [highlightedMessageId, setHighlightedMessageId] = createSignal<number | null>(null)
|
||
const [inputMessage, setInputMessage] = createSignal('')
|
||
const [isLoading, setIsLoading] = createSignal(false)
|
||
const [showSettings, setShowSettings] = createSignal(false)
|
||
const [settingsExpanded, setSettingsExpanded] = createSignal(false)
|
||
const [contextSettings, setContextSettings] = createSignal({
|
||
bookmarks: true,
|
||
tasks: true,
|
||
files: true,
|
||
notes: true
|
||
})
|
||
const [aiSettings, setAiSettings] = createSignal({
|
||
temperature: 0.7,
|
||
maxTokens: 2048,
|
||
systemPrompt: 'You are a helpful AI assistant for the Trackeep productivity application. Help users manage their tasks, bookmarks, files, and notes effectively.'
|
||
})
|
||
|
||
const aiTools = [
|
||
{ id: 'summarizer', label: 'Content Summarizer', icon: FileTextIcon, description: 'Summarize your notes, documents, and content' },
|
||
{ id: 'tasks', label: 'Task Suggestions', icon: CheckSquare, description: 'Get AI-powered task suggestions and organization' },
|
||
{ id: 'content', label: 'Content Generation', icon: Sparkles, description: 'Generate content using AI assistance' }
|
||
]
|
||
|
||
const parseChatDeeplink = () => {
|
||
const params = new URLSearchParams(window.location.search)
|
||
const rawSession = (params.get('session') || '').trim()
|
||
const rawMessage = (params.get('message') || '').trim()
|
||
setDeeplinkSessionId(rawSession || null)
|
||
const parsedMessageId = Number(rawMessage)
|
||
setDeeplinkMessageId(Number.isFinite(parsedMessageId) && parsedMessageId > 0 ? parsedMessageId : null)
|
||
}
|
||
|
||
const scrollToHighlightedMessage = (messageId: number) => {
|
||
window.requestAnimationFrame(() => {
|
||
const element = document.getElementById(`chat-message-${messageId}`)
|
||
if (!element) return
|
||
element.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||
})
|
||
}
|
||
|
||
const loadSessionMessages = async (sessionId: string) => {
|
||
try {
|
||
const token = getToken()
|
||
const response = await fetch(`${getApiOrigin()}/api/v1/chat/sessions/${sessionId}/messages`, {
|
||
headers: {
|
||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||
},
|
||
})
|
||
|
||
if (!response.ok) {
|
||
throw new Error('Failed to fetch messages')
|
||
}
|
||
|
||
const data = await response.json()
|
||
const parsedMessages: ChatMessage[] = (Array.isArray(data) ? data : []).map((message: any) => ({
|
||
id: Number(message.id || Date.now()),
|
||
content: message.content || '',
|
||
role: message.role === 'user' ? 'user' : 'assistant',
|
||
created_at: message.created_at || new Date().toISOString(),
|
||
token_count: message.token_count,
|
||
context_items: Array.isArray(message.context_items) ? message.context_items : [],
|
||
}))
|
||
|
||
setMessages(parsedMessages)
|
||
const targetMessageId = deeplinkMessageId()
|
||
if (targetMessageId && String(deeplinkSessionId() || '') === sessionId) {
|
||
if (parsedMessages.some((message) => message.id === targetMessageId)) {
|
||
setHighlightedMessageId(targetMessageId)
|
||
scrollToHighlightedMessage(targetMessageId)
|
||
window.setTimeout(() => {
|
||
setHighlightedMessageId((current) => (current === targetMessageId ? null : current))
|
||
}, 6000)
|
||
} else {
|
||
setHighlightedMessageId(null)
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.error('Failed to load session messages:', error)
|
||
setMessages([])
|
||
}
|
||
}
|
||
|
||
createEffect(() => {
|
||
const loadedSessions = sessions()
|
||
if (!loadedSessions || loadedSessions.length === 0) {
|
||
return
|
||
}
|
||
|
||
if (!deeplinkResolved()) {
|
||
const requestedSessionId = deeplinkSessionId()
|
||
if (requestedSessionId) {
|
||
const matchingSession = loadedSessions.find((session) => String(session.id) === requestedSessionId)
|
||
if (matchingSession) {
|
||
setCurrentSessionId(requestedSessionId)
|
||
setDeeplinkResolved(true)
|
||
void loadSessionMessages(requestedSessionId)
|
||
return
|
||
}
|
||
}
|
||
setDeeplinkResolved(true)
|
||
}
|
||
|
||
if (currentSessionId()) {
|
||
return
|
||
}
|
||
|
||
const firstSessionId = String(loadedSessions[0].id)
|
||
setCurrentSessionId(firstSessionId)
|
||
void loadSessionMessages(firstSessionId)
|
||
})
|
||
|
||
const handleSendMessage = async () => {
|
||
const message = inputMessage().trim()
|
||
if (!message || isLoading()) return
|
||
|
||
// Add user message
|
||
const userMessage: ChatMessage = {
|
||
id: Date.now(),
|
||
content: message,
|
||
role: 'user',
|
||
created_at: new Date().toISOString()
|
||
}
|
||
|
||
setMessages((prev) => [...prev, userMessage])
|
||
setInputMessage('')
|
||
setIsLoading(true)
|
||
|
||
try {
|
||
const token = getToken()
|
||
const payload: Record<string, any> = {
|
||
message,
|
||
context: {
|
||
bookmarks: contextSettings().bookmarks,
|
||
tasks: contextSettings().tasks,
|
||
files: contextSettings().files,
|
||
notes: contextSettings().notes,
|
||
},
|
||
provider: getProviderFromModel(selectedModel()),
|
||
}
|
||
|
||
if (currentSessionId()) {
|
||
payload.session_id = currentSessionId()
|
||
}
|
||
|
||
const response = await fetch(`${getApiOrigin()}/api/v1/chat/send`, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||
},
|
||
body: JSON.stringify(payload),
|
||
})
|
||
|
||
if (!response.ok) {
|
||
throw new Error('Failed to send message')
|
||
}
|
||
|
||
const data = await response.json()
|
||
const sessionId = String(data.session_id || currentSessionId() || '')
|
||
if (sessionId) {
|
||
setCurrentSessionId(sessionId)
|
||
}
|
||
|
||
const aiResponse: ChatMessage = {
|
||
id: Date.now() + 1,
|
||
content: data.message || 'No response received.',
|
||
role: 'assistant',
|
||
created_at: data.timestamp || new Date().toISOString(),
|
||
}
|
||
|
||
setMessages((prev) => [...prev, aiResponse])
|
||
await refetchSessions()
|
||
} catch (error) {
|
||
console.error('Failed to send message:', error)
|
||
setMessages((prev) => [
|
||
...prev,
|
||
{
|
||
id: Date.now() + 1,
|
||
content: 'Message failed. Please check your AI settings and try again.',
|
||
role: 'assistant',
|
||
created_at: new Date().toISOString(),
|
||
},
|
||
])
|
||
} finally {
|
||
setIsLoading(false)
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div class="mt-4 pb-32 max-w-7xl mx-auto flex flex-col md:flex-row gap-4">
|
||
<div class="w-72 flex-shrink-0 bg-background rounded-lg border shadow-sm overflow-hidden flex flex-col">
|
||
<Show when={showSettings()}>
|
||
<div class="p-6 border-b bg-muted/30">
|
||
<div class="flex items-center justify-between mb-4">
|
||
<h3 class="text-sm font-medium text-foreground">
|
||
AI Settings
|
||
</h3>
|
||
<div class="flex items-center gap-2">
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
onClick={() => setSettingsExpanded(!settingsExpanded())}
|
||
class="h-6 w-6 p-0"
|
||
>
|
||
{settingsExpanded() ? '−' : '+'}
|
||
</Button>
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
onClick={() => setShowSettings(false)}
|
||
class="h-6 w-6 p-0"
|
||
>
|
||
×
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Context Settings */}
|
||
<div class="mb-6">
|
||
<h4 class="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-3">Context Sources</h4>
|
||
<div class="grid grid-cols-2 gap-3">
|
||
<label class="flex items-center gap-3 text-sm cursor-pointer bg-background/50 hover:bg-background border border-border/50 rounded-lg p-3 transition-all hover:shadow-sm">
|
||
<input
|
||
type="checkbox"
|
||
checked={contextSettings().bookmarks}
|
||
onChange={(e) => setContextSettings(prev => ({ ...prev, bookmarks: (e.target as HTMLInputElement).checked }))}
|
||
class="w-4 h-4 text-primary border-border rounded focus:ring-primary focus:ring-2"
|
||
/>
|
||
<div class="flex items-center gap-2 flex-1">
|
||
<span class="font-medium">Bookmarks</span>
|
||
</div>
|
||
</label>
|
||
<label class="flex items-center gap-3 text-sm cursor-pointer bg-background/50 hover:bg-background border border-border/50 rounded-lg p-3 transition-all hover:shadow-sm">
|
||
<input
|
||
type="checkbox"
|
||
checked={contextSettings().tasks}
|
||
onChange={(e) => setContextSettings(prev => ({ ...prev, tasks: (e.target as HTMLInputElement).checked }))}
|
||
class="w-4 h-4 text-primary border-border rounded focus:ring-primary focus:ring-2"
|
||
/>
|
||
<div class="flex items-center gap-2 flex-1">
|
||
<span class="font-medium">Tasks</span>
|
||
</div>
|
||
</label>
|
||
<label class="flex items-center gap-3 text-sm cursor-pointer bg-background/50 hover:bg-background border border-border/50 rounded-lg p-3 transition-all hover:shadow-sm">
|
||
<input
|
||
type="checkbox"
|
||
checked={contextSettings().files}
|
||
onChange={(e) => setContextSettings(prev => ({ ...prev, files: (e.target as HTMLInputElement).checked }))}
|
||
class="w-4 h-4 text-primary border-border rounded focus:ring-primary focus:ring-2"
|
||
/>
|
||
<div class="flex items-center gap-2 flex-1">
|
||
<span class="font-medium">Files</span>
|
||
</div>
|
||
</label>
|
||
<label class="flex items-center gap-3 text-sm cursor-pointer bg-background/50 hover:bg-background border border-border/50 rounded-lg p-3 transition-all hover:shadow-sm">
|
||
<input
|
||
type="checkbox"
|
||
checked={contextSettings().notes}
|
||
onChange={(e) => setContextSettings(prev => ({ ...prev, notes: (e.target as HTMLInputElement).checked }))}
|
||
class="w-4 h-4 text-primary border-border rounded focus:ring-primary focus:ring-2"
|
||
/>
|
||
<div class="flex items-center gap-2 flex-1">
|
||
<span class="font-medium">Notes</span>
|
||
</div>
|
||
</label>
|
||
</div>
|
||
<div class="mt-3 p-3 bg-primary/5 border border-primary/20 rounded-lg">
|
||
<p class="text-xs text-muted-foreground">
|
||
<strong>Tip:</strong> Enable context sources to give the AI access to your data for more personalized responses.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Expanded AI Settings */}
|
||
<Show when={settingsExpanded()}>
|
||
<div class="space-y-6">
|
||
{/* Model Selection */}
|
||
<div>
|
||
<h4 class="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-3">Available Models</h4>
|
||
<div class="space-y-2 max-h-48 overflow-y-auto">
|
||
<For each={getAIModels()}>
|
||
{(model) => (
|
||
<div
|
||
class={`p-3 rounded-lg border cursor-pointer transition-all ${
|
||
selectedModel() === model.id
|
||
? 'bg-primary/10 border-primary/30'
|
||
: 'bg-background/50 hover:bg-background border-border/50'
|
||
}`}
|
||
onClick={() => setSelectedModel(model.id)}
|
||
>
|
||
<div class="flex items-center justify-between">
|
||
<div class="flex-1">
|
||
<div class="font-medium text-sm">{model.name}</div>
|
||
<div class="text-xs text-muted-foreground mt-1">{model.description}</div>
|
||
<div class="flex items-center gap-2 mt-2">
|
||
<span class="text-xs px-2 py-1 bg-primary/10 text-primary rounded-full">
|
||
{model.provider}
|
||
</span>
|
||
<span class="text-xs px-2 py-1 bg-muted text-muted-foreground rounded-full">
|
||
{model.category}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
{selectedModel() === model.id && (
|
||
<div class="ml-2">
|
||
<div class="w-2 h-2 bg-primary rounded-full"></div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</For>
|
||
</div>
|
||
</div>
|
||
|
||
{/* AI Parameters */}
|
||
<div>
|
||
<h4 class="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-3">AI Parameters</h4>
|
||
<div class="space-y-4">
|
||
<div>
|
||
<label class="block text-sm font-medium mb-2">Temperature: {aiSettings().temperature}</label>
|
||
<input
|
||
type="range"
|
||
min="0"
|
||
max="2"
|
||
step="0.1"
|
||
value={aiSettings().temperature}
|
||
onInput={(e) => setAiSettings(prev => ({ ...prev, temperature: parseFloat((e.target as HTMLInputElement).value) }))}
|
||
class="w-full h-2 bg-muted rounded-lg appearance-none cursor-pointer"
|
||
/>
|
||
<div class="flex justify-between text-xs text-muted-foreground mt-1">
|
||
<span>Precise</span>
|
||
<span>Creative</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div>
|
||
<label class="block text-sm font-medium mb-2">Max Tokens: {aiSettings().maxTokens}</label>
|
||
<input
|
||
type="range"
|
||
min="256"
|
||
max="4096"
|
||
step="256"
|
||
value={aiSettings().maxTokens}
|
||
onInput={(e) => setAiSettings(prev => ({ ...prev, maxTokens: parseInt((e.target as HTMLInputElement).value) }))}
|
||
class="w-full h-2 bg-muted rounded-lg appearance-none cursor-pointer"
|
||
/>
|
||
<div class="flex justify-between text-xs text-muted-foreground mt-1">
|
||
<span>256</span>
|
||
<span>4096</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* System Prompt */}
|
||
<div>
|
||
<h4 class="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-3">System Prompt</h4>
|
||
<textarea
|
||
value={aiSettings().systemPrompt}
|
||
onInput={(e) => setAiSettings(prev => ({ ...prev, systemPrompt: (e.target as HTMLTextAreaElement).value }))}
|
||
class="w-full h-20 p-3 text-sm bg-background/50 border border-border/50 rounded-lg resize-none focus:ring-2 focus:ring-primary focus:border-transparent"
|
||
placeholder="Enter system prompt..."
|
||
/>
|
||
</div>
|
||
</div>
|
||
</Show>
|
||
</div>
|
||
</Show>
|
||
|
||
<div class="p-6">
|
||
<Button
|
||
onClick={() => {
|
||
setCurrentSessionId(null)
|
||
setMessages([])
|
||
}}
|
||
class="w-full mb-6 h-11"
|
||
>
|
||
New Chat
|
||
</Button>
|
||
|
||
<div class="space-y-3 max-h-[calc(100vh-280px)] overflow-y-auto pr-2">
|
||
<For each={sessions()}>
|
||
{session => (
|
||
<div
|
||
class={`p-4 rounded-xl cursor-pointer transition-all duration-200 border ${
|
||
currentSessionId() === session.id.toString()
|
||
? 'bg-primary/10 border-primary/20 shadow-sm'
|
||
: 'hover:bg-muted border-transparent hover:shadow-sm'
|
||
}`}
|
||
onClick={() => {
|
||
const sessionId = session.id.toString()
|
||
setCurrentSessionId(sessionId)
|
||
void loadSessionMessages(sessionId)
|
||
}}
|
||
>
|
||
<div class="flex items-center justify-between">
|
||
<div class="flex-1 min-w-0 pr-2">
|
||
<h4 class="font-medium truncate mb-1">{session.title}</h4>
|
||
<p class="text-sm text-muted-foreground">
|
||
{session.message_count} message{session.message_count !== 1 ? 's' : ''}
|
||
</p>
|
||
</div>
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
onClick={async (e: MouseEvent) => {
|
||
e.stopPropagation()
|
||
try {
|
||
const token = getToken()
|
||
const response = await fetch(`${getApiOrigin()}/api/v1/chat/sessions/${session.id}`, {
|
||
method: 'DELETE',
|
||
headers: {
|
||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||
},
|
||
})
|
||
|
||
if (!response.ok) {
|
||
throw new Error('Failed to delete session')
|
||
}
|
||
|
||
if (currentSessionId() === String(session.id)) {
|
||
setCurrentSessionId(null)
|
||
setMessages([])
|
||
}
|
||
await refetchSessions()
|
||
} catch (error) {
|
||
console.error('Delete session failed:', error)
|
||
}
|
||
}}
|
||
class="opacity-0 group-hover:opacity-100 transition-opacity"
|
||
>
|
||
<Trash class="h-4 w-4" />
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</For>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Chat Area */}
|
||
<div class="flex-1 flex flex-col min-w-0 bg-background rounded-lg border shadow-sm overflow-hidden">
|
||
<div class="hidden md:flex items-center justify-between p-6 border-b bg-card/95 backdrop-blur-sm">
|
||
<div class="flex items-center gap-6">
|
||
<div class="flex items-center gap-3">
|
||
<div>
|
||
<h2 class="font-semibold text-lg">AI Assistant</h2>
|
||
<p class="text-sm text-muted-foreground">Your intelligent workspace companion</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="flex items-center gap-1 p-1 bg-muted rounded-lg">
|
||
<button
|
||
onClick={() => setActiveView('chat')}
|
||
class={`px-3 py-2 rounded-md text-sm font-medium transition-colors ${
|
||
activeView() === 'chat'
|
||
? 'bg-background shadow-sm'
|
||
: 'text-muted-foreground hover:text-foreground'
|
||
}`}
|
||
>
|
||
Chat
|
||
</button>
|
||
<button
|
||
onClick={() => setActiveView('ai-tools')}
|
||
class={`px-3 py-2 rounded-md text-sm font-medium transition-colors ${
|
||
activeView() === 'ai-tools'
|
||
? 'bg-background shadow-sm'
|
||
: 'text-muted-foreground hover:text-foreground'
|
||
}`}
|
||
>
|
||
AI Tools
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* AI Model Picker */}
|
||
<div class="relative">
|
||
<button
|
||
onClick={() => setShowModelPicker(!showModelPicker())}
|
||
class="flex items-center gap-2 px-3 py-2 bg-muted hover:bg-muted/80 rounded-lg text-sm transition-colors"
|
||
>
|
||
<span>{getAIModels().find(m => m.id === selectedModel())?.name || 'Select Model'}</span>
|
||
<ChevronDown class="h-4 w-4" />
|
||
</button>
|
||
|
||
<Show when={showModelPicker()}>
|
||
<div class="absolute right-0 mt-2 w-72 bg-background border rounded-lg shadow-lg z-50 p-2">
|
||
<For each={getAIModels()}>
|
||
{model => (
|
||
<button
|
||
onClick={() => {
|
||
setSelectedModel(model.id)
|
||
setShowModelPicker(false)
|
||
}}
|
||
class={`w-full text-left p-3 rounded-lg transition-colors ${
|
||
selectedModel() === model.id
|
||
? 'bg-primary/10 border border-primary/20'
|
||
: 'hover:bg-muted'
|
||
}`}
|
||
>
|
||
<div class="font-medium text-sm">{model.name}</div>
|
||
<div class="text-xs text-muted-foreground mt-1">{model.description}</div>
|
||
</button>
|
||
)}
|
||
</For>
|
||
</div>
|
||
</Show>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Main Content Area */}
|
||
<div class="flex-1 flex flex-col min-h-0">
|
||
<Show when={activeView() === 'chat'}>
|
||
<div class="flex-1 overflow-y-auto min-h-0">
|
||
<div class="space-y-6 max-w-5xl mx-auto p-6">
|
||
<For each={messages()}>
|
||
{message => (
|
||
<div
|
||
class={`flex gap-4 ${
|
||
message.role === 'user' ? 'justify-end' : 'justify-start'
|
||
}`}
|
||
>
|
||
<div
|
||
id={`chat-message-${message.id}`}
|
||
class={`max-w-[80%] rounded-2xl p-4 shadow-sm transition-all duration-200 hover:shadow-md ${
|
||
message.role === 'user'
|
||
? 'bg-gradient-to-br from-primary to-primary/90 text-primary-foreground ml-auto'
|
||
: 'bg-gradient-to-br from-muted to-muted/90 border border-border/50'
|
||
} ${highlightedMessageId() === message.id ? 'ring-2 ring-primary/50 ring-offset-2 ring-offset-background' : ''}`}
|
||
>
|
||
<div class="flex items-start gap-3">
|
||
<div class={`flex-shrink-0 w-8 h-8 rounded-full flex items-center justify-center transition-transform hover:scale-105 ${
|
||
message.role === 'user' ? 'bg-primary-foreground/20 ring-2 ring-primary-foreground/30' : 'bg-primary/10 ring-2 ring-primary/20'
|
||
}`}>
|
||
{message.role === 'user' ? (
|
||
<User class="w-4 h-4 text-xs" />
|
||
) : (
|
||
<Sparkles class="w-4 h-4 text-primary animate-pulse" />
|
||
)}
|
||
</div>
|
||
<div class="flex-1">
|
||
<p class="text-sm leading-relaxed whitespace-pre-wrap break-words">{message.content}</p>
|
||
<div class="flex items-center gap-2 mt-2">
|
||
<p class="text-xs opacity-70">
|
||
{new Date(message.created_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
||
</p>
|
||
{message.role === 'user' && (
|
||
<div class="w-2 h-2 bg-primary-foreground/50 rounded-full"></div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</For>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="border-t bg-card/95 backdrop-blur-sm">
|
||
<div class="p-6">
|
||
<div class="flex gap-4">
|
||
{/* AI Model Switcher */}
|
||
<div class="relative">
|
||
<button
|
||
onClick={() => setShowModelPicker(!showModelPicker())}
|
||
class="flex items-center gap-2 px-4 py-2.5 bg-gradient-to-r from-muted to-muted/80 hover:from-muted/90 hover:to-muted/70 rounded-xl text-sm transition-all duration-200 border border-border/50 shadow-sm hover:shadow-md"
|
||
>
|
||
<Sparkles class="w-4 h-4 transition-transform hover:scale-110" />
|
||
<span class="text-sm font-medium">
|
||
{getAIModels().find(m => m.id === selectedModel())?.name || 'Select Model'}
|
||
</span>
|
||
<ChevronDown class={`h-4 w-4 transition-transform duration-200 ${showModelPicker() ? 'rotate-180' : ''}`} />
|
||
</button>
|
||
|
||
<Show when={showModelPicker()}>
|
||
<div class="absolute bottom-full left-0 mb-2 w-80 bg-gradient-to-b from-background to-background/95 backdrop-blur-sm border border-border/50 rounded-xl shadow-xl z-50 p-2 max-h-96 overflow-y-auto">
|
||
<div class="p-3 border-b border-border/50 mb-2 bg-muted/30 rounded-lg">
|
||
<h4 class="text-sm font-semibold text-foreground">Select AI Model</h4>
|
||
<p class="text-xs text-muted-foreground">Choose the best model for your needs</p>
|
||
</div>
|
||
<For each={getAIModels()}>
|
||
{model => (
|
||
<button
|
||
onClick={() => {
|
||
setSelectedModel(model.id)
|
||
setShowModelPicker(false)
|
||
}}
|
||
class={`w-full text-left p-3 rounded-lg transition-colors ${
|
||
selectedModel() === model.id
|
||
? 'bg-primary/10 border border-primary/20'
|
||
: 'hover:bg-muted'
|
||
}`}
|
||
>
|
||
<div class="flex items-center justify-between">
|
||
<div class="flex items-center gap-3 flex-1">
|
||
<Sparkles class="w-4 h-4" />
|
||
<div class="flex-1">
|
||
<div class="font-medium text-sm">{model.name}</div>
|
||
<div class="text-xs text-muted-foreground mt-1">{model.description}</div>
|
||
<div class="flex items-center gap-2 mt-2">
|
||
<span class="text-xs px-2 py-1 bg-gradient-to-r from-primary/10 to-primary/5 text-primary rounded-full border border-primary/20">
|
||
{model.provider}
|
||
</span>
|
||
<span class={`text-xs px-2 py-1 rounded-full border ${
|
||
model.category === 'available'
|
||
? 'bg-gradient-to-r from-green-50 to-green-100 text-green-700 border-green-200'
|
||
: 'bg-gradient-to-r from-muted/50 to-muted text-muted-foreground border-border/50'
|
||
}`}>
|
||
{model.category === 'available' ? 'Available' : 'Disabled'}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
{selectedModel() === model.id && (
|
||
<div class="ml-2">
|
||
<div class="w-2 h-2 bg-primary rounded-full animate-pulse"></div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</button>
|
||
)}
|
||
</For>
|
||
</div>
|
||
</Show>
|
||
</div>
|
||
|
||
<div class="relative flex-1">
|
||
<div class="relative">
|
||
<Input
|
||
value={inputMessage()}
|
||
onInput={(e) => setInputMessage((e.currentTarget as HTMLInputElement).value)}
|
||
placeholder="Type your message..."
|
||
class="flex-1 pr-12 rounded-xl border-border/50 bg-background/95 backdrop-blur-sm shadow-sm transition-all duration-200 focus:shadow-md focus:border-primary/50"
|
||
onKeyDown={(e: KeyboardEvent) => {
|
||
if (e.key === 'Enter' && !e.shiftKey && inputMessage().trim()) {
|
||
handleSendMessage()
|
||
}
|
||
}}
|
||
/>
|
||
<div class="absolute right-2 top-1/2 -translate-y-1/2 flex items-center gap-1">
|
||
<div class="w-1 h-1 bg-muted-foreground/40 rounded-full animate-pulse"></div>
|
||
<div class="w-1 h-1 bg-muted-foreground/40 rounded-full animate-pulse" style="animation-delay: 0.2s"></div>
|
||
<div class="w-1 h-1 bg-muted-foreground/40 rounded-full animate-pulse" style="animation-delay: 0.4s"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<Button
|
||
disabled={isLoading() || !inputMessage().trim()}
|
||
onClick={handleSendMessage}
|
||
class="rounded-xl px-4 py-2.5 bg-gradient-to-r from-primary to-primary/90 hover:from-primary/90 hover:to-primary shadow-sm hover:shadow-md transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed"
|
||
>
|
||
<Send class="h-4 w-4" />
|
||
<span class="ml-2 text-sm font-medium">Send</span>
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</Show>
|
||
|
||
<Show when={activeView() === 'ai-tools'}>
|
||
<div class="flex-1 overflow-y-auto p-6 h-[calc(100vh-320px)]">
|
||
<div class="max-w-5xl mx-auto">
|
||
<div class="mb-8">
|
||
<h3 class="text-lg font-semibold mb-2">AI Tools</h3>
|
||
<p class="text-muted-foreground">Enhance your productivity with AI-powered tools</p>
|
||
</div>
|
||
|
||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
|
||
<For each={aiTools}>
|
||
{tool => (
|
||
<Card
|
||
class={`p-6 cursor-pointer transition-all hover:shadow-lg ${
|
||
aiTool() === tool.id ? 'ring-2 ring-primary bg-primary/5' : ''
|
||
}`}
|
||
onClick={() => setAiTool(tool.id as any)}
|
||
>
|
||
<h4 class="font-semibold mb-2">{tool.label}</h4>
|
||
<p class="text-sm text-muted-foreground">{tool.description}</p>
|
||
</Card>
|
||
)}
|
||
</For>
|
||
</div>
|
||
|
||
<Show when={aiTool() === 'summarizer'}>
|
||
<Card class="p-6">
|
||
<h4 class="text-lg font-semibold mb-4">Content Summarizer</h4>
|
||
<p class="text-muted-foreground mb-4">
|
||
Get AI-powered summaries of your notes, documents, and bookmarks.
|
||
</p>
|
||
<div class="space-y-4">
|
||
<div class="space-y-2">
|
||
<label class="block text-sm font-medium">Content to summarize:</label>
|
||
<textarea
|
||
placeholder="Paste your content or describe what you want summarized..."
|
||
value={inputMessage()}
|
||
onInput={(e) => setInputMessage((e.currentTarget as HTMLTextAreaElement).value)}
|
||
class="w-full min-h-[100px] p-3 border border-input rounded-md bg-background text-foreground resize-none focus:outline-none focus:ring-2 focus:ring-ring"
|
||
/>
|
||
</div>
|
||
<div class="flex gap-2">
|
||
<Button
|
||
onClick={() => setActiveView('chat')}
|
||
disabled={!inputMessage().trim()}
|
||
>
|
||
Summarize In Chat
|
||
</Button>
|
||
<Button variant="outline" onClick={() => setInputMessage('')}>
|
||
Clear
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</Card>
|
||
</Show>
|
||
|
||
<Show when={aiTool() === 'tasks'}>
|
||
<Card class="p-6">
|
||
<h4 class="text-lg font-semibold mb-4">Task Suggestions</h4>
|
||
<p class="text-muted-foreground mb-4">
|
||
AI task suggestions are generated from your real workspace context.
|
||
</p>
|
||
<div class="space-y-4">
|
||
<div class="p-4 bg-muted/30 rounded-lg">
|
||
<h5 class="font-medium mb-2">No suggestions yet</h5>
|
||
<p class="text-sm text-muted-foreground">
|
||
Start a chat and ask for task suggestions to generate items from your current data.
|
||
</p>
|
||
</div>
|
||
<div class="flex gap-2">
|
||
<Button onClick={() => setActiveView('chat')}>
|
||
Open Chat
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</Card>
|
||
</Show>
|
||
|
||
<Show when={aiTool() === 'content'}>
|
||
<Card class="p-6">
|
||
<h4 class="text-lg font-semibold mb-4">Content Generation</h4>
|
||
<p class="text-muted-foreground mb-4">
|
||
Generate content using AI assistance for your projects and documentation.
|
||
</p>
|
||
<div class="space-y-4">
|
||
<div class="space-y-2">
|
||
<label class="block text-sm font-medium">What to generate:</label>
|
||
<textarea
|
||
placeholder="Describe the content you want to generate..."
|
||
value={inputMessage()}
|
||
onInput={(e) => setInputMessage((e.currentTarget as HTMLTextAreaElement).value)}
|
||
class="w-full min-h-[100px] p-3 border border-input rounded-md bg-background text-foreground resize-none focus:outline-none focus:ring-2 focus:ring-ring"
|
||
/>
|
||
</div>
|
||
<div class="space-y-2">
|
||
<label class="block text-sm font-medium">Content Type:</label>
|
||
<div class="grid grid-cols-2 gap-2">
|
||
<Button
|
||
variant={inputMessage().toLowerCase().includes('blog') ? 'default' : 'outline'}
|
||
onClick={() => setInputMessage('Generate a blog post about: ')}
|
||
>
|
||
Blog Post
|
||
</Button>
|
||
<Button
|
||
variant={inputMessage().toLowerCase().includes('email') ? 'default' : 'outline'}
|
||
onClick={() => setInputMessage('Generate an email about: ')}
|
||
>
|
||
Email
|
||
</Button>
|
||
<Button
|
||
variant={inputMessage().toLowerCase().includes('documentation') ? 'default' : 'outline'}
|
||
onClick={() => setInputMessage('Generate documentation for: ')}
|
||
>
|
||
Documentation
|
||
</Button>
|
||
<Button
|
||
variant={inputMessage().toLowerCase().includes('social') ? 'default' : 'outline'}
|
||
onClick={() => setInputMessage('Generate social media content about: ')}
|
||
>
|
||
Social Media
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
<div class="flex gap-2">
|
||
<Button
|
||
onClick={() => setActiveView('chat')}
|
||
disabled={!inputMessage().trim()}
|
||
>
|
||
Generate In Chat
|
||
</Button>
|
||
<Button variant="outline" onClick={() => setInputMessage('')}>
|
||
Clear
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</Card>
|
||
</Show>
|
||
</div>
|
||
</div>
|
||
</Show>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
export default Chat
|