Something went wrong. Try again.
This repository has no description
Something went wrong. Try again.
14 kB · 492 lines
TSX
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493import { isCapabilityAction } from "../../shared/capability-approval";import { ActivityDetails } from "./activity-details";import { AttachmentCards } from "./chat-attachments";import { SkillScriptOutput, skillScriptOutput } from "./skill-script-output";import { messageAttachments } from "../../shared/attachments";import { useMemo, useState, useEffect, useRef, type OctaneNode } from "octane";import { Lexer, type Token, type Tokens } from "marked";import { decodeHTML } from "entities";import { CodeHighlighted } from "octane-kumo/code/custom";import { Button, LinkButton } from "octane-kumo/components/button";import { Banner } from "octane-kumo/components/banner";import { Loader } from "octane-kumo/components/loader";import { CheckCircleIcon, WarningCircleIcon, MinusCircleIcon,} from "@octanejs/phosphor-icons";import type { UIMessage } from "ai";import { validateMemoryId } from "../../shared/memory";import type { ToolActivity, ToolActivityStatus,} from "../../shared/tool-activity";
/** Application links have a deliberately smaller surface than external links. */export function safeChatUrl( value: string, application = true,): string | undefined { const url = decodeHTML(value); if (/[\u0000-\u0020\\]/.test(url)) return; if ( application && /^\/(tasks|conversations)\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test( url, ) ) return url; try { const parsed = new URL(url); if ( !["http:", "https:"].includes(parsed.protocol) || parsed.username || parsed.password ) return; return parsed.href; } catch { return; }}function SafeLink({ href, children }: { href: string; children: OctaneNode }) { const safe = safeChatUrl(href); return safe ? ( <a href={safe} {...(safe.startsWith("/") ? {} : { target: "_blank", rel: "noopener noreferrer" })} > {children} </a> ) : ( <span>{children}</span> );}function Code({ code, language, streaming,}: { code: string; language: string; streaming: boolean;}) { const [notice, setNotice] = useState(""); const mounted = useRef(false); useEffect(() => { mounted.current = true; return () => { mounted.current = false; }; }, []); return ( <div className="chat-code"> <div className="chat-code-toolbar"> <span>{language || "Plain text"}</span> <Button size="sm" variant="secondary" aria-label="Copy code" onClick={async () => { try { if (!navigator.clipboard) throw new Error("Clipboard unavailable"); await navigator.clipboard.writeText(code); if (mounted.current) setNotice("Copied"); } catch { if (mounted.current) setNotice("Could not copy. Select the code to copy it."); } }} > Copy </Button> <span role="status">{notice}</span> </div> {streaming || code.length > 40_000 ? ( <pre> <code>{code}</code> </pre> ) : ( <CodeHighlighted code={code} lang={language} /> )} </div> );}function renderTokens( tokens: Token[], streaming: boolean, depth = 0,): OctaneNode { if (depth > 32) return tokens.map((token) => token.raw).join(""); return tokens.map((token, index) => { const child = (children: Token[]) => renderTokens(children, streaming, depth + 1); switch (token.type) { case "space": case "def": return null; case "heading": return <h3 key={index}>{child((token as Tokens.Heading).tokens)}</h3>; case "paragraph": return <p key={index}>{child((token as Tokens.Paragraph).tokens)}</p>; case "text": { const text = token as Tokens.Text; return text.tokens ? child(text.tokens) : decodeHTML(text.text); } case "escape": return decodeHTML((token as Tokens.Escape).text); case "html": return <span key={index}>{token.raw}</span>; case "strong": return ( <strong key={index}>{child((token as Tokens.Strong).tokens)}</strong> ); case "em": return <em key={index}>{child((token as Tokens.Em).tokens)}</em>; case "del": return <del key={index}>{child((token as Tokens.Del).tokens)}</del>; case "codespan": return ( <code key={index}>{decodeHTML((token as Tokens.Codespan).text)}</code> ); case "code": { const code = token as Tokens.Code; return ( <Code key={index} code={code.text} language={code.lang?.split(/\s/)[0] ?? ""} streaming={streaming} /> ); } case "link": { const link = token as Tokens.Link; return ( <SafeLink key={index} href={link.href}> {child(link.tokens)} </SafeLink> ); } case "image": return ( <span key={index}> [Image: {decodeHTML((token as Tokens.Image).text)}] </span> ); case "br": return <br key={index} />; case "hr": return <hr key={index} />; case "blockquote": return ( <blockquote key={index}> {child((token as Tokens.Blockquote).tokens)} </blockquote> ); case "list": { const list = token as Tokens.List; const items = list.items.map((item, i) => ( <li key={i}> {item.task ? ( <span aria-label={item.checked ? "Completed" : "Not completed"}> {item.checked ? "☑ " : "☐ "} </span> ) : null} {child(item.tokens)} </li> )); return list.ordered ? ( <ol key={index} start={list.start || 1}> {items} </ol> ) : ( <ul key={index}>{items}</ul> ); } case "table": { const table = token as Tokens.Table; return ( <div className="chat-table" key={index}> <table> <thead> <tr> {table.header.map((cell, i) => ( <th key={i}>{child(cell.tokens)}</th> ))} </tr> </thead> <tbody> {table.rows.map((row, i) => ( <tr key={i}> {row.map((cell, j) => ( <td key={j}>{child(cell.tokens)}</td> ))} </tr> ))} </tbody> </table> </div> ); } default: return <span key={index}>{token.raw}</span>; } });}export function hasCodeBlock(text: string) { return Lexer.lex(text, { gfm: true }).some((token) => token.type === "code");}function Markdown({ text, streaming }: { text: string; streaming: boolean }) { const tokens = useMemo(() => Lexer.lex(text, { gfm: true }), [text]); return <div className="chat-markdown">{renderTokens(tokens, streaming)}</div>;}type ToolPart = UIMessage["parts"][number] & { toolCallId: string; state: string; toolName?: string; input?: unknown; output?: unknown; errorText?: string;};function resourceAction(part: ToolPart) { if ( part.state !== "output-available" || !part.output || typeof part.output !== "object" ) return; const result = part.output as { id?: unknown; url?: unknown; error?: unknown; }; if (result.error) return; const toolName = part.toolName || part.type.replace(/^tool-/, ""); if (toolName === "remember" || toolName === "updateMemory") { try { validateMemoryId(result.id); return { href: `/settings#memory-${result.id}`, label: "View memory" }; } catch { return; } } if (typeof result.url !== "string") return; const href = safeChatUrl(result.url); if (href?.startsWith("/tasks/")) return { href, label: "View scheduled task" }; if (href?.startsWith("/conversations/")) return { href, label: "View conversation" };}function ToolRow({ part, activity,}: { part: ToolPart; activity?: ToolActivity;}) { const scriptOutput = part.state === "output-available" ? skillScriptOutput(part.output) : undefined; const fallback: ToolActivityStatus = part.state === "output-error" || part.state === "output-denied" ? "failed" : part.state === "output-available" ? scriptOutput && !scriptOutput.result.ok ? "failed" : "succeeded" : part.state === "input-streaming" ? "pending" : "running"; const status = activity?.status ?? fallback; const loading = status === "pending" || status === "running"; const label = { pending: "Pending", running: "Running", succeeded: "Succeeded", failed: "Failed", cancelled: "Cancelled", }[status]; const result = part.output && typeof part.output === "object" ? (part.output as { url?: unknown; status?: unknown }) : undefined; const action = resourceAction(part); return ( <Banner className="chat-tool" size="sm" variant={ status === "failed" ? "error" : status === "cancelled" ? "alert" : status === "running" ? "default" : "secondary" } data-status={status} aria-busy={loading} role="status" icon={ <span className="chat-tool-icon" aria-hidden="true"> {loading ? ( <Loader size="sm" /> ) : status === "succeeded" ? ( <CheckCircleIcon size={16} /> ) : status === "failed" ? ( <WarningCircleIcon size={16} /> ) : ( <MinusCircleIcon size={16} /> )} </span> } title={ activity?.inputSummary || (isCapabilityAction(part.toolName ?? part.type.replace(/^tool-/, "")) ? "Extension action" : part.toolName || part.type.replace(/^tool-/, "")) } description={ <> <span className="chat-tool-status">{label}</span> {status === "running" && activity?.progress ? ( <span> · {activity.progress.text}</span> ) : activity?.outputSummary ? ( <span> · {activity.outputSummary}</span> ) : null} {action && result?.status === "unavailable" ? ( <span> · Schedule unavailable</span> ) : action && result?.status === "inactive" ? ( <span> · Inactive</span> ) : null} <ActivityDetails activity={activity} /> {scriptOutput ? <SkillScriptOutput output={scriptOutput} /> : null} </> } action={ action ? ( <LinkButton className="chat-tool-action" href={action.href} variant="secondary" size="sm" > {action.label} </LinkButton> ) : undefined } /> );}function sources(message: UIMessage) { const result = new Map< string, { url: string; title: string; kind: string } >(); const add = (url: unknown, title: unknown, kind: string) => { if (typeof url !== "string") return; const safe = safeChatUrl(url, false); if (safe && !result.has(safe)) result.set(safe, { url: safe, title: typeof title === "string" && title ? title : new URL(safe).hostname, kind, }); }; for (const part of message.parts) { if (part.type === "source-url") add(part.url, part.title, "Source"); if ( "output" in part && part.output && typeof part.output === "object" && "sources" in part.output && Array.isArray(part.output.sources) ) { for (const source of part.output.sources) if (source && typeof source === "object") add( source.finalUrl, source.title, source.sourceKind === "search" ? "Search result" : source.sourceKind === "browser" ? "Browser page" : "Read page", ); } } return [...result.values()];}export function ChatMessage({ message, conversationId, activities, streaming,}: { message: UIMessage; conversationId?: string; activities: ReadonlyMap<string, ToolActivity>; streaming: boolean;}) { const citations = useMemo(() => sources(message), [message]); return ( <article id={`chat-message-${message.id}`} className="chat-message" data-message-id={message.id} data-role={message.role} tabIndex={-1} > <h2>{message.role === "user" ? "You" : "Flarebot"}</h2> <div className="chat-message-body"> {conversationId ? ( <AttachmentCards conversationId={conversationId} files={messageAttachments(message)} /> ) : null} {message.parts.map((part, index) => part.type === "text" ? ( message.role === "user" ? ( <p key={index} className="chat-user-text"> {part.text} </p> ) : ( <Markdown key={index} text={part.text} streaming={streaming} /> ) ) : "toolCallId" in part ? ( <ToolRow key={part.toolCallId} part={part as ToolPart} activity={activities.get(part.toolCallId)} /> ) : null, )} {citations.length ? ( <details className="chat-source-disclosure"> <summary>Sources ({citations.length})</summary> <nav className="chat-sources" aria-label="Sources"> {citations.map((source) => ( <a key={source.url} href={source.url} target="_blank" rel="noopener noreferrer" > <span>{source.title}</span> <small>{source.kind}</small> </a> ))} </nav> </details> ) : null} </div> </article> );}