From 5f5f72268a6f182f48a45338ae94da9fda71d64b Mon Sep 17 00:00:00 2001 From: Anais Saraiva Date: Mon, 21 Sep 2026 14:30:28 +0200 Subject: [PATCH] fixes --- frontend/src/components/Sidebar.tsx | 85 ++++++++++++++++++++++++++++- frontend/src/lib/api.ts | 5 ++ src/index.ts | 33 +++++++++++ 3 files changed, 122 insertions(+), 1 deletion(-) diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index e285612..ad829ec 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -1,17 +1,21 @@ -import { useRef, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { + Flower, LayoutDashboard, MessageSquarePlus, MoreVertical, PanelLeftClose, Pencil, + Search, Settings, Share2, Tag, Trash2, + Users, X, } from "lucide-react"; import type { Conversation, User } from "../lib/types.ts"; +import { api } from "../lib/api.ts"; import { Avatar, Button, ContextMenu, IconButton } from "./ui.tsx"; import { cn } from "../lib/cn.ts"; @@ -57,6 +61,9 @@ export function Sidebar({ onCloseMobile: () => void; }) { const [menu, setMenu] = useState(null); + const [query, setQuery] = useState(""); + const [results, setResults] = useState<(Conversation & { snippet: string | null; snippetRole: string | null })[] | null>(null); + const [searching, setSearching] = useState(false); const touchX = useRef(null); // Long-press (mobile right-click): open the context menu, suppress the tap. const pressTimer = useRef | null>(null); @@ -79,6 +86,24 @@ export function Sidebar({ pressPos.current = null; } + // Debounced search over titles, topics and old prompts/replies. + useEffect(() => { + const q = query.trim(); + if (q.length < 2) { + setResults(null); + setSearching(false); + return; + } + setSearching(true); + const t = setTimeout(() => { + api.searchConvs(q) + .then((res) => setResults(res.conversations)) + .catch(() => setResults([])) + .finally(() => setSearching(false)); + }, 250); + return () => clearTimeout(t); + }, [query]); + if (collapsed && !mobileOpen) return null; return ( @@ -102,6 +127,12 @@ export function Sidebar({ collapsed && "md:hidden", )} > +
+ + + + KisAssistant +
+
+ + setQuery(e.target.value)} + placeholder="Search chats…" + aria-label="Search conversations" + className="w-full rounded-2xl border border-stone-200/70 bg-stone-100 py-2 pl-9 pr-8 text-sm outline-none transition placeholder:text-stone-400 focus:border-accent-500/60 focus:bg-white dark:border-zinc-800 dark:bg-zinc-800/60 dark:placeholder:text-zinc-500 dark:focus:bg-zinc-900" + /> + {query && ( + + )} +
+ {results !== null ? ( + <> + {searching &&

Searching…

} + {!searching && results.length === 0 && ( +

No match for “{query.trim()}”.

+ )} + {results.map((c) => ( +
{ setQuery(""); onSelect(c.id); }} + onKeyDown={(e) => { if (e.key === "Enter") { setQuery(""); onSelect(c.id); } }} + className={cn( + "group cursor-pointer select-none rounded-2xl px-3 py-2.5 text-sm transition", + c.id === activeId + ? "bg-accent-600/10 font-medium text-accent-900 dark:bg-accent-500/10 dark:text-accent-100" + : "hover:bg-stone-200/50 dark:hover:bg-zinc-800/70", + )} + > + {c.title} + {c.snippet && ( + + {c.snippetRole === "user" ? "you: " : ""}{c.snippet} + + )} +
+ ))} + + ) : ( + <> {convs.length === 0 && (

No conversations yet.
Start a new chat above.

)} @@ -172,6 +253,8 @@ export function Sidebar({
))} + + )} {menu && ( diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index bf22c47..9f60795 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -71,6 +71,11 @@ export const api = { req<{ settings: { registrationEnabled: boolean; ocrEnabled: boolean } }>("/api/admin/settings", { method: "PATCH", body: JSON.stringify(patch) }), convs: () => req<{ conversations: Conversation[] }>("/api/conversations"), + /** Search your old chats (titles, topics and message content). */ + searchConvs: (q: string) => + req<{ conversations: (Conversation & { snippet: string | null; snippetRole: string | null })[] }>( + `/api/conversations/search?q=${encodeURIComponent(q)}`, + ), createConv: (c: { title?: string; topic?: string; model?: string }) => req<{ conversation: Conversation }>("/api/conversations", { method: "POST", body: JSON.stringify(c) }), getConv: (id: string) => req<{ conversation: Conversation; messages: ChatMessage[] }>(`/api/conversations/${id}`), diff --git a/src/index.ts b/src/index.ts index 753305b..bd53175 100644 --- a/src/index.ts +++ b/src/index.ts @@ -25,6 +25,7 @@ import { deleteUserSessions, ensureShare, getConversation, + getDb, getMessages, getShareByConv, getShareByPublic, @@ -42,6 +43,7 @@ import { updateUser, } from "./db.ts"; import type { ChatMessage } from "./drivers/types.ts"; +import type { ConversationRow } from "./db.ts"; import type { OllamaDriver } from "./drivers/ollama.ts"; import { DriverRegistry } from "./drivers/registry.ts"; import { createHash, randomBytes } from "node:crypto"; @@ -623,6 +625,37 @@ const server = Bun.serve({ return json({ conversations: listConversations(user.id) }); } + // Full-text-ish search over your own chats: titles, topics AND old + // prompts/replies. Returns the matching conversations, most recent + // first, with a short snippet of the first hit. + if (path === "/api/conversations/search" && req.method === "GET") { + const q = (url.searchParams.get("q") ?? "").trim().slice(0, 120); + if (q.length < 2) return json({ conversations: [] }); + const like = `%${q.replace(/[\\%_]/g, (c) => `\\${c}`)}%`; + const rows = getDb().query( + `SELECT c.*, m.content AS hit, m.role AS hit_role FROM conversations c + LEFT JOIN messages m ON m.conv_id = c.id + AND m.content LIKE ? ESCAPE '\\' + WHERE c.user_id = ? AND (c.title LIKE ? ESCAPE '\\' OR c.topic LIKE ? ESCAPE '\\' OR m.id IS NOT NULL) + GROUP BY c.id ORDER BY c.updated_at DESC LIMIT 30`, + ).all(like, user.id, like, like) as (ConversationRow & { hit: string | null; hit_role: string | null })[]; + return json({ + conversations: rows.map(({ hit, hit_role, ...c }) => { + let snippet: string | null = null; + if (typeof hit === "string" && hit.length > 0) { + if (hit.length <= 140) { + snippet = hit; + } else { + const at = hit.toLowerCase().indexOf(q.toLowerCase()); + const from = Math.max(0, (at < 0 ? 0 : at) - 40); + snippet = `…${hit.slice(from, from + 140)}…`; + } + } + return { ...c, snippet, snippetRole: hit_role ?? null }; + }), + }); + } + if (path === "/api/conversations" && req.method === "POST") { const { ok, body } = await readJson(req); if (!ok) return json({ error: "invalid JSON" }, 400);