diff options
Diffstat (limited to 'makima/frontend/src')
| -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 | ||||
| -rw-r--r-- | makima/frontend/src/lib/api.ts | 57 | ||||
| -rw-r--r-- | makima/frontend/src/routes/files.tsx | 41 |
6 files changed, 633 insertions, 30 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> ); diff --git a/makima/frontend/src/lib/api.ts b/makima/frontend/src/lib/api.ts index ec596ce..5ef9c22 100644 --- a/makima/frontend/src/lib/api.ts +++ b/makima/frontend/src/lib/api.ts @@ -49,6 +49,22 @@ export interface TranscriptEntry { isFinal: boolean; } +// Chart types for visualization +export type ChartType = "line" | "bar" | "pie" | "area"; + +// Body element types for structured content +export type BodyElement = + | { type: "heading"; level: number; text: string } + | { type: "paragraph"; text: string } + | { + type: "chart"; + chartType: ChartType; + title?: string; + data: Record<string, unknown>[]; + config?: Record<string, unknown>; + } + | { type: "image"; src: string; alt?: string; caption?: string }; + export interface FileSummary { id: string; name: string; @@ -66,6 +82,8 @@ export interface FileDetail { description: string | null; transcript: TranscriptEntry[]; location: string | null; + summary: string | null; + body: BodyElement[]; createdAt: string; updatedAt: string; } @@ -86,6 +104,28 @@ export interface UpdateFileRequest { name?: string; description?: string; transcript?: TranscriptEntry[]; + summary?: string; + body?: BodyElement[]; +} + +// Chat API types +export interface ChatRequest { + message: string; +} + +export interface ToolCallInfo { + name: string; + result: { + success: boolean; + message: string; + }; +} + +export interface ChatResponse { + response: string; + toolCalls: ToolCallInfo[]; + updatedBody: BodyElement[]; + updatedSummary: string | null; } // File API functions @@ -140,3 +180,20 @@ export async function deleteFile(id: string): Promise<void> { throw new Error(`Failed to delete file: ${res.statusText}`); } } + +// Chat API function +export async function chatWithFile( + id: string, + message: string +): Promise<ChatResponse> { + const res = await fetch(`${API_BASE}/api/v1/files/${id}/chat`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ message }), + }); + if (!res.ok) { + const errorText = await res.text(); + throw new Error(`Chat failed: ${errorText || res.statusText}`); + } + return res.json(); +} diff --git a/makima/frontend/src/routes/files.tsx b/makima/frontend/src/routes/files.tsx index 86a24b8..00c334d 100644 --- a/makima/frontend/src/routes/files.tsx +++ b/makima/frontend/src/routes/files.tsx @@ -3,8 +3,9 @@ import { useParams, useNavigate } from "react-router"; import { Masthead } from "../components/Masthead"; import { FileList } from "../components/files/FileList"; import { FileDetail } from "../components/files/FileDetail"; +import { CliInput } from "../components/files/CliInput"; import { useFiles } from "../hooks/useFiles"; -import type { FileDetail as FileDetailType } from "../lib/api"; +import type { FileDetail as FileDetailType, BodyElement } from "../lib/api"; export default function FilesPage() { const { id } = useParams<{ id: string }>(); @@ -58,25 +59,45 @@ export default function FilesPage() { [editFile, fetchFile] ); + const handleBodyUpdate = useCallback( + (body: BodyElement[], summary: string | null) => { + if (fileDetail) { + setFileDetail({ + ...fileDetail, + body, + summary, + }); + } + }, + [fileDetail] + ); + return ( <div className="relative z-10 h-screen flex flex-col overflow-hidden"> <Masthead showTicker={false} showNav /> - <main className="flex-1 p-4 md:p-6 min-h-0 overflow-hidden"> + <main className="flex-1 p-4 md:p-6 min-h-0 overflow-hidden flex flex-col"> {error && ( - <div className="mb-4 p-3 border border-red-400/50 bg-red-400/10 text-red-400 font-mono text-sm"> + <div className="mb-4 p-3 border border-red-400/50 bg-red-400/10 text-red-400 font-mono text-sm shrink-0"> {error} </div> )} {id && fileDetail ? ( - <FileDetail - file={fileDetail} - loading={detailLoading} - onBack={handleBack} - onSave={handleSave} - onDelete={handleDelete} - /> + <div className="flex-1 flex flex-col min-h-0 overflow-hidden"> + <div className="flex-1 min-h-0 overflow-hidden"> + <FileDetail + file={fileDetail} + loading={detailLoading} + onBack={handleBack} + onSave={handleSave} + onDelete={handleDelete} + /> + </div> + <div className="shrink-0"> + <CliInput fileId={id} onUpdate={handleBodyUpdate} /> + </div> + </div> ) : id && detailLoading ? ( <div className="panel h-full flex items-center justify-center"> <div className="font-mono text-[#9bc3ff] text-sm">Loading...</div> |
