Compare commits
3 Commits
e9347c5e5a
...
3bc08c6de7
| Author | SHA1 | Date | |
|---|---|---|---|
| 3bc08c6de7 | |||
| 8fe2b1c43e | |||
| 43cfb694e7 |
@@ -21,18 +21,24 @@ import type { WsFloatingRequest } from '../../shared/api-types';
|
||||
|
||||
const AI_STREAM_CHANNEL = 'ai:stream';
|
||||
|
||||
const BRIEF_SCHEDULER_INTERVAL_MS = 60_000;
|
||||
const BRIEF_REGEN_DEBOUNCE_MS = 1_500;
|
||||
const NOOP = () => undefined;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface OrchestrateInput {
|
||||
message: string;
|
||||
requestId?: string;
|
||||
conversationHistory?: Array<{ role: 'user' | 'assistant' | 'system'; content: string }>;
|
||||
sender?: Electron.WebContents;
|
||||
}
|
||||
|
||||
interface OrchestrateFloatingInput {
|
||||
message: string;
|
||||
requestId?: string;
|
||||
scope: WsFloatingRequest['scope'];
|
||||
sender?: Electron.WebContents;
|
||||
}
|
||||
@@ -42,6 +48,10 @@ interface OrchestrateResult {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
let briefSchedulerTimer: ReturnType<typeof setInterval> | null = null;
|
||||
let briefRegenerationTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let lastGeneratedSlotKey: string | null = null;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// IPC helper
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -73,18 +83,18 @@ async function checkConnectivity(): Promise<{ ok: true } | { ok: false; error: s
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function orchestrate(input: OrchestrateInput): Promise<OrchestrateResult> {
|
||||
const { message, conversationHistory, sender } = input;
|
||||
const { message, requestId, conversationHistory, sender } = input;
|
||||
|
||||
const check = await checkConnectivity();
|
||||
if (!check.ok) return { response: '', error: check.error };
|
||||
|
||||
try {
|
||||
const client = getBackendClient();
|
||||
const { requestId, promise } = client.sendHomeRequest(message, conversationHistory, {
|
||||
onStart: () => sendFrame(sender, { type: 'stream_start', requestId }),
|
||||
onText: (chunk) => sendFrame(sender, { type: 'stream_text', requestId, chunk }),
|
||||
onEnd: (mutations) => sendFrame(sender, { type: 'stream_end', requestId, mutations: mutations as unknown[] | undefined }),
|
||||
onError: () => sendFrame(sender, { type: 'stream_end', requestId }),
|
||||
const { requestId: activeRequestId, promise } = client.sendHomeRequest(message, conversationHistory, requestId, {
|
||||
onStart: () => sendFrame(sender, { type: 'stream_start', requestId: activeRequestId }),
|
||||
onText: (chunk) => sendFrame(sender, { type: 'stream_text', requestId: activeRequestId, chunk }),
|
||||
onEnd: (mutations) => sendFrame(sender, { type: 'stream_end', requestId: activeRequestId, mutations: mutations as unknown[] | undefined }),
|
||||
onError: () => sendFrame(sender, { type: 'stream_end', requestId: activeRequestId }),
|
||||
});
|
||||
|
||||
await promise;
|
||||
@@ -109,19 +119,19 @@ export async function orchestrate(input: OrchestrateInput): Promise<OrchestrateR
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function orchestrateFloating(input: OrchestrateFloatingInput): Promise<OrchestrateResult> {
|
||||
const { message, scope, sender } = input;
|
||||
const { message, requestId, scope, sender } = input;
|
||||
|
||||
const check = await checkConnectivity();
|
||||
if (!check.ok) return { response: '', error: check.error };
|
||||
|
||||
try {
|
||||
const client = getBackendClient();
|
||||
const { requestId, promise } = client.sendFloatingRequest(message, scope, {
|
||||
onStart: () => sendFrame(sender, { type: 'stream_start', requestId }),
|
||||
onText: (chunk) => sendFrame(sender, { type: 'stream_text', requestId, chunk }),
|
||||
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 }),
|
||||
const { requestId: activeRequestId, promise } = client.sendFloatingRequest(message, scope, requestId, {
|
||||
onStart: () => sendFrame(sender, { type: 'stream_start', requestId: activeRequestId }),
|
||||
onText: (chunk) => sendFrame(sender, { type: 'stream_text', requestId: activeRequestId, chunk }),
|
||||
onEnd: (mutations) => sendFrame(sender, { type: 'stream_end', requestId: activeRequestId, mutations: mutations as unknown[] | undefined }),
|
||||
onDomain: (domain) => sendFrame(sender, { type: 'floating_domain', requestId: activeRequestId, domain }),
|
||||
onError: () => sendFrame(sender, { type: 'stream_end', requestId: activeRequestId }),
|
||||
});
|
||||
|
||||
await promise;
|
||||
@@ -162,6 +172,25 @@ function todayString(): string {
|
||||
return new Date().toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function getBriefTimeSlot(now: Date): string {
|
||||
const minutes = now.getHours() * 60 + now.getMinutes();
|
||||
if (minutes < 6 * 60) return 'night';
|
||||
if (minutes < 9 * 60) return 'morning';
|
||||
if (minutes < 11 * 60 + 30) return 'mid_morning';
|
||||
if (minutes < 14 * 60) return 'lunch';
|
||||
if (minutes < 17 * 60) return 'afternoon';
|
||||
if (minutes < 21 * 60) return 'evening';
|
||||
return 'night';
|
||||
}
|
||||
|
||||
function getCurrentSlotKey(now = new Date()): string {
|
||||
return `${todayString()}:${getBriefTimeSlot(now)}`;
|
||||
}
|
||||
|
||||
function markCurrentSlotAsGenerated(): void {
|
||||
lastGeneratedSlotKey = getCurrentSlotKey();
|
||||
}
|
||||
|
||||
/** Returns cached brief content if it was generated today, otherwise null. */
|
||||
export function getCachedBrief(): string | null {
|
||||
const cache = getStore().get('dailyBriefCache');
|
||||
@@ -172,40 +201,85 @@ export function getCachedBrief(): string | null {
|
||||
/** Invalidate the cache so the next home visit triggers a fresh generation. */
|
||||
export function invalidateBriefCache(): void {
|
||||
getStore().set('dailyBriefCache', null);
|
||||
scheduleBriefRegeneration();
|
||||
}
|
||||
|
||||
function scheduleBriefRegeneration(delayMs = BRIEF_REGEN_DEBOUNCE_MS): void {
|
||||
if (briefRegenerationTimer) {
|
||||
clearTimeout(briefRegenerationTimer);
|
||||
}
|
||||
|
||||
briefRegenerationTimer = setTimeout(() => {
|
||||
briefRegenerationTimer = null;
|
||||
void generateAndCacheBrief();
|
||||
}, delayMs);
|
||||
}
|
||||
|
||||
/** Regenerate the brief silently in background, cache it, then push to all windows. */
|
||||
export async function generateAndCacheBrief(): Promise<void> {
|
||||
export async function generateAndCacheBrief(): Promise<boolean> {
|
||||
const check = await checkConnectivity();
|
||||
if (!check.ok) return;
|
||||
if (!check.ok) return false;
|
||||
|
||||
let content = '';
|
||||
try {
|
||||
const client = getBackendClient();
|
||||
const { promise } = client.sendHomeRequest(DAILY_BRIEF_PROMPT, undefined, {
|
||||
onStart: () => {},
|
||||
const { promise } = client.sendHomeRequest(DAILY_BRIEF_PROMPT, undefined, undefined, {
|
||||
onStart: NOOP,
|
||||
onText: (chunk) => { content += chunk; },
|
||||
onEnd: () => {},
|
||||
onError: () => {},
|
||||
onEnd: NOOP,
|
||||
onError: NOOP,
|
||||
});
|
||||
await promise;
|
||||
} catch {
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!content) return;
|
||||
if (!content) return false;
|
||||
|
||||
getStore().set('dailyBriefCache', { content, date: todayString() });
|
||||
markCurrentSlotAsGenerated();
|
||||
|
||||
for (const win of BrowserWindow.getAllWindows()) {
|
||||
if (!win.isDestroyed()) {
|
||||
win.webContents.send('ai:brief-updated', content);
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export function startBriefScheduler(): void {
|
||||
if (briefSchedulerTimer) return;
|
||||
|
||||
const tick = async () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) return;
|
||||
const slotKey = getCurrentSlotKey();
|
||||
if (slotKey === lastGeneratedSlotKey) return;
|
||||
const generated = await generateAndCacheBrief();
|
||||
if (!generated) return;
|
||||
};
|
||||
|
||||
briefSchedulerTimer = setInterval(() => {
|
||||
void tick();
|
||||
}, BRIEF_SCHEDULER_INTERVAL_MS);
|
||||
|
||||
// Run once on startup so the current slot has a fresh brief immediately.
|
||||
void tick();
|
||||
}
|
||||
|
||||
export function stopBriefScheduler(): void {
|
||||
if (briefSchedulerTimer) {
|
||||
clearInterval(briefSchedulerTimer);
|
||||
briefSchedulerTimer = null;
|
||||
}
|
||||
if (briefRegenerationTimer) {
|
||||
clearTimeout(briefRegenerationTimer);
|
||||
briefRegenerationTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Stream the daily brief to the renderer and cache the result. */
|
||||
export async function dailyBrief(sender?: Electron.WebContents): Promise<OrchestrateResult> {
|
||||
export async function dailyBrief(sender?: Electron.WebContents, requestId?: string): Promise<OrchestrateResult> {
|
||||
let content = '';
|
||||
|
||||
const check = await checkConnectivity();
|
||||
@@ -213,15 +287,15 @@ export async function dailyBrief(sender?: Electron.WebContents): Promise<Orchest
|
||||
|
||||
try {
|
||||
const client = getBackendClient();
|
||||
const requestId = crypto.randomUUID();
|
||||
const { promise } = client.sendHomeRequest(DAILY_BRIEF_PROMPT, undefined, {
|
||||
onStart: () => sendFrame(sender, { type: 'stream_start', requestId }),
|
||||
const activeRequestId = requestId ?? crypto.randomUUID();
|
||||
const { promise } = client.sendHomeRequest(DAILY_BRIEF_PROMPT, undefined, activeRequestId, {
|
||||
onStart: () => sendFrame(sender, { type: 'stream_start', requestId: activeRequestId }),
|
||||
onText: (chunk) => {
|
||||
content += chunk;
|
||||
sendFrame(sender, { type: 'stream_text', requestId, chunk });
|
||||
sendFrame(sender, { type: 'stream_text', requestId: activeRequestId, chunk });
|
||||
},
|
||||
onEnd: () => sendFrame(sender, { type: 'stream_end', requestId }),
|
||||
onError: () => sendFrame(sender, { type: 'stream_end', requestId }),
|
||||
onEnd: () => sendFrame(sender, { type: 'stream_end', requestId: activeRequestId }),
|
||||
onError: () => sendFrame(sender, { type: 'stream_end', requestId: activeRequestId }),
|
||||
});
|
||||
await promise;
|
||||
} catch (err) {
|
||||
@@ -233,6 +307,7 @@ export async function dailyBrief(sender?: Electron.WebContents): Promise<Orchest
|
||||
|
||||
if (content) {
|
||||
getStore().set('dailyBriefCache', { content, date: todayString() });
|
||||
markCurrentSlotAsGenerated();
|
||||
}
|
||||
|
||||
return { response: 'ok' };
|
||||
|
||||
@@ -30,6 +30,7 @@ import type {
|
||||
WsAgentData,
|
||||
LocalAgentConfig,
|
||||
WsFloatingRequest,
|
||||
WsFloatingDomain,
|
||||
} from '../../shared/api-types';
|
||||
import { DrizzleExecutor } from './drizzle-executor';
|
||||
import { readAgentFiles } from '../agents/file-reader';
|
||||
@@ -123,7 +124,7 @@ interface StreamListener {
|
||||
onStart: () => void;
|
||||
onText: (chunk: string) => void;
|
||||
onEnd: (mutations?: unknown) => void;
|
||||
onDomain: (domain: string) => void;
|
||||
onDomain: (domain: WsFloatingDomain['domain']) => void;
|
||||
onError: (err: Error) => void;
|
||||
resolve: () => void;
|
||||
reject: (err: Error) => void;
|
||||
@@ -233,22 +234,23 @@ export class BackendClient {
|
||||
sendHomeRequest(
|
||||
message: string,
|
||||
conversationHistory?: Array<{ role: 'user' | 'assistant' | 'system'; content: string }>,
|
||||
requestId?: string,
|
||||
callbacks?: Partial<Omit<StreamListener, 'resolve' | 'reject'>>,
|
||||
): { requestId: string; promise: Promise<void> } {
|
||||
const requestId = crypto.randomUUID();
|
||||
const activeRequestId = requestId ?? crypto.randomUUID();
|
||||
const promise = new Promise<void>((resolve, reject) => {
|
||||
this.streamListeners.set(requestId, {
|
||||
this.streamListeners.set(activeRequestId, {
|
||||
onStart: callbacks?.onStart ?? ((): void => { /* no-op */ }),
|
||||
onText: callbacks?.onText ?? ((): void => { /* no-op */ }),
|
||||
onEnd: (mutations) => {
|
||||
callbacks?.onEnd?.(mutations);
|
||||
this.streamListeners.delete(requestId);
|
||||
this.streamListeners.delete(activeRequestId);
|
||||
resolve();
|
||||
},
|
||||
onDomain: callbacks?.onDomain ?? ((): void => { /* no-op */ }),
|
||||
onError: (err) => {
|
||||
callbacks?.onError?.(err);
|
||||
this.streamListeners.delete(requestId);
|
||||
this.streamListeners.delete(activeRequestId);
|
||||
reject(err);
|
||||
},
|
||||
resolve,
|
||||
@@ -257,17 +259,17 @@ export class BackendClient {
|
||||
|
||||
const ws = this.persistentWs;
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN) {
|
||||
this.streamListeners.delete(requestId);
|
||||
this.streamListeners.delete(activeRequestId);
|
||||
reject(new OfflineError('Persistent WS not connected'));
|
||||
return;
|
||||
}
|
||||
|
||||
const homePayload = toSnakeCase({ type: 'home_request', requestId, message, conversationHistory });
|
||||
const homePayload = toSnakeCase({ type: 'home_request', requestId: activeRequestId, message, conversationHistory });
|
||||
logWsSend(homePayload);
|
||||
ws.send(JSON.stringify(homePayload));
|
||||
});
|
||||
|
||||
return { requestId, promise };
|
||||
return { requestId: activeRequestId, promise };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -277,22 +279,23 @@ export class BackendClient {
|
||||
sendFloatingRequest(
|
||||
message: string,
|
||||
scope: WsFloatingRequest['scope'],
|
||||
requestId?: string,
|
||||
callbacks?: Partial<Omit<StreamListener, 'resolve' | 'reject'>>,
|
||||
): { requestId: string; promise: Promise<void> } {
|
||||
const requestId = crypto.randomUUID();
|
||||
const activeRequestId = requestId ?? crypto.randomUUID();
|
||||
const promise = new Promise<void>((resolve, reject) => {
|
||||
this.streamListeners.set(requestId, {
|
||||
this.streamListeners.set(activeRequestId, {
|
||||
onStart: callbacks?.onStart ?? ((): void => { /* no-op */ }),
|
||||
onText: callbacks?.onText ?? ((): void => { /* no-op */ }),
|
||||
onEnd: (mutations) => {
|
||||
callbacks?.onEnd?.(mutations);
|
||||
this.streamListeners.delete(requestId);
|
||||
this.streamListeners.delete(activeRequestId);
|
||||
resolve();
|
||||
},
|
||||
onDomain: callbacks?.onDomain ?? ((): void => { /* no-op */ }),
|
||||
onError: (err) => {
|
||||
callbacks?.onError?.(err);
|
||||
this.streamListeners.delete(requestId);
|
||||
this.streamListeners.delete(activeRequestId);
|
||||
reject(err);
|
||||
},
|
||||
resolve,
|
||||
@@ -301,17 +304,17 @@ export class BackendClient {
|
||||
|
||||
const ws = this.persistentWs;
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN) {
|
||||
this.streamListeners.delete(requestId);
|
||||
this.streamListeners.delete(activeRequestId);
|
||||
reject(new OfflineError('Persistent WS not connected'));
|
||||
return;
|
||||
}
|
||||
|
||||
const floatingPayload = toSnakeCase({ type: 'floating_request', requestId, message, scope });
|
||||
const floatingPayload = toSnakeCase({ type: 'floating_request', requestId: activeRequestId, message, scope });
|
||||
logWsSend(floatingPayload);
|
||||
ws.send(JSON.stringify(floatingPayload));
|
||||
});
|
||||
|
||||
return { requestId, promise };
|
||||
return { requestId: activeRequestId, promise };
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@@ -10,6 +10,7 @@ import { getBackendClient } from './api/backend-client';
|
||||
import { getBackupManager } from './backup/backup-manager';
|
||||
import { getSyncQueue } from './backup/sync-queue';
|
||||
import { getStore } from './store';
|
||||
import { startBriefScheduler, stopBriefScheduler } from './ai/orchestrator';
|
||||
|
||||
// Handle creating/removing shortcuts on Windows when installing/uninstalling.
|
||||
if (started) {
|
||||
@@ -87,10 +88,13 @@ app.on('ready', () => {
|
||||
}
|
||||
})
|
||||
.catch((err) => console.error('[DeviceWS] Startup connect failed:', err));
|
||||
|
||||
startBriefScheduler();
|
||||
});
|
||||
|
||||
// Clean up the persistent WS and backup timers before the app exits
|
||||
app.on('will-quit', () => {
|
||||
stopBriefScheduler();
|
||||
getBackupManager().stopPeriodicBackup();
|
||||
getBackendClient().disconnectPersistent();
|
||||
});
|
||||
|
||||
@@ -15,12 +15,29 @@ import type { TRPCContext } from '../ipc';
|
||||
|
||||
const t = initTRPC.context<TRPCContext>().create();
|
||||
|
||||
/** Returns true if the given Unix-ms timestamp falls on today's calendar date. */
|
||||
function isDueToday(ts: number | null | undefined): boolean {
|
||||
function isInCurrentWeek(ts: number | null | undefined): boolean {
|
||||
if (!ts) return false;
|
||||
const d = new Date(ts);
|
||||
const now = new Date();
|
||||
return d.getFullYear() === now.getFullYear() && d.getMonth() === now.getMonth() && d.getDate() === now.getDate();
|
||||
const start = new Date(now);
|
||||
const day = start.getDay();
|
||||
const daysSinceMonday = (day + 6) % 7;
|
||||
start.setDate(start.getDate() - daysSinceMonday);
|
||||
start.setHours(0, 0, 0, 0);
|
||||
|
||||
const end = new Date(start);
|
||||
end.setDate(end.getDate() + 7);
|
||||
end.setMilliseconds(end.getMilliseconds() - 1);
|
||||
|
||||
return ts >= start.getTime() && ts <= end.getTime();
|
||||
}
|
||||
|
||||
function isBriefRelevantTask(dueDate: number | null | undefined, status: string | null | undefined): boolean {
|
||||
if (!isInCurrentWeek(dueDate)) return false;
|
||||
return status !== 'done';
|
||||
}
|
||||
|
||||
function isBriefRelevantTimeline(date: number | null | undefined): boolean {
|
||||
return isInCurrentWeek(date);
|
||||
}
|
||||
|
||||
const router = t.router;
|
||||
@@ -331,7 +348,8 @@ const tasksRouter = router({
|
||||
isApproved: input.isApproved ?? 1,
|
||||
createdAt: now,
|
||||
}).run();
|
||||
if (isDueToday(input.dueDate)) invalidateBriefCache();
|
||||
const nextStatus = input.status ?? 'todo';
|
||||
if (isBriefRelevantTask(input.dueDate, nextStatus)) invalidateBriefCache();
|
||||
return { id };
|
||||
}),
|
||||
|
||||
@@ -348,6 +366,12 @@ const tasksRouter = router({
|
||||
isApproved: z.number().optional(),
|
||||
}))
|
||||
.mutation(({ input }) => {
|
||||
const prev = getDb()
|
||||
.select({ dueDate: tasks.dueDate, status: tasks.status })
|
||||
.from(tasks)
|
||||
.where(eq(tasks.id, input.id))
|
||||
.all()[0];
|
||||
|
||||
const set: Partial<{
|
||||
title: string;
|
||||
description: string | null;
|
||||
@@ -369,14 +393,27 @@ const tasksRouter = router({
|
||||
if (Object.keys(set).length > 0) {
|
||||
getDb().update(tasks).set(set).where(eq(tasks.id, input.id)).run();
|
||||
}
|
||||
if (isDueToday(input.dueDate)) invalidateBriefCache();
|
||||
|
||||
const prevRelevant = isBriefRelevantTask(prev?.dueDate, prev?.status);
|
||||
const nextDueDate = input.dueDate !== undefined ? input.dueDate : prev?.dueDate;
|
||||
const nextStatus = input.status !== undefined ? input.status : prev?.status;
|
||||
const nextRelevant = isBriefRelevantTask(nextDueDate, nextStatus);
|
||||
if (prevRelevant || nextRelevant) invalidateBriefCache();
|
||||
|
||||
return null;
|
||||
}),
|
||||
|
||||
delete: publicProcedure
|
||||
.input(z.object({ id: z.string() }))
|
||||
.mutation(({ input }) => {
|
||||
const prev = getDb()
|
||||
.select({ dueDate: tasks.dueDate, status: tasks.status })
|
||||
.from(tasks)
|
||||
.where(eq(tasks.id, input.id))
|
||||
.all()[0];
|
||||
|
||||
getDb().delete(tasks).where(eq(tasks.id, input.id)).run();
|
||||
if (isBriefRelevantTask(prev?.dueDate, prev?.status)) invalidateBriefCache();
|
||||
return { success: true as const };
|
||||
}),
|
||||
|
||||
@@ -436,7 +473,7 @@ const timelineEventsRouter = router({
|
||||
isAiSuggested: input.isAiSuggested ?? 0,
|
||||
createdAt: now,
|
||||
}).run();
|
||||
if (isDueToday(input.date)) invalidateBriefCache();
|
||||
if (isBriefRelevantTimeline(input.date)) invalidateBriefCache();
|
||||
return { id };
|
||||
}),
|
||||
|
||||
@@ -449,6 +486,12 @@ const timelineEventsRouter = router({
|
||||
isCompleted: z.number().optional(),
|
||||
}))
|
||||
.mutation(({ input }) => {
|
||||
const prev = getDb()
|
||||
.select({ date: timelineEvents.date })
|
||||
.from(timelineEvents)
|
||||
.where(eq(timelineEvents.id, input.id))
|
||||
.all()[0];
|
||||
|
||||
const set: Partial<{ title: string; date: number; endDate: number | null; isCompleted: number }> = {};
|
||||
if (input.title !== undefined) set.title = input.title;
|
||||
if (input.date !== undefined) set.date = input.date;
|
||||
@@ -457,14 +500,26 @@ const timelineEventsRouter = router({
|
||||
if (Object.keys(set).length > 0) {
|
||||
getDb().update(timelineEvents).set(set).where(eq(timelineEvents.id, input.id)).run();
|
||||
}
|
||||
if (isDueToday(input.date)) invalidateBriefCache();
|
||||
|
||||
const prevRelevant = isBriefRelevantTimeline(prev?.date);
|
||||
const nextDate = input.date !== undefined ? input.date : prev?.date;
|
||||
const nextRelevant = isBriefRelevantTimeline(nextDate);
|
||||
if (prevRelevant || nextRelevant) invalidateBriefCache();
|
||||
|
||||
return null;
|
||||
}),
|
||||
|
||||
delete: publicProcedure
|
||||
.input(z.object({ id: z.string() }))
|
||||
.mutation(({ input }) => {
|
||||
const prev = getDb()
|
||||
.select({ date: timelineEvents.date })
|
||||
.from(timelineEvents)
|
||||
.where(eq(timelineEvents.id, input.id))
|
||||
.all()[0];
|
||||
|
||||
getDb().delete(timelineEvents).where(eq(timelineEvents.id, input.id)).run();
|
||||
if (isBriefRelevantTimeline(prev?.date)) invalidateBriefCache();
|
||||
return { success: true as const };
|
||||
}),
|
||||
});
|
||||
@@ -594,6 +649,7 @@ const settingsRouter = router({
|
||||
const aiRouter = router({
|
||||
chat: publicProcedure
|
||||
.input(z.object({
|
||||
requestId: z.string().optional(),
|
||||
message: z.string(),
|
||||
conversationHistory: z.array(z.object({
|
||||
role: z.enum(['user', 'assistant', 'system']),
|
||||
@@ -610,12 +666,14 @@ const aiRouter = router({
|
||||
if (input.mode === 'floating' && input.scope) {
|
||||
return await orchestrateFloating({
|
||||
message: input.message,
|
||||
requestId: input.requestId,
|
||||
scope: input.scope,
|
||||
sender: ctx.sender,
|
||||
});
|
||||
}
|
||||
return await orchestrate({
|
||||
message: input.message,
|
||||
requestId: input.requestId,
|
||||
conversationHistory: input.conversationHistory,
|
||||
sender: ctx.sender,
|
||||
});
|
||||
@@ -628,9 +686,10 @@ const aiRouter = router({
|
||||
.query(() => getCachedBrief()),
|
||||
|
||||
dailyBrief: publicProcedure
|
||||
.mutation(async ({ ctx }) => {
|
||||
.input(z.object({ requestId: z.string().optional() }).optional())
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
try {
|
||||
return await dailyBrief(ctx.sender);
|
||||
return await dailyBrief(ctx.sender, input?.requestId);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'Unknown error';
|
||||
return { response: '', error: msg };
|
||||
|
||||
@@ -26,7 +26,20 @@ type V3StreamEvent =
|
||||
| { type: 'stream_start'; requestId: string }
|
||||
| { type: 'stream_text'; requestId: string; chunk: string }
|
||||
| { type: 'stream_end'; requestId: string; mutations?: unknown[] }
|
||||
| { type: 'floating_domain'; requestId: string; domain: 'tasks' | 'notes' | 'timelines' | 'projects' };
|
||||
| {
|
||||
type: 'floating_domain';
|
||||
requestId: string;
|
||||
domain:
|
||||
| 'tasks'
|
||||
| 'notes'
|
||||
| 'timelines'
|
||||
| 'projects'
|
||||
| {
|
||||
type: 'task' | 'timeline' | 'project' | 'note' | 'node';
|
||||
id?: string | null;
|
||||
section?: 'task' | 'timeline' | 'note' | null;
|
||||
};
|
||||
};
|
||||
|
||||
contextBridge.exposeInMainWorld('electronAI', {
|
||||
/** Subscribe to v3 AI stream events. Returns an unsubscribe function. */
|
||||
|
||||
@@ -91,17 +91,19 @@ function mergeTimelineSegments(segments: ContentSegment[]): ContentSegment[] {
|
||||
if (!uniqueTimelineIds.length) return segments;
|
||||
|
||||
const merged: ContentSegment[] = [];
|
||||
let lastTimelineInsertIndex = 0;
|
||||
|
||||
for (const seg of segments) {
|
||||
if (seg.type === 'entity' && (seg.entity === 'timeline' || seg.entity === 'timelineEvent')) {
|
||||
lastTimelineInsertIndex = merged.length;
|
||||
continue;
|
||||
}
|
||||
|
||||
merged.push(seg);
|
||||
}
|
||||
|
||||
// Keep prose flow untouched, then append a single consolidated timeline block.
|
||||
merged.push({ type: 'entity', entity: 'timeline', ids: uniqueTimelineIds });
|
||||
// Keep prose flow untouched and place consolidated timeline at the last timeline tag position.
|
||||
merged.splice(lastTimelineInsertIndex, 0, { type: 'entity', entity: 'timeline', ids: uniqueTimelineIds });
|
||||
|
||||
return merged;
|
||||
}
|
||||
@@ -238,6 +240,22 @@ export function AIChatPanel({
|
||||
setDailyBrief(cached);
|
||||
}, [cachedBriefQuery.data]);
|
||||
|
||||
// Listen for background brief refreshes pushed by the main process.
|
||||
useEffect(() => {
|
||||
if (!isHomePage) return;
|
||||
|
||||
const unsubscribe = window.electronAI.onBriefUpdated((content) => {
|
||||
briefModule.content = content;
|
||||
briefModule.streamFired = true;
|
||||
setDailyBrief(content);
|
||||
void utils.ai.getBrief.invalidate();
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribe?.();
|
||||
};
|
||||
}, [isHomePage, utils]);
|
||||
|
||||
// Stream a fresh brief when cache is empty (once per app session).
|
||||
useEffect(() => {
|
||||
if (!isHomePage || !authStatusQuery.data?.authenticated) return;
|
||||
@@ -251,14 +269,10 @@ export function AIChatPanel({
|
||||
if (briefModule.streamFired) return;
|
||||
briefModule.streamFired = true;
|
||||
briefContentRef.current = '';
|
||||
const requestId = crypto.randomUUID();
|
||||
|
||||
let briefRequestId: string | null = null;
|
||||
let unsubscribe: (() => void) | null = window.electronAI.onStreamEvent((event) => {
|
||||
if (event.type === 'stream_start') {
|
||||
briefRequestId = event.requestId;
|
||||
return;
|
||||
}
|
||||
if (briefRequestId !== null && event.requestId !== briefRequestId) return;
|
||||
if (event.requestId !== requestId) return;
|
||||
|
||||
if (event.type === 'stream_text') {
|
||||
briefContentRef.current += event.chunk;
|
||||
@@ -275,7 +289,7 @@ export function AIChatPanel({
|
||||
}
|
||||
});
|
||||
|
||||
briefMutation.mutate(undefined, {
|
||||
briefMutation.mutate({ requestId }, {
|
||||
onError: () => {
|
||||
unsubscribe?.();
|
||||
unsubscribe = null;
|
||||
@@ -359,7 +373,7 @@ export function AIChatPanel({
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div className="px-4 pb-3 max-h-64 overflow-y-auto">
|
||||
<ChatMarkdown content={dailyBrief} />
|
||||
<MessageContent content={dailyBrief} />
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
@@ -447,7 +461,7 @@ export function AIChatPanel({
|
||||
<Skeleton className="h-5 w-2/3" />
|
||||
</div>
|
||||
) : dailyBrief ? (
|
||||
<ChatMarkdown content={dailyBrief} size="lg" />
|
||||
<MessageContent content={dailyBrief} fontSize={CHAT_FONT} />
|
||||
) : null}
|
||||
</motion.div>
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
CHAT_HEIGHT,
|
||||
PADDING,
|
||||
} from '@/context/FloatingChatContext';
|
||||
import { useAIChat, type UIChatContext } from '@/hooks/useAIChat';
|
||||
import { useAIChat, type UIChatContext, type FloatingDomainSignal } from '@/hooks/useAIChat';
|
||||
import { ChatMarkdown } from '@/components/ai/AIChatPanel';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
|
||||
@@ -22,11 +22,63 @@ const DOMAIN_ROUTES: Record<string, string> = {
|
||||
projects: '/projects',
|
||||
};
|
||||
|
||||
const DOMAIN_SECTION_IDS: Partial<Record<'tasks' | 'notes' | 'timelines' | 'projects', string>> = {
|
||||
tasks: 'tasks-list',
|
||||
timelines: 'timeline-chart',
|
||||
};
|
||||
|
||||
interface DomainNavigationTarget {
|
||||
route: '/tasks' | '/timeline' | '/projects' | '/notes/$noteId';
|
||||
sectionId?: string;
|
||||
projectId?: string;
|
||||
noteId?: string;
|
||||
nodeId?: string;
|
||||
}
|
||||
|
||||
function normalizeDomainSignal(domain: FloatingDomainSignal): DomainNavigationTarget | null {
|
||||
if (typeof domain === 'string') {
|
||||
const route = DOMAIN_ROUTES[domain];
|
||||
if (!route) return null;
|
||||
return {
|
||||
route: route as DomainNavigationTarget['route'],
|
||||
sectionId: DOMAIN_SECTION_IDS[domain as keyof typeof DOMAIN_SECTION_IDS],
|
||||
};
|
||||
}
|
||||
|
||||
switch (domain.type) {
|
||||
case 'task':
|
||||
return { route: '/tasks', sectionId: 'tasks-list' };
|
||||
case 'timeline':
|
||||
return { route: '/timeline', sectionId: 'timeline-chart' };
|
||||
case 'note':
|
||||
if (!domain.id) return { route: '/projects' };
|
||||
return { route: '/notes/$noteId', noteId: domain.id };
|
||||
case 'project': {
|
||||
if (domain.section === 'task') {
|
||||
return { route: '/projects', sectionId: 'project-tasks', projectId: domain.id ?? undefined };
|
||||
}
|
||||
if (domain.section === 'timeline') {
|
||||
return { route: '/projects', sectionId: 'project-timeline', projectId: domain.id ?? undefined };
|
||||
}
|
||||
if (domain.section === 'note') {
|
||||
return { route: '/projects', sectionId: 'project-notes', projectId: domain.id ?? undefined };
|
||||
}
|
||||
return { route: '/projects', projectId: domain.id ?? undefined };
|
||||
}
|
||||
case 'node':
|
||||
if (!domain.id) return null;
|
||||
return { route: '/projects', sectionId: domain.id, nodeId: domain.id };
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function FloatingChatInner() {
|
||||
const { state, sections, close, updatePosition, setPendingSection } = useFloatingChat();
|
||||
const { state, sections, close, updatePosition, setPendingSection, moveToSection } = useFloatingChat();
|
||||
const navigate = useNavigate();
|
||||
const routerState = useRouterState();
|
||||
const prevPathRef = useRef(routerState.location.pathname);
|
||||
const domainNavigationInFlightRef = useRef(false);
|
||||
|
||||
// Active section lookup
|
||||
const activeSection = sections.get(state.activeSectionId ?? '');
|
||||
@@ -55,17 +107,50 @@ function FloatingChatInner() {
|
||||
|
||||
// Handle floating_domain signals — navigate in background
|
||||
const handleDomainSignal = useCallback(
|
||||
(domain: 'tasks' | 'notes' | 'timelines' | 'projects') => {
|
||||
const route = DOMAIN_ROUTES[domain];
|
||||
if (!route) return;
|
||||
(domainSignal: FloatingDomainSignal) => {
|
||||
const target = normalizeDomainSignal(domainSignal);
|
||||
if (!target) return;
|
||||
|
||||
// If backend points to a currently registered node/section, move there immediately.
|
||||
if (target.sectionId && sections.has(target.sectionId)) {
|
||||
moveToSection(target.sectionId);
|
||||
return;
|
||||
}
|
||||
|
||||
const currentPath = routerState.location.pathname;
|
||||
if (currentPath === route) return;
|
||||
const isCurrentRoute =
|
||||
(target.route === '/projects' && currentPath === '/projects') ||
|
||||
(target.route === '/tasks' && currentPath === '/tasks') ||
|
||||
(target.route === '/timeline' && currentPath === '/timeline') ||
|
||||
(target.route === '/notes/$noteId' && currentPath.startsWith('/notes/'));
|
||||
|
||||
setPendingSection({ sectionId: domain });
|
||||
void navigate({ to: route });
|
||||
if (isCurrentRoute && target.sectionId) {
|
||||
setPendingSection({ sectionId: target.sectionId });
|
||||
return;
|
||||
}
|
||||
|
||||
if (isCurrentRoute) return;
|
||||
|
||||
domainNavigationInFlightRef.current = true;
|
||||
|
||||
const pendingSectionId = target.sectionId;
|
||||
if (pendingSectionId) {
|
||||
setPendingSection({ sectionId: pendingSectionId });
|
||||
} else {
|
||||
setPendingSection(undefined);
|
||||
}
|
||||
|
||||
if (target.route === '/projects') {
|
||||
void navigate({ to: '/projects', search: target.projectId ? { projectId: target.projectId } : {} });
|
||||
} else if (target.route === '/notes/$noteId' && target.noteId) {
|
||||
void navigate({ to: '/notes/$noteId', params: { noteId: target.noteId } });
|
||||
} else if (target.route === '/tasks') {
|
||||
void navigate({ to: '/tasks' });
|
||||
} else if (target.route === '/timeline') {
|
||||
void navigate({ to: '/timeline' });
|
||||
}
|
||||
},
|
||||
[routerState.location.pathname, navigate, setPendingSection],
|
||||
[routerState.location.pathname, navigate, setPendingSection, sections, moveToSection],
|
||||
);
|
||||
|
||||
const {
|
||||
@@ -98,8 +183,13 @@ function FloatingChatInner() {
|
||||
|
||||
useEffect(() => {
|
||||
const currentPath = routerState.location.pathname;
|
||||
if (prevPathRef.current !== currentPath && state.isOpen && !state.pendingSection) {
|
||||
close();
|
||||
if (prevPathRef.current !== currentPath && state.isOpen) {
|
||||
// Keep floating chat alive when navigation is AI-domain driven.
|
||||
if (domainNavigationInFlightRef.current) {
|
||||
domainNavigationInFlightRef.current = false;
|
||||
} else if (!state.pendingSection) {
|
||||
close();
|
||||
}
|
||||
}
|
||||
prevPathRef.current = currentPath;
|
||||
}, [routerState.location.pathname, state.isOpen, state.pendingSection, close]);
|
||||
|
||||
@@ -1,6 +1,17 @@
|
||||
import { useState, useCallback, useRef } from 'react';
|
||||
import { useState, useCallback, useRef, useEffect, useMemo } from 'react';
|
||||
import { trpc } from '@/lib/trpc';
|
||||
|
||||
export type FloatingDomainSignal =
|
||||
| 'tasks'
|
||||
| 'notes'
|
||||
| 'timelines'
|
||||
| 'projects'
|
||||
| {
|
||||
type: 'task' | 'timeline' | 'project' | 'note' | 'node';
|
||||
id?: string | null;
|
||||
section?: 'task' | 'timeline' | 'note' | null;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -37,7 +48,23 @@ interface UseAIChatReturn {
|
||||
}
|
||||
|
||||
interface UseAIChatOptions {
|
||||
onDomainSignal?: (domain: 'tasks' | 'notes' | 'timelines' | 'projects') => void;
|
||||
onDomainSignal?: (domain: FloatingDomainSignal) => void;
|
||||
}
|
||||
|
||||
interface CachedChatState {
|
||||
messages: ChatMessage[];
|
||||
input: string;
|
||||
}
|
||||
|
||||
const chatSessionCache = new Map<string, CachedChatState>();
|
||||
|
||||
function getContextCacheKey(ctx: UIChatContext): string {
|
||||
if (ctx.type === 'global') return 'global';
|
||||
if (ctx.type === 'project') return `project:${ctx.projectId ?? ''}`;
|
||||
|
||||
// Floating chat should keep a single continuous session while the panel is open,
|
||||
// even when route/section context changes due floating-domain navigation.
|
||||
return 'floating';
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -76,19 +103,45 @@ function parseMutationsToEntityTags(mutations: unknown[] | undefined): string {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function useAIChat(defaultContext: UIChatContext, options?: UseAIChatOptions): UseAIChatReturn {
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||
const [input, setInput] = useState('');
|
||||
const contextCacheKey = useMemo(
|
||||
() => getContextCacheKey(defaultContext),
|
||||
[defaultContext.type, defaultContext.projectId, defaultContext.scope?.type, defaultContext.scope?.id],
|
||||
);
|
||||
|
||||
const [messages, setMessages] = useState<ChatMessage[]>(
|
||||
() => chatSessionCache.get(contextCacheKey)?.messages ?? [],
|
||||
);
|
||||
const [input, setInput] = useState(
|
||||
() => chatSessionCache.get(contextCacheKey)?.input ?? '',
|
||||
);
|
||||
const [isStreaming, setIsStreaming] = useState(false);
|
||||
const [streamingContent, setStreamingContent] = useState('');
|
||||
|
||||
const streamingContentRef = useRef('');
|
||||
const chatMutation = trpc.ai.chat.useMutation();
|
||||
|
||||
// Keep local state aligned when the chat context changes in-place.
|
||||
useEffect(() => {
|
||||
const cached = chatSessionCache.get(contextCacheKey);
|
||||
setMessages(cached?.messages ?? []);
|
||||
setInput(cached?.input ?? '');
|
||||
setIsStreaming(false);
|
||||
setStreamingContent('');
|
||||
streamingContentRef.current = '';
|
||||
}, [contextCacheKey]);
|
||||
|
||||
// Persist the chat session so remounting the panel restores the conversation.
|
||||
useEffect(() => {
|
||||
chatSessionCache.set(contextCacheKey, { messages, input });
|
||||
}, [contextCacheKey, messages, input]);
|
||||
|
||||
const clearMessages = useCallback(() => {
|
||||
setMessages([]);
|
||||
setStreamingContent('');
|
||||
streamingContentRef.current = '';
|
||||
}, []);
|
||||
chatSessionCache.set(contextCacheKey, { messages: [], input: '' });
|
||||
setInput('');
|
||||
}, [contextCacheKey]);
|
||||
|
||||
const handleSend = useCallback(
|
||||
(overrideMessage?: string, overrideContext?: UIChatContext) => {
|
||||
@@ -102,6 +155,7 @@ export function useAIChat(defaultContext: UIChatContext, options?: UseAIChatOpti
|
||||
role: 'user',
|
||||
content: trimmed,
|
||||
};
|
||||
const requestId = crypto.randomUUID();
|
||||
|
||||
setMessages((prev) => [...prev, userMsg]);
|
||||
if (!overrideMessage) setInput('');
|
||||
@@ -109,24 +163,16 @@ export function useAIChat(defaultContext: UIChatContext, options?: UseAIChatOpti
|
||||
setStreamingContent('');
|
||||
streamingContentRef.current = '';
|
||||
|
||||
// Capture the requestId from stream_start so we only handle events
|
||||
// for this specific chat request (avoids cross-contamination with
|
||||
// other concurrent streams like the daily brief).
|
||||
let activeRequestId: string | null = null;
|
||||
|
||||
const unsubscribe = window.electronAI.onStreamEvent((event) => {
|
||||
// Latch the requestId on first stream_start
|
||||
if (event.type === 'stream_start') {
|
||||
activeRequestId = event.requestId;
|
||||
return;
|
||||
}
|
||||
|
||||
// Once we have a requestId, ignore events from other streams
|
||||
if (activeRequestId !== null && event.requestId !== activeRequestId) {
|
||||
// Strictly process only frames for this chat request.
|
||||
if (event.requestId !== requestId) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (event.type) {
|
||||
case 'stream_start':
|
||||
break;
|
||||
|
||||
case 'stream_text':
|
||||
streamingContentRef.current += event.chunk;
|
||||
setStreamingContent(streamingContentRef.current);
|
||||
@@ -167,6 +213,7 @@ export function useAIChat(defaultContext: UIChatContext, options?: UseAIChatOpti
|
||||
|
||||
chatMutation.mutate(
|
||||
{
|
||||
requestId,
|
||||
message: trimmed,
|
||||
conversationHistory,
|
||||
...(isFloating && ctx.scope
|
||||
|
||||
@@ -17,7 +17,20 @@ type V3StreamEvent =
|
||||
| { type: 'stream_start'; requestId: string }
|
||||
| { type: 'stream_text'; requestId: string; chunk: string }
|
||||
| { type: 'stream_end'; requestId: string; mutations?: unknown[] }
|
||||
| { type: 'floating_domain'; requestId: string; domain: 'tasks' | 'notes' | 'timelines' | 'projects' };
|
||||
| {
|
||||
type: 'floating_domain';
|
||||
requestId: string;
|
||||
domain:
|
||||
| 'tasks'
|
||||
| 'notes'
|
||||
| 'timelines'
|
||||
| 'projects'
|
||||
| {
|
||||
type: 'task' | 'timeline' | 'project' | 'note' | 'node';
|
||||
id?: string | null;
|
||||
section?: 'task' | 'timeline' | 'note' | null;
|
||||
};
|
||||
};
|
||||
|
||||
interface ElectronAI {
|
||||
onStreamEvent: (cb: (data: V3StreamEvent) => void) => () => void;
|
||||
|
||||
@@ -199,7 +199,14 @@ 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', 'timelines', 'projects']),
|
||||
domain: z.union([
|
||||
z.enum(['tasks', 'notes', 'timelines', 'projects']),
|
||||
z.object({
|
||||
type: z.enum(['task', 'timeline', 'project', 'note', 'node']),
|
||||
id: z.string().nullable().optional(),
|
||||
section: z.enum(['task', 'timeline', 'note']).nullable().optional(),
|
||||
}),
|
||||
]),
|
||||
});
|
||||
export type WsFloatingDomain = z.infer<typeof WsFloatingDomainSchema>;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user