Initial commit

This commit is contained in:
2026-08-07 23:53:15 +05:30
commit 90f14a73b2
222 changed files with 35000 additions and 0 deletions

View File

@@ -0,0 +1,167 @@
import * as React from 'react';
import { RotateCcw, Sparkles, X } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Surface } from '@/components/ds/Surface';
import { IconButton } from '@/components/ds/IconButton';
import { Alert } from '@/components/ds/Alert';
import { KROW_LOGO_URL } from '@/assets/brand';
import { useAssistantFacts, useConversation } from './useAssistant';
import { Message, ThinkingIndicator } from './AssistantMessage';
import { PromptInput } from './PromptInput';
/**
* Krow AI Assistant — the dashboard's contextual assistant.
*
* One component, reused on every supported page; only `context` differs.
*
* The layout is a fixed three-part frame — header, conversation, composer —
* where only the conversation scrolls. That is what lets the panel stay pinned
* beside a long dashboard without ever growing the page or losing its input.
*
* The empty state is deliberately a conversational entry point rather than a
* menu: an icon, a question, and an input. Suggested chips fill the input rather
* than firing an action, so the user always sees the question before it is asked
* and can edit it — which is what makes this read as an assistant instead of a
* second dashboard.
*
* Independent of Owliver. It talks to a provider (see provider.js) which can
* later be pointed at an external service without changing anything here.
*/
export default function KrowAssistant({ context, className, onClose }) {
const facts = useAssistantFacts({ needsWorkerProfile: context.needsWorkerProfile });
const { messages, pending, error, busy, send, stop, reset } = useConversation({
contextId: context.id,
facts,
});
const [input, setInput] = React.useState('');
const scrollRef = React.useRef(null);
const isEmpty = messages.length === 0 && !pending;
/* Follow the newest content as it streams. */
React.useEffect(() => {
const el = scrollRef.current;
if (el) el.scrollTop = el.scrollHeight;
}, [messages, pending]);
/**
* A chip's prompt resolves to its capability so the answer is precise, while
* the same question typed by hand still resolves through keyword matching.
*/
const submit = (text) => {
const question = text.trim();
if (!question || busy) return;
const chip = context.chips.find((c) => c.prompt === question);
send({ question, capability: chip?.capability ?? null });
setInput('');
};
return (
<Surface
variant="glass"
radius="xl"
padding="none"
elevation="sm"
className={cn('flex flex-col overflow-hidden', className)}
aria-label="Krow AI Assistant"
>
{/* Header — fixed */}
<div className="flex shrink-0 items-center gap-2.5 border-b border-white/60 px-4 py-3">
<span className="grid h-8 w-8 shrink-0 place-items-center rounded-lg bg-krow-blue text-white shadow-brand">
<Sparkles className="h-4 w-4" aria-hidden="true" />
</span>
<div className="min-w-0 flex-1">
<h2 className="font-heading text-body-sm font-semibold leading-tight text-ink-1">Krow AI</h2>
<p className="truncate text-caption leading-tight text-ink-3">{context.page}</p>
</div>
{messages.length > 0 && (
<IconButton icon={RotateCcw} label="New conversation" variant="ghost" size="sm" onClick={reset} />
)}
{onClose && (
<IconButton icon={X} label="Close assistant" variant="ghost" size="sm" onClick={onClose} />
)}
</div>
{/* Conversation — the only scrolling region */}
<div ref={scrollRef} className="min-h-0 flex-1 overflow-y-auto" aria-live="polite">
{isEmpty ? (
/* Landing: an invitation to ask, not a list of features. */
<div className="flex h-full flex-col items-center justify-center px-6 py-8 text-center animate-fade-in">
<span className="relative mb-5 grid h-14 w-14 place-items-center rounded-2xl bg-white shadow-brand ring-1 ring-krow-blue/10">
<img src={KROW_LOGO_URL} alt="" aria-hidden="true" className="h-5 w-auto object-contain" />
<span
aria-hidden="true"
className="absolute -right-1 -top-1 grid h-5 w-5 place-items-center rounded-full bg-krow-blue text-white shadow-sm"
>
<Sparkles className="h-2.5 w-2.5" />
</span>
</span>
<h3 className="font-heading text-title text-ink-1">
What would you like to analyze today?
</h3>
<p className="mt-2 max-w-[19rem] text-body-sm leading-relaxed text-ink-3">
{context.tagline}
</p>
</div>
) : (
<div className="space-y-4 px-4 py-4">
{messages.map((message, i) => (
<Message key={i} role={message.role} text={message.text} stopped={message.stopped} />
))}
{pending?.thinking && <ThinkingIndicator />}
{pending && !pending.thinking && <Message role="assistant" text={pending.text} streaming />}
{error && <Alert tone="destructive">{error}</Alert>}
</div>
)}
</div>
{/* Suggested prompts + composer — fixed */}
<div className="shrink-0 space-y-2.5 border-t border-white/60 px-3 pb-3 pt-2.5">
{!busy && (
/* On the landing there is room to show every suggestion, so they wrap.
Once a conversation is underway the space belongs to the thread, so
they collapse to a single scrollable row. */
<div
className={cn(
'flex gap-1.5',
isEmpty ? 'flex-wrap justify-center' : 'overflow-x-auto no-scrollbar'
)}
>
{context.chips.map((chip) => (
<button
key={chip.label}
type="button"
/* Fills the input — never sends, never navigates. */
onClick={() => setInput(chip.prompt)}
className="shrink-0 rounded-full border border-border bg-surface px-2.5 py-1 text-caption
text-ink-3 transition-colors duration-base hover:border-krow-blue/40
hover:bg-krow-blue-tint hover:text-krow-blue focus-ring"
>
{chip.label}
</button>
))}
</div>
)}
<PromptInput
value={input}
onChange={setInput}
onSubmit={submit}
onStop={stop}
busy={busy}
placeholders={context.placeholders}
size={isEmpty ? 'large' : 'default'}
/>
<p className="px-1 text-[10px] leading-tight text-ink-4">
Krow AI reads this page&apos;s data. Check anything you act on.
</p>
</div>
</Surface>
);
}