Add missing chat action wiring
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run

This commit is contained in:
Snider 2026-04-15 20:37:07 +01:00
parent 99a3f77e47
commit 7ec5cb7a6c
5 changed files with 76 additions and 1 deletions

View file

@ -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":

View file

@ -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)"

View file

@ -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<void> {
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<ImageAttachment> {
const data = await this.readFileAsDataURL(file);
const dimensions = await this.readImageDimensions(data);

View file

@ -29,10 +29,19 @@ import { ImageAttachment } from './chat.types';
<div class="composer__meta">
<span>{{ value.length }} chars · {{ attachments.length }} image(s)</span>
<div class="composer__actions">
<wa-button type="button" appearance="plain" (click)="filePicker.click()">Attach</wa-button>
<wa-button
type="button"
appearance="plain"
[disabled]="!visionEnabled"
[attr.title]="!visionEnabled ? visionDisabledReason : null"
(click)="openFilePicker(filePicker)"
>
Attach
</wa-button>
<wa-button type="button" variant="brand" [disabled]="disabled" (click)="submit.emit()">Send</wa-button>
</div>
</div>
<p *ngIf="!visionEnabled" class="composer__hint">{{ visionDisabledReason }}</p>
</div>
`,
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<string>();
@Output() attachFiles = new EventEmitter<FileList | File[]>();
@Output() removeAttachment = new EventEmitter<number>();
@ -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}`;
}

View file

@ -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;