,
This commit is contained in:
@@ -2,25 +2,27 @@ import { useEffect, useState, type FC, type ReactNode } from "react";
|
||||
import { ArrowUpDown, Bot, ChevronLeft, ChevronRight, Download, Filter, HardDrive, KeyRound, Pencil, RefreshCw, Search, Trash2, UserPlus, Users } from "lucide-react";
|
||||
import { api } from "../lib/api.ts";
|
||||
import type { DriverModel, User } from "../lib/types.ts";
|
||||
import { useT, type StringKey } from "../lib/i18n.ts";
|
||||
import { Avatar, Button, ConfirmDialog, CopyButton, Field, IconButton, Input, Modal, Picker, Spinner } from "./ui.tsx";
|
||||
|
||||
/** Curated Ollama library catalog (admin downloads only). */
|
||||
const OLLAMA_CATALOG: { name: string; desc: string; sizes: string[] }[] = [
|
||||
{ name: "llama3.2", desc: "Meta's compact everyday model", sizes: ["1b", "3b"] },
|
||||
{ name: "llama3.1", desc: "Meta's general-purpose workhorse", sizes: ["8b", "70b"] },
|
||||
{ name: "qwen2.5", desc: "Alibaba's multilingual all-rounder", sizes: ["0.5b", "1.5b", "7b", "14b", "32b"] },
|
||||
{ name: "gemma2", desc: "Google's efficient open model", sizes: ["2b", "9b", "27b"] },
|
||||
{ name: "mistral", desc: "Mistral 7B — fast and capable", sizes: ["7b"] },
|
||||
{ name: "phi3", desc: "Microsoft's small research model", sizes: ["3.8b", "14b"] },
|
||||
{ name: "deepseek-r1", desc: "Reasoning model (chain-of-thought)", sizes: ["1.5b", "7b", "8b", "14b", "32b"] },
|
||||
{ name: "codellama", desc: "Code completion and chat", sizes: ["7b", "13b", "34b"] },
|
||||
{ name: "llava", desc: "Vision model (image understanding)", sizes: ["7b", "13b", "34b"] },
|
||||
{ name: "nomic-embed-text", desc: "Text embeddings", sizes: [] },
|
||||
/** Curated Ollama library catalog (admin downloads only). Descriptions resolve via i18n (`ollama.cat.*`). */
|
||||
const OLLAMA_CATALOG: { name: string; catKey: StringKey; sizes: string[] }[] = [
|
||||
{ name: "llama3.2", catKey: "ollama.cat.llama3_2", sizes: ["1b", "3b"] },
|
||||
{ name: "llama3.1", catKey: "ollama.cat.llama3_1", sizes: ["8b", "70b"] },
|
||||
{ name: "qwen2.5", catKey: "ollama.cat.qwen2_5", sizes: ["0.5b", "1.5b", "7b", "14b", "32b"] },
|
||||
{ name: "gemma2", catKey: "ollama.cat.gemma2", sizes: ["2b", "9b", "27b"] },
|
||||
{ name: "mistral", catKey: "ollama.cat.mistral", sizes: ["7b"] },
|
||||
{ name: "phi3", catKey: "ollama.cat.phi3", sizes: ["3.8b", "14b"] },
|
||||
{ name: "deepseek-r1", catKey: "ollama.cat.deepseek_r1", sizes: ["1.5b", "7b", "8b", "14b", "32b"] },
|
||||
{ name: "codellama", catKey: "ollama.cat.codellama", sizes: ["7b", "13b", "34b"] },
|
||||
{ name: "llava", catKey: "ollama.cat.llava", sizes: ["7b", "13b", "34b"] },
|
||||
{ name: "nomic-embed-text", catKey: "ollama.cat.nomic_embed_text", sizes: [] },
|
||||
];
|
||||
|
||||
/* ---------- Ollama model catalog (admin) ---------- */
|
||||
|
||||
export function OllamaModelsModal({ open, onClose }: { open: boolean; onClose: () => void }) {
|
||||
const { t } = useT();
|
||||
const [installed, setInstalled] = useState<DriverModel[]>([]);
|
||||
const [pullName, setPullName] = useState("");
|
||||
const [pullStatus, setPullStatus] = useState("");
|
||||
|
||||
@@ -5,8 +5,10 @@ import { AssistantAvatar, Avatar, FlowerMark, IconButton, Picker, type PickerGro
|
||||
import { Markdown } from "./Markdown.tsx";
|
||||
import { cn } from "../lib/cn.ts";
|
||||
import { useFeatures } from "../lib/features.ts";
|
||||
import { useT } from "../lib/i18n.ts";
|
||||
|
||||
function MessageBubble({ msg, authorAvatar, authorName }: { msg: ChatMessage; authorAvatar?: string; authorName: string }) {
|
||||
const { t } = useT();
|
||||
const isUser = msg.role === "user";
|
||||
return (
|
||||
<div className={cn("flex gap-3", isUser && "flex-row-reverse")}>
|
||||
@@ -16,7 +18,7 @@ function MessageBubble({ msg, authorAvatar, authorName }: { msg: ChatMessage; au
|
||||
<div className={cn("min-w-0 max-w-[85%]", isUser && "flex flex-col items-end")}>
|
||||
<div className="mb-1 flex items-center gap-1.5 text-xs opacity-60">
|
||||
{isUser ? <UserIcon size={12} /> : <Bot size={12} />}
|
||||
{isUser ? "You" : "KisAssistant"}
|
||||
{isUser ? t("chat.you") : "KisAssistant"}
|
||||
</div>
|
||||
{isUser ? (
|
||||
<div className="whitespace-pre-wrap rounded-3xl rounded-tr-lg bg-accent-600 px-4 py-2.5 text-[15px] text-white dark:bg-accent-500 dark:text-zinc-950">
|
||||
@@ -73,6 +75,7 @@ export function Chat({
|
||||
}) {
|
||||
const [draft, setDraft] = useState("");
|
||||
const [attachOpen, setAttachOpen] = useState(false);
|
||||
const { t } = useT();
|
||||
const features = useFeatures();
|
||||
const boxRef = useRef<HTMLDivElement>(null);
|
||||
const taRef = useRef<HTMLTextAreaElement>(null);
|
||||
@@ -114,35 +117,35 @@ export function Chat({
|
||||
return (
|
||||
<div className="flex h-full min-w-0 flex-1 flex-col">
|
||||
<header className="flex items-center gap-2 border-b border-stone-200/70 px-4 py-2.5 backdrop-blur dark:border-zinc-800">
|
||||
<IconButton title="Open chats" onClick={onOpenNav} className="md:hidden">
|
||||
<IconButton title={t("chat.openNav")} onClick={onOpenNav} className="md:hidden">
|
||||
<Menu size={18} />
|
||||
</IconButton>
|
||||
{sidebarCollapsed && (
|
||||
<span className="hidden md:inline">
|
||||
<IconButton title="Expand sidebar" onClick={onExpandSidebar}>
|
||||
<IconButton title={t("chat.expand")} onClick={onExpandSidebar}>
|
||||
<PanelLeftOpen size={18} />
|
||||
</IconButton>
|
||||
</span>
|
||||
)}
|
||||
<span className="hidden items-center gap-2 font-semibold sm:flex">
|
||||
<FlowerMark size={24} dynamic={false} />
|
||||
{conv?.title || "New chat"}
|
||||
{conv?.title || t("sidebar.newChat")}
|
||||
</span>
|
||||
{conv?.topic && (
|
||||
<span className="hidden rounded-full bg-stone-200/70 px-2.5 py-0.5 text-xs opacity-80 md:inline dark:bg-zinc-800">{conv.topic}</span>
|
||||
)}
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<Picker
|
||||
ariaLabel="Model"
|
||||
ariaLabel={t("chat.modelAria")}
|
||||
icon={Cpu}
|
||||
value={model}
|
||||
onChange={onModelChange}
|
||||
groups={modelGroups}
|
||||
placeholder={models.length === 0 ? "No models" : "Select model…"}
|
||||
placeholder={models.length === 0 ? t("chat.noModels") : t("chat.selectModel")}
|
||||
disabled={models.length === 0}
|
||||
/>
|
||||
{conv && (
|
||||
<IconButton title="Share publicly" onClick={onShare}>
|
||||
<IconButton title={t("sidebar.share")} onClick={onShare}>
|
||||
<Share2 size={17} />
|
||||
</IconButton>
|
||||
)}
|
||||
@@ -154,8 +157,8 @@ export function Chat({
|
||||
{messages.length === 0 && !streaming && (
|
||||
<div className="py-16 text-center">
|
||||
<FlowerMark size={56} dynamic={false} className="mx-auto mb-4" />
|
||||
<h2 className="text-2xl font-bold tracking-tight">How can I help, {user.displayName.split(" ")[0]}?</h2>
|
||||
<p className="mt-1 text-sm opacity-60">Pick a model, then type below. Markdown is supported.</p>
|
||||
<h2 className="text-2xl font-bold tracking-tight">{t("chat.greet", { name: user.displayName.split(" ")[0]! })}</h2>
|
||||
<p className="mt-1 text-sm opacity-60">{t("chat.greetSub")}</p>
|
||||
</div>
|
||||
)}
|
||||
{messages.filter((m) => m.role !== "system").map((m, i) => (
|
||||
@@ -173,7 +176,7 @@ export function Chat({
|
||||
</div>
|
||||
)}
|
||||
{sending && streaming === "" && (
|
||||
<div className="flex items-center gap-2 opacity-60"><Spinner size={15} /><span className="text-sm">Thinking…</span></div>
|
||||
<div className="flex items-center gap-2 opacity-60"><Spinner size={15} /><span className="text-sm">{t("chat.thinking")}</span></div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -189,7 +192,7 @@ export function Chat({
|
||||
<span key={a.id} className="inline-flex max-w-full items-center gap-1.5 rounded-full border border-accent-600/30 bg-accent-600/10 py-1 pl-3 pr-1.5 text-xs font-medium dark:border-accent-500/30 dark:bg-accent-500/10">
|
||||
{a.kind === "ocr" ? <ScanText size={13} /> : <FileText size={13} />}
|
||||
<span className="max-w-40 truncate">{a.name}</span>
|
||||
<button onClick={() => onRemoveAttachment(a.id)} aria-label={`Remove ${a.name}`} className="rounded-full p-1 transition hover:bg-black/10 dark:hover:bg-white/10">
|
||||
<button onClick={() => onRemoveAttachment(a.id)} aria-label={t("chat.removeAttach", { name: a.name })} className="rounded-full p-1 transition hover:bg-black/10 dark:hover:bg-white/10">
|
||||
<X size={13} />
|
||||
</button>
|
||||
</span>
|
||||
@@ -211,8 +214,8 @@ export function Chat({
|
||||
<ScanText size={16} />
|
||||
</span>
|
||||
<span>
|
||||
<span className="block text-sm font-medium">Transcribe image {ocrAvailable ? "" : "(unavailable)"}</span>
|
||||
<span className="block text-xs opacity-60">JPG, PNG, WEBP — OCR on the server, you review the text first</span>
|
||||
<span className="block text-sm font-medium">{t("chat.ocrOpt")} {ocrAvailable ? "" : t("chat.unavailable")}</span>
|
||||
<span className="block text-xs opacity-60">{t("chat.ocrHint")}</span>
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
@@ -223,12 +226,12 @@ export function Chat({
|
||||
<FileText size={16} />
|
||||
</span>
|
||||
<span>
|
||||
<span className="block text-sm font-medium">Attach text file</span>
|
||||
<span className="block text-xs opacity-60">TXT or Markdown, max 500KB</span>
|
||||
<span className="block text-sm font-medium">{t("chat.textOpt")}</span>
|
||||
<span className="block text-xs opacity-60">{t("chat.textHint")}</span>
|
||||
</span>
|
||||
</button>
|
||||
<p className="px-3 pb-1.5 pt-2 text-xs opacity-60">
|
||||
Uploads go through platform tools only. Images are sent to the model as reviewed text — never as raw images.
|
||||
{t("chat.attachNote")}
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
@@ -239,8 +242,8 @@ export function Chat({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAttachOpen((o) => !o)}
|
||||
aria-label="Attach a file"
|
||||
title="Attach a file"
|
||||
aria-label={t("chat.attachTip")}
|
||||
title={t("chat.attachTip")}
|
||||
className="grid h-10 w-10 shrink-0 -translate-y-[2px] place-items-center rounded-full transition hover:bg-stone-100 active:scale-95 dark:hover:bg-zinc-800"
|
||||
>
|
||||
<Paperclip size={17} className="opacity-70" />
|
||||
@@ -259,14 +262,14 @@ export function Chat({
|
||||
submit();
|
||||
}
|
||||
}}
|
||||
placeholder="Message KisAssistant…"
|
||||
placeholder={t("chat.placeholder")}
|
||||
className="max-h-50 flex-1 resize-none bg-transparent py-2.5 text-[15px] outline-none placeholder:text-stone-400 dark:placeholder:text-zinc-500"
|
||||
style={{ maxHeight: 200 }}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={(!draft.trim() && attachments.length === 0) || sending}
|
||||
aria-label="Send"
|
||||
aria-label={t("chat.send")}
|
||||
className="grid h-10 w-10 shrink-0 place-items-center rounded-full bg-accent-600 text-white shadow transition hover:bg-accent-500 active:scale-95 disabled:opacity-40 dark:bg-accent-500 dark:text-zinc-950 dark:hover:bg-accent-400"
|
||||
>
|
||||
<SendHorizontal size={17} />
|
||||
@@ -275,7 +278,7 @@ export function Chat({
|
||||
<p className="py-2 text-center text-xs opacity-50">
|
||||
<a href="/wiki" target="_blank" rel="noreferrer" className="inline-flex items-center gap-1 underline-offset-2 hover:underline">
|
||||
<BookOpen size={12} />
|
||||
Wiki & docs
|
||||
{t("chat.wiki")}
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -196,7 +196,7 @@ export function Sidebar({
|
||||
) : (
|
||||
<>
|
||||
{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">{t("sidebar.emptyA")}<br />{t("sidebar.emptyB")}</p>
|
||||
)}
|
||||
{convs.map((c) => (
|
||||
<div
|
||||
@@ -243,7 +243,7 @@ export function Sidebar({
|
||||
)}
|
||||
</span>
|
||||
<button
|
||||
aria-label="Conversation options"
|
||||
aria-label={t("sidebar.convOptions")}
|
||||
onClick={(e) => openMenu(e, c)}
|
||||
className="rounded-full p-1 opacity-0 transition group-hover:opacity-60 hover:!opacity-100 hover:bg-stone-300/50 dark:hover:bg-zinc-700"
|
||||
>
|
||||
@@ -261,32 +261,32 @@ export function Sidebar({
|
||||
y={menu.y}
|
||||
onClose={() => setMenu(null)}
|
||||
items={[
|
||||
{ icon: Pencil, label: "Rename", onClick: () => onRename(menu.conv) },
|
||||
{ icon: Tag, label: "Set topic", onClick: () => onTopic(menu.conv) },
|
||||
{ icon: Share2, label: "Share publicly", onClick: () => onShare(menu.conv) },
|
||||
{ icon: Trash2, label: "Delete", danger: true, onClick: () => onDelete(menu.conv) },
|
||||
{ icon: Pencil, label: t("sidebar.rename"), onClick: () => onRename(menu.conv) },
|
||||
{ icon: Tag, label: t("sidebar.topic"), onClick: () => onTopic(menu.conv) },
|
||||
{ icon: Share2, label: t("sidebar.share"), onClick: () => onShare(menu.conv) },
|
||||
{ icon: Trash2, label: t("sidebar.delete"), danger: true, onClick: () => onDelete(menu.conv) },
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="space-y-2 rounded-2xl border border-stone-200/70 bg-white p-2.5 dark:border-zinc-800 dark:bg-zinc-900">
|
||||
<button onClick={onOpenSettings} className="m-0.5 flex w-[calc(100%-4px)] items-center gap-2.5 rounded-xl p-1.5 text-left transition hover:bg-accent-600/10" title="Profile & settings">
|
||||
<button onClick={onOpenSettings} className="m-0.5 flex w-[calc(100%-4px)] items-center gap-2.5 rounded-xl p-1.5 text-left transition hover:bg-accent-600/10" title={t("sidebar.settingsTip")}>
|
||||
<Avatar name={user.displayName} url={user.avatarUrl} size={34} />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm font-medium">{user.displayName}</span>
|
||||
<span className="block truncate text-xs opacity-60">@{user.username}{user.isAdmin ? " · admin" : ""}</span>
|
||||
<span className="block truncate text-xs opacity-60">@{user.username}{user.isAdmin ? ` · ${t("sidebar.admin")}` : ""}</span>
|
||||
</span>
|
||||
<Settings size={16} className="opacity-50" />
|
||||
</button>
|
||||
<div className="flex gap-1.5">
|
||||
{user.isAdmin && (
|
||||
<Button variant="secondary" size="sm" className="flex-1" onClick={onOpenAdmin} title="Open the Admin Center">
|
||||
<Button variant="secondary" size="sm" className="flex-1" onClick={onOpenAdmin} title={t("sidebar.adminCenterTip")}>
|
||||
<LayoutDashboard size={15} />
|
||||
Admin Center
|
||||
{t("sidebar.adminCenter")}
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="ghost" size="sm" className={user.isAdmin ? "" : "flex-1"} onClick={onLogout}>
|
||||
Log out
|
||||
{t("sidebar.logout")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,7 @@ import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from
|
||||
import { createPortal } from "react-dom";
|
||||
import { Check, ChevronDown, Copy, Loader2, X, type LucideIcon } from "lucide-react";
|
||||
import { cn } from "../lib/cn.ts";
|
||||
import { useT } from "../lib/i18n.ts";
|
||||
import { LANGS, setLang, useT, type LangId } from "../lib/i18n.ts";
|
||||
|
||||
/* ---------- Button ---------- */
|
||||
|
||||
@@ -234,6 +234,21 @@ export function Picker({
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------- Language picker (framework-styled dropdown over LANGS) ---------- */
|
||||
|
||||
export function LangPicker({ align = "right" }: { align?: "left" | "right" }) {
|
||||
const { t, lang } = useT();
|
||||
return (
|
||||
<Picker
|
||||
ariaLabel={t("settings.language")}
|
||||
value={lang}
|
||||
onChange={(v) => setLang(v as LangId)}
|
||||
align={align}
|
||||
options={LANGS.map((l) => ({ value: l.id, label: l.label }))}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------- Modal / Dialog ---------- */
|
||||
|
||||
export function Modal({
|
||||
|
||||
Reference in New Issue
Block a user