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:
Evandro Fonseca Junior committed 2025-10-28 12:51:19 -03:00
1 parent 58e5522700
commit a3f8226fda
42 files changed
+4465 -314

No files matched your search

+296
View File
@@ -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>
);
}
+154
View File
@@ -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>
);
}
+111
View File
@@ -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>
);
}
+50
View File
@@ -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>
);
}