This commit is contained in:
k
2026-09-21 14:30:28 +02:00
parent c05aa7a211
commit 5f5f72268a
3 changed files with 122 additions and 1 deletions
+84 -1
View File
@@ -1,17 +1,21 @@
import { useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { import {
Flower,
LayoutDashboard, LayoutDashboard,
MessageSquarePlus, MessageSquarePlus,
MoreVertical, MoreVertical,
PanelLeftClose, PanelLeftClose,
Pencil, Pencil,
Search,
Settings, Settings,
Share2, Share2,
Tag, Tag,
Trash2, Trash2,
Users,
X, X,
} from "lucide-react"; } from "lucide-react";
import type { Conversation, User } from "../lib/types.ts"; import type { Conversation, User } from "../lib/types.ts";
import { api } from "../lib/api.ts";
import { Avatar, Button, ContextMenu, IconButton } from "./ui.tsx"; import { Avatar, Button, ContextMenu, IconButton } from "./ui.tsx";
import { cn } from "../lib/cn.ts"; import { cn } from "../lib/cn.ts";
@@ -57,6 +61,9 @@ export function Sidebar({
onCloseMobile: () => void; onCloseMobile: () => void;
}) { }) {
const [menu, setMenu] = useState<MenuState | null>(null); const [menu, setMenu] = useState<MenuState | null>(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<number | null>(null); const touchX = useRef<number | null>(null);
// Long-press (mobile right-click): open the context menu, suppress the tap. // Long-press (mobile right-click): open the context menu, suppress the tap.
const pressTimer = useRef<ReturnType<typeof setTimeout> | null>(null); const pressTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -79,6 +86,24 @@ export function Sidebar({
pressPos.current = null; 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; if (collapsed && !mobileOpen) return null;
return ( return (
@@ -102,6 +127,12 @@ export function Sidebar({
collapsed && "md:hidden", collapsed && "md:hidden",
)} )}
> >
<div className="flex items-center gap-2 px-2 pb-1 pt-0.5">
<span className="grid h-9 w-9 shrink-0 place-items-center rounded-2xl bg-accent-600/10 text-accent-600 dark:text-accent-400">
<Flower size={20} />
</span>
<span className="font-serif text-xl font-bold tracking-tight">KisAssistant</span>
</div>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<Button variant="secondary" size="sm" className="flex-1" onClick={onNew}> <Button variant="secondary" size="sm" className="flex-1" onClick={onNew}>
<MessageSquarePlus size={16} /> <MessageSquarePlus size={16} />
@@ -114,8 +145,58 @@ export function Sidebar({
<X size={18} /> <X size={18} />
</IconButton> </IconButton>
</div> </div>
<div className="relative">
<Search size={15} className="pointer-events-none absolute left-3.5 top-1/2 -translate-y-1/2 opacity-50" />
<input
value={query}
onChange={(e) => 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 && (
<button
aria-label="Clear search"
onClick={() => setQuery("")}
className="absolute right-2.5 top-1/2 -translate-y-1/2 rounded-full p-0.5 opacity-60 transition hover:opacity-100"
>
<X size={14} />
</button>
)}
</div>
<div className="flex-1 space-y-1 overflow-y-auto py-1"> <div className="flex-1 space-y-1 overflow-y-auto py-1">
{results !== null ? (
<>
{searching && <p className="px-3 py-4 text-center text-sm opacity-50">Searching…</p>}
{!searching && results.length === 0 && (
<p className="px-3 py-6 text-center text-sm opacity-50">No match for “{query.trim()}”.</p>
)}
{results.map((c) => (
<div
key={c.id}
role="button"
tabIndex={0}
onClick={() => { 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",
)}
>
<span className="block truncate">{c.title}</span>
{c.snippet && (
<span className="mt-0.5 block truncate text-xs opacity-60">
{c.snippetRole === "user" ? "you: " : ""}{c.snippet}
</span>
)}
</div>
))}
</>
) : (
<>
{convs.length === 0 && ( {convs.length === 0 && (
<p className="px-3 py-6 text-center text-sm opacity-50">No conversations yet.<br />Start a new chat above.</p> <p className="px-3 py-6 text-center text-sm opacity-50">No conversations yet.<br />Start a new chat above.</p>
)} )}
@@ -172,6 +253,8 @@ export function Sidebar({
</button> </button>
</div> </div>
))} ))}
</>
)}
</div> </div>
{menu && ( {menu && (
+5
View File
@@ -71,6 +71,11 @@ export const api = {
req<{ settings: { registrationEnabled: boolean; ocrEnabled: boolean } }>("/api/admin/settings", { method: "PATCH", body: JSON.stringify(patch) }), req<{ settings: { registrationEnabled: boolean; ocrEnabled: boolean } }>("/api/admin/settings", { method: "PATCH", body: JSON.stringify(patch) }),
convs: () => req<{ conversations: Conversation[] }>("/api/conversations"), 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 }) => createConv: (c: { title?: string; topic?: string; model?: string }) =>
req<{ conversation: Conversation }>("/api/conversations", { method: "POST", body: JSON.stringify(c) }), req<{ conversation: Conversation }>("/api/conversations", { method: "POST", body: JSON.stringify(c) }),
getConv: (id: string) => req<{ conversation: Conversation; messages: ChatMessage[] }>(`/api/conversations/${id}`), getConv: (id: string) => req<{ conversation: Conversation; messages: ChatMessage[] }>(`/api/conversations/${id}`),
+33
View File
@@ -25,6 +25,7 @@ import {
deleteUserSessions, deleteUserSessions,
ensureShare, ensureShare,
getConversation, getConversation,
getDb,
getMessages, getMessages,
getShareByConv, getShareByConv,
getShareByPublic, getShareByPublic,
@@ -42,6 +43,7 @@ import {
updateUser, updateUser,
} from "./db.ts"; } from "./db.ts";
import type { ChatMessage } from "./drivers/types.ts"; import type { ChatMessage } from "./drivers/types.ts";
import type { ConversationRow } from "./db.ts";
import type { OllamaDriver } from "./drivers/ollama.ts"; import type { OllamaDriver } from "./drivers/ollama.ts";
import { DriverRegistry } from "./drivers/registry.ts"; import { DriverRegistry } from "./drivers/registry.ts";
import { createHash, randomBytes } from "node:crypto"; import { createHash, randomBytes } from "node:crypto";
@@ -623,6 +625,37 @@ const server = Bun.serve({
return json({ conversations: listConversations(user.id) }); 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") { if (path === "/api/conversations" && req.method === "POST") {
const { ok, body } = await readJson(req); const { ok, body } = await readJson(req);
if (!ok) return json({ error: "invalid JSON" }, 400); if (!ok) return json({ error: "invalid JSON" }, 400);