fixes
This commit is contained in:
@@ -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 && (
|
||||||
|
|||||||
@@ -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}`),
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user