mirror of
https://github.com/ollama/ollama.git
synced 2026-01-18 20:39:13 -05:00
Compare commits
3 Commits
parth/decr
...
hoyyeva/re
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
42d6a3f075 | ||
|
|
ed553f51f7 | ||
|
|
7d6f0c621f |
@@ -305,6 +305,9 @@ func main() {
|
||||
go func() {
|
||||
<-signals
|
||||
slog.Info("received SIGINT or SIGTERM signal, shutting down")
|
||||
if err := st.ClearAllDrafts(); err != nil {
|
||||
slog.Warn("failed to clear drafts on shutdown", "error", err)
|
||||
}
|
||||
quit()
|
||||
}()
|
||||
|
||||
|
||||
@@ -182,6 +182,11 @@ func osRun(_ func(), hasCompletedFirstRun, startHidden bool) {
|
||||
}
|
||||
|
||||
func quit() {
|
||||
if wv.Store != nil {
|
||||
if err := wv.Store.ClearAllDrafts(); err != nil {
|
||||
slog.Warn("failed to clear drafts on quit", "error", err)
|
||||
}
|
||||
}
|
||||
C.quit()
|
||||
}
|
||||
|
||||
|
||||
@@ -111,6 +111,11 @@ func (*appCallbacks) UIRunning() bool {
|
||||
}
|
||||
|
||||
func (app *appCallbacks) Quit() {
|
||||
if wv.Store != nil {
|
||||
if err := wv.Store.ClearAllDrafts(); err != nil {
|
||||
slog.Warn("failed to clear drafts on quit", "error", err)
|
||||
}
|
||||
}
|
||||
app.t.Quit()
|
||||
wv.Terminate()
|
||||
}
|
||||
|
||||
6
app/package-lock.json
generated
Normal file
6
app/package-lock.json
generated
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "app",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {}
|
||||
}
|
||||
@@ -14,7 +14,7 @@ import (
|
||||
|
||||
// currentSchemaVersion defines the current database schema version.
|
||||
// Increment this when making schema changes that require migrations.
|
||||
const currentSchemaVersion = 12
|
||||
const currentSchemaVersion = 13
|
||||
|
||||
// database wraps the SQLite connection.
|
||||
// SQLite handles its own locking for concurrent access:
|
||||
@@ -95,7 +95,8 @@ func (db *database) init() error {
|
||||
id TEXT PRIMARY KEY,
|
||||
title TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
browser_state TEXT
|
||||
browser_state TEXT,
|
||||
draft TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS messages (
|
||||
@@ -244,6 +245,12 @@ func (db *database) migrate() error {
|
||||
return fmt.Errorf("migrate v11 to v12: %w", err)
|
||||
}
|
||||
version = 12
|
||||
case 12:
|
||||
// add draft column to chats table
|
||||
if err := db.migrateV12ToV13(); err != nil {
|
||||
return fmt.Errorf("migrate v12 to v13: %w", err)
|
||||
}
|
||||
version = 13
|
||||
default:
|
||||
// If we have a version we don't recognize, just set it to current
|
||||
// This might happen during development
|
||||
@@ -452,6 +459,21 @@ func (db *database) migrateV11ToV12() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// migrateV12ToV13 adds the draft column to the chats table
|
||||
func (db *database) migrateV12ToV13() error {
|
||||
_, err := db.conn.Exec(`ALTER TABLE chats ADD COLUMN draft TEXT NOT NULL DEFAULT ''`)
|
||||
if err != nil && !duplicateColumnError(err) {
|
||||
return fmt.Errorf("add draft column: %w", err)
|
||||
}
|
||||
|
||||
_, err = db.conn.Exec(`UPDATE settings SET schema_version = 13`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update schema version: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// cleanupOrphanedData removes orphaned records that may exist due to the foreign key bug
|
||||
func (db *database) cleanupOrphanedData() error {
|
||||
_, err := db.conn.Exec(`
|
||||
@@ -570,7 +592,7 @@ func (db *database) getAllChats() ([]Chat, error) {
|
||||
|
||||
func (db *database) getChatWithOptions(id string, loadAttachmentData bool) (*Chat, error) {
|
||||
query := `
|
||||
SELECT id, title, created_at, browser_state
|
||||
SELECT id, title, created_at, browser_state, draft
|
||||
FROM chats
|
||||
WHERE id = ?
|
||||
`
|
||||
@@ -578,12 +600,14 @@ func (db *database) getChatWithOptions(id string, loadAttachmentData bool) (*Cha
|
||||
var chat Chat
|
||||
var createdAt time.Time
|
||||
var browserState sql.NullString
|
||||
var draft sql.NullString
|
||||
|
||||
err := db.conn.QueryRow(query, id).Scan(
|
||||
&chat.ID,
|
||||
&chat.Title,
|
||||
&createdAt,
|
||||
&browserState,
|
||||
&draft,
|
||||
)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
@@ -599,6 +623,9 @@ func (db *database) getChatWithOptions(id string, loadAttachmentData bool) (*Cha
|
||||
chat.BrowserState = raw
|
||||
}
|
||||
}
|
||||
if draft.Valid {
|
||||
chat.Draft = draft.String
|
||||
}
|
||||
|
||||
messages, err := db.getMessages(id, loadAttachmentData)
|
||||
if err != nil {
|
||||
@@ -622,11 +649,12 @@ func (db *database) saveChat(chat Chat) error {
|
||||
// UPSERT would overwrite browser_state with NULL, breaking revisit rendering that relies
|
||||
// on the last persisted full tool state.
|
||||
query := `
|
||||
INSERT INTO chats (id, title, created_at, browser_state)
|
||||
VALUES (?, ?, ?, ?)
|
||||
INSERT INTO chats (id, title, created_at, browser_state, draft)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
title = excluded.title,
|
||||
browser_state = COALESCE(excluded.browser_state, chats.browser_state)
|
||||
browser_state = COALESCE(excluded.browser_state, chats.browser_state),
|
||||
draft = excluded.draft
|
||||
`
|
||||
|
||||
var browserState sql.NullString
|
||||
@@ -639,6 +667,7 @@ func (db *database) saveChat(chat Chat) error {
|
||||
chat.Title,
|
||||
chat.CreatedAt,
|
||||
browserState,
|
||||
chat.Draft,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("save chat: %w", err)
|
||||
@@ -669,6 +698,23 @@ func (db *database) saveChat(chat Chat) error {
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// updateChatDraft updates only the draft for a chat
|
||||
func (db *database) updateChatDraft(chatID string, draft string) error {
|
||||
_, err := db.conn.Exec(`UPDATE chats SET draft = ? WHERE id = ?`, draft, chatID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update chat draft: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (db *database) clearAllDrafts() error {
|
||||
_, err := db.conn.Exec(`UPDATE chats SET draft = ''`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("clear all drafts: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// updateChatBrowserState updates only the browser_state for a chat
|
||||
func (db *database) updateChatBrowserState(chatID string, state json.RawMessage) error {
|
||||
_, err := db.conn.Exec(`UPDATE chats SET browser_state = ? WHERE id = ?`, string(state), chatID)
|
||||
|
||||
@@ -109,6 +109,7 @@ type Chat struct {
|
||||
Title string `json:"title"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
BrowserState json.RawMessage `json:"browser_state,omitempty" ts_type:"BrowserStateData"`
|
||||
Draft string `json:"draft,omitempty"`
|
||||
}
|
||||
|
||||
// NewChat creates a new Chat with the ID, with CreatedAt timestamp initialized
|
||||
@@ -451,6 +452,22 @@ func (s *Store) AppendMessage(chatID string, message Message) error {
|
||||
return s.db.appendMessage(chatID, message)
|
||||
}
|
||||
|
||||
func (s *Store) UpdateChatDraft(chatID string, draft string) error {
|
||||
if err := s.ensureDB(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return s.db.updateChatDraft(chatID, draft)
|
||||
}
|
||||
|
||||
func (s *Store) ClearAllDrafts() error {
|
||||
if err := s.ensureDB(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return s.db.clearAllDrafts()
|
||||
}
|
||||
|
||||
func (s *Store) UpdateChatBrowserState(chatID string, state json.RawMessage) error {
|
||||
if err := s.ensureDB(); err != nil {
|
||||
return err
|
||||
|
||||
@@ -159,6 +159,7 @@ export class Chat {
|
||||
title: string;
|
||||
created_at: Time;
|
||||
browser_state?: BrowserStateData;
|
||||
draft?: string;
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
@@ -167,6 +168,7 @@ export class Chat {
|
||||
this.title = source["title"];
|
||||
this.created_at = this.convertValues(source["created_at"], Time);
|
||||
this.browser_state = source["browser_state"];
|
||||
this.draft = source["draft"];
|
||||
}
|
||||
|
||||
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
||||
|
||||
@@ -299,6 +299,20 @@ export async function renameChat(chatId: string, title: string): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateChatDraft(chatId: string, draft: string): Promise<void> {
|
||||
const response = await fetch(`${API_BASE}/api/v1/chat/${chatId}/draft`, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ draft }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const error = await response.text();
|
||||
throw new Error(error || "Failed to update draft");
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteChat(chatId: string): Promise<void> {
|
||||
const response = await fetch(`${API_BASE}/api/v1/chat/${chatId}`, {
|
||||
method: "DELETE",
|
||||
|
||||
@@ -282,6 +282,7 @@ export default function Chat({ chatId }: { chatId: string }) {
|
||||
onSubmit={handleChatFormSubmit}
|
||||
chatId={chatId}
|
||||
autoFocus={true}
|
||||
initialDraft={chatQuery?.data?.chat?.draft ?? ""}
|
||||
editingMessage={editingMessage}
|
||||
onCancelEdit={handleCancelEdit}
|
||||
isDisabled={isDisabled}
|
||||
|
||||
@@ -27,6 +27,7 @@ import { ErrorMessage } from "./ErrorMessage";
|
||||
import { processFiles } from "@/utils/fileValidation";
|
||||
import type { ImageData } from "@/types/webview";
|
||||
import { PlusIcon } from "@heroicons/react/24/outline";
|
||||
import { useDraftMessage } from "@/hooks/useDraftMessage";
|
||||
|
||||
export type ThinkingLevel = "low" | "medium" | "high";
|
||||
|
||||
@@ -62,6 +63,7 @@ interface ChatFormProps {
|
||||
chatId?: string;
|
||||
isDownloadingModel?: boolean;
|
||||
isDisabled?: boolean;
|
||||
initialDraft?: string;
|
||||
// Editing props - when provided, ChatForm enters edit mode
|
||||
editingMessage?: {
|
||||
content: string;
|
||||
@@ -84,6 +86,7 @@ function ChatForm({
|
||||
chatId = "new",
|
||||
isDownloadingModel = false,
|
||||
isDisabled = false,
|
||||
initialDraft,
|
||||
editingMessage,
|
||||
onCancelEdit,
|
||||
onFilesReceived,
|
||||
@@ -118,6 +121,8 @@ function ChatForm({
|
||||
null,
|
||||
);
|
||||
|
||||
const { saveDraft, clearDraft } = useDraftMessage(chatId);
|
||||
|
||||
const handleThinkingLevelDropdownToggle = (isOpen: boolean) => {
|
||||
if (
|
||||
isOpen &&
|
||||
@@ -308,10 +313,39 @@ function ChatForm({
|
||||
}
|
||||
}, [editingMessage]);
|
||||
|
||||
// Clear composition and reset textarea height when chatId changes
|
||||
useEffect(() => {
|
||||
resetChatForm();
|
||||
}, [chatId]);
|
||||
if (editingMessage) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (initialDraft && initialDraft.trim()) {
|
||||
setMessage({
|
||||
content: initialDraft,
|
||||
attachments: [],
|
||||
fileErrors: [],
|
||||
});
|
||||
|
||||
// Adjust textarea height after loading draft
|
||||
setTimeout(() => {
|
||||
if (textareaRef.current && initialDraft) {
|
||||
textareaRef.current.style.height = "auto";
|
||||
textareaRef.current.style.height =
|
||||
Math.min(textareaRef.current.scrollHeight, 24 * 8) + "px";
|
||||
}
|
||||
}, 0);
|
||||
} else {
|
||||
resetChatForm();
|
||||
}
|
||||
}, [chatId, initialDraft, editingMessage]);
|
||||
|
||||
// Save draft only when navigating away or on blur
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (!editingMessage && message.content.trim()) {
|
||||
saveDraft(message.content);
|
||||
}
|
||||
};
|
||||
}, [message.content, editingMessage, saveDraft]);
|
||||
|
||||
// Auto-focus textarea when autoFocus is true or when streaming completes (but not when editing)
|
||||
useEffect(() => {
|
||||
@@ -511,12 +545,13 @@ function ChatForm({
|
||||
});
|
||||
}
|
||||
|
||||
// Clear composition after successful submission
|
||||
// Clear composition and draft after successful submission
|
||||
setMessage({
|
||||
content: "",
|
||||
attachments: [],
|
||||
fileErrors: [],
|
||||
});
|
||||
clearDraft();
|
||||
|
||||
// Reset textarea height and refocus after submit
|
||||
setTimeout(() => {
|
||||
@@ -621,6 +656,13 @@ function ChatForm({
|
||||
e.target.style.height = Math.min(e.target.scrollHeight, 24 * 8) + "px";
|
||||
};
|
||||
|
||||
// Save draft when textarea loses focus
|
||||
const handleTextareaBlur = () => {
|
||||
if (!editingMessage && message.content.trim()) {
|
||||
saveDraft(message.content);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFilesUpload = async () => {
|
||||
try {
|
||||
setFileUploadError(null);
|
||||
@@ -832,6 +874,7 @@ function ChatForm({
|
||||
ref={textareaRef}
|
||||
value={message.content}
|
||||
onChange={handleTextareaChange}
|
||||
onBlur={handleTextareaBlur}
|
||||
placeholder="Send a message"
|
||||
disabled={isDisabled}
|
||||
className={`allow-context-menu w-full overflow-y-auto text-neutral-700 outline-none resize-none border-none bg-transparent dark:text-white placeholder:text-neutral-400 dark:placeholder:text-neutral-500 min-h-[24px] leading-6 transition-opacity duration-300 ${
|
||||
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
ArrowLeftIcon,
|
||||
} from "@heroicons/react/20/solid";
|
||||
import { Settings as SettingsType } from "@/gotypes";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import { useUser } from "@/hooks/useUser";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { getSettings, updateSettings } from "@/api";
|
||||
@@ -52,7 +51,6 @@ export default function Settings() {
|
||||
const [isAwaitingConnection, setIsAwaitingConnection] = useState(false);
|
||||
const [connectionError, setConnectionError] = useState<string | null>(null);
|
||||
const [pollingInterval, setPollingInterval] = useState<number | null>(null);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const {
|
||||
data: settingsData,
|
||||
@@ -216,7 +214,7 @@ export default function Settings() {
|
||||
>
|
||||
{isWindows && (
|
||||
<button
|
||||
onClick={() => navigate({ to: "/" })}
|
||||
onClick={() => window.history.back()}
|
||||
className="hover:bg-neutral-100 mr-3 dark:hover:bg-neutral-800 rounded-full p-1.5"
|
||||
>
|
||||
<ArrowLeftIcon className="w-5 h-5 dark:text-white" />
|
||||
@@ -226,7 +224,7 @@ export default function Settings() {
|
||||
</h1>
|
||||
{!isWindows && (
|
||||
<button
|
||||
onClick={() => navigate({ to: "/" })}
|
||||
onClick={() => window.history.back()}
|
||||
className="p-1 hover:bg-neutral-100 mr-3 dark:hover:bg-neutral-800 rounded-full"
|
||||
>
|
||||
<XMarkIcon className="w-6 h-6 dark:text-white" />
|
||||
|
||||
34
app/ui/app/src/hooks/useDraftMessage.ts
Normal file
34
app/ui/app/src/hooks/useDraftMessage.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { useCallback } from "react";
|
||||
import { updateChatDraft } from "@/api";
|
||||
|
||||
export function useDraftMessage(chatId: string) {
|
||||
const saveDraft = useCallback(async (content: string) => {
|
||||
try {
|
||||
if (chatId === "new") {
|
||||
return;
|
||||
}
|
||||
|
||||
await updateChatDraft(chatId, content);
|
||||
} catch (error) {
|
||||
console.error("Error saving draft message:", error);
|
||||
}
|
||||
}, [chatId]);
|
||||
|
||||
const clearDraft = useCallback(async () => {
|
||||
try {
|
||||
if (chatId === "new") {
|
||||
return;
|
||||
}
|
||||
|
||||
await updateChatDraft(chatId, "");
|
||||
} catch (error) {
|
||||
console.error("Error clearing draft message:", error);
|
||||
}
|
||||
}, [chatId]);
|
||||
|
||||
return {
|
||||
saveDraft,
|
||||
clearDraft,
|
||||
};
|
||||
}
|
||||
|
||||
23
app/ui/ui.go
23
app/ui/ui.go
@@ -253,6 +253,7 @@ func (s *Server) Handler() http.Handler {
|
||||
mux.Handle("DELETE /api/v1/chat/{id}", handle(s.deleteChat))
|
||||
mux.Handle("POST /api/v1/create-chat", handle(s.createChat))
|
||||
mux.Handle("PUT /api/v1/chat/{id}/rename", handle(s.renameChat))
|
||||
mux.Handle("PUT /api/v1/chat/{id}/draft", handle(s.updateDraft))
|
||||
|
||||
mux.Handle("GET /api/v1/inference-compute", handle(s.getInferenceCompute))
|
||||
mux.Handle("POST /api/v1/model/upstream", handle(s.modelUpstream))
|
||||
@@ -1276,6 +1277,28 @@ func (s *Server) renameChat(w http.ResponseWriter, r *http.Request) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) updateDraft(w http.ResponseWriter, r *http.Request) error {
|
||||
cid := r.PathValue("id")
|
||||
if cid == "" {
|
||||
return fmt.Errorf("chat ID is required")
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Draft string `json:"draft"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
return fmt.Errorf("invalid request body: %w", err)
|
||||
}
|
||||
|
||||
if err := s.Store.UpdateChatDraft(cid, req.Draft); err != nil {
|
||||
return fmt.Errorf("failed to update draft: %w", err)
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) deleteChat(w http.ResponseWriter, r *http.Request) error {
|
||||
cid := r.PathValue("id")
|
||||
if cid == "" {
|
||||
|
||||
Reference in New Issue
Block a user