Compare commits
2 Commits
038cd48285
...
e9347c5e5a
| Author | SHA1 | Date | |
|---|---|---|---|
| e9347c5e5a | |||
| 3bc8ad32cd |
@@ -83,7 +83,6 @@ export async function orchestrate(input: OrchestrateInput): Promise<OrchestrateR
|
||||
const { requestId, promise } = client.sendHomeRequest(message, conversationHistory, {
|
||||
onStart: () => sendFrame(sender, { type: 'stream_start', requestId }),
|
||||
onText: (chunk) => sendFrame(sender, { type: 'stream_text', requestId, chunk }),
|
||||
onBlock: (blockType, data) => sendFrame(sender, { type: 'stream_block', requestId, blockType, data }),
|
||||
onEnd: (mutations) => sendFrame(sender, { type: 'stream_end', requestId, mutations: mutations as unknown[] | undefined }),
|
||||
onError: () => sendFrame(sender, { type: 'stream_end', requestId }),
|
||||
});
|
||||
@@ -120,7 +119,6 @@ export async function orchestrateFloating(input: OrchestrateFloatingInput): Prom
|
||||
const { requestId, promise } = client.sendFloatingRequest(message, scope, {
|
||||
onStart: () => sendFrame(sender, { type: 'stream_start', requestId }),
|
||||
onText: (chunk) => sendFrame(sender, { type: 'stream_text', requestId, chunk }),
|
||||
onBlock: (blockType, data) => sendFrame(sender, { type: 'stream_block', requestId, blockType, data }),
|
||||
onEnd: (mutations) => sendFrame(sender, { type: 'stream_end', requestId, mutations: mutations as unknown[] | undefined }),
|
||||
onDomain: (domain) => sendFrame(sender, { type: 'floating_domain', requestId, domain }),
|
||||
onError: () => sendFrame(sender, { type: 'stream_end', requestId }),
|
||||
@@ -187,7 +185,6 @@ export async function generateAndCacheBrief(): Promise<void> {
|
||||
const { promise } = client.sendHomeRequest(DAILY_BRIEF_PROMPT, undefined, {
|
||||
onStart: () => {},
|
||||
onText: (chunk) => { content += chunk; },
|
||||
onBlock: () => {},
|
||||
onEnd: () => {},
|
||||
onError: () => {},
|
||||
});
|
||||
@@ -223,7 +220,6 @@ export async function dailyBrief(sender?: Electron.WebContents): Promise<Orchest
|
||||
content += chunk;
|
||||
sendFrame(sender, { type: 'stream_text', requestId, chunk });
|
||||
},
|
||||
onBlock: () => {},
|
||||
onEnd: () => sendFrame(sender, { type: 'stream_end', requestId }),
|
||||
onError: () => sendFrame(sender, { type: 'stream_end', requestId }),
|
||||
});
|
||||
|
||||
@@ -122,7 +122,6 @@ export class ServerError extends Error {
|
||||
interface StreamListener {
|
||||
onStart: () => void;
|
||||
onText: (chunk: string) => void;
|
||||
onBlock: (blockType: string, data: Record<string, unknown>) => void;
|
||||
onEnd: (mutations?: unknown) => void;
|
||||
onDomain: (domain: string) => void;
|
||||
onError: (err: Error) => void;
|
||||
@@ -241,7 +240,6 @@ export class BackendClient {
|
||||
this.streamListeners.set(requestId, {
|
||||
onStart: callbacks?.onStart ?? ((): void => { /* no-op */ }),
|
||||
onText: callbacks?.onText ?? ((): void => { /* no-op */ }),
|
||||
onBlock: callbacks?.onBlock ?? ((): void => { /* no-op */ }),
|
||||
onEnd: (mutations) => {
|
||||
callbacks?.onEnd?.(mutations);
|
||||
this.streamListeners.delete(requestId);
|
||||
@@ -286,7 +284,6 @@ export class BackendClient {
|
||||
this.streamListeners.set(requestId, {
|
||||
onStart: callbacks?.onStart ?? ((): void => { /* no-op */ }),
|
||||
onText: callbacks?.onText ?? ((): void => { /* no-op */ }),
|
||||
onBlock: callbacks?.onBlock ?? ((): void => { /* no-op */ }),
|
||||
onEnd: (mutations) => {
|
||||
callbacks?.onEnd?.(mutations);
|
||||
this.streamListeners.delete(requestId);
|
||||
@@ -674,12 +671,6 @@ export class BackendClient {
|
||||
break;
|
||||
}
|
||||
|
||||
case 'stream_block': {
|
||||
const listener = this.streamListeners.get(frame.data.requestId);
|
||||
listener?.onBlock(frame.data.blockType, frame.data.data);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'stream_end': {
|
||||
const listener = this.streamListeners.get(frame.data.requestId);
|
||||
listener?.onEnd(frame.data.mutations as unknown);
|
||||
|
||||
@@ -28,6 +28,8 @@ const TABLE_REGISTRY = {
|
||||
notes,
|
||||
taskComments,
|
||||
timelineEvents,
|
||||
// Alias: the backend sends "timelines" as the table name
|
||||
timelines: timelineEvents,
|
||||
} as const;
|
||||
|
||||
type TableName = keyof typeof TABLE_REGISTRY;
|
||||
|
||||
@@ -601,7 +601,7 @@ const aiRouter = router({
|
||||
})).optional(),
|
||||
mode: z.enum(['home', 'floating']).optional(),
|
||||
scope: z.object({
|
||||
type: z.enum(['task', 'project', 'note', 'timelineEvent']),
|
||||
type: z.enum(['task', 'project', 'note', 'timeline']),
|
||||
id: z.string().optional(),
|
||||
}).optional(),
|
||||
}))
|
||||
|
||||
@@ -25,9 +25,8 @@ const AI_STREAM_CHANNEL = 'ai:stream';
|
||||
type V3StreamEvent =
|
||||
| { type: 'stream_start'; requestId: string }
|
||||
| { type: 'stream_text'; requestId: string; chunk: string }
|
||||
| { type: 'stream_block'; requestId: string; blockType: 'chart' | 'entity_ref' | 'table' | 'timeline'; data: Record<string, unknown> }
|
||||
| { type: 'stream_end'; requestId: string; mutations?: unknown[] }
|
||||
| { type: 'floating_domain'; requestId: string; domain: 'tasks' | 'notes' | 'timelineEvents' | 'projects' };
|
||||
| { type: 'floating_domain'; requestId: string; domain: 'tasks' | 'notes' | 'timelines' | 'projects' };
|
||||
|
||||
contextBridge.exposeInMainWorld('electronAI', {
|
||||
/** Subscribe to v3 AI stream events. Returns an unsubscribe function. */
|
||||
|
||||
@@ -10,10 +10,101 @@ import { Button } from '@/components/ui/button';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { GradualBlur } from '@/components/ui/gradual-blur';
|
||||
import { BlockRenderer } from './blocks';
|
||||
import { ChatEntityBlock } from './blocks/ChatEntityBlock';
|
||||
import { ChatChartBlock } from './blocks/ChatChartBlock';
|
||||
import type { EntityRefBlockData, ChartBlockData } from '../../../shared/api-types';
|
||||
|
||||
/** Fluid font size for chat messages — scales with viewport width */
|
||||
const CHAT_FONT = 'clamp(0.9rem, 1.2vw, 1.20rem)';
|
||||
const CHAT_FONT = '1rem';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Inline tag parsing (entities + charts)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Matches entity tags in both formats:
|
||||
* - <task>[id1,id2]</task>
|
||||
* - <timeline>id1,id2</timeline>
|
||||
*/
|
||||
const ENTITY_TAG_RE = /<(?<entity>task|project|note|timeline|timelineEvent)>(?:\[(?<bracketIds>[^\]]+)\]|(?<plainIds>[^<]+))<\/\k<entity>>/;
|
||||
/** Matches chart tags: <chart>{...JSON...}</chart> */
|
||||
const CHART_TAG_RE = /<chart>(?<chartJson>\{[\s\S]*?\})<\/chart>/;
|
||||
/** Combined: matches the first occurrence of either tag */
|
||||
const INLINE_TAG_RE = new RegExp(`${ENTITY_TAG_RE.source}|${CHART_TAG_RE.source}`);
|
||||
|
||||
type ContentSegment =
|
||||
| { type: 'text'; content: string }
|
||||
| { type: 'entity'; entity: EntityRefBlockData['entity']; ids: string[] }
|
||||
| { type: 'chart'; data: ChartBlockData };
|
||||
|
||||
function parseInlineTags(content: string): ContentSegment[] {
|
||||
const segments: ContentSegment[] = [];
|
||||
let remaining = content;
|
||||
|
||||
while (remaining) {
|
||||
const match = INLINE_TAG_RE.exec(remaining);
|
||||
if (!match) {
|
||||
segments.push({ type: 'text', content: remaining });
|
||||
break;
|
||||
}
|
||||
|
||||
const before = remaining.slice(0, match.index);
|
||||
if (before) segments.push({ type: 'text', content: before });
|
||||
|
||||
const groups = match.groups ?? {};
|
||||
|
||||
if (groups.entity) {
|
||||
const entity = groups.entity as EntityRefBlockData['entity'];
|
||||
const rawIds = groups.bracketIds ?? groups.plainIds ?? '';
|
||||
const ids = rawIds.split(',').map((id) => id.trim()).filter(Boolean);
|
||||
segments.push({ type: 'entity', entity, ids });
|
||||
} else if (groups.chartJson) {
|
||||
try {
|
||||
const chartData = JSON.parse(groups.chartJson) as ChartBlockData;
|
||||
segments.push({ type: 'chart', data: chartData });
|
||||
} catch {
|
||||
// Malformed JSON — keep as text
|
||||
segments.push({ type: 'text', content: match[0] });
|
||||
}
|
||||
}
|
||||
|
||||
const matchIndex = typeof match.index === 'number' ? match.index : 0;
|
||||
remaining = remaining.slice(matchIndex + match[0].length);
|
||||
}
|
||||
|
||||
return segments;
|
||||
}
|
||||
|
||||
function hasInlineTags(content: string): boolean {
|
||||
return INLINE_TAG_RE.test(content);
|
||||
}
|
||||
|
||||
function mergeTimelineSegments(segments: ContentSegment[]): ContentSegment[] {
|
||||
const allTimelineIds: string[] = [];
|
||||
for (const seg of segments) {
|
||||
if (seg.type === 'entity' && (seg.entity === 'timeline' || seg.entity === 'timelineEvent')) {
|
||||
allTimelineIds.push(...seg.ids);
|
||||
}
|
||||
}
|
||||
|
||||
const uniqueTimelineIds = [...new Set(allTimelineIds)];
|
||||
if (!uniqueTimelineIds.length) return segments;
|
||||
|
||||
const merged: ContentSegment[] = [];
|
||||
|
||||
for (const seg of segments) {
|
||||
if (seg.type === 'entity' && (seg.entity === 'timeline' || seg.entity === 'timelineEvent')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
merged.push(seg);
|
||||
}
|
||||
|
||||
// Keep prose flow untouched, then append a single consolidated timeline block.
|
||||
merged.push({ type: 'entity', entity: 'timeline', ids: uniqueTimelineIds });
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
const SUGGESTION_CHIPS = [
|
||||
{ icon: ListTodo, label: "What's on my plate today?" },
|
||||
@@ -78,7 +169,6 @@ export function AIChatPanel({
|
||||
setInput,
|
||||
isStreaming,
|
||||
streamingContent,
|
||||
streamingBlocks,
|
||||
handleSend: chatHandleSend,
|
||||
} = useAIChat(chatContext);
|
||||
|
||||
@@ -425,16 +515,13 @@ export function AIChatPanel({
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={msg.id} className={`mr-auto ${msg.blocks.length ? 'w-full' : 'max-w-[75%]'}`}>
|
||||
<div key={msg.id} className={`mr-auto ${hasInlineTags(msg.content) ? 'w-full' : 'max-w-[75%]'}`}>
|
||||
<div className="flex items-center gap-1.5 mb-1">
|
||||
<Sparkles size={16} className="text-foreground" />
|
||||
<span style={{ fontSize: CHAT_FONT }} className="font-semibold">Adiuva</span>
|
||||
</div>
|
||||
<div className="pl-[22px] flex flex-col gap-3">
|
||||
<ChatMarkdown content={msg.content} fontSize={CHAT_FONT} />
|
||||
{msg.blocks.map((block) => (
|
||||
<BlockRenderer key={block.id} block={block} />
|
||||
))}
|
||||
<MessageContent content={msg.content} fontSize={CHAT_FONT} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -442,17 +529,14 @@ export function AIChatPanel({
|
||||
|
||||
{/* Streaming AI response */}
|
||||
{isStreaming && (
|
||||
<div ref={setStreamingEl} className={`mr-auto ${streamingBlocks.length ? 'w-full' : 'max-w-[75%]'}`}>
|
||||
<div ref={setStreamingEl} className={`mr-auto ${hasInlineTags(streamingContent) ? 'w-full' : 'max-w-[75%]'}`}>
|
||||
<div className="flex items-center gap-1.5 mb-1">
|
||||
<Sparkles size={16} className="text-foreground" />
|
||||
<span style={{ fontSize: CHAT_FONT }} className="font-semibold">Adiuva</span>
|
||||
</div>
|
||||
{streamingContent ? (
|
||||
<div className="pl-[22px] flex flex-col gap-3">
|
||||
<ChatMarkdown content={streamingContent} fontSize={CHAT_FONT} />
|
||||
{streamingBlocks.map((block) => (
|
||||
<BlockRenderer key={block.id} block={block} />
|
||||
))}
|
||||
<MessageContent content={streamingContent} fontSize={CHAT_FONT} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2 pl-[22px]">
|
||||
@@ -500,6 +584,48 @@ export function AIChatPanel({
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------- MessageContent: text with inline entity blocks ---------- */
|
||||
|
||||
function MessageContent({ content, fontSize }: { content: string; fontSize?: string }) {
|
||||
const segments = mergeTimelineSegments(parseInlineTags(content));
|
||||
|
||||
// Fast path: no inline tags, just render markdown
|
||||
if (segments.length === 1 && segments[0]?.type === 'text') {
|
||||
return <ChatMarkdown content={content} fontSize={fontSize} />;
|
||||
}
|
||||
|
||||
// No content at all
|
||||
if (segments.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
{segments.map((seg, i) => {
|
||||
if (seg.type === 'text') {
|
||||
return <ChatMarkdown key={i} content={seg.content} fontSize={fontSize} />;
|
||||
}
|
||||
if (seg.type === 'chart') {
|
||||
return (
|
||||
<motion.div key={i} {...blockAnimation}>
|
||||
<ChatChartBlock data={seg.data} />
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<motion.div key={i} {...blockAnimation}>
|
||||
<ChatEntityBlock data={{ entity: seg.entity, ids: seg.ids }} />
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const blockAnimation = {
|
||||
initial: { opacity: 0, scale: 0.95 },
|
||||
animate: { opacity: 1, scale: 1 },
|
||||
transition: { type: 'spring' as const, stiffness: 400, damping: 30 },
|
||||
};
|
||||
|
||||
/* ---------- ChatInput: Floating glass card ---------- */
|
||||
|
||||
interface ChatInputProps {
|
||||
|
||||
@@ -18,7 +18,7 @@ import { Skeleton } from '@/components/ui/skeleton';
|
||||
const DOMAIN_ROUTES: Record<string, string> = {
|
||||
tasks: '/tasks',
|
||||
notes: '/notes',
|
||||
timelineEvents: '/timeline',
|
||||
timelines: '/timeline',
|
||||
projects: '/projects',
|
||||
};
|
||||
|
||||
@@ -40,8 +40,8 @@ function FloatingChatInner() {
|
||||
: activeSection.label?.toLowerCase().includes('note')
|
||||
? 'note'
|
||||
: activeSection.label?.toLowerCase().includes('timeline')
|
||||
? 'timelineEvent'
|
||||
: 'project') as 'task' | 'project' | 'note' | 'timelineEvent',
|
||||
? 'timeline'
|
||||
: 'project') as 'task' | 'project' | 'note' | 'timeline',
|
||||
id: activeSection.projectId,
|
||||
}
|
||||
: undefined;
|
||||
@@ -55,7 +55,7 @@ function FloatingChatInner() {
|
||||
|
||||
// Handle floating_domain signals — navigate in background
|
||||
const handleDomainSignal = useCallback(
|
||||
(domain: 'tasks' | 'notes' | 'timelineEvents' | 'projects') => {
|
||||
(domain: 'tasks' | 'notes' | 'timelines' | 'projects') => {
|
||||
const route = DOMAIN_ROUTES[domain];
|
||||
if (!route) return;
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import { TaskRow, type TaskItem } from '@/components/tasks/TaskRow';
|
||||
import { TaskDetailDialog } from '@/components/tasks/TaskDetailDialog';
|
||||
import { EditTaskDialog } from '@/components/tasks/EditTaskDialog';
|
||||
import { Item, ItemMedia, ItemContent, ItemTitle, ItemDescription } from '@/components/ui/item';
|
||||
import { ChatTimelineBlock } from './ChatTimelineBlock';
|
||||
import type { EntityRefBlockData } from '../../../../../shared/api-types';
|
||||
|
||||
export function ChatEntityBlock({ data }: { data: EntityRefBlockData }) {
|
||||
@@ -18,6 +19,8 @@ export function ChatEntityBlock({ data }: { data: EntityRefBlockData }) {
|
||||
return <ProjectEntityBlock ids={ids} />;
|
||||
case 'note':
|
||||
return <NoteEntityBlock ids={ids} />;
|
||||
case 'timeline':
|
||||
return <TimelineEntityBlock ids={ids} />;
|
||||
case 'timelineEvent':
|
||||
return <TimelineEventEntityBlock ids={ids} />;
|
||||
default:
|
||||
@@ -169,6 +172,36 @@ function NoteEntityBlock({ ids }: { ids: string[] }) {
|
||||
// Timeline Events
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function TimelineEntityBlock({ ids }: { ids: string[] }) {
|
||||
const { data: allEvents } = trpc.timelineEvents.list.useQuery();
|
||||
|
||||
const timelineData = useMemo(() => {
|
||||
const filtered = allEvents?.filter((e) => ids.includes(e.id)) ?? [];
|
||||
|
||||
return {
|
||||
events: filtered
|
||||
.map((e) => {
|
||||
const date = new Date(e.date).getTime();
|
||||
const endDate = e.endDate ? new Date(e.endDate).getTime() : undefined;
|
||||
return {
|
||||
id: e.id,
|
||||
title: e.title,
|
||||
date,
|
||||
endDate,
|
||||
projectId: e.projectId,
|
||||
isCompleted: e.isCompleted,
|
||||
isAiSuggested: e.isAiSuggested,
|
||||
};
|
||||
})
|
||||
.filter((e) => Number.isFinite(e.date)),
|
||||
};
|
||||
}, [allEvents, ids]);
|
||||
|
||||
if (!timelineData.events.length) return null;
|
||||
|
||||
return <ChatTimelineBlock data={timelineData} />;
|
||||
}
|
||||
|
||||
function TimelineEventEntityBlock({ ids }: { ids: string[] }) {
|
||||
const { data: allEvents } = trpc.timelineEvents.list.useQuery();
|
||||
|
||||
|
||||
@@ -1,27 +1,55 @@
|
||||
import { format } from 'date-fns';
|
||||
import { useMemo } from 'react';
|
||||
import { trpc } from '@/lib/trpc';
|
||||
import { ProjectTimelineBox, type ProjectGroup } from '@/components/timeline/ProjectTimelineBox';
|
||||
import type { TimelineEvent } from '@/components/timeline/ProjectTimeline';
|
||||
import type { TimelineBlockData } from '../../../../../shared/api-types';
|
||||
|
||||
export function ChatTimelineBlock({ data }: { data: TimelineBlockData }) {
|
||||
const { events } = data;
|
||||
const { events: rawEvents } = data;
|
||||
|
||||
const { data: allProjects } = trpc.projects.list.useQuery({ includeArchived: true });
|
||||
|
||||
const events = useMemo<TimelineEvent[]>(() => {
|
||||
return rawEvents
|
||||
.map((event) => ({
|
||||
id: event.id,
|
||||
title: event.title,
|
||||
date: event.date,
|
||||
endDate: event.endDate ?? null,
|
||||
projectId: event.projectId ?? null,
|
||||
isCompleted: event.isCompleted ?? 0,
|
||||
isAiSuggested: event.isAiSuggested ?? 0,
|
||||
}))
|
||||
.filter((event) => Number.isFinite(event.date));
|
||||
}, [rawEvents]);
|
||||
|
||||
if (!events.length) return null;
|
||||
|
||||
const sorted = [...events].sort((a, b) => a.date - b.date);
|
||||
const projectIds = [...new Set(events.map((event) => event.projectId).filter((id): id is string => !!id))];
|
||||
|
||||
const singleProject = projectIds.length === 1
|
||||
? allProjects?.find((project) => project.id === projectIds[0])
|
||||
: undefined;
|
||||
|
||||
const dates = events.flatMap((event) => (event.endDate ? [event.date, event.endDate] : [event.date]));
|
||||
const now = Date.now();
|
||||
const minDate = Math.min(...dates, now);
|
||||
const maxDate = Math.max(...dates, now);
|
||||
const PAD_MS = 3 * 24 * 60 * 60 * 1000;
|
||||
|
||||
const group: ProjectGroup = {
|
||||
projectId: singleProject?.id ?? null,
|
||||
projectName: singleProject?.name ?? 'Timeline',
|
||||
projectStatus: singleProject?.status ?? 'active',
|
||||
breadcrumb: [],
|
||||
events,
|
||||
startDate: new Date(minDate - PAD_MS),
|
||||
endDate: new Date(maxDate + PAD_MS),
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-border bg-card p-4">
|
||||
<div className="relative ml-3 border-l border-border pl-6">
|
||||
{sorted.map((ev, i) => (
|
||||
<div key={ev.id} className={i < sorted.length - 1 ? 'pb-5' : ''}>
|
||||
<div className="absolute -left-[5px] mt-1.5 h-2.5 w-2.5 rounded-full bg-primary" />
|
||||
<p className="text-sm font-medium leading-tight">{ev.title}</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{format(new Date(ev.date), 'MMM d, yyyy')}
|
||||
{ev.endDate && ` — ${format(new Date(ev.endDate), 'MMM d, yyyy')}`}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="w-full">
|
||||
<ProjectTimelineBox group={group} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,41 +1 @@
|
||||
import { motion } from 'framer-motion';
|
||||
import type { StreamBlock } from '@/hooks/useAIChat';
|
||||
import type {
|
||||
ChartBlockData,
|
||||
EntityRefBlockData,
|
||||
TableBlockData,
|
||||
TimelineBlockData,
|
||||
} from '../../../../../shared/api-types';
|
||||
import { ChatChartBlock } from './ChatChartBlock';
|
||||
import { ChatEntityBlock } from './ChatEntityBlock';
|
||||
import { ChatTableBlock } from './ChatTableBlock';
|
||||
import { ChatTimelineBlock } from './ChatTimelineBlock';
|
||||
|
||||
const blockAnimation = {
|
||||
initial: { opacity: 0, scale: 0.95 },
|
||||
animate: { opacity: 1, scale: 1 },
|
||||
transition: { type: 'spring' as const, stiffness: 400, damping: 30 },
|
||||
};
|
||||
|
||||
export function BlockRenderer({ block }: { block: StreamBlock }) {
|
||||
return (
|
||||
<motion.div {...blockAnimation}>
|
||||
{renderBlock(block)}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
function renderBlock(block: StreamBlock) {
|
||||
switch (block.blockType) {
|
||||
case 'chart':
|
||||
return <ChatChartBlock data={block.data as unknown as ChartBlockData} />;
|
||||
case 'entity_ref':
|
||||
return <ChatEntityBlock data={block.data as unknown as EntityRefBlockData} />;
|
||||
case 'table':
|
||||
return <ChatTableBlock data={block.data as unknown as TableBlockData} />;
|
||||
case 'timeline':
|
||||
return <ChatTimelineBlock data={block.data as unknown as TimelineBlockData} />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
/**\n * Block components are now rendered inline by the MessageContent parser\n * in AIChatPanel.tsx. Import individual block components directly:\n * - ChatEntityBlock\n * - ChatChartBlock\n * - ChatTableBlock\n * - ChatTimelineBlock\n */\nexport { ChatEntityBlock } from './ChatEntityBlock';\nexport { ChatChartBlock } from './ChatChartBlock';\nexport { ChatTableBlock } from './ChatTableBlock';\nexport { ChatTimelineBlock } from './ChatTimelineBlock';
|
||||
|
||||
@@ -14,22 +14,15 @@ export interface UIChatContext {
|
||||
projectId?: string;
|
||||
/** For floating mode — the entity scope to pass to the backend. */
|
||||
scope?: {
|
||||
type: 'task' | 'project' | 'note' | 'timelineEvent';
|
||||
type: 'task' | 'project' | 'note' | 'timeline';
|
||||
id?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface StreamBlock {
|
||||
id: string;
|
||||
blockType: 'chart' | 'entity_ref' | 'table' | 'timeline';
|
||||
data: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface ChatMessage {
|
||||
id: string;
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
blocks: StreamBlock[];
|
||||
error?: boolean;
|
||||
}
|
||||
|
||||
@@ -39,29 +32,29 @@ interface UseAIChatReturn {
|
||||
setInput: (v: string) => void;
|
||||
isStreaming: boolean;
|
||||
streamingContent: string;
|
||||
streamingBlocks: StreamBlock[];
|
||||
handleSend: (overrideMessage?: string, overrideContext?: UIChatContext) => void;
|
||||
clearMessages: () => void;
|
||||
}
|
||||
|
||||
interface UseAIChatOptions {
|
||||
onDomainSignal?: (domain: 'tasks' | 'notes' | 'timelineEvents' | 'projects') => void;
|
||||
onDomainSignal?: (domain: 'tasks' | 'notes' | 'timelines' | 'projects') => void;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const TABLE_TO_ENTITY: Record<string, 'task' | 'project' | 'note' | 'timelineEvent'> = {
|
||||
const TABLE_TO_ENTITY: Record<string, 'task' | 'project' | 'note' | 'timeline'> = {
|
||||
tasks: 'task',
|
||||
projects: 'project',
|
||||
notes: 'note',
|
||||
timelineEvents: 'timelineEvent',
|
||||
timelines: 'timeline',
|
||||
timelineEvents: 'timeline',
|
||||
};
|
||||
|
||||
function parseMutationsToBlocks(mutations: unknown[] | undefined): StreamBlock[] {
|
||||
if (!Array.isArray(mutations)) return [];
|
||||
const blocks: StreamBlock[] = [];
|
||||
function parseMutationsToEntityTags(mutations: unknown[] | undefined): string {
|
||||
if (!Array.isArray(mutations)) return '';
|
||||
const tags: string[] = [];
|
||||
for (const m of mutations) {
|
||||
if (!m || typeof m !== 'object') continue;
|
||||
const mut = m as Record<string, unknown>;
|
||||
@@ -73,9 +66,9 @@ function parseMutationsToBlocks(mutations: unknown[] | undefined): StreamBlock[]
|
||||
if (!rows.length) continue;
|
||||
const ids = rows.map((r) => String(r.id ?? '')).filter(Boolean);
|
||||
if (!ids.length) continue;
|
||||
blocks.push({ id: crypto.randomUUID(), blockType: 'entity_ref', data: { entity, ids } });
|
||||
tags.push(`<${entity}>[${ids.join(',')}]</${entity}>`);
|
||||
}
|
||||
return blocks;
|
||||
return tags.length ? '\n' + tags.join('\n') : '';
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -87,18 +80,14 @@ export function useAIChat(defaultContext: UIChatContext, options?: UseAIChatOpti
|
||||
const [input, setInput] = useState('');
|
||||
const [isStreaming, setIsStreaming] = useState(false);
|
||||
const [streamingContent, setStreamingContent] = useState('');
|
||||
const [streamingBlocks, setStreamingBlocks] = useState<StreamBlock[]>([]);
|
||||
|
||||
const streamingContentRef = useRef('');
|
||||
const streamingBlocksRef = useRef<StreamBlock[]>([]);
|
||||
const chatMutation = trpc.ai.chat.useMutation();
|
||||
|
||||
const clearMessages = useCallback(() => {
|
||||
setMessages([]);
|
||||
setStreamingContent('');
|
||||
setStreamingBlocks([]);
|
||||
streamingContentRef.current = '';
|
||||
streamingBlocksRef.current = [];
|
||||
}, []);
|
||||
|
||||
const handleSend = useCallback(
|
||||
@@ -112,16 +101,13 @@ export function useAIChat(defaultContext: UIChatContext, options?: UseAIChatOpti
|
||||
id: crypto.randomUUID(),
|
||||
role: 'user',
|
||||
content: trimmed,
|
||||
blocks: [],
|
||||
};
|
||||
|
||||
setMessages((prev) => [...prev, userMsg]);
|
||||
if (!overrideMessage) setInput('');
|
||||
setIsStreaming(true);
|
||||
setStreamingContent('');
|
||||
setStreamingBlocks([]);
|
||||
streamingContentRef.current = '';
|
||||
streamingBlocksRef.current = [];
|
||||
|
||||
// Capture the requestId from stream_start so we only handle events
|
||||
// for this specific chat request (avoids cross-contamination with
|
||||
@@ -146,21 +132,9 @@ export function useAIChat(defaultContext: UIChatContext, options?: UseAIChatOpti
|
||||
setStreamingContent(streamingContentRef.current);
|
||||
break;
|
||||
|
||||
case 'stream_block': {
|
||||
const block: StreamBlock = {
|
||||
id: crypto.randomUUID(),
|
||||
blockType: event.blockType,
|
||||
data: event.data,
|
||||
};
|
||||
streamingBlocksRef.current = [...streamingBlocksRef.current, block];
|
||||
setStreamingBlocks([...streamingBlocksRef.current]);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'stream_end': {
|
||||
const finalContent = streamingContentRef.current;
|
||||
const mutationBlocks = parseMutationsToBlocks(event.mutations);
|
||||
const finalBlocks = [...streamingBlocksRef.current, ...mutationBlocks];
|
||||
const mutationTags = parseMutationsToEntityTags(event.mutations);
|
||||
const finalContent = streamingContentRef.current + mutationTags;
|
||||
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
@@ -168,13 +142,10 @@ export function useAIChat(defaultContext: UIChatContext, options?: UseAIChatOpti
|
||||
id: crypto.randomUUID(),
|
||||
role: 'assistant',
|
||||
content: finalContent,
|
||||
blocks: finalBlocks,
|
||||
},
|
||||
]);
|
||||
setStreamingContent('');
|
||||
setStreamingBlocks([]);
|
||||
streamingContentRef.current = '';
|
||||
streamingBlocksRef.current = [];
|
||||
setIsStreaming(false);
|
||||
unsubscribe();
|
||||
break;
|
||||
@@ -208,12 +179,10 @@ export function useAIChat(defaultContext: UIChatContext, options?: UseAIChatOpti
|
||||
unsubscribe();
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{ id: crypto.randomUUID(), role: 'assistant', content: data.error!, blocks: [], error: true },
|
||||
{ id: crypto.randomUUID(), role: 'assistant', content: data.error!, error: true },
|
||||
]);
|
||||
setStreamingContent('');
|
||||
setStreamingBlocks([]);
|
||||
streamingContentRef.current = '';
|
||||
streamingBlocksRef.current = [];
|
||||
setIsStreaming(false);
|
||||
} else {
|
||||
// Safety fallback: the tRPC response always arrives after the
|
||||
@@ -222,21 +191,18 @@ export function useAIChat(defaultContext: UIChatContext, options?: UseAIChatOpti
|
||||
// React StrictMode, or requestId mismatch), this ensures
|
||||
// isStreaming is reset.
|
||||
unsubscribe();
|
||||
if (streamingContentRef.current || streamingBlocksRef.current.length) {
|
||||
if (streamingContentRef.current) {
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
role: 'assistant',
|
||||
content: streamingContentRef.current,
|
||||
blocks: streamingBlocksRef.current,
|
||||
},
|
||||
]);
|
||||
}
|
||||
setStreamingContent('');
|
||||
setStreamingBlocks([]);
|
||||
streamingContentRef.current = '';
|
||||
streamingBlocksRef.current = [];
|
||||
setIsStreaming(false);
|
||||
}
|
||||
},
|
||||
@@ -248,14 +214,11 @@ export function useAIChat(defaultContext: UIChatContext, options?: UseAIChatOpti
|
||||
id: crypto.randomUUID(),
|
||||
role: 'assistant',
|
||||
content: err.message || 'An unexpected error occurred.',
|
||||
blocks: [],
|
||||
error: true,
|
||||
},
|
||||
]);
|
||||
setStreamingContent('');
|
||||
setStreamingBlocks([]);
|
||||
streamingContentRef.current = '';
|
||||
streamingBlocksRef.current = [];
|
||||
setIsStreaming(false);
|
||||
},
|
||||
},
|
||||
@@ -270,7 +233,6 @@ export function useAIChat(defaultContext: UIChatContext, options?: UseAIChatOpti
|
||||
setInput,
|
||||
isStreaming,
|
||||
streamingContent,
|
||||
streamingBlocks,
|
||||
handleSend,
|
||||
clearMessages,
|
||||
};
|
||||
|
||||
@@ -16,9 +16,8 @@ interface ElectronTRPC {
|
||||
type V3StreamEvent =
|
||||
| { type: 'stream_start'; requestId: string }
|
||||
| { type: 'stream_text'; requestId: string; chunk: string }
|
||||
| { type: 'stream_block'; requestId: string; blockType: 'chart' | 'entity_ref' | 'table' | 'timeline'; data: Record<string, unknown> }
|
||||
| { type: 'stream_end'; requestId: string; mutations?: unknown[] }
|
||||
| { type: 'floating_domain'; requestId: string; domain: 'tasks' | 'notes' | 'timelineEvents' | 'projects' };
|
||||
| { type: 'floating_domain'; requestId: string; domain: 'tasks' | 'notes' | 'timelines' | 'projects' };
|
||||
|
||||
interface ElectronAI {
|
||||
onStreamEvent: (cb: (data: V3StreamEvent) => void) => () => void;
|
||||
|
||||
@@ -116,7 +116,7 @@ export const WsFloatingRequestSchema = z.object({
|
||||
type: z.literal('floating_request'),
|
||||
message: z.string(),
|
||||
scope: z.object({
|
||||
type: z.enum(['task', 'project', 'note', 'timelineEvent']),
|
||||
type: z.enum(['task', 'project', 'note', 'timeline']),
|
||||
id: z.string().optional(),
|
||||
}),
|
||||
});
|
||||
@@ -186,14 +186,6 @@ export const WsStreamTextSchema = z.object({
|
||||
});
|
||||
export type WsStreamText = z.infer<typeof WsStreamTextSchema>;
|
||||
|
||||
export const WsStreamBlockSchema = z.object({
|
||||
type: z.literal('stream_block'),
|
||||
requestId: z.string(),
|
||||
blockType: z.enum(['chart', 'entity_ref', 'table', 'timeline']),
|
||||
data: z.record(z.string(), z.unknown()),
|
||||
});
|
||||
export type WsStreamBlock = z.infer<typeof WsStreamBlockSchema>;
|
||||
|
||||
export const WsStreamEndSchema = z.object({
|
||||
type: z.literal('stream_end'),
|
||||
requestId: z.string(),
|
||||
@@ -207,7 +199,7 @@ export type WsStreamEnd = z.infer<typeof WsStreamEndSchema>;
|
||||
export const WsFloatingDomainSchema = z.object({
|
||||
type: z.literal('floating_domain'),
|
||||
requestId: z.string(),
|
||||
domain: z.enum(['tasks', 'notes', 'timelineEvents', 'projects']),
|
||||
domain: z.enum(['tasks', 'notes', 'timelines', 'projects']),
|
||||
});
|
||||
export type WsFloatingDomain = z.infer<typeof WsFloatingDomainSchema>;
|
||||
|
||||
@@ -221,7 +213,7 @@ export interface ChartBlockData {
|
||||
}
|
||||
|
||||
export interface EntityRefBlockData {
|
||||
entity: 'task' | 'project' | 'note' | 'timelineEvent';
|
||||
entity: 'task' | 'project' | 'note' | 'timeline' | 'timelineEvent';
|
||||
/** Live IDs — component fetches real data from local DB. */
|
||||
ids: string[];
|
||||
/** @deprecated Legacy inline items — prefer `ids` for live data. */
|
||||
@@ -234,7 +226,15 @@ export interface TableBlockData {
|
||||
}
|
||||
|
||||
export interface TimelineBlockData {
|
||||
events: { id: string; title: string; date: number; endDate?: number }[];
|
||||
events: {
|
||||
id: string;
|
||||
title: string;
|
||||
date: number;
|
||||
endDate?: number;
|
||||
projectId?: string | null;
|
||||
isCompleted?: number;
|
||||
isAiSuggested?: number;
|
||||
}[];
|
||||
}
|
||||
|
||||
export const WsServerFrameSchema = z.discriminatedUnion('type', [
|
||||
@@ -243,7 +243,6 @@ export const WsServerFrameSchema = z.discriminatedUnion('type', [
|
||||
WsAgentRunSchema,
|
||||
WsStreamStartSchema,
|
||||
WsStreamTextSchema,
|
||||
WsStreamBlockSchema,
|
||||
WsStreamEndSchema,
|
||||
WsFloatingDomainSchema,
|
||||
]);
|
||||
|
||||
Reference in New Issue
Block a user