wip: agui integration
This commit is contained in:
129
src/renderer/navigation/assistantActionDispatcher.ts
Normal file
129
src/renderer/navigation/assistantActionDispatcher.ts
Normal 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}` };
|
||||
}
|
||||
30
src/renderer/navigation/assistantConversation.ts
Normal file
30
src/renderer/navigation/assistantConversation.ts
Normal 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,
|
||||
}),
|
||||
};
|
||||
}
|
||||
391
src/renderer/navigation/assistantPanelSpec.ts
Normal file
391
src/renderer/navigation/assistantPanelSpec.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
67
src/renderer/navigation/assistantPromptContext.ts
Normal file
67
src/renderer/navigation/assistantPromptContext.ts
Normal 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');
|
||||
}
|
||||
73
src/renderer/navigation/chatSession.ts
Normal file
73
src/renderer/navigation/chatSession.ts
Normal 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 || '',
|
||||
};
|
||||
}
|
||||
24
src/renderer/navigation/chatSurfaceMode.ts
Normal file
24
src/renderer/navigation/chatSurfaceMode.ts
Normal 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];
|
||||
}
|
||||
56
src/renderer/navigation/useChatMessageSender.ts
Normal file
56
src/renderer/navigation/useChatMessageSender.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
127
src/renderer/navigation/useChatSurfaceState.ts
Normal file
127
src/renderer/navigation/useChatSurfaceState.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user