docs: replace README with Next.js boilerplate template
Updated README.md from a detailed OllaHub project description to the standard Next.js getting started template, likely to initialize or reset the project as a Next.js application.
This commit is contained in:
1 parent
58e5522700
commit
a3f8226fda
42 files changed
+4465
-314
No files matched your search
@@ -0,0 +1,41 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { ModeToggle } from "@/components/mode-toggle";
|
||||
import { ChatContainer } from "@/components/chat/ChatContainer";
|
||||
import {
|
||||
SettingsButton,
|
||||
SettingsModal,
|
||||
} from "@/components/settings/SettingsModal";
|
||||
|
||||
interface ClientHomeProps {
|
||||
offline: boolean;
|
||||
models: any[];
|
||||
}
|
||||
|
||||
export function ClientHome({ offline, models }: ClientHomeProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="h-screen flex flex-col bg-[var(--background)]">
|
||||
<header className="sticky top-0 z-20 border-b border-[var(--border)] bg-[color-mix(in_oklab,var(--background),black_3%)]/80 backdrop-blur supports-[backdrop-filter]:bg-[color-mix(in_oklab,var(--background),black_3%)]/60">
|
||||
<div className="mx-auto flex max-w-full items-center justify-between px-4 py-3">
|
||||
<h1 className="text-lg font-semibold tracking-tight">Ollahub</h1>
|
||||
<div className="flex items-center gap-3">
|
||||
{offline && (
|
||||
<span className="rounded-md border border-[var(--border)] bg-[var(--surface)] px-2 py-1 text-xs">
|
||||
Ollama offline
|
||||
</span>
|
||||
)}
|
||||
<SettingsButton onOpen={() => setOpen(true)} />
|
||||
<ModeToggle />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<main className="flex-1 overflow-hidden">
|
||||
<ChatContainer models={models} offline={offline} />
|
||||
</main>
|
||||
<SettingsModal isOpen={open} onClose={() => setOpen(false)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { ModelDropdown } from "@/components/model/ModelDropdown";
|
||||
import { ModelPullDialog } from "@/components/model/ModelPullDialog";
|
||||
import { ChatMessage, ChatInput } from "@/components/chat/ChatMessage";
|
||||
import { SystemPrompt } from "@/components/chat/SystemPrompt";
|
||||
import type { ModelInfo } from "@/lib/models";
|
||||
import type { Message } from "@/lib/chat";
|
||||
import { createMessage } from "@/lib/chat";
|
||||
import { useUserPrefs, buildOllamaOptions } from "@/hooks/useUserPrefs";
|
||||
|
||||
interface ChatContainerProps {
|
||||
models: ModelInfo[];
|
||||
offline: boolean;
|
||||
}
|
||||
|
||||
export function ChatContainer({ models, offline }: ChatContainerProps) {
|
||||
const { prefs, ready } = useUserPrefs();
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const [selectedModel, setSelectedModel] = useState<string | null>(null);
|
||||
const [isStreaming, setIsStreaming] = useState(false);
|
||||
const [streamingContent, setStreamingContent] = useState("");
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
const [showPullDialog, setShowPullDialog] = useState(false);
|
||||
const [modelToPull, setModelToPull] = useState<string>("");
|
||||
const [systemPrompt, setSystemPrompt] = useState<string>("");
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const scrollToBottom = () => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
scrollToBottom();
|
||||
}, [messages, streamingContent]);
|
||||
|
||||
const handleSendMessage = async (content: string) => {
|
||||
if (!selectedModel || isStreaming) return;
|
||||
|
||||
const userMessage = createMessage("user", content);
|
||||
const newMessages = [...messages, userMessage];
|
||||
|
||||
// Add system prompt if it exists and this is the first user message
|
||||
const messagesToSend =
|
||||
systemPrompt && messages.length === 0
|
||||
? [createMessage("system", systemPrompt), ...newMessages]
|
||||
: newMessages;
|
||||
|
||||
setMessages(newMessages);
|
||||
setIsStreaming(true);
|
||||
setStreamingContent("");
|
||||
|
||||
try {
|
||||
const options = buildOllamaOptions(
|
||||
prefs.device,
|
||||
prefs.numGpu,
|
||||
prefs.gpuIndex
|
||||
);
|
||||
abortRef.current = new AbortController();
|
||||
const response = await fetch("/api/ollama/chat", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
model: selectedModel,
|
||||
messages: messagesToSend,
|
||||
options,
|
||||
system: systemPrompt || undefined,
|
||||
}),
|
||||
signal: abortRef.current.signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to send message");
|
||||
}
|
||||
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) throw new Error("No response body");
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
let assistantContent = "";
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split("\n");
|
||||
buffer = lines.pop() || "";
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.trim()) continue;
|
||||
|
||||
try {
|
||||
const data = JSON.parse(line);
|
||||
if (data.token) {
|
||||
assistantContent += data.token;
|
||||
setStreamingContent(assistantContent);
|
||||
}
|
||||
if (data.done) {
|
||||
const assistantMessage = createMessage(
|
||||
"assistant",
|
||||
assistantContent
|
||||
);
|
||||
setMessages((prev) => [...prev, assistantMessage]);
|
||||
setStreamingContent("");
|
||||
}
|
||||
if (data.error) {
|
||||
throw new Error(data.error);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Error parsing stream:", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Chat error:", error);
|
||||
const errorMessage = createMessage(
|
||||
"assistant",
|
||||
"Sorry, I encountered an error. Please try again."
|
||||
);
|
||||
setMessages((prev) => [...prev, errorMessage]);
|
||||
} finally {
|
||||
setIsStreaming(false);
|
||||
setStreamingContent("");
|
||||
abortRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
const handleStop = () => {
|
||||
if (abortRef.current && isStreaming) {
|
||||
abortRef.current.abort();
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectModel = (modelId: string) => {
|
||||
const model = models.find((m) => m.id === modelId);
|
||||
if (model) {
|
||||
setSelectedModel(modelId);
|
||||
// Optional: clear chat when switching models
|
||||
// setMessages([]);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePullModel = async (modelName: string) => {
|
||||
try {
|
||||
const response = await fetch("/api/ollama/pull", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ model: modelName }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to pull model");
|
||||
}
|
||||
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) throw new Error("No response body");
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split("\n");
|
||||
buffer = lines.pop() || "";
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.trim()) continue;
|
||||
|
||||
try {
|
||||
const data = JSON.parse(line);
|
||||
if (data.status === "success") {
|
||||
// Model pulled successfully, refresh models list
|
||||
window.location.reload();
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Error parsing pull stream:", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const selectedModelData = models.find((m) => m.id === selectedModel);
|
||||
|
||||
return (
|
||||
<div className="flex h-full bg-[var(--background)]">
|
||||
{/* Sidebar */}
|
||||
<div className="w-80 border-r border-[var(--border)] bg-[var(--surface)] p-4 overflow-y-auto">
|
||||
<div className="mb-4">
|
||||
<h2 className="text-sm font-medium text-[var(--foreground)]/80 mb-2">
|
||||
Models
|
||||
</h2>
|
||||
<ModelDropdown
|
||||
models={models}
|
||||
selectedModel={selectedModel}
|
||||
onSelectModel={handleSelectModel}
|
||||
disabled={isStreaming}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{offline && (
|
||||
<div className="rounded-lg border border-orange-200 bg-orange-50 p-3">
|
||||
<p className="text-sm text-orange-800">
|
||||
Ollama is offline. Using mock data.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedModelData && (
|
||||
<div className="rounded-lg border border-[var(--border)] bg-[var(--background)] p-3">
|
||||
<h3 className="font-medium text-sm mb-2">Selected Model</h3>
|
||||
<div className="space-y-1 text-xs text-[var(--foreground)]/80">
|
||||
<div>Name: {selectedModelData.name}</div>
|
||||
<div>Size: {selectedModelData.sizeGB} GB</div>
|
||||
<div>Quantization: {selectedModelData.quantization}</div>
|
||||
<div>Device: {selectedModelData.device}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<SystemPrompt onSystemPromptChange={setSystemPrompt} />
|
||||
</div>
|
||||
|
||||
{/* Chat Area */}
|
||||
<div className="flex-1 flex flex-col min-h-0">
|
||||
{/* Chat Header */}
|
||||
<div className="border-b border-[var(--border)] p-4 flex items-center justify-between gap-3">
|
||||
<h1 className="text-lg font-semibold">
|
||||
{selectedModelData
|
||||
? `Chat with ${selectedModelData.name}`
|
||||
: "Select a model to start chatting"}
|
||||
</h1>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={handleStop}
|
||||
disabled={!isStreaming}
|
||||
className="rounded-md border border-[var(--border)] px-3 py-1 text-sm hover:bg-[var(--surface)] disabled:opacity-50"
|
||||
>
|
||||
Stop
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Messages */}
|
||||
<div className="flex-1 overflow-y-auto min-h-0">
|
||||
{messages.length === 0 ? (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-center text-[var(--foreground)]/60">
|
||||
<p className="text-lg mb-2">Welcome to Ollahub</p>
|
||||
<p className="text-sm">Select a model and start chatting!</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{messages.map((message) => (
|
||||
<ChatMessage key={message.id} message={message} />
|
||||
))}
|
||||
{isStreaming && streamingContent && (
|
||||
<ChatMessage
|
||||
message={createMessage("assistant", streamingContent)}
|
||||
isStreaming={true}
|
||||
/>
|
||||
)}
|
||||
<div ref={messagesEndRef} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Input */}
|
||||
<ChatInput
|
||||
onSendMessage={handleSendMessage}
|
||||
disabled={!selectedModel || isStreaming}
|
||||
placeholder={
|
||||
selectedModel ? "Type a message..." : "Select a model first..."
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Pull Dialog */}
|
||||
<ModelPullDialog
|
||||
isOpen={showPullDialog}
|
||||
onClose={() => setShowPullDialog(false)}
|
||||
modelName={modelToPull}
|
||||
onConfirmPull={handlePullModel}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { User, Bot, Send, Loader2 } from "lucide-react";
|
||||
import { MarkdownRenderer } from "@/components/message/MarkdownRenderer";
|
||||
import { MessageActions } from "@/components/message/MessageActions";
|
||||
import { extractCodeBlocks } from "@/hooks/useMarkdown";
|
||||
import type { Message } from "@/lib/chat";
|
||||
import { formatTimestamp } from "@/lib/chat";
|
||||
|
||||
interface ChatMessageProps {
|
||||
message: Message;
|
||||
isStreaming?: boolean;
|
||||
}
|
||||
|
||||
export function ChatMessage({
|
||||
message,
|
||||
isStreaming = false,
|
||||
}: ChatMessageProps) {
|
||||
const isUser = message.role === "user";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex gap-3 p-4 ${isUser ? "justify-end" : "justify-start"}`}
|
||||
>
|
||||
{!isUser && (
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-[var(--accent)] text-white">
|
||||
<Bot className="h-4 w-4" />
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={`max-w-[80%] rounded-lg px-4 py-2 ${
|
||||
isUser
|
||||
? "bg-[var(--accent)] text-white"
|
||||
: "bg-[var(--surface)] text-[var(--foreground)]"
|
||||
}`}
|
||||
>
|
||||
<div className="whitespace-pre-wrap break-words">
|
||||
{isUser ? (
|
||||
message.content
|
||||
) : (
|
||||
<MarkdownRenderer content={message.content} />
|
||||
)}
|
||||
</div>
|
||||
{isStreaming && <span className="ml-1 animate-pulse">▊</span>}
|
||||
{!isUser && !isStreaming && (
|
||||
<div className="mt-2">
|
||||
<MessageActions
|
||||
content={message.content}
|
||||
extractCodeBlocks={extractCodeBlocks}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={`mt-1 text-xs opacity-70 ${
|
||||
isUser ? "text-right" : "text-left"
|
||||
}`}
|
||||
>
|
||||
{formatTimestamp(message.timestamp)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isUser && (
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-[var(--surface)]">
|
||||
<User className="h-4 w-4" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ChatInputProps {
|
||||
onSendMessage: (content: string) => void;
|
||||
disabled?: boolean;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
export function ChatInput({
|
||||
onSendMessage,
|
||||
disabled = false,
|
||||
placeholder = "Type a message...",
|
||||
}: ChatInputProps) {
|
||||
const [content, setContent] = useState("");
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (content.trim() && !disabled) {
|
||||
onSendMessage(content.trim());
|
||||
setContent("");
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSubmit(e);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.style.height = "auto";
|
||||
textareaRef.current.style.height = `${Math.min(
|
||||
textareaRef.current.scrollHeight,
|
||||
120
|
||||
)}px`;
|
||||
}
|
||||
}, [content]);
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="flex gap-2 p-4 border-t border-[var(--border)]"
|
||||
>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
className="flex-1 resize-none rounded-lg border border-[var(--border)] bg-[var(--background)] px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[var(--accent)] disabled:opacity-50"
|
||||
rows={1}
|
||||
maxLength={2000}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!content.trim() || disabled}
|
||||
className="flex h-10 w-10 items-center justify-center rounded-lg bg-[var(--accent)] text-white transition-colors hover:bg-[color-mix(in_oklab,var(--accent),black_10%)] disabled:opacity-50"
|
||||
>
|
||||
{disabled ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Send className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
interface StreamingTextProps {
|
||||
text: string;
|
||||
isStreaming: boolean;
|
||||
}
|
||||
|
||||
export function StreamingText({ text, isStreaming }: StreamingTextProps) {
|
||||
return (
|
||||
<span>
|
||||
{text}
|
||||
{isStreaming && <span className="ml-1 animate-pulse">▊</span>}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Settings, Save, RotateCcw } from "lucide-react";
|
||||
import { SystemPromptInfo } from "./SystemPromptInfo";
|
||||
|
||||
interface SystemPromptProps {
|
||||
onSystemPromptChange: (prompt: string) => void;
|
||||
}
|
||||
|
||||
export function SystemPrompt({ onSystemPromptChange }: SystemPromptProps) {
|
||||
const [systemPrompt, setSystemPrompt] = useState("");
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [hasChanges, setHasChanges] = useState(false);
|
||||
|
||||
// Load from localStorage on mount
|
||||
useEffect(() => {
|
||||
const saved = localStorage.getItem("ollahub-system-prompt");
|
||||
if (saved) {
|
||||
setSystemPrompt(saved);
|
||||
onSystemPromptChange(saved);
|
||||
}
|
||||
}, [onSystemPromptChange]);
|
||||
|
||||
const handleSave = () => {
|
||||
localStorage.setItem("ollahub-system-prompt", systemPrompt);
|
||||
onSystemPromptChange(systemPrompt);
|
||||
setIsEditing(false);
|
||||
setHasChanges(false);
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
const defaultPrompt = "";
|
||||
setSystemPrompt(defaultPrompt);
|
||||
localStorage.removeItem("ollahub-system-prompt");
|
||||
onSystemPromptChange(defaultPrompt);
|
||||
setIsEditing(false);
|
||||
setHasChanges(false);
|
||||
};
|
||||
|
||||
const handleChange = (value: string) => {
|
||||
setSystemPrompt(value);
|
||||
setHasChanges(true);
|
||||
setIsEditing(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mt-6">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h3 className="text-sm font-medium text-[var(--foreground)]/80">
|
||||
System Prompt
|
||||
</h3>
|
||||
<div className="flex gap-1">
|
||||
<SystemPromptInfo />
|
||||
{isEditing && (
|
||||
<>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={!hasChanges}
|
||||
className="p-1 rounded hover:bg-[var(--background)] disabled:opacity-50"
|
||||
title="Save prompt"
|
||||
>
|
||||
<Save className="h-3 w-3" />
|
||||
</button>
|
||||
<button
|
||||
onClick={handleReset}
|
||||
className="p-1 rounded hover:bg-[var(--background)]"
|
||||
title="Reset to default"
|
||||
>
|
||||
<RotateCcw className="h-3 w-3" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setIsEditing(!isEditing)}
|
||||
className="p-1 rounded hover:bg-[var(--background)]"
|
||||
title="Edit system prompt"
|
||||
>
|
||||
<Settings className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isEditing ? (
|
||||
<div className="space-y-2">
|
||||
<textarea
|
||||
value={systemPrompt}
|
||||
onChange={(e) => handleChange(e.target.value)}
|
||||
placeholder="Enter system prompt (optional)..."
|
||||
className="w-full h-24 resize-none rounded-md border border-[var(--border)] bg-[var(--background)] px-3 py-2 text-xs focus:outline-none focus:ring-2 focus:ring-[var(--accent)]"
|
||||
/>
|
||||
<p className="text-xs text-[var(--foreground)]/60">
|
||||
System prompts help define the AI's behavior and personality.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-md border border-[var(--border)] bg-[var(--background)] p-3">
|
||||
{systemPrompt ? (
|
||||
<p className="text-xs text-[var(--foreground)]/80 whitespace-pre-wrap">
|
||||
{systemPrompt}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-xs text-[var(--foreground)]/60 italic">
|
||||
No system prompt set
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
"use client";
|
||||
|
||||
import { Info } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
export function SystemPromptInfo() {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="p-1 rounded hover:bg-[var(--background)]"
|
||||
title="About system prompts"
|
||||
>
|
||||
<Info className="h-3 w-3" />
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div className="absolute top-6 left-0 z-50 w-80 rounded-lg border border-[var(--border)] bg-[var(--background)] p-3 shadow-lg">
|
||||
<div className="text-xs space-y-2">
|
||||
<h4 className="font-medium">System Prompts</h4>
|
||||
<p>
|
||||
System prompts define the AI's behavior, personality, and response
|
||||
style. They are sent once at the beginning of each conversation.
|
||||
</p>
|
||||
<div className="space-y-1">
|
||||
<p className="font-medium">Examples:</p>
|
||||
<ul className="list-disc list-inside space-y-1 text-[var(--foreground)]/80">
|
||||
<li>"You are a helpful coding assistant"</li>
|
||||
<li>"Respond in Portuguese, be concise"</li>
|
||||
<li>"You are a creative writing coach"</li>
|
||||
</ul>
|
||||
</div>
|
||||
<p className="text-[var(--foreground)]/60">
|
||||
Based on Ollama documentation - system messages help guide model
|
||||
behavior.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setIsOpen(false)}
|
||||
className="absolute top-2 right-2 p-1 rounded hover:bg-[var(--surface)]"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import rehypeRaw from "rehype-raw";
|
||||
import rehypeHighlight from "rehype-highlight";
|
||||
|
||||
type MarkdownRendererProps = {
|
||||
content: string;
|
||||
};
|
||||
|
||||
export function MarkdownRenderer({ content }: MarkdownRendererProps) {
|
||||
return (
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
rehypePlugins={[rehypeRaw, rehypeHighlight]}
|
||||
>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Copy, Edit3, RefreshCw, Check } from "lucide-react";
|
||||
|
||||
type MessageActionsProps = {
|
||||
content: string;
|
||||
disabled?: boolean;
|
||||
onEdit?: () => void;
|
||||
onRegenerate?: () => void;
|
||||
extractCodeBlocks?: (
|
||||
text: string
|
||||
) => Array<{ language: string; code: string }>;
|
||||
};
|
||||
|
||||
export function MessageActions({
|
||||
content,
|
||||
disabled,
|
||||
onEdit,
|
||||
onRegenerate,
|
||||
extractCodeBlocks,
|
||||
}: MessageActionsProps) {
|
||||
const [copied, setCopied] = useState<"none" | "markdown" | "text" | "code">(
|
||||
"none"
|
||||
);
|
||||
|
||||
const copy = async (text: string, flag: MessageActionsProps["content"]) => {
|
||||
await navigator.clipboard.writeText(text);
|
||||
setCopied(flag as any);
|
||||
setTimeout(() => setCopied("none"), 1500);
|
||||
};
|
||||
|
||||
const stripMarkdown = (md: string) =>
|
||||
md
|
||||
.replace(/```[\s\S]*?```/g, (block) =>
|
||||
block.replace(/```[\s\S]*?\n/, "").replace(/```$/, "")
|
||||
)
|
||||
.replace(/[#*_>`~-]/g, "");
|
||||
const codeBlocks = extractCodeBlocks ? extractCodeBlocks(content) : [];
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1 opacity-70">
|
||||
<button
|
||||
className="rounded px-2 py-1 text-xs hover:bg-[var(--surface)]"
|
||||
onClick={() => copy(content, "markdown")}
|
||||
disabled={disabled}
|
||||
aria-label="Copy as markdown"
|
||||
>
|
||||
{copied === "markdown" ? (
|
||||
<Check className="h-3 w-3" />
|
||||
) : (
|
||||
<Copy className="h-3 w-3" />
|
||||
)}{" "}
|
||||
MD
|
||||
</button>
|
||||
<button
|
||||
className="rounded px-2 py-1 text-xs hover:bg-[var(--surface)]"
|
||||
onClick={() => copy(stripMarkdown(content), "text")}
|
||||
disabled={disabled}
|
||||
aria-label="Copy as plain text"
|
||||
>
|
||||
{copied === "text" ? (
|
||||
<Check className="h-3 w-3" />
|
||||
) : (
|
||||
<Copy className="h-3 w-3" />
|
||||
)}{" "}
|
||||
TXT
|
||||
</button>
|
||||
{codeBlocks.map((b, i) => (
|
||||
<button
|
||||
key={`${b.language}-${i}`}
|
||||
className="rounded px-2 py-1 text-xs hover:bg-[var(--surface)]"
|
||||
onClick={() => copy(b.code, "code")}
|
||||
disabled={disabled}
|
||||
aria-label={`Copy ${b.language} code`}
|
||||
>
|
||||
{copied === "code" ? (
|
||||
<Check className="h-3 w-3" />
|
||||
) : (
|
||||
<Copy className="h-3 w-3" />
|
||||
)}{" "}
|
||||
{b.language}
|
||||
</button>
|
||||
))}
|
||||
{onEdit && (
|
||||
<button
|
||||
className="rounded px-2 py-1 text-xs hover:bg-[var(--surface)]"
|
||||
onClick={onEdit}
|
||||
disabled={disabled}
|
||||
aria-label="Edit message"
|
||||
>
|
||||
<Edit3 className="h-3 w-3" /> Edit
|
||||
</button>
|
||||
)}
|
||||
{onRegenerate && (
|
||||
<button
|
||||
className="rounded px-2 py-1 text-xs hover:bg-[var(--surface)]"
|
||||
onClick={onRegenerate}
|
||||
disabled={disabled}
|
||||
aria-label="Regenerate"
|
||||
>
|
||||
<RefreshCw className="h-3 w-3" /> Retry
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
"use client";
|
||||
|
||||
import { Moon, Sun } from "lucide-react";
|
||||
import { useTheme } from "next-themes";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export function ModeToggle() {
|
||||
const { theme, setTheme } = useTheme();
|
||||
const [mounted, setMounted] = useState(false);
|
||||
useEffect(() => setMounted(true), []);
|
||||
if (!mounted) return null;
|
||||
const isDark =
|
||||
theme === "dark" ||
|
||||
(!theme &&
|
||||
typeof window !== "undefined" &&
|
||||
window.matchMedia("(prefers-color-scheme: dark)").matches);
|
||||
return (
|
||||
<button
|
||||
aria-label="Alternar tema"
|
||||
className="inline-flex h-9 items-center gap-2 rounded-md border border-[var(--border)] bg-[var(--surface)] px-3 text-sm transition-colors hover:bg-[color-mix(in_oklab,var(--surface),black_6%)] focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent)]"
|
||||
onClick={() => setTheme(isDark ? "light" : "dark")}
|
||||
>
|
||||
{isDark ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
|
||||
<span className="hidden sm:inline">{isDark ? "Light" : "Dark"}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
"use client";
|
||||
|
||||
import { Cpu, CircuitBoard, Info } from "lucide-react";
|
||||
import type { ModelInfo } from "@/lib/models";
|
||||
import { useState } from "react";
|
||||
|
||||
export function ModelCard({ model }: { model: ModelInfo }) {
|
||||
const [hover, setHover] = useState(false);
|
||||
const isGpu = model.device === "GPU";
|
||||
return (
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onMouseEnter={() => setHover(true)}
|
||||
onMouseLeave={() => setHover(false)}
|
||||
onFocus={() => setHover(true)}
|
||||
onBlur={() => setHover(false)}
|
||||
className="group relative rounded-lg border border-[var(--border)] bg-[var(--background)] p-4 shadow-sm transition-all hover:shadow-md focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent)]"
|
||||
>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<h3 className="text-[16px] font-semibold tracking-tight">
|
||||
{model.name}
|
||||
</h3>
|
||||
<span className="inline-flex items-center gap-1 rounded-md border border-[var(--border)] px-2 py-0.5 text-xs text-[var(--foreground)]/80">
|
||||
{isGpu ? (
|
||||
<CircuitBoard className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<Cpu className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{model.device}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm text-[var(--foreground)]/80">
|
||||
<span className="rounded-md bg-[var(--surface)] px-2 py-0.5">
|
||||
{model.quantization}
|
||||
</span>
|
||||
<span className="rounded-md bg-[var(--surface)] px-2 py-0.5">
|
||||
{model.sizeGB} GB
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="pointer-events-none absolute right-3 top-3 opacity-60">
|
||||
<Info className="h-4 w-4" />
|
||||
</div>
|
||||
|
||||
{hover && (
|
||||
<div className="absolute left-0 top-full z-10 mt-2 w-72 rounded-md border border-[var(--border)] bg-[var(--background)] p-3 text-sm shadow-lg">
|
||||
<div className="mb-2 font-medium">Detalhes (estimativa)</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span>Uso de CPU</span>
|
||||
<span className="font-medium">{model.estCpuUsagePct ?? "—"}%</span>
|
||||
</div>
|
||||
<div className="mt-1 flex items-center justify-between">
|
||||
<span>Uso de VRAM</span>
|
||||
<span className="font-medium">{model.estVramGB ?? 0} GB</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { ChevronDown, Cpu, CircuitBoard } from "lucide-react";
|
||||
import type { ModelInfo } from "@/lib/models";
|
||||
|
||||
interface ModelDropdownProps {
|
||||
models: ModelInfo[];
|
||||
selectedModel: string | null;
|
||||
onSelectModel: (modelId: string) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function ModelDropdown({
|
||||
models,
|
||||
selectedModel,
|
||||
onSelectModel,
|
||||
disabled = false,
|
||||
}: ModelDropdownProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const selectedModelData = models.find((m) => m.id === selectedModel);
|
||||
|
||||
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);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="relative" ref={dropdownRef}>
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
disabled={disabled}
|
||||
className="flex w-full items-center justify-between rounded-lg border border-[var(--border)] bg-[var(--background)] px-3 py-2 text-left text-sm transition-colors hover:bg-[var(--surface)] disabled:opacity-50"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{selectedModelData ? (
|
||||
<>
|
||||
<span className="truncate">{selectedModelData.name}</span>
|
||||
<span className="flex items-center gap-1 rounded-md bg-[var(--surface)] px-2 py-0.5 text-xs">
|
||||
{selectedModelData.device === "GPU" ? (
|
||||
<CircuitBoard className="h-3 w-3" />
|
||||
) : (
|
||||
<Cpu className="h-3 w-3" />
|
||||
)}
|
||||
{selectedModelData.device}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-[var(--foreground)]/60">
|
||||
Select a model...
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<ChevronDown
|
||||
className={`h-4 w-4 transition-transform ${
|
||||
isOpen ? "rotate-180" : ""
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div className="absolute top-full z-50 mt-1 w-full rounded-lg border border-[var(--border)] bg-[var(--background)] shadow-lg">
|
||||
<div className="max-h-60 overflow-y-auto">
|
||||
{models.length === 0 ? (
|
||||
<div className="px-3 py-2 text-sm text-[var(--foreground)]/60">
|
||||
No models available
|
||||
</div>
|
||||
) : (
|
||||
models.map((model) => (
|
||||
<button
|
||||
key={model.id}
|
||||
onClick={() => {
|
||||
onSelectModel(model.id);
|
||||
setIsOpen(false);
|
||||
}}
|
||||
className="flex w-full items-center justify-between px-3 py-2 text-left text-sm transition-colors hover:bg-[var(--surface)]"
|
||||
>
|
||||
<div className="flex flex-col items-start gap-1">
|
||||
<span className="font-medium">{model.name}</span>
|
||||
<div className="flex items-center gap-2 text-xs text-[var(--foreground)]/60">
|
||||
<span className="rounded-md bg-[var(--surface)] px-2 py-0.5">
|
||||
{model.quantization}
|
||||
</span>
|
||||
<span className="rounded-md bg-[var(--surface)] px-2 py-0.5">
|
||||
{model.sizeGB} GB
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
{model.device === "GPU" ? (
|
||||
<CircuitBoard className="h-3 w-3" />
|
||||
) : (
|
||||
<Cpu className="h-3 w-3" />
|
||||
)}
|
||||
{model.device}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
"use client";
|
||||
|
||||
import { ModelCard } from "./ModelCard";
|
||||
import type { ModelInfo } from "@/lib/models";
|
||||
|
||||
export function ModelList({ models }: { models: ModelInfo[] }) {
|
||||
if (!models?.length) {
|
||||
return (
|
||||
<div className="rounded-lg border border-[var(--border)] p-6 text-sm text-[var(--foreground)]/70">
|
||||
Nenhum modelo encontrado.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
|
||||
{models.map((m) => (
|
||||
<ModelCard key={m.id} model={m} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { X, Download, Loader2 } from "lucide-react";
|
||||
|
||||
interface ModelPullDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
modelName: string;
|
||||
onConfirmPull: (modelName: string) => Promise<void>;
|
||||
}
|
||||
|
||||
interface PullProgress {
|
||||
status: string;
|
||||
digest?: string;
|
||||
total?: number;
|
||||
completed?: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export function ModelPullDialog({
|
||||
isOpen,
|
||||
onClose,
|
||||
modelName,
|
||||
onConfirmPull,
|
||||
}: ModelPullDialogProps) {
|
||||
const [isPulling, setIsPulling] = useState(false);
|
||||
const [progress, setProgress] = useState<PullProgress | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
setIsPulling(false);
|
||||
setProgress(null);
|
||||
setError(null);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
const handleConfirm = async () => {
|
||||
setIsPulling(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
await onConfirmPull(modelName);
|
||||
onClose();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to pull model");
|
||||
} finally {
|
||||
setIsPulling(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<div className="absolute inset-0 bg-black/50" onClick={onClose} />
|
||||
<div className="relative w-full max-w-md rounded-lg border border-[var(--border)] bg-[var(--background)] p-6 shadow-lg">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold">Install Model</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
disabled={isPulling}
|
||||
className="rounded-md p-1 transition-colors hover:bg-[var(--surface)] disabled:opacity-50"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<p className="text-sm text-[var(--foreground)]/80 mb-2">
|
||||
Install <span className="font-medium">{modelName}</span>?
|
||||
</p>
|
||||
<p className="text-xs text-[var(--foreground)]/60">
|
||||
This will download the model to your local Ollama installation.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{progress && (
|
||||
<div className="mb-4 rounded-lg bg-[var(--surface)] p-3">
|
||||
<div className="flex items-center justify-between text-sm mb-2">
|
||||
<span>Downloading...</span>
|
||||
<span>
|
||||
{progress.total && progress.completed
|
||||
? `${Math.round(
|
||||
(progress.completed / progress.total) * 100,
|
||||
)}%`
|
||||
: "0%"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full bg-[var(--border)] rounded-full h-2">
|
||||
<div
|
||||
className="bg-[var(--accent)] h-2 rounded-full transition-all duration-300"
|
||||
style={{
|
||||
width:
|
||||
progress.total && progress.completed
|
||||
? `${(progress.completed / progress.total) * 100}%`
|
||||
: "0%",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{progress.digest && (
|
||||
<p className="text-xs text-[var(--foreground)]/60 mt-1">
|
||||
{progress.digest.slice(0, 12)}...
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 rounded-lg bg-red-50 border border-red-200 p-3">
|
||||
<p className="text-sm text-red-800">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2 justify-end">
|
||||
<button
|
||||
onClick={onClose}
|
||||
disabled={isPulling}
|
||||
className="px-4 py-2 text-sm rounded-md border border-[var(--border)] transition-colors hover:bg-[var(--surface)] disabled:opacity-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleConfirm}
|
||||
disabled={isPulling}
|
||||
className="flex items-center gap-2 px-4 py-2 text-sm rounded-md bg-[var(--accent)] text-white transition-colors hover:bg-[color-mix(in_oklab,var(--accent),black_10%)] disabled:opacity-50"
|
||||
>
|
||||
{isPulling ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Installing...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Download className="h-4 w-4" />
|
||||
Install
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { X, Settings } from "lucide-react";
|
||||
import { useUserPrefs, type DevicePref } from "@/hooks/useUserPrefs";
|
||||
|
||||
export function SettingsButton({ onOpen }: { onOpen: () => void }) {
|
||||
return (
|
||||
<button
|
||||
onClick={onOpen}
|
||||
aria-label="Open settings"
|
||||
className="inline-flex h-9 items-center gap-2 rounded-md border border-border bg-[var(--surface)] px-3 text-sm transition-colors hover:bg-[color-mix(in_oklab,var(--surface),black_6%)] focus:outline-none focus-visible:ring-2 focus-visible:ring-accent"
|
||||
>
|
||||
<Settings className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">Settings</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function SettingsModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
}: {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { prefs, ready, update } = useUserPrefs();
|
||||
const [tab, setTab] = useState<"general" | "execution">("general");
|
||||
const [localPrompt, setLocalPrompt] = useState("");
|
||||
const [device, setDevice] = useState<DevicePref>("auto");
|
||||
const [gpuIndex, setGpuIndex] = useState(0);
|
||||
const [numGpu, setNumGpu] = useState(1);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ready) return;
|
||||
setLocalPrompt(prefs.systemPrompt);
|
||||
setDevice(prefs.device);
|
||||
setGpuIndex(prefs.gpuIndex);
|
||||
setNumGpu(prefs.numGpu);
|
||||
}, [ready, prefs]);
|
||||
|
||||
const handleSave = () => {
|
||||
update({ systemPrompt: localPrompt, device, gpuIndex, numGpu });
|
||||
onClose();
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<div className="absolute inset-0 bg-black/50" onClick={onClose} />
|
||||
<div className="relative w-full max-w-xl rounded-lg border border-border bg-background p-4 shadow-lg">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h2 className="text-base font-semibold">Settings</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded p-1 hover:bg-[var(--surface)]"
|
||||
aria-label="Close"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mb-4 flex gap-2 border-b border-border">
|
||||
<button
|
||||
className={`px-3 py-2 text-sm ${
|
||||
tab === "general"
|
||||
? "border-b-2 border-accent font-medium"
|
||||
: "text-[var(--foreground)]/60"
|
||||
}`}
|
||||
onClick={() => setTab("general")}
|
||||
>
|
||||
General
|
||||
</button>
|
||||
<button
|
||||
className={`px-3 py-2 text-sm ${
|
||||
tab === "execution"
|
||||
? "border-b-2 border-accent font-medium"
|
||||
: "text-[var(--foreground)]/60"
|
||||
}`}
|
||||
onClick={() => setTab("execution")}
|
||||
>
|
||||
Execution
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{tab === "general" && (
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">System Prompt</label>
|
||||
<textarea
|
||||
value={localPrompt}
|
||||
onChange={(e) => setLocalPrompt(e.target.value)}
|
||||
placeholder="Define how the AI should behave..."
|
||||
className="h-32 w-full resize-none rounded-md border border-border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-accent"
|
||||
/>
|
||||
<p className="text-xs text-[var(--foreground)]/60">
|
||||
Sent at the start of a conversation to guide model behavior.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === "execution" && (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm font-medium">Device</label>
|
||||
<div className="flex gap-2">
|
||||
{(["auto", "cpu", "gpu"] as DevicePref[]).map((d) => (
|
||||
<button
|
||||
key={d}
|
||||
onClick={() => setDevice(d)}
|
||||
className={`rounded-md border border-border px-3 py-1 text-sm ${
|
||||
device === d
|
||||
? "bg-[var(--surface)]"
|
||||
: "hover:bg-[var(--surface)]"
|
||||
}`}
|
||||
>
|
||||
{d.toUpperCase()}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs text-[var(--foreground)]/60">
|
||||
CPU best-effort via num_gpu:0. For hard CPU-only, set
|
||||
CUDA_VISIBLE_DEVICES=-1 before starting Ollama.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="text-sm font-medium">
|
||||
GPU Index (main_gpu)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={gpuIndex}
|
||||
onChange={(e) => setGpuIndex(Number(e.target.value))}
|
||||
className="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-accent"
|
||||
min={0}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium">
|
||||
Num GPUs (num_gpu)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={numGpu}
|
||||
onChange={(e) => setNumGpu(Number(e.target.value))}
|
||||
className="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-accent"
|
||||
min={0}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-[var(--foreground)]/60">
|
||||
Auto: no options sent. GPU: sends num_gpu/main_gpu. CPU: sends
|
||||
num_gpu:0.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-6 flex justify-end gap-2">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded-md border border-border px-4 py-2 text-sm hover:bg-[var(--surface)]"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
className="rounded-md bg-accent px-4 py-2 text-sm text-white hover:bg-[color-mix(in_oklab,var(--accent),black_10%)]"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { ThemeProvider as NextThemesProvider } from "next-themes";
|
||||
|
||||
export function ThemeProvider({
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NextThemesProvider>) {
|
||||
return (
|
||||
<NextThemesProvider
|
||||
attribute="class"
|
||||
defaultTheme="system"
|
||||
enableSystem
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</NextThemesProvider>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user