summaryrefslogtreecommitdiff
path: root/makima/frontend/src/components
diff options
context:
space:
mode:
authorsoryu <soryu@soryu.co>2025-12-23 14:43:23 +0000
committersoryu <soryu@soryu.co>2025-12-23 14:47:18 +0000
commit555061b179b8ec034cb70f9a2dd6c823ced0f637 (patch)
tree0545b4395dab6d957884d8d36bf15b8da529dc1f /makima/frontend/src/components
parenta32dc56d2e5447ef8988cb98b8686476cc94e70c (diff)
downloadsoryu-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.tsx181
-rw-r--r--makima/frontend/src/components/files/BodyRenderer.tsx125
-rw-r--r--makima/frontend/src/components/files/CliInput.tsx168
-rw-r--r--makima/frontend/src/components/files/FileDetail.tsx91
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]">&gt;</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" : ""
+ }`}
+ >
+ &gt;
+ </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>
);