@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.
- package/.all-contributorsrc +18 -0
- package/.claude/settings.local.json +61 -0
- package/.dockerignore +113 -0
- package/.env.example +68 -0
- package/.github/FUNDING.yml +5 -0
- package/.github/ISSUE_TEMPLATE/bug_report.md +74 -0
- package/.github/ISSUE_TEMPLATE/feature_request.md +78 -0
- package/.github/dependabot.yml +136 -0
- package/.github/pull_request_template.md +96 -0
- package/.github/workflows/ci.yml +283 -0
- package/AGENTS.md +237 -0
- package/CODE_OF_CONDUCT.md +134 -0
- package/CONTRIBUTING.md +77 -0
- package/Caddyfile +50 -0
- package/Dockerfile.backend +60 -0
- package/LICENSE +21 -0
- package/README.md +284 -0
- package/RELEASE_CHECKLIST.md +34 -0
- package/SECURITY.md +60 -0
- package/backend/package.json +43 -0
- package/backend/src/__tests__/auth-helpers.test.ts +51 -0
- package/backend/src/__tests__/chunker.test.ts +65 -0
- package/backend/src/__tests__/config.test.ts +91 -0
- package/backend/src/__tests__/csrf.test.ts +91 -0
- package/backend/src/__tests__/embedding.test.ts +48 -0
- package/backend/src/__tests__/rate-limit.test.ts +46 -0
- package/backend/src/__tests__/routes.test.ts +38 -0
- package/backend/src/__tests__/schema.test.ts +31 -0
- package/backend/src/__tests__/validation.test.ts +556 -0
- package/backend/src/api/middleware/auth.ts +56 -0
- package/backend/src/api/middleware/csrf.ts +91 -0
- package/backend/src/api/middleware/rate-limit.ts +77 -0
- package/backend/src/api/middleware/webhook-verify.ts +22 -0
- package/backend/src/api/routes/attachments.ts +280 -0
- package/backend/src/api/routes/auth.ts +52 -0
- package/backend/src/api/routes/collaboration.ts +121 -0
- package/backend/src/api/routes/documents.ts +664 -0
- package/backend/src/api/routes/folders.ts +226 -0
- package/backend/src/api/routes/search.ts +354 -0
- package/backend/src/api/routes/share.ts +512 -0
- package/backend/src/api/routes/tags.ts +247 -0
- package/backend/src/api/routes/versions.ts +99 -0
- package/backend/src/api/routes/webhooks.ts +43 -0
- package/backend/src/embedding/chunker.ts +74 -0
- package/backend/src/embedding/index.ts +117 -0
- package/backend/src/embedding/providers/ollama.ts +63 -0
- package/backend/src/embedding/providers/openrouter.ts +71 -0
- package/backend/src/embedding/utils.ts +13 -0
- package/backend/src/embedding/worker.ts +89 -0
- package/backend/src/index.ts +147 -0
- package/backend/src/lib/auth-helpers.ts +27 -0
- package/backend/src/lib/auth.ts +35 -0
- package/backend/src/lib/config.ts +73 -0
- package/backend/src/lib/db.ts +7 -0
- package/backend/src/lib/embedding-queue.ts +12 -0
- package/backend/src/lib/logger.ts +18 -0
- package/backend/src/lib/markdown-to-doc.ts +45 -0
- package/backend/src/lib/minio.ts +46 -0
- package/backend/src/lib/redis.ts +19 -0
- package/backend/src/lib/yjs-provider.ts +182 -0
- package/backend/tests/integration/_harness.ts +754 -0
- package/backend/tests/integration/auth.test.ts +296 -0
- package/backend/tests/integration/routes.documents.test.ts +459 -0
- package/backend/tests/integration/routes.folders.test.ts +337 -0
- package/backend/tests/integration/routes.search.test.ts +322 -0
- package/backend/tests/integration/routes.share.test.ts +773 -0
- package/backend/tests/integration/routes.tags.test.ts +425 -0
- package/backend/tests/integration/routes.versions.test.ts +233 -0
- package/backend/tsconfig.json +18 -0
- package/docker-compose.yml +218 -0
- package/docs/API.md +328 -0
- package/docs/ARCHITECTURE.md +75 -0
- package/docs/DEPLOYMENT.md +113 -0
- package/docs/PRODUCTION_STATUS.md +61 -0
- package/docs/openapi.json +385 -0
- package/frontend/.svelte-kit.old/ambient.d.ts +230 -0
- package/frontend/.svelte-kit.old/env.d.ts +1 -0
- package/frontend/.svelte-kit.old/generated/client/app.js +46 -0
- package/frontend/.svelte-kit.old/generated/client/matchers.js +1 -0
- package/frontend/.svelte-kit.old/generated/client/nodes/0.js +3 -0
- package/frontend/.svelte-kit.old/generated/client/nodes/1.js +1 -0
- package/frontend/.svelte-kit.old/generated/client/nodes/10.js +3 -0
- package/frontend/.svelte-kit.old/generated/client/nodes/2.js +1 -0
- package/frontend/.svelte-kit.old/generated/client/nodes/3.js +1 -0
- package/frontend/.svelte-kit.old/generated/client/nodes/4.js +1 -0
- package/frontend/.svelte-kit.old/generated/client/nodes/5.js +3 -0
- package/frontend/.svelte-kit.old/generated/client/nodes/6.js +1 -0
- package/frontend/.svelte-kit.old/generated/client/nodes/7.js +3 -0
- package/frontend/.svelte-kit.old/generated/client/nodes/8.js +1 -0
- package/frontend/.svelte-kit.old/generated/client/nodes/9.js +3 -0
- package/frontend/.svelte-kit.old/generated/root.js +3 -0
- package/frontend/.svelte-kit.old/generated/root.svelte +80 -0
- package/frontend/.svelte-kit.old/generated/server/internal.js +55 -0
- package/frontend/.svelte-kit.old/non-ambient.d.ts +59 -0
- package/frontend/.svelte-kit.old/tsconfig.json +59 -0
- package/frontend/.svelte-kit.old/types/route_meta_data.json +40 -0
- package/frontend/.svelte-kit.old/types/src/routes/$types.d.ts +21 -0
- package/frontend/.svelte-kit.old/types/src/routes/(app)/$types.d.ts +30 -0
- package/frontend/.svelte-kit.old/types/src/routes/(app)/docs/[id]/$types.d.ts +27 -0
- package/frontend/.svelte-kit.old/types/src/routes/(app)/docs/[id]/proxy+page.ts +25 -0
- package/frontend/.svelte-kit.old/types/src/routes/api/[...path]/$types.d.ts +10 -0
- package/frontend/.svelte-kit.old/types/src/routes/folders/[id]/$types.d.ts +27 -0
- package/frontend/.svelte-kit.old/types/src/routes/folders/[id]/proxy+page.ts +15 -0
- package/frontend/.svelte-kit.old/types/src/routes/login/$types.d.ts +17 -0
- package/frontend/.svelte-kit.old/types/src/routes/register/$types.d.ts +17 -0
- package/frontend/.svelte-kit.old/types/src/routes/s/[token]/$types.d.ts +20 -0
- package/frontend/.svelte-kit.old/types/src/routes/s/[token]/proxy+page.ts +6 -0
- package/frontend/.svelte-kit.old/types/src/routes/search/$types.d.ts +19 -0
- package/frontend/.svelte-kit.old/types/src/routes/search/proxy+page.ts +26 -0
- package/frontend/.svelte-kit.old/types/src/routes/settings/$types.d.ts +17 -0
- package/frontend/Dockerfile +44 -0
- package/frontend/biome.json +40 -0
- package/frontend/components.json +18 -0
- package/frontend/messages/en.json +434 -0
- package/frontend/package.json +70 -0
- package/frontend/project.inlang/settings.json +12 -0
- package/frontend/src/app.css +6 -0
- package/frontend/src/app.d.ts +13 -0
- package/frontend/src/app.html +30 -0
- package/frontend/src/hooks.server.ts +10 -0
- package/frontend/src/hooks.ts +10 -0
- package/frontend/src/lib/api/attachments.ts +45 -0
- package/frontend/src/lib/api/client.test.ts +15 -0
- package/frontend/src/lib/api/client.ts +57 -0
- package/frontend/src/lib/api/documents.ts +83 -0
- package/frontend/src/lib/api/folders.ts +180 -0
- package/frontend/src/lib/api/search.test.ts +52 -0
- package/frontend/src/lib/api/search.ts +128 -0
- package/frontend/src/lib/api/settings.ts +95 -0
- package/frontend/src/lib/api/share.ts +71 -0
- package/frontend/src/lib/api/tags.test.ts +91 -0
- package/frontend/src/lib/api/tags.ts +87 -0
- package/frontend/src/lib/auth-client.ts +10 -0
- package/frontend/src/lib/collaboration.ts +63 -0
- package/frontend/src/lib/components/AttachmentUpload.svelte +110 -0
- package/frontend/src/lib/components/DatePicker.svelte +322 -0
- package/frontend/src/lib/components/DocumentCard.svelte +166 -0
- package/frontend/src/lib/components/EmptyState.svelte +49 -0
- package/frontend/src/lib/components/FolderCard.svelte +93 -0
- package/frontend/src/lib/components/ScrollToTop.svelte +72 -0
- package/frontend/src/lib/components/SearchBar.svelte +47 -0
- package/frontend/src/lib/components/SearchResult.svelte +115 -0
- package/frontend/src/lib/components/SettingsDialog.svelte +271 -0
- package/frontend/src/lib/components/ShareDialog.svelte +158 -0
- package/frontend/src/lib/components/ShareLink.svelte +98 -0
- package/frontend/src/lib/components/TagCreateDialog.svelte +284 -0
- package/frontend/src/lib/components/VersionDiff.svelte +55 -0
- package/frontend/src/lib/components/VersionHistory.svelte +96 -0
- package/frontend/src/lib/components/editor/DocumentTitle.svelte +87 -0
- package/frontend/src/lib/components/editor/EditorToolbar.svelte +1367 -0
- package/frontend/src/lib/components/editor/HiAiEditor.svelte +531 -0
- package/frontend/src/lib/components/editor/LinkDialog.svelte +134 -0
- package/frontend/src/lib/components/editor/MarkdownToggle.svelte +88 -0
- package/frontend/src/lib/components/editor/editorExtensions.ts +53 -0
- package/frontend/src/lib/components/editor/markdown.ts +38 -0
- package/frontend/src/lib/components/sidebar/FolderTree.svelte +731 -0
- package/frontend/src/lib/components/sidebar/RecentDocs.svelte +311 -0
- package/frontend/src/lib/components/sidebar/Sidebar.svelte +156 -0
- package/frontend/src/lib/components/sidebar/TagList.svelte +200 -0
- package/frontend/src/lib/components/ui/confirm-dialog/ConfirmDialog.svelte +76 -0
- package/frontend/src/lib/components/ui/confirm-dialog/index.ts +1 -0
- package/frontend/src/lib/stores/tag-store.svelte.ts +56 -0
- package/frontend/src/lib/stores/theme.svelte.ts +97 -0
- package/frontend/src/lib/svelte.d.ts +6 -0
- package/frontend/src/lib/types.ts +44 -0
- package/frontend/src/lib/utils/clipboard.ts +17 -0
- package/frontend/src/lib/utils/strip-markdown.ts +59 -0
- package/frontend/src/lib/utils.ts +33 -0
- package/frontend/src/routes/(app)/+layout.svelte +17 -0
- package/frontend/src/routes/(app)/+page.server.ts +10 -0
- package/frontend/src/routes/(app)/+page.svelte +303 -0
- package/frontend/src/routes/(app)/docs/[id]/+page.server.ts +10 -0
- package/frontend/src/routes/(app)/docs/[id]/+page.svelte +1108 -0
- package/frontend/src/routes/(app)/docs/[id]/+page.ts +24 -0
- package/frontend/src/routes/(app)/search/+page.svelte +593 -0
- package/frontend/src/routes/(app)/search/+page.ts +25 -0
- package/frontend/src/routes/+error.svelte +12 -0
- package/frontend/src/routes/+layout.svelte +18 -0
- package/frontend/src/routes/+layout.ts +2 -0
- package/frontend/src/routes/api/[...path]/+server.ts +111 -0
- package/frontend/src/routes/folders/[id]/+page.server.ts +10 -0
- package/frontend/src/routes/folders/[id]/+page.svelte +319 -0
- package/frontend/src/routes/folders/[id]/+page.ts +14 -0
- package/frontend/src/routes/login/+page.svelte +90 -0
- package/frontend/src/routes/register/+page.svelte +97 -0
- package/frontend/src/routes/s/[token]/+page.svelte +496 -0
- package/frontend/src/routes/s/[token]/+page.ts +5 -0
- package/frontend/src/routes/settings/+page.svelte +175 -0
- package/frontend/static/favicon.png +0 -0
- package/frontend/static/logo.png +0 -0
- package/frontend/svelte.config.js +15 -0
- package/frontend/tsconfig.json +15 -0
- package/frontend/vite.config.ts +25 -0
- package/init.sql +9 -0
- package/logo.png +0 -0
- package/package.json +39 -0
- package/package.public.json +39 -0
- package/packages/db/drizzle.config.ts +10 -0
- package/packages/db/package.json +30 -0
- package/packages/db/src/client.ts +9 -0
- package/packages/db/src/index.ts +2 -0
- package/packages/db/src/migrations/0000_nice_bedlam.sql +165 -0
- package/packages/db/src/migrations/0001_w2_3_test.sql +5 -0
- package/packages/db/src/migrations/0002_rename_content_json.sql +2 -0
- package/packages/db/src/migrations/meta/0000_snapshot.json +1331 -0
- package/packages/db/src/migrations/meta/0001_snapshot.json +1399 -0
- package/packages/db/src/migrations/meta/0002_snapshot.json +1399 -0
- package/packages/db/src/migrations/meta/_journal.json +27 -0
- package/packages/db/src/schema.ts +378 -0
- package/packages/db/tsconfig.json +17 -0
- package/scripts/export-openapi.ts +37 -0
- package/scripts/health-check.sh +75 -0
- package/scripts/migrate.sh +135 -0
- package/scripts/prework_backup.sh +25 -0
- package/scripts/release.sh +83 -0
- 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
|
+
}
|