diff --git a/pkg/display/display.go b/pkg/display/display.go index 17edc470..6c3fb8fe 100644 --- a/pkg/display/display.go +++ b/pkg/display/display.go @@ -485,6 +485,8 @@ func (s *Service) handleWSMessage(msg WSMessage) core.Result { return c.Action("gui.chat.selectModel").Run(ctx, wsOptions(msg.Data)) case "chat:settings:save": return c.Action("gui.chat.settings.save").Run(ctx, wsOptions(msg.Data)) + case "chat:settings:defaults": + return c.Action("gui.chat.settings.defaults").Run(ctx, wsOptions(msg.Data)) case "chat:settings:load": return c.Action("gui.chat.settings.load").Run(ctx, wsOptions(msg.Data)) case "chat:settings:reset": @@ -507,6 +509,8 @@ func (s *Service) handleWSMessage(msg WSMessage) core.Result { return c.Action("gui.chat.conversations.export").Run(ctx, wsOptions(msg.Data)) case "chat:attach-image": return c.Action("gui.chat.attachImage").Run(ctx, wsOptions(msg.Data)) + case "chat:remove-image": + return c.Action("gui.chat.removeImage").Run(ctx, wsOptions(msg.Data)) case "chat:thinking:start": return c.Action("gui.chat.thinking.start").Run(ctx, wsOptions(msg.Data)) case "chat:thinking:append": diff --git a/ui/src/chat/chat-panel.component.ts b/ui/src/chat/chat-panel.component.ts index 53e0c29c..e22b1848 100644 --- a/ui/src/chat/chat-panel.component.ts +++ b/ui/src/chat/chat-panel.component.ts @@ -73,6 +73,8 @@ import { ChatStateService } from './chat-state.service'; [value]="state.draft()" [disabled]="state.sending()" [attachments]="state.queuedAttachments()" + [visionEnabled]="state.selectedModelSupportsVision()" + [visionDisabledReason]="'Image input is only available for vision-capable local models.'" (valueChange)="state.draft.set($event)" (attachFiles)="state.queueImageFiles($event)" (removeAttachment)="state.removeQueuedAttachment($event)" diff --git a/ui/src/chat/chat-state.service.ts b/ui/src/chat/chat-state.service.ts index f93df97f..f8bc8fbe 100644 --- a/ui/src/chat/chat-state.service.ts +++ b/ui/src/chat/chat-state.service.ts @@ -38,6 +38,12 @@ export class ChatStateService { readonly sending = signal(false); readonly modelSwitching = signal(false); readonly selectedModel = signal(''); + readonly selectedModelEntry = computed( + () => this.models().find((model) => model.name === this.selectedModel()) ?? null, + ); + readonly selectedModelSupportsVision = computed( + () => this.supportsVision(this.selectedModelEntry()), + ); readonly thinkingActive = computed( () => this.activeConversation()?.messages.some((message) => message.thinking?.active) ?? false, ); @@ -168,6 +174,9 @@ export class ChatStateService { } async queueImageFiles(files: FileList | File[]): Promise { + if (!this.selectedModelSupportsVision()) { + return; + } const items = Array.from(files); for (const file of items) { if (!file.type.startsWith('image/')) { @@ -434,6 +443,17 @@ export class ChatStateService { }; } + // Vision is currently limited to Gemma-family multimodal models such as lemer/lemma. + // Example: this.supportsVision({ name: 'lemer', architecture: 'gemma3' }) == true + private supportsVision(model: ModelEntry | null): boolean { + if (!model) { + return false; + } + const architecture = (model.architecture ?? '').toLowerCase(); + const name = (model.name ?? '').toLowerCase(); + return architecture.includes('gemma') || name === 'lemer' || name === 'lemma'; + } + private async fileToAttachment(file: File): Promise { const data = await this.readFileAsDataURL(file); const dimensions = await this.readImageDimensions(data); diff --git a/ui/src/chat/input-area.component.ts b/ui/src/chat/input-area.component.ts index e4f44b75..2243322b 100644 --- a/ui/src/chat/input-area.component.ts +++ b/ui/src/chat/input-area.component.ts @@ -29,10 +29,19 @@ import { ImageAttachment } from './chat.types';
{{ value.length }} chars ยท {{ attachments.length }} image(s)
- Attach + + Attach + Send
+

{{ visionDisabledReason }}

`, styles: [ @@ -45,6 +54,7 @@ import { ImageAttachment } from './chat.types'; 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; } + .composer__hint { margin: 0; color: #fbbf24; font-size: 0.8rem; } wa-button[disabled] { opacity: 0.4; } `, ], @@ -55,6 +65,8 @@ export class InputAreaComponent implements AfterViewInit { @Input() value = ''; @Input() disabled = false; @Input() attachments: ImageAttachment[] = []; + @Input() visionEnabled = true; + @Input() visionDisabledReason = ''; @Output() valueChange = new EventEmitter(); @Output() attachFiles = new EventEmitter(); @Output() removeAttachment = new EventEmitter(); @@ -83,12 +95,18 @@ export class InputAreaComponent implements AfterViewInit { onDrop(event: DragEvent): void { event.preventDefault(); + if (!this.visionEnabled) { + return; + } if (event.dataTransfer?.files?.length) { this.attachFiles.emit(event.dataTransfer.files); } } onPaste(event: ClipboardEvent): void { + if (!this.visionEnabled) { + return; + } const files = Array.from(event.clipboardData?.files ?? []); if (files.length > 0) { this.attachFiles.emit(files); @@ -96,6 +114,9 @@ export class InputAreaComponent implements AfterViewInit { } onFileSelection(event: Event): void { + if (!this.visionEnabled) { + return; + } const input = event.target as HTMLInputElement; if (input.files?.length) { this.attachFiles.emit(input.files); @@ -103,6 +124,13 @@ export class InputAreaComponent implements AfterViewInit { } } + openFilePicker(input: HTMLInputElement): void { + if (!this.visionEnabled) { + return; + } + input.click(); + } + attachmentSource(attachment: ImageAttachment): string { return `data:${attachment.mime_type};base64,${attachment.data}`; } diff --git a/ui/src/generated/core-gui-chat.bindings.ts b/ui/src/generated/core-gui-chat.bindings.ts index 71a3b08f..3e9d6d67 100644 --- a/ui/src/generated/core-gui-chat.bindings.ts +++ b/ui/src/generated/core-gui-chat.bindings.ts @@ -13,6 +13,14 @@ declare global { } export interface ChatRouteMap { + 'gui.chat.clear': { + request: { id?: string; conversation_id?: string }; + response: Conversation; + }; + 'gui.chat.history': { + request: { id?: string; conversation_id?: string }; + response: Conversation; + }; 'gui.chat.models': { request: void; response: ModelEntry[] }; 'gui.chat.settings.defaults': { request: void; response: ChatSettings }; 'gui.chat.settings.load': { request: void; response: ChatSettings }; @@ -29,6 +37,7 @@ export interface ChatRouteMap { 'gui.chat.conversations.delete': { request: { id: string }; response: void }; 'gui.chat.conversations.rename': { request: { id: string; title: string }; response: Conversation }; 'gui.chat.conversations.export': { request: { id: string }; response: string }; + 'gui.chat.conversation.save': { request: Conversation; response: Conversation }; 'gui.chat.attachImage': { request: ({ conversation_id?: string } & ImageAttachment); response: ImageAttachment; @@ -41,6 +50,18 @@ export interface ChatRouteMap { request: { conversation_id?: string; content: string }; response: Conversation; }; + 'gui.chat.thinking.start': { + request: { conversation_id: string; message_id?: string; started_at?: string }; + response: { conversation_id: string; message_id?: string; started_at?: string }; + }; + 'gui.chat.thinking.append': { + request: { conversation_id: string; message_id?: string; content?: string }; + response: string; + }; + 'gui.chat.thinking.end': { + request: { conversation_id: string; message_id?: string; started_at?: string; duration_ms?: number }; + response: number; + }; } export type ChatRoute = keyof ChatRouteMap;