Add missing chat action wiring
This commit is contained in:
parent
99a3f77e47
commit
7ec5cb7a6c
5 changed files with 76 additions and 1 deletions
|
|
@ -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":
|
||||
|
|
|
|||
|
|
@ -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)"
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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}`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue