Implement missing GUI preload and chat spec features

This commit is contained in:
Claude 2026-04-14 15:57:05 +01:00
parent c8490ccf8d
commit caef048906
4 changed files with 292 additions and 54 deletions

View file

@ -835,49 +835,139 @@ func buildElectronShimScript() string {
return () => {};
};
root.electron = root.electron || {
clipboard: {
readText() {
return invoke('clipboard:read').then((result) => {
if (result && typeof result === 'object' && 'text' in result) {
return String(result.text ?? '');
class CoreElectronNotification {
constructor(options = {}) {
this.options = options && typeof options === 'object' ? { ...options } : {};
this.onclick = null;
this.onshow = null;
this.onclose = null;
this.onerror = null;
this._fallback = null;
}
static isSupported() {
return Boolean(root.__coreGUIBridge) || Boolean(fallbackNotification());
}
show() {
const payload = {
title: String(this.options.title ?? ''),
message: String(this.options.body ?? this.options.message ?? ''),
subtitle: typeof this.options.subtitle === 'string' ? this.options.subtitle : '',
icon: typeof this.options.icon === 'string' ? this.options.icon : '',
silent: Boolean(this.options.silent),
actions: Array.isArray(this.options.actions) ? this.options.actions : [],
};
return invoke('notification:show', payload)
.catch((error) => {
const NativeNotification = fallbackNotification();
if (!NativeNotification) {
throw error;
}
return String(result ?? '');
const nativeNotification = new NativeNotification(payload.title || 'Notification', {
body: payload.message,
icon: payload.icon || undefined,
silent: payload.silent,
});
this._fallback = nativeNotification;
if (typeof nativeNotification.addEventListener === 'function') {
nativeNotification.addEventListener('click', () => {
if (typeof this.onclick === 'function') {
this.onclick({ target: nativeNotification });
}
});
nativeNotification.addEventListener('close', () => {
if (typeof this.onclose === 'function') {
this.onclose({ target: nativeNotification });
}
});
} else {
nativeNotification.onclick = (event) => {
if (typeof this.onclick === 'function') {
this.onclick(event);
}
};
}
return nativeNotification;
})
.then((result) => {
if (typeof this.onshow === 'function') {
this.onshow({ target: result });
}
return result;
})
.catch((error) => {
if (typeof this.onerror === 'function') {
this.onerror(error);
}
throw error;
});
},
writeText(text) {
return invoke('clipboard:write', { text: String(text ?? '') });
},
}
close() {
if (this._fallback && typeof this._fallback.close === 'function') {
this._fallback.close();
}
if (typeof this.onclose === 'function') {
this.onclose({ target: this });
}
}
}
const fallbackNotification = () => {
if (typeof root.Notification === 'function' && root.Notification !== CoreElectronNotification) {
return root.Notification;
}
return null;
};
root.electron = root.electron || {};
root.electron.Notification = root.electron.Notification || CoreElectronNotification;
root.electron.clipboard = root.electron.clipboard || {
readText() {
return invoke('clipboard:read').then((result) => {
if (result && typeof result === 'object' && 'text' in result) {
return String(result.text ?? '');
}
return String(result ?? '');
});
},
dialog: {
showMessageBox(options) {
return invoke('dialog:message', options ?? {});
},
showOpenDialog(options) {
return invoke('dialog:open-file', options ?? {});
},
showSaveDialog(options) {
return invoke('dialog:save-file', options ?? {});
},
writeText(text) {
return invoke('clipboard:write', { text: String(text ?? '') });
},
ipcRenderer: {
invoke(channel, payload) {
return invoke('core.ipc.query', { channel, payload });
},
on(channel, handler) {
return subscribe(channel, handler);
},
send(channel, payload) {
return invoke('core.ipc.action', { channel, payload });
},
};
root.electron.dialog = root.electron.dialog || {
showMessageBox(options) {
return invoke('dialog:message', options ?? {});
},
shell: {
openExternal(target) {
return invoke('browser:open-url', { url: String(target ?? '') });
},
openPath(target) {
return invoke('browser:open-file', { path: String(target ?? '') });
},
showOpenDialog(options) {
return invoke('dialog:open-file', options ?? {});
},
showSaveDialog(options) {
return invoke('dialog:save-file', options ?? {});
},
};
root.electron.ipcRenderer = root.electron.ipcRenderer || {
invoke(channel, payload) {
return invoke('core.ipc.query', { channel, payload });
},
on(channel, handler) {
return subscribe(channel, handler);
},
send(channel, payload) {
return invoke('core.ipc.action', { channel, payload });
},
};
root.electron.shell = root.electron.shell || {
openExternal(target) {
return invoke('browser:open-url', { url: String(target ?? '') });
},
openPath(target) {
return invoke('browser:open-file', { path: String(target ?? '') });
},
};

View file

@ -46,6 +46,8 @@ func TestPreloadScript_Good(t *testing.T) {
assert.Contains(t, script, "window.__appPreloadLoaded = true;")
assert.Contains(t, script, "root.core.ml.generate")
assert.Contains(t, script, "root.electron = root.electron ||")
assert.Contains(t, script, "root.electron.Notification")
assert.Contains(t, script, "notification:show")
assert.Contains(t, script, `"theme":"dark"`)
assert.Contains(t, script, `"session_id":"abc123"`)
}
@ -60,6 +62,7 @@ func TestInjectPreload_Good(t *testing.T) {
assert.Contains(t, target.script, "root.core.ml.generate")
assert.Contains(t, target.script, "root.core.storage")
assert.Contains(t, target.script, "root.electron.Notification")
}
func TestBrowserStoragePersistenceAndSearch_Good(t *testing.T) {

View file

@ -14,7 +14,12 @@ import {
ChatService,
Conversation,
ImageAttachment,
SUPPORTED_CHAT_IMAGE_ACCEPT,
SUPPORTED_CHAT_IMAGE_LABEL,
ToolInvocation,
conversationSearchText,
isSupportedChatImageFile,
isSupportedChatImageMimeType,
} from '../services/chat.service';
import { UiStateService } from '../services/ui-state.service';
@ -346,7 +351,7 @@ interface ConversationGroup {
<input
#filePicker
type="file"
accept="image/*"
[attr.accept]="attachmentAccept"
hidden
[disabled]="!selectedModelSupportsVision()"
(change)="onFilePicked($event)"
@ -1009,6 +1014,7 @@ export class DashboardComponent implements AfterViewChecked {
protected readonly searchQuery = this.uiState.searchQuery;
protected readonly activeConversation = this.chat.activeConversation;
protected readonly attachmentAccept = SUPPORTED_CHAT_IMAGE_ACCEPT;
protected readonly selectedModelSupportsVision = computed(
() => this.chat.selectedModelEntry()?.supportsVision !== false,
);
@ -1019,8 +1025,7 @@ export class DashboardComponent implements AfterViewChecked {
for (const conversation of this.chat.conversations()) {
if (query) {
const haystack = `${conversation.title} ${conversation.messages.map((message) => message.content).join(' ')}`.toLowerCase();
if (!haystack.includes(query)) {
if (!conversationSearchText(conversation).includes(query)) {
continue;
}
}
@ -1343,11 +1348,29 @@ export class DashboardComponent implements AfterViewChecked {
}
return;
}
const supportedFiles = imageFiles.filter((file) => isSupportedChatImageFile(file));
const skippedFiles = imageFiles.length - supportedFiles.length;
if (supportedFiles.length === 0) {
this.showComposerNotice(`Supported image formats: ${SUPPORTED_CHAT_IMAGE_LABEL}.`);
return;
}
if (!this.selectedModelSupportsVision()) {
this.showComposerNotice('The selected model does not support image input.');
return;
}
await this.chat.addAttachments(imageFiles);
try {
await this.chat.addAttachments(supportedFiles);
if (skippedFiles > 0) {
this.showComposerNotice(
`Skipped ${skippedFiles} unsupported attachment${skippedFiles === 1 ? '' : 's'}. Supported formats: ${SUPPORTED_CHAT_IMAGE_LABEL}.`,
);
}
} catch (error) {
const message = error instanceof Error ? error.message : `Supported image formats: ${SUPPORTED_CHAT_IMAGE_LABEL}.`;
this.showComposerNotice(message);
}
}
private showComposerNotice(message: string): void {
@ -1407,7 +1430,7 @@ function hasImageFiles(dataTransfer: DataTransfer | null): boolean {
if (!dataTransfer) {
return false;
}
return Array.from(dataTransfer.items).some((item) => item.type.startsWith('image/'));
return Array.from(dataTransfer.items).some((item) => isSupportedChatImageMimeType(item.type));
}
function renderMarkdownContent(content: string): string {

View file

@ -68,6 +68,47 @@ export interface Conversation {
}
const STORAGE_KEY = 'core.gui.chat.state';
export const SUPPORTED_CHAT_IMAGE_MIME_TYPES = [
'image/png',
'image/jpeg',
'image/webp',
'image/gif',
] as const;
export const SUPPORTED_CHAT_IMAGE_LABEL = 'PNG, JPEG, WebP, or GIF';
export const SUPPORTED_CHAT_IMAGE_ACCEPT = SUPPORTED_CHAT_IMAGE_MIME_TYPES.join(',');
interface ChatToolDefinition {
name: string;
description: string;
parameters: Record<string, string>;
}
const REGISTERED_CHAT_TOOLS: ChatToolDefinition[] = [
{
name: 'gui.chat.settings.load',
description: 'Load the persisted inference defaults for the chat shell.',
parameters: {},
},
{
name: 'gui.chat.models',
description: 'List the locally available chat models and which model is selected.',
parameters: {},
},
{
name: 'gui.chat.conversations.search',
description: 'Search saved conversation history by title, content, tool calls, and attachments.',
parameters: {
q: 'Search string',
},
},
{
name: 'gui.route.store',
description: 'Search the local CoreGUI store surface for matching chat data.',
parameters: {
q: 'Search string',
},
},
];
function defaultSettings(): ChatSettings {
return {
@ -113,6 +154,69 @@ function defaultModels(): ModelEntry[] {
];
}
export function normaliseChatImageMimeType(mimeType: string): string {
return mimeType.trim().toLowerCase();
}
function inferChatImageMimeType(fileName: string): string {
const normalized = fileName.trim().toLowerCase();
if (normalized.endsWith('.png')) {
return 'image/png';
}
if (normalized.endsWith('.jpg') || normalized.endsWith('.jpeg')) {
return 'image/jpeg';
}
if (normalized.endsWith('.webp')) {
return 'image/webp';
}
if (normalized.endsWith('.gif')) {
return 'image/gif';
}
return '';
}
function resolveChatImageMimeType(file: Pick<File, 'name' | 'type'>): string {
const mimeType = normaliseChatImageMimeType(file.type ?? '');
if (mimeType) {
return mimeType;
}
return inferChatImageMimeType(file.name ?? '');
}
export function isSupportedChatImageMimeType(mimeType: string): boolean {
return SUPPORTED_CHAT_IMAGE_MIME_TYPES.includes(
normaliseChatImageMimeType(mimeType) as (typeof SUPPORTED_CHAT_IMAGE_MIME_TYPES)[number],
);
}
export function isSupportedChatImageFile(file: Pick<File, 'name' | 'type'>): boolean {
return isSupportedChatImageMimeType(resolveChatImageMimeType(file));
}
export function conversationSearchText(conversation: Conversation): string {
return [
conversation.title,
conversation.model,
...conversation.messages.flatMap((message) => [
message.role,
message.content,
message.thinking?.content ?? '',
...(message.attachments ?? []).flatMap((attachment) => [
attachment.filename,
attachment.mimeType,
]),
...(message.toolCalls ?? []).flatMap((toolCall) => [
toolCall.name,
JSON.stringify(toolCall.arguments ?? {}),
toolCall.result,
toolCall.error ?? '',
]),
]),
]
.join(' ')
.toLowerCase();
}
@Injectable({ providedIn: 'root' })
export class ChatService {
private readonly storage = window.localStorage;
@ -247,12 +351,17 @@ export class ChatService {
}
async addAttachment(file: File): Promise<void> {
const mimeType = resolveChatImageMimeType(file);
if (!isSupportedChatImageMimeType(mimeType)) {
throw new Error(`Supported image formats: ${SUPPORTED_CHAT_IMAGE_LABEL}.`);
}
const dataUrl = await readAsDataURL(file);
const dimensions = await readImageSize(dataUrl);
const attachment: ImageAttachment = {
id: crypto.randomUUID(),
filename: file.name,
mimeType: file.type || 'application/octet-stream',
mimeType,
data: dataUrl,
width: dimensions.width,
height: dimensions.height,
@ -694,8 +803,7 @@ function describeConversationMatches(conversations: Conversation[], query: strin
if (!needle) {
return true;
}
const haystack = `${conversation.title} ${conversation.messages.map((message) => message.content).join(' ')}`.toLowerCase();
return haystack.includes(needle);
return conversationSearchText(conversation).includes(needle);
});
if (matches.length === 0) {
@ -733,16 +841,30 @@ function describeStoreMatches(conversations: Conversation[], query: string): str
}
function buildToolAwarePrompt(prompt: string, toolOutputs: string[]): string {
if (toolOutputs.length === 0) {
return prompt;
const sections = [
'System tool manifest:',
buildToolManifest(),
'',
'User prompt:',
prompt,
];
if (toolOutputs.length > 0) {
sections.push('', 'Tool context:', ...toolOutputs.map((output) => `- ${output}`));
}
return [
prompt,
'',
'Tool context:',
...toolOutputs.map((output) => `- ${output}`),
].join('\n');
return sections.join('\n');
}
function buildToolManifest(): string {
return REGISTERED_CHAT_TOOLS.map((tool) => {
const params = Object.entries(tool.parameters)
.map(([name, description]) => `${name}: ${description}`)
.join(', ');
return params
? `- ${tool.name}: ${tool.description} Parameters: ${params}.`
: `- ${tool.name}: ${tool.description}`;
}).join('\n');
}
function readAsDataURL(file: File): Promise<string> {