wip: agui integration

This commit is contained in:
2026-02-25 19:51:58 +01:00
parent 5efbcfe03a
commit fcdf869a7c
59 changed files with 3467 additions and 267 deletions

View File

@@ -0,0 +1,129 @@
import type { SidebarView } from './sidebarViewRegistry';
import type { TabType } from '../store/appStore';
import { isSidebarView } from './sidebarViewRegistry';
import { z } from 'zod';
export interface AssistantActionInput {
action: string;
payload?: Record<string, unknown>;
}
export interface AssistantActionDependencies {
setSelectedPost: (id: string | null) => void;
setSelectedMedia: (id: string | null) => void;
openTab: (tab: { type: TabType; id: string; isTransient: boolean }) => void;
setActiveView: (view: SidebarView) => void;
toggleSidebar: () => void;
togglePanel: () => void;
toggleAssistantSidebar: () => void;
}
export interface AssistantActionResult {
handled: boolean;
error?: string;
}
const openPostPayloadSchema = z.object({
postId: z.string().min(1),
});
const openMediaPayloadSchema = z.object({
mediaId: z.string().min(1),
});
const switchViewPayloadSchema = z
.object({
view: z.string().min(1),
})
.refine((payload) => isSidebarView(payload.view), {
message: 'view must be a valid sidebar view',
});
const openChatPayloadSchema = z.object({
conversationId: z.string().min(1),
});
function invalidPayloadError(action: string): AssistantActionResult {
return {
handled: false,
error: `Invalid payload for ${action} action`,
};
}
export function dispatchAssistantAction(
input: AssistantActionInput,
dependencies: AssistantActionDependencies,
): AssistantActionResult {
const payload = input.payload ?? {};
if (input.action === 'openPost') {
const parsed = openPostPayloadSchema.safeParse(payload);
if (!parsed.success) {
return invalidPayloadError('openPost');
}
const { postId } = parsed.data;
dependencies.setActiveView('posts');
dependencies.setSelectedPost(postId);
dependencies.openTab({ type: 'post', id: postId, isTransient: false });
return { handled: true };
}
if (input.action === 'openMedia') {
const parsed = openMediaPayloadSchema.safeParse(payload);
if (!parsed.success) {
return invalidPayloadError('openMedia');
}
const { mediaId } = parsed.data;
dependencies.setActiveView('media');
dependencies.setSelectedMedia(mediaId);
dependencies.openTab({ type: 'media', id: mediaId, isTransient: false });
return { handled: true };
}
if (input.action === 'switchView') {
const parsed = switchViewPayloadSchema.safeParse(payload);
if (!parsed.success) {
return invalidPayloadError('switchView');
}
const { view } = parsed.data;
dependencies.setActiveView(view as SidebarView);
return { handled: true };
}
if (input.action === 'openChat') {
const parsed = openChatPayloadSchema.safeParse(payload);
if (!parsed.success) {
return invalidPayloadError('openChat');
}
dependencies.setActiveView('chat');
dependencies.openTab({ type: 'chat', id: parsed.data.conversationId, isTransient: false });
return { handled: true };
}
if (input.action === 'openSettings') {
dependencies.setActiveView('settings');
dependencies.openTab({ type: 'settings', id: 'settings', isTransient: false });
return { handled: true };
}
if (input.action === 'toggleSidebar') {
dependencies.toggleSidebar();
return { handled: true };
}
if (input.action === 'togglePanel') {
dependencies.togglePanel();
return { handled: true };
}
if (input.action === 'toggleAssistantSidebar') {
dependencies.toggleAssistantSidebar();
return { handled: true };
}
return { handled: false, error: `Unsupported action: ${input.action}` };
}

View File

