@hiai-gg/hiai-docs 0.0.1

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 (216) hide show
  1. package/.all-contributorsrc +18 -0
  2. package/.claude/settings.local.json +61 -0
  3. package/.dockerignore +113 -0
  4. package/.env.example +68 -0
  5. package/.github/FUNDING.yml +5 -0
  6. package/.github/ISSUE_TEMPLATE/bug_report.md +74 -0
  7. package/.github/ISSUE_TEMPLATE/feature_request.md +78 -0
  8. package/.github/dependabot.yml +136 -0
  9. package/.github/pull_request_template.md +96 -0
  10. package/.github/workflows/ci.yml +283 -0
  11. package/AGENTS.md +237 -0
  12. package/CODE_OF_CONDUCT.md +134 -0
  13. package/CONTRIBUTING.md +77 -0
  14. package/Caddyfile +50 -0
  15. package/Dockerfile.backend +60 -0
  16. package/LICENSE +21 -0
  17. package/README.md +284 -0
  18. package/RELEASE_CHECKLIST.md +34 -0
  19. package/SECURITY.md +60 -0
  20. package/backend/package.json +43 -0
  21. package/backend/src/__tests__/auth-helpers.test.ts +51 -0
  22. package/backend/src/__tests__/chunker.test.ts +65 -0
  23. package/backend/src/__tests__/config.test.ts +91 -0
  24. package/backend/src/__tests__/csrf.test.ts +91 -0
  25. package/backend/src/__tests__/embedding.test.ts +48 -0
  26. package/backend/src/__tests__/rate-limit.test.ts +46 -0
  27. package/backend/src/__tests__/routes.test.ts +38 -0
  28. package/backend/src/__tests__/schema.test.ts +31 -0
  29. package/backend/src/__tests__/validation.test.ts +556 -0
  30. package/backend/src/api/middleware/auth.ts +56 -0
  31. package/backend/src/api/middleware/csrf.ts +91 -0
  32. package/backend/src/api/middleware/rate-limit.ts +77 -0
  33. package/backend/src/api/middleware/webhook-verify.ts +22 -0
  34. package/backend/src/api/routes/attachments.ts +280 -0
  35. package/backend/src/api/routes/auth.ts +52 -0
  36. package/backend/src/api/routes/collaboration.ts +121 -0
  37. package/backend/src/api/routes/documents.ts +664 -0
  38. package/backend/src/api/routes/folders.ts +226 -0
  39. package/backend/src/api/routes/search.ts +354 -0
  40. package/backend/src/api/routes/share.ts +512 -0
  41. package/backend/src/api/routes/tags.ts +247 -0
  42. package/backend/src/api/routes/versions.ts +99 -0
  43. package/backend/src/api/routes/webhooks.ts +43 -0
  44. package/backend/src/embedding/chunker.ts +74 -0
  45. package/backend/src/embedding/index.ts +117 -0
  46. package/backend/src/embedding/providers/ollama.ts +63 -0
  47. package/backend/src/embedding/providers/openrouter.ts +71 -0
  48. package/backend/src/embedding/utils.ts +13 -0
  49. package/backend/src/embedding/worker.ts +89 -0
  50. package/backend/src/index.ts +147 -0
  51. package/backend/src/lib/auth-helpers.ts +27 -0
  52. package/backend/src/lib/auth.ts +35 -0
  53. package/backend/src/lib/config.ts +73 -0
  54. package/backend/src/lib/db.ts +7 -0
  55. package/backend/src/lib/embedding-queue.ts +12 -0
  56. package/backend/src/lib/logger.ts +18 -0
  57. package/backend/src/lib/markdown-to-doc.ts +45 -0
  58. package/backend/src/lib/minio.ts +46 -0
  59. package/backend/src/lib/redis.ts +19 -0
  60. package/backend/src/lib/yjs-provider.ts +182 -0
  61. package/backend/tests/integration/_harness.ts +754 -0
  62. package/backend/tests/integration/auth.test.ts +296 -0
  63. package/backend/tests/integration/routes.documents.test.ts +459 -0
  64. package/backend/tests/integration/routes.folders.test.ts +337 -0
  65. package/backend/tests/integration/routes.search.test.ts +322 -0
  66. package/backend/tests/integration/routes.share.test.ts +773 -0
  67. package/backend/tests/integration/routes.tags.test.ts +425 -0
  68. package/backend/tests/integration/routes.versions.test.ts +233 -0
  69. package/backend/tsconfig.json +18 -0
  70. package/docker-compose.yml +218 -0
  71. package/docs/API.md +328 -0
  72. package/docs/ARCHITECTURE.md +75 -0
  73. package/docs/DEPLOYMENT.md +113 -0
  74. package/docs/PRODUCTION_STATUS.md +61 -0
  75. package/docs/openapi.json +385 -0
  76. package/frontend/.svelte-kit.old/ambient.d.ts +230 -0
  77. package/frontend/.svelte-kit.old/env.d.ts +1 -0
  78. package/frontend/.svelte-kit.old/generated/client/app.js +46 -0
  79. package/frontend/.svelte-kit.old/generated/client/matchers.js +1 -0
  80. package/frontend/.svelte-kit.old/generated/client/nodes/0.js +3 -0
  81. package/frontend/.svelte-kit.old/generated/client/nodes/1.js +1 -0
  82. package/frontend/.svelte-kit.old/generated/client/nodes/10.js +3 -0
  83. package/frontend/.svelte-kit.old/generated/client/nodes/2.js +1 -0
  84. package/frontend/.svelte-kit.old/generated/client/nodes/3.js +1 -0
  85. package/frontend/.svelte-kit.old/generated/client/nodes/4.js +1 -0
  86. package/frontend/.svelte-kit.old/generated/client/nodes/5.js +3 -0
  87. package/frontend/.svelte-kit.old/generated/client/nodes/6.js +1 -0
  88. package/frontend/.svelte-kit.old/generated/client/nodes/7.js +3 -0
  89. package/frontend/.svelte-kit.old/generated/client/nodes/8.js +1 -0
  90. package/frontend/.svelte-kit.old/generated/client/nodes/9.js +3 -0
  91. package/frontend/.svelte-kit.old/generated/root.js +3 -0
  92. package/frontend/.svelte-kit.old/generated/root.svelte +80 -0
  93. package/frontend/.svelte-kit.old/generated/server/internal.js +55 -0
  94. package/frontend/.svelte-kit.old/non-ambient.d.ts +59 -0
  95. package/frontend/.svelte-kit.old/tsconfig.json +59 -0
  96. package/frontend/.svelte-kit.old/types/route_meta_data.json +40 -0
  97. package/frontend/.svelte-kit.old/types/src/routes/$types.d.ts +21 -0
  98. package/frontend/.svelte-kit.old/types/src/routes/(app)/$types.d.ts +30 -0
  99. package/frontend/.svelte-kit.old/types/src/routes/(app)/docs/[id]/$types.d.ts +27 -0
  100. package/frontend/.svelte-kit.old/types/src/routes/(app)/docs/[id]/proxy+page.ts +25 -0
  101. package/frontend/.svelte-kit.old/types/src/routes/api/[...path]/$types.d.ts +10 -0
  102. package/frontend/.svelte-kit.old/types/src/routes/folders/[id]/$types.d.ts +27 -0
  103. package/frontend/.svelte-kit.old/types/src/routes/folders/[id]/proxy+page.ts +15 -0
  104. package/frontend/.svelte-kit.old/types/src/routes/login/$types.d.ts +17 -0
  105. package/frontend/.svelte-kit.old/types/src/routes/register/$types.d.ts +17 -0
  106. package/frontend/.svelte-kit.old/types/src/routes/s/[token]/$types.d.ts +20 -0
  107. package/frontend/.svelte-kit.old/types/src/routes/s/[token]/proxy+page.ts +6 -0
  108. package/frontend/.svelte-kit.old/types/src/routes/search/$types.d.ts +19 -0
  109. package/frontend/.svelte-kit.old/types/src/routes/search/proxy+page.ts +26 -0
  110. package/frontend/.svelte-kit.old/types/src/routes/settings/$types.d.ts +17 -0
  111. package/frontend/Dockerfile +44 -0
  112. package/frontend/biome.json +40 -0
  113. package/frontend/components.json +18 -0
  114. package/frontend/messages/en.json +434 -0
  115. package/frontend/package.json +70 -0
  116. package/frontend/project.inlang/settings.json +12 -0
  117. package/frontend/src/app.css +6 -0
  118. package/frontend/src/app.d.ts +13 -0
  119. package/frontend/src/app.html +30 -0
  120. package/frontend/src/hooks.server.ts +10 -0
  121. package/frontend/src/hooks.ts +10 -0
  122. package/frontend/src/lib/api/attachments.ts +45 -0
  123. package/frontend/src/lib/api/client.test.ts +15 -0
  124. package/frontend/src/lib/api/client.ts +57 -0
  125. package/frontend/src/lib/api/documents.ts +83 -0
  126. package/frontend/src/lib/api/folders.ts +180 -0
  127. package/frontend/src/lib/api/search.test.ts +52 -0
  128. package/frontend/src/lib/api/search.ts +128 -0
  129. package/frontend/src/lib/api/settings.ts +95 -0
  130. package/frontend/src/lib/api/share.ts +71 -0
  131. package/frontend/src/lib/api/tags.test.ts +91 -0
  132. package/frontend/src/lib/api/tags.ts +87 -0
  133. package/frontend/src/lib/auth-client.ts +10 -0
  134. package/frontend/src/lib/collaboration.ts +63 -0
  135. package/frontend/src/lib/components/AttachmentUpload.svelte +110 -0
  136. package/frontend/src/lib/components/DatePicker.svelte +322 -0
  137. package/frontend/src/lib/components/DocumentCard.svelte +166 -0
  138. package/frontend/src/lib/components/EmptyState.svelte +49 -0
  139. package/frontend/src/lib/components/FolderCard.svelte +93 -0
  140. package/frontend/src/lib/components/ScrollToTop.svelte +72 -0
  141. package/frontend/src/lib/components/SearchBar.svelte +47 -0
  142. package/frontend/src/lib/components/SearchResult.svelte +115 -0
  143. package/frontend/src/lib/components/SettingsDialog.svelte +271 -0
  144. package/frontend/src/lib/components/ShareDialog.svelte +158 -0
  145. package/frontend/src/lib/components/ShareLink.svelte +98 -0
  146. package/frontend/src/lib/components/TagCreateDialog.svelte +284 -0
  147. package/frontend/src/lib/components/VersionDiff.svelte +55 -0
  148. package/frontend/src/lib/components/VersionHistory.svelte +96 -0
  149. package/frontend/src/lib/components/editor/DocumentTitle.svelte +87 -0
  150. package/frontend/src/lib/components/editor/EditorToolbar.svelte +1367 -0
  151. package/frontend/src/lib/components/editor/HiAiEditor.svelte +531 -0
  152. package/frontend/src/lib/components/editor/LinkDialog.svelte +134 -0
  153. package/frontend/src/lib/components/editor/MarkdownToggle.svelte +88 -0
  154. package/frontend/src/lib/components/editor/editorExtensions.ts +53 -0
  155. package/frontend/src/lib/components/editor/markdown.ts +38 -0
  156. package/frontend/src/lib/components/sidebar/FolderTree.svelte +731 -0
  157. package/frontend/src/lib/components/sidebar/RecentDocs.svelte +311 -0
  158. package/frontend/src/lib/components/sidebar/Sidebar.svelte +156 -0
  159. package/frontend/src/lib/components/sidebar/TagList.svelte +200 -0
  160. package/frontend/src/lib/components/ui/confirm-dialog/ConfirmDialog.svelte +76 -0
  161. package/frontend/src/lib/components/ui/confirm-dialog/index.ts +1 -0
  162. package/frontend/src/lib/stores/tag-store.svelte.ts +56 -0
  163. package/frontend/src/lib/stores/theme.svelte.ts +97 -0
  164. package/frontend/src/lib/svelte.d.ts +6 -0
  165. package/frontend/src/lib/types.ts +44 -0
  166. package/frontend/src/lib/utils/clipboard.ts +17 -0
  167. package/frontend/src/lib/utils/strip-markdown.ts +59 -0
  168. package/frontend/src/lib/utils.ts +33 -0
  169. package/frontend/src/routes/(app)/+layout.svelte +17 -0
  170. package/frontend/src/routes/(app)/+page.server.ts +10 -0
  171. package/frontend/src/routes/(app)/+page.svelte +303 -0
  172. package/frontend/src/routes/(app)/docs/[id]/+page.server.ts +10 -0
  173. package/frontend/src/routes/(app)/docs/[id]/+page.svelte +1108 -0
  174. package/frontend/src/routes/(app)/docs/[id]/+page.ts +24 -0
  175. package/frontend/src/routes/(app)/search/+page.svelte +593 -0
  176. package/frontend/src/routes/(app)/search/+page.ts +25 -0
  177. package/frontend/src/routes/+error.svelte +12 -0
  178. package/frontend/src/routes/+layout.svelte +18 -0
  179. package/frontend/src/routes/+layout.ts +2 -0
  180. package/frontend/src/routes/api/[...path]/+server.ts +111 -0
  181. package/frontend/src/routes/folders/[id]/+page.server.ts +10 -0
  182. package/frontend/src/routes/folders/[id]/+page.svelte +319 -0
  183. package/frontend/src/routes/folders/[id]/+page.ts +14 -0
  184. package/frontend/src/routes/login/+page.svelte +90 -0
  185. package/frontend/src/routes/register/+page.svelte +97 -0
  186. package/frontend/src/routes/s/[token]/+page.svelte +496 -0
  187. package/frontend/src/routes/s/[token]/+page.ts +5 -0
  188. package/frontend/src/routes/settings/+page.svelte +175 -0
  189. package/frontend/static/favicon.png +0 -0
  190. package/frontend/static/logo.png +0 -0
  191. package/frontend/svelte.config.js +15 -0
  192. package/frontend/tsconfig.json +15 -0
  193. package/frontend/vite.config.ts +25 -0
  194. package/init.sql +9 -0
  195. package/logo.png +0 -0
  196. package/package.json +39 -0
  197. package/package.public.json +39 -0
  198. package/packages/db/drizzle.config.ts +10 -0
  199. package/packages/db/package.json +30 -0
  200. package/packages/db/src/client.ts +9 -0
  201. package/packages/db/src/index.ts +2 -0
  202. package/packages/db/src/migrations/0000_nice_bedlam.sql +165 -0
  203. package/packages/db/src/migrations/0001_w2_3_test.sql +5 -0
  204. package/packages/db/src/migrations/0002_rename_content_json.sql +2 -0
  205. package/packages/db/src/migrations/meta/0000_snapshot.json +1331 -0
  206. package/packages/db/src/migrations/meta/0001_snapshot.json +1399 -0
  207. package/packages/db/src/migrations/meta/0002_snapshot.json +1399 -0
  208. package/packages/db/src/migrations/meta/_journal.json +27 -0
  209. package/packages/db/src/schema.ts +378 -0
  210. package/packages/db/tsconfig.json +17 -0
  211. package/scripts/export-openapi.ts +37 -0
  212. package/scripts/health-check.sh +75 -0
  213. package/scripts/migrate.sh +135 -0
  214. package/scripts/prework_backup.sh +25 -0
  215. package/scripts/release.sh +83 -0
  216. package/tsconfig.json +25 -0
