diff options
| author | soryu <soryu@soryu.co> | 2025-12-24 05:45:22 +0000 |
|---|---|---|
| committer | soryu <soryu@soryu.co> | 2025-12-24 05:45:22 +0000 |
| commit | 2faba0388f93d8e4fb86219eba7883b331d501ff (patch) | |
| tree | 92b83b8d558a652d3777627b2ac95ded250faa48 | |
| parent | 8f016a0e9d14badc39dffd67ed6fb862f9d08496 (diff) | |
| download | soryu-2faba0388f93d8e4fb86219eba7883b331d501ff.tar.gz soryu-2faba0388f93d8e4fb86219eba7883b331d501ff.zip | |
Add versioning to files
| -rw-r--r-- | makima/frontend/src/components/files/FileDetail.tsx | 35 | ||||
| -rw-r--r-- | makima/frontend/src/components/files/UpdateNotification.tsx | 118 | ||||
| -rw-r--r-- | makima/frontend/src/components/files/VersionHistoryDropdown.tsx | 263 | ||||
| -rw-r--r-- | makima/frontend/src/hooks/useVersionHistory.ts | 137 | ||||
| -rw-r--r-- | makima/frontend/src/lib/api.ts | 135 | ||||
| -rw-r--r-- | makima/frontend/src/routes/files.tsx | 207 | ||||
| -rw-r--r-- | makima/frontend/tsconfig.tsbuildinfo | 2 | ||||
| -rw-r--r-- | makima/migrations/20241225000000_add_file_versions.sql | 49 | ||||
| -rw-r--r-- | makima/src/db/models.rs | 96 | ||||
| -rw-r--r-- | makima/src/db/repository.rs | 144 | ||||
| -rw-r--r-- | makima/src/llm/mod.rs | 2 | ||||
| -rw-r--r-- | makima/src/llm/tools.rs | 168 | ||||
| -rw-r--r-- | makima/src/server/handlers/chat.rs | 246 | ||||
| -rw-r--r-- | makima/src/server/handlers/mod.rs | 1 | ||||
| -rw-r--r-- | makima/src/server/handlers/versions.rs | 207 | ||||
| -rw-r--r-- | makima/src/server/mod.rs | 6 |
16 files changed, 1757 insertions, 59 deletions
diff --git a/makima/frontend/src/components/files/FileDetail.tsx b/makima/frontend/src/components/files/FileDetail.tsx index 29311b8..c7b716a 100644 --- a/makima/frontend/src/components/files/FileDetail.tsx +++ b/makima/frontend/src/components/files/FileDetail.tsx @@ -1,6 +1,7 @@ import { useState, useEffect } from "react"; -import type { FileDetail as FileDetailType } from "../../lib/api"; +import type { FileDetail as FileDetailType, FileVersionSummary, FileVersion } from "../../lib/api"; import { BodyRenderer } from "./BodyRenderer"; +import { VersionHistoryDropdown } from "./VersionHistoryDropdown"; interface FileDetailProps { file: FileDetailType; @@ -13,6 +14,15 @@ interface FileDetailProps { onEditingChange?: (isEditing: boolean) => void; hasPendingRemoteUpdate?: boolean; onOverwrite?: () => void; + // Version history props + versions?: FileVersionSummary[]; + versionsLoading?: boolean; + selectedVersion?: FileVersion | null; + loadingVersion?: boolean; + restoring?: boolean; + onSelectVersion?: (version: number) => void; + onRestoreVersion?: (version: number) => void; + onClearVersionSelection?: () => void; } export function FileDetail({ @@ -26,6 +36,14 @@ export function FileDetail({ onEditingChange, hasPendingRemoteUpdate, onOverwrite, + versions = [], + versionsLoading = false, + selectedVersion = null, + loadingVersion = false, + restoring = false, + onSelectVersion, + onRestoreVersion, + onClearVersionSelection, }: FileDetailProps) { const [isEditing, setIsEditing] = useState(false); const [name, setName] = useState(file.name); @@ -68,9 +86,22 @@ export function FileDetail({ > ← Back to list </button> - <div className="flex gap-2"> + <div className="flex items-center gap-2"> {isEditing ? ( <> + {onSelectVersion && onRestoreVersion && onClearVersionSelection && ( + <VersionHistoryDropdown + currentVersion={file.version} + versions={versions} + loading={versionsLoading} + selectedVersion={selectedVersion} + loadingVersion={loadingVersion} + onSelectVersion={onSelectVersion} + onRestoreVersion={onRestoreVersion} + onClearSelection={onClearVersionSelection} + restoring={restoring} + /> + )} <button onClick={handleCancel} className="px-3 py-1.5 font-mono text-xs text-[#9bc3ff] border border-[rgba(117,170,252,0.25)] hover:border-[#3f6fb3] transition-colors uppercase" diff --git a/makima/frontend/src/components/files/UpdateNotification.tsx b/makima/frontend/src/components/files/UpdateNotification.tsx index c87d535..863f951 100644 --- a/makima/frontend/src/components/files/UpdateNotification.tsx +++ b/makima/frontend/src/components/files/UpdateNotification.tsx @@ -39,12 +39,75 @@ function getElementTypeLabel(element: BodyElement): string { } } +// Word-level diff for showing inline changes +interface WordDiff { + type: "same" | "added" | "removed"; + text: string; +} + +function computeWordDiff(oldText: string, newText: string): WordDiff[] { + const oldWords = oldText.split(/(\s+)/); + const newWords = newText.split(/(\s+)/); + const result: WordDiff[] = []; + + // Simple LCS-based diff + const m = oldWords.length; + const n = newWords.length; + + // Build LCS table + const dp: number[][] = Array(m + 1) + .fill(null) + .map(() => Array(n + 1).fill(0)); + + for (let i = 1; i <= m; i++) { + for (let j = 1; j <= n; j++) { + if (oldWords[i - 1] === newWords[j - 1]) { + dp[i][j] = dp[i - 1][j - 1] + 1; + } else { + dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]); + } + } + } + + // Backtrack to find diff + let i = m, + j = n; + const diffStack: WordDiff[] = []; + + while (i > 0 || j > 0) { + if (i > 0 && j > 0 && oldWords[i - 1] === newWords[j - 1]) { + diffStack.push({ type: "same", text: oldWords[i - 1] }); + i--; + j--; + } else if (j > 0 && (i === 0 || dp[i][j - 1] >= dp[i - 1][j])) { + diffStack.push({ type: "added", text: newWords[j - 1] }); + j--; + } else { + diffStack.push({ type: "removed", text: oldWords[i - 1] }); + i--; + } + } + + // Reverse and merge consecutive same-type diffs + for (let k = diffStack.length - 1; k >= 0; k--) { + const item = diffStack[k]; + if (result.length > 0 && result[result.length - 1].type === item.type) { + result[result.length - 1].text += item.text; + } else { + result.push({ ...item }); + } + } + + return result; +} + interface DiffItem { type: "added" | "removed" | "modified" | "unchanged"; localElement?: BodyElement; remoteElement?: BodyElement; localIndex?: number; remoteIndex?: number; + wordDiff?: WordDiff[]; } // Simple diff algorithm - compares elements by their text content @@ -66,13 +129,15 @@ function computeDiff(localBody: BodyElement[], remoteBody: BodyElement[]): DiffI const localText = getElementText(local); const remoteText = getElementText(remote); if (localText !== remoteText || local.type !== remote.type) { - // Element modified + // Element modified - compute word-level diff + const wordDiff = computeWordDiff(localText, remoteText); diffs.push({ type: "modified", localElement: local, remoteElement: remote, localIndex: i, remoteIndex: i, + wordDiff, }); } // Skip unchanged elements @@ -186,20 +251,49 @@ function DiffItemView({ diff }: { diff: DiffItem }) { [{getElementTypeLabel(diff.localElement!)}] </span> </div> - <div className="space-y-2"> - <div> - <div className="text-[#555] text-[10px] font-mono uppercase mb-1">Your version:</div> - <div className="font-mono text-xs text-red-300/70 break-words bg-red-500/10 p-2 border-l-2 border-red-500/50"> - {truncateText(getElementText(diff.localElement!), 120)} - </div> + {diff.wordDiff ? ( + <div className="font-mono text-xs break-words leading-relaxed"> + {diff.wordDiff.map((word, idx) => { + if (word.type === "same") { + return ( + <span key={idx} className="text-white/60"> + {word.text} + </span> + ); + } else if (word.type === "removed") { + return ( + <span + key={idx} + className="bg-red-500/30 text-red-300 line-through" + > + {word.text} + </span> + ); + } else { + return ( + <span key={idx} className="bg-green-500/30 text-green-300"> + {word.text} + </span> + ); + } + })} </div> - <div> - <div className="text-[#555] text-[10px] font-mono uppercase mb-1">Remote version:</div> - <div className="font-mono text-xs text-green-300/70 break-words bg-green-500/10 p-2 border-l-2 border-green-500/50"> - {truncateText(getElementText(diff.remoteElement!), 120)} + ) : ( + <div className="space-y-2"> + <div> + <div className="text-[#555] text-[10px] font-mono uppercase mb-1">Your version:</div> + <div className="font-mono text-xs text-red-300/70 break-words bg-red-500/10 p-2 border-l-2 border-red-500/50"> + {getElementText(diff.localElement!)} + </div> + </div> + <div> + <div className="text-[#555] text-[10px] font-mono uppercase mb-1">Remote version:</div> + <div className="font-mono text-xs text-green-300/70 break-words bg-green-500/10 p-2 border-l-2 border-green-500/50"> + {getElementText(diff.remoteElement!)} + </div> </div> </div> - </div> + )} </div> ); } diff --git a/makima/frontend/src/components/files/VersionHistoryDropdown.tsx b/makima/frontend/src/components/files/VersionHistoryDropdown.tsx new file mode 100644 index 0000000..50e6f0a --- /dev/null +++ b/makima/frontend/src/components/files/VersionHistoryDropdown.tsx @@ -0,0 +1,263 @@ +import { useState, useEffect, useRef } from "react"; +import type { FileVersionSummary, FileVersion, VersionSource } from "../../lib/api"; + +interface VersionHistoryDropdownProps { + currentVersion: number; + versions: FileVersionSummary[]; + loading: boolean; + selectedVersion: FileVersion | null; + loadingVersion: boolean; + onSelectVersion: (version: number) => void; + onRestoreVersion: (version: number) => void; + onClearSelection: () => void; + restoring: boolean; +} + +function formatDate(dateString: string): string { + const date = new Date(dateString); + return date.toLocaleString("en-US", { + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }); +} + +function getSourceLabel(source: VersionSource): string { + switch (source) { + case "user": + return "User"; + case "llm": + return "AI"; + case "system": + return "System"; + } +} + +function getSourceColor(source: VersionSource): string { + switch (source) { + case "user": + return "text-blue-400"; + case "llm": + return "text-purple-400"; + case "system": + return "text-gray-400"; + } +} + +export function VersionHistoryDropdown({ + currentVersion, + versions, + loading, + selectedVersion, + loadingVersion, + onSelectVersion, + onRestoreVersion, + onClearSelection, + restoring, +}: VersionHistoryDropdownProps) { + const [isOpen, setIsOpen] = useState(false); + const [showConfirm, setShowConfirm] = useState(false); + const [versionToRestore, setVersionToRestore] = useState<number | null>(null); + const dropdownRef = useRef<HTMLDivElement>(null); + + // Close dropdown when clicking outside + useEffect(() => { + function handleClickOutside(event: MouseEvent) { + if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { + setIsOpen(false); + } + } + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + }, []); + + const handleVersionClick = (version: number) => { + if (version === currentVersion) return; + onSelectVersion(version); + setIsOpen(false); + }; + + const handleRestoreClick = (version: number) => { + setVersionToRestore(version); + setShowConfirm(true); + }; + + const handleConfirmRestore = () => { + if (versionToRestore !== null) { + onRestoreVersion(versionToRestore); + } + setShowConfirm(false); + setVersionToRestore(null); + }; + + const handleCancelRestore = () => { + setShowConfirm(false); + setVersionToRestore(null); + onClearSelection(); + }; + + // If showing the selected version preview + if (selectedVersion) { + return ( + <div className="border border-[#3f6fb3]/50 bg-[#0d1b2d] p-4"> + <div className="flex items-center justify-between mb-3"> + <div className="flex items-center gap-2"> + <span className="font-mono text-xs text-[#75aafc] uppercase">Viewing Version</span> + <span className="font-mono text-sm text-[#dbe7ff] font-bold"> + v{selectedVersion.version} + </span> + <span className={`font-mono text-xs ${getSourceColor(selectedVersion.source)}`}> + ({getSourceLabel(selectedVersion.source)}) + </span> + </div> + <button + onClick={onClearSelection} + className="font-mono text-xs text-[#555] hover:text-white/70" + > + Close + </button> + </div> + + <div className="text-[10px] font-mono text-[#555] mb-3"> + {formatDate(selectedVersion.createdAt)} + {selectedVersion.changeDescription && ( + <span className="ml-2 text-[#9bc3ff]">- {selectedVersion.changeDescription}</span> + )} + </div> + + {/* Preview of version content */} + <div className="max-h-48 overflow-y-auto mb-4 border border-[#333] bg-black/20 p-3"> + {selectedVersion.body.length === 0 ? ( + <div className="text-[#555] text-xs italic">No content</div> + ) : ( + <div className="space-y-2"> + {selectedVersion.body.map((element, index) => ( + <div key={index} className="font-mono text-xs text-white/70"> + {element.type === "heading" && ( + <div className="text-[#9bc3ff] font-bold"> + {"#".repeat(element.level)} {element.text} + </div> + )} + {element.type === "paragraph" && ( + <div className="text-white/60">{element.text}</div> + )} + {element.type === "chart" && ( + <div className="text-purple-400">[Chart: {element.title || element.chartType}]</div> + )} + {element.type === "image" && ( + <div className="text-green-400">[Image: {element.alt || element.src}]</div> + )} + </div> + ))} + </div> + )} + </div> + + {/* Restore button */} + {!showConfirm ? ( + <button + onClick={() => handleRestoreClick(selectedVersion.version)} + disabled={restoring} + className="w-full px-3 py-2 font-mono text-xs text-[#dbe7ff] bg-[#0f3c78] border border-[#3f6fb3] hover:bg-[#153667] transition-colors disabled:opacity-50" + > + {restoring ? "Restoring..." : "Restore This Version"} + </button> + ) : ( + <div className="border border-yellow-500/30 bg-yellow-500/10 p-3"> + <div className="font-mono text-xs text-yellow-400 mb-2"> + Are you sure you want to restore to version {versionToRestore}? + </div> + <div className="font-mono text-[10px] text-white/50 mb-3"> + This will create a new version with the content from v{versionToRestore}. + Your current changes will be preserved as a separate version. + </div> + <div className="flex gap-2"> + <button + onClick={handleConfirmRestore} + disabled={restoring} + className="flex-1 px-3 py-1.5 font-mono text-xs text-[#dbe7ff] bg-yellow-600/50 border border-yellow-500/50 hover:bg-yellow-600/70 transition-colors disabled:opacity-50" + > + {restoring ? "Restoring..." : "Confirm Restore"} + </button> + <button + onClick={handleCancelRestore} + disabled={restoring} + className="px-3 py-1.5 font-mono text-xs text-[#555] border border-[#333] hover:text-white/70" + > + Cancel + </button> + </div> + </div> + )} + </div> + ); + } + + return ( + <div ref={dropdownRef} className="relative"> + <button + onClick={() => setIsOpen(!isOpen)} + className="flex items-center gap-2 px-3 py-1.5 font-mono text-xs text-[#9bc3ff] border border-[rgba(117,170,252,0.25)] hover:border-[#3f6fb3] transition-colors" + > + <span>v{currentVersion}</span> + <svg + width="10" + height="6" + viewBox="0 0 10 6" + fill="none" + className={`transition-transform ${isOpen ? "rotate-180" : ""}`} + > + <path d="M1 1L5 5L9 1" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" /> + </svg> + </button> + + {isOpen && ( + <div className="absolute top-full left-0 mt-1 w-64 max-h-72 overflow-y-auto bg-[#0d1b2d] border border-[#3f6fb3]/50 shadow-lg z-50"> + <div className="p-2 border-b border-[#333] font-mono text-[10px] text-[#555] uppercase"> + Version History + </div> + + {loading ? ( + <div className="p-4 text-center font-mono text-xs text-[#555]">Loading...</div> + ) : versions.length === 0 ? ( + <div className="p-4 text-center font-mono text-xs text-[#555]">No versions found</div> + ) : ( + <div> + {versions.map((version) => ( + <button + key={version.version} + onClick={() => handleVersionClick(version.version)} + disabled={version.version === currentVersion || loadingVersion} + className={`w-full text-left px-3 py-2 font-mono text-xs transition-colors ${ + version.version === currentVersion + ? "bg-[#0f3c78]/50 text-[#dbe7ff]" + : "hover:bg-[#0f3c78]/30 text-white/70" + } ${loadingVersion ? "opacity-50" : ""}`} + > + <div className="flex items-center justify-between"> + <span className="font-bold">v{version.version}</span> + <span className={getSourceColor(version.source)}> + {getSourceLabel(version.source)} + </span> + </div> + <div className="text-[10px] text-[#555] mt-0.5"> + {formatDate(version.createdAt)} + </div> + {version.changeDescription && ( + <div className="text-[10px] text-[#75aafc] mt-0.5 truncate"> + {version.changeDescription} + </div> + )} + {version.version === currentVersion && ( + <div className="text-[10px] text-green-400 mt-0.5">(current)</div> + )} + </button> + ))} + </div> + )} + </div> + )} + </div> + ); +} diff --git a/makima/frontend/src/hooks/useVersionHistory.ts b/makima/frontend/src/hooks/useVersionHistory.ts new file mode 100644 index 0000000..f9d4122 --- /dev/null +++ b/makima/frontend/src/hooks/useVersionHistory.ts @@ -0,0 +1,137 @@ +import { useState, useCallback, useEffect } from "react"; +import { + listFileVersions, + getFileVersion, + restoreFileVersion, + type FileVersionSummary, + type FileVersion, + type FileDetail, + VersionConflictError, +} from "../lib/api"; + +export interface UseVersionHistoryOptions { + fileId: string | null; + currentVersion: number; +} + +export interface VersionHistoryState { + versions: FileVersionSummary[]; + loading: boolean; + error: string | null; + selectedVersion: FileVersion | null; + loadingVersion: boolean; +} + +export function useVersionHistory(options: UseVersionHistoryOptions) { + const { fileId, currentVersion } = options; + const [versions, setVersions] = useState<FileVersionSummary[]>([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState<string | null>(null); + const [selectedVersion, setSelectedVersion] = useState<FileVersion | null>(null); + const [loadingVersion, setLoadingVersion] = useState(false); + const [restoring, setRestoring] = useState(false); + const [conflict, setConflict] = useState<{ expected: number; actual: number } | null>(null); + + // Fetch version list + const fetchVersions = useCallback(async () => { + if (!fileId) return; + + setLoading(true); + setError(null); + try { + const response = await listFileVersions(fileId); + setVersions(response.versions); + } catch (e) { + setError(e instanceof Error ? e.message : "Failed to fetch versions"); + } finally { + setLoading(false); + } + }, [fileId]); + + // Fetch a specific version's content + const fetchVersion = useCallback( + async (version: number): Promise<FileVersion | null> => { + if (!fileId) return null; + + setLoadingVersion(true); + setError(null); + try { + const versionData = await getFileVersion(fileId, version); + setSelectedVersion(versionData); + return versionData; + } catch (e) { + setError(e instanceof Error ? e.message : "Failed to fetch version"); + return null; + } finally { + setLoadingVersion(false); + } + }, + [fileId] + ); + + // Restore to a specific version (creates a new version with that content) + const restoreToVersion = useCallback( + async (targetVersion: number): Promise<FileDetail | null> => { + if (!fileId) return null; + + setRestoring(true); + setError(null); + setConflict(null); + try { + const result = await restoreFileVersion(fileId, targetVersion, currentVersion); + // Refresh version list after restore + await fetchVersions(); + setSelectedVersion(null); + return result; + } catch (e) { + if (e instanceof VersionConflictError) { + setConflict({ + expected: e.expectedVersion, + actual: e.actualVersion, + }); + return null; + } + setError(e instanceof Error ? e.message : "Failed to restore version"); + return null; + } finally { + setRestoring(false); + } + }, + [fileId, currentVersion, fetchVersions] + ); + + // Clear selected version + const clearSelectedVersion = useCallback(() => { + setSelectedVersion(null); + }, []); + + // Clear conflict + const clearConflict = useCallback(() => { + setConflict(null); + }, []); + + // Fetch versions when fileId changes + useEffect(() => { + if (fileId) { + fetchVersions(); + } else { + setVersions([]); + setSelectedVersion(null); + } + }, [fileId, fetchVersions]); + + return { + versions, + loading, + error, + selectedVersion, + loadingVersion, + restoring, + conflict, + fetchVersions, + fetchVersion, + restoreToVersion, + clearSelectedVersion, + clearConflict, + }; +} diff --git a/makima/frontend/src/lib/api.ts b/makima/frontend/src/lib/api.ts index f1e3a9f..931981b 100644 --- a/makima/frontend/src/lib/api.ts +++ b/makima/frontend/src/lib/api.ts @@ -236,3 +236,138 @@ export async function chatWithFile( } return res.json(); } + +// Version history types +export type VersionSource = "user" | "llm" | "system"; + +export interface FileVersion { + version: number; + name: string; + description: string | null; + summary: string | null; + body: BodyElement[]; + source: VersionSource; + createdAt: string; + changeDescription?: string; +} + +export interface FileVersionSummary { + version: number; + source: VersionSource; + createdAt: string; + changeDescription?: string; +} + +export interface FileVersionListResponse { + versions: FileVersionSummary[]; + total: number; +} + +export interface RestoreVersionRequest { + targetVersion: number; +} + +// Version history API functions +export async function listFileVersions(fileId: string): Promise<FileVersionListResponse> { + const res = await fetch(`${API_BASE}/api/v1/files/${fileId}/versions`); + if (!res.ok) { + throw new Error(`Failed to list versions: ${res.statusText}`); + } + return res.json(); +} + +export async function getFileVersion(fileId: string, version: number): Promise<FileVersion> { + const res = await fetch(`${API_BASE}/api/v1/files/${fileId}/versions/${version}`); + if (!res.ok) { + throw new Error(`Failed to get version: ${res.statusText}`); + } + return res.json(); +} + +export async function restoreFileVersion( + fileId: string, + targetVersion: number, + currentVersion: number +): Promise<FileDetail> { + const res = await fetch(`${API_BASE}/api/v1/files/${fileId}/versions/restore`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ targetVersion, currentVersion }), + }); + + if (res.status === 409) { + const conflict = (await res.json()) as ConflictErrorResponse; + throw new VersionConflictError(conflict); + } + + if (!res.ok) { + throw new Error(`Failed to restore version: ${res.statusText}`); + } + return res.json(); +} + +// ============================================================================= +// LLM Tool Definitions for Version History +// ============================================================================= +// These types define the tools available to the LLM for version history access. +// The backend should implement handlers for these tools. + +/** + * Tool: read_version + * Allows the LLM to read the content of a specific historical version. + * This is read-only - it does not modify the document. + */ +export interface ReadVersionToolInput { + version: number; +} + +export interface ReadVersionToolOutput { + success: boolean; + version: number; + body: BodyElement[]; + summary: string | null; + source: VersionSource; + createdAt: string; + changeDescription?: string; + message: string; +} + +/** + * Tool: list_versions + * Allows the LLM to list all available versions of the document. + */ +export interface ListVersionsToolOutput { + success: boolean; + versions: FileVersionSummary[]; + currentVersion: number; + message: string; +} + +/** + * Tool: restore_version + * Allows the LLM to restore the document to a specific historical version. + * This creates a new version with the content from the target version. + */ +export interface RestoreVersionToolInput { + targetVersion: number; + reason?: string; +} + +export interface RestoreVersionToolOutput { + success: boolean; + previousVersion: number; + newVersion: number; + restoredFromVersion: number; + message: string; +} + +// LLM Tool type definitions for the backend +export type LlmVersionTool = + | { name: "read_version"; input: ReadVersionToolInput } + | { name: "list_versions"; input: Record<string, never> } + | { name: "restore_version"; input: RestoreVersionToolInput }; + +export type LlmVersionToolResult = + | { name: "read_version"; result: ReadVersionToolOutput } + | { name: "list_versions"; result: ListVersionsToolOutput } + | { name: "restore_version"; result: RestoreVersionToolOutput }; diff --git a/makima/frontend/src/routes/files.tsx b/makima/frontend/src/routes/files.tsx index f398041..0d870f7 100644 --- a/makima/frontend/src/routes/files.tsx +++ b/makima/frontend/src/routes/files.tsx @@ -7,6 +7,7 @@ import { CliInput } from "../components/files/CliInput"; import { ConflictNotification } from "../components/files/ConflictNotification"; import { UpdateNotification } from "../components/files/UpdateNotification"; import { useFiles } from "../hooks/useFiles"; +import { useVersionHistory } from "../hooks/useVersionHistory"; import { useFileSubscription, type FileUpdateEvent, @@ -22,37 +23,121 @@ export default function FilesPage() { const [creating, setCreating] = useState(false); const [remoteUpdate, setRemoteUpdate] = useState<FileUpdateEvent | null>(null); const [remoteFileData, setRemoteFileData] = useState<FileDetailType | null>(null); - const [hasLocalChanges, setHasLocalChanges] = useState(false); - const [isActivelyEditing, setIsActivelyEditing] = useState(false); const pendingUpdateRef = useRef(false); + // Track the last version we sent to detect our own updates + const lastSentVersionRef = useRef<number | null>(null); + // Track the version we just successfully saved (to ignore its WebSocket notification) + const lastSavedVersionRef = useRef<number | null>(null); + // Use refs for values checked in WebSocket callback to avoid stale closures + const hasLocalChangesRef = useRef(false); + const isActivelyEditingRef = useRef(false); + const currentVersionRef = useRef<number | null>(null); + + // Helper functions to update refs (used only in callbacks, not for rendering) + const updateHasLocalChanges = useCallback((value: boolean) => { + hasLocalChangesRef.current = value; + }, []); + + const updateIsActivelyEditing = useCallback((value: boolean) => { + isActivelyEditingRef.current = value; + }, []); + + // Version history + const { + versions, + loading: versionsLoading, + selectedVersion, + loadingVersion, + restoring, + fetchVersion, + restoreToVersion, + clearSelectedVersion, + fetchVersions, + } = useVersionHistory({ + fileId: id || null, + currentVersion: fileDetail?.version || 0, + }); + + // Handle version restore + const handleRestoreVersion = useCallback( + async (targetVersion: number) => { + const result = await restoreToVersion(targetVersion); + if (result) { + currentVersionRef.current = result.version; + setFileDetail(result); + updateHasLocalChanges(false); + // Refresh version list after restore + fetchVersions(); + } + }, + [restoreToVersion, fetchVersions, updateHasLocalChanges] + ); // Load file detail when URL has an id useEffect(() => { if (id) { setDetailLoading(true); - setHasLocalChanges(false); + updateHasLocalChanges(false); + // Reset pending update tracking when switching files + pendingUpdateRef.current = false; + lastSentVersionRef.current = null; + lastSavedVersionRef.current = null; + currentVersionRef.current = null; + setRemoteUpdate(null); + setRemoteFileData(null); fetchFile(id).then((detail) => { + if (detail) { + currentVersionRef.current = detail.version; + } setFileDetail(detail); setDetailLoading(false); }); } else { setFileDetail(null); - setHasLocalChanges(false); + currentVersionRef.current = null; + updateHasLocalChanges(false); } - }, [id, fetchFile]); + }, [id, fetchFile, updateHasLocalChanges]); // Handle file update events from WebSocket const handleFileUpdate = useCallback( async (event: FileUpdateEvent) => { - // Ignore our own updates + // Check if this is a version we just saved - ignore it + // This handles the case where the WebSocket arrives after the HTTP response + if (lastSavedVersionRef.current !== null && event.version === lastSavedVersionRef.current) { + lastSavedVersionRef.current = null; + return; + } + + // If we have a pending update, check if this is our own update if (pendingUpdateRef.current) { - pendingUpdateRef.current = false; + if (lastSentVersionRef.current !== null) { + const expectedNewVersion = lastSentVersionRef.current + 1; + if (event.version === expectedNewVersion) { + // This is our own update - ignore it + pendingUpdateRef.current = false; + lastSentVersionRef.current = null; + return; + } + } + // We sent an update but received a different version - could be a race condition + // Still ignore since we have an update in flight + return; + } + + // Check if this version matches what we already have + // This catches cases where our save's WebSocket arrives late + if (currentVersionRef.current !== null && event.version === currentVersionRef.current) { return; } // If no local changes and not actively editing, auto-refresh - if (!hasLocalChanges && !isActivelyEditing) { + // Use refs to get current values (avoid stale closure issues) + if (!hasLocalChangesRef.current && !isActivelyEditingRef.current) { const detail = await fetchFile(event.fileId); + if (detail) { + currentVersionRef.current = detail.version; + } setFileDetail(detail); } else { // Fetch remote version for diff display @@ -62,7 +147,7 @@ export default function FilesPage() { setRemoteUpdate(event); } }, - [hasLocalChanges, isActivelyEditing, fetchFile] + [fetchFile] ); // Subscribe to file updates @@ -98,13 +183,22 @@ export default function FilesPage() { async (fileId: string, name: string, description: string) => { if (!fileDetail) return; pendingUpdateRef.current = true; - const result = await editFile(fileId, { name, description, version: fileDetail.version }); - if (result) { - setFileDetail(result); - setHasLocalChanges(false); + lastSentVersionRef.current = fileDetail.version; + try { + const result = await editFile(fileId, { name, description, version: fileDetail.version }); + if (result) { + // Track the saved version to ignore its WebSocket notification + lastSavedVersionRef.current = result.version; + currentVersionRef.current = result.version; + setFileDetail(result); + updateHasLocalChanges(false); + } + } finally { + pendingUpdateRef.current = false; + lastSentVersionRef.current = null; } }, - [editFile, fileDetail] + [editFile, fileDetail, updateHasLocalChanges] ); const handleBodyUpdate = useCallback( @@ -132,18 +226,27 @@ export default function FilesPage() { ...fileDetail, body: newBody, }); - setHasLocalChanges(true); + updateHasLocalChanges(true); // Save to backend with version for optimistic locking pendingUpdateRef.current = true; - const result = await editFile(id, { body: newBody, version: fileDetail.version }); - if (result) { - setFileDetail(result); - setHasLocalChanges(false); + lastSentVersionRef.current = fileDetail.version; + try { + const result = await editFile(id, { body: newBody, version: fileDetail.version }); + if (result) { + // Track the saved version to ignore its WebSocket notification + lastSavedVersionRef.current = result.version; + currentVersionRef.current = result.version; + setFileDetail(result); + updateHasLocalChanges(false); + } + } finally { + pendingUpdateRef.current = false; + lastSentVersionRef.current = null; } } }, - [fileDetail, id, editFile] + [fileDetail, id, editFile, updateHasLocalChanges] ); const handleBodyReorder = useCallback( @@ -159,18 +262,27 @@ export default function FilesPage() { ...fileDetail, body: newBody, }); - setHasLocalChanges(true); + updateHasLocalChanges(true); // Save to backend with version for optimistic locking pendingUpdateRef.current = true; - const result = await editFile(id, { body: newBody, version: fileDetail.version }); - if (result) { - setFileDetail(result); - setHasLocalChanges(false); + lastSentVersionRef.current = fileDetail.version; + try { + const result = await editFile(id, { body: newBody, version: fileDetail.version }); + if (result) { + // Track the saved version to ignore its WebSocket notification + lastSavedVersionRef.current = result.version; + currentVersionRef.current = result.version; + setFileDetail(result); + updateHasLocalChanges(false); + } + } finally { + pendingUpdateRef.current = false; + lastSentVersionRef.current = null; } } }, - [fileDetail, id, editFile] + [fileDetail, id, editFile, updateHasLocalChanges] ); const handleCreate = useCallback(async () => { @@ -194,10 +306,13 @@ export default function FilesPage() { if (id) { clearConflict(); const detail = await fetchFile(id); + if (detail) { + currentVersionRef.current = detail.version; + } setFileDetail(detail); - setHasLocalChanges(false); + updateHasLocalChanges(false); } - }, [id, clearConflict, fetchFile]); + }, [id, clearConflict, fetchFile, updateHasLocalChanges]); const handleConflictForceOverwrite = useCallback(async () => { if (id && fileDetail) { @@ -207,25 +322,37 @@ export default function FilesPage() { if (latest) { // Retry with latest version pendingUpdateRef.current = true; - const result = await editFile(id, { body: fileDetail.body, version: latest.version }); - if (result) { - setFileDetail(result); - setHasLocalChanges(false); + lastSentVersionRef.current = latest.version; + try { + const result = await editFile(id, { body: fileDetail.body, version: latest.version }); + if (result) { + // Track the saved version to ignore its WebSocket notification + lastSavedVersionRef.current = result.version; + currentVersionRef.current = result.version; + setFileDetail(result); + updateHasLocalChanges(false); + } + } finally { + pendingUpdateRef.current = false; + lastSentVersionRef.current = null; } } } - }, [id, fileDetail, clearConflict, fetchFile, editFile]); + }, [id, fileDetail, clearConflict, fetchFile, editFile, updateHasLocalChanges]); // Remote update handlers const handleRemoteUpdateRefresh = useCallback(async () => { if (id) { const detail = await fetchFile(id); + if (detail) { + currentVersionRef.current = detail.version; + } setFileDetail(detail); setRemoteUpdate(null); setRemoteFileData(null); - setHasLocalChanges(false); + updateHasLocalChanges(false); } - }, [id, fetchFile]); + }, [id, fetchFile, updateHasLocalChanges]); const handleRemoteUpdateDismiss = useCallback(() => { setRemoteUpdate(null); @@ -254,9 +381,17 @@ export default function FilesPage() { onDelete={handleDelete} onBodyElementUpdate={handleBodyElementUpdate} onBodyReorder={handleBodyReorder} - onEditingChange={setIsActivelyEditing} + onEditingChange={updateIsActivelyEditing} hasPendingRemoteUpdate={!!remoteUpdate} onOverwrite={handleRemoteUpdateDismiss} + versions={versions} + versionsLoading={versionsLoading} + selectedVersion={selectedVersion} + loadingVersion={loadingVersion} + restoring={restoring} + onSelectVersion={fetchVersion} + onRestoreVersion={handleRestoreVersion} + onClearVersionSelection={clearSelectedVersion} /> </div> <div className="shrink-0"> diff --git a/makima/frontend/tsconfig.tsbuildinfo b/makima/frontend/tsconfig.tsbuildinfo index bda8af0..d7218f9 100644 --- a/makima/frontend/tsconfig.tsbuildinfo +++ b/makima/frontend/tsconfig.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/main.tsx","./src/vite-env.d.ts","./src/components/gridoverlay.tsx","./src/components/logo.tsx","./src/components/masthead.tsx","./src/components/navstrip.tsx","./src/components/rewritelink.tsx","./src/components/charts/chartrenderer.tsx","./src/components/files/bodyrenderer.tsx","./src/components/files/cliinput.tsx","./src/components/files/conflictnotification.tsx","./src/components/files/filedetail.tsx","./src/components/files/filelist.tsx","./src/components/files/updatenotification.tsx","./src/components/listen/controlpanel.tsx","./src/components/listen/speakerpanel.tsx","./src/components/listen/transcriptpanel.tsx","./src/hooks/usefilesubscription.ts","./src/hooks/usefiles.ts","./src/hooks/usemicrophone.ts","./src/hooks/usetextscramble.ts","./src/hooks/usewebsocket.ts","./src/lib/api.ts","./src/routes/_index.tsx","./src/routes/files.tsx","./src/routes/listen.tsx","./src/types/messages.ts"],"version":"5.9.3"}
\ No newline at end of file +{"root":["./src/main.tsx","./src/vite-env.d.ts","./src/components/gridoverlay.tsx","./src/components/logo.tsx","./src/components/masthead.tsx","./src/components/navstrip.tsx","./src/components/rewritelink.tsx","./src/components/charts/chartrenderer.tsx","./src/components/files/bodyrenderer.tsx","./src/components/files/cliinput.tsx","./src/components/files/conflictnotification.tsx","./src/components/files/filedetail.tsx","./src/components/files/filelist.tsx","./src/components/files/updatenotification.tsx","./src/components/files/versionhistorydropdown.tsx","./src/components/listen/controlpanel.tsx","./src/components/listen/speakerpanel.tsx","./src/components/listen/transcriptpanel.tsx","./src/hooks/usefilesubscription.ts","./src/hooks/usefiles.ts","./src/hooks/usemicrophone.ts","./src/hooks/usetextscramble.ts","./src/hooks/useversionhistory.ts","./src/hooks/usewebsocket.ts","./src/lib/api.ts","./src/routes/_index.tsx","./src/routes/files.tsx","./src/routes/listen.tsx","./src/types/messages.ts"],"version":"5.9.3"}
\ No newline at end of file diff --git a/makima/migrations/20241225000000_add_file_versions.sql b/makima/migrations/20241225000000_add_file_versions.sql new file mode 100644 index 0000000..5074eaa --- /dev/null +++ b/makima/migrations/20241225000000_add_file_versions.sql @@ -0,0 +1,49 @@ +-- Create file_versions table to store version history +CREATE TABLE IF NOT EXISTS file_versions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + file_id UUID NOT NULL REFERENCES files(id) ON DELETE CASCADE, + version INTEGER NOT NULL, + name VARCHAR(255) NOT NULL, + description TEXT, + summary TEXT, + body JSONB NOT NULL DEFAULT '[]'::jsonb, + source VARCHAR(32) NOT NULL DEFAULT 'user', -- 'user', 'llm', or 'system' + change_description TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + -- Each file can only have one record per version number + UNIQUE(file_id, version) +); + +-- Index for efficient version lookups +CREATE INDEX idx_file_versions_file_id ON file_versions(file_id); +CREATE INDEX idx_file_versions_file_version ON file_versions(file_id, version DESC); + +-- Function to save a version snapshot before file updates +CREATE OR REPLACE FUNCTION save_file_version() +RETURNS TRIGGER AS $$ +BEGIN + -- Save the current state as a version before the update + INSERT INTO file_versions (file_id, version, name, description, summary, body, source, change_description, created_at) + VALUES ( + OLD.id, + OLD.version, + OLD.name, + OLD.description, + OLD.summary, + OLD.body, + COALESCE(current_setting('app.version_source', true), 'user'), + current_setting('app.change_description', true), + OLD.updated_at + ) + ON CONFLICT (file_id, version) DO NOTHING; + + RETURN NEW; +END; +$$ language 'plpgsql'; + +-- Trigger to automatically save versions on update +CREATE TRIGGER save_file_version_trigger + BEFORE UPDATE ON files + FOR EACH ROW + EXECUTE FUNCTION save_file_version(); diff --git a/makima/src/db/models.rs b/makima/src/db/models.rs index 8204b86..617e590 100644 --- a/makima/src/db/models.rs +++ b/makima/src/db/models.rs @@ -149,3 +149,99 @@ impl From<File> for FileSummary { } } } + +// ============================================================================= +// Version History Types +// ============================================================================= + +/// Source of a version change +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, sqlx::Type)] +#[sqlx(type_name = "varchar")] +#[serde(rename_all = "lowercase")] +pub enum VersionSource { + #[sqlx(rename = "user")] + User, + #[sqlx(rename = "llm")] + Llm, + #[sqlx(rename = "system")] + System, +} + +impl std::fmt::Display for VersionSource { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + VersionSource::User => write!(f, "user"), + VersionSource::Llm => write!(f, "llm"), + VersionSource::System => write!(f, "system"), + } + } +} + +impl std::str::FromStr for VersionSource { + type Err = String; + + fn from_str(s: &str) -> Result<Self, Self::Err> { + match s.to_lowercase().as_str() { + "user" => Ok(VersionSource::User), + "llm" => Ok(VersionSource::Llm), + "system" => Ok(VersionSource::System), + _ => Err(format!("Unknown version source: {}", s)), + } + } +} + +/// Full version record from the database +#[derive(Debug, Clone, FromRow, Serialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct FileVersion { + pub id: Uuid, + pub file_id: Uuid, + pub version: i32, + pub name: String, + pub description: Option<String>, + pub summary: Option<String>, + #[sqlx(json)] + pub body: Vec<BodyElement>, + pub source: String, + pub change_description: Option<String>, + pub created_at: DateTime<Utc>, +} + +/// Summary of a version for list views +#[derive(Debug, Clone, Serialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct FileVersionSummary { + pub version: i32, + pub source: String, + pub created_at: DateTime<Utc>, + pub change_description: Option<String>, +} + +impl From<FileVersion> for FileVersionSummary { + fn from(v: FileVersion) -> Self { + Self { + version: v.version, + source: v.source, + created_at: v.created_at, + change_description: v.change_description, + } + } +} + +/// Response for version list endpoint +#[derive(Debug, Serialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct FileVersionListResponse { + pub versions: Vec<FileVersionSummary>, + pub total: i64, +} + +/// Request to restore a file to a previous version +#[derive(Debug, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct RestoreVersionRequest { + /// The version to restore to + pub target_version: i32, + /// The current version (for optimistic locking) + pub current_version: i32, +} diff --git a/makima/src/db/repository.rs b/makima/src/db/repository.rs index 5b962ee..4137ba6 100644 --- a/makima/src/db/repository.rs +++ b/makima/src/db/repository.rs @@ -4,7 +4,7 @@ use chrono::Utc; use sqlx::PgPool; use uuid::Uuid; -use super::models::{CreateFileRequest, File, UpdateFileRequest}; +use super::models::{CreateFileRequest, File, FileVersion, UpdateFileRequest}; /// Default owner ID for anonymous users. pub const ANONYMOUS_OWNER_ID: Uuid = Uuid::from_u128(0x00000000_0000_0000_0000_000000000002); @@ -221,3 +221,145 @@ pub async fn count_files(pool: &PgPool) -> Result<i64, sqlx::Error> { Ok(result.0) } + +// ============================================================================= +// Version History Functions +// ============================================================================= + +/// Set the version source for the current transaction. +/// This is used by the trigger to record who made the change. +pub async fn set_version_source(pool: &PgPool, source: &str) -> Result<(), sqlx::Error> { + sqlx::query(&format!("SET LOCAL app.version_source = '{}'", source)) + .execute(pool) + .await?; + Ok(()) +} + +/// Set the change description for the current transaction. +pub async fn set_change_description(pool: &PgPool, description: &str) -> Result<(), sqlx::Error> { + // Escape single quotes for SQL + let escaped = description.replace('\'', "''"); + sqlx::query(&format!("SET LOCAL app.change_description = '{}'", escaped)) + .execute(pool) + .await?; + Ok(()) +} + +/// List all versions of a file, ordered by version DESC. +pub async fn list_file_versions(pool: &PgPool, file_id: Uuid) -> Result<Vec<FileVersion>, sqlx::Error> { + // First get the current version from the files table + let current = get_file(pool, file_id).await?; + + let mut versions = sqlx::query_as::<_, FileVersion>( + r#" + SELECT id, file_id, version, name, description, summary, body, source, change_description, created_at + FROM file_versions + WHERE file_id = $1 + ORDER BY version DESC + "#, + ) + .bind(file_id) + .fetch_all(pool) + .await?; + + // Add the current version as the first entry if it exists + if let Some(file) = current { + let current_version = FileVersion { + id: file.id, + file_id: file.id, + version: file.version, + name: file.name, + description: file.description, + summary: file.summary, + body: file.body, + source: "user".to_string(), // Current version source + change_description: None, + created_at: file.updated_at, + }; + versions.insert(0, current_version); + } + + Ok(versions) +} + +/// Get a specific version of a file. +pub async fn get_file_version( + pool: &PgPool, + file_id: Uuid, + version: i32, +) -> Result<Option<FileVersion>, sqlx::Error> { + // First check if this is the current version + if let Some(file) = get_file(pool, file_id).await? { + if file.version == version { + return Ok(Some(FileVersion { + id: file.id, + file_id: file.id, + version: file.version, + name: file.name, + description: file.description, + summary: file.summary, + body: file.body, + source: "user".to_string(), + change_description: None, + created_at: file.updated_at, + })); + } + } + + // Otherwise, look in the versions table + sqlx::query_as::<_, FileVersion>( + r#" + SELECT id, file_id, version, name, description, summary, body, source, change_description, created_at + FROM file_versions + WHERE file_id = $1 AND version = $2 + "#, + ) + .bind(file_id) + .bind(version) + .fetch_optional(pool) + .await +} + +/// Restore a file to a previous version. +/// This creates a new version with the content from the target version. +pub async fn restore_file_version( + pool: &PgPool, + file_id: Uuid, + target_version: i32, + current_version: i32, +) -> Result<Option<File>, RepositoryError> { + // Get the target version content + let target = get_file_version(pool, file_id, target_version).await?; + let Some(target) = target else { + return Ok(None); + }; + + // Set version source and description for the trigger + set_version_source(pool, "system").await?; + set_change_description(pool, &format!("Restored from version {}", target_version)).await?; + + // Update the file with the target version's content + // This will trigger the save_file_version trigger to save the current state first + let update_req = UpdateFileRequest { + name: Some(target.name), + description: target.description, + transcript: None, + summary: target.summary, + body: Some(target.body), + version: Some(current_version), + }; + + update_file(pool, file_id, update_req).await +} + +/// Count versions for a file. +pub async fn count_file_versions(pool: &PgPool, file_id: Uuid) -> Result<i64, sqlx::Error> { + let result: (i64,) = sqlx::query_as( + "SELECT COUNT(*) + 1 FROM file_versions WHERE file_id = $1", // +1 for current version + ) + .bind(file_id) + .fetch_one(pool) + .await?; + + Ok(result.0) +} diff --git a/makima/src/llm/mod.rs b/makima/src/llm/mod.rs index 7de8afe..0df492d 100644 --- a/makima/src/llm/mod.rs +++ b/makima/src/llm/mod.rs @@ -6,7 +6,7 @@ pub mod tools; pub use claude::{ClaudeClient, ClaudeModel}; pub use groq::GroqClient; -pub use tools::{execute_tool_call, Tool, ToolCall, ToolResult, AVAILABLE_TOOLS}; +pub use tools::{execute_tool_call, Tool, ToolCall, ToolResult, VersionToolRequest, AVAILABLE_TOOLS}; /// Available LLM providers and models #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] diff --git a/makima/src/llm/tools.rs b/makima/src/llm/tools.rs index e6b2954..35f321f 100644 --- a/makima/src/llm/tools.rs +++ b/makima/src/llm/tools.rs @@ -232,9 +232,62 @@ pub static AVAILABLE_TOOLS: once_cell::sync::Lazy<Vec<Tool>> = "required": ["input", "filter"] }), }, + // Version history tools + Tool { + name: "list_versions".to_string(), + description: "List all available versions of the current document. Returns version numbers, sources (user/llm/system), timestamps, and change descriptions.".to_string(), + parameters: json!({ + "type": "object", + "properties": {}, + "required": [] + }), + }, + Tool { + name: "read_version".to_string(), + description: "Read the content of a specific historical version of the document. This is read-only and does not modify the current document.".to_string(), + parameters: json!({ + "type": "object", + "properties": { + "version": { + "type": "integer", + "description": "The version number to read" + } + }, + "required": ["version"] + }), + }, + Tool { + name: "restore_version".to_string(), + description: "Restore the document to a previous version. This creates a new version with the content from the target version. The current content will be preserved as a historical version.".to_string(), + parameters: json!({ + "type": "object", + "properties": { + "target_version": { + "type": "integer", + "description": "The version number to restore to" + }, + "reason": { + "type": "string", + "description": "Optional reason for the restore (will be recorded in change description)" + } + }, + "required": ["target_version"] + }), + }, ] }); +/// Request for version-related operations that require async database access +#[derive(Debug, Clone)] +pub enum VersionToolRequest { + /// List all versions of the current file + ListVersions, + /// Read a specific version + ReadVersion { version: i32 }, + /// Restore to a specific version + RestoreVersion { target_version: i32, reason: Option<String> }, +} + /// Result of executing a tool call with modified file state #[derive(Debug)] pub struct ToolExecutionResult { @@ -242,6 +295,8 @@ pub struct ToolExecutionResult { pub new_body: Option<Vec<BodyElement>>, pub new_summary: Option<String>, pub parsed_data: Option<serde_json::Value>, + /// Request for async version operations (handled by chat handler) + pub version_request: Option<VersionToolRequest>, } /// Execute a tool call and return the result along with any state changes @@ -261,6 +316,10 @@ pub fn execute_tool_call( "parse_csv" => execute_parse_csv(call), "clear_body" => execute_clear_body(), "jq" => execute_jq(call), + // Version history tools - return request for async handling + "list_versions" => execute_list_versions(), + "read_version" => execute_read_version(call), + "restore_version" => execute_restore_version(call), _ => ToolExecutionResult { result: ToolResult { success: false, @@ -269,6 +328,7 @@ pub fn execute_tool_call( new_body: None, new_summary: None, parsed_data: None, + version_request: None, }, } } @@ -305,6 +365,7 @@ fn execute_add_heading(call: &ToolCall, current_body: &[BodyElement]) -> ToolExe new_body: Some(new_body), new_summary: None, parsed_data: None, + version_request: None, } } @@ -345,6 +406,7 @@ fn execute_add_paragraph(call: &ToolCall, current_body: &[BodyElement]) -> ToolE new_body: Some(new_body), new_summary: None, parsed_data: None, + version_request: None, } } @@ -410,6 +472,7 @@ fn execute_add_chart(call: &ToolCall, current_body: &[BodyElement]) -> ToolExecu new_body: Some(new_body), new_summary: None, parsed_data: None, + version_request: None, } } @@ -425,6 +488,7 @@ fn execute_remove_element(call: &ToolCall, current_body: &[BodyElement]) -> Tool new_body: None, new_summary: None, parsed_data: None, + version_request: None, }; }; @@ -438,6 +502,7 @@ fn execute_remove_element(call: &ToolCall, current_body: &[BodyElement]) -> Tool new_body: None, new_summary: None, parsed_data: None, + version_request: None, }; } @@ -452,6 +517,7 @@ fn execute_remove_element(call: &ToolCall, current_body: &[BodyElement]) -> Tool new_body: Some(new_body), new_summary: None, parsed_data: None, + version_request: None, } } @@ -468,6 +534,7 @@ fn execute_update_element(call: &ToolCall, current_body: &[BodyElement]) -> Tool new_body: None, new_summary: None, parsed_data: None, + version_request: None, }; }; @@ -480,6 +547,7 @@ fn execute_update_element(call: &ToolCall, current_body: &[BodyElement]) -> Tool new_body: None, new_summary: None, parsed_data: None, + version_request: None, }; }; @@ -493,6 +561,7 @@ fn execute_update_element(call: &ToolCall, current_body: &[BodyElement]) -> Tool new_body: None, new_summary: None, parsed_data: None, + version_request: None, }; } @@ -530,6 +599,7 @@ fn execute_update_element(call: &ToolCall, current_body: &[BodyElement]) -> Tool new_body: None, new_summary: None, parsed_data: None, + version_request: None, }; } }; @@ -545,6 +615,7 @@ fn execute_update_element(call: &ToolCall, current_body: &[BodyElement]) -> Tool new_body: Some(new_body), new_summary: None, parsed_data: None, + version_request: None, } } @@ -561,6 +632,7 @@ fn execute_reorder_elements(call: &ToolCall, current_body: &[BodyElement]) -> To new_body: None, new_summary: None, parsed_data: None, + version_request: None, }; }; @@ -579,6 +651,7 @@ fn execute_reorder_elements(call: &ToolCall, current_body: &[BodyElement]) -> To new_body: None, new_summary: None, parsed_data: None, + version_request: None, }; } @@ -594,6 +667,7 @@ fn execute_reorder_elements(call: &ToolCall, current_body: &[BodyElement]) -> To new_body: Some(new_body), new_summary: None, parsed_data: None, + version_request: None, } } @@ -613,6 +687,7 @@ fn execute_set_summary(call: &ToolCall, _current_summary: Option<&str>) -> ToolE new_body: None, new_summary: Some(summary), parsed_data: None, + version_request: None, } } @@ -633,6 +708,7 @@ fn execute_parse_csv(call: &ToolCall) -> ToolExecutionResult { new_body: None, new_summary: None, parsed_data: None, + version_request: None, }; } @@ -668,6 +744,7 @@ fn execute_parse_csv(call: &ToolCall) -> ToolExecutionResult { new_body: None, new_summary: None, parsed_data: Some(json!(data)), + version_request: None, } } @@ -680,6 +757,7 @@ fn execute_clear_body() -> ToolExecutionResult { new_body: Some(vec![]), new_summary: None, parsed_data: None, + version_request: None, } } @@ -695,6 +773,7 @@ fn execute_jq(call: &ToolCall) -> ToolExecutionResult { new_body: None, new_summary: None, parsed_data: None, + version_request: None, }; } }; @@ -710,6 +789,7 @@ fn execute_jq(call: &ToolCall) -> ToolExecutionResult { new_body: None, new_summary: None, parsed_data: None, + version_request: None, }; } }; @@ -729,6 +809,7 @@ fn execute_jq(call: &ToolCall) -> ToolExecutionResult { new_body: None, new_summary: None, parsed_data: None, + version_request: None, }; } @@ -741,6 +822,7 @@ fn execute_jq(call: &ToolCall) -> ToolExecutionResult { new_body: None, new_summary: None, parsed_data: None, + version_request: None, }; }; @@ -755,6 +837,7 @@ fn execute_jq(call: &ToolCall) -> ToolExecutionResult { new_body: None, new_summary: None, parsed_data: None, + version_request: None, }; } @@ -779,6 +862,7 @@ fn execute_jq(call: &ToolCall) -> ToolExecutionResult { new_body: None, new_summary: None, parsed_data: None, + version_request: None, }; } } @@ -808,6 +892,90 @@ fn execute_jq(call: &ToolCall) -> ToolExecutionResult { new_body: None, new_summary: None, parsed_data: Some(output), + version_request: None, + } +} + +// ============================================================================= +// Version History Tool Execution Functions +// ============================================================================= +// These return version_request instead of performing the operation directly, +// because they require async database access which is handled in the chat handler. + +fn execute_list_versions() -> ToolExecutionResult { + ToolExecutionResult { + result: ToolResult { + success: true, + message: "Listing versions...".to_string(), + }, + new_body: None, + new_summary: None, + parsed_data: None, + version_request: Some(VersionToolRequest::ListVersions), + } +} + +fn execute_read_version(call: &ToolCall) -> ToolExecutionResult { + let version = call.arguments.get("version").and_then(|v| v.as_i64()); + + let Some(version) = version else { + return ToolExecutionResult { + result: ToolResult { + success: false, + message: "Missing version parameter".to_string(), + }, + new_body: None, + new_summary: None, + parsed_data: None, + version_request: None, + }; + }; + + ToolExecutionResult { + result: ToolResult { + success: true, + message: format!("Reading version {}...", version), + }, + new_body: None, + new_summary: None, + parsed_data: None, + version_request: Some(VersionToolRequest::ReadVersion { version: version as i32 }), + } +} + +fn execute_restore_version(call: &ToolCall) -> ToolExecutionResult { + let target_version = call.arguments.get("target_version").and_then(|v| v.as_i64()); + let reason = call + .arguments + .get("reason") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + + let Some(target_version) = target_version else { + return ToolExecutionResult { + result: ToolResult { + success: false, + message: "Missing target_version parameter".to_string(), + }, + new_body: None, + new_summary: None, + parsed_data: None, + version_request: None, + }; + }; + + ToolExecutionResult { + result: ToolResult { + success: true, + message: format!("Restoring to version {}...", target_version), + }, + new_body: None, + new_summary: None, + parsed_data: None, + version_request: Some(VersionToolRequest::RestoreVersion { + target_version: target_version as i32, + reason, + }), } } diff --git a/makima/src/server/handlers/chat.rs b/makima/src/server/handlers/chat.rs index 3bdbc74..396c973 100644 --- a/makima/src/server/handlers/chat.rs +++ b/makima/src/server/handlers/chat.rs @@ -10,12 +10,12 @@ use serde::{Deserialize, Serialize}; use utoipa::ToSchema; use uuid::Uuid; -use crate::db::{models::BodyElement, repository}; +use crate::db::{models::BodyElement, repository::{self, RepositoryError}}; use crate::llm::{ claude::{self, ClaudeClient, ClaudeError, ClaudeModel}, execute_tool_call, groq::{GroqClient, GroqError, Message, ToolCallResponse}, - LlmModel, ToolCall, ToolResult, AVAILABLE_TOOLS, + LlmModel, ToolCall, ToolResult, VersionToolRequest, AVAILABLE_TOOLS, }; use crate::server::state::{FileUpdateNotification, SharedState}; @@ -236,6 +236,10 @@ pub async fn chat_handler( let mut current_summary = file.summary.clone(); let mut all_tool_call_infos: Vec<ToolCallInfo> = Vec::new(); let mut final_response: Option<String> = None; + // Track if a version restore already happened (to avoid double-saving) + let mut version_restored = false; + // Track if there were modifications after a restore + let mut has_changes_after_restore = false; // Multi-turn tool calling loop for round in 0..MAX_TOOL_ROUNDS { @@ -320,15 +324,49 @@ pub async fn chat_handler( // Execute each tool call and add results to conversation for (i, tool_call) in result.tool_calls.iter().enumerate() { - let execution_result = + let mut execution_result = execute_tool_call(tool_call, ¤t_body, current_summary.as_deref()); - // Apply state changes + // Handle version tool requests that need async database access + if let Some(version_request) = &execution_result.version_request { + let version_result = handle_version_request( + pool, + id, + version_request, + ¤t_body, + current_summary.as_deref(), + file.version, + ) + .await; + + // Update execution result with actual version operation result + execution_result.result = version_result.result; + execution_result.parsed_data = version_result.data; + + // Apply state changes from restore operation + if let Some(new_body) = version_result.new_body { + current_body = new_body; + // Mark that a restore happened - file was already saved + version_restored = true; + } + if let Some(new_summary) = version_result.new_summary { + current_summary = Some(new_summary); + } + } + + // Apply state changes from regular tools if let Some(new_body) = execution_result.new_body { current_body = new_body; + // If this is a regular tool (not a version operation), track it + if execution_result.version_request.is_none() && version_restored { + has_changes_after_restore = true; + } } if let Some(new_summary) = execution_result.new_summary { current_summary = Some(new_summary); + if execution_result.version_request.is_none() && version_restored { + has_changes_after_restore = true; + } } // Build tool result message content @@ -378,7 +416,9 @@ pub async fn chat_handler( } // Save changes to database if any tools were executed - if !all_tool_call_infos.is_empty() { + // Skip if a version restore already happened (file was already saved during restore) + // UNLESS there were additional modifications after the restore + if !all_tool_call_infos.is_empty() && (!version_restored || has_changes_after_restore) { let update_req = crate::db::models::UpdateFileRequest { name: None, description: None, @@ -506,3 +546,199 @@ fn build_file_context(file: &crate::db::models::File) -> String { context } + +/// Result of handling a version tool request +struct VersionRequestResult { + result: ToolResult, + data: Option<serde_json::Value>, + new_body: Option<Vec<BodyElement>>, + new_summary: Option<String>, +} + +/// Handle version tool requests that require async database access +async fn handle_version_request( + pool: &sqlx::PgPool, + file_id: Uuid, + request: &VersionToolRequest, + _current_body: &[BodyElement], + _current_summary: Option<&str>, + current_version: i32, +) -> VersionRequestResult { + match request { + VersionToolRequest::ListVersions => { + match repository::list_file_versions(pool, file_id).await { + Ok(versions) => { + let version_data: Vec<serde_json::Value> = versions + .iter() + .map(|v| { + serde_json::json!({ + "version": v.version, + "source": v.source, + "createdAt": v.created_at.to_rfc3339(), + "changeDescription": v.change_description, + }) + }) + .collect(); + + VersionRequestResult { + result: ToolResult { + success: true, + message: format!("Found {} versions. Current version is {}.", versions.len(), current_version), + }, + data: Some(serde_json::json!({ + "currentVersion": current_version, + "versions": version_data, + })), + new_body: None, + new_summary: None, + } + } + Err(e) => VersionRequestResult { + result: ToolResult { + success: false, + message: format!("Failed to list versions: {}", e), + }, + data: None, + new_body: None, + new_summary: None, + }, + } + } + VersionToolRequest::ReadVersion { version } => { + match repository::get_file_version(pool, file_id, *version).await { + Ok(Some(ver)) => { + // Convert body elements to a readable format + let body_preview: Vec<String> = ver + .body + .iter() + .enumerate() + .map(|(i, element)| { + let desc = match element { + BodyElement::Heading { level, text } => format!("H{}: {}", level, text), + BodyElement::Paragraph { text } => { + let preview = if text.len() > 100 { + format!("{}...", &text[..100]) + } else { + text.clone() + }; + format!("Paragraph: {}", preview) + } + BodyElement::Chart { chart_type, title, .. } => { + format!( + "Chart ({:?}){}", + chart_type, + title.as_ref().map(|t| format!(": {}", t)).unwrap_or_default() + ) + } + BodyElement::Image { alt, .. } => { + format!("Image{}", alt.as_ref().map(|a| format!(": {}", a)).unwrap_or_default()) + } + }; + format!("[{}] {}", i, desc) + }) + .collect(); + + VersionRequestResult { + result: ToolResult { + success: true, + message: format!( + "Version {} from {} (source: {}). {} body elements.", + ver.version, + ver.created_at.format("%Y-%m-%d %H:%M"), + ver.source, + ver.body.len() + ), + }, + data: Some(serde_json::json!({ + "version": ver.version, + "source": ver.source, + "createdAt": ver.created_at.to_rfc3339(), + "summary": ver.summary, + "bodyPreview": body_preview, + "changeDescription": ver.change_description, + })), + new_body: None, + new_summary: None, + } + } + Ok(None) => VersionRequestResult { + result: ToolResult { + success: false, + message: format!("Version {} not found", version), + }, + data: None, + new_body: None, + new_summary: None, + }, + Err(e) => VersionRequestResult { + result: ToolResult { + success: false, + message: format!("Failed to read version: {}", e), + }, + data: None, + new_body: None, + new_summary: None, + }, + } + } + VersionToolRequest::RestoreVersion { target_version, reason } => { + // Set change description if provided + if let Some(reason) = reason { + let _ = repository::set_change_description(pool, reason).await; + } + + match repository::restore_file_version(pool, file_id, *target_version, current_version).await { + Ok(Some(restored_file)) => { + VersionRequestResult { + result: ToolResult { + success: true, + message: format!( + "Restored to version {}. New version is {}.", + target_version, restored_file.version + ), + }, + data: Some(serde_json::json!({ + "previousVersion": current_version, + "restoredFromVersion": target_version, + "newVersion": restored_file.version, + })), + new_body: Some(restored_file.body), + new_summary: restored_file.summary, + } + } + Ok(None) => VersionRequestResult { + result: ToolResult { + success: false, + message: format!("Version {} not found", target_version), + }, + data: None, + new_body: None, + new_summary: None, + }, + Err(RepositoryError::VersionConflict { expected, actual }) => { + VersionRequestResult { + result: ToolResult { + success: false, + message: format!( + "Version conflict: expected {}, actual {}. Document was modified.", + expected, actual + ), + }, + data: None, + new_body: None, + new_summary: None, + } + } + Err(e) => VersionRequestResult { + result: ToolResult { + success: false, + message: format!("Failed to restore version: {}", e), + }, + data: None, + new_body: None, + new_summary: None, + }, + } + } + } +} diff --git a/makima/src/server/handlers/mod.rs b/makima/src/server/handlers/mod.rs index c08f1bd..3211f94 100644 --- a/makima/src/server/handlers/mod.rs +++ b/makima/src/server/handlers/mod.rs @@ -4,3 +4,4 @@ pub mod chat; pub mod file_ws; pub mod files; pub mod listen; +pub mod versions; diff --git a/makima/src/server/handlers/versions.rs b/makima/src/server/handlers/versions.rs new file mode 100644 index 0000000..15118d6 --- /dev/null +++ b/makima/src/server/handlers/versions.rs @@ -0,0 +1,207 @@ +//! HTTP handlers for file version history operations. + +use axum::{ + extract::{Path, State}, + http::StatusCode, + response::IntoResponse, + Json, +}; +use uuid::Uuid; + +use crate::db::models::{FileVersionListResponse, FileVersionSummary, RestoreVersionRequest}; +use crate::db::repository::{self, RepositoryError}; +use crate::server::messages::ApiError; +use crate::server::state::{FileUpdateNotification, SharedState}; + +/// List all versions of a file. +#[utoipa::path( + get, + path = "/api/v1/files/{id}/versions", + params( + ("id" = Uuid, Path, description = "File ID") + ), + responses( + (status = 200, description = "List of file versions", body = FileVersionListResponse), + (status = 404, description = "File not found", body = ApiError), + (status = 503, description = "Database not configured", body = ApiError), + (status = 500, description = "Internal server error", body = ApiError), + ), + tag = "Versions" +)] +pub async fn list_versions( + State(state): State<SharedState>, + Path(file_id): Path<Uuid>, +) -> impl IntoResponse { + let Some(ref pool) = state.db_pool else { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), + ) + .into_response(); + }; + + // Check if file exists + match repository::get_file(pool, file_id).await { + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "File not found")), + ) + .into_response(); + } + Err(e) => { + tracing::error!("Failed to check file {}: {}", file_id, e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", e.to_string())), + ) + .into_response(); + } + Ok(Some(_)) => {} + } + + match repository::list_file_versions(pool, file_id).await { + Ok(versions) => { + let summaries: Vec<FileVersionSummary> = + versions.into_iter().map(FileVersionSummary::from).collect(); + let total = summaries.len() as i64; + Json(FileVersionListResponse { + versions: summaries, + total, + }) + .into_response() + } + Err(e) => { + tracing::error!("Failed to list versions for file {}: {}", file_id, e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", e.to_string())), + ) + .into_response() + } + } +} + +/// Get a specific version of a file. +#[utoipa::path( + get, + path = "/api/v1/files/{id}/versions/{version}", + params( + ("id" = Uuid, Path, description = "File ID"), + ("version" = i32, Path, description = "Version number") + ), + responses( + (status = 200, description = "Version details", body = crate::db::models::FileVersion), + (status = 404, description = "Version not found", body = ApiError), + (status = 503, description = "Database not configured", body = ApiError), + (status = 500, description = "Internal server error", body = ApiError), + ), + tag = "Versions" +)] +pub async fn get_version( + State(state): State<SharedState>, + Path((file_id, version)): Path<(Uuid, i32)>, +) -> impl IntoResponse { + let Some(ref pool) = state.db_pool else { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), + ) + .into_response(); + }; + + match repository::get_file_version(pool, file_id, version).await { + Ok(Some(version)) => Json(version).into_response(), + Ok(None) => ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Version not found")), + ) + .into_response(), + Err(e) => { + tracing::error!("Failed to get version {} for file {}: {}", version, file_id, e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", e.to_string())), + ) + .into_response() + } + } +} + +/// Restore a file to a previous version. +#[utoipa::path( + post, + path = "/api/v1/files/{id}/versions/restore", + params( + ("id" = Uuid, Path, description = "File ID") + ), + request_body = RestoreVersionRequest, + responses( + (status = 200, description = "File restored to previous version", body = crate::db::models::File), + (status = 404, description = "File or version not found", body = ApiError), + (status = 409, description = "Version conflict", body = ApiError), + (status = 503, description = "Database not configured", body = ApiError), + (status = 500, description = "Internal server error", body = ApiError), + ), + tag = "Versions" +)] +pub async fn restore_version( + State(state): State<SharedState>, + Path(file_id): Path<Uuid>, + Json(req): Json<RestoreVersionRequest>, +) -> impl IntoResponse { + let Some(ref pool) = state.db_pool else { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), + ) + .into_response(); + }; + + match repository::restore_file_version(pool, file_id, req.target_version, req.current_version).await { + Ok(Some(file)) => { + // Broadcast update notification + state.broadcast_file_update(FileUpdateNotification { + file_id, + version: file.version, + updated_fields: vec!["body".to_string(), "summary".to_string()], + updated_by: "system".to_string(), + }); + Json(file).into_response() + } + Ok(None) => ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "File or version not found")), + ) + .into_response(), + Err(RepositoryError::VersionConflict { expected, actual }) => { + tracing::info!( + "Version conflict on file {} restore: expected {}, actual {}", + file_id, + expected, + actual + ); + ( + StatusCode::CONFLICT, + Json(serde_json::json!({ + "code": "VERSION_CONFLICT", + "message": format!( + "File was modified by another user. Expected version {}, actual version {}", + expected, actual + ), + "expectedVersion": expected, + "actualVersion": actual, + })), + ) + .into_response() + } + Err(RepositoryError::Database(e)) => { + tracing::error!("Failed to restore file {} to version {}: {}", file_id, req.target_version, e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", e.to_string())), + ) + .into_response() + } + } +} diff --git a/makima/src/server/mod.rs b/makima/src/server/mod.rs index f132cf4..ee5e9bd 100644 --- a/makima/src/server/mod.rs +++ b/makima/src/server/mod.rs @@ -17,7 +17,7 @@ use tower_http::trace::TraceLayer; use utoipa::OpenApi; use utoipa_swagger_ui::SwaggerUi; -use crate::server::handlers::{chat, file_ws, files, listen}; +use crate::server::handlers::{chat, file_ws, files, listen, versions}; use crate::server::openapi::ApiDoc; use crate::server::state::SharedState; @@ -52,6 +52,10 @@ pub fn make_router(state: SharedState) -> Router { .delete(files::delete_file), ) .route("/files/{id}/chat", post(chat::chat_handler)) + // Version history endpoints + .route("/files/{id}/versions", get(versions::list_versions)) + .route("/files/{id}/versions/{version}", get(versions::get_version)) + .route("/files/{id}/versions/restore", post(versions::restore_version)) .with_state(state); let swagger = SwaggerUi::new("/swagger-ui") |