@@ -0,0 +1,30 @@
import type { AssistantEditorContext } from './assistantPromptContext';
import { buildAssistantStartPrompt } from './assistantPromptContext';
export interface AssistantRequestPlan {
shouldCreateConversation: boolean;
outboundMessage: string;
}
export function planAssistantRequest(input: {
conversationId: string | null;
userPrompt: string;
context: AssistantEditorContext | null;
}): AssistantRequestPlan {
const userPrompt = input.userPrompt.trim();
if (input.conversationId) {
return {
shouldCreateConversation: false,
outboundMessage: userPrompt,
};
}
return {
shouldCreateConversation: true,
outboundMessage: buildAssistantStartPrompt({
userPrompt,
context: input.context,
}),
};
}

View File

@@ -0,0 +1,391 @@
import { z } from 'zod';
const textElementSchema = z.object({
type: z.literal('text'),
text: z.string().min(1),
});
const metricElementSchema = z.object({
type: z.literal('metric'),
label: z.string().min(1),
value: z.string().min(1),
});
const listElementSchema = z.object({
type: z.literal('list'),
title: z.string().optional(),
items: z.array(z.string().min(1)).min(1),
});
const tableElementSchema = z.object({
type: z.literal('table'),
columns: z.array(z.string().min(1)).min(1),
rows: z.array(z.array(z.string())).min(1),
});
const actionElementSchema = z.object({
type: z.literal('action'),
label: z.string().min(1),
action: z.string().min(1),
payload: z.record(z.string(), z.unknown()).optional(),
});
const chartElementSchema = z.object({
type: z.literal('chart'),
chartType: z.enum(['bar', 'line', 'pie']),
title: z.string().min(1).optional(),
series: z.array(
z.object({
label: z.string().min(1),
value: z.number(),
}),
).min(1),
});
const inputTypeSchema = z.enum(['text', 'textarea', 'select', 'checkbox', 'date', 'number']);
const inputOptionSchema = z.object({
label: z.string().min(1),
value: z.string(),
});
const inputElementSchema = z.object({
type: z.literal('input'),
key: z.string().min(1),
label: z.string().min(1),
inputType: inputTypeSchema,
placeholder: z.string().optional(),
defaultValue: z.union([z.string(), z.number(), z.boolean()]).optional(),
options: z.array(inputOptionSchema).optional(),
action: z.string().min(1).optional(),
submitLabel: z.string().min(1).optional(),
payload: z.record(z.string(), z.unknown()).optional(),
});
const datePickerElementSchema = z.object({
type: z.literal('datePicker'),
key: z.string().min(1),
label: z.string().min(1),
defaultValue: z.string().optional(),
min: z.string().optional(),
max: z.string().optional(),
action: z.string().min(1).optional(),
submitLabel: z.string().min(1).optional(),
payload: z.record(z.string(), z.unknown()).optional(),
});
const formFieldSchema = z.object({
key: z.string().min(1),
label: z.string().min(1),
inputType: inputTypeSchema,
placeholder: z.string().optional(),
defaultValue: z.union([z.string(), z.number(), z.boolean()]).optional(),
options: z.array(inputOptionSchema).optional(),
required: z.boolean().optional(),
});
const formElementSchema = z.object({
type: z.literal('form'),
formId: z.string().min(1),
title: z.string().optional(),
submitLabel: z.string().min(1),
action: z.string().min(1),
payload: z.record(z.string(), z.unknown()).optional(),
fields: z.array(formFieldSchema).min(1),
});
const cardActionSchema = z.object({
label: z.string().min(1),
action: z.string().min(1),
payload: z.record(z.string(), z.unknown()).optional(),
});
const cardElementSchema = z.object({
type: z.literal('card'),
title: z.string().min(1),
body: z.string().min(1),
subtitle: z.string().optional(),
actions: z.array(cardActionSchema).optional(),
});
const imageElementSchema = z.object({
type: z.literal('image'),
src: z.string().min(1),
alt: z.string().optional(),
caption: z.string().optional(),
action: z.string().min(1).optional(),
payload: z.record(z.string(), z.unknown()).optional(),
});
let assistantPanelElementSchemaRef: z.ZodTypeAny;
const tabsElementSchema: z.ZodTypeAny = z.lazy(() => z.object({
type: z.literal('tabs'),
widgetId: z.string().min(1).optional(),
defaultTabId: z.string().min(1).optional(),
tabs: z.array(
z.object({
id: z.string().min(1),
label: z.string().min(1),
elements: z.array(assistantPanelElementSchemaRef).min(1),
}),
).min(1),
}));
assistantPanelElementSchemaRef = z.discriminatedUnion('type', [
textElementSchema,
metricElementSchema,
listElementSchema,
tableElementSchema,
actionElementSchema,
chartElementSchema,
inputElementSchema,
formElementSchema,
datePickerElementSchema,
cardElementSchema,
imageElementSchema,
tabsElementSchema,
]);
export const assistantPanelElementSchema = assistantPanelElementSchemaRef;
export const assistantPanelSpecSchema = z.object({
specVersion: z.literal('1'),
elements: z.array(assistantPanelElementSchema).min(1),
});
export type AssistantPanelElement = z.infer<typeof assistantPanelElementSchema>;
export type AssistantPanelSpec = z.infer<typeof assistantPanelSpecSchema>;
export interface AssistantResponseContent {
displayText: string;
panelSpec: AssistantPanelSpec | null;
}
function toRecord(value: unknown): Record<string, unknown> | null {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return null;
}
return value as Record<string, unknown>;
}
function normalizeChartElement(record: Record<string, unknown>): Record<string, unknown> | null {
const normalized: Record<string, unknown> = {
...record,
};
const dataRecord = toRecord(record.data);
if (Array.isArray(record.series)) {
return normalized;
}
if (!dataRecord) {
return normalized;
}
const labels = Array.isArray(dataRecord.labels) ? dataRecord.labels : [];
const datasets = Array.isArray(dataRecord.datasets) ? dataRecord.datasets : [];
const firstDataset = toRecord(datasets[0]);
const values = Array.isArray(firstDataset?.data) ? firstDataset?.data : [];
if (labels.length === 0 || values.length === 0) {
return normalized;
}
const series = labels
.map((label, index) => ({
label: String(label),
value: Number(values[index]),
}))
.filter((entry) => Number.isFinite(entry.value));
if (series.length === 0) {
return normalized;
}
normalized.series = series;
delete normalized.data;
return normalized;
}
function normalizeTabContent(tabValue: unknown): Record<string, unknown>[] {
if (Array.isArray(tabValue)) {
return tabValue.map((entry) => normalizeElement(entry)).filter((entry): entry is Record<string, unknown> => Boolean(entry));
}
const normalized = normalizeElement(tabValue);
return normalized ? [normalized] : [];
}
function normalizeTabsElement(record: Record<string, unknown>): Record<string, unknown> | null {
const tabs = Array.isArray(record.tabs) ? record.tabs : [];
const normalizedTabs = tabs
.map((tabValue, tabIndex) => {
const tabRecord = toRecord(tabValue);
if (!tabRecord) {
return null;
}
const id = typeof tabRecord.id === 'string' && tabRecord.id.trim().length > 0
? tabRecord.id
: `tab-${tabIndex + 1}`;
const label = typeof tabRecord.label === 'string' && tabRecord.label.trim().length > 0
? tabRecord.label
: typeof tabRecord.title === 'string' && tabRecord.title.trim().length > 0
? tabRecord.title
: id;
const elements = Array.isArray(tabRecord.elements)
? normalizeTabContent(tabRecord.elements)
: normalizeTabContent(tabRecord.content);
if (elements.length === 0) {
return null;
}
return {
id,
label,
elements,
};
})
.filter((entry): entry is { id: string; label: string; elements: Record<string, unknown>[] } => Boolean(entry));
if (normalizedTabs.length === 0) {
return null;
}
return {
...record,
tabs: normalizedTabs,
};
}
function normalizeElement(value: unknown): Record<string, unknown> | null {
const record = toRecord(value);
if (!record) {
return null;
}
const type = typeof record.type === 'string' ? record.type : '';
if (type === 'markdown') {
const textValue = typeof record.content === 'string' ? record.content : typeof record.text === 'string' ? record.text : '';
if (!textValue.trim()) {
return null;
}
return {
type: 'text',
text: textValue,
};
}
if (type === 'chart') {
return normalizeChartElement(record);
}
if (type === 'tabs') {
return normalizeTabsElement(record);
}
return record;
}
function normalizeCandidate(parsed: unknown): AssistantPanelSpec | null {
const canonicalResult = assistantPanelSpecSchema.safeParse(parsed);
if (canonicalResult.success) {
return canonicalResult.data;
}
const record = toRecord(parsed);
if (!record) {
return null;
}
if (record.type === 'tab' && record.content) {
return normalizeCandidate(record.content);
}
if (record.type === 'tabs') {
const tabsElement = normalizeTabsElement(record);
if (!tabsElement) {
return null;
}
const asSpec = {
specVersion: '1',
elements: [tabsElement],
};
const normalizedResult = assistantPanelSpecSchema.safeParse(asSpec);
return normalizedResult.success ? normalizedResult.data : null;
}
if (Array.isArray(record.elements)) {
const normalizedElements = record.elements
.map((element) => normalizeElement(element))
.filter((element): element is Record<string, unknown> => Boolean(element));
if (normalizedElements.length === 0) {
return null;
}
const asSpec = {
specVersion: '1',
elements: normalizedElements,
};
const normalizedResult = assistantPanelSpecSchema.safeParse(asSpec);
return normalizedResult.success ? normalizedResult.data : null;
}
const normalizedElement = normalizeElement(record);
if (!normalizedElement) {
return null;
}
const asSpec = {
specVersion: '1',
elements: [normalizedElement],
};
const normalizedResult = assistantPanelSpecSchema.safeParse(asSpec);
return normalizedResult.success ? normalizedResult.data : null;
}
function parseSpecCandidate(raw: string): AssistantPanelSpec | null {
try {
const parsed = JSON.parse(raw);
return normalizeCandidate(parsed);
} catch {
return null;
}
}
export function extractAssistantPanelSpec(message: string): AssistantPanelSpec | null {
return extractAssistantResponseContent(message).panelSpec;
}
export function extractAssistantResponseContent(message: string): AssistantResponseContent {
const trimmed = message.trim();
const fencedMatches = [...trimmed.matchAll(/```(json)?\s*([\s\S]*?)```/gi)];
for (const match of fencedMatches) {
const candidate = match[2]?.trim();
if (!candidate) {
continue;
}
const parsed = parseSpecCandidate(candidate);
if (parsed) {
const displayText = trimmed.replace(match[0], '').trim();
return {
displayText,
panelSpec: parsed,
};
}
}
const parsedWholeMessage = parseSpecCandidate(trimmed);
return {
displayText: parsedWholeMessage ? '' : trimmed,
panelSpec: parsedWholeMessage,
};
}

