larkup 0.1.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env +2 -0
- package/app/actions/vercel.ts +417 -0
- package/app/api/chat/route.ts +192 -0
- package/app/api/chat/status/route.ts +55 -0
- package/app/api/chat/suggestions/route.ts +115 -0
- package/app/api/config/route.ts +73 -0
- package/app/api/config/test/route.ts +67 -0
- package/app/api/config/test-embedding/route.ts +40 -0
- package/app/api/config/test-llm/route.ts +47 -0
- package/app/api/config/test-provider/route.ts +36 -0
- package/app/api/demo/route.ts +176 -0
- package/app/api/deploy/ssh/route.ts +340 -0
- package/app/api/documents/route.ts +90 -0
- package/app/api/firecrawl/local/route.ts +46 -0
- package/app/api/index/route.ts +113 -0
- package/app/api/jobs/[id]/route.ts +44 -0
- package/app/api/jobs/route.ts +116 -0
- package/app/api/parse-file/route.ts +39 -0
- package/app/api/search/google/route.ts +162 -0
- package/app/api/search/route.ts +29 -0
- package/app/api/server/download/route.ts +112 -0
- package/app/api/server/generate/route.ts +46 -0
- package/app/api/server/local/route.ts +42 -0
- package/app/api/servers/route.ts +137 -0
- package/app/api/vector-stores/install/route.ts +65 -0
- package/app/api/vector-stores/status/route.ts +26 -0
- package/app/chat/page.tsx +15 -0
- package/app/configure/page.tsx +53 -0
- package/app/data/page.tsx +15 -0
- package/app/demo/page.tsx +15 -0
- package/app/favicon.ico +0 -0
- package/app/globals.css +190 -0
- package/app/index-data/page.tsx +15 -0
- package/app/layout.tsx +51 -0
- package/app/page.tsx +34 -0
- package/app/server/page.tsx +22 -0
- package/app/simple/chat/page.tsx +17 -0
- package/app/simple/docs/page.tsx +21 -0
- package/app/simple/layout.tsx +9 -0
- package/app/simple/settings/page.tsx +627 -0
- package/app/template.tsx +16 -0
- package/app/themes.css +733 -0
- package/components/alerts/generic-alert.tsx +74 -0
- package/components/app-sidebar.tsx +187 -0
- package/components/app-topnav.tsx +195 -0
- package/components/chat/chat-settings-modal.tsx +209 -0
- package/components/chat/chat-workspace.tsx +348 -0
- package/components/chat/knowledge-base-result.tsx +97 -0
- package/components/chat/message-item.tsx +103 -0
- package/components/client-layout-wrapper.tsx +182 -0
- package/components/configure/configure-form.tsx +1683 -0
- package/components/configure/store-fields.tsx +152 -0
- package/components/data/corpus-panel.tsx +499 -0
- package/components/data/data-workspace.tsx +272 -0
- package/components/data/firecrawl-notice.tsx +236 -0
- package/components/data/jobs-panel.tsx +485 -0
- package/components/data/paste-panel.tsx +79 -0
- package/components/data/scrape-panel.tsx +964 -0
- package/components/data/upload-panel.tsx +714 -0
- package/components/demo/demo-workspace.tsx +419 -0
- package/components/global-settings.tsx +286 -0
- package/components/index/index-workspace.tsx +482 -0
- package/components/onboarding/simple-setup.tsx +702 -0
- package/components/onboarding/tech-setup.tsx +121 -0
- package/components/onboarding/welcome-screen.tsx +183 -0
- package/components/page-header.tsx +44 -0
- package/components/server/code-viewer.tsx +87 -0
- package/components/server/deploy-button.tsx +1390 -0
- package/components/server/server-workspace.tsx +392 -0
- package/components/simple/sdk-connect-dialog.tsx +201 -0
- package/components/simple/simple-index-button.tsx +83 -0
- package/components/simple/simple-sidebar.tsx +161 -0
- package/components/theme-customizer-provider.tsx +171 -0
- package/components/theme-switcher.tsx +445 -0
- package/components/ui/alert-dialog.tsx +187 -0
- package/components/ui/alert.tsx +76 -0
- package/components/ui/badge.tsx +52 -0
- package/components/ui/button.tsx +59 -0
- package/components/ui/card.tsx +103 -0
- package/components/ui/checkbox.tsx +28 -0
- package/components/ui/collapsible.tsx +21 -0
- package/components/ui/dialog.tsx +159 -0
- package/components/ui/dropdown-menu.tsx +281 -0
- package/components/ui/input.tsx +22 -0
- package/components/ui/label.tsx +20 -0
- package/components/ui/popover.tsx +90 -0
- package/components/ui/progress.tsx +83 -0
- package/components/ui/provider-icon.tsx +94 -0
- package/components/ui/scroll-area.tsx +55 -0
- package/components/ui/select.tsx +248 -0
- package/components/ui/separator.tsx +25 -0
- package/components/ui/sheet.tsx +138 -0
- package/components/ui/skeleton.tsx +13 -0
- package/components/ui/sonner.tsx +63 -0
- package/components/ui/switch.tsx +32 -0
- package/components/ui/table.tsx +116 -0
- package/components/ui/tabs.tsx +84 -0
- package/components/ui/textarea.tsx +18 -0
- package/components/ui/tooltip.tsx +66 -0
- package/components/workspace/delete-server-dialog.tsx +71 -0
- package/components/workspace/server-form-dialog.tsx +103 -0
- package/components/workspace/server-switcher.tsx +163 -0
- package/components/workspace/workspace-provider.tsx +177 -0
- package/components/workspace/workspace-top-bar.tsx +23 -0
- package/components.json +21 -0
- package/lib/error-formatter.ts +15 -0
- package/lib/utils.ts +6 -0
- package/next-env.d.ts +6 -0
- package/next.config.mjs +18 -0
- package/package.json +65 -0
- package/postcss.config.mjs +8 -0
- package/public/docker.png +0 -0
- package/public/favicon.ico +0 -0
- package/public/github.svg +3 -0
- package/public/hetzner.svg +1 -0
- package/public/icons/aws.svg +38 -0
- package/public/icons/azure.svg +1 -0
- package/public/icons/chroma.png +0 -0
- package/public/icons/cohere.svg +30 -0
- package/public/icons/digital-ocean.webp +0 -0
- package/public/icons/firecrawl2.png +0 -0
- package/public/icons/gcp.svg +1 -0
- package/public/icons/gemini.svg +1 -0
- package/public/icons/jina.svg +1 -0
- package/public/icons/lancedb2.png +0 -0
- package/public/icons/mistral.svg +469 -0
- package/public/icons/nomic.png +0 -0
- package/public/icons/openai.svg +1 -0
- package/public/icons/pgvector2.png +0 -0
- package/public/icons/pinecone.png +0 -0
- package/public/icons/qdrant.svg +35 -0
- package/public/icons/supabase.png +0 -0
- package/public/icons/vercel copy.svg +1 -0
- package/public/icons/vercel.svg +1 -0
- package/public/icons/voyage-light.png +0 -0
- package/public/icons/weaviate.webp +0 -0
- package/public/logo-light.png +0 -0
- package/public/logo.png +0 -0
- package/public/logo9.png +0 -0
- package/public/vercel.svg +1 -0
- package/tsconfig.json +33 -0
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState, useRef, useEffect } from "react";
|
|
4
|
+
import { useChat } from "@ai-sdk/react";
|
|
5
|
+
import { DefaultChatTransport } from "ai";
|
|
6
|
+
import {
|
|
7
|
+
ArrowUp,
|
|
8
|
+
SquarePen,
|
|
9
|
+
RotateCcw,
|
|
10
|
+
TriangleAlert,
|
|
11
|
+
Loader2,
|
|
12
|
+
ChevronDown,
|
|
13
|
+
MessageCircle,
|
|
14
|
+
} from "lucide-react";
|
|
15
|
+
import useSWR from "swr";
|
|
16
|
+
import { MessageItem } from "@/components/chat/message-item";
|
|
17
|
+
import { ChatSettingsModal } from "@/components/chat/chat-settings-modal";
|
|
18
|
+
import { useWorkspace } from "@/components/workspace/workspace-provider";
|
|
19
|
+
import { cn } from "@/lib/utils";
|
|
20
|
+
import { PROVIDER_META, ProviderIcon } from "@/components/ui/provider-icon";
|
|
21
|
+
|
|
22
|
+
const fetcher = (url: string) => fetch(url).then((r) => r.json());
|
|
23
|
+
|
|
24
|
+
interface ChatStatus {
|
|
25
|
+
ready: boolean;
|
|
26
|
+
indexed: boolean;
|
|
27
|
+
blockers: string[];
|
|
28
|
+
provider: string;
|
|
29
|
+
chatModelId: string;
|
|
30
|
+
availableModels: { id: string; label: string; provider: string }[];
|
|
31
|
+
suggestions: string[];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function ChatWorkspace() {
|
|
35
|
+
const { activeServer } = useWorkspace();
|
|
36
|
+
const serverId = activeServer?.id ?? null;
|
|
37
|
+
|
|
38
|
+
const statusKey = serverId
|
|
39
|
+
? `/api/chat/status?serverId=${encodeURIComponent(serverId)}`
|
|
40
|
+
: "/api/chat/status";
|
|
41
|
+
const { data: status, isLoading: statusLoading } = useSWR<ChatStatus>(
|
|
42
|
+
statusKey,
|
|
43
|
+
fetcher,
|
|
44
|
+
{ refreshInterval: 10000 },
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
const [selectedModel, setSelectedModel] = useState<string>("");
|
|
48
|
+
const [showModelSelect, setShowModelSelect] = useState(false);
|
|
49
|
+
const [input, setInput] = useState("");
|
|
50
|
+
|
|
51
|
+
// Update selected model when status loads
|
|
52
|
+
useEffect(() => {
|
|
53
|
+
if (status?.chatModelId && !selectedModel) {
|
|
54
|
+
setSelectedModel(status.chatModelId);
|
|
55
|
+
}
|
|
56
|
+
}, [status?.chatModelId, selectedModel]);
|
|
57
|
+
|
|
58
|
+
// Fetch suggestions if missing
|
|
59
|
+
useEffect(() => {
|
|
60
|
+
if (
|
|
61
|
+
status?.ready &&
|
|
62
|
+
status.suggestions &&
|
|
63
|
+
status.suggestions.length === 0
|
|
64
|
+
) {
|
|
65
|
+
const url = serverId
|
|
66
|
+
? `/api/chat/suggestions?serverId=${encodeURIComponent(serverId)}`
|
|
67
|
+
: "/api/chat/suggestions";
|
|
68
|
+
fetch(url, { method: "POST" });
|
|
69
|
+
}
|
|
70
|
+
}, [status?.ready, status?.suggestions, serverId]);
|
|
71
|
+
|
|
72
|
+
const {
|
|
73
|
+
messages,
|
|
74
|
+
sendMessage,
|
|
75
|
+
status: chatStatus,
|
|
76
|
+
setMessages,
|
|
77
|
+
error,
|
|
78
|
+
regenerate,
|
|
79
|
+
} = useChat({
|
|
80
|
+
transport: new DefaultChatTransport({
|
|
81
|
+
api: "/api/chat",
|
|
82
|
+
body: {
|
|
83
|
+
serverId,
|
|
84
|
+
chatModelId: selectedModel || undefined,
|
|
85
|
+
},
|
|
86
|
+
}),
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
const scrollRef = useRef<HTMLDivElement>(null);
|
|
90
|
+
const isBusy = chatStatus === "submitted" || chatStatus === "streaming";
|
|
91
|
+
|
|
92
|
+
useEffect(() => {
|
|
93
|
+
scrollRef.current?.scrollTo({
|
|
94
|
+
top: scrollRef.current.scrollHeight,
|
|
95
|
+
behavior: "smooth",
|
|
96
|
+
});
|
|
97
|
+
}, [messages, chatStatus]);
|
|
98
|
+
|
|
99
|
+
function handleSubmit(e: React.FormEvent) {
|
|
100
|
+
e.preventDefault();
|
|
101
|
+
const text = input.trim();
|
|
102
|
+
if (!text || isBusy) return;
|
|
103
|
+
sendMessage({ text });
|
|
104
|
+
setInput("");
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function newChat() {
|
|
108
|
+
setMessages([]);
|
|
109
|
+
setInput("");
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const isEmpty = messages.length === 0;
|
|
113
|
+
const ready = status?.ready ?? false;
|
|
114
|
+
|
|
115
|
+
if (statusLoading) {
|
|
116
|
+
return (
|
|
117
|
+
<div className="flex flex-1 items-center justify-center">
|
|
118
|
+
<Loader2 className="size-6 animate-spin text-muted-foreground" />
|
|
119
|
+
</div>
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return (
|
|
124
|
+
<div className="flex flex-1 flex-col overflow-hidden">
|
|
125
|
+
{/* Header bar */}
|
|
126
|
+
<div className="flex items-center justify-between border-b border-border px-6 pb-3">
|
|
127
|
+
<div className="flex items-center gap-3">
|
|
128
|
+
{/* We moved model selector to the right */}
|
|
129
|
+
</div>
|
|
130
|
+
<div className="flex items-center gap-2">
|
|
131
|
+
{/* Model selector */}
|
|
132
|
+
{status?.availableModels && status.availableModels.length > 0 && (
|
|
133
|
+
<div className="relative">
|
|
134
|
+
<button
|
|
135
|
+
type="button"
|
|
136
|
+
onClick={() => setShowModelSelect((o) => !o)}
|
|
137
|
+
className="flex w-[250px] justify-between items-center gap-1.5 rounded-lg border border-border bg-white px-3 py-1.5 text-xs text-foreground transition hover:bg-muted"
|
|
138
|
+
>
|
|
139
|
+
{status.availableModels.find((m) => m.id === selectedModel)
|
|
140
|
+
?.label ||
|
|
141
|
+
selectedModel ||
|
|
142
|
+
"Select model"}
|
|
143
|
+
<ChevronDown className="size-3 text-muted-foreground" />
|
|
144
|
+
</button>
|
|
145
|
+
{showModelSelect && (
|
|
146
|
+
<>
|
|
147
|
+
<div
|
|
148
|
+
className="fixed inset-0 z-40"
|
|
149
|
+
onClick={() => setShowModelSelect(false)}
|
|
150
|
+
/>
|
|
151
|
+
<div className="absolute right-0 top-full z-50 mt-1 max-h-64 min-w-[250px] overflow-y-auto rounded-lg border border-border bg-card p-1 shadow-lg [&::-webkit-scrollbar]:hidden">
|
|
152
|
+
{status.availableModels.map((m) => (
|
|
153
|
+
<button
|
|
154
|
+
key={m.id}
|
|
155
|
+
type="button"
|
|
156
|
+
onClick={() => {
|
|
157
|
+
setSelectedModel(m.id);
|
|
158
|
+
setShowModelSelect(false);
|
|
159
|
+
}}
|
|
160
|
+
className={cn(
|
|
161
|
+
"flex w-full gap-3 items-center justify-between rounded-md px-3 py-2 text-left text-xs transition",
|
|
162
|
+
m.id === selectedModel
|
|
163
|
+
? "bg-primary/10 text-primary font-medium"
|
|
164
|
+
: "text-foreground hover:bg-muted",
|
|
165
|
+
)}
|
|
166
|
+
>
|
|
167
|
+
<span>{m.label}</span>
|
|
168
|
+
{PROVIDER_META[
|
|
169
|
+
m.provider as keyof typeof PROVIDER_META
|
|
170
|
+
] ? (
|
|
171
|
+
<ProviderIcon
|
|
172
|
+
src={
|
|
173
|
+
PROVIDER_META[
|
|
174
|
+
m.provider as keyof typeof PROVIDER_META
|
|
175
|
+
].iconSrc
|
|
176
|
+
}
|
|
177
|
+
alt={m.provider}
|
|
178
|
+
pillBg={
|
|
179
|
+
PROVIDER_META[
|
|
180
|
+
m.provider as keyof typeof PROVIDER_META
|
|
181
|
+
].pillBg
|
|
182
|
+
}
|
|
183
|
+
size={16}
|
|
184
|
+
/>
|
|
185
|
+
) : (
|
|
186
|
+
<span className="text-[10px] uppercase text-muted-foreground/60">
|
|
187
|
+
{m.provider}
|
|
188
|
+
</span>
|
|
189
|
+
)}
|
|
190
|
+
</button>
|
|
191
|
+
))}
|
|
192
|
+
</div>
|
|
193
|
+
</>
|
|
194
|
+
)}
|
|
195
|
+
</div>
|
|
196
|
+
)}
|
|
197
|
+
|
|
198
|
+
<div className="h-4 w-px bg-border mx-1" />
|
|
199
|
+
|
|
200
|
+
<button
|
|
201
|
+
type="button"
|
|
202
|
+
onClick={newChat}
|
|
203
|
+
title="New chat"
|
|
204
|
+
className="flex h-8 w-8 items-center justify-center rounded-lg text-muted-foreground transition hover:bg-secondary hover:text-foreground"
|
|
205
|
+
>
|
|
206
|
+
<SquarePen className="h-[16px] w-[16px]" />
|
|
207
|
+
</button>
|
|
208
|
+
<ChatSettingsModal />
|
|
209
|
+
</div>
|
|
210
|
+
</div>
|
|
211
|
+
|
|
212
|
+
{/* Messages area */}
|
|
213
|
+
<div
|
|
214
|
+
ref={scrollRef}
|
|
215
|
+
className="min-h-0 flex-1 overflow-y-auto [&::-webkit-scrollbar]:hidden"
|
|
216
|
+
>
|
|
217
|
+
<div className="mx-auto w-full max-w-3xl px-4 py-6 sm:px-6">
|
|
218
|
+
{!ready ? (
|
|
219
|
+
<div className="flex flex-col items-center justify-center gap-3 pt-[18vh] text-center">
|
|
220
|
+
<div className="flex h-14 w-14 items-center justify-center rounded-2xl bg-muted text-muted-foreground">
|
|
221
|
+
<MessageCircle className="size-7" />
|
|
222
|
+
</div>
|
|
223
|
+
<h1 className="text-xl font-semibold tracking-tight text-foreground">
|
|
224
|
+
Chat not ready yet
|
|
225
|
+
</h1>
|
|
226
|
+
<p className="max-w-md text-sm leading-relaxed text-muted-foreground">
|
|
227
|
+
{status?.blockers?.join(" ") ||
|
|
228
|
+
"Set an API key in Settings to start chatting."}
|
|
229
|
+
</p>
|
|
230
|
+
</div>
|
|
231
|
+
) : isEmpty ? (
|
|
232
|
+
<div className="flex flex-col items-center justify-center gap-3 pt-[18vh] text-center">
|
|
233
|
+
<div className="flex h-14 w-14 items-center justify-center rounded-2xl bg-primary/10 text-primary">
|
|
234
|
+
<MessageCircle className="size-7" />
|
|
235
|
+
</div>
|
|
236
|
+
<h1 className="text-xl font-semibold tracking-tight text-foreground text-balance">
|
|
237
|
+
Chat with your knowledge base
|
|
238
|
+
</h1>
|
|
239
|
+
<p className="max-w-md text-sm leading-relaxed text-muted-foreground text-pretty">
|
|
240
|
+
Ask questions and get AI-powered answers grounded in your
|
|
241
|
+
indexed documents.
|
|
242
|
+
</p>
|
|
243
|
+
{!status?.indexed && (
|
|
244
|
+
<div className="mt-2 flex items-center gap-2 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-700">
|
|
245
|
+
<TriangleAlert className="size-3.5 shrink-0" />
|
|
246
|
+
No documents indexed yet, add documents in the Docs page. The
|
|
247
|
+
agent can still answer general questions.
|
|
248
|
+
</div>
|
|
249
|
+
)}
|
|
250
|
+
<div className="mt-4 flex flex-wrap justify-center gap-2">
|
|
251
|
+
{status?.suggestions && status.suggestions.length > 0 ? (
|
|
252
|
+
status.suggestions.map((s) => (
|
|
253
|
+
<button
|
|
254
|
+
key={s}
|
|
255
|
+
type="button"
|
|
256
|
+
onClick={() => {
|
|
257
|
+
sendMessage({ text: s });
|
|
258
|
+
}}
|
|
259
|
+
className="rounded-full border border-border bg-card px-3.5 py-1.5 text-xs text-foreground transition hover:bg-secondary"
|
|
260
|
+
>
|
|
261
|
+
{s}
|
|
262
|
+
</button>
|
|
263
|
+
))
|
|
264
|
+
) : (
|
|
265
|
+
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
|
266
|
+
{/* <Loader2 className="size-3 animate-spin" /> Generating
|
|
267
|
+
suggestions... */}
|
|
268
|
+
</div>
|
|
269
|
+
)}
|
|
270
|
+
</div>
|
|
271
|
+
</div>
|
|
272
|
+
) : (
|
|
273
|
+
<div className="flex flex-col gap-6">
|
|
274
|
+
{messages.map((m, idx) => (
|
|
275
|
+
<MessageItem
|
|
276
|
+
key={m.id}
|
|
277
|
+
message={m}
|
|
278
|
+
isLast={idx === messages.length - 1}
|
|
279
|
+
isStreaming={chatStatus === "streaming"}
|
|
280
|
+
/>
|
|
281
|
+
))}
|
|
282
|
+
{chatStatus === "submitted" ? (
|
|
283
|
+
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
|
284
|
+
<span className="h-2 w-2 animate-pulse rounded-full bg-foreground" />
|
|
285
|
+
Thinking…
|
|
286
|
+
</div>
|
|
287
|
+
) : null}
|
|
288
|
+
{error ? (
|
|
289
|
+
<div className="flex items-start gap-3 rounded-xl border border-destructive/30 bg-destructive/5 px-4 py-3">
|
|
290
|
+
<TriangleAlert className="mt-0.5 h-4 w-4 shrink-0 text-destructive" />
|
|
291
|
+
<div className="flex-1">
|
|
292
|
+
<p className="text-sm text-foreground">
|
|
293
|
+
{error.message ||
|
|
294
|
+
"Something went wrong while generating a response."}
|
|
295
|
+
</p>
|
|
296
|
+
<button
|
|
297
|
+
type="button"
|
|
298
|
+
onClick={() => regenerate()}
|
|
299
|
+
className="mt-2 inline-flex items-center gap-1.5 rounded-lg border border-border bg-card px-2.5 py-1.5 text-xs font-medium text-foreground transition hover:bg-secondary"
|
|
300
|
+
>
|
|
301
|
+
<RotateCcw className="h-3.5 w-3.5" />
|
|
302
|
+
Try again
|
|
303
|
+
</button>
|
|
304
|
+
</div>
|
|
305
|
+
</div>
|
|
306
|
+
) : null}
|
|
307
|
+
</div>
|
|
308
|
+
)}
|
|
309
|
+
</div>
|
|
310
|
+
</div>
|
|
311
|
+
|
|
312
|
+
{/* Input area */}
|
|
313
|
+
{ready && (
|
|
314
|
+
<div className="relative z-10000 px-4 pb-5 pt-3 sm:px-6">
|
|
315
|
+
<form onSubmit={handleSubmit} className="mx-auto w-full max-w-3xl">
|
|
316
|
+
<div className="flex items-end gap-2 rounded-2xl border border-border bg-card p-2 transition focus-within:ring-1 focus-within:ring-ring">
|
|
317
|
+
<textarea
|
|
318
|
+
value={input}
|
|
319
|
+
onChange={(e) => setInput(e.target.value)}
|
|
320
|
+
onKeyDown={(e) => {
|
|
321
|
+
if (e.key === "Enter" && !e.shiftKey) {
|
|
322
|
+
e.preventDefault();
|
|
323
|
+
handleSubmit(e);
|
|
324
|
+
}
|
|
325
|
+
}}
|
|
326
|
+
rows={2}
|
|
327
|
+
disabled={!ready}
|
|
328
|
+
placeholder="Ask about your knowledge base…"
|
|
329
|
+
className="max-h-40 flex-1 resize-none bg-transparent px-2 py-1.5 text-sm text-foreground outline-none placeholder:text-muted-foreground"
|
|
330
|
+
/>
|
|
331
|
+
<button
|
|
332
|
+
type="submit"
|
|
333
|
+
disabled={!input.trim() || isBusy}
|
|
334
|
+
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-primary text-primary-foreground transition hover:opacity-90 disabled:opacity-40"
|
|
335
|
+
>
|
|
336
|
+
{isBusy ? (
|
|
337
|
+
<Loader2 className="h-[18px] w-[18px] animate-spin" />
|
|
338
|
+
) : (
|
|
339
|
+
<ArrowUp className="h-[18px] w-[18px]" />
|
|
340
|
+
)}
|
|
341
|
+
</button>
|
|
342
|
+
</div>
|
|
343
|
+
</form>
|
|
344
|
+
</div>
|
|
345
|
+
)}
|
|
346
|
+
</div>
|
|
347
|
+
);
|
|
348
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
import { ChevronDown, Loader2, Database } from "lucide-react";
|
|
5
|
+
|
|
6
|
+
interface KBHit {
|
|
7
|
+
title: string;
|
|
8
|
+
url: string;
|
|
9
|
+
score: number;
|
|
10
|
+
text: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Renders knowledge base retrieval results from tool-searchKnowledgeBase parts.
|
|
15
|
+
*
|
|
16
|
+
* UIMessage parts for tools look like:
|
|
17
|
+
* { type: "tool-searchKnowledgeBase", state: "input-streaming" | "input-available" | "output", input: { query }, output: { query, hits } }
|
|
18
|
+
*/
|
|
19
|
+
export function KnowledgeBaseResult({
|
|
20
|
+
parts,
|
|
21
|
+
isShimmering,
|
|
22
|
+
}: {
|
|
23
|
+
parts: any[];
|
|
24
|
+
isShimmering?: boolean;
|
|
25
|
+
}) {
|
|
26
|
+
const [open, setOpen] = useState(false);
|
|
27
|
+
const isRunning = parts.some(
|
|
28
|
+
(part) =>
|
|
29
|
+
part.state === "input-streaming" || part.state === "input-available",
|
|
30
|
+
);
|
|
31
|
+
const hits: KBHit[] = parts.flatMap((part) => part.output?.hits ?? []);
|
|
32
|
+
const queries = parts.map((part) => part.input?.query).filter(Boolean);
|
|
33
|
+
|
|
34
|
+
return (
|
|
35
|
+
<div className="relative overflow-hidden rounded-xl border border-border bg-card">
|
|
36
|
+
{isShimmering && (
|
|
37
|
+
<div className="pointer-events-none absolute inset-0 z-10 animate-pulse bg-linear-to-r from-transparent via-foreground/5 to-transparent" />
|
|
38
|
+
)}
|
|
39
|
+
<button
|
|
40
|
+
type="button"
|
|
41
|
+
onClick={() => setOpen((o) => !o)}
|
|
42
|
+
className="flex w-full items-center gap-3 px-3.5 py-3 text-left"
|
|
43
|
+
>
|
|
44
|
+
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-secondary text-foreground">
|
|
45
|
+
{isRunning ? (
|
|
46
|
+
<Loader2 className="h-4 w-4 animate-spin" />
|
|
47
|
+
) : (
|
|
48
|
+
<Database className="size-4" />
|
|
49
|
+
)}
|
|
50
|
+
</div>
|
|
51
|
+
<div className="min-w-0 flex-1">
|
|
52
|
+
<div className="text-sm font-medium text-foreground">
|
|
53
|
+
{isRunning ? "Searching knowledge base…" : "Knowledge base"}
|
|
54
|
+
</div>
|
|
55
|
+
<div className="truncate text-xs text-muted-foreground">
|
|
56
|
+
{queries.length > 0
|
|
57
|
+
? `"${queries[0]}"${queries.length > 1 ? ` and ${queries.length - 1} more` : ""}`
|
|
58
|
+
: "RAG retrieval"}
|
|
59
|
+
{!isRunning
|
|
60
|
+
? ` · ${hits.length} ${hits.length === 1 ? "result" : "results"}`
|
|
61
|
+
: ""}
|
|
62
|
+
</div>
|
|
63
|
+
</div>
|
|
64
|
+
{!isRunning && hits.length > 0 ? (
|
|
65
|
+
<ChevronDown
|
|
66
|
+
className={`h-4 w-4 text-muted-foreground transition ${open ? "rotate-180" : ""}`}
|
|
67
|
+
/>
|
|
68
|
+
) : null}
|
|
69
|
+
</button>
|
|
70
|
+
|
|
71
|
+
{open && hits.length > 0 ? (
|
|
72
|
+
<div className="flex flex-col gap-px border-t border-border bg-border">
|
|
73
|
+
{hits.map((h, i) => (
|
|
74
|
+
<div key={i} className="bg-card px-3.5 py-3">
|
|
75
|
+
<div className="flex items-center justify-between gap-2">
|
|
76
|
+
<span className="truncate text-sm font-medium text-foreground">
|
|
77
|
+
{h.title}
|
|
78
|
+
</span>
|
|
79
|
+
<span className="shrink-0 rounded-full bg-secondary px-2 py-0.5 text-[11px] tabular-nums text-muted-foreground">
|
|
80
|
+
{Math.round(h.score * 100)}%
|
|
81
|
+
</span>
|
|
82
|
+
</div>
|
|
83
|
+
{h.url ? (
|
|
84
|
+
<div className="mt-0.5 truncate text-xs text-muted-foreground">
|
|
85
|
+
{h.url}
|
|
86
|
+
</div>
|
|
87
|
+
) : null}
|
|
88
|
+
<p className="mt-1.5 line-clamp-3 text-xs leading-relaxed text-muted-foreground">
|
|
89
|
+
{h.text}
|
|
90
|
+
</p>
|
|
91
|
+
</div>
|
|
92
|
+
))}
|
|
93
|
+
</div>
|
|
94
|
+
) : null}
|
|
95
|
+
</div>
|
|
96
|
+
);
|
|
97
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import type { UIMessage } from "ai";
|
|
4
|
+
import { KnowledgeBaseResult } from "@/components/chat/knowledge-base-result";
|
|
5
|
+
|
|
6
|
+
export function MessageItem({
|
|
7
|
+
message,
|
|
8
|
+
isLast,
|
|
9
|
+
isStreaming,
|
|
10
|
+
}: {
|
|
11
|
+
message: UIMessage;
|
|
12
|
+
isLast?: boolean;
|
|
13
|
+
isStreaming?: boolean;
|
|
14
|
+
}) {
|
|
15
|
+
const isUser = message.role === "user";
|
|
16
|
+
|
|
17
|
+
if (isUser) {
|
|
18
|
+
const text = message.parts
|
|
19
|
+
.filter((p) => p.type === "text")
|
|
20
|
+
.map((p: any) => p.text)
|
|
21
|
+
.join("");
|
|
22
|
+
return (
|
|
23
|
+
<div className="message user-message flex justify-end" data-role="user">
|
|
24
|
+
<div className="max-w-[85%] rounded-2xl rounded-br-md bg-primary/10 px-4 py-2.5 text-[15px] leading-relaxed text-foreground">
|
|
25
|
+
{text}
|
|
26
|
+
</div>
|
|
27
|
+
</div>
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// UIMessage parts: "text", "tool-searchKnowledgeBase", etc.
|
|
32
|
+
const kbParts = message.parts.filter(
|
|
33
|
+
(p: any) => p.type === "tool-searchKnowledgeBase",
|
|
34
|
+
);
|
|
35
|
+
const textParts = message.parts.filter((p: any) => p.type === "text");
|
|
36
|
+
|
|
37
|
+
const isShimmering =
|
|
38
|
+
textParts.every((p: any) => !p.text || p.text.trim().length === 0) &&
|
|
39
|
+
isLast &&
|
|
40
|
+
isStreaming;
|
|
41
|
+
|
|
42
|
+
return (
|
|
43
|
+
<div className="message assistant-message flex flex-col gap-3" data-role="assistant">
|
|
44
|
+
{kbParts.length > 0 ? (
|
|
45
|
+
<KnowledgeBaseResult parts={kbParts} isShimmering={isShimmering} />
|
|
46
|
+
) : null}
|
|
47
|
+
|
|
48
|
+
{textParts.map((part: any, i: number) => (
|
|
49
|
+
<div
|
|
50
|
+
key={i}
|
|
51
|
+
className="prose prose-sm max-w-none text-[15px] text-foreground prose-headings:text-foreground prose-strong:text-foreground prose-a:text-foreground prose-a:underline prose-a:underline-offset-2 prose-code:rounded prose-code:bg-secondary prose-code:px-1 prose-code:py-0.5 prose-code:text-xs prose-code:before:content-none prose-code:after:content-none"
|
|
52
|
+
dangerouslySetInnerHTML={{ __html: renderMarkdown(part.text || "") }}
|
|
53
|
+
/>
|
|
54
|
+
))}
|
|
55
|
+
</div>
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Lightweight markdown → HTML for chat messages.
|
|
61
|
+
*/
|
|
62
|
+
function renderMarkdown(text: string): string {
|
|
63
|
+
if (!text) return "";
|
|
64
|
+
return text
|
|
65
|
+
.replace(/&/g, "&")
|
|
66
|
+
.replace(/</g, "<")
|
|
67
|
+
.replace(/>/g, ">")
|
|
68
|
+
// Code blocks
|
|
69
|
+
.replace(
|
|
70
|
+
/```(\w*)\n([\s\S]*?)```/g,
|
|
71
|
+
'<pre class="rounded-lg bg-secondary p-3 text-xs overflow-x-auto my-2"><code>$2</code></pre>',
|
|
72
|
+
)
|
|
73
|
+
// Inline code
|
|
74
|
+
.replace(/`([^`]+)`/g, "<code>$1</code>")
|
|
75
|
+
// Bold
|
|
76
|
+
.replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>")
|
|
77
|
+
// Italic
|
|
78
|
+
.replace(/\*(.+?)\*/g, "<em>$1</em>")
|
|
79
|
+
// Links
|
|
80
|
+
.replace(
|
|
81
|
+
/\[([^\]]+)\]\(([^)]+)\)/g,
|
|
82
|
+
'<a href="$2" target="_blank" rel="noreferrer">$1</a>',
|
|
83
|
+
)
|
|
84
|
+
// Headings
|
|
85
|
+
.replace(
|
|
86
|
+
/^### (.+)$/gm,
|
|
87
|
+
'<h3 class="text-base font-semibold mt-3 mb-1">$1</h3>',
|
|
88
|
+
)
|
|
89
|
+
.replace(
|
|
90
|
+
/^## (.+)$/gm,
|
|
91
|
+
'<h2 class="text-lg font-semibold mt-4 mb-1">$1</h2>',
|
|
92
|
+
)
|
|
93
|
+
.replace(
|
|
94
|
+
/^# (.+)$/gm,
|
|
95
|
+
'<h1 class="text-xl font-semibold mt-4 mb-2">$1</h1>',
|
|
96
|
+
)
|
|
97
|
+
// Lists
|
|
98
|
+
.replace(/^- (.+)$/gm, '<li class="ml-4 list-disc">$1</li>')
|
|
99
|
+
.replace(/^\d+\. (.+)$/gm, '<li class="ml-4 list-decimal">$1</li>')
|
|
100
|
+
// Paragraphs
|
|
101
|
+
.replace(/\n\n/g, "</p><p>")
|
|
102
|
+
.replace(/\n/g, "<br/>");
|
|
103
|
+
}
|