business-stack 0.1.0

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.
Files changed (181) hide show
  1. package/.python-version +1 -0
  2. package/backend/.env.example +65 -0
  3. package/backend/alembic/env.py +63 -0
  4. package/backend/alembic/script.py.mako +26 -0
  5. package/backend/alembic/versions/2a9c8f1d0e7b_multimodal_kb_schema.py +279 -0
  6. package/backend/alembic/versions/3c1d2e4f5a6b_sqlite_vec_embeddings.py +58 -0
  7. package/backend/alembic/versions/4e8b0c2d1a3f_document_links.py +50 -0
  8. package/backend/alembic/versions/6a0b1c2d3e4f_link_expansion_dedupe_columns.py +49 -0
  9. package/backend/alembic/versions/7d8e9f0a1b2c_document_chunks.py +70 -0
  10. package/backend/alembic/versions/8f2a1c0d9e3b_initial_empty_revision.py +22 -0
  11. package/backend/alembic/versions/9f0a1b2c3d4e_entity_mentions_cooccurrence.py +123 -0
  12. package/backend/alembic/versions/b1c2d3e4f5a6_pipeline_dedupe_dlq.py +99 -0
  13. package/backend/alembic/versions/c2d3e4f5061a_chat_sessions_messages.py +59 -0
  14. package/backend/alembic.ini +42 -0
  15. package/backend/app/__init__.py +0 -0
  16. package/backend/app/config.py +337 -0
  17. package/backend/app/connectors/__init__.py +13 -0
  18. package/backend/app/connectors/base.py +39 -0
  19. package/backend/app/connectors/builtins.py +51 -0
  20. package/backend/app/connectors/playwright_session.py +146 -0
  21. package/backend/app/connectors/registry.py +68 -0
  22. package/backend/app/connectors/thread_expansion/__init__.py +33 -0
  23. package/backend/app/connectors/thread_expansion/fakes.py +154 -0
  24. package/backend/app/connectors/thread_expansion/models.py +113 -0
  25. package/backend/app/connectors/thread_expansion/reddit.py +53 -0
  26. package/backend/app/connectors/thread_expansion/twitter.py +49 -0
  27. package/backend/app/db.py +5 -0
  28. package/backend/app/dependencies.py +34 -0
  29. package/backend/app/logging_config.py +35 -0
  30. package/backend/app/main.py +97 -0
  31. package/backend/app/middleware/__init__.py +0 -0
  32. package/backend/app/middleware/gateway_identity.py +17 -0
  33. package/backend/app/middleware/openapi_gateway.py +71 -0
  34. package/backend/app/middleware/request_id.py +23 -0
  35. package/backend/app/openapi_config.py +126 -0
  36. package/backend/app/routers/__init__.py +0 -0
  37. package/backend/app/routers/admin_pipeline.py +123 -0
  38. package/backend/app/routers/chat.py +206 -0
  39. package/backend/app/routers/chunks.py +36 -0
  40. package/backend/app/routers/entity_extract.py +31 -0
  41. package/backend/app/routers/example.py +8 -0
  42. package/backend/app/routers/gemini_embed.py +58 -0
  43. package/backend/app/routers/health.py +28 -0
  44. package/backend/app/routers/ingestion.py +146 -0
  45. package/backend/app/routers/link_expansion.py +34 -0
  46. package/backend/app/routers/pipeline_status.py +304 -0
  47. package/backend/app/routers/query.py +63 -0
  48. package/backend/app/routers/vectors.py +63 -0
  49. package/backend/app/schemas/__init__.py +0 -0
  50. package/backend/app/schemas/canonical.py +44 -0
  51. package/backend/app/schemas/chat.py +50 -0
  52. package/backend/app/schemas/ingest.py +29 -0
  53. package/backend/app/schemas/query.py +153 -0
  54. package/backend/app/schemas/vectors.py +56 -0
  55. package/backend/app/services/__init__.py +0 -0
  56. package/backend/app/services/chat_store.py +152 -0
  57. package/backend/app/services/chunking/__init__.py +3 -0
  58. package/backend/app/services/chunking/llm_boundaries.py +63 -0
  59. package/backend/app/services/chunking/schemas.py +30 -0
  60. package/backend/app/services/chunking/semantic_chunk.py +178 -0
  61. package/backend/app/services/chunking/splitters.py +214 -0
  62. package/backend/app/services/embeddings/__init__.py +20 -0
  63. package/backend/app/services/embeddings/build_inputs.py +140 -0
  64. package/backend/app/services/embeddings/dlq.py +128 -0
  65. package/backend/app/services/embeddings/gemini_api.py +207 -0
  66. package/backend/app/services/embeddings/persist.py +74 -0
  67. package/backend/app/services/embeddings/types.py +32 -0
  68. package/backend/app/services/embeddings/worker.py +224 -0
  69. package/backend/app/services/entities/__init__.py +12 -0
  70. package/backend/app/services/entities/gliner_extract.py +63 -0
  71. package/backend/app/services/entities/llm_extract.py +94 -0
  72. package/backend/app/services/entities/pipeline.py +179 -0
  73. package/backend/app/services/entities/spacy_extract.py +63 -0
  74. package/backend/app/services/entities/types.py +15 -0
  75. package/backend/app/services/gemini_chat.py +113 -0
  76. package/backend/app/services/hooks/__init__.py +3 -0
  77. package/backend/app/services/hooks/post_ingest.py +186 -0
  78. package/backend/app/services/ingestion/__init__.py +0 -0
  79. package/backend/app/services/ingestion/persist.py +188 -0
  80. package/backend/app/services/integrations_remote.py +91 -0
  81. package/backend/app/services/link_expansion/__init__.py +3 -0
  82. package/backend/app/services/link_expansion/canonical_url.py +45 -0
  83. package/backend/app/services/link_expansion/domain_policy.py +26 -0
  84. package/backend/app/services/link_expansion/html_extract.py +72 -0
  85. package/backend/app/services/link_expansion/rate_limit.py +32 -0
  86. package/backend/app/services/link_expansion/robots.py +46 -0
  87. package/backend/app/services/link_expansion/schemas.py +67 -0
  88. package/backend/app/services/link_expansion/worker.py +458 -0
  89. package/backend/app/services/normalization/__init__.py +7 -0
  90. package/backend/app/services/normalization/normalizer.py +331 -0
  91. package/backend/app/services/normalization/persist_normalized.py +67 -0
  92. package/backend/app/services/playwright_extract/__init__.py +13 -0
  93. package/backend/app/services/playwright_extract/__main__.py +96 -0
  94. package/backend/app/services/playwright_extract/extract.py +181 -0
  95. package/backend/app/services/retrieval_service.py +351 -0
  96. package/backend/app/sqlite_ext.py +36 -0
  97. package/backend/app/storage/__init__.py +3 -0
  98. package/backend/app/storage/blobs.py +30 -0
  99. package/backend/app/vectorstore/__init__.py +13 -0
  100. package/backend/app/vectorstore/sqlite_vec_store.py +242 -0
  101. package/backend/backend.egg-info/PKG-INFO +18 -0
  102. package/backend/backend.egg-info/SOURCES.txt +93 -0
  103. package/backend/backend.egg-info/dependency_links.txt +1 -0
  104. package/backend/backend.egg-info/entry_points.txt +2 -0
  105. package/backend/backend.egg-info/requires.txt +15 -0
  106. package/backend/backend.egg-info/top_level.txt +4 -0
  107. package/backend/package.json +15 -0
  108. package/backend/pyproject.toml +52 -0
  109. package/backend/tests/conftest.py +40 -0
  110. package/backend/tests/test_chat.py +92 -0
  111. package/backend/tests/test_chunking.py +132 -0
  112. package/backend/tests/test_entities.py +170 -0
  113. package/backend/tests/test_gemini_embed.py +224 -0
  114. package/backend/tests/test_health.py +24 -0
  115. package/backend/tests/test_ingest_raw.py +123 -0
  116. package/backend/tests/test_link_expansion.py +241 -0
  117. package/backend/tests/test_main.py +12 -0
  118. package/backend/tests/test_normalizer.py +114 -0
  119. package/backend/tests/test_openapi_gateway.py +40 -0
  120. package/backend/tests/test_pipeline_hardening.py +285 -0
  121. package/backend/tests/test_pipeline_status.py +71 -0
  122. package/backend/tests/test_playwright_extract.py +80 -0
  123. package/backend/tests/test_post_ingest_hooks.py +162 -0
  124. package/backend/tests/test_query.py +165 -0
  125. package/backend/tests/test_thread_expansion.py +72 -0
  126. package/backend/tests/test_vectors.py +85 -0
  127. package/backend/uv.lock +1839 -0
  128. package/bin/business-stack.cjs +412 -0
  129. package/frontend/web/.env.example +23 -0
  130. package/frontend/web/AGENTS.md +5 -0
  131. package/frontend/web/CLAUDE.md +1 -0
  132. package/frontend/web/README.md +36 -0
  133. package/frontend/web/components.json +25 -0
  134. package/frontend/web/next-env.d.ts +6 -0
  135. package/frontend/web/next.config.ts +30 -0
  136. package/frontend/web/package.json +65 -0
  137. package/frontend/web/postcss.config.mjs +7 -0
  138. package/frontend/web/skills-lock.json +35 -0
  139. package/frontend/web/src/app/account/[[...path]]/page.tsx +19 -0
  140. package/frontend/web/src/app/auth/[[...path]]/page.tsx +14 -0
  141. package/frontend/web/src/app/chat/page.tsx +725 -0
  142. package/frontend/web/src/app/favicon.ico +0 -0
  143. package/frontend/web/src/app/globals.css +563 -0
  144. package/frontend/web/src/app/layout.tsx +50 -0
  145. package/frontend/web/src/app/page.tsx +96 -0
  146. package/frontend/web/src/app/settings/integrations/actions.ts +74 -0
  147. package/frontend/web/src/app/settings/integrations/integrations-settings-form.tsx +330 -0
  148. package/frontend/web/src/app/settings/integrations/page.tsx +41 -0
  149. package/frontend/web/src/app/webhooks/alpha-alerts/route.ts +84 -0
  150. package/frontend/web/src/components/home-auth-panel.tsx +49 -0
  151. package/frontend/web/src/components/providers.tsx +50 -0
  152. package/frontend/web/src/lib/alpha-webhook/connectors/registry.ts +35 -0
  153. package/frontend/web/src/lib/alpha-webhook/connectors/types.ts +8 -0
  154. package/frontend/web/src/lib/alpha-webhook/connectors/wabridge-delivery.test.ts +40 -0
  155. package/frontend/web/src/lib/alpha-webhook/connectors/wabridge-delivery.ts +78 -0
  156. package/frontend/web/src/lib/alpha-webhook/connectors/wabridge.ts +30 -0
  157. package/frontend/web/src/lib/alpha-webhook/handler.ts +12 -0
  158. package/frontend/web/src/lib/alpha-webhook/signature.test.ts +33 -0
  159. package/frontend/web/src/lib/alpha-webhook/signature.ts +21 -0
  160. package/frontend/web/src/lib/alpha-webhook/types.ts +23 -0
  161. package/frontend/web/src/lib/auth-client.ts +23 -0
  162. package/frontend/web/src/lib/integrations-config.ts +125 -0
  163. package/frontend/web/src/lib/ui-utills.tsx +90 -0
  164. package/frontend/web/src/lib/utils.ts +6 -0
  165. package/frontend/web/tsconfig.json +36 -0
  166. package/frontend/web/tsconfig.tsbuildinfo +1 -0
  167. package/frontend/web/vitest.config.ts +14 -0
  168. package/gateway/.env.example +23 -0
  169. package/gateway/README.md +13 -0
  170. package/gateway/package.json +24 -0
  171. package/gateway/src/auth.ts +49 -0
  172. package/gateway/src/index.ts +141 -0
  173. package/gateway/src/integrations/admin.ts +19 -0
  174. package/gateway/src/integrations/crypto.ts +52 -0
  175. package/gateway/src/integrations/handlers.ts +124 -0
  176. package/gateway/src/integrations/keys.ts +12 -0
  177. package/gateway/src/integrations/store.ts +106 -0
  178. package/gateway/src/stack-secrets.ts +35 -0
  179. package/gateway/tsconfig.json +13 -0
  180. package/package.json +33 -0
  181. package/turbo.json +27 -0
