mirror of
https://github.com/willchen96/mike.git
synced 2026-06-20 21:18:07 +02:00
487 lines
16 KiB
TypeScript
487 lines
16 KiB
TypeScript
import { Router } from "express";
|
||
import { requireAuth } from "../middleware/auth";
|
||
import { createServerSupabase } from "../lib/supabase";
|
||
import {
|
||
buildDocContext,
|
||
buildMessages,
|
||
enrichWithPriorEvents,
|
||
buildWorkflowStore,
|
||
extractAnnotations,
|
||
runLLMStream,
|
||
type ChatMessage,
|
||
} from "../lib/chatTools";
|
||
import { completeText } from "../lib/llm";
|
||
import { getUserApiKeys, getUserModelSettings } from "../lib/userSettings";
|
||
import { checkProjectAccess } from "../lib/access";
|
||
|
||
export const chatRouter = Router();
|
||
|
||
// GET /chat
|
||
// Visible chats = the user's own chats + every chat under a project the
|
||
// user owns (so a project owner sees all collaborator chats in their
|
||
// own projects in the global recent-chats list). Chats in projects that
|
||
// are merely *shared with* the user are NOT included here — those are
|
||
// listed per-project via GET /projects/:projectId/chats.
|
||
chatRouter.get("/", requireAuth, async (req, res) => {
|
||
const userId = res.locals.userId as string;
|
||
const db = createServerSupabase();
|
||
|
||
const { data: ownProjects, error: projErr } = await db
|
||
.from("projects")
|
||
.select("id")
|
||
.eq("user_id", userId);
|
||
if (projErr) return void res.status(500).json({ detail: projErr.message });
|
||
const ownProjectIds = ((ownProjects ?? []) as { id: string }[]).map(
|
||
(p) => p.id,
|
||
);
|
||
|
||
const filter =
|
||
ownProjectIds.length > 0
|
||
? `user_id.eq.${userId},project_id.in.(${ownProjectIds.join(",")})`
|
||
: `user_id.eq.${userId}`;
|
||
|
||
const { data, error } = await db
|
||
.from("chats")
|
||
.select("*")
|
||
.or(filter)
|
||
.order("created_at", { ascending: false });
|
||
if (error) return void res.status(500).json({ detail: error.message });
|
||
res.json(data ?? []);
|
||
});
|
||
|
||
// POST /chat/create
|
||
chatRouter.post("/create", requireAuth, async (req, res) => {
|
||
const userId = res.locals.userId as string;
|
||
const projectId: string | null = req.body.project_id ?? null;
|
||
const db = createServerSupabase();
|
||
const { data, error } = await db
|
||
.from("chats")
|
||
.insert({ user_id: userId, project_id: projectId ?? undefined })
|
||
.select("id")
|
||
.single();
|
||
|
||
if (error) return void res.status(500).json({ detail: error.message });
|
||
res.json({ id: data.id });
|
||
});
|
||
|
||
// GET /chat/:chatId
|
||
chatRouter.get("/:chatId", requireAuth, async (req, res) => {
|
||
const userId = res.locals.userId as string;
|
||
const userEmail = res.locals.userEmail as string | undefined;
|
||
const { chatId } = req.params;
|
||
const db = createServerSupabase();
|
||
|
||
const { data: chat, error } = await db
|
||
.from("chats")
|
||
.select("*")
|
||
.eq("id", chatId)
|
||
.single();
|
||
if (error || !chat)
|
||
return void res.status(404).json({ detail: "Chat not found" });
|
||
// Owner of the chat OR a member of the chat's project can view it.
|
||
let canView = chat.user_id === userId;
|
||
if (!canView && chat.project_id) {
|
||
const access = await checkProjectAccess(
|
||
chat.project_id,
|
||
userId,
|
||
userEmail,
|
||
db,
|
||
);
|
||
canView = access.ok;
|
||
}
|
||
if (!canView)
|
||
return void res.status(404).json({ detail: "Chat not found" });
|
||
|
||
const { data: messages } = await db
|
||
.from("chat_messages")
|
||
.select("*")
|
||
.eq("chat_id", chatId)
|
||
.order("created_at", { ascending: true });
|
||
|
||
const hydrated = await hydrateEditStatuses(messages ?? [], db);
|
||
res.json({ chat, messages: hydrated });
|
||
});
|
||
|
||
// Stored message annotations/events capture the `status` at the time the
|
||
// assistant produced the edit (always "pending"). If the user later accepts
|
||
// or rejects, `document_edits.status` is updated but the stored message
|
||
// annotation is not. On chat load we merge the current DB status in so
|
||
// EditCards render with the real state.
|
||
async function hydrateEditStatuses(
|
||
messages: Record<string, unknown>[],
|
||
db: ReturnType<typeof createServerSupabase>,
|
||
): Promise<Record<string, unknown>[]> {
|
||
const editIds = new Set<string>();
|
||
const versionIds = new Set<string>();
|
||
const collectFromAnnList = (list: unknown) => {
|
||
if (!Array.isArray(list)) return;
|
||
for (const a of list as Record<string, unknown>[]) {
|
||
if (typeof a?.edit_id === "string") editIds.add(a.edit_id);
|
||
if (typeof a?.version_id === "string")
|
||
versionIds.add(a.version_id);
|
||
}
|
||
};
|
||
for (const m of messages) {
|
||
collectFromAnnList(m.annotations);
|
||
const content = m.content;
|
||
if (Array.isArray(content)) {
|
||
for (const ev of content as Record<string, unknown>[]) {
|
||
if (ev?.type === "doc_edited") {
|
||
collectFromAnnList(ev.annotations);
|
||
if (typeof ev.version_id === "string")
|
||
versionIds.add(ev.version_id);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
if (editIds.size === 0 && versionIds.size === 0) return messages;
|
||
|
||
// Edit status patch.
|
||
const statusById = new Map<string, "pending" | "accepted" | "rejected">();
|
||
if (editIds.size > 0) {
|
||
const { data: rows } = await db
|
||
.from("document_edits")
|
||
.select("id, status")
|
||
.in("id", Array.from(editIds));
|
||
for (const r of (rows ?? []) as { id: string; status: string }[]) {
|
||
if (
|
||
r.status === "pending" ||
|
||
r.status === "accepted" ||
|
||
r.status === "rejected"
|
||
) {
|
||
statusById.set(r.id, r.status);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Version-number patch — old stored events don't carry `version_number`
|
||
// because they predate the schema change. Look it up from
|
||
// document_versions so the UI can render "V3" chips + download filenames.
|
||
const versionNumberById = new Map<string, number | null>();
|
||
if (versionIds.size > 0) {
|
||
const { data: vrows } = await db
|
||
.from("document_versions")
|
||
.select("id, version_number")
|
||
.in("id", Array.from(versionIds));
|
||
for (const r of (vrows ?? []) as {
|
||
id: string;
|
||
version_number: number | null;
|
||
}[]) {
|
||
versionNumberById.set(r.id, r.version_number ?? null);
|
||
}
|
||
}
|
||
|
||
const patchAnnList = (list: unknown): unknown => {
|
||
if (!Array.isArray(list)) return list;
|
||
return (list as Record<string, unknown>[]).map((a) => {
|
||
let next = a;
|
||
if (typeof a?.edit_id === "string" && statusById.has(a.edit_id)) {
|
||
next = { ...next, status: statusById.get(a.edit_id) };
|
||
}
|
||
if (
|
||
typeof a?.version_id === "string" &&
|
||
versionNumberById.has(a.version_id)
|
||
) {
|
||
next = {
|
||
...next,
|
||
version_number: versionNumberById.get(a.version_id) ?? null,
|
||
};
|
||
}
|
||
return next;
|
||
});
|
||
};
|
||
return messages.map((m) => {
|
||
const next: Record<string, unknown> = { ...m };
|
||
next.annotations = patchAnnList(m.annotations);
|
||
if (Array.isArray(m.content)) {
|
||
next.content = (m.content as Record<string, unknown>[]).map(
|
||
(ev) => {
|
||
if (ev?.type !== "doc_edited") return ev;
|
||
let patched: Record<string, unknown> = {
|
||
...ev,
|
||
annotations: patchAnnList(ev.annotations),
|
||
};
|
||
if (
|
||
typeof ev.version_id === "string" &&
|
||
versionNumberById.has(ev.version_id)
|
||
) {
|
||
patched = {
|
||
...patched,
|
||
version_number:
|
||
versionNumberById.get(ev.version_id) ?? null,
|
||
};
|
||
}
|
||
return patched;
|
||
},
|
||
);
|
||
}
|
||
return next;
|
||
});
|
||
}
|
||
|
||
// PATCH /chat/:chatId
|
||
chatRouter.patch("/:chatId", requireAuth, async (req, res) => {
|
||
const userId = res.locals.userId as string;
|
||
const { chatId } = req.params;
|
||
const title = (req.body.title ?? "").trim();
|
||
if (!title)
|
||
return void res.status(400).json({ detail: "title is required" });
|
||
|
||
const db = createServerSupabase();
|
||
const { data, error } = await db
|
||
.from("chats")
|
||
.update({ title })
|
||
.eq("id", chatId)
|
||
.eq("user_id", userId)
|
||
.select("id, title")
|
||
.single();
|
||
|
||
if (error || !data)
|
||
return void res.status(404).json({ detail: "Chat not found" });
|
||
res.json(data);
|
||
});
|
||
|
||
// DELETE /chat/:chatId
|
||
chatRouter.delete("/:chatId", requireAuth, async (req, res) => {
|
||
const userId = res.locals.userId as string;
|
||
const { chatId } = req.params;
|
||
const db = createServerSupabase();
|
||
const { error } = await db
|
||
.from("chats")
|
||
.delete()
|
||
.eq("id", chatId)
|
||
.eq("user_id", userId);
|
||
|
||
if (error) return void res.status(500).json({ detail: error.message });
|
||
res.status(204).send();
|
||
});
|
||
|
||
// POST /chat/:chatId/generate-title
|
||
chatRouter.post("/:chatId/generate-title", requireAuth, async (req, res) => {
|
||
const userId = res.locals.userId as string;
|
||
const userEmail = res.locals.userEmail as string | undefined;
|
||
const { chatId } = req.params;
|
||
const message: string = (req.body.message ?? "").trim();
|
||
if (!message)
|
||
return void res.status(400).json({ detail: "message is required" });
|
||
|
||
const db = createServerSupabase();
|
||
const { data: chat, error } = await db
|
||
.from("chats")
|
||
.select("id, user_id, project_id")
|
||
.eq("id", chatId)
|
||
.single();
|
||
|
||
if (error || !chat)
|
||
return void res.status(404).json({ detail: "Chat not found" });
|
||
let canTitle = chat.user_id === userId;
|
||
if (!canTitle && chat.project_id) {
|
||
const access = await checkProjectAccess(
|
||
chat.project_id,
|
||
userId,
|
||
userEmail,
|
||
db,
|
||
);
|
||
canTitle = access.ok;
|
||
}
|
||
if (!canTitle)
|
||
return void res.status(404).json({ detail: "Chat not found" });
|
||
|
||
try {
|
||
const { title_model, api_keys } = await getUserModelSettings(
|
||
userId,
|
||
db,
|
||
);
|
||
const titleText = await completeText({
|
||
model: title_model,
|
||
user: `Generate a concise title (3–6 words) for a chat in an AI Legal Platform that starts with this message. The title should describe the topic or document — do NOT include words like "Legal Assistant", "AI", "Chat", or any similar prefix. Return only the title, no quotes or punctuation.\n\nMessage: ${message.slice(0, 500)}`,
|
||
maxTokens: 64,
|
||
apiKeys: api_keys,
|
||
});
|
||
const title = titleText.trim() || message.slice(0, 60);
|
||
|
||
await db
|
||
.from("chats")
|
||
.update({ title })
|
||
.eq("id", chatId)
|
||
.eq("user_id", userId);
|
||
|
||
res.json({ title });
|
||
} catch (err) {
|
||
console.error("[generate-title]", err);
|
||
res.status(500).json({ detail: "Failed to generate title" });
|
||
}
|
||
});
|
||
|
||
// POST /chat — streaming
|
||
chatRouter.post("/", requireAuth, async (req, res) => {
|
||
const userId = res.locals.userId as string;
|
||
const { messages, chat_id, project_id, model } = req.body as {
|
||
messages: ChatMessage[];
|
||
chat_id?: string;
|
||
project_id?: string;
|
||
model?: string;
|
||
};
|
||
|
||
console.log("[chat/stream] incoming request", {
|
||
userId,
|
||
chat_id,
|
||
project_id,
|
||
model,
|
||
messageCount: messages?.length,
|
||
});
|
||
|
||
const userEmail = res.locals.userEmail as string | undefined;
|
||
const db = createServerSupabase();
|
||
let chatId = chat_id ?? null;
|
||
let chatTitle: string | null = null;
|
||
|
||
if (chatId) {
|
||
// Either chat owner OR a member of the chat's project can post.
|
||
const { data: existing } = await db
|
||
.from("chats")
|
||
.select("id, title, user_id, project_id")
|
||
.eq("id", chatId)
|
||
.single();
|
||
let canUse = !!existing && existing.user_id === userId;
|
||
if (!canUse && existing?.project_id) {
|
||
const access = await checkProjectAccess(
|
||
existing.project_id,
|
||
userId,
|
||
userEmail,
|
||
db,
|
||
);
|
||
canUse = access.ok;
|
||
}
|
||
if (!canUse || !existing) chatId = null;
|
||
else chatTitle = existing.title;
|
||
}
|
||
|
||
if (!chatId) {
|
||
// If creating a chat tied to a project, the user must have access
|
||
// to the project (own or shared).
|
||
if (project_id) {
|
||
const access = await checkProjectAccess(
|
||
project_id,
|
||
userId,
|
||
userEmail,
|
||
db,
|
||
);
|
||
if (!access.ok)
|
||
return void res
|
||
.status(404)
|
||
.json({ detail: "Project not found" });
|
||
}
|
||
const { data: newChat, error } = await db
|
||
.from("chats")
|
||
.insert({ user_id: userId, project_id: project_id ?? null })
|
||
.select("id, title")
|
||
.single();
|
||
if (error || !newChat) {
|
||
console.error("[chat/stream] failed to create chat", error);
|
||
return void res
|
||
.status(500)
|
||
.json({ detail: "Failed to create chat" });
|
||
}
|
||
chatId = newChat.id as string;
|
||
chatTitle = newChat.title;
|
||
}
|
||
|
||
console.log("[chat/stream] resolved chatId", chatId);
|
||
|
||
const lastUser = [...messages].reverse().find((m) => m.role === "user");
|
||
if (lastUser) {
|
||
await db.from("chat_messages").insert({
|
||
chat_id: chatId,
|
||
role: "user",
|
||
content: lastUser.content,
|
||
files: lastUser.files ?? null,
|
||
workflow: lastUser.workflow ?? null,
|
||
});
|
||
}
|
||
|
||
const { docIndex, docStore } = await buildDocContext(
|
||
messages,
|
||
userId,
|
||
db,
|
||
chatId,
|
||
);
|
||
const docAvailability = Object.entries(docIndex).map(([doc_id, info]) => ({
|
||
doc_id,
|
||
filename: info.filename,
|
||
}));
|
||
const enrichedMessages = await enrichWithPriorEvents(
|
||
messages,
|
||
chatId,
|
||
db,
|
||
docIndex,
|
||
);
|
||
const apiMessages = buildMessages(enrichedMessages, docAvailability);
|
||
|
||
const workflowStore = await buildWorkflowStore(userId, userEmail, db);
|
||
|
||
console.log("[chat/stream] starting LLM stream", {
|
||
apiMessageCount: apiMessages.length,
|
||
docCount: Object.keys(docIndex).length,
|
||
workflowCount: Object.keys(workflowStore).length,
|
||
});
|
||
|
||
res.setHeader("Content-Type", "text/event-stream");
|
||
res.setHeader("Cache-Control", "no-cache");
|
||
res.setHeader("Connection", "keep-alive");
|
||
res.setHeader("X-Accel-Buffering", "no");
|
||
res.flushHeaders();
|
||
|
||
const write = (line: string) => res.write(line);
|
||
|
||
const apiKeys = await getUserApiKeys(userId, db);
|
||
|
||
try {
|
||
write(`data: ${JSON.stringify({ type: "chat_id", chatId })}\n\n`);
|
||
|
||
const { fullText, events } = await runLLMStream({
|
||
apiMessages,
|
||
docStore,
|
||
docIndex,
|
||
userId,
|
||
db,
|
||
write,
|
||
workflowStore,
|
||
model,
|
||
apiKeys,
|
||
projectId: project_id ?? null,
|
||
});
|
||
|
||
console.log("[chat/stream] LLM stream finished", {
|
||
fullTextLen: fullText?.length ?? 0,
|
||
eventCount: events?.length ?? 0,
|
||
});
|
||
|
||
const annotations = extractAnnotations(fullText, docIndex, events);
|
||
await db.from("chat_messages").insert({
|
||
chat_id: chatId,
|
||
role: "assistant",
|
||
content: events.length ? events : null,
|
||
annotations: annotations.length ? annotations : null,
|
||
});
|
||
|
||
if (!chatTitle && lastUser?.content) {
|
||
await db
|
||
.from("chats")
|
||
.update({ title: lastUser.content.slice(0, 120) })
|
||
.eq("id", chatId);
|
||
}
|
||
} catch (err) {
|
||
console.error("[chat/stream] error:", err);
|
||
try {
|
||
write(
|
||
`data: ${JSON.stringify({ type: "error", message: "Stream error" })}\n\n`,
|
||
);
|
||
write("data: [DONE]\n\n");
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
} finally {
|
||
res.end();
|
||
}
|
||
});
|