fixes
This commit is contained in:
@@ -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<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);
|
||||
// Long-press (mobile right-click): open the context menu, suppress the tap.
|
||||
const pressTimer = useRef<ReturnType<typeof setTimeout> | 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",
|
||||
)}
|
||||
>
|
||||
<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">
|
||||
<Button variant="secondary" size="sm" className="flex-1" onClick={onNew}>
|
||||
<MessageSquarePlus size={16} />
|
||||
@@ -114,8 +145,58 @@ export function Sidebar({
|
||||
<X size={18} />
|
||||
</IconButton>
|
||||
</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">
|
||||
{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 && (
|
||||
<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>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{menu && (
|
||||
|
||||
@@ -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}`),
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user