@@ -0,0 +1,725 @@
1
+ "use client";
2
+
3
+ import Link from "next/link";
4
+ import { useCallback, useEffect, useState } from "react";
5
+ import { authClient } from "@/lib/auth-client";
6
+
7
+ type ChatSessionRow = {
8
+ id: string;
9
+ title: string | null;
10
+ created_at: string;
11
+ updated_at: string;
12
+ };
13
+
14
+ type ChatMessageRow = {
15
+ id: number;
16
+ role: string;
17
+ content: string;
18
+ meta_json: string | null;
19
+ created_at: string;
20
+ };
21
+
22
+ type RawIngestResponse = {
23
+ document_id: string;
24
+ status: string;
25
+ connector: string;
26
+ deduplicated?: boolean;
27
+ normalization_failed?: boolean;
28
+ };
29
+
30
+ type PipelineStep = {
31
+ id: string;
32
+ label: string;
33
+ state: "ok" | "warn" | "error" | "pending";
34
+ detail: string | null;
35
+ };
36
+
37
+ type EmbeddingDlq = {
38
+ state: string;
39
+ attempt_count: number;
40
+ last_error: string;
41
+ next_retry_at: string | null;
42
+ updated_at: string | null;
43
+ };
44
+
45
+ type DocumentPipeline = {
46
+ document_id: string;
47
+ status: string;
48
+ content_type: string | null;
49
+ content_block_count: number;
50
+ chunk_count: number;
51
+ vector_row_count: number;
52
+ gemini_embedding_row_count: number;
53
+ normalization_error: Record<string, unknown> | string | null;
54
+ ingest_meta: Record<string, unknown>;
55
+ dlq: EmbeddingDlq | null;
56
+ steps: PipelineStep[];
57
+ checked_at: string;
58
+ };
59
+
60
+ function parseErrorDetail(json: unknown): string {
61
+ if (json !== null && typeof json === "object" && "detail" in json) {
62
+ const d = (json as { detail: unknown }).detail;
63
+ if (typeof d === "string") {
64
+ return d;
65
+ }
66
+ return JSON.stringify(d);
67
+ }
68
+ return "Request failed.";
69
+ }
70
+
71
+ async function backendJson<T>(
72
+ path: string,
73
+ init?: RequestInit,
74
+ ): Promise<{ ok: true; data: T } | { ok: false; status: number; message: string }> {
75
+ const headers: HeadersInit = { ...init?.headers };
76
+ if (init?.body != null && !("Content-Type" in (headers as Record<string, string>))) {
77
+ (headers as Record<string, string>)["Content-Type"] = "application/json";
78
+ }
79
+ let res: Response;
80
+ try {
81
+ res = await fetch(`/api/backend${path}`, {
82
+ credentials: "include",
83
+ ...init,
84
+ headers,
85
+ });
86
+ } catch {
87
+ return { ok: false, status: 0, message: "Network error." };
88
+ }
89
+ const rawText = await res.text();
90
+ let parsed: unknown = null;
91
+ if (rawText) {
92
+ try {
93
+ parsed = JSON.parse(rawText) as unknown;
94
+ } catch {
95
+ parsed = null;
96
+ }
97
+ }
98
+ if (!res.ok) {
99
+ const message =
100
+ res.status === 401
101
+ ? "Sign in to use chat and ingestion."
102
+ : parseErrorDetail(parsed);
103
+ return { ok: false, status: res.status, message };
104
+ }
105
+ return { ok: true, data: parsed as T };
106
+ }
107
+
108
+ export default function ChatPage() {
109
+ const { data: session, isPending: sessionPending } = authClient.useSession();
110
+ const [sessions, setSessions] = useState<ChatSessionRow[]>([]);
111
+ const [activeId, setActiveId] = useState<string | null>(null);
112
+ const [messages, setMessages] = useState<ChatMessageRow[]>([]);
113
+ const [listError, setListError] = useState<string | null>(null);
114
+ const [messagesError, setMessagesError] = useState<string | null>(null);
115
+ const [input, setInput] = useState("");
116
+ const [sendBusy, setSendBusy] = useState(false);
117
+ const [ingestText, setIngestText] = useState("");
118
+ const [embedAfterIngest, setEmbedAfterIngest] = useState(true);
119
+ const [ingestBusy, setIngestBusy] = useState(false);
120
+ const [ingestNote, setIngestNote] = useState<string | null>(null);
121
+ const [traceDocId, setTraceDocId] = useState("");
122
+ const [pipeline, setPipeline] = useState<DocumentPipeline | null>(null);
123
+ const [pipelineError, setPipelineError] = useState<string | null>(null);
124
+ const [pipelineBusy, setPipelineBusy] = useState(false);
125
+ const [pipelineAutoRefresh, setPipelineAutoRefresh] = useState(true);
126
+ const [pipelineActionsBusy, setPipelineActionsBusy] = useState(false);
127
+
128
+ const loadSessions = useCallback(async () => {
129
+ setListError(null);
130
+ const r = await backendJson<ChatSessionRow[]>("/chat/sessions");
131
+ if (!r.ok) {
132
+ setListError(r.message);
133
+ return;
134
+ }
135
+ setSessions(r.data);
136
+ }, []);
137
+
138
+ const loadMessages = useCallback(async (sessionId: string) => {
139
+ setMessagesError(null);
140
+ const r = await backendJson<ChatMessageRow[]>(
141
+ `/chat/sessions/${encodeURIComponent(sessionId)}/messages`,
142
+ );
143
+ if (!r.ok) {
144
+ setMessagesError(r.message);
145
+ setMessages([]);
146
+ return;
147
+ }
148
+ setMessages(r.data);
149
+ }, []);
150
+
151
+ useEffect(() => {
152
+ if (!session?.user) {
153
+ return;
154
+ }
155
+ void loadSessions();
156
+ }, [session?.user, loadSessions]);
157
+
158
+ useEffect(() => {
159
+ if (!activeId || !session?.user) {
160
+ setMessages([]);
161
+ return;
162
+ }
163
+ void loadMessages(activeId);
164
+ }, [activeId, session?.user, loadMessages]);
165
+
166
+ const fetchPipeline = useCallback(async (docId: string) => {
167
+ const id = docId.trim();
168
+ if (!id) {
169
+ setPipeline(null);
170
+ setPipelineError(null);
171
+ return;
172
+ }
173
+ setPipelineBusy(true);
174
+ setPipelineError(null);
175
+ const r = await backendJson<DocumentPipeline>(
176
+ `/ingest/documents/${encodeURIComponent(id)}/pipeline`,
177
+ );
178
+ setPipelineBusy(false);
179
+ if (!r.ok) {
180
+ setPipeline(null);
181
+ setPipelineError(r.message);
182
+ return;
183
+ }
184
+ setPipeline(r.data);
185
+ }, []);
186
+
187
+ useEffect(() => {
188
+ if (!session?.user || !traceDocId.trim() || !pipelineAutoRefresh) {
189
+ return;
190
+ }
191
+ void fetchPipeline(traceDocId);
192
+ const t = window.setInterval(() => {
193
+ void fetchPipeline(traceDocId);
194
+ }, 2500);
195
+ return () => window.clearInterval(t);
196
+ }, [session?.user, traceDocId, pipelineAutoRefresh, fetchPipeline]);
197
+
198
+ const startNewChat = async () => {
199
+ const r = await backendJson<ChatSessionRow>("/chat/sessions", {
200
+ method: "POST",
201
+ body: JSON.stringify({}),
202
+ });
203
+ if (!r.ok) {
204
+ setListError(r.message);
205
+ return;
206
+ }
207
+ setSessions((prev) => [r.data, ...prev]);
208
+ setActiveId(r.data.id);
209
+ };
210
+
211
+ const removeSession = async (id: string) => {
212
+ const r = await backendJson<{ deleted: boolean }>(
213
+ `/chat/sessions/${encodeURIComponent(id)}`,
214
+ { method: "DELETE" },
215
+ );
216
+ if (!r.ok) {
217
+ setListError(r.message);
218
+ return;
219
+ }
220
+ setSessions((prev) => prev.filter((s) => s.id !== id));
221
+ if (activeId === id) {
222
+ setActiveId(null);
223
+ setMessages([]);
224
+ }
225
+ };
226
+
227
+ const sendMessage = async () => {
228
+ const text = input.trim();
229
+ if (!activeId || !text || sendBusy) {
230
+ return;
231
+ }
232
+ setSendBusy(true);
233
+ setMessagesError(null);
234
+ const r = await backendJson<{
235
+ reply: string;
236
+ assistant_message_id: number;
237
+ }>(`/chat/sessions/${encodeURIComponent(activeId)}/complete`, {
238
+ method: "POST",
239
+ body: JSON.stringify({ message: text, k: 8 }),
240
+ });
241
+ setSendBusy(false);
242
+ setInput("");
243
+ if (!r.ok) {
244
+ setMessagesError(r.message);
245
+ return;
246
+ }
247
+ void loadMessages(activeId);
248
+ void loadSessions();
249
+ };
250
+
251
+ const runIngest = async () => {
252
+ const body = ingestText.trim();
253
+ if (!body || ingestBusy) {
254
+ return;
255
+ }
256
+ setIngestBusy(true);
257
+ setIngestNote(null);
258
+ const envelope = {
259
+ source: "web-chat",
260
+ timestamp: new Date().toISOString(),
261
+ content_type: "text" as const,
262
+ payload: body,
263
+ metadata: {},
264
+ };
265
+ const ingest = await backendJson<RawIngestResponse>(
266
+ "/ingest/raw?connector=generic",
267
+ { method: "POST", body: JSON.stringify(envelope) },
268
+ );
269
+ if (!ingest.ok) {
270
+ setIngestBusy(false);
271
+ setIngestNote(ingest.message);
272
+ return;
273
+ }
274
+ const docId = ingest.data.document_id;
275
+ const dedup = ingest.data.deduplicated === true;
276
+ const normFail = ingest.data.normalization_failed === true;
277
+ setTraceDocId(docId);
278
+ setPipelineAutoRefresh(true);
279
+ void fetchPipeline(docId);
280
+ if (embedAfterIngest) {
281
+ const emb = await backendJson<{ accepted: boolean }>(
282
+ `/ingest/documents/${encodeURIComponent(docId)}/embed`,
283
+ { method: "POST", body: JSON.stringify({ multimodal: false }) },
284
+ );
285
+ setIngestBusy(false);
286
+ if (!emb.ok) {
287
+ setIngestNote(
288
+ `Ingested document ${docId}, but embedding failed: ${emb.message}`,
289
+ );
290
+ return;
291
+ }
292
+ const extra =
293
+ dedup ? " (deduplicated)" : normFail ? " (normalization had issues)" : "";
294
+ setIngestNote(
295
+ `Ingested ${docId}${extra}; embedding queued — watch the pipeline panel below.`,
296
+ );
297
+ } else {
298
+ setIngestBusy(false);
299
+ setIngestNote(`Ingested ${docId} (no embed — not in vector index yet).`);
300
+ }
301
+ setIngestText("");
302
+ };
303
+
304
+ if (sessionPending) {
305
+ return (
306
+ <main className="mx-auto flex w-full max-w-5xl flex-1 flex-col gap-4 p-6">
307
+ <p className="text-sm text-neutral-500">Checking session…</p>
308
+ </main>
309
+ );
310
+ }
311
+
312
+ if (!session?.user) {
313
+ return (
314
+ <main className="mx-auto flex w-full max-w-5xl flex-1 flex-col gap-4 p-6">
315
+ <h1 className="text-2xl font-semibold tracking-tight">Chat</h1>
316
+ <p className="text-sm text-neutral-600 dark:text-neutral-400">
317
+ Sign in to load sessions and talk to your knowledge base.
318
+ </p>
319
+ <Link
320
+ className="inline-flex w-fit items-center justify-center rounded-md bg-neutral-900 px-4 py-2 text-sm font-medium text-white hover:bg-neutral-800 dark:bg-neutral-100 dark:text-neutral-900 dark:hover:bg-white"
321
+ href="/auth/sign-in"
322
+ >
323
+ Sign in
324
+ </Link>
325
+ <p className="text-sm">
326
+ <Link
327
+ href="/"
328
+ className="text-neutral-600 underline dark:text-neutral-400"
329
+ >
330
+ Home
331
+ </Link>
332
+ </p>
333
+ </main>
334
+ );
335
+ }
336
+
337
+ return (
338
+ <main className="mx-auto flex w-full max-w-5xl flex-1 flex-col gap-4 p-6">
339
+ <div className="flex flex-wrap items-baseline justify-between gap-2">
340
+ <h1 className="text-2xl font-semibold tracking-tight">Chat</h1>
341
+ <Link
342
+ href="/"
343
+ className="text-sm text-neutral-600 underline dark:text-neutral-400"
344
+ >
345
+ Home
346
+ </Link>
347
+ </div>
348
+ <p className="text-sm text-neutral-600 dark:text-neutral-400">
349
+ Messages are stored per user in SQLite. Each reply runs retrieval over
350
+ your embedded documents, then Gemini when configured.
351
+ </p>
352
+
353
+ {listError ? (
354
+ <p className="text-sm text-amber-800 dark:text-amber-200">{listError}</p>
355
+ ) : null}
356
+
357
+ <div className="grid min-h-[28rem] gap-4 md:grid-cols-[12rem_1fr]">
358
+ <aside className="flex flex-col gap-2 rounded-lg border border-neutral-200 bg-white/60 p-3 dark:border-neutral-800 dark:bg-neutral-950/40">
359
+ <button
360
+ type="button"
361
+ onClick={() => void startNewChat()}
362
+ className="rounded-md bg-neutral-900 px-3 py-2 text-xs font-medium text-white hover:bg-neutral-800 dark:bg-neutral-100 dark:text-neutral-900 dark:hover:bg-white"
363
+ >
364
+ New chat
365
+ </button>
366
+ <ul className="flex max-h-64 flex-col gap-1 overflow-y-auto text-xs md:max-h-none">
367
+ {sessions.map((s) => (
368
+ <li key={s.id} className="flex items-start gap-1">
369
+ <button
370
+ type="button"
371
+ onClick={() => setActiveId(s.id)}
372
+ className={`min-w-0 flex-1 rounded px-2 py-1 text-left hover:bg-neutral-100 dark:hover:bg-neutral-900 ${
373
+ activeId === s.id
374
+ ? "bg-neutral-100 font-medium dark:bg-neutral-900"
375
+ : ""
376
+ }`}
377
+ >
378
+ <span className="line-clamp-2 break-all">
379
+ {s.title?.trim() || "Untitled"}
380
+ </span>
381
+ </button>
382
+ <button
383
+ type="button"
384
+ aria-label="Delete chat"
385
+ onClick={() => void removeSession(s.id)}
386
+ className="shrink-0 rounded px-1 text-red-600 hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-950/40"
387
+ >
388
+ ×
389
+ </button>
390
+ </li>
391
+ ))}
392
+ </ul>
393
+ </aside>
394
+
395
+ <div className="flex min-h-0 flex-col gap-3">
396
+ <section className="flex min-h-0 flex-1 flex-col rounded-lg border border-neutral-200 bg-white/60 dark:border-neutral-800 dark:bg-neutral-950/40">
397
+ <div className="border-b border-neutral-200 px-3 py-2 text-xs font-medium text-neutral-500 dark:border-neutral-800">
398
+ {activeId ? `Session ${activeId.slice(0, 8)}…` : "Select or start a chat"}
399
+ </div>
400
+ {messagesError ? (
401
+ <p className="p-3 text-sm text-amber-800 dark:text-amber-200">
402
+ {messagesError}
403
+ </p>
404
+ ) : null}
405
+ <div className="flex-1 space-y-3 overflow-y-auto p-3 text-sm">
406
+ {!activeId ? (
407
+ <p className="text-neutral-500">Choose a session on the left.</p>
408
+ ) : messages.length === 0 ? (
409
+ <p className="text-neutral-500">No messages yet.</p>
410
+ ) : (
411
+ messages.map((m) => (
412
+ <div
413
+ key={m.id}
414
+ className={`rounded-lg px-3 py-2 ${
415
+ m.role === "user"
416
+ ? "ml-8 bg-neutral-100 dark:bg-neutral-900"
417
+ : "mr-8 border border-neutral-200 dark:border-neutral-800"
418
+ }`}
419
+ >
420
+ <p className="text-[10px] uppercase tracking-wide text-neutral-400">
421
+ {m.role}
422
+ </p>
423
+ <p className="whitespace-pre-wrap text-neutral-900 dark:text-neutral-100">
424
+ {m.content}
425
+ </p>
426
+ </div>
427
+ ))
428
+ )}
429
+ </div>
430
+ <div className="border-t border-neutral-200 p-3 dark:border-neutral-800">
431
+ <div className="flex gap-2">
432
+ <textarea
433
+ className="min-h-[4rem] flex-1 resize-y rounded-md border border-neutral-300 bg-white px-3 py-2 text-sm dark:border-neutral-700 dark:bg-neutral-950"
434
+ placeholder={
435
+ activeId
436
+ ? "Ask something (uses RAG + Gemini)…"
437
+ : "Start a chat first…"
438
+ }
439
+ value={input}
440
+ disabled={!activeId || sendBusy}
441
+ onChange={(e) => setInput(e.target.value)}
442
+ onKeyDown={(e) => {
443
+ if (e.key === "Enter" && !e.shiftKey) {
444
+ e.preventDefault();
445
+ void sendMessage();
446
+ }
447
+ }}
448
+ />
449
+ <button
450
+ type="button"
451
+ disabled={!activeId || sendBusy || !input.trim()}
452
+ onClick={() => void sendMessage()}
453
+ className="h-fit self-end rounded-md bg-neutral-900 px-4 py-2 text-sm font-medium text-white disabled:opacity-40 dark:bg-neutral-100 dark:text-neutral-900"
454
+ >
455
+ {sendBusy ? "…" : "Send"}
456
+ </button>
457
+ </div>
458
+ </div>
459
+ </section>
460
+
461
+ <section className="rounded-lg border border-neutral-200 bg-white/60 p-3 dark:border-neutral-800 dark:bg-neutral-950/40">
462
+ <h2 className="text-sm font-medium text-neutral-700 dark:text-neutral-300">
463
+ Ingest text
464
+ </h2>
465
+ <p className="mt-1 text-xs text-neutral-500">
466
+ Adds a document via{" "}
467
+ <code className="rounded bg-neutral-100 px-1 dark:bg-neutral-900">
468
+ POST /ingest/raw
469
+ </code>
470
+ . Optional embed queues chunks for vector search used in chat.
471
+ </p>
472
+ <textarea
473
+ className="mt-2 min-h-[5rem] w-full resize-y rounded-md border border-neutral-300 bg-white px-3 py-2 text-sm dark:border-neutral-700 dark:bg-neutral-950"
474
+ placeholder="Paste notes, snippets, or knowledge to store…"
475
+ value={ingestText}
476
+ disabled={ingestBusy}
477
+ onChange={(e) => setIngestText(e.target.value)}
478
+ />
479
+ <label className="mt-2 flex cursor-pointer items-center gap-2 text-xs text-neutral-600 dark:text-neutral-400">
480
+ <input
481
+ type="checkbox"
482
+ checked={embedAfterIngest}
483
+ disabled={ingestBusy}
484
+ onChange={(e) => setEmbedAfterIngest(e.target.checked)}
485
+ />
486
+ Queue embedding after ingest (recommended for RAG)
487
+ </label>
488
+ <button
489
+ type="button"
490
+ disabled={ingestBusy || !ingestText.trim()}
491
+ onClick={() => void runIngest()}
492
+ className="mt-2 rounded-md border border-neutral-300 bg-white px-4 py-2 text-sm font-medium hover:bg-neutral-50 disabled:opacity-40 dark:border-neutral-700 dark:bg-neutral-950 dark:hover:bg-neutral-900"
493
+ >
494
+ {ingestBusy ? "Working…" : "Ingest"}
495
+ </button>
496
+ {ingestNote ? (
497
+ <p className="mt-2 text-xs text-neutral-700 dark:text-neutral-300">
498
+ {ingestNote}
499
+ </p>
500
+ ) : null}
501
+ </section>
502
+
503
+ <section className="rounded-lg border border-neutral-200 bg-white/60 p-3 dark:border-neutral-800 dark:bg-neutral-950/40">
504
+ <h2 className="text-sm font-medium text-neutral-700 dark:text-neutral-300">
505
+ Embedding pipeline (debug)
506
+ </h2>
507
+ <p className="mt-1 text-xs text-neutral-500">
508
+ Live view of ingest → blocks → chunks → Gemini + sqlite-vec. Uses{" "}
509
+ <code className="rounded bg-neutral-100 px-1 dark:bg-neutral-900">
510
+ GET /ingest/documents/&#123;id&#125;/pipeline
511
+ </code>
512
+ .
513
+ </p>
514
+ <div className="mt-2 flex flex-wrap items-end gap-2">
515
+ <label className="flex min-w-[12rem] flex-1 flex-col gap-1 text-xs text-neutral-600 dark:text-neutral-400">
516
+ Document ID
517
+ <input
518
+ className="rounded-md border border-neutral-300 bg-white px-2 py-1.5 font-mono text-xs dark:border-neutral-700 dark:bg-neutral-950"
519
+ value={traceDocId}
520
+ onChange={(e) => setTraceDocId(e.target.value)}
521
+ placeholder="Paste UUID after ingest…"
522
+ />
523
+ </label>
524
+ <button
525
+ type="button"
526
+ disabled={pipelineBusy || !traceDocId.trim()}
527
+ onClick={() => void fetchPipeline(traceDocId)}
528
+ className="rounded-md border border-neutral-300 bg-white px-3 py-1.5 text-xs font-medium hover:bg-neutral-50 disabled:opacity-40 dark:border-neutral-700 dark:bg-neutral-950 dark:hover:bg-neutral-900"
529
+ >
530
+ {pipelineBusy ? "Loading…" : "Refresh"}
531
+ </button>
532
+ <label className="flex cursor-pointer items-center gap-2 text-xs text-neutral-600 dark:text-neutral-400">
533
+ <input
534
+ type="checkbox"
535
+ checked={pipelineAutoRefresh}
536
+ onChange={(e) => setPipelineAutoRefresh(e.target.checked)}
537
+ />
538
+ Auto-refresh (2.5s)
539
+ </label>
540
+ </div>
541
+ {pipelineError ? (
542
+ <p className="mt-2 text-xs text-red-700 dark:text-red-300">
543
+ {pipelineError}
544
+ </p>
545
+ ) : null}
546
+ {pipeline ? (
547
+ <div className="mt-3 space-y-3">
548
+ <div className="flex flex-wrap gap-2">
549
+ {pipeline.steps.map((step, i) => {
550
+ const ring =
551
+ step.state === "ok"
552
+ ? "border-emerald-500/80 bg-emerald-50 dark:bg-emerald-950/30"
553
+ : step.state === "warn"
554
+ ? "border-amber-500/80 bg-amber-50 dark:bg-amber-950/30"
555
+ : step.state === "error"
556
+ ? "border-red-500/80 bg-red-50 dark:bg-red-950/30"
557
+ : "border-dashed border-neutral-400 bg-neutral-50 dark:border-neutral-600 dark:bg-neutral-900/40";
558
+ return (
559
+ <div key={step.id} className="flex items-stretch gap-2">
560
+ {i > 0 ? (
561
+ <div
562
+ className="hidden w-4 self-center border-t border-dotted border-neutral-300 sm:block dark:border-neutral-600"
563
+ aria-hidden
564
+ />
565
+ ) : null}
566
+ <div
567
+ className={`max-w-[11rem] rounded-lg border px-2.5 py-2 text-xs ${ring}`}
568
+ >
569
+ <p className="font-medium text-neutral-800 dark:text-neutral-100">
570
+ {step.label}
571
+ </p>
572
+ <p className="mt-0.5 text-[10px] uppercase tracking-wide text-neutral-500">
573
+ {step.state}
574
+ </p>
575
+ {step.detail ? (
576
+ <p className="mt-1 break-words text-[11px] leading-snug text-neutral-600 dark:text-neutral-400">
577
+ {step.detail}
578
+ </p>
579
+ ) : null}
580
+ </div>
581
+ </div>
582
+ );
583
+ })}
584
+ </div>
585
+ <dl className="grid grid-cols-2 gap-x-4 gap-y-1 text-xs sm:grid-cols-4">
586
+ <div>
587
+ <dt className="text-neutral-500">Status</dt>
588
+ <dd className="font-mono text-neutral-800 dark:text-neutral-200">
589
+ {pipeline.status}
590
+ </dd>
591
+ </div>
592
+ <div>
593
+ <dt className="text-neutral-500">Blocks</dt>
594
+ <dd className="font-mono text-neutral-800 dark:text-neutral-200">
595
+ {pipeline.content_block_count}
596
+ </dd>
597
+ </div>
598
+ <div>
599
+ <dt className="text-neutral-500">Chunks</dt>
600
+ <dd className="font-mono text-neutral-800 dark:text-neutral-200">
601
+ {pipeline.chunk_count}
602
+ </dd>
603
+ </div>
604
+ <div>
605
+ <dt className="text-neutral-500">Vector rows</dt>
606
+ <dd className="font-mono text-neutral-800 dark:text-neutral-200">
607
+ {pipeline.vector_row_count} / {pipeline.gemini_embedding_row_count}{" "}
608
+ vec / gemini
609
+ </dd>
610
+ </div>
611
+ </dl>
612
+ <p className="text-[10px] text-neutral-400">
613
+ Checked {pipeline.checked_at}
614
+ </p>
615
+ {pipeline.dlq ? (
616
+ <div className="rounded-md border border-red-200 bg-red-50/80 p-2 text-xs text-red-900 dark:border-red-900 dark:bg-red-950/40 dark:text-red-100">
617
+ <p className="font-medium">Embedding DLQ</p>
618
+ <p className="mt-1 font-mono text-[11px]">
619
+ {pipeline.dlq.state} · attempts {pipeline.dlq.attempt_count}
620
+ </p>
621
+ {pipeline.dlq.next_retry_at ? (
622
+ <p className="mt-1 text-[11px]">
623
+ Next retry: {pipeline.dlq.next_retry_at}
624
+ </p>
625
+ ) : null}
626
+ <pre className="mt-2 max-h-24 overflow-auto whitespace-pre-wrap break-words text-[11px] opacity-90">
627
+ {pipeline.dlq.last_error}
628
+ </pre>
629
+ </div>
630
+ ) : null}
631
+ {(typeof pipeline.ingest_meta.embedding_error === "string"
632
+ ? pipeline.ingest_meta.embedding_error
633
+ : null) ? (
634
+ <div className="rounded-md border border-amber-200 bg-amber-50/80 p-2 text-xs text-amber-950 dark:border-amber-900 dark:bg-amber-950/40 dark:text-amber-100">
635
+ <p className="font-medium">ingest_meta.embedding_error</p>
636
+ <pre className="mt-1 max-h-20 overflow-auto whitespace-pre-wrap break-words text-[11px]">
637
+ {String(pipeline.ingest_meta.embedding_error)}
638
+ </pre>
639
+ </div>
640
+ ) : null}
641
+ <div className="flex flex-wrap gap-2">
642
+ <button
643
+ type="button"
644
+ disabled={pipelineActionsBusy || !traceDocId.trim()}
645
+ onClick={async () => {
646
+ setPipelineActionsBusy(true);
647
+ const r = await backendJson<{ chunks_written: number }>(
648
+ `/ingest/documents/${encodeURIComponent(traceDocId.trim())}/chunks`,
649
+ {
650
+ method: "POST",
651
+ body: JSON.stringify({
652
+ use_llm_weak_structure: false,
653
+ }),
654
+ },
655
+ );
656
+ setPipelineActionsBusy(false);
657
+ if (!r.ok) {
658
+ setPipelineError(r.message);
659
+ return;
660
+ }
661
+ void fetchPipeline(traceDocId);
662
+ }}
663
+ className="rounded-md border border-neutral-300 bg-white px-3 py-1.5 text-xs font-medium hover:bg-neutral-50 disabled:opacity-40 dark:border-neutral-700 dark:bg-neutral-950 dark:hover:bg-neutral-900"
664
+ >
665
+ Rebuild chunks
666
+ </button>
667
+ <button
668
+ type="button"
669
+ disabled={pipelineActionsBusy || !traceDocId.trim()}
670
+ onClick={async () => {
671
+ setPipelineActionsBusy(true);
672
+ const r = await backendJson<{ accepted: boolean }>(
673
+ `/admin/documents/${encodeURIComponent(traceDocId.trim())}/retry-embedding`,
674
+ {
675
+ method: "POST",
676
+ body: JSON.stringify({ multimodal: false }),
677
+ },
678
+ );
679
+ setPipelineActionsBusy(false);
680
+ if (!r.ok) {
681
+ setPipelineError(r.message);
682
+ return;
683
+ }
684
+ void fetchPipeline(traceDocId);
685
+ }}
686
+ className="rounded-md border border-neutral-300 bg-white px-3 py-1.5 text-xs font-medium hover:bg-neutral-50 disabled:opacity-40 dark:border-neutral-700 dark:bg-neutral-950 dark:hover:bg-neutral-900"
687
+ >
688
+ Retry embedding (admin)
689
+ </button>
690
+ <button
691
+ type="button"
692
+ disabled={pipelineActionsBusy || !traceDocId.trim()}
693
+ onClick={async () => {
694
+ setPipelineActionsBusy(true);
695
+ const r = await backendJson<{ accepted: boolean }>(
696
+ `/ingest/documents/${encodeURIComponent(traceDocId.trim())}/embed`,
697
+ {
698
+ method: "POST",
699
+ body: JSON.stringify({ multimodal: false }),
700
+ },
701
+ );
702
+ setPipelineActionsBusy(false);
703
+ if (!r.ok) {
704
+ setPipelineError(r.message);
705
+ return;
706
+ }
707
+ void fetchPipeline(traceDocId);
708
+ }}
709
+ className="rounded-md border border-neutral-300 bg-white px-3 py-1.5 text-xs font-medium hover:bg-neutral-50 disabled:opacity-40 dark:border-neutral-700 dark:bg-neutral-950 dark:hover:bg-neutral-900"
710
+ >
711
+ Queue embed again
712
+ </button>
713
+ </div>
714
+ </div>
715
+ ) : (
716
+ <p className="mt-2 text-xs text-neutral-500">
717
+ Enter a document id and refresh to load pipeline state.
718
+ </p>
719
+ )}
720
+ </section>
721
+ </div>
722
+ </div>
723
+ </main>
724
+ );
725
+ }