This commit is contained in:
+212
-16
@@ -3,6 +3,9 @@ import { performance } from "node:perf_hooks";
|
||||
import { z } from "zod";
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||
import { ActiveSseStream, type SseStreamEvent } from "./active-streams.js";
|
||||
import { invokeAgentInline, type AgentInlineInvoker } from "./agent-inline.js";
|
||||
import { buildInlineAgentThreadContext, decodeChatHistoryCursor, decodeChatListCursor, encodeChatListCursor, loadCompleteChatHistory, readChatHistoryPage } from "./chat-history.js";
|
||||
import { containsSybilMention, filterAgentAsideMessages } from "./agent-mention.js";
|
||||
import { prisma } from "./db.js";
|
||||
import { requireAdmin } from "./auth.js";
|
||||
import { env } from "./env.js";
|
||||
@@ -25,6 +28,7 @@ const MAX_FORK_MESSAGE_SNIPPET_CHARS = 48;
|
||||
const EnabledToolsSchema = z.array(z.string().trim().min(1).max(80)).max(20).transform((value) => normalizeEnabledChatTools(value));
|
||||
|
||||
type IncomingChatMessage = {
|
||||
id?: string;
|
||||
role: "system" | "user" | "assistant" | "tool";
|
||||
content: string;
|
||||
name?: string;
|
||||
@@ -92,14 +96,14 @@ function withRequestUserLocation<T extends { userLocation?: string }>(body: T, r
|
||||
return body.userLocation ? body : { ...body, userLocation: inferRequestUserLocation(req) };
|
||||
}
|
||||
|
||||
async function storeNonAssistantMessages(chatId: string, messages: IncomingChatMessage[], clientRequestId?: string) {
|
||||
async function storeNonAssistantMessages(chatId: string, messages: IncomingChatMessage[], clientRequestId?: string, agentAside = false) {
|
||||
const incoming = messages.filter((m) => m.role !== "assistant");
|
||||
if (!incoming.length) return;
|
||||
|
||||
const existing = await prisma.message.findMany({
|
||||
where: { chatId },
|
||||
orderBy: [{ createdAt: "asc" }, { id: "asc" }],
|
||||
select: { role: true, content: true, name: true, metadata: true },
|
||||
select: { id: true, role: true, content: true, name: true, metadata: true },
|
||||
});
|
||||
const existingNonAssistant = existing.filter((m) => m.role !== "assistant" && !isToolCallLogMessage(m));
|
||||
|
||||
@@ -109,7 +113,20 @@ async function storeNonAssistantMessages(chatId: string, messages: IncomingChatM
|
||||
sharedPrefix += 1;
|
||||
}
|
||||
|
||||
if (sharedPrefix === incoming.length) return;
|
||||
if (sharedPrefix === incoming.length) {
|
||||
const lastUserIndex = incoming.map((message) => message.role).lastIndexOf("user");
|
||||
const storedUser = lastUserIndex >= 0 ? existingNonAssistant[lastUserIndex] : undefined;
|
||||
if (agentAside && storedUser && sameMessage(storedUser, incoming[lastUserIndex]!)) {
|
||||
const metadata = storedUser.metadata && typeof storedUser.metadata === "object" && !Array.isArray(storedUser.metadata)
|
||||
? storedUser.metadata as Record<string, unknown>
|
||||
: {};
|
||||
await prisma.message.update({
|
||||
where: { id: storedUser.id },
|
||||
data: { metadata: { ...metadata, ...(clientRequestId ? { clientRequestId } : {}), agentAside: true } as any },
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
const toInsert = sharedPrefix === existingNonAssistant.length ? incoming.slice(existingNonAssistant.length) : incoming;
|
||||
if (!toInsert.length) return;
|
||||
|
||||
@@ -119,6 +136,7 @@ async function storeNonAssistantMessages(chatId: string, messages: IncomingChatM
|
||||
const metadata = {
|
||||
...(m.attachments?.length ? { attachments: m.attachments } : {}),
|
||||
...(clientRequestId && index === finalUserMessageIndex ? { clientRequestId } : {}),
|
||||
...(agentAside && index === finalUserMessageIndex ? { agentAside: true } : {}),
|
||||
};
|
||||
return {
|
||||
chatId,
|
||||
@@ -161,6 +179,7 @@ const ChatAttachmentSchema = z.discriminatedUnion("kind", [
|
||||
|
||||
const CompletionMessageSchema = z
|
||||
.object({
|
||||
id: z.string().trim().min(1).max(128).optional(),
|
||||
role: z.enum(["system", "user", "assistant", "tool"]),
|
||||
content: z.string(),
|
||||
name: z.string().optional(),
|
||||
@@ -228,6 +247,12 @@ function mergeAttachmentsIntoMetadata(metadata: unknown, attachments?: ChatAttac
|
||||
};
|
||||
}
|
||||
|
||||
function removeReservedAgentAsideMetadata(metadata: unknown) {
|
||||
if (!metadata || typeof metadata !== "object" || Array.isArray(metadata)) return metadata;
|
||||
const { agentAside: _agentAside, ...remaining } = metadata as Record<string, unknown>;
|
||||
return Object.keys(remaining).length ? remaining : undefined;
|
||||
}
|
||||
|
||||
function normalizeAdditionalSystemPrompt(value: string | null | undefined) {
|
||||
const trimmed = value?.trim();
|
||||
return trimmed || null;
|
||||
@@ -263,6 +288,71 @@ async function applyStoredChatSettings<T extends { chatId?: string; messages: In
|
||||
});
|
||||
}
|
||||
|
||||
function isInlineAgentRequest(messages: IncomingChatMessage[]) {
|
||||
const latestUserMessage = [...messages].reverse().find((message) => message.role === "user");
|
||||
return latestUserMessage ? containsSybilMention(latestUserMessage.content) : false;
|
||||
}
|
||||
|
||||
async function prepareInlineAgentRequest(chatId: string, requestId: string, messages: IncomingChatMessage[]) {
|
||||
const currentRequest = [...messages].reverse().find((message) => message.role === "user")?.content;
|
||||
if (currentRequest === undefined) throw new Error("The current direct user request is unavailable; Sybil did not answer from partial history.");
|
||||
let history: Awaited<ReturnType<typeof loadCompleteChatHistory>>;
|
||||
try {
|
||||
history = await loadCompleteChatHistory(prisma, chatId);
|
||||
} catch {
|
||||
throw new Error("The complete visible thread could not be loaded; Sybil did not answer from partial history.");
|
||||
}
|
||||
const context = buildInlineAgentThreadContext(chatId, currentRequest, history);
|
||||
return { chatId, requestId, context };
|
||||
}
|
||||
|
||||
async function withoutAgentAsideMessages<T extends { chatId?: string; messages: IncomingChatMessage[] }>(body: T): Promise<T> {
|
||||
if (!body.chatId) return body;
|
||||
const storedMessages = await prisma.message.findMany({
|
||||
where: { chatId: body.chatId },
|
||||
orderBy: [{ createdAt: "asc" }, { id: "asc" }],
|
||||
select: { id: true, role: true, content: true, name: true, metadata: true },
|
||||
});
|
||||
const messages = filterAgentAsideMessages(body.messages, storedMessages, (incoming, stored) => sameMessage(stored, incoming));
|
||||
return { ...body, messages };
|
||||
}
|
||||
|
||||
async function storeAgentAsideReply(chatId: string, content: string, clientRequestId?: string) {
|
||||
return prisma.message.create({
|
||||
data: {
|
||||
chatId,
|
||||
role: "assistant" as any,
|
||||
content,
|
||||
name: "Sybil Agent",
|
||||
metadata: { agentAside: true, ...(clientRequestId ? { clientRequestId } : {}) } as any,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function executeActiveAgentAsideStream(chatId: string, body: z.infer<typeof CompletionStreamBody>, stream: ActiveSseStream, invoke: AgentInlineInvoker) {
|
||||
stream.emit("meta", {
|
||||
type: "meta",
|
||||
chatId,
|
||||
callId: null,
|
||||
provider: "hermes-agent",
|
||||
model: "sybil-agent",
|
||||
agentAside: true,
|
||||
});
|
||||
void (async () => {
|
||||
try {
|
||||
const request = await prepareInlineAgentRequest(chatId, body.clientRequestId ?? randomUUID(), body.messages);
|
||||
const text = await invoke(request);
|
||||
await storeAgentAsideReply(chatId, text, body.clientRequestId);
|
||||
stream.emit("delta", { type: "delta", text });
|
||||
stream.complete({ event: "done", data: { type: "done", text } });
|
||||
} catch (error) {
|
||||
stream.complete({ event: "error", data: { message: getErrorMessage(error) } });
|
||||
} finally {
|
||||
clearActiveChatStream(chatId, stream);
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
const SearchRunBody = z.object({
|
||||
query: z.string().trim().min(1).optional(),
|
||||
title: z.string().trim().min(1).optional(),
|
||||
@@ -717,14 +807,16 @@ function completeChatSubmissionStream(
|
||||
stream: ActiveSseStream,
|
||||
chatId: string,
|
||||
body: z.infer<typeof CompletionStreamBody>,
|
||||
assistantText: string
|
||||
assistantText: string,
|
||||
agentAside = false,
|
||||
) {
|
||||
stream.emit("meta", {
|
||||
type: "meta",
|
||||
chatId,
|
||||
callId: null,
|
||||
provider: body.provider,
|
||||
model: body.model,
|
||||
provider: agentAside ? "hermes-agent" : body.provider,
|
||||
model: agentAside ? "sybil-agent" : body.model,
|
||||
...(agentAside ? { agentAside: true } : {}),
|
||||
});
|
||||
stream.complete({
|
||||
event: "done",
|
||||
@@ -900,7 +992,8 @@ async function executeSearchRunStream(searchId: string, body: SearchRunRequest,
|
||||
}
|
||||
}
|
||||
|
||||
export async function registerRoutes(app: FastifyInstance) {
|
||||
export async function registerRoutes(app: FastifyInstance, options: { agentInlineInvoker?: AgentInlineInvoker } = {}) {
|
||||
const agentInlineInvoker = options.agentInlineInvoker ?? invokeAgentInline;
|
||||
app.get("/health", { logLevel: "silent" }, async () => ({ ok: true }));
|
||||
|
||||
app.get("/v1/auth/session", async (req) => {
|
||||
@@ -962,8 +1055,42 @@ export async function registerRoutes(app: FastifyInstance) {
|
||||
return { items: await listWorkspaceItems() };
|
||||
});
|
||||
|
||||
app.get("/v1/chats", async (req) => {
|
||||
app.get("/v1/chats", async (req, reply) => {
|
||||
requireAdmin(req);
|
||||
const Query = z.object({ cursor: z.string().max(512).optional(), limit: z.coerce.number().int().min(1).max(100).optional() });
|
||||
const rawQuery = req.query as Record<string, unknown>;
|
||||
if (Object.keys(rawQuery).length) {
|
||||
reply.header("Cache-Control", "no-store");
|
||||
const parsedQuery = Query.safeParse(req.query);
|
||||
if (!parsedQuery.success) return app.httpErrors.badRequest(parsedQuery.error.message);
|
||||
let cursor: ReturnType<typeof decodeChatListCursor> | undefined;
|
||||
if (parsedQuery.data.cursor) {
|
||||
try {
|
||||
cursor = decodeChatListCursor(parsedQuery.data.cursor);
|
||||
} catch {
|
||||
return app.httpErrors.badRequest("Invalid chat-list cursor");
|
||||
}
|
||||
}
|
||||
const limit = parsedQuery.data.limit ?? 50;
|
||||
const chats = await prisma.chat.findMany({
|
||||
where: cursor ? {
|
||||
OR: [
|
||||
{ updatedAt: { lt: cursor.updatedAt } },
|
||||
{ updatedAt: cursor.updatedAt, id: { gt: cursor.id } },
|
||||
],
|
||||
} : undefined,
|
||||
orderBy: [{ updatedAt: "desc" }, { id: "asc" }],
|
||||
take: limit + 1,
|
||||
select: { id: true, title: true, parentChatId: true, createdAt: true, updatedAt: true, initiatedProvider: true, initiatedModel: true, lastUsedProvider: true, lastUsedModel: true },
|
||||
});
|
||||
const hasMore = chats.length > limit;
|
||||
const page = hasMore ? chats.slice(0, limit) : chats;
|
||||
const last = page.at(-1);
|
||||
return {
|
||||
chats: page.map((chat) => serializeProviderFields(chat)),
|
||||
nextCursor: hasMore && last ? encodeChatListCursor({ updatedAt: last.updatedAt, id: last.id }) : null,
|
||||
};
|
||||
}
|
||||
const chats = await listRecentChatsWithRoots();
|
||||
return { chats: chats.map((chat) => serializeChatLike(chat)) };
|
||||
});
|
||||
@@ -1513,6 +1640,32 @@ export async function registerRoutes(app: FastifyInstance) {
|
||||
return streamActiveRun(req, reply, stream);
|
||||
});
|
||||
|
||||
app.get("/v1/chats/:chatId/messages", async (req, reply) => {
|
||||
reply.header("Cache-Control", "no-store");
|
||||
requireAdmin(req);
|
||||
const Params = z.object({ chatId: z.string() });
|
||||
const Query = z.object({
|
||||
cursor: z.string().max(512).optional(),
|
||||
limit: z.coerce.number().int().min(1).max(100).default(50),
|
||||
});
|
||||
const { chatId } = Params.parse(req.params);
|
||||
const parsedQuery = Query.safeParse(req.query);
|
||||
if (!parsedQuery.success) return app.httpErrors.badRequest(parsedQuery.error.message);
|
||||
const { limit } = parsedQuery.data;
|
||||
let cursor: ReturnType<typeof decodeChatHistoryCursor> | undefined;
|
||||
if (parsedQuery.data.cursor) {
|
||||
try {
|
||||
cursor = decodeChatHistoryCursor(parsedQuery.data.cursor);
|
||||
} catch {
|
||||
return app.httpErrors.badRequest("Invalid history cursor");
|
||||
}
|
||||
}
|
||||
|
||||
const page = await readChatHistoryPage(prisma, chatId, limit, cursor);
|
||||
if (!page) return app.httpErrors.notFound("chat not found");
|
||||
return page;
|
||||
});
|
||||
|
||||
app.get("/v1/chats/:chatId", async (req) => {
|
||||
requireAdmin(req);
|
||||
const Params = z.object({ chatId: z.string() });
|
||||
@@ -1552,7 +1705,7 @@ export async function registerRoutes(app: FastifyInstance) {
|
||||
role: body.role as any,
|
||||
content: body.content,
|
||||
name: body.name,
|
||||
metadata: mergeAttachmentsIntoMetadata(body.metadata, body.attachments) as any,
|
||||
metadata: mergeAttachmentsIntoMetadata(removeReservedAgentAsideMetadata(body.metadata), body.attachments) as any,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1574,6 +1727,7 @@ export async function registerRoutes(app: FastifyInstance) {
|
||||
|
||||
const Body = z.object({
|
||||
chatId: z.string().optional(),
|
||||
clientRequestId: z.string().trim().min(1).max(128).optional(),
|
||||
provider: ProviderSchema,
|
||||
model: z.string().min(1),
|
||||
messages: z.array(CompletionMessageSchema),
|
||||
@@ -1596,10 +1750,44 @@ export async function registerRoutes(app: FastifyInstance) {
|
||||
|
||||
// Store only new non-assistant messages to avoid duplicate history entries.
|
||||
if (body.chatId) {
|
||||
await storeNonAssistantMessages(body.chatId, body.messages);
|
||||
const agentAside = isInlineAgentRequest(body.messages);
|
||||
await storeNonAssistantMessages(body.chatId, body.messages, body.clientRequestId, agentAside);
|
||||
if (agentAside) {
|
||||
if (body.clientRequestId) {
|
||||
const completed = await findCompletedChatSubmission(body.chatId, body.clientRequestId);
|
||||
if (completed) {
|
||||
const completedAgentAside = Boolean((completed.metadata as Record<string, unknown> | null)?.agentAside === true);
|
||||
return {
|
||||
chatId: body.chatId,
|
||||
provider: completedAgentAside ? "hermes-agent" : body.provider,
|
||||
model: completedAgentAside ? "sybil-agent" : body.model,
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: completed.content,
|
||||
...(completedAgentAside ? { name: "Sybil Agent", agentAside: true } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
const requestId = body.clientRequestId ?? randomUUID();
|
||||
try {
|
||||
const request = await prepareInlineAgentRequest(body.chatId, requestId, body.messages);
|
||||
const content = await agentInlineInvoker(request);
|
||||
await storeAgentAsideReply(body.chatId, content, body.clientRequestId);
|
||||
return {
|
||||
chatId: body.chatId,
|
||||
provider: "hermes-agent",
|
||||
model: "sybil-agent",
|
||||
message: { role: "assistant", content, name: "Sybil Agent", agentAside: true },
|
||||
};
|
||||
} catch (error) {
|
||||
return app.httpErrors.unprocessableEntity(getErrorMessage(error));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const result = await runMultiplex(await applyStoredChatSettings(body));
|
||||
const contextBody = await withoutAgentAsideMessages(body);
|
||||
const result = await runMultiplex(await applyStoredChatSettings(contextBody));
|
||||
|
||||
return {
|
||||
chatId: body.chatId ?? null,
|
||||
@@ -1635,6 +1823,7 @@ export async function registerRoutes(app: FastifyInstance) {
|
||||
const parsed = CompletionStreamBody.safeParse(req.body);
|
||||
if (!parsed.success) return app.httpErrors.badRequest(parsed.error.message);
|
||||
const body = withRequestUserLocation(parsed.data, req);
|
||||
const agentAside = body.chatId ? isInlineAgentRequest(body.messages) : false;
|
||||
|
||||
// Ensure the chat exists and identify its family before reserving a stream.
|
||||
let chatFamilyRootId: string | null = null;
|
||||
@@ -1665,16 +1854,22 @@ export async function registerRoutes(app: FastifyInstance) {
|
||||
if (body.clientRequestId) {
|
||||
const completedSubmission = await findCompletedChatSubmission(body.chatId, body.clientRequestId);
|
||||
if (completedSubmission) {
|
||||
completeChatSubmissionStream(reservedStream, body.chatId, body, completedSubmission.content);
|
||||
const completedAgentAside = Boolean((completedSubmission.metadata as Record<string, unknown> | null)?.agentAside === true);
|
||||
completeChatSubmissionStream(reservedStream, body.chatId, body, completedSubmission.content, completedAgentAside);
|
||||
clearActiveChatStream(body.chatId, reservedStream);
|
||||
return streamActiveRun(req, reply, reservedStream);
|
||||
}
|
||||
}
|
||||
|
||||
// Reserve the stream before persistence so deletion cannot interleave with setup.
|
||||
await storeNonAssistantMessages(body.chatId, body.messages, body.clientRequestId);
|
||||
const configuredBody = await applyStoredChatSettings(body);
|
||||
executeActiveChatStream(body.chatId, configuredBody, reservedStream);
|
||||
await storeNonAssistantMessages(body.chatId, body.messages, body.clientRequestId, agentAside);
|
||||
if (agentAside) {
|
||||
executeActiveAgentAsideStream(body.chatId, body, reservedStream, agentInlineInvoker);
|
||||
} else {
|
||||
const contextBody = await withoutAgentAsideMessages(body);
|
||||
const configuredBody = await applyStoredChatSettings(contextBody);
|
||||
executeActiveChatStream(body.chatId, configuredBody, reservedStream);
|
||||
}
|
||||
return streamActiveRun(req, reply, reservedStream);
|
||||
} catch (err) {
|
||||
reservedStream.complete({ event: "error", data: { message: getErrorMessage(err) } });
|
||||
@@ -1686,7 +1881,8 @@ export async function registerRoutes(app: FastifyInstance) {
|
||||
reply.raw.writeHead(200, buildSseHeaders(typeof req.headers.origin === "string" ? req.headers.origin : undefined));
|
||||
reply.raw.flushHeaders();
|
||||
|
||||
for await (const ev of runMultiplexStream(await applyStoredChatSettings(body))) {
|
||||
const contextBody = await withoutAgentAsideMessages(body);
|
||||
for await (const ev of runMultiplexStream(await applyStoredChatSettings(contextBody))) {
|
||||
writeSseEvent(reply, mapChatStreamEvent(ev));
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user