diff options
| author | soryu <soryu@soryu.co> | 2025-12-23 14:43:23 +0000 |
|---|---|---|
| committer | soryu <soryu@soryu.co> | 2025-12-23 14:47:18 +0000 |
| commit | 555061b179b8ec034cb70f9a2dd6c823ced0f637 (patch) | |
| tree | 0545b4395dab6d957884d8d36bf15b8da529dc1f /makima/frontend/src/components | |
| parent | a32dc56d2e5447ef8988cb98b8686476cc94e70c (diff) | |
| download | soryu-555061b179b8ec034cb70f9a2dd6c823ced0f637.tar.gz soryu-555061b179b8ec034cb70f9a2dd6c823ced0f637.zip | |
Add file body and initial tool call system
Diffstat (limited to 'makima/frontend/src/components')
| -rw-r--r-- | makima/frontend/src/components/charts/ChartRenderer.tsx | 181 | ||||
| -rw-r--r-- | makima/frontend/src/components/files/BodyRenderer.tsx | 125 | ||||
| -rw-r--r-- | makima/frontend/src/components/files/CliInput.tsx | 168 | ||||
| -rw-r--r-- | makima/frontend/src/components/files/FileDetail.tsx | 91 |
4 files changed, 545 insertions, 20 deletions
diff --git a/makima/frontend/src/components/charts/ChartRenderer.tsx b/makima/frontend/src/components/charts/ChartRenderer.tsx new file mode 100644 index 0000000..276b170 --- /dev/null +++ b/makima/frontend/src/components/charts/ChartRenderer.tsx @@ -0,0 +1,181 @@ +import { useMemo } from "react"; +import { + LineChart, + Line, + BarChart, + Bar, + PieChart, + Pie, + AreaChart, + Area, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + Legend, + ResponsiveContainer, + Cell, +} from "recharts"; +import type { ChartType } from "../../lib/api"; + +interface ChartRendererProps { + chartType: ChartType; + data: Record<string, unknown>[]; + title?: string; + config?: Record<string, unknown>; +} + +// Default color palette +const COLORS = [ + "#9bc3ff", + "#ff9b9b", + "#9bffb3", + "#ffeb9b", + "#d49bff", + "#9bfff0", + "#ff9beb", + "#b3ff9b", +]; + +export function ChartRenderer({ + chartType, + data, + title, + config, +}: ChartRendererProps) { + // Extract data keys (excluding 'name' which is used for labels) + const dataKeys = useMemo(() => { + if (data.length === 0) return []; + const keys = Object.keys(data[0]).filter((key) => key !== "name"); + return keys; + }, [data]); + + // Get colors from config or use defaults + const colors = (config?.colors as string[]) || COLORS; + + const renderChart = () => { + switch (chartType) { + case "line": + return ( + <LineChart data={data}> + <CartesianGrid strokeDasharray="3 3" stroke="#333" /> + <XAxis dataKey="name" stroke="#9bc3ff" fontSize={12} /> + <YAxis stroke="#9bc3ff" fontSize={12} /> + <Tooltip + contentStyle={{ + backgroundColor: "#111", + border: "1px solid #9bc3ff", + borderRadius: "0", + }} + /> + <Legend /> + {dataKeys.map((key, i) => ( + <Line + key={key} + type="monotone" + dataKey={key} + stroke={colors[i % colors.length]} + strokeWidth={2} + dot={{ fill: colors[i % colors.length] }} + /> + ))} + </LineChart> + ); + + case "bar": + return ( + <BarChart data={data}> + <CartesianGrid strokeDasharray="3 3" stroke="#333" /> + <XAxis dataKey="name" stroke="#9bc3ff" fontSize={12} /> + <YAxis stroke="#9bc3ff" fontSize={12} /> + <Tooltip + contentStyle={{ + backgroundColor: "#111", + border: "1px solid #9bc3ff", + borderRadius: "0", + }} + /> + <Legend /> + {dataKeys.map((key, i) => ( + <Bar key={key} dataKey={key} fill={colors[i % colors.length]} /> + ))} + </BarChart> + ); + + case "area": + return ( + <AreaChart data={data}> + <CartesianGrid strokeDasharray="3 3" stroke="#333" /> + <XAxis dataKey="name" stroke="#9bc3ff" fontSize={12} /> + <YAxis stroke="#9bc3ff" fontSize={12} /> + <Tooltip + contentStyle={{ + backgroundColor: "#111", + border: "1px solid #9bc3ff", + borderRadius: "0", + }} + /> + <Legend /> + {dataKeys.map((key, i) => ( + <Area + key={key} + type="monotone" + dataKey={key} + stroke={colors[i % colors.length]} + fill={colors[i % colors.length]} + fillOpacity={0.3} + /> + ))} + </AreaChart> + ); + + case "pie": + // For pie charts, use the first data key as value + const valueKey = dataKeys[0] || "value"; + return ( + <PieChart> + <Pie + data={data} + dataKey={valueKey} + nameKey="name" + cx="50%" + cy="50%" + outerRadius={80} + label={({ name, percent }) => + `${name}: ${((percent ?? 0) * 100).toFixed(0)}%` + } + labelLine={{ stroke: "#9bc3ff" }} + > + {data.map((_, i) => ( + <Cell key={i} fill={colors[i % colors.length]} /> + ))} + </Pie> + <Tooltip + contentStyle={{ + backgroundColor: "#111", + border: "1px solid #9bc3ff", + borderRadius: "0", + }} + /> + <Legend /> + </PieChart> + ); + + default: + return <div className="text-red-400">Unknown chart type: {chartType}</div>; + } + }; + + return ( + <div className="w-full"> + {title && ( + <h4 className="text-[#9bc3ff] font-mono text-sm mb-2">{title}</h4> + )} + <div className="h-64 w-full"> + <ResponsiveContainer width="100%" height="100%"> + {renderChart()} + </ResponsiveContainer> + </div> + </div> + ); +} diff --git a/makima/frontend/src/components/files/BodyRenderer.tsx b/makima/frontend/src/components/files/BodyRenderer.tsx new file mode 100644 index 0000000..9d008e2 --- /dev/null +++ b/makima/frontend/src/components/files/BodyRenderer.tsx @@ -0,0 +1,125 @@ +import type { BodyElement } from "../../lib/api"; +import { ChartRenderer } from "../charts/ChartRenderer"; + +interface BodyRendererProps { + elements: BodyElement[]; +} + +export function BodyRenderer({ elements }: BodyRendererProps) { + if (elements.length === 0) { + return ( + <div className="text-[#555] font-mono text-sm italic"> + No content yet. Use the CLI below to add content. + </div> + ); + } + + return ( + <div className="space-y-4"> + {elements.map((element, index) => ( + <BodyElementRenderer key={index} element={element} /> + ))} + </div> + ); +} + +function BodyElementRenderer({ element }: { element: BodyElement }) { + switch (element.type) { + case "heading": + return <HeadingElement level={element.level} text={element.text} />; + case "paragraph": + return <ParagraphElement text={element.text} />; + case "chart": + return ( + <ChartElement + chartType={element.chartType} + data={element.data} + title={element.title} + config={element.config} + /> + ); + case "image": + return ( + <ImageElement + src={element.src} + alt={element.alt} + caption={element.caption} + /> + ); + default: + return null; + } +} + +function HeadingElement({ level, text }: { level: number; text: string }) { + const className = "font-mono text-[#9bc3ff]"; + + switch (level) { + case 1: + return <h1 className={`${className} text-2xl font-bold`}>{text}</h1>; + case 2: + return <h2 className={`${className} text-xl font-bold`}>{text}</h2>; + case 3: + return <h3 className={`${className} text-lg font-semibold`}>{text}</h3>; + case 4: + return <h4 className={`${className} text-base font-semibold`}>{text}</h4>; + case 5: + return <h5 className={`${className} text-sm font-semibold`}>{text}</h5>; + case 6: + return <h6 className={`${className} text-xs font-semibold`}>{text}</h6>; + default: + return <h3 className={`${className} text-lg font-semibold`}>{text}</h3>; + } +} + +function ParagraphElement({ text }: { text: string }) { + return <p className="font-mono text-sm text-white/80 leading-relaxed">{text}</p>; +} + +function ChartElement({ + chartType, + data, + title, + config, +}: { + chartType: "line" | "bar" | "pie" | "area"; + data: Record<string, unknown>[]; + title?: string; + config?: Record<string, unknown>; +}) { + return ( + <div className="border border-[#333] p-4 bg-black/30"> + <ChartRenderer + chartType={chartType} + data={data} + title={title} + config={config} + /> + </div> + ); +} + +function ImageElement({ + src, + alt, + caption, +}: { + src: string; + alt?: string; + caption?: string; +}) { + return ( + <figure className="space-y-2"> + <img + src={src} + alt={alt || ""} + className="max-w-full border border-[#333]" + /> + {caption && ( + <figcaption className="text-[#555] font-mono text-xs italic"> + {caption} + </figcaption> + )} + </figure> + ); +} diff --git a/makima/frontend/src/components/files/CliInput.tsx b/makima/frontend/src/components/files/CliInput.tsx new file mode 100644 index 0000000..b20eb27 --- /dev/null +++ b/makima/frontend/src/components/files/CliInput.tsx @@ -0,0 +1,168 @@ +import { useState, useCallback, useRef, useEffect } from "react"; +import { chatWithFile, type BodyElement } from "../../lib/api"; + +interface CliInputProps { + fileId: string; + onUpdate: (body: BodyElement[], summary: string | null) => void; +} + +interface Message { + id: string; + type: "user" | "assistant" | "error"; + content: string; + toolCalls?: { name: string; success: boolean; message: string }[]; +} + +export function CliInput({ fileId, onUpdate }: CliInputProps) { + const [input, setInput] = useState(""); + const [loading, setLoading] = useState(false); + const [messages, setMessages] = useState<Message[]>([]); + const [expanded, setExpanded] = useState(false); + const inputRef = useRef<HTMLInputElement>(null); + const messagesRef = useRef<HTMLDivElement>(null); + + // Auto-scroll to bottom when messages change + useEffect(() => { + if (messagesRef.current) { + messagesRef.current.scrollTop = messagesRef.current.scrollHeight; + } + }, [messages]); + + const handleSubmit = useCallback( + async (e: React.FormEvent) => { + e.preventDefault(); + if (!input.trim() || loading) return; + + const userMessage = input.trim(); + setInput(""); + setExpanded(true); + + // Add user message + const userMsgId = Date.now().toString(); + setMessages((prev) => [ + ...prev, + { id: userMsgId, type: "user", content: userMessage }, + ]); + + setLoading(true); + + try { + const response = await chatWithFile(fileId, userMessage); + + // Add assistant response + const assistantMsgId = (Date.now() + 1).toString(); + setMessages((prev) => [ + ...prev, + { + id: assistantMsgId, + type: "assistant", + content: response.response, + toolCalls: response.toolCalls.map((tc) => ({ + name: tc.name, + success: tc.result.success, + message: tc.result.message, + })), + }, + ]); + + // Update parent with new body/summary + onUpdate(response.updatedBody, response.updatedSummary); + } catch (err) { + const errorMsgId = (Date.now() + 1).toString(); + setMessages((prev) => [ + ...prev, + { + id: errorMsgId, + type: "error", + content: err instanceof Error ? err.message : "An error occurred", + }, + ]); + } finally { + setLoading(false); + inputRef.current?.focus(); + } + }, + [input, loading, fileId, onUpdate] + ); + + const clearMessages = useCallback(() => { + setMessages([]); + }, []); + + return ( + <div className="border-t border-[rgba(117,170,252,0.35)] bg-[#0d1b2d]"> + {/* Messages Panel (expandable) */} + {expanded && messages.length > 0 && ( + <div + ref={messagesRef} + className="max-h-48 overflow-y-auto p-3 space-y-2 border-b border-[rgba(117,170,252,0.2)]" + > + {messages.map((msg) => ( + <div key={msg.id} className="font-mono text-xs"> + {msg.type === "user" && ( + <div className="flex gap-2"> + <span className="text-[#9bc3ff]">></span> + <span className="text-white/80">{msg.content}</span> + </div> + )} + {msg.type === "assistant" && ( + <div className="pl-4 space-y-1"> + <div className="text-[#75aafc]">{msg.content}</div> + {msg.toolCalls && msg.toolCalls.length > 0 && ( + <div className="text-[#555] text-[10px] space-y-0.5"> + {msg.toolCalls.map((tc, i) => ( + <div key={i}> + <span + className={ + tc.success ? "text-green-500" : "text-red-400" + } + > + {tc.success ? "+" : "x"} + </span>{" "} + {tc.name}: {tc.message} + </div> + ))} + </div> + )} + </div> + )} + {msg.type === "error" && ( + <div className="pl-4 text-red-400">{msg.content}</div> + )} + </div> + ))} + </div> + )} + + {/* Input Bar */} + <form onSubmit={handleSubmit} className="flex items-center gap-2 p-3"> + <span className="text-[#9bc3ff] font-mono text-sm">$</span> + <input + ref={inputRef} + type="text" + value={input} + onChange={(e) => setInput(e.target.value)} + placeholder={loading ? "Processing..." : "Add a heading, chart, or summary..."} + disabled={loading} + className="flex-1 bg-transparent border-none outline-none font-mono text-sm text-white placeholder-[#555]" + /> + {messages.length > 0 && ( + <button + type="button" + onClick={clearMessages} + className="text-[#555] hover:text-[#9bc3ff] font-mono text-xs transition-colors" + > + clear + </button> + )} + <button + type="submit" + disabled={loading || !input.trim()} + className="px-3 py-1 font-mono text-xs text-[#9bc3ff] border border-[rgba(117,170,252,0.25)] hover:border-[#3f6fb3] disabled:opacity-50 disabled:cursor-not-allowed transition-colors uppercase" + > + {loading ? "..." : "Send"} + </button> + </form> + </div> + ); +} diff --git a/makima/frontend/src/components/files/FileDetail.tsx b/makima/frontend/src/components/files/FileDetail.tsx index 643f35e..ffc67dd 100644 --- a/makima/frontend/src/components/files/FileDetail.tsx +++ b/makima/frontend/src/components/files/FileDetail.tsx @@ -1,5 +1,6 @@ -import { useState } from "react"; +import { useState, useEffect } from "react"; import type { FileDetail as FileDetailType } from "../../lib/api"; +import { BodyRenderer } from "./BodyRenderer"; interface FileDetailProps { file: FileDetailType; @@ -19,6 +20,13 @@ export function FileDetail({ const [isEditing, setIsEditing] = useState(false); const [name, setName] = useState(file.name); const [description, setDescription] = useState(file.description || ""); + const [transcriptExpanded, setTranscriptExpanded] = useState(false); + + // Update local state when file changes + useEffect(() => { + setName(file.name); + setDescription(file.description || ""); + }, [file.name, file.description]); const handleSave = () => { onSave(file.id, name, description); @@ -116,27 +124,70 @@ export function FileDetail({ )} </div> - {/* Transcript */} - <div className="flex-1 overflow-y-auto p-4 space-y-3"> - {file.transcript.length === 0 ? ( - <div className="text-center text-[#9bc3ff] text-sm font-mono opacity-60 py-8"> - No transcript entries. + {/* Content */} + <div className="flex-1 overflow-y-auto p-4 space-y-6"> + {/* Summary Section */} + {file.summary && ( + <div className="border-l-2 border-[#9bc3ff] pl-4"> + <h3 className="font-mono text-xs text-[#75aafc] uppercase mb-2"> + Summary + </h3> + <p className="font-mono text-sm text-[#dbe7ff] leading-relaxed"> + {file.summary} + </p> </div> - ) : ( - file.transcript.map((entry) => ( - <div key={entry.id} className="font-mono text-sm"> - <div className="flex items-baseline gap-2 mb-1"> - <span className="text-[#75aafc] text-xs"> - [{entry.start.toFixed(2)}s - {entry.end.toFixed(2)}s] - </span> - <span className="text-[#9bc3ff] text-xs font-bold"> - {entry.speaker} - </span> - </div> - <p className="m-0 text-[#dbe7ff] leading-relaxed">{entry.text}</p> - </div> - )) )} + + {/* Body Content */} + <div> + <h3 className="font-mono text-xs text-[#75aafc] uppercase mb-3"> + Content + </h3> + <BodyRenderer elements={file.body} /> + </div> + + {/* Collapsible Transcript Section */} + <div className="border-t border-dashed border-[rgba(117,170,252,0.35)] pt-4"> + <button + onClick={() => setTranscriptExpanded(!transcriptExpanded)} + className="flex items-center gap-2 font-mono text-xs text-[#75aafc] hover:text-[#9bc3ff] transition-colors uppercase w-full text-left" + > + <span + className={`transition-transform ${ + transcriptExpanded ? "rotate-90" : "" + }`} + > + > + </span> + Transcript ({file.transcript.length} entries) + </button> + + {transcriptExpanded && ( + <div className="mt-4 space-y-3 pl-4"> + {file.transcript.length === 0 ? ( + <div className="text-[#9bc3ff] text-sm font-mono opacity-60"> + No transcript entries. + </div> + ) : ( + file.transcript.map((entry) => ( + <div key={entry.id} className="font-mono text-sm"> + <div className="flex items-baseline gap-2 mb-1"> + <span className="text-[#75aafc] text-xs"> + [{entry.start.toFixed(2)}s - {entry.end.toFixed(2)}s] + </span> + <span className="text-[#9bc3ff] text-xs font-bold"> + {entry.speaker} + </span> + </div> + <p className="m-0 text-[#dbe7ff] leading-relaxed"> + {entry.text} + </p> + </div> + )) + )} + </div> + )} + </div> </div> </div> ); |