View File

@@ -0,0 +1,67 @@
import type { MediaData, PostData, Tab } from '../store/appStore';
export interface AssistantEditorContext {
tabType: Tab['type'] | 'none';
id?: string;
title?: string;
}
export function resolveAssistantEditorContext(input: {
activeTab: Tab | null;
posts: PostData[];
media: MediaData[];
}): AssistantEditorContext | null {
const { activeTab, posts, media } = input;
if (!activeTab) {
return null;
}
if (activeTab.type === 'post') {
const currentPost = posts.find((post) => post.id === activeTab.id);
return {
tabType: 'post',
id: activeTab.id,
title: currentPost?.title,
};
}
if (activeTab.type === 'media') {
const currentMedia = media.find((item) => item.id === activeTab.id);
return {
tabType: 'media',
id: activeTab.id,
title: currentMedia?.originalName || currentMedia?.filename || currentMedia?.title,
};
}
return {
tabType: activeTab.type,
id: activeTab.id,
title: activeTab.id,
};
}
export function buildAssistantStartPrompt(input: {
userPrompt: string;
context: AssistantEditorContext | null;
}): string {
const userPrompt = input.userPrompt.trim();
const context = input.context;
const lines = [
`User request: ${userPrompt}`,
`Current editor context type: ${context?.tabType ?? 'none'}`,
];
if (context?.id) {
lines.push(`Current editor context id: ${context.id}`);
}
if (context?.title) {
lines.push(`Current editor context title: ${context.title}`);
}
lines.push('Use this context when analyzing data and proposing UI updates.');
return lines.join('\n');
}

