From 8d072ce1aad6a5269c234754f052bc8196c10e38 Mon Sep 17 00:00:00 2001 From: SlothfulDreams Date: Wed, 13 Aug 2025 00:56:09 -0400 Subject: [PATCH 1/3] Move .github folder to root directory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Relocate GitHub workflows from modelmux/.github/ to root level for proper GitHub Actions functionality. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- {modelmux/.github => .github}/workflows/claude-code-review.yml | 0 {modelmux/.github => .github}/workflows/claude.yml | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename {modelmux/.github => .github}/workflows/claude-code-review.yml (100%) rename {modelmux/.github => .github}/workflows/claude.yml (100%) diff --git a/modelmux/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml similarity index 100% rename from modelmux/.github/workflows/claude-code-review.yml rename to .github/workflows/claude-code-review.yml diff --git a/modelmux/.github/workflows/claude.yml b/.github/workflows/claude.yml similarity index 100% rename from modelmux/.github/workflows/claude.yml rename to .github/workflows/claude.yml From e631c670204845417413c9195ef913f4373664b6 Mon Sep 17 00:00:00 2001 From: SlothfulDreams Date: Wed, 13 Aug 2025 01:15:54 -0400 Subject: [PATCH 2/3] manage model implementation --- modelmux/src/App.tsx | 4 +- .../ChatInterface/chat-message-actions.tsx | 5 +- .../components/available-models-table.tsx | 104 ++++++++++++++ .../components/delete-confirmation-dialog.tsx | 54 ++++++++ .../ModelManager/components/error-display.tsx | 24 ++++ .../components/installed-models-table.tsx | 90 ++++++++++++ .../components/loading-display.tsx | 13 ++ .../ModelManager/hooks/use-model-actions.ts | 80 +++++++++++ .../ModelManager/hooks/use-model-manager.ts | 73 ++++++++++ .../ModelManager/model-manager-interface.tsx | 97 +++++++++++++ .../ModelManager/utils/model-filters.ts | 11 ++ modelmux/src/components/manage-model.tsx | 10 -- modelmux/src/components/ui/progress.tsx | 29 ++++ modelmux/src/components/ui/table.tsx | 129 ++++++++++++++++++ modelmux/src/lib/ollama.ts | 124 ++++++++++++++++- 15 files changed, 830 insertions(+), 17 deletions(-) create mode 100644 modelmux/src/components/ModelManager/components/available-models-table.tsx create mode 100644 modelmux/src/components/ModelManager/components/delete-confirmation-dialog.tsx create mode 100644 modelmux/src/components/ModelManager/components/error-display.tsx create mode 100644 modelmux/src/components/ModelManager/components/installed-models-table.tsx create mode 100644 modelmux/src/components/ModelManager/components/loading-display.tsx create mode 100644 modelmux/src/components/ModelManager/hooks/use-model-actions.ts create mode 100644 modelmux/src/components/ModelManager/hooks/use-model-manager.ts create mode 100644 modelmux/src/components/ModelManager/model-manager-interface.tsx create mode 100644 modelmux/src/components/ModelManager/utils/model-filters.ts delete mode 100644 modelmux/src/components/manage-model.tsx create mode 100644 modelmux/src/components/ui/progress.tsx create mode 100644 modelmux/src/components/ui/table.tsx diff --git a/modelmux/src/App.tsx b/modelmux/src/App.tsx index 8de054f..4b304e0 100644 --- a/modelmux/src/App.tsx +++ b/modelmux/src/App.tsx @@ -1,7 +1,7 @@ import { useState } from "react"; import { ChatInterface } from "@/components/ChatInterface/chat-interface"; import { ThemeProvider } from "@/components/theme-provider"; -import { ManageModel } from "@/components/manage-model"; +import { ModelManagerInterface } from "@/components/ModelManager/model-manager-interface"; import Layout from "@/components/sidebar/layout"; import WorkspaceInterface from "@/components/Workspace/workspace-interface"; @@ -14,7 +14,7 @@ function App() { {currentView === "chat" && } - {currentView === "models" && } + {currentView === "models" && } {currentView === "workspaces" && } diff --git a/modelmux/src/components/ChatInterface/chat-message-actions.tsx b/modelmux/src/components/ChatInterface/chat-message-actions.tsx index ba69a81..4f8e163 100644 --- a/modelmux/src/components/ChatInterface/chat-message-actions.tsx +++ b/modelmux/src/components/ChatInterface/chat-message-actions.tsx @@ -12,13 +12,12 @@ import { TooltipTrigger, } from "@/components/ui/tooltip"; import { useEffect, useState, useContext } from "react"; -import { getCurrentModel, modelList } from "@/lib/ollama"; -import { ModelResponse } from "ollama"; +import { getCurrentModel, modelList, type OllamaModel } from "@/lib/ollama"; import { RetryContext } from "./Context/ChatContext"; export function ChatMessageActions() { const [modelName, setModelName] = useState(""); - const [models, setModels] = useState([]); + const [models, setModels] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [dropdownOpen, setDropdownOpen] = useState(false); diff --git a/modelmux/src/components/ModelManager/components/available-models-table.tsx b/modelmux/src/components/ModelManager/components/available-models-table.tsx new file mode 100644 index 0000000..7372dfe --- /dev/null +++ b/modelmux/src/components/ModelManager/components/available-models-table.tsx @@ -0,0 +1,104 @@ +import { Download, Loader2 } from "lucide-react"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { Button } from "@/components/ui/button"; +import { Progress } from "@/components/ui/progress"; +import { type AvailableModel } from "@/lib/ollama"; + +interface AvailableModelsTableProps { + models: AvailableModel[]; + loading: Record; + progress: Record; + onInstall: (modelName: string) => void; +} + +export function AvailableModelsTable({ + models, + loading, + progress, + onInstall, +}: AvailableModelsTableProps) { + return ( +
+

+ Available Models ({models.length}) +

+
+ + + + Model Name + Description + Updated + Action + + + + {models.length === 0 ? ( + + + No available models to install + + + ) : ( + models + .slice(0, 20) + .map((model) => ( + + + {model.model_name} + + + {model.description} + + + {new Date(model.last_updated).toLocaleDateString()} + + +
+ + {loading[model.model_name] && + progress[model.model_name] && ( +
+ +
+ {progress[model.model_name].percentage}% -{" "} + {progress[model.model_name].status} +
+
+ )} +
+
+
+ )) + )} +
+
+
+
+ ); +} \ No newline at end of file diff --git a/modelmux/src/components/ModelManager/components/delete-confirmation-dialog.tsx b/modelmux/src/components/ModelManager/components/delete-confirmation-dialog.tsx new file mode 100644 index 0000000..afd5ff4 --- /dev/null +++ b/modelmux/src/components/ModelManager/components/delete-confirmation-dialog.tsx @@ -0,0 +1,54 @@ +import { Loader2 } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { type OllamaModel } from "@/lib/ollama"; + +interface DeleteConfirmationDialogProps { + open: boolean; + model: OllamaModel | null; + loading: boolean; + onOpenChange: (open: boolean) => void; + onConfirm: () => void; + onCancel: () => void; +} + +export function DeleteConfirmationDialog({ + open, + model, + loading, + onOpenChange, + onConfirm, + onCancel, +}: DeleteConfirmationDialogProps) { + return ( + + + + Confirm Uninstall + + Are you sure you want to uninstall "{model?.name}"? + This action cannot be undone. + + + + + + + + + ); +} \ No newline at end of file diff --git a/modelmux/src/components/ModelManager/components/error-display.tsx b/modelmux/src/components/ModelManager/components/error-display.tsx new file mode 100644 index 0000000..4b0248b --- /dev/null +++ b/modelmux/src/components/ModelManager/components/error-display.tsx @@ -0,0 +1,24 @@ +import { Button } from "@/components/ui/button"; + +interface ErrorDisplayProps { + error: string; + onDismiss: () => void; +} + +export function ErrorDisplay({ error, onDismiss }: ErrorDisplayProps) { + return ( +
+
+ {error} + +
+
+ ); +} \ No newline at end of file diff --git a/modelmux/src/components/ModelManager/components/installed-models-table.tsx b/modelmux/src/components/ModelManager/components/installed-models-table.tsx new file mode 100644 index 0000000..6ec1796 --- /dev/null +++ b/modelmux/src/components/ModelManager/components/installed-models-table.tsx @@ -0,0 +1,90 @@ +import { Trash2, Loader2 } from "lucide-react"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { Button } from "@/components/ui/button"; +import { + formatModelSize, + formatModelName, + getModelTag, + type OllamaModel, +} from "@/lib/ollama"; + +interface InstalledModelsTableProps { + models: OllamaModel[]; + loading: Record; + onUninstall: (model: OllamaModel) => void; +} + +export function InstalledModelsTable({ + models, + loading, + onUninstall, +}: InstalledModelsTableProps) { + return ( +
+

+ Installed Models ({models.length}) +

+
+ + + + Model Name + Size + Modified + Action + + + + {models.length === 0 ? ( + + + No models installed + + + ) : ( + models.map((model) => ( + + + {formatModelName(model.name)} + + :{getModelTag(model.name)} + + + {formatModelSize(model.size)} + + {new Date(model.modified_at).toLocaleDateString()} + + + + + + )) + )} + +
+
+
+ ); +} \ No newline at end of file diff --git a/modelmux/src/components/ModelManager/components/loading-display.tsx b/modelmux/src/components/ModelManager/components/loading-display.tsx new file mode 100644 index 0000000..d678a20 --- /dev/null +++ b/modelmux/src/components/ModelManager/components/loading-display.tsx @@ -0,0 +1,13 @@ +import { Loader2 } from "lucide-react"; + +export function LoadingDisplay() { + return ( +
+

Manage Models

+
+ + Loading models... +
+
+ ); +} \ No newline at end of file diff --git a/modelmux/src/components/ModelManager/hooks/use-model-actions.ts b/modelmux/src/components/ModelManager/hooks/use-model-actions.ts new file mode 100644 index 0000000..af14da5 --- /dev/null +++ b/modelmux/src/components/ModelManager/hooks/use-model-actions.ts @@ -0,0 +1,80 @@ +import { pullModel, deleteModel, type OllamaModel } from "@/lib/ollama"; + +interface UseModelActionsProps { + loading: { + install: Record; + uninstall: Record; + fetch: boolean; + }; + setLoading: (loading: any) => void; + setProgress: (progress: any) => void; + setError: (error: string | null) => void; + loadInstalledModels: () => Promise; +} + +export function useModelActions({ + loading, + setLoading, + setProgress, + setError, + loadInstalledModels, +}: UseModelActionsProps) { + const handleInstall = async (modelName: string) => { + if (loading.install[modelName]) { + return; + } + + setLoading((prev: any) => ({ + ...prev, + install: { ...prev.install, [modelName]: true }, + })); + setError(null); + + try { + await pullModel(modelName, (progressData) => { + setProgress((prev: any) => ({ + ...prev, + [modelName]: progressData, + })); + }); + await loadInstalledModels(); + } catch (error) { + console.error("Failed to install model:", error); + setError(`Failed to install ${modelName}. Please try again.`); + } finally { + setLoading((prev: any) => ({ + ...prev, + install: { ...prev.install, [modelName]: false }, + })); + setProgress((prev: any) => { + const { [modelName]: _, ...rest } = prev; + return rest; + }); + } + }; + + const handleUninstall = async (model: OllamaModel) => { + setLoading((prev: any) => ({ + ...prev, + uninstall: { ...prev.uninstall, [model.name]: true }, + })); + setError(null); + try { + await deleteModel(model.name); + await loadInstalledModels(); + } catch (error) { + console.error("Failed to uninstall model:", error); + setError(`Failed to uninstall ${model.name}. Please try again.`); + } finally { + setLoading((prev: any) => ({ + ...prev, + uninstall: { ...prev.uninstall, [model.name]: false }, + })); + } + }; + + return { + handleInstall, + handleUninstall, + }; +} \ No newline at end of file diff --git a/modelmux/src/components/ModelManager/hooks/use-model-manager.ts b/modelmux/src/components/ModelManager/hooks/use-model-manager.ts new file mode 100644 index 0000000..1136ebb --- /dev/null +++ b/modelmux/src/components/ModelManager/hooks/use-model-manager.ts @@ -0,0 +1,73 @@ +import { useState, useEffect } from "react"; +import { + modelList, + getAvailableModels, + type OllamaModel, + type AvailableModel, +} from "@/lib/ollama"; + +interface LoadingState { + install: Record; + uninstall: Record; + fetch: boolean; +} + +interface ProgressState { + percentage: number; + status: string; +} + +export function useModelManager() { + const [installedModels, setInstalledModels] = useState([]); + const [availableModels, setAvailableModels] = useState([]); + const [loading, setLoading] = useState({ + install: {}, + uninstall: {}, + fetch: true, + }); + const [progress, setProgress] = useState>({}); + const [error, setError] = useState(null); + + const loadInstalledModels = async () => { + try { + const models = await modelList(); + setInstalledModels(models); + } catch (error) { + console.error("Failed to load installed models:", error); + setError("Failed to load installed models. Please try again."); + } + }; + + const loadAvailableModels = async () => { + try { + const models = await getAvailableModels(); + setAvailableModels(models); + } catch (error) { + console.error("Failed to load available models:", error); + setError( + "Failed to load available models. Please check your internet connection.", + ); + } + }; + + useEffect(() => { + const loadData = async () => { + setLoading((prev) => ({ ...prev, fetch: true })); + await Promise.all([loadInstalledModels(), loadAvailableModels()]); + setLoading((prev) => ({ ...prev, fetch: false })); + }; + loadData(); + }, []); + + return { + installedModels, + availableModels, + loading, + progress, + error, + setLoading, + setProgress, + setError, + loadInstalledModels, + }; +} \ No newline at end of file diff --git a/modelmux/src/components/ModelManager/model-manager-interface.tsx b/modelmux/src/components/ModelManager/model-manager-interface.tsx new file mode 100644 index 0000000..0015aca --- /dev/null +++ b/modelmux/src/components/ModelManager/model-manager-interface.tsx @@ -0,0 +1,97 @@ +import { useState } from "react"; +import { type OllamaModel } from "@/lib/ollama"; +import { useModelManager } from "./hooks/use-model-manager"; +import { useModelActions } from "./hooks/use-model-actions"; +import { getFilteredAvailableModels } from "./utils/model-filters"; +import { LoadingDisplay } from "./components/loading-display"; +import { ErrorDisplay } from "./components/error-display"; +import { InstalledModelsTable } from "./components/installed-models-table"; +import { AvailableModelsTable } from "./components/available-models-table"; +import { DeleteConfirmationDialog } from "./components/delete-confirmation-dialog"; + +export function ModelManagerInterface() { + const { + installedModels, + availableModels, + loading, + progress, + error, + setLoading, + setProgress, + setError, + loadInstalledModels, + } = useModelManager(); + + const { handleInstall, handleUninstall } = useModelActions({ + loading, + setLoading, + setProgress, + setError, + loadInstalledModels, + }); + + const [deleteDialog, setDeleteDialog] = useState<{ + open: boolean; + model: OllamaModel | null; + }>({ open: false, model: null }); + + const filteredAvailableModels = getFilteredAvailableModels( + installedModels, + availableModels + ); + + const handleUninstallClick = (model: OllamaModel) => { + setDeleteDialog({ open: true, model }); + }; + + const handleDeleteConfirm = () => { + if (deleteDialog.model) { + handleUninstall(deleteDialog.model); + setDeleteDialog({ open: false, model: null }); + } + }; + + const handleDeleteCancel = () => { + setDeleteDialog({ open: false, model: null }); + }; + + if (loading.fetch) { + return ; + } + + return ( +
+

Manage Models

+ + {error && ( + setError(null)} /> + )} + + + + + + + setDeleteDialog({ open, model: deleteDialog.model }) + } + onConfirm={handleDeleteConfirm} + onCancel={handleDeleteCancel} + /> +
+ ); +} \ No newline at end of file diff --git a/modelmux/src/components/ModelManager/utils/model-filters.ts b/modelmux/src/components/ModelManager/utils/model-filters.ts new file mode 100644 index 0000000..fbc8c25 --- /dev/null +++ b/modelmux/src/components/ModelManager/utils/model-filters.ts @@ -0,0 +1,11 @@ +import { formatModelName, type OllamaModel, type AvailableModel } from "@/lib/ollama"; + +export function getFilteredAvailableModels( + installedModels: OllamaModel[], + availableModels: AvailableModel[] +) { + const installedNames = installedModels.map((m) => formatModelName(m.name)); + return availableModels.filter( + (model) => !installedNames.includes(formatModelName(model.model_name)), + ); +} \ No newline at end of file diff --git a/modelmux/src/components/manage-model.tsx b/modelmux/src/components/manage-model.tsx deleted file mode 100644 index 292e6f5..0000000 --- a/modelmux/src/components/manage-model.tsx +++ /dev/null @@ -1,10 +0,0 @@ -export function ManageModel() { - return ( -
-

Manage Models

-

- Here you can manage your local and remote models. -

-
- ); -} diff --git a/modelmux/src/components/ui/progress.tsx b/modelmux/src/components/ui/progress.tsx new file mode 100644 index 0000000..564a32f --- /dev/null +++ b/modelmux/src/components/ui/progress.tsx @@ -0,0 +1,29 @@ +import * as React from "react"; +import { cn } from "@/lib/utils"; + +const Progress = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes & { + value?: number; + max?: number; + } +>(({ className, value = 0, max = 100, ...props }, ref) => ( +
+
+
+)); +Progress.displayName = "Progress"; + +export { Progress }; \ No newline at end of file diff --git a/modelmux/src/components/ui/table.tsx b/modelmux/src/components/ui/table.tsx new file mode 100644 index 0000000..64e5ba9 --- /dev/null +++ b/modelmux/src/components/ui/table.tsx @@ -0,0 +1,129 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +function Table({ + className, + ...props +}: React.ComponentProps<"table">) { + return ( +
+ + + ) +} + +function TableHeader({ + className, + ...props +}: React.ComponentProps<"thead">) { + return ( + + ) +} + +function TableBody({ + className, + ...props +}: React.ComponentProps<"tbody">) { + return ( + + ) +} + +function TableFooter({ + className, + ...props +}: React.ComponentProps<"tfoot">) { + return ( + tr]:last:border-b-0", + className + )} + {...props} + /> + ) +} + +function TableRow({ + className, + ...props +}: React.ComponentProps<"tr">) { + return ( + + ) +} + +function TableHead({ + className, + ...props +}: React.ComponentProps<"th">) { + return ( +
+ ) +} + +function TableCell({ + className, + ...props +}: React.ComponentProps<"td">) { + return ( + + ) +} + +function TableCaption({ + className, + ...props +}: React.ComponentProps<"caption">) { + return ( +
+ ) +} + +export { + Table, + TableHeader, + TableBody, + TableFooter, + TableHead, + TableRow, + TableCell, + TableCaption, +} \ No newline at end of file diff --git a/modelmux/src/lib/ollama.ts b/modelmux/src/lib/ollama.ts index 544b422..f61703d 100644 --- a/modelmux/src/lib/ollama.ts +++ b/modelmux/src/lib/ollama.ts @@ -1,5 +1,38 @@ import ollama from "ollama"; +export interface OllamaModel { + name: string; + modified_at: string; + model: string; + size: number; + digest: string; + details: { + parent_model: string; + format: string; + family: string; + families: string[] | null; + parameter_size: string; + quantization_level: string; + }; + expires_at: string; + size_vram: number; +} + +export interface AvailableModel { + model_identifier: string; + namespace: string; + model_name: string; + model_type: string; + description: string; + capability: string; + labels: string[]; + pulls: number; + tags: string[]; + last_updated: string; + last_updated_str: string; + url: string; +} + interface MemoryMessage { role: "user" | "system" | "assistant"; content: string; @@ -34,9 +67,9 @@ export async function Response( return { message: llmMessage, data: llmMessageData }; } -export async function modelList() { +export async function modelList(): Promise { const list = await ollama.list(); - return list.models; + return list.models as unknown as OllamaModel[]; } export async function getCurrentModel() { @@ -93,3 +126,90 @@ export async function generateSingleEmbedding( throw error; } } + +export async function pullModel( + modelName: string, + onProgress?: (progress: { percentage: number; status: string }) => void, +) { + try { + console.log("Pulling model:", modelName); + const stream = await ollama.pull({ + model: modelName, + stream: true, + }); + + for await (const chunk of stream) { + console.log("Pull progress:", chunk); + + if (onProgress) { + let percentage = 0; + let status = chunk.status || "downloading"; + + if (chunk.completed && chunk.total) { + percentage = Math.round((chunk.completed / chunk.total) * 100); + } else if (chunk.status === "success") { + percentage = 100; + status = "completed"; + } + + onProgress({ percentage, status }); + } + + if (chunk.status === "success") { + console.log("Model pulled successfully:", modelName); + break; + } + } + + return { success: true }; + } catch (error) { + console.error("Failed to pull model:", error); + throw error; + } +} + +export async function deleteModel(modelName: string) { + try { + await ollama.delete({ model: modelName }); + return { success: true }; + } catch (error) { + console.error("Failed to delete model:", error); + throw error; + } +} + +export async function getAvailableModels(): Promise { + try { + const response = await fetch("https://ollamadb.dev/api/v1/models"); + if (!response.ok) { + throw new Error("Failed to fetch available models"); + } + const data = await response.json(); + return data.models || []; + } catch (error) { + console.error("Failed to fetch available models:", error); + return []; + } +} + +export function formatModelSize(sizeInBytes: number): string { + const units = ["B", "KB", "MB", "GB", "TB"]; + let size = sizeInBytes; + let unitIndex = 0; + + while (size >= 1024 && unitIndex < units.length - 1) { + size /= 1024; + unitIndex++; + } + + return `${size.toFixed(1)} ${units[unitIndex]}`; +} + +export function formatModelName(name: string): string { + return name.split(":")[0]; +} + +export function getModelTag(name: string): string { + const parts = name.split(":"); + return parts.length > 1 ? parts[1] : "latest"; +} From 4582cdef626a6cf109f49f66c47a15327f346e02 Mon Sep 17 00:00:00 2001 From: SlothfulDreams Date: Wed, 13 Aug 2025 02:17:44 -0400 Subject: [PATCH 3/3] Update Claude workflows to match with main - grant write permissions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- .github/workflows/claude-code-review.yml | 4 ++-- .github/workflows/claude.yml | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml index 53a1687..f1c49ae 100644 --- a/.github/workflows/claude-code-review.yml +++ b/.github/workflows/claude-code-review.yml @@ -20,8 +20,8 @@ jobs: runs-on: ubuntu-latest permissions: - contents: read - pull-requests: read + contents: write + pull-requests: write issues: write id-token: write diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml index ff4cc86..760b9a3 100644 --- a/.github/workflows/claude.yml +++ b/.github/workflows/claude.yml @@ -19,8 +19,8 @@ jobs: (github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude'))) runs-on: ubuntu-latest permissions: - contents: read - pull-requests: read + contents: write + pull-requests: write issues: write id-token: write actions: read # Required for Claude to read CI results on PRs