Fresh history cut from cynarAI/evoo antigravity-handoff/project-timeline (Voice First + project timeline). Brand docs/titles as evOo; keep @rakazo/* packages as tech debt. Excludes node_modules, caches, secrets, voice blobs.
352 lines
9.7 KiB
TypeScript
352 lines
9.7 KiB
TypeScript
import type { Actor, MessageBlock, SearchHit } from "@rakazo/contracts";
|
|
import { extractLinksFromText, matchesSearchQuery, snippetAroundMatch } from "@rakazo/core";
|
|
import { Prisma, type PrismaClient } from "@rakazo/db";
|
|
|
|
const SEARCH_LIMIT = 25;
|
|
/** Cap name matches so content hits (messages/files/links/routines) keep most of the budget. */
|
|
const CONVERSATION_HIT_LIMIT = 5;
|
|
|
|
async function findArtifactMessages(
|
|
prisma: PrismaClient,
|
|
actor: Actor,
|
|
artifacts: Array<{ id: string; targetId: string }>,
|
|
target: "bot" | "group",
|
|
): Promise<Map<string, { id: string; seq: number }>> {
|
|
if (artifacts.length === 0) return new Map();
|
|
const targetColumn = target === "bot" ? Prisma.sql`t."botId"` : Prisma.sql`t."groupId"`;
|
|
const candidates = Prisma.join(
|
|
artifacts.map(({ id, targetId }) => Prisma.sql`(${id}, ${targetId})`),
|
|
);
|
|
const rows = await prisma.$queryRaw<Array<{ artifactId: string; id: string; seq: number }>>(
|
|
Prisma.sql`
|
|
SELECT candidate."artifactId", message.id, message.seq
|
|
FROM (VALUES ${candidates}) AS candidate("artifactId", "targetId")
|
|
CROSS JOIN LATERAL (
|
|
SELECT m.id, m.seq
|
|
FROM messages m
|
|
INNER JOIN threads t ON t.id = m."threadId"
|
|
WHERE t."spaceId" = ${actor.spaceId}
|
|
AND t."userId" = ${actor.userId}
|
|
AND ${targetColumn} = candidate."targetId"
|
|
AND m.blocks::text ILIKE ('%' || candidate."artifactId" || '%')
|
|
ORDER BY m."createdAt" DESC
|
|
LIMIT 1
|
|
) message
|
|
`,
|
|
);
|
|
return new Map(rows.map(({ artifactId, id, seq }) => [artifactId, { id, seq }]));
|
|
}
|
|
|
|
export async function querySpaceSearch(
|
|
prisma: PrismaClient,
|
|
actor: Actor,
|
|
q: string,
|
|
): Promise<SearchHit[]> {
|
|
const query = q.trim();
|
|
if (!query) return [];
|
|
|
|
const conversationHits: SearchHit[] = [];
|
|
const contentHits: SearchHit[] = [];
|
|
const seen = new Set<string>();
|
|
|
|
function pushInto(bucket: SearchHit[], hit: SearchHit, limit: number) {
|
|
const key = [
|
|
hit.kind,
|
|
hit.botId ?? "",
|
|
hit.groupId ?? "",
|
|
hit.messageId ?? "",
|
|
hit.artifactId ?? "",
|
|
hit.routineId ?? "",
|
|
hit.url ?? "",
|
|
].join(":");
|
|
if (seen.has(key) || bucket.length >= limit) return;
|
|
seen.add(key);
|
|
bucket.push(hit);
|
|
}
|
|
|
|
const bots = await prisma.bot.findMany({
|
|
where: {
|
|
spaceId: actor.spaceId,
|
|
userId: actor.userId,
|
|
archivedAt: null,
|
|
OR: [
|
|
{ name: { contains: query, mode: "insensitive" } },
|
|
{ title: { contains: query, mode: "insensitive" } },
|
|
{ description: { contains: query, mode: "insensitive" } },
|
|
],
|
|
},
|
|
take: CONVERSATION_HIT_LIMIT,
|
|
});
|
|
for (const bot of bots) {
|
|
pushInto(
|
|
conversationHits,
|
|
{
|
|
kind: "conversation",
|
|
botId: bot.id,
|
|
botName: bot.name,
|
|
title: bot.name,
|
|
snippet: bot.title || bot.description || bot.name,
|
|
},
|
|
CONVERSATION_HIT_LIMIT * 2,
|
|
);
|
|
}
|
|
|
|
const groups = await prisma.chatGroup.findMany({
|
|
where: {
|
|
spaceId: actor.spaceId,
|
|
userId: actor.userId,
|
|
name: { contains: query, mode: "insensitive" },
|
|
},
|
|
take: CONVERSATION_HIT_LIMIT,
|
|
});
|
|
for (const group of groups) {
|
|
pushInto(
|
|
conversationHits,
|
|
{
|
|
kind: "conversation",
|
|
groupId: group.id,
|
|
groupName: group.name,
|
|
title: group.name,
|
|
snippet: group.name,
|
|
},
|
|
CONVERSATION_HIT_LIMIT * 2,
|
|
);
|
|
}
|
|
|
|
const contentBudget = Math.max(0, SEARCH_LIMIT - conversationHits.length);
|
|
|
|
const artifacts = await prisma.artifact.findMany({
|
|
where: {
|
|
spaceId: actor.spaceId,
|
|
userId: actor.userId,
|
|
groupId: null,
|
|
botId: { not: null },
|
|
name: { contains: query, mode: "insensitive" },
|
|
bot: { archivedAt: null },
|
|
},
|
|
include: { bot: { select: { name: true } } },
|
|
take: SEARCH_LIMIT,
|
|
});
|
|
const botArtifactMessages = await findArtifactMessages(
|
|
prisma,
|
|
actor,
|
|
artifacts.flatMap((artifact) =>
|
|
artifact.botId && artifact.bot ? [{ id: artifact.id, targetId: artifact.botId }] : [],
|
|
),
|
|
"bot",
|
|
);
|
|
for (const artifact of artifacts) {
|
|
if (!artifact.botId || !artifact.bot) continue;
|
|
const message = botArtifactMessages.get(artifact.id);
|
|
if (!message) continue;
|
|
pushInto(
|
|
contentHits,
|
|
{
|
|
kind: "file",
|
|
botId: artifact.botId,
|
|
botName: artifact.bot.name,
|
|
title: artifact.name,
|
|
snippet: `${artifact.mimeType} · ${artifact.size} bytes`,
|
|
artifactId: artifact.id,
|
|
messageId: message.id,
|
|
seq: message.seq,
|
|
},
|
|
contentBudget,
|
|
);
|
|
}
|
|
|
|
const groupArtifacts = await prisma.artifact.findMany({
|
|
where: {
|
|
spaceId: actor.spaceId,
|
|
userId: actor.userId,
|
|
groupId: { not: null },
|
|
name: { contains: query, mode: "insensitive" },
|
|
},
|
|
include: { group: { select: { name: true } } },
|
|
take: SEARCH_LIMIT,
|
|
});
|
|
const groupArtifactMessages = await findArtifactMessages(
|
|
prisma,
|
|
actor,
|
|
groupArtifacts.flatMap((artifact) =>
|
|
artifact.groupId && artifact.group ? [{ id: artifact.id, targetId: artifact.groupId }] : [],
|
|
),
|
|
"group",
|
|
);
|
|
for (const artifact of groupArtifacts) {
|
|
if (!artifact.groupId || !artifact.group) continue;
|
|
const message = groupArtifactMessages.get(artifact.id);
|
|
if (!message) continue;
|
|
pushInto(
|
|
contentHits,
|
|
{
|
|
kind: "file",
|
|
groupId: artifact.groupId,
|
|
groupName: artifact.group.name,
|
|
title: artifact.name,
|
|
snippet: `${artifact.mimeType} · ${artifact.size} bytes`,
|
|
artifactId: artifact.id,
|
|
messageId: message.id,
|
|
seq: message.seq,
|
|
},
|
|
contentBudget,
|
|
);
|
|
}
|
|
|
|
const routines = await prisma.routine.findMany({
|
|
where: {
|
|
spaceId: actor.spaceId,
|
|
userId: actor.userId,
|
|
OR: [
|
|
{ name: { contains: query, mode: "insensitive" } },
|
|
{ prompt: { contains: query, mode: "insensitive" } },
|
|
],
|
|
bot: { archivedAt: null },
|
|
},
|
|
include: { bot: { select: { name: true } } },
|
|
take: SEARCH_LIMIT,
|
|
});
|
|
for (const routine of routines) {
|
|
pushInto(
|
|
contentHits,
|
|
{
|
|
kind: "routine",
|
|
botId: routine.botId,
|
|
botName: routine.bot.name,
|
|
title: routine.name,
|
|
snippet: snippetAroundMatch(routine.prompt, query),
|
|
routineId: routine.id,
|
|
},
|
|
contentBudget,
|
|
);
|
|
}
|
|
|
|
const pattern = `%${query}%`;
|
|
const messageRows = await prisma.$queryRaw<
|
|
Array<{
|
|
id: string;
|
|
threadId: string;
|
|
seq: number;
|
|
blocks: Prisma.JsonValue;
|
|
botId: string;
|
|
botName: string;
|
|
}>
|
|
>`
|
|
SELECT m.id, m."threadId", m.seq, m.blocks, b.id AS "botId", b.name AS "botName"
|
|
FROM messages m
|
|
INNER JOIN threads t ON t.id = m."threadId"
|
|
INNER JOIN bots b ON b.id = t."botId"
|
|
WHERE t."spaceId" = ${actor.spaceId}
|
|
AND t."userId" = ${actor.userId}
|
|
AND b."archivedAt" IS NULL
|
|
AND m.blocks::text ILIKE ${pattern}
|
|
ORDER BY m."createdAt" DESC
|
|
LIMIT ${SEARCH_LIMIT}
|
|
`;
|
|
|
|
for (const row of messageRows) {
|
|
pushMessageHits(row.blocks as MessageBlock[], {
|
|
botId: row.botId,
|
|
botName: row.botName,
|
|
messageId: row.id,
|
|
seq: row.seq,
|
|
query,
|
|
push: (hit) => pushInto(contentHits, hit, contentBudget),
|
|
});
|
|
}
|
|
|
|
const groupMessageRows = await prisma.$queryRaw<
|
|
Array<{
|
|
id: string;
|
|
threadId: string;
|
|
seq: number;
|
|
blocks: Prisma.JsonValue;
|
|
groupId: string;
|
|
groupName: string;
|
|
}>
|
|
>`
|
|
SELECT m.id, m."threadId", m.seq, m.blocks, g.id AS "groupId", g.name AS "groupName"
|
|
FROM messages m
|
|
INNER JOIN threads t ON t.id = m."threadId"
|
|
INNER JOIN chat_groups g ON g.id = t."groupId"
|
|
WHERE t."spaceId" = ${actor.spaceId}
|
|
AND t."userId" = ${actor.userId}
|
|
AND t."groupId" IS NOT NULL
|
|
AND m.blocks::text ILIKE ${pattern}
|
|
ORDER BY m."createdAt" DESC
|
|
LIMIT ${SEARCH_LIMIT}
|
|
`;
|
|
|
|
for (const row of groupMessageRows) {
|
|
pushMessageHits(row.blocks as MessageBlock[], {
|
|
groupId: row.groupId,
|
|
groupName: row.groupName,
|
|
messageId: row.id,
|
|
seq: row.seq,
|
|
query,
|
|
push: (hit) => pushInto(contentHits, hit, contentBudget),
|
|
});
|
|
}
|
|
|
|
return [...conversationHits, ...contentHits].slice(0, SEARCH_LIMIT);
|
|
}
|
|
|
|
function pushMessageHits(
|
|
blocks: MessageBlock[],
|
|
ctx: {
|
|
botId?: string;
|
|
botName?: string;
|
|
groupId?: string;
|
|
groupName?: string;
|
|
messageId: string;
|
|
seq: number;
|
|
query: string;
|
|
push: (hit: SearchHit) => void;
|
|
},
|
|
) {
|
|
// Discriminate on groupId (not name): empty group names must still target the group.
|
|
const destination = ctx.groupId
|
|
? { groupId: ctx.groupId, groupName: ctx.groupName ?? "" }
|
|
: { botId: ctx.botId!, botName: ctx.botName! };
|
|
const title = ctx.groupId ? (ctx.groupName ?? "") : (ctx.botName ?? "");
|
|
let messageHit = false;
|
|
for (const block of blocks) {
|
|
if (block.kind !== "text") continue;
|
|
const text = block.text;
|
|
if (matchesSearchQuery(ctx.query, text)) {
|
|
ctx.push({
|
|
kind: "message",
|
|
...destination,
|
|
title,
|
|
snippet: snippetAroundMatch(text, ctx.query),
|
|
messageId: ctx.messageId,
|
|
seq: ctx.seq,
|
|
});
|
|
messageHit = true;
|
|
}
|
|
for (const url of extractLinksFromText(text)) {
|
|
if (matchesSearchQuery(ctx.query, url)) {
|
|
ctx.push({
|
|
kind: "link",
|
|
...destination,
|
|
title: url,
|
|
snippet: snippetAroundMatch(text, ctx.query),
|
|
messageId: ctx.messageId,
|
|
seq: ctx.seq,
|
|
url,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
if (!messageHit && matchesSearchQuery(ctx.query, JSON.stringify(blocks))) {
|
|
ctx.push({
|
|
kind: "message",
|
|
...destination,
|
|
title,
|
|
snippet: ctx.query,
|
|
messageId: ctx.messageId,
|
|
seq: ctx.seq,
|
|
});
|
|
}
|
|
}
|