diff --git a/pkg/chat/messages.go b/pkg/chat/messages.go index 5b744d49..64850401 100644 --- a/pkg/chat/messages.go +++ b/pkg/chat/messages.go @@ -2,6 +2,26 @@ package chat import "time" +type QueryHistory struct { + ConversationID string `json:"conversation_id,omitempty"` + ID string `json:"id,omitempty"` +} + +type QueryModels struct{} + +type QuerySettings struct{} + +type QueryConversationList struct{} + +type QueryConversationGet struct { + ConversationID string `json:"conversation_id,omitempty"` + ID string `json:"id,omitempty"` +} + +type QueryConversationSearch struct { + Query string `json:"q"` +} + type ChatMessage struct { ID string `json:"id"` Role string `json:"role"` diff --git a/pkg/chat/service.go b/pkg/chat/service.go index 597501e9..071df30c 100644 --- a/pkg/chat/service.go +++ b/pkg/chat/service.go @@ -68,7 +68,9 @@ type renameInput struct { } type selectModelInput struct { - Model string `json:"model"` + Model string `json:"model"` + ConversationID string `json:"conversation_id,omitempty"` + ID string `json:"id,omitempty"` } type attachImageInput struct { @@ -169,6 +171,7 @@ func (s *Service) OnStartup(_ context.Context) core.Result { s.toolExecutor = subsystem } s.toolHandler = NewToolCallHandler(s.toolExecutor) + s.Core().RegisterQuery(s.handleQuery) s.registerActions() return core.Result{OK: true} } @@ -177,6 +180,29 @@ func (s *Service) HandleIPCEvents(_ *core.Core, _ core.Message) core.Result { return core.Result{OK: true} } +func (s *Service) handleQuery(_ *core.Core, q core.Query) core.Result { + switch typed := q.(type) { + case QueryHistory: + conv, err := s.getConversation(typed.ID, typed.ConversationID) + return core.Result{}.New(conv, err) + case QueryModels: + return core.Result{Value: s.discoverModels(), OK: true} + case QuerySettings: + return core.Result{Value: s.loadSettings(), OK: true} + case QueryConversationList: + conversations, err := s.listConversationSummaries() + return core.Result{}.New(conversations, err) + case QueryConversationGet: + conv, err := s.getConversation(typed.ID, typed.ConversationID) + return core.Result{}.New(conv, err) + case QueryConversationSearch: + results, err := s.searchConversationSummaries(typed.Query) + return core.Result{}.New(results, err) + default: + return core.Result{} + } +} + func (s *Service) registerActions() { c := s.Core() c.Action("gui.chat.send", func(ctx context.Context, opts core.Options) core.Result { @@ -211,9 +237,7 @@ func (s *Service) registerActions() { if err != nil { return core.Result{Value: err, OK: false} } - settings := s.loadSettings() - settings.DefaultModel = input.Model - err = s.saveSettings(settings) + settings, err := s.selectModel(input) return core.Result{}.New(settings, err) }) c.Action("gui.chat.settings.save", func(_ context.Context, opts core.Options) core.Result { @@ -349,6 +373,31 @@ func (s *Service) loadSettings() ChatSettings { return settings } +func (s *Service) selectModel(input selectModelInput) (ChatSettings, error) { + settings := s.loadSettings() + settings.DefaultModel = input.Model + if err := s.saveSettings(settings); err != nil { + return ChatSettings{}, err + } + + targetConversation := coalesce(input.ConversationID, input.ID) + if targetConversation == "" { + return settings, nil + } + + conv, err := s.loadConversation(targetConversation) + if err != nil { + return ChatSettings{}, err + } + conv.Model = input.Model + conv, err = s.saveConversation(conv) + if err != nil { + return ChatSettings{}, err + } + s.emit(ActionConversationUpdated{Conversation: conv}) + return settings, nil +} + func (s *Service) saveConversation(conv Conversation) (Conversation, error) { conv.UpdatedAt = s.now() payload := core.JSONMarshalString(conv) diff --git a/pkg/chat/service_test.go b/pkg/chat/service_test.go index bcd0c6a7..8f8e9c97 100644 --- a/pkg/chat/service_test.go +++ b/pkg/chat/service_test.go @@ -81,6 +81,10 @@ func TestService_Good_SendAndHistory(t *testing.T) { )) require.True(t, history.OK) assert.Equal(t, conv.ID, history.Value.(Conversation).ID) + + queryHistory := c.QUERY(QueryHistory{ConversationID: conv.ID}) + require.True(t, queryHistory.OK) + assert.Equal(t, conv.ID, queryHistory.Value.(Conversation).ID) } func TestService_Good_ToolCallRoundTrip(t *testing.T) { @@ -113,3 +117,26 @@ func TestService_Good_ToolCallRoundTrip(t *testing.T) { assert.Equal(t, "tool", conv.Messages[2].Role) assert.True(t, strings.Contains(conv.Messages[len(conv.Messages)-1].Content, "left-right")) } + +func TestService_Good_SelectModelUpdatesConversation(t *testing.T) { + c := newChatCore(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + _, _ = io.WriteString(w, "data: {\"id\":\"chatcmpl-1\",\"choices\":[{\"delta\":{\"content\":\"Hello\"}}]}\n\n") + _, _ = io.WriteString(w, "data: {\"id\":\"chatcmpl-1\",\"choices\":[{\"finish_reason\":\"stop\"}]}\n\n") + _, _ = io.WriteString(w, "data: [DONE]\n\n") + }, &mockToolExecutor{}) + + created := c.Action("gui.chat.conversations.new").Run(context.Background(), core.NewOptions()) + require.True(t, created.OK) + conv := created.Value.(Conversation) + + selected := c.Action("gui.chat.selectModel").Run(context.Background(), core.NewOptions( + core.Option{Key: "model", Value: "lemma"}, + core.Option{Key: "conversation_id", Value: conv.ID}, + )) + require.True(t, selected.OK) + + updated := c.QUERY(QueryConversationGet{ConversationID: conv.ID}) + require.True(t, updated.OK) + assert.Equal(t, "lemma", updated.Value.(Conversation).Model) +} diff --git a/pkg/display/scheme.go b/pkg/display/scheme.go index 281619f0..d5bdd1d4 100644 --- a/pkg/display/scheme.go +++ b/pkg/display/scheme.go @@ -163,13 +163,11 @@ func (s *Service) resolveModelsRoute(subpath string, query url.Values) core.Resu } } -func (s *Service) resolveChatRoute(ctx context.Context, subpath string, query url.Values) core.Result { +func (s *Service) resolveChatRoute(_ context.Context, subpath string, query url.Values) core.Result { if id := coalesce(query.Get("conversation_id"), query.Get("id"), subpath); id != "" { - return s.Core().Action("gui.chat.history").Run(ctx, core.NewOptions( - core.Option{Key: "conversation_id", Value: id}, - )) + return s.Core().QUERY(chat.QueryHistory{ConversationID: id}) } - return s.Core().Action("gui.chat.conversations.list").Run(ctx, core.NewOptions()) + return s.Core().QUERY(chat.QueryConversationList{}) } func (s *Service) resolveUnavailableCoreRoute(route, subpath string, query url.Values) core.Result { @@ -400,7 +398,7 @@ func (s *Service) renderStoreSearchPage(query string, results []StorageEntry) st func (s *Service) searchAllStorage(query string) []StorageEntry { results := s.storage.Search(query) - if conversations := s.Core().Action("gui.chat.conversations.search").Run(context.Background(), core.NewOptions(core.Option{Key: "q", Value: query})); conversations.OK { + if conversations := s.Core().QUERY(chat.QueryConversationSearch{Query: query}); conversations.OK { switch list := conversations.Value.(type) { case []any: for _, item := range list { diff --git a/ui/src/chat/chat-panel.component.ts b/ui/src/chat/chat-panel.component.ts index 0479fcc3..da743aab 100644 --- a/ui/src/chat/chat-panel.component.ts +++ b/ui/src/chat/chat-panel.component.ts @@ -22,12 +22,15 @@ import { ChatStateService } from './chat-state.service'; template: `
@@ -40,7 +43,7 @@ import { ChatStateService } from './chat-state.service';
@@ -51,6 +54,7 @@ import { ChatStateService } from './chat-state.service'; [models]="state.models()" [settings]="state.settings()" (saved)="state.saveSettings($event)" + (reset)="state.resetSettings()" (closed)="state.settingsOpen.set(false)" /> @@ -61,7 +65,10 @@ import { ChatStateService } from './chat-state.service'; diff --git a/ui/src/chat/chat-state.service.ts b/ui/src/chat/chat-state.service.ts index ffd7a9e0..889bb5b4 100644 --- a/ui/src/chat/chat-state.service.ts +++ b/ui/src/chat/chat-state.service.ts @@ -1,6 +1,16 @@ -import { Injectable, computed, effect, inject, signal } from '@angular/core'; +import { Injectable, effect, inject, signal } from '@angular/core'; import { WebSocketService } from '../services/websocket.service'; -import { ChatMessage, ChatSettings, Conversation, ConversationSummary, ModelEntry } from './chat.types'; +import { + ChatMessage, + ChatSettings, + Conversation, + ConversationSummary, + ImageAttachment, + ModelEntry, + ThinkingState, + ToolCall, + ToolResult, +} from './chat.types'; declare global { interface Window { @@ -15,6 +25,7 @@ export class ChatStateService { readonly conversations = signal([]); readonly activeConversation = signal(null); readonly models = signal([]); + readonly queuedAttachments = signal([]); readonly settings = signal({ temperature: 1, top_p: 0.95, @@ -29,19 +40,14 @@ export class ChatStateService { readonly settingsOpen = signal(false); readonly sending = signal(false); readonly selectedModel = signal(''); - readonly filteredConversations = computed(() => { - const needle = this.historyQuery().trim().toLowerCase(); - if (!needle) { - return this.conversations(); - } - return this.conversations().filter((item) => item.title.toLowerCase().includes(needle)); - }); constructor() { effect(() => { const current = this.activeConversation(); - if (current) { - this.selectedModel.set(current.model || this.settings().default_model); + if (current?.model) { + this.selectedModel.set(current.model); + } else if (this.settings().default_model) { + this.selectedModel.set(this.settings().default_model); } }); } @@ -56,6 +62,7 @@ export class ChatStateService { const conversation = await this.invoke('gui.chat.conversations.get', { id }); if (conversation) { this.activeConversation.set(conversation); + this.queuedAttachments.set([]); } } @@ -63,15 +70,21 @@ export class ChatStateService { const conversation = await this.invoke('gui.chat.conversations.new'); if (conversation) { this.activeConversation.set(conversation); - this.conversations.update((items) => [conversation, ...items.filter((item) => item.id !== conversation.id)]); + this.upsertSummary(this.toSummary(conversation)); + this.queuedAttachments.set([]); } } async deleteConversation(id: string): Promise { await this.invoke('gui.chat.conversations.delete', { id }); - this.conversations.update((items) => items.filter((item) => item.id !== id)); + this.removeSummary(id); if (this.activeConversation()?.id === id) { this.activeConversation.set(null); + this.queuedAttachments.set([]); + const next = this.conversations()[0]; + if (next) { + await this.refreshConversation(next.id); + } } } @@ -82,6 +95,29 @@ export class ChatStateService { } } + async exportConversation(id: string): Promise { + const markdown = await this.invoke('gui.chat.conversations.export', { id }); + if (!markdown) { + return; + } + const blob = new Blob([markdown], { type: 'text/markdown;charset=utf-8' }); + const url = URL.createObjectURL(blob); + const anchor = document.createElement('a'); + anchor.href = url; + anchor.download = `${id}.md`; + anchor.click(); + URL.revokeObjectURL(url); + } + + async setHistoryQuery(query: string): Promise { + this.historyQuery.set(query); + const trimmed = query.trim(); + const route = trimmed ? 'gui.chat.conversations.search' : 'gui.chat.conversations.list'; + const payload = trimmed ? { q: trimmed } : undefined; + const conversations = await this.invoke(route, payload); + this.conversations.set(conversations ?? []); + } + async saveSettings(settings: ChatSettings): Promise { const saved = await this.invoke('gui.chat.settings.save', settings); if (saved) { @@ -92,9 +128,53 @@ export class ChatStateService { } } + async resetSettings(): Promise { + const reset = await this.invoke('gui.chat.settings.reset'); + if (reset) { + this.settings.set(reset); + if (reset.default_model) { + this.selectedModel.set(reset.default_model); + } + } + } + + async changeModel(model: string): Promise { + if (!model) { + return; + } + const settings = await this.invoke('gui.chat.selectModel', { + model, + conversation_id: this.activeConversation()?.id, + }); + this.selectedModel.set(model); + if (settings) { + this.settings.set(settings); + } + this.activeConversation.update((conversation) => (conversation ? { ...conversation, model } : conversation)); + this.conversations.update((items) => items.map((item) => (item.id === this.activeConversation()?.id ? { ...item, model } : item))); + } + + async queueImageFiles(files: FileList | File[]): Promise { + const items = Array.from(files); + for (const file of items) { + if (!file.type.startsWith('image/')) { + continue; + } + const attachment = await this.fileToAttachment(file); + await this.invoke('gui.chat.attachImage', { + conversation_id: this.activeConversation()?.id, + ...attachment, + }); + } + } + + removeQueuedAttachment(index: number): void { + this.queuedAttachments.update((items) => items.filter((_, itemIndex) => itemIndex !== index)); + } + async sendMessage(): Promise { const content = this.draft().trim(); - if (!content && !(this.activeConversation()?.messages?.length)) { + if (!content && this.queuedAttachments().length === 0) { return; } this.sending.set(true); @@ -107,6 +187,7 @@ export class ChatStateService { this.activeConversation.set(response); this.mergeConversation(response); this.draft.set(''); + this.queuedAttachments.set([]); } } finally { this.sending.set(false); @@ -126,11 +207,6 @@ export class ChatStateService { if (current) { this.selectedModel.set(current.name); } - } else { - this.models.set([ - { name: 'lemer', architecture: 'gemma3', quant_bits: 4, size_bytes: 1_500_000_000, loaded: true, backend: 'metal' }, - ]); - this.selectedModel.set('lemer'); } if (settings) { @@ -143,68 +219,228 @@ export class ChatStateService { if (conversations?.length) { this.conversations.set(conversations); await this.refreshConversation(conversations[0].id); - } else { - await this.startConversation(); + return; } + await this.startConversation(); } private bindEvents(): void { this.ws.on('chat.conversation', (payload) => { - const data = payload as { conversation?: Conversation; conversation_id?: string }; + const data = payload as { action?: string; conversation?: Conversation; conversationId?: string; conversation_id?: string }; + const conversationID = data.conversation_id ?? data.conversationId ?? ''; if (data.conversation) { this.mergeConversation(data.conversation); } - if (data.conversation_id && this.activeConversation()?.id === data.conversation_id) { - this.activeConversation.set(null); - } - }); - this.ws.on('chat.message', (payload) => { - const data = payload as { conversation_id: string; message: ChatMessage }; - if (!data?.message) { - return; - } - if (this.activeConversation()?.id === data.conversation_id) { - this.activeConversation.update((conversation) => - conversation - ? { - ...conversation, - messages: [...conversation.messages, data.message], - updated_at: data.message.created_at, - } - : conversation, - ); - } - }); - this.ws.on('chat.token', (payload) => { - const data = payload as { conversation_id: string; message_id: string; content: string }; - if (this.activeConversation()?.id !== data.conversation_id) { - return; - } - this.activeConversation.update((conversation) => { - if (!conversation) { - return conversation; + if (data.action === 'deleted' && conversationID) { + this.removeSummary(conversationID); + if (this.activeConversation()?.id === conversationID) { + this.activeConversation.set(null); } - const messages = conversation.messages.map((message) => - message.id === data.message_id ? { ...message, content: `${message.content ?? ''}${data.content ?? ''}` } : message, - ); - return { ...conversation, messages }; - }); + } + if (data.action === 'cleared' && conversationID && this.activeConversation()?.id === conversationID) { + this.activeConversation.update((conversation) => (conversation ? { ...conversation, messages: [] } : conversation)); + } + }); + + this.ws.on('chat.message', (payload) => { + const data = payload as { conversationId?: string; conversation_id?: string; message?: ChatMessage; messageId?: string; message_id?: string; state?: string; finishReason?: string }; + const conversationID = data.conversation_id ?? data.conversationId ?? ''; + if (this.activeConversation()?.id !== conversationID) { + return; + } + if (data.message) { + this.upsertMessage(conversationID, data.message); + } + if (data.state === 'started') { + this.upsertMessage(conversationID, { + id: data.message_id ?? data.messageId ?? '', + role: 'assistant', + content: '', + created_at: new Date().toISOString(), + model: this.selectedModel(), + }); + } + if (data.state === 'finished') { + this.patchMessage(conversationID, data.message_id ?? data.messageId ?? '', (message) => ({ + ...message, + finish_reason: data.finishReason, + })); + } + }); + + this.ws.on('chat.token', (payload) => { + const data = payload as { conversationId?: string; conversation_id?: string; messageId?: string; message_id?: string; content?: string }; + const conversationID = data.conversation_id ?? data.conversationId ?? ''; + const messageID = data.message_id ?? data.messageId ?? ''; + this.patchMessage(conversationID, messageID, (message) => ({ + ...message, + content: `${message.content ?? ''}${data.content ?? ''}`, + })); + }); + + this.ws.on('chat.thinking.start', (payload) => { + const data = payload as { conversationId?: string; conversation_id?: string; messageId?: string; message_id?: string; startedAt?: string }; + this.patchThinking(data, (thinking) => ({ + ...thinking, + active: true, + started_at: data.startedAt, + })); + }); + + this.ws.on('chat.thinking.append', (payload) => { + const data = payload as { conversationId?: string; conversation_id?: string; messageId?: string; message_id?: string; content?: string }; + this.patchThinking(data, (thinking) => ({ + ...thinking, + content: `${thinking.content ?? ''}${data.content ?? ''}`, + })); + }); + + this.ws.on('chat.thinking.end', (payload) => { + const data = payload as { conversationId?: string; conversation_id?: string; messageId?: string; message_id?: string; durationMs?: number }; + this.patchThinking(data, (thinking) => ({ + ...thinking, + active: false, + duration_ms: data.durationMs, + })); + }); + + this.ws.on('chat.tool.call', (payload) => { + const data = payload as { conversationId?: string; conversation_id?: string; messageId?: string; message_id?: string; call?: ToolCall }; + const conversationID = data.conversation_id ?? data.conversationId ?? ''; + const messageID = data.message_id ?? data.messageId ?? ''; + if (!data.call) { + return; + } + this.patchMessage(conversationID, messageID, (message) => ({ + ...message, + tool_calls: [...(message.tool_calls ?? []), data.call!], + })); + }); + + this.ws.on('chat.tool.result', (payload) => { + const data = payload as { conversationId?: string; conversation_id?: string; messageId?: string; message_id?: string; result?: ToolResult }; + const conversationID = data.conversation_id ?? data.conversationId ?? ''; + const messageID = data.message_id ?? data.messageId ?? ''; + if (!data.result) { + return; + } + this.patchMessage(conversationID, messageID, (message) => ({ + ...message, + tool_results: [...(message.tool_results ?? []), data.result!], + })); + }); + + this.ws.on('chat.image.queued', (payload) => { + const data = payload as { conversationId?: string; conversation_id?: string; attachment?: ImageAttachment }; + const conversationID = data.conversation_id ?? data.conversationId ?? ''; + const activeID = this.activeConversation()?.id ?? 'draft'; + if (!data.attachment || conversationID !== activeID && !(conversationID === 'draft' && !this.activeConversation()?.messages.length)) { + return; + } + this.queuedAttachments.update((items) => [...items, data.attachment!]); }); } private mergeConversation(conversation: Conversation): void { - const summary: ConversationSummary = { + this.upsertSummary(this.toSummary(conversation)); + if (this.activeConversation()?.id === conversation.id || !this.activeConversation()) { + this.activeConversation.set(conversation); + } + } + + private upsertSummary(summary: ConversationSummary): void { + this.conversations.update((items) => { + const next = [summary, ...items.filter((item) => item.id !== summary.id)]; + return next.sort((left, right) => Date.parse(right.updated_at) - Date.parse(left.updated_at)); + }); + } + + private removeSummary(id: string): void { + this.conversations.update((items) => items.filter((item) => item.id !== id)); + } + + private upsertMessage(conversationID: string, message: ChatMessage): void { + this.activeConversation.update((conversation) => { + if (!conversation || conversation.id !== conversationID) { + return conversation; + } + const existingIndex = conversation.messages.findIndex((item) => item.id === message.id); + const messages = [...conversation.messages]; + if (existingIndex >= 0) { + messages[existingIndex] = { ...messages[existingIndex], ...message }; + } else { + messages.push(message); + } + return { ...conversation, messages, updated_at: message.created_at || conversation.updated_at }; + }); + } + + private patchMessage(conversationID: string, messageID: string, update: (message: ChatMessage) => ChatMessage): void { + if (!conversationID || !messageID || this.activeConversation()?.id !== conversationID) { + return; + } + this.activeConversation.update((conversation) => { + if (!conversation) { + return conversation; + } + return { + ...conversation, + messages: conversation.messages.map((message) => (message.id === messageID ? update(message) : message)), + }; + }); + } + + private patchThinking( + payload: { conversationId?: string; conversation_id?: string; messageId?: string; message_id?: string }, + update: (thinking: ThinkingState) => ThinkingState, + ): void { + const conversationID = payload.conversation_id ?? payload.conversationId ?? ''; + const messageID = payload.message_id ?? payload.messageId ?? ''; + this.patchMessage(conversationID, messageID, (message) => ({ + ...message, + thinking: update(message.thinking ?? { active: false, content: '' }), + })); + } + + private toSummary(conversation: Conversation): ConversationSummary { + return { id: conversation.id, title: conversation.title, model: conversation.model, created_at: conversation.created_at, updated_at: conversation.updated_at, - message_count: conversation.messages?.length ?? conversation.message_count ?? 0, + message_count: conversation.messages?.length ?? 0, }; - this.conversations.update((items) => [summary, ...items.filter((item) => item.id !== summary.id)]); - if (this.activeConversation()?.id === conversation.id || !this.activeConversation()) { - this.activeConversation.set(conversation); - } + } + + private async fileToAttachment(file: File): Promise { + const data = await this.readFileAsDataURL(file); + const dimensions = await this.readImageDimensions(data); + return { + filename: file.name, + mime_type: file.type || 'image/png', + data: data.split(',', 2)[1] ?? data, + width: dimensions.width, + height: dimensions.height, + }; + } + + private readFileAsDataURL(file: File): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onerror = () => reject(reader.error); + reader.onload = () => resolve(String(reader.result ?? '')); + reader.readAsDataURL(file); + }); + } + + private readImageDimensions(source: string): Promise<{ width: number; height: number }> { + return new Promise((resolve) => { + const image = new Image(); + image.onload = () => resolve({ width: image.width, height: image.height }); + image.onerror = () => resolve({ width: 0, height: 0 }); + image.src = source; + }); } private async invoke(route: string, payload?: unknown): Promise { @@ -221,11 +457,23 @@ export class ChatStateService { { name: 'lemma', architecture: 'qwen3', quant_bits: 8, size_bytes: 3200000000, loaded: false, backend: 'ollama' }, ] as T; } - if (route === 'gui.chat.settings.load' || route === 'gui.chat.settings.save') { + if (route === 'gui.chat.settings.load' || route === 'gui.chat.settings.save' || route === 'gui.chat.settings.reset') { return (payload ?? this.settings()) as T; } - if (route === 'gui.chat.conversations.list') { - return [] as T; + if (route === 'gui.chat.selectModel') { + return { + ...this.settings(), + default_model: (payload as { model?: string })?.model ?? this.settings().default_model, + } as T; + } + if (route === 'gui.chat.conversations.list' || route === 'gui.chat.conversations.search') { + return this.conversations() as T; + } + if (route === 'gui.chat.conversations.export') { + return '# Exported Conversation\n' as T; + } + if (route === 'gui.chat.attachImage') { + return payload as T; } if (route === 'gui.chat.conversations.new') { const now = new Date().toISOString(); @@ -243,23 +491,22 @@ export class ChatStateService { const conversation = this.activeConversation() ?? ((await this.mockInvoke('gui.chat.conversations.new')) as Conversation); const content = (payload as { content?: string })?.content ?? ''; const now = new Date().toISOString(); - const response: Conversation = { + return { ...conversation, updated_at: now, title: conversation.title === 'New Chat' ? content.slice(0, 48) || 'New Chat' : conversation.title, messages: [ ...conversation.messages, - { id: `user-${Date.now()}`, role: 'user', content, created_at: now, model: this.selectedModel() }, + { id: `user-${Date.now()}`, role: 'user', content, created_at: now, model: this.selectedModel(), attachments: this.queuedAttachments() }, { id: `assistant-${Date.now() + 1}`, role: 'assistant', - content: `Local mock response for: ${content}`, + content: `Local mock response for: ${content || 'image input'}`, created_at: now, model: this.selectedModel(), }, ], - }; - return response as T; + } as T; } return {} as T; } diff --git a/ui/src/chat/chat.types.ts b/ui/src/chat/chat.types.ts index 43cba22c..0bf21bc6 100644 --- a/ui/src/chat/chat.types.ts +++ b/ui/src/chat/chat.types.ts @@ -1,3 +1,30 @@ +export interface ThinkingState { + active: boolean; + content: string; + started_at?: string; + ended_at?: string; + duration_ms?: number; +} + +export interface ToolCall { + id: string; + name: string; + arguments: Record; +} + +export interface ToolResult { + tool_call_id: string; + content: string; +} + +export interface ImageAttachment { + filename: string; + mime_type: string; + data: string; + width: number; + height: number; +} + export interface ChatMessage { id: string; role: string; @@ -5,14 +32,10 @@ export interface ChatMessage { created_at: string; model?: string; finish_reason?: string; - thinking?: { - active: boolean; - content: string; - duration_ms?: number; - }; - tool_calls?: Array<{ id: string; name: string; arguments: Record }>; - tool_results?: Array<{ tool_call_id: string; content: string }>; - attachments?: Array<{ filename: string; mime_type: string; data: string; width: number; height: number }>; + thinking?: ThinkingState; + tool_calls?: ToolCall[]; + tool_results?: ToolResult[]; + attachments?: ImageAttachment[]; } export interface ConversationSummary { @@ -26,6 +49,7 @@ export interface ConversationSummary { export interface Conversation extends ConversationSummary { messages: ChatMessage[]; + settings?: ChatSettings | null; } export interface ModelEntry { diff --git a/ui/src/chat/conversation-sidebar.component.ts b/ui/src/chat/conversation-sidebar.component.ts index b964bc81..7cb9afca 100644 --- a/ui/src/chat/conversation-sidebar.component.ts +++ b/ui/src/chat/conversation-sidebar.component.ts @@ -1,29 +1,39 @@ -import { CommonModule } from '@angular/common'; +import { CommonModule, DatePipe } from '@angular/common'; import { Component, EventEmitter, Input, Output } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { ConversationSummary } from './chat.types'; +type ConversationGroup = { label: string; items: ConversationSummary[] }; + @Component({ selector: 'chat-conversation-sidebar', standalone: true, - imports: [CommonModule, FormsModule], + imports: [CommonModule, FormsModule, DatePipe], template: ` `, @@ -31,13 +41,18 @@ import { ConversationSummary } from './chat.types'; ` .sidebar { display: grid; gap: 1rem; padding: 1rem; background: rgba(9, 20, 34, 0.72); border-right: 1px solid rgba(124, 156, 191, 0.16); } .sidebar__head { display: grid; gap: 0.75rem; } - .sidebar__list { display: grid; gap: 0.5rem; align-content: start; overflow: auto; } - .ghost, input { width: 100%; border-radius: 0.9rem; border: 1px solid rgba(124, 156, 191, 0.2); background: rgba(11, 27, 44, 0.7); color: #eaf4ff; padding: 0.8rem 0.9rem; } + .sidebar__list { display: grid; gap: 1rem; align-content: start; overflow: auto; } + .group { display: grid; gap: 0.5rem; } + .group__label { margin: 0; color: #94a3b8; text-transform: uppercase; letter-spacing: 0.14em; font-size: 0.7rem; } + .ghost, input, .conversation__actions button { border-radius: 0.9rem; border: 1px solid rgba(124, 156, 191, 0.2); background: rgba(11, 27, 44, 0.7); color: #eaf4ff; padding: 0.8rem 0.9rem; } .ghost { cursor: pointer; text-align: left; font-weight: 700; } - .conversation { display: grid; gap: 0.2rem; padding: 0.9rem; border: 0; border-radius: 1rem; background: rgba(8, 21, 35, 0.55); color: #dbeafe; text-align: left; cursor: pointer; } + .conversation { display: grid; gap: 0.35rem; padding: 0.9rem; border: 0; border-radius: 1rem; background: rgba(8, 21, 35, 0.55); color: #dbeafe; text-align: left; cursor: pointer; } .conversation--active { background: linear-gradient(135deg, rgba(14, 116, 144, 0.55), rgba(8, 47, 73, 0.82)); box-shadow: inset 0 0 0 1px rgba(125, 211, 252, 0.28); } .conversation__title { font-weight: 700; } .conversation__meta { color: #94a3b8; font-size: 0.8rem; } + .conversation__actions { display: flex; gap: 0.45rem; flex-wrap: wrap; } + .conversation__actions button { padding: 0.35rem 0.6rem; font-size: 0.72rem; } + .danger { color: #fecaca; border-color: rgba(248, 113, 113, 0.28); } `, ], }) @@ -48,4 +63,45 @@ export class ConversationSidebarComponent { @Output() queryChange = new EventEmitter(); @Output() select = new EventEmitter(); @Output() create = new EventEmitter(); + @Output() rename = new EventEmitter<{ id: string; title: string }>(); + @Output() delete = new EventEmitter(); + @Output() export = new EventEmitter(); + + get groupedConversations(): ConversationGroup[] { + const now = new Date(); + const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime(); + const yesterday = today - 24 * 60 * 60 * 1000; + const lastWeek = today - 7 * 24 * 60 * 60 * 1000; + const groups = new Map(); + + for (const conversation of this.conversations) { + const updatedAt = Date.parse(conversation.updated_at); + let label = 'Older'; + if (updatedAt >= today) { + label = 'Today'; + } else if (updatedAt >= yesterday) { + label = 'Yesterday'; + } else if (updatedAt >= lastWeek) { + label = 'Previous 7 Days'; + } + groups.set(label, [...(groups.get(label) ?? []), conversation]); + } + + return ['Today', 'Yesterday', 'Previous 7 Days', 'Older'] + .filter((label) => groups.has(label)) + .map((label) => ({ label, items: groups.get(label) ?? [] })); + } + + renameConversation(item: ConversationSummary): void { + const title = window.prompt('Rename conversation', item.title)?.trim(); + if (title) { + this.rename.emit({ id: item.id, title }); + } + } + + removeConversation(item: ConversationSummary): void { + if (window.confirm(`Delete "${item.title}"?`)) { + this.delete.emit(item.id); + } + } } diff --git a/ui/src/chat/input-area.component.ts b/ui/src/chat/input-area.component.ts index 1696b538..4be592aa 100644 --- a/ui/src/chat/input-area.component.ts +++ b/ui/src/chat/input-area.component.ts @@ -1,41 +1,75 @@ import { CommonModule } from '@angular/common'; -import { Component, EventEmitter, Input, Output } from '@angular/core'; +import { AfterViewInit, Component, ElementRef, EventEmitter, Input, Output, ViewChild } from '@angular/core'; import { FormsModule } from '@angular/forms'; +import { ImageAttachment } from './chat.types'; @Component({ selector: 'chat-input-area', standalone: true, imports: [CommonModule, FormsModule], template: ` -
+
+ +
+
+ +
{{ attachment.filename }}
+ +
+
- {{ value.length }} chars - + {{ value.length }} chars · {{ attachments.length }} image(s) +
+ + +
`, styles: [ ` .composer { display: grid; gap: 0.75rem; padding: 1rem; border-radius: 1.35rem; background: rgba(8, 21, 35, 0.86); border: 1px solid rgba(125, 211, 252, 0.12); } - textarea { width: 100%; min-height: 7rem; resize: vertical; border: 0; background: transparent; color: #f8fafc; font: inherit; outline: 0; } - .composer__meta { display: flex; justify-content: space-between; align-items: center; color: #94a3b8; font-size: 0.82rem; } + .composer__attachments { display: flex; gap: 0.75rem; overflow: auto; } + .attachment { min-width: 8rem; margin: 0; display: grid; gap: 0.45rem; padding: 0.6rem; border-radius: 1rem; background: rgba(2, 6, 23, 0.76); } + .attachment img { width: 100%; aspect-ratio: 1.3; object-fit: cover; border-radius: 0.8rem; } + .attachment figcaption { color: #cbd5e1; font-size: 0.78rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + textarea { width: 100%; min-height: 3.5rem; max-height: 10.5rem; resize: none; border: 0; background: transparent; color: #f8fafc; font: inherit; outline: 0; line-height: 1.6; } + .composer__meta { display: flex; justify-content: space-between; align-items: center; gap: 1rem; color: #94a3b8; font-size: 0.82rem; } + .composer__actions { display: flex; gap: 0.6rem; } button { border: 0; border-radius: 999px; padding: 0.8rem 1.2rem; background: linear-gradient(135deg, #f59e0b, #fb7185); color: #111827; font-weight: 800; cursor: pointer; } + .ghost { background: rgba(15, 23, 42, 0.8); color: #e2e8f0; border: 1px solid rgba(148, 163, 184, 0.22); } button:disabled { opacity: 0.4; cursor: not-allowed; } `, ], }) -export class InputAreaComponent { +export class InputAreaComponent implements AfterViewInit { + @ViewChild('textarea') private readonly textarea?: ElementRef; + @Input() value = ''; @Input() disabled = false; + @Input() attachments: ImageAttachment[] = []; @Output() valueChange = new EventEmitter(); + @Output() attachFiles = new EventEmitter(); + @Output() removeAttachment = new EventEmitter(); @Output() submit = new EventEmitter(); + ngAfterViewInit(): void { + this.resizeTextarea(); + } + + onValueChange(value: string): void { + this.valueChange.emit(value); + queueMicrotask(() => this.resizeTextarea()); + } + submitOnEnter(event: Event): void { const keyboard = event as KeyboardEvent; if (!keyboard.shiftKey) { @@ -43,4 +77,43 @@ export class InputAreaComponent { this.submit.emit(); } } + + onDragOver(event: DragEvent): void { + event.preventDefault(); + } + + onDrop(event: DragEvent): void { + event.preventDefault(); + if (event.dataTransfer?.files?.length) { + this.attachFiles.emit(event.dataTransfer.files); + } + } + + onPaste(event: ClipboardEvent): void { + const files = Array.from(event.clipboardData?.files ?? []); + if (files.length > 0) { + this.attachFiles.emit(files); + } + } + + onFileSelection(event: Event): void { + const input = event.target as HTMLInputElement; + if (input.files?.length) { + this.attachFiles.emit(input.files); + input.value = ''; + } + } + + attachmentSource(attachment: ImageAttachment): string { + return `data:${attachment.mime_type};base64,${attachment.data}`; + } + + private resizeTextarea(): void { + const element = this.textarea?.nativeElement; + if (!element) { + return; + } + element.style.height = 'auto'; + element.style.height = `${Math.min(element.scrollHeight, 168)}px`; + } } diff --git a/ui/src/chat/message-list.component.ts b/ui/src/chat/message-list.component.ts index 6771c65a..de7b17f3 100644 --- a/ui/src/chat/message-list.component.ts +++ b/ui/src/chat/message-list.component.ts @@ -1,11 +1,12 @@ -import { CommonModule } from '@angular/common'; +import { CommonModule, DatePipe } from '@angular/common'; import { Component, Input } from '@angular/core'; -import { ChatMessage } from './chat.types'; +import { DomSanitizer, SafeHtml } from '@angular/platform-browser'; +import { ChatMessage, ImageAttachment } from './chat.types'; @Component({ selector: 'chat-message-list', standalone: true, - imports: [CommonModule], + imports: [CommonModule, DatePipe], template: `
@@ -13,19 +14,32 @@ import { ChatMessage } from './chat.types'; {{ message.role }} {{ message.model || 'local' }} -

{{ message.content }}

-
- Thinking -

{{ message.thinking?.content }}

+
+
+
+ +
{{ attachment.filename }} · {{ attachment.width }}×{{ attachment.height }}
+
+
+ Thinking · {{ thinkingDuration(message) }} +

{{ message.thinking?.content }}

+
Tool calls -
{{ message.tool_calls | json }}
+
+
{{ call.name }}
+
{{ call.arguments | json }}
+
Tool results -
{{ message.tool_results | json }}
+
+
{{ result.tool_call_id }}
+
{{ result.content }}
+
+
{{ message.created_at | date: 'MMM d, HH:mm:ss' }}
`, @@ -34,13 +48,57 @@ import { ChatMessage } from './chat.types'; .thread { display: grid; gap: 1rem; align-content: start; } .bubble { max-width: 54rem; padding: 1rem 1.1rem; border-radius: 1.25rem; background: rgba(11, 27, 44, 0.88); border: 1px solid rgba(125, 211, 252, 0.12); box-shadow: 0 12px 40px rgba(0, 0, 0, 0.18); } .bubble--user { margin-left: auto; background: linear-gradient(135deg, rgba(8, 47, 73, 0.95), rgba(14, 116, 144, 0.7)); } - header { display: flex; justify-content: space-between; gap: 1rem; margin-bottom: 0.65rem; color: #7dd3fc; text-transform: uppercase; letter-spacing: 0.08em; font-size: 0.72rem; } - p { margin: 0; white-space: pre-wrap; line-height: 1.6; color: #ecfeff; } + header, footer { display: flex; justify-content: space-between; gap: 1rem; color: #7dd3fc; font-size: 0.72rem; } + header { margin-bottom: 0.65rem; text-transform: uppercase; letter-spacing: 0.08em; } + footer { margin-top: 0.9rem; color: #94a3b8; } + .content { color: #ecfeff; line-height: 1.6; } + .attachments { display: flex; gap: 0.8rem; flex-wrap: wrap; margin-top: 0.9rem; } + .attachment { width: min(16rem, 100%); margin: 0; display: grid; gap: 0.4rem; } + .attachment img { width: 100%; border-radius: 1rem; } + .attachment figcaption { color: #cbd5e1; font-size: 0.76rem; } .thinking, .tool { margin-top: 0.85rem; padding-top: 0.85rem; border-top: 1px solid rgba(125, 211, 252, 0.12); color: #cbd5e1; } - pre { margin: 0.35rem 0 0; overflow: auto; background: rgba(2, 6, 23, 0.6); padding: 0.85rem; border-radius: 0.8rem; } + .tool { display: grid; gap: 0.65rem; } + .tool__block { display: grid; gap: 0.35rem; } + .tool__title { color: #f8fafc; font-weight: 700; } + pre { margin: 0; overflow: auto; background: rgba(2, 6, 23, 0.6); padding: 0.85rem; border-radius: 0.8rem; white-space: pre-wrap; } `, ], }) export class MessageListComponent { @Input() messages: ChatMessage[] = []; + + constructor(private readonly sanitizer: DomSanitizer) {} + + attachmentSource(attachment: ImageAttachment): string { + return `data:${attachment.mime_type};base64,${attachment.data}`; + } + + thinkingDuration(message: ChatMessage): string { + const duration = message.thinking?.duration_ms ?? 0; + return duration > 0 ? `${(duration / 1000).toFixed(1)}s` : 'in progress'; + } + + renderMarkdown(content: string): SafeHtml { + const escaped = this.escapeHTML(content ?? ''); + const blocks = escaped.replace(/```([\w-]+)?\n([\s\S]*?)```/g, (_, language, code) => { + const label = language ? `
${language}
` : ''; + return `${label}
${code.trim()}
`; + }); + const inline = blocks + .replace(/\*\*(.+?)\*\*/g, '$1') + .replace(/\*(.+?)\*/g, '$1') + .replace(/`([^`]+)`/g, '$1') + .replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1') + .replace(/\n/g, '
'); + return this.sanitizer.bypassSecurityTrustHtml(inline); + } + + private escapeHTML(value: string): string { + return value + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); + } } diff --git a/ui/src/chat/model-selector.component.ts b/ui/src/chat/model-selector.component.ts index fca8beed..ca5149d1 100644 --- a/ui/src/chat/model-selector.component.ts +++ b/ui/src/chat/model-selector.component.ts @@ -12,7 +12,7 @@ import { ModelEntry } from './chat.types'; Model @@ -20,7 +20,7 @@ import { ModelEntry } from './chat.types'; styles: [ ` .selector { display: grid; gap: 0.35rem; color: #cbd5e1; font-size: 0.82rem; } - select { min-width: 16rem; border-radius: 0.8rem; border: 1px solid rgba(124, 156, 191, 0.2); background: rgba(8, 21, 35, 0.8); color: #e2e8f0; padding: 0.72rem 0.9rem; } + select { min-width: 18rem; border-radius: 0.8rem; border: 1px solid rgba(124, 156, 191, 0.2); background: rgba(8, 21, 35, 0.8); color: #e2e8f0; padding: 0.72rem 0.9rem; } `, ], }) diff --git a/ui/src/chat/settings-panel.component.ts b/ui/src/chat/settings-panel.component.ts index bfa66bfc..2cafda3c 100644 --- a/ui/src/chat/settings-panel.component.ts +++ b/ui/src/chat/settings-panel.component.ts @@ -11,13 +11,29 @@ import { ChatSettings, ModelEntry } from './chat.types';
Inference settings - +
+ + +
- - - - - + + + + +