feat: add daily brief functionality and integrate into AI chat panel

This commit is contained in:
Roberto Musso
2026-02-26 16:18:41 +01:00
parent f4eb278692
commit f09afd2d9e
4 changed files with 130 additions and 97 deletions

View File

@@ -1,10 +1,8 @@
{ {
"mcpServers": { "mcpServers": {
"shadcn": { "shadcn": {
"command": "cmd", "command": "npx",
"args": [ "args": [
"/c",
"npx",
"shadcn@latest", "shadcn@latest",
"mcp" "mcp"
] ]

View File

@@ -946,3 +946,27 @@ export async function orchestrate(input: OrchestrateInput): Promise<OrchestrateR
return { response: '', error: errMsg }; return { response: '', error: errMsg };
} }
} }
// ---------------------------------------------------------------------------
// Daily Brief (dedicated entry point)
// ---------------------------------------------------------------------------
const DAILY_BRIEF_PROMPT =
`Act as a professional and efficient executive assistant. Give me a concise daily brief for today.
Strict Rules:
- Adopt a polite, formal, and helpful tone. Do not use emojis, slang, or overly casual encouragement.
- Focus strictly on actionable or critical items: tasks due today, upcoming deadlines this week, overdue items, and significant project activity.
- Do NOT mention zero-counts (e.g., "no overdue items") or general statistics (e.g., "2 active projects", "2 completed tasks"). Only report what needs my attention.
- Do NOT include any headers, titles, dates, or greetings.
- Do NOT use labels like "Due today:" or "Overdue:". Integrate the information naturally into sentences.
- Use **bold** for key phrases, task names, or project names.
- Keep the entire response to 3-5 sentences.`;
export async function dailyBrief(sender?: Electron.WebContents): Promise<OrchestrateResult> {
return orchestrate({
message: DAILY_BRIEF_PROMPT,
context: { type: 'global' },
sender,
});
}

View File

@@ -6,7 +6,7 @@ import { getDb } from '../db';
import { clients, projects, tasks, checkpoints, notes, taskComments } from '../db/schema'; import { clients, projects, tasks, checkpoints, notes, taskComments } from '../db/schema';
import { getStore } from '../store'; import { getStore } from '../store';
import { saveTokenAndInit, hasActiveToken } from '../ai/provider'; import { saveTokenAndInit, hasActiveToken } from '../ai/provider';
import { orchestrate } from '../ai/orchestrator'; import { orchestrate, dailyBrief } from '../ai/orchestrator';
import { upsertNoteEmbedding } from '../db/vectordb'; import { upsertNoteEmbedding } from '../db/vectordb';
import type { TRPCContext } from '../ipc'; import type { TRPCContext } from '../ipc';
@@ -572,6 +572,15 @@ const aiRouter = router({
await saveTokenAndInit(input.token); await saveTokenAndInit(input.token);
return { success: true }; return { success: true };
}), }),
dailyBrief: publicProcedure
.mutation(async ({ ctx }) => {
try {
return await dailyBrief(ctx.sender);
} catch (err) {
const msg = err instanceof Error ? err.message : 'Unknown error';
return { response: '', error: msg };
}
}),
hasToken: publicProcedure.query(async () => { hasToken: publicProcedure.query(async () => {
return hasActiveToken(); return hasActiveToken();
}), }),

View File