View File

@@ -0,0 +1,73 @@
export interface ChatService {
createConversation: (title?: string, model?: string) => Promise<{ id: string } | null | undefined>;
sendMessage: (
conversationId: string,
message: string,
metadata?: SendMessageMetadata,
) => Promise<{ success: boolean; message?: string; error?: string } | null | undefined>;
}
export interface SendMessageMetadata {
surface?: 'tab' | 'sidebar';
}
export interface EnsureConversationIdInput {
currentConversationId: string | null;
createTitle: string;
chatService: Pick<ChatService, 'createConversation'>;
}
export interface SendConversationMessageInput {
conversationId: string;
message: string;
metadata?: SendMessageMetadata;
chatService: Pick<ChatService, 'sendMessage'>;
}
export interface SendConversationMessageResult {
success: boolean;
message: string;
error?: string;
}
export async function ensureConversationId(input: EnsureConversationIdInput): Promise<string> {
if (input.currentConversationId) {
return input.currentConversationId;
}
const conversation = await input.chatService.createConversation(input.createTitle);
if (!conversation?.id) {
throw new Error('No conversation id returned');
}
return conversation.id;
}
export async function sendConversationMessage(
input: SendConversationMessageInput,
): Promise<SendConversationMessageResult> {
const result = input.metadata
? await input.chatService.sendMessage(input.conversationId, input.message, input.metadata)
: await input.chatService.sendMessage(input.conversationId, input.message);
if (result?.success === false) {
return {
success: false,
message: '',
error: result.error || 'Failed to send message',
};
}
if (!result) {
return {
success: false,
message: '',
error: 'No response returned',
};
}
return {
success: true,
message: result.message || '',
};
}