@@ -0,0 +1,247 @@
1
+ import { documents, documentTags, tags } from "@hiai-docs/db/schema";
2
+ import { and, count, eq } from "drizzle-orm";
3
+ import { Elysia } from "elysia";
4
+ import { z } from "zod";
5
+ import { getSessionUserId } from "../../lib/auth-helpers";
6
+ import { db } from "../../lib/db";
7
+ import { logger } from "../../lib/logger";
8
+ import { writeRateLimiter } from "../middleware/rate-limit";
9
+
10
+ const createTagSchema = z.object({
11
+ name: z.string().min(1).max(100),
12
+ color: z.string().max(20).optional(),
13
+ });
14
+
15
+ const updateTagSchema = z.object({
16
+ name: z.string().min(1).max(100).optional(),
17
+ color: z.string().max(20).optional(),
18
+ });
19
+
20
+ const addTagToDocSchema = z.object({
21
+ tagId: z.string().uuid(),
22
+ });
23
+
24
+ export const tagRoutes = new Elysia({ prefix: "/api" })
25
+ .get("/tags", async ({ set, request }) => {
26
+ const userId = await getSessionUserId(request.headers);
27
+ if (!userId) {
28
+ set.status = 401;
29
+ return { error: "Unauthorized" };
30
+ }
31
+ try {
32
+ const rows = await db
33
+ .select({
34
+ id: tags.id,
35
+ name: tags.name,
36
+ color: tags.color,
37
+ createdAt: tags.createdAt,
38
+ documentCount: count(documentTags.documentId),
39
+ })
40
+ .from(tags)
41
+ .leftJoin(documentTags, eq(tags.id, documentTags.tagId))
42
+ .where(eq(tags.ownerId, userId))
43
+ .groupBy(tags.id, tags.name, tags.color, tags.createdAt)
44
+ .orderBy(tags.name);
45
+ return rows;
46
+ } catch (err) {
47
+ logger.error({ err }, "Failed to list tags");
48
+ set.status = 500;
49
+ return { error: "Failed to list tags" };
50
+ }
51
+ })
52
+ .post("/tags", async ({ request, set }) => {
53
+ const ip =
54
+ request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ??
55
+ request.headers.get("x-real-ip") ??
56
+ "unknown";
57
+ const rl = await writeRateLimiter(ip);
58
+ if (!rl.allowed) {
59
+ set.status = 429;
60
+ return { error: "Rate limited" };
61
+ }
62
+ const userId = await getSessionUserId(request.headers);
63
+ if (!userId) {
64
+ set.status = 401;
65
+ return { error: "Unauthorized" };
66
+ }
67
+ const body = createTagSchema.safeParse(await request.json());
68
+ if (!body.success) {
69
+ set.status = 400;
70
+ return { error: "Invalid input", details: body.error.flatten() };
71
+ }
72
+ try {
73
+ const existing = await db
74
+ .select({ id: tags.id })
75
+ .from(tags)
76
+ .where(and(eq(tags.ownerId, userId), eq(tags.name, body.data.name)))
77
+ .limit(1);
78
+ if (existing.length > 0) {
79
+ set.status = 409;
80
+ return { error: "Tag with this name already exists" };
81
+ }
82
+ const [created] = await db
83
+ .insert(tags)
84
+ .values({
85
+ ownerId: userId,
86
+ name: body.data.name,
87
+ color: body.data.color ?? null,
88
+ })
89
+ .returning();
90
+ set.status = 201;
91
+ return created;
92
+ } catch (err) {
93
+ logger.error({ err }, "Failed to create tag");
94
+ set.status = 500;
95
+ return { error: "Failed to create tag" };
96
+ }
97
+ })
98
+ .patch("/tags/:id", async ({ params, request, set }) => {
99
+ const ip =
100
+ request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ??
101
+ request.headers.get("x-real-ip") ??
102
+ "unknown";
103
+ const rl = await writeRateLimiter(ip);
104
+ if (!rl.allowed) {
105
+ set.status = 429;
106
+ return { error: "Rate limited" };
107
+ }
108
+ const userId = await getSessionUserId(request.headers);
109
+ if (!userId) {
110
+ set.status = 401;
111
+ return { error: "Unauthorized" };
112
+ }
113
+ const body = updateTagSchema.safeParse(await request.json());
114
+ if (!body.success) {
115
+ set.status = 400;
116
+ return { error: "Invalid input", details: body.error.flatten() };
117
+ }
118
+ try {
119
+ const [updated] = await db
120
+ .update(tags)
121
+ .set({
122
+ ...(body.data.name !== undefined && { name: body.data.name }),
123
+ ...(body.data.color !== undefined && { color: body.data.color }),
124
+ })
125
+ .where(and(eq(tags.id, params.id), eq(tags.ownerId, userId)))
126
+ .returning();
127
+ if (!updated) {
128
+ set.status = 404;
129
+ return { error: "Tag not found" };
130
+ }
131
+ return updated;
132
+ } catch (err) {
133
+ logger.error({ err }, "Failed to update tag");
134
+ set.status = 500;
135
+ return { error: "Failed to update tag" };
136
+ }
137
+ })
138
+ .delete("/tags/:id", async ({ params, set, request }) => {
139
+ const ip =
140
+ request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ??
141
+ request.headers.get("x-real-ip") ??
142
+ "unknown";
143
+ const rl = await writeRateLimiter(ip);
144
+ if (!rl.allowed) {
145
+ set.status = 429;
146
+ return { error: "Rate limited" };
147
+ }
148
+ const userId = await getSessionUserId(request.headers);
149
+ if (!userId) {
150
+ set.status = 401;
151
+ return { error: "Unauthorized" };
152
+ }
153
+ try {
154
+ await db
155
+ .delete(tags)
156
+ .where(and(eq(tags.id, params.id), eq(tags.ownerId, userId)));
157
+ return { success: true };
158
+ } catch (err) {
159
+ logger.error({ err }, "Failed to delete tag");
160
+ set.status = 500;
161
+ return { error: "Failed to delete tag" };
162
+ }
163
+ })
164
+ .post("/documents/:id/tags", async ({ params, request, set }) => {
165
+ const ip =
166
+ request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ??
167
+ request.headers.get("x-real-ip") ??
168
+ "unknown";
169
+ const rl = await writeRateLimiter(ip);
170
+ if (!rl.allowed) {
171
+ set.status = 429;
172
+ return { error: "Rate limited" };
173
+ }
174
+ const userId = await getSessionUserId(request.headers);
175
+ if (!userId) {
176
+ set.status = 401;
177
+ return { error: "Unauthorized" };
178
+ }
179
+ const body = addTagToDocSchema.safeParse(await request.json());
180
+ if (!body.success) {
181
+ set.status = 400;
182
+ return { error: "Invalid input" };
183
+ }
184
+ try {
185
+ const [doc] = await db
186
+ .select({ id: documents.id })
187
+ .from(documents)
188
+ .where(and(eq(documents.id, params.id), eq(documents.ownerId, userId)))
189
+ .limit(1);
190
+ if (!doc) {
191
+ set.status = 404;
192
+ return { error: "Document not found" };
193
+ }
194
+
195
+ await db.insert(documentTags).values({
196
+ documentId: params.id,
197
+ tagId: body.data.tagId,
198
+ });
199
+ set.status = 201;
200
+ return { success: true };
201
+ } catch (err) {
202
+ logger.error({ err }, "Failed to add tag to document");
203
+ set.status = 500;
204
+ return { error: "Failed to add tag to document" };
205
+ }
206
+ })
207
+ .delete("/documents/:id/tags/:tagId", async ({ params, set, request }) => {
208
+ const ip =
209
+ request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ??
210
+ request.headers.get("x-real-ip") ??
211
+ "unknown";
212
+ const rl = await writeRateLimiter(ip);
213
+ if (!rl.allowed) {
214
+ set.status = 429;
215
+ return { error: "Rate limited" };
216
+ }
217
+ const userId = await getSessionUserId(request.headers);
218
+ if (!userId) {
219
+ set.status = 401;
220
+ return { error: "Unauthorized" };
221
+ }
222
+ try {
223
+ const [doc] = await db
224
+ .select({ id: documents.id })
225
+ .from(documents)
226
+ .where(and(eq(documents.id, params.id), eq(documents.ownerId, userId)))
227
+ .limit(1);
228
+ if (!doc) {
229
+ set.status = 404;
230
+ return { error: "Document not found" };
231
+ }
232
+
233
+ await db
234
+ .delete(documentTags)
235
+ .where(
236
+ and(
237
+ eq(documentTags.documentId, params.id),
238
+ eq(documentTags.tagId, params.tagId),
239
+ ),
240
+ );
241
+ return { success: true };
242
+ } catch (err) {
243
+ logger.error({ err }, "Failed to remove tag from document");
244
+ set.status = 500;
245
+ return { error: "Failed to remove tag" };
246
+ }
247
+ });
@@ -0,0 +1,99 @@
1
+ import { documents, versions } from "@hiai-docs/db/schema";
2
+ import { and, desc, eq } from "drizzle-orm";
3
+ import { Elysia } from "elysia";
4
+ import { getSessionUserId } from "../../lib/auth-helpers";
5
+ import { db } from "../../lib/db";
6
+ import { logger } from "../../lib/logger";
7
+
8
+ export const versionRoutes = new Elysia({
9
+ prefix: "/api/documents/:id/versions",
10
+ })
11
+ // GET /api/documents/:id/versions — list versions
12
+ .get("/", async ({ params, set, request }) => {
13
+ const userId = await getSessionUserId(request.headers);
14
+ if (!userId) {
15
+ set.status = 401;
16
+ return { error: "Unauthorized" };
17
+ }
18
+ try {
19
+ const doc = await db
20
+ .select({ id: documents.id })
21
+ .from(documents)
22
+ .where(and(eq(documents.id, params.id), eq(documents.ownerId, userId)))
23
+ .limit(1);
24
+
25
+ if (doc.length === 0) {
26
+ set.status = 404;
27
+ return { error: "Document not found" };
28
+ }
29
+
30
+ const rows = await db
31
+ .select({
32
+ id: versions.id,
33
+ documentId: versions.documentId,
34
+ content: versions.content,
35
+ contentJson: versions.contentJson,
36
+ createdBy: versions.createdBy,
37
+ createdAt: versions.createdAt,
38
+ })
39
+ .from(versions)
40
+ .where(eq(versions.documentId, params.id))
41
+ .orderBy(desc(versions.createdAt));
42
+
43
+ return rows;
44
+ } catch (err) {
45
+ logger.error({ err, docId: params.id }, "Failed to list versions");
46
+ set.status = 500;
47
+ return { error: "Failed to list versions" };
48
+ }
49
+ })
50
+
51
+ // GET /api/documents/:id/versions/:vid — get specific version
52
+ .get("/:vid", async ({ params, set, request }) => {
53
+ const userId = await getSessionUserId(request.headers);
54
+ if (!userId) {
55
+ set.status = 401;
56
+ return { error: "Unauthorized" };
57
+ }
58
+ try {
59
+ const doc = await db
60
+ .select({ id: documents.id })
61
+ .from(documents)
62
+ .where(and(eq(documents.id, params.id), eq(documents.ownerId, userId)))
63
+ .limit(1);
64
+
65
+ if (doc.length === 0) {
66
+ set.status = 404;
67
+ return { error: "Document not found" };
68
+ }
69
+
70
+ const rows = await db
71
+ .select({
72
+ id: versions.id,
73
+ documentId: versions.documentId,
74
+ content: versions.content,
75
+ contentJson: versions.contentJson,
76
+ createdBy: versions.createdBy,
77
+ createdAt: versions.createdAt,
78
+ })
79
+ .from(versions)
80
+ .where(
81
+ and(eq(versions.id, params.vid), eq(versions.documentId, params.id)),
82
+ )
83
+ .limit(1);
84
+
85
+ if (rows.length === 0) {
86
+ set.status = 404;
87
+ return { error: "Version not found" };
88
+ }
89
+
90
+ return rows[0];
91
+ } catch (err) {
92
+ logger.error(
93
+ { err, docId: params.id, vid: params.vid },
94
+ "Failed to get version",
95
+ );
96
+ set.status = 500;
97
+ return { error: "Failed to get version" };
98
+ }
99
+ });
@@ -0,0 +1,43 @@
1
+ import { attachments } from "@hiai-docs/db/schema";
2
+ import { eq } from "drizzle-orm";
3
+ import { Elysia } from "elysia";
4
+ import { db } from "../../lib/db";
5
+ import { logger } from "../../lib/logger";
6
+ import { verifyWebhookSignature } from "../middleware/webhook-verify";
7
+
8
+ export const webhookRoutes = new Elysia({ prefix: "/api/webhooks" }).post(
9
+ "/minio",
10
+ async ({ request, body }) => {
11
+ const rawBody = await request.text();
12
+ const sig = request.headers.get("x-minio-signature");
13
+
14
+ if (!verifyWebhookSignature(rawBody, sig)) {
15
+ logger.warn("Invalid MinIO webhook signature");
16
+ return { error: "Invalid signature" };
17
+ }
18
+
19
+ const event = body as Record<string, unknown>;
20
+ const records = (event.Records ?? []) as Array<Record<string, unknown>>;
21
+
22
+ for (const record of records) {
23
+ const eventName = record.eventName as string;
24
+ const s3 = record.s3 as Record<string, unknown> | undefined;
25
+ const key = (s3?.object as Record<string, unknown>)?.key as string;
26
+
27
+ if (!key) continue;
28
+
29
+ logger.info({ eventName, key }, "MinIO webhook event");
30
+
31
+ if (eventName === "s3:ObjectRemoved:Delete") {
32
+ await db
33
+ .delete(attachments)
34
+ .where(eq(attachments.minioKey, key))
35
+ .catch((err: unknown) =>
36
+ logger.error({ err, key }, "Failed to mark attachment deleted"),
37
+ );
38
+ }
39
+ }
40
+
41
+ return { received: true };
42
+ },
43
+ );
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Text chunking for embedding pipeline.
3
+ * Strategy: split by paragraphs, then merge into ~500 token chunks with 50 token overlap.
4
+ * Token estimation: 1 token ≈ 4 chars (rough heuristic).
5
+ */
6
+
7
+ const CHARS_PER_TOKEN = 4;
8
+ const TARGET_TOKENS = 500;
9
+ const OVERLAP_TOKENS = 50;
10
+
11
+ const TARGET_CHARS = TARGET_TOKENS * CHARS_PER_TOKEN; // 2000
12
+ const OVERLAP_CHARS = OVERLAP_TOKENS * CHARS_PER_TOKEN; // 200
13
+
14
+ /**
15
+ * Split text into chunks suitable for embedding.
16
+ * Each chunk is approximately 500 tokens (~2000 characters).
17
+ * Adjacent chunks overlap by ~50 tokens (~200 characters).
18
+ */
19
+ export function chunkText(text: string): string[] {
20
+ if (!text || text.trim().length === 0) {
21
+ return [];
22
+ }
23
+
24
+ // Split into paragraphs (preserve double-newline boundaries)
25
+ const paragraphs = text.split(/\n\s*\n/).filter((p) => p.trim().length > 0);
26
+
27
+ if (paragraphs.length === 0) {
28
+ return [];
29
+ }
30
+
31
+ // If a single paragraph exceeds target, split it further by sentences
32
+ const normalizedParagraphs: string[] = [];
33
+ for (const para of paragraphs) {
34
+ if (para.length > TARGET_CHARS * 1.5) {
35
+ // Split oversized paragraph by sentences
36
+ const sentences = para.match(/[^.!?]+[.!?]+[\s]*/g) || [para];
37
+ normalizedParagraphs.push(...sentences);
38
+ } else {
39
+ normalizedParagraphs.push(para);
40
+ }
41
+ }
42
+
43
+ const chunks: string[] = [];
44
+ let currentChunk = "";
45
+
46
+ for (const paragraph of normalizedParagraphs) {
47
+ const candidate = currentChunk
48
+ ? `${currentChunk}\n\n${paragraph}`
49
+ : paragraph;
50
+
51
+ if (candidate.length <= TARGET_CHARS) {
52
+ currentChunk = candidate;
53
+ } else {
54
+ // Flush current chunk if non-empty
55
+ if (currentChunk.length > 0) {
56
+ chunks.push(currentChunk.trim());
57
+ }
58
+ // Start new chunk with overlap from end of previous chunk
59
+ if (OVERLAP_CHARS > 0 && currentChunk.length > 0) {
60
+ const overlap = currentChunk.slice(-OVERLAP_CHARS);
61
+ currentChunk = `${overlap}\n\n${paragraph}`;
62
+ } else {
63
+ currentChunk = paragraph;
64
+ }
65
+ }
66
+ }
67
+
68
+ // Flush remaining chunk
69
+ if (currentChunk.trim().length > 0) {
70
+ chunks.push(currentChunk.trim());
71
+ }
72
+
73
+ return chunks;
74
+ }
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Embedding pipeline entry point.
3
+ * Provider factory with fallback logic, document chunking, and graceful degradation.
4
+ */
5
+
6
+ import { config } from "../lib/config";
7
+ import { logger } from "../lib/logger";
8
+ import { chunkText } from "./chunker";
9
+ import { getOllamaEmbedding } from "./providers/ollama";
10
+ import { getOpenRouterEmbedding } from "./providers/openrouter";
11
+
12
+ const EMBEDDING_DIMENSIONS = 1024;
13
+
14
+ /**
15
+ * Get an embedding vector using the primary provider.
16
+ */
17
+ async function getPrimaryEmbedding(text: string): Promise<number[]> {
18
+ switch (config.EMBEDDING_PROVIDER) {
19
+ case "ollama":
20
+ return getOllamaEmbedding(
21
+ text,
22
+ config.EMBEDDING_MODEL,
23
+ config.EMBEDDING_OLLAMA_URL,
24
+ );
25
+ case "openrouter":
26
+ return getOpenRouterEmbedding(
27
+ text,
28
+ config.EMBEDDING_MODEL,
29
+ config.OPENROUTER_API_KEY ?? "",
30
+ );
31
+ case "voyage":
32
+ // Voyage not yet implemented — fall through to fallback
33
+ throw new Error("Voyage embedding provider is not yet implemented");
34
+ default:
35
+ throw new Error(
36
+ `Unknown embedding provider: ${config.EMBEDDING_PROVIDER}`,
37
+ );
38
+ }
39
+ }
40
+
41
+ /**
42
+ * Get an embedding vector using the fallback provider.
43
+ */
44
+ async function getFallbackEmbedding(text: string): Promise<number[]> {
45
+ switch (config.EMBEDDING_FALLBACK_PROVIDER) {
46
+ case "ollama":
47
+ return getOllamaEmbedding(
48
+ text,
49
+ config.EMBEDDING_FALLBACK_MODEL,
50
+ config.EMBEDDING_OLLAMA_URL,
51
+ );
52
+ case "openrouter":
53
+ return getOpenRouterEmbedding(
54
+ text,
55
+ config.EMBEDDING_FALLBACK_MODEL,
56
+ config.OPENROUTER_API_KEY ?? "",
57
+ );
58
+ case "voyage":
59
+ throw new Error("Voyage embedding provider is not yet implemented");
60
+ default:
61
+ throw new Error(
62
+ `Unknown fallback embedding provider: ${config.EMBEDDING_FALLBACK_PROVIDER}`,
63
+ );
64
+ }
65
+ }
66
+
67
+ /**
68
+ * Get an embedding vector for a single text.
69
+ * Tries primary provider, then fallback, then returns a zero vector.
70
+ */
71
+ export async function getEmbedding(text: string): Promise<number[]> {
72
+ try {
73
+ const embedding = await getPrimaryEmbedding(text);
74
+ return embedding;
75
+ } catch (primaryErr) {
76
+ logger.warn(
77
+ { err: primaryErr, provider: config.EMBEDDING_PROVIDER },
78
+ "Primary embedding provider failed, trying fallback",
79
+ );
80
+
81
+ try {
82
+ const embedding = await getFallbackEmbedding(text);
83
+ return embedding;
84
+ } catch (fallbackErr) {
85
+ logger.error(
86
+ { err: fallbackErr, provider: config.EMBEDDING_FALLBACK_PROVIDER },
87
+ "Fallback embedding provider also failed, returning zero vector",
88
+ );
89
+ return new Array(EMBEDDING_DIMENSIONS).fill(0);
90
+ }
91
+ }
92
+ }
93
+
94
+ /**
95
+ * Chunk a document and embed each chunk.
96
+ * Returns one embedding vector per chunk.
97
+ */
98
+ export async function embedDocument(
99
+ title: string,
100
+ content: string,
101
+ ): Promise<number[][]> {
102
+ const fullText = `${title}\n\n${content}`;
103
+ const chunks = chunkText(fullText);
104
+
105
+ if (chunks.length === 0) {
106
+ return [new Array(EMBEDDING_DIMENSIONS).fill(0)];
107
+ }
108
+
109
+ const results: number[][] = [];
110
+ for (let i = 0; i < chunks.length; i += 5) {
111
+ const batch = chunks.slice(i, i + 5);
112
+ const batchResults = await Promise.all(batch.map(getEmbedding));
113
+ results.push(...batchResults);
114
+ }
115
+
116
+ return results;
117
+ }
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Ollama embedding provider.
3
+ * POST to ${OLLAMA_URL}/api/embeddings with configurable model.
4
+ */
5
+
6
+ import { logger } from "../../lib/logger";
7
+ import { normalizeDimensions } from "../utils";
8
+
9
+ const EMBEDDING_DIMENSIONS = 1024;
10
+ const TIMEOUT_MS = 30_000;
11
+
12
+ interface OllamaEmbeddingResponse {
13
+ embedding: number[];
14
+ }
15
+
16
+ /**
17
+ * Get an embedding vector from Ollama.
18
+ * @param text - Text to embed
19
+ * @param model - Model name (default: nomic-embed-text)
20
+ * @param ollamaUrl - Ollama API base URL
21
+ * @returns number[] of length 1024
22
+ */
23
+ export async function getOllamaEmbedding(
24
+ text: string,
25
+ model: string,
26
+ ollamaUrl: string,
27
+ ): Promise<number[]> {
28
+ const url = `${ollamaUrl}/api/embeddings`;
29
+
30
+ const controller = new AbortController();
31
+ const timeout = setTimeout(() => controller.abort(), TIMEOUT_MS);
32
+
33
+ try {
34
+ const response = await fetch(url, {
35
+ method: "POST",
36
+ headers: { "Content-Type": "application/json" },
37
+ body: JSON.stringify({ model, prompt: text }),
38
+ signal: controller.signal,
39
+ });
40
+
41
+ if (!response.ok) {
42
+ const body = await response.text().catch(() => "unknown");
43
+ throw new Error(`Ollama embedding failed: ${response.status} ${body}`);
44
+ }
45
+
46
+ const data = (await response.json()) as OllamaEmbeddingResponse;
47
+ const embedding = data.embedding;
48
+
49
+ if (!Array.isArray(embedding) || embedding.length === 0) {
50
+ throw new Error("Ollama returned empty or invalid embedding");
51
+ }
52
+
53
+ return normalizeDimensions(embedding, EMBEDDING_DIMENSIONS);
54
+ } catch (err) {
55
+ if (err instanceof Error && err.name === "AbortError") {
56
+ logger.error({ url, model }, "Ollama embedding request timed out");
57
+ throw new Error(`Ollama embedding timed out after ${TIMEOUT_MS}ms`);
58
+ }
59
+ throw err;
60
+ } finally {
61
+ clearTimeout(timeout);
62
+ }
63
+ }