@@ -61,6 +61,7 @@ export function AIChatPanel({
const streamingContentRef = useRef(''); const streamingContentRef = useRef('');
const chatMutation = trpc.ai.chat.useMutation(); const chatMutation = trpc.ai.chat.useMutation();
const briefMutation = trpc.ai.dailyBrief.useMutation();
const scrollToBottom = useCallback(() => { const scrollToBottom = useCallback(() => {
const el = messagesContainerRef.current; const el = messagesContainerRef.current;
@@ -98,28 +99,21 @@ export function AIChatPanel({
setDailyBrief(briefContentRef.current); setDailyBrief(briefContentRef.current);
}); });
chatMutation.mutate( briefMutation.mutate(undefined, {
{ onSuccess: (data) => {
message: if (data.error) {
'Give me a concise daily brief for today. Highlight tasks due today and this week, any overdue items, and recent project activity. Use **bold** for key phrases. Keep it to 3-5 sentences.',
context: { type: 'global' },
},
{
onSuccess: (data) => {
if (data.error) {
unsubscribe();
setDailyBrief(null);
setBriefLoading(false);
}
},
onError: () => {
unsubscribe(); unsubscribe();
setDailyBrief(null); setDailyBrief(null);
setBriefLoading(false); setBriefLoading(false);
}, }
}, },
); onError: () => {
}, [isHomePage, hasTokenQuery.data]); // chatMutation excluded — only fire once unsubscribe();
setDailyBrief(null);
setBriefLoading(false);
},
});
}, [isHomePage, hasTokenQuery.data]); // briefMutation excluded — only fire once
const handleSend = useCallback(() => { const handleSend = useCallback(() => {
const trimmed = input.trim(); const trimmed = input.trim();
@@ -256,53 +250,78 @@ export function AIChatPanel({
viewportRef={messagesContainerRef} viewportRef={messagesContainerRef}
viewportClassName={ viewportClassName={
isHomePage && !hasMessages isHomePage && !hasMessages
? undefined ? '[&>div]:!flex [&>div]:!flex-col [&>div]:!min-h-full [&>div]:!justify-center'
: '[&>div]:!flex [&>div]:!flex-col [&>div]:!min-h-full [&>div]:!justify-end' : '[&>div]:!flex [&>div]:!flex-col [&>div]:!min-h-full [&>div]:!justify-end'
} }
onWheel={handleWheel} onWheel={handleWheel}
> >
{/* Home page initial state: greeting + brief */} {/* Home page initial state: greeting + brief */}
{isHomePage && !hasMessages && ( {isHomePage && !hasMessages && (
<div className="mx-auto w-full max-w-3xl px-6 pt-8 pb-44"> <div className="mx-auto w-full max-w-3xl px-6 pt-8 pb-8">
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-8">
{/* Greeting + badge */} {/* Greeting + brief grouped closely */}
<div className="flex items-center justify-between gap-4 flex-wrap"> <div className="flex flex-col gap-1">
<h1 className="text-[30px] font-semibold" style={{ letterSpacing: '-1px' }}> <div className="flex items-center justify-between gap-4 flex-wrap">
Hello, {userName} <h1 className="text-[30px] font-semibold" style={{ letterSpacing: '-1px' }}>
</h1> Hello, {userName}
<Badge variant="secondary"> </h1>
{dueCount} Task{dueCount !== 1 ? 's' : ''} due <Badge variant="secondary">
</Badge> {dueCount} Task{dueCount !== 1 ? 's' : ''} due
</Badge>
</div>
{/* Daily brief */}
<div>
{hasTokenQuery.data === false ? (
<div className="flex flex-col items-center gap-3 py-2">
<KeyRound size={24} className="text-muted-foreground" />
<p className="text-sm text-muted-foreground text-center">
Configure your AI provider in Settings to enable the daily brief.
</p>
<Button variant="outline" size="sm" onClick={onOpenSettings}>
Open Settings
</Button>
</div>
) : briefLoading && !dailyBrief ? (
<div className="space-y-2">
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-4 w-1/2" />
<Skeleton className="h-4 w-2/3" />
</div>
) : dailyBrief ? (
<ChatMarkdown content={dailyBrief} />
) : (
<p className="text-sm text-muted-foreground">
Your daily brief will appear here.
</p>
)}
</div>
</div> </div>
{/* Daily brief card */} {/* Inline input + suggestion chips */}
<Card> <div>
<CardContent className="pt-6"> <ChatInput
{hasTokenQuery.data === false ? ( input={input}
<div className="flex flex-col items-center gap-3 py-2"> isStreaming={isStreaming || briefLoading}
<KeyRound size={24} className="text-muted-foreground" /> onInputChange={setInput}
<p className="text-sm text-muted-foreground text-center"> onKeyDown={handleKeyDown}
Configure your AI provider in Settings to enable the daily brief. onSend={handleSend}
</p> isHomePage={isHomePage}
<Button variant="outline" size="sm" onClick={onOpenSettings}> />
Open Settings <div className="grid grid-cols-2 sm:grid-cols-4 gap-2 mt-3">
</Button> {SUGGESTION_CHIPS.map((chip) => (
</div> <button
) : briefLoading && !dailyBrief ? ( key={chip.label}
<div className="space-y-2"> type="button"
<Skeleton className="h-4 w-3/4" /> className="flex items-start gap-2 rounded-xl border border-border bg-background px-3 py-2.5 text-left text-sm text-foreground shadow-sm transition-colors hover:bg-muted cursor-pointer"
<Skeleton className="h-4 w-1/2" /> onClick={() => setInput(chip.label)}
<Skeleton className="h-4 w-2/3" /> >
</div> <chip.icon size={16} className="shrink-0 mt-0.5 text-muted-foreground" />
) : dailyBrief ? ( <span>{chip.label}</span>
<ChatMarkdown content={dailyBrief} /> </button>
) : ( ))}
<p className="text-sm text-muted-foreground"> </div>
Your daily brief will appear here. </div>
</p>
)}
</CardContent>
</Card>
</div> </div>
</div> </div>
)} )}
@@ -311,13 +330,11 @@ export function AIChatPanel({
{isHomePage && hasMessages && ( {isHomePage && hasMessages && (
<div className="mx-auto w-full max-w-3xl px-6 pt-8 pb-44"> <div className="mx-auto w-full max-w-3xl px-6 pt-8 pb-44">
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
{/* Brief card persists */} {/* Brief persists */}
{dailyBrief && ( {dailyBrief && (
<Card className="mb-2"> <div className="mb-2">
<CardContent className="pt-6"> <ChatMarkdown content={dailyBrief} />
<ChatMarkdown content={dailyBrief} /> </div>
</CardContent>
</Card>
)} )}
{/* Chat messages */} {/* Chat messages */}
@@ -440,37 +457,22 @@ export function AIChatPanel({
)} )}
</ScrollArea> </ScrollArea>
{/* Fixed input — pinned to the bottom */} {/* Fixed input — pinned to the bottom (hidden on home initial state) */}
<div className="absolute bottom-0 left-0 right-0 z-10 px-6 pb-4 pt-12 pointer-events-none"> {!(isHomePage && !hasMessages) && (
<div className="absolute inset-x-0 top-0 h-full bg-gradient-to-b from-transparent via-background/80 to-background" /> <div className="absolute bottom-0 left-0 right-0 z-10 px-6 pb-4 pt-12 pointer-events-none">
<div className={`relative pointer-events-auto mx-auto ${isHomePage ? 'max-w-3xl' : 'max-w-[1088px]'}`}> <div className="absolute inset-x-0 top-0 h-full bg-gradient-to-b from-transparent via-background/80 to-background" />
<ChatInput <div className={`relative pointer-events-auto mx-auto ${isHomePage ? 'max-w-3xl' : 'max-w-[1088px]'}`}>
input={input} <ChatInput
isStreaming={isStreaming || briefLoading} input={input}
onInputChange={setInput} isStreaming={isStreaming || briefLoading}
onKeyDown={handleKeyDown} onInputChange={setInput}
onSend={handleSend} onKeyDown={handleKeyDown}
isHomePage={isHomePage} onSend={handleSend}
/> isHomePage={isHomePage}
/>
{/* Suggestion chips (home page only, before first message) */} </div>
{isHomePage && !hasMessages && (
<div className="flex flex-wrap gap-2 mt-3">
{SUGGESTION_CHIPS.map((chip) => (
<Button
key={chip.label}
variant="outline"
className="gap-2 h-auto py-2.5 px-4 text-left"
onClick={() => setInput(chip.label)}
>
<chip.icon size={16} className="shrink-0" />
<span className="text-sm">{chip.label}</span>
</Button>
))}
</div>
)}
</div> </div>
</div> )}
</div> </div>
); );
} }