View File

@@ -0,0 +1,24 @@
export type ChatSurfaceModeId = 'tab' | 'sidebar';
export interface ChatSurfaceMode {
showModelSelector: boolean;
showWelcomeTips: boolean;
showToolMarkers: boolean;
}
const CHAT_SURFACE_MODE_REGISTRY: Record<ChatSurfaceModeId, ChatSurfaceMode> = {
tab: {
showModelSelector: true,
showWelcomeTips: true,
showToolMarkers: true,
},
sidebar: {
showModelSelector: false,
showWelcomeTips: false,
showToolMarkers: true,
},
};
export function getChatSurfaceMode(modeId: ChatSurfaceModeId): ChatSurfaceMode {
return CHAT_SURFACE_MODE_REGISTRY[modeId];
}

View File

@@ -0,0 +1,56 @@
import { useCallback, useState } from 'react';
import { sendConversationMessage, type ChatService, type SendMessageMetadata } from './chatSession';
interface UseChatMessageSenderInput {
chatService: Pick<ChatService, 'sendMessage'> | null | undefined;
}
interface UseChatMessageSenderParams {
conversationId: string;
message: string;
metadata?: SendMessageMetadata;
}
export function useChatMessageSender(input: UseChatMessageSenderInput) {
const [lastError, setLastError] = useState<string | null>(null);
const sendMessage = useCallback(
async (params: UseChatMessageSenderParams) => {
if (!input.chatService) {
const error = 'Chat service unavailable';
setLastError(error);
return {
success: false as const,
message: '',
error,
};
}
const result = await sendConversationMessage({
conversationId: params.conversationId,
message: params.message,
metadata: params.metadata,
chatService: input.chatService,
});
if (!result.success) {
setLastError(result.error || 'Failed to send message');
return result;
}
setLastError(null);
return result;
},
[input.chatService],
);
const clearError = useCallback(() => {
setLastError(null);
}, []);
return {
sendMessage,
lastError,
clearError,
};
}

View File

@@ -0,0 +1,127 @@
import { useCallback, useRef, useState } from 'react';
import type { ChatMessage } from '../types/electron';
export interface ChatToolEvent {
type: 'call' | 'result';
name: string;
args?: unknown;
timestamp: number;
}
export function useChatSurfaceState() {
const [messages, setMessagesState] = useState<ChatMessage[]>([]);
const [isStreaming, setIsStreaming] = useState(false);
const [streamingContent, setStreamingContent] = useState('');
const [toolEvents, setToolEvents] = useState<ChatToolEvent[]>([]);
const streamingRef = useRef('');
const toolEventsRef = useRef<Array<{ name: string; args?: unknown }>>([]);
const setMessages = useCallback((nextMessages: ChatMessage[]) => {
setMessagesState(nextMessages);
}, []);
const beginUserTurn = useCallback((conversationId: string, content: string) => {
const userMessage: ChatMessage = {
id: `temp-${Date.now()}`,
conversationId,
role: 'user',
content,
createdAt: new Date().toISOString(),
};
setMessagesState((prev) => [...prev, userMessage]);
setIsStreaming(true);
streamingRef.current = '';
setStreamingContent('');
setToolEvents([]);
toolEventsRef.current = [];
}, []);
const appendStreamDelta = useCallback((delta: string) => {
streamingRef.current += delta;
setStreamingContent(streamingRef.current);
}, []);
const recordToolCall = useCallback((name: string, args?: unknown) => {
toolEventsRef.current.push({ name, args });
setToolEvents((prev) => [...prev, { type: 'call', name, args, timestamp: Date.now() }]);
}, []);
const recordToolResult = useCallback((name: string) => {
setToolEvents((prev) => [...prev, { type: 'result', name, timestamp: Date.now() }]);
}, []);
const stopStreaming = useCallback(() => {
setIsStreaming(false);
setStreamingContent('');
streamingRef.current = '';
}, []);
const appendAssistantMessage = useCallback((conversationId: string, content: string) => {
const assistantMessage: ChatMessage = {
id: `assistant-${Date.now()}`,
conversationId,
role: 'assistant',
content,
createdAt: new Date().toISOString(),
};
setMessagesState((prev) => [...prev, assistantMessage]);
}, []);
const finalizeAssistantTurn = useCallback((conversationId: string, content: string) => {
if (content) {
const assistantMessage: ChatMessage = {
id: `assistant-${Date.now()}`,
conversationId,
role: 'assistant',
content,
toolCalls: toolEventsRef.current.length > 0 ? JSON.stringify(toolEventsRef.current) : undefined,
createdAt: new Date().toISOString(),
};
setMessagesState((prev) => [...prev, assistantMessage]);
}
setIsStreaming(false);
setStreamingContent('');
streamingRef.current = '';
}, []);
const abortStreaming = useCallback((conversationId: string, cancelledSuffix: string) => {
const partialContent = streamingRef.current;
setIsStreaming(false);
setStreamingContent('');
streamingRef.current = '';
if (!partialContent) {
return;
}
const partialMessage: ChatMessage = {
id: `partial-${Date.now()}`,
conversationId,
role: 'assistant',
content: `${partialContent}\n\n*(${cancelledSuffix})*`,
createdAt: new Date().toISOString(),
};
setMessagesState((prev) => [...prev, partialMessage]);
}, []);
const getStreamingContent = useCallback(() => streamingRef.current, []);
return {
messages,
isStreaming,
streamingContent,
toolEvents,
setMessages,
beginUserTurn,
appendStreamDelta,
recordToolCall,
recordToolResult,
appendAssistantMessage,
finalizeAssistantTurn,
stopStreaming,
abortStreaming,
getStreamingContent,
};
}