Files
cjyyzx/packages/database/src/models/agent.ts
T
xiaohei c3210b2eec
E2E CI / Check Duplicate Run (push) Failing after 3s
🔄 Branch Synchronization / sync-branches (push) Failing after 3s
Test CI / Check Duplicate Run (push) Failing after 3s
E2E CI / Test Web App (push) Has been skipped
Test CI / Test App (shard 1/3) (push) Has been skipped
Test CI / Test Packages (push) Has been skipped
Test CI / Test App (shard 3/3) (push) Has been skipped
Test CI / Test App (shard 2/3) (push) Has been skipped
Test CI / Test Database (push) Has been skipped
Test CI / Test Desktop App (push) Has been skipped
Test CI / Merge and Upload App Coverage (push) Has been skipped
LobeChat deep customization: welcome tagline / example prompts / default agent / model-admin lock / enterprise identity bridge
2026-04-21 20:34:20 +08:00

601 lines
19 KiB
TypeScript

import { getAgentPersistConfig } from '@lobechat/builtin-agents';
import { DEFAULT_INBOX_AVATAR, INBOX_SESSION_ID } from '@lobechat/const';
import { and, desc, eq, ilike, inArray, isNull, or, sql } from 'drizzle-orm';
import type { PartialDeep } from 'type-fest';
import { merge } from '@/utils/merge';
import type { AgentItem } from '../schemas';
import {
agents,
agentsFiles,
agentsKnowledgeBases,
agentsToSessions,
documents,
files,
knowledgeBases,
sessions,
} from '../schemas';
import type { LobeChatDatabase } from '../type';
export class AgentModel {
private userId: string;
private db: LobeChatDatabase;
constructor(db: LobeChatDatabase, userId: string) {
this.userId = userId;
this.db = db;
}
getAgentConfigById = async (id: string) => {
const agent = await this.db.query.agents.findFirst({
where: and(eq(agents.id, id), eq(agents.userId, this.userId)),
});
if (!agent) return null;
return this.enrichAgentWithKnowledge(agent);
};
/**
* Query non-virtual agents with optional keyword filter.
* Returns minimal agent info (id, title, description, avatar, backgroundColor).
* Excludes virtual agents (like inbox, supervisors, etc).
*/
queryAgents = async (params?: { keyword?: string; limit?: number; offset?: number }) => {
const { keyword, limit = 9999, offset = 0 } = params ?? {};
// Include agents where virtual is false OR null (legacy data without virtual field)
const baseConditions = and(
eq(agents.userId, this.userId),
or(eq(agents.virtual, false), isNull(agents.virtual)),
);
// Add keyword search condition if provided
const searchCondition = keyword
? and(
baseConditions,
or(ilike(agents.title, `%${keyword}%`), ilike(agents.description, `%${keyword}%`)),
)
: baseConditions;
return this.db
.select({
avatar: agents.avatar,
backgroundColor: agents.backgroundColor,
description: agents.description,
id: agents.id,
title: agents.title,
})
.from(agents)
.where(searchCondition)
.orderBy(desc(agents.updatedAt))
.limit(limit)
.offset(offset);
};
/**
* Get minimal agent info (avatar, title, backgroundColor) by IDs.
* For inbox agent (slug='inbox'), falls back to LobeAI defaults when avatar/title are missing.
*/
getAgentAvatarsByIds = async (ids: string[]) => {
if (ids.length === 0) return [];
const rows = await this.db
.select({
avatar: agents.avatar,
backgroundColor: agents.backgroundColor,
id: agents.id,
slug: agents.slug,
title: agents.title,
})
.from(agents)
.where(and(eq(agents.userId, this.userId), inArray(agents.id, ids)));
return rows.map(({ slug, ...row }) => ({
...row,
avatar: row.avatar || (slug === INBOX_SESSION_ID ? DEFAULT_INBOX_AVATAR : null),
title: row.title || (slug === INBOX_SESSION_ID ? 'Enterprise Assistant' : null),
}));
};
/**
* Get agent config by ID or slug (single query with OR condition)
*/
getAgentConfig = async (idOrSlug: string) => {
const agent = await this.db.query.agents.findFirst({
where: and(
eq(agents.userId, this.userId),
or(eq(agents.id, idOrSlug), eq(agents.slug, idOrSlug)),
),
});
if (!agent) return null;
return this.enrichAgentWithKnowledge(agent);
};
/**
* Enrich agent with knowledge base and files data
*/
private enrichAgentWithKnowledge = async (agent: AgentItem) => {
const knowledge = await this.getAgentAssignedKnowledge(agent.id);
// Fetch document content for enabled files
const enabledFileIds = knowledge.files
.filter((f) => f.enabled)
.map((f) => f.id)
.filter((id) => id !== undefined);
let files: Array<(typeof knowledge.files)[number] & { content?: string | null }> =
knowledge.files;
if (enabledFileIds.length > 0) {
const documentsData = await this.db.query.documents.findMany({
where: and(eq(documents.userId, this.userId), inArray(documents.fileId, enabledFileIds)),
});
const documentMap = new Map(documentsData.map((doc) => [doc.fileId, doc.content]));
files = knowledge.files.map((file) => ({
...file,
content: file.enabled && file.id ? documentMap.get(file.id) : undefined,
}));
}
return { ...agent, ...knowledge, files };
};
getAgentAssignedKnowledge = async (id: string) => {
// Run both queries in parallel for better performance
// Include userId check to ensure user can only access their own agent's knowledge
const [knowledgeBaseResult, fileResult] = await Promise.all([
this.db
.select({ enabled: agentsKnowledgeBases.enabled, knowledgeBases })
.from(agentsKnowledgeBases)
.where(
and(eq(agentsKnowledgeBases.agentId, id), eq(agentsKnowledgeBases.userId, this.userId)),
)
.orderBy(desc(agentsKnowledgeBases.createdAt))
.leftJoin(knowledgeBases, eq(knowledgeBases.id, agentsKnowledgeBases.knowledgeBaseId)),
this.db
.select({ enabled: agentsFiles.enabled, files })
.from(agentsFiles)
.where(and(eq(agentsFiles.agentId, id), eq(agentsFiles.userId, this.userId)))
.orderBy(desc(agentsFiles.createdAt))
.leftJoin(files, eq(files.id, agentsFiles.fileId)),
]);
return {
files: fileResult.map((item) => ({
...item.files,
enabled: item.enabled,
})),
knowledgeBases: knowledgeBaseResult.map((item) => ({
...item.knowledgeBases,
enabled: item.enabled,
})),
};
};
/**
* Find agent by session id
*/
findBySessionId = async (sessionId: string) => {
const item = await this.db.query.agentsToSessions.findFirst({
where: and(
eq(agentsToSessions.sessionId, sessionId),
eq(agentsToSessions.userId, this.userId),
),
});
if (!item) return;
const agentId = item.agentId;
return this.getAgentConfigById(agentId);
};
createAgentKnowledgeBase = async (
agentId: string,
knowledgeBaseId: string,
enabled: boolean = true,
) => {
return this.db.insert(agentsKnowledgeBases).values({
agentId,
enabled,
knowledgeBaseId,
userId: this.userId,
});
};
deleteAgentKnowledgeBase = async (agentId: string, knowledgeBaseId: string) => {
return this.db
.delete(agentsKnowledgeBases)
.where(
and(
eq(agentsKnowledgeBases.agentId, agentId),
eq(agentsKnowledgeBases.knowledgeBaseId, knowledgeBaseId),
eq(agentsKnowledgeBases.userId, this.userId),
),
);
};
toggleKnowledgeBase = async (agentId: string, knowledgeBaseId: string, enabled?: boolean) => {
return this.db
.update(agentsKnowledgeBases)
.set({ enabled })
.where(
and(
eq(agentsKnowledgeBases.agentId, agentId),
eq(agentsKnowledgeBases.knowledgeBaseId, knowledgeBaseId),
eq(agentsKnowledgeBases.userId, this.userId),
),
);
};
createAgentFiles = async (agentId: string, fileIds: string[], enabled: boolean = true) => {
// Exclude the fileIds that already exist in agentsFiles, and then insert them
const existingFiles = await this.db
.select({ id: agentsFiles.fileId })
.from(agentsFiles)
.where(
and(
eq(agentsFiles.agentId, agentId),
eq(agentsFiles.userId, this.userId),
inArray(agentsFiles.fileId, fileIds),
),
);
const existingFilesIds = new Set(existingFiles.map((item) => item.id));
const needToInsertFileIds = fileIds.filter((fileId) => !existingFilesIds.has(fileId));
if (needToInsertFileIds.length === 0) return;
return this.db
.insert(agentsFiles)
.values(
needToInsertFileIds.map((fileId) => ({ agentId, enabled, fileId, userId: this.userId })),
);
};
deleteAgentFile = async (agentId: string, fileId: string) => {
return this.db
.delete(agentsFiles)
.where(
and(
eq(agentsFiles.agentId, agentId),
eq(agentsFiles.fileId, fileId),
eq(agentsFiles.userId, this.userId),
),
);
};
/**
* Delete an agent and its associated session.
* This will cascade delete messages, topics, etc. through the session deletion.
*/
delete = async (agentId: string) => {
return this.db.transaction(async (trx) => {
// 1. Get associated session IDs
const links = await trx
.select({ sessionId: agentsToSessions.sessionId })
.from(agentsToSessions)
.where(
and(eq(agentsToSessions.agentId, agentId), eq(agentsToSessions.userId, this.userId)),
);
const sessionIds = links.map((link) => link.sessionId);
// 2. Delete links in agentsToSessions
await trx
.delete(agentsToSessions)
.where(
and(eq(agentsToSessions.agentId, agentId), eq(agentsToSessions.userId, this.userId)),
);
// 3. Delete associated sessions (this will cascade delete messages, topics, etc.)
if (sessionIds.length > 0) {
await trx
.delete(sessions)
.where(and(inArray(sessions.id, sessionIds), eq(sessions.userId, this.userId)));
}
// 4. Delete the agent itself
return trx.delete(agents).where(and(eq(agents.id, agentId), eq(agents.userId, this.userId)));
});
};
/**
* Batch delete agents by IDs.
* This is a simpler delete that only removes the agent records.
* Use this for virtual agents that don't have associated sessions.
*/
batchDelete = async (agentIds: string[]) => {
if (agentIds.length === 0) return;
return this.db
.delete(agents)
.where(and(eq(agents.userId, this.userId), inArray(agents.id, agentIds)));
};
toggleFile = async (agentId: string, fileId: string, enabled?: boolean) => {
return this.db
.update(agentsFiles)
.set({ enabled })
.where(
and(
eq(agentsFiles.agentId, agentId),
eq(agentsFiles.fileId, fileId),
eq(agentsFiles.userId, this.userId),
),
);
};
/**
* Create an agent record only (without creating a session).
* This is used for creating virtual agents (e.g., group chat members).
*/
create = async (config: Partial<AgentItem>): Promise<AgentItem> => {
const [result] = await this.db
.insert(agents)
.values([
{
...config,
model: typeof config.model === 'string' ? config.model : null,
userId: this.userId,
},
])
.returning();
return result;
};
/**
* Batch create multiple agents (without sessions).
* Used for creating multiple virtual agents at once (e.g., group chat members).
*/
batchCreate = async (configs: Partial<AgentItem>[]): Promise<AgentItem[]> => {
if (configs.length === 0) return [];
return this.db
.insert(agents)
.values(
configs.map((config) => ({
...config,
model: typeof config.model === 'string' ? config.model : null,
userId: this.userId,
})),
)
.returning();
};
update = async (agentId: string, data: Partial<AgentItem>) => {
return this.db
.update(agents)
.set({ ...data, updatedAt: new Date() })
.where(and(eq(agents.id, agentId), eq(agents.userId, this.userId)));
};
touchUpdatedAt = async (agentId: string) => {
return this.update(agentId, {});
};
/**
* Check if an agent with the given marketIdentifier already exists
* @returns true if exists, false otherwise
*/
checkByMarketIdentifier = async (marketIdentifier: string): Promise<boolean> => {
const result = await this.db.query.agents.findFirst({
where: and(eq(agents.marketIdentifier, marketIdentifier), eq(agents.userId, this.userId)),
});
return !!result;
};
/**
* Get an agent by marketIdentifier
* If multiple agents match, returns the most recently updated one
* @returns agent id if exists, null otherwise
*/
getAgentByMarketIdentifier = async (marketIdentifier: string): Promise<string | null> => {
const result = await this.db.query.agents.findFirst({
columns: { id: true },
orderBy: (agents, { desc }) => [desc(agents.updatedAt)],
where: and(eq(agents.marketIdentifier, marketIdentifier), eq(agents.userId, this.userId)),
});
return result?.id ?? null;
};
/**
* Get an agent by the forkedFromIdentifier stored in params
* @param forkedFromIdentifier - The source agent's market identifier
* @returns agent id if exists, null otherwise
*/
getAgentByForkedFromIdentifier = async (forkedFromIdentifier: string): Promise<string | null> => {
const result = await this.db.query.agents.findFirst({
columns: { id: true },
orderBy: (agents, { desc }) => [desc(agents.updatedAt)],
where: and(
eq(agents.userId, this.userId),
sql`${agents.params}->>'forkedFromIdentifier' = ${forkedFromIdentifier}`,
),
});
return result?.id ?? null;
};
updateConfig = async (agentId: string, data: PartialDeep<AgentItem> | undefined | null) => {
if (!data || Object.keys(data).length === 0) return;
const agent = await this.db.query.agents.findFirst({
where: and(eq(agents.id, agentId), eq(agents.userId, this.userId)),
});
if (!agent) return;
// First process the params field: undefined means delete, null means disable flag
const existingParams = agent.params ?? {};
const updatedParams: Record<string, any> = { ...existingParams };
if (data.params) {
const incomingParams = data.params as Record<string, any>;
Object.keys(incomingParams).forEach((key) => {
const incomingValue = incomingParams[key];
// undefined means explicitly delete this field
if (incomingValue === undefined) {
delete updatedParams[key];
return;
}
// All other values (including null) are directly overwritten, null means disable this param on the frontend
updatedParams[key] = incomingValue;
});
}
// Build data to be merged, excluding params (processed separately)
const { params: _params, ...restData } = data;
const mergedValue = merge(agent, restData);
// Apply the processed parameters
mergedValue.params = Object.keys(updatedParams).length > 0 ? updatedParams : undefined;
// Final cleanup: ensure no undefined or null values enter the database
if (mergedValue.params) {
const params = mergedValue.params as Record<string, any>;
Object.keys(params).forEach((key) => {
if (params[key] === undefined) {
delete params[key];
}
});
if (Object.keys(params).length === 0) {
mergedValue.params = undefined;
}
}
// Remove timestamp fields to let Drizzle's $onUpdate handle them automatically
const { updatedAt: _, accessedAt: __, createdAt: ___, ...updateData } = mergedValue;
return this.db
.update(agents)
.set(updateData)
.where(and(eq(agents.id, agentId), eq(agents.userId, this.userId)));
};
/**
* Update the sessionGroupId for an agent
*/
updateSessionGroupId = async (agentId: string, sessionGroupId: string | null) => {
const result = await this.db
.update(agents)
.set({ sessionGroupId, updatedAt: new Date() })
.where(and(eq(agents.id, agentId), eq(agents.userId, this.userId)))
.returning();
return result[0];
};
/**
* Duplicate an agent.
* Returns the new agent ID.
*/
duplicate = async (agentId: string, newTitle?: string): Promise<{ agentId: string } | null> => {
// Get the source agent
const sourceAgent = await this.db.query.agents.findFirst({
where: and(eq(agents.id, agentId), eq(agents.userId, this.userId)),
});
if (!sourceAgent) return null;
// Create new agent with explicit include fields
const [newAgent] = await this.db
.insert(agents)
.values({
avatar: sourceAgent.avatar,
backgroundColor: sourceAgent.backgroundColor,
chatConfig: sourceAgent.chatConfig,
description: sourceAgent.description,
fewShots: sourceAgent.fewShots,
model: sourceAgent.model,
openingMessage: sourceAgent.openingMessage,
openingQuestions: sourceAgent.openingQuestions,
params: sourceAgent.params,
pinned: sourceAgent.pinned,
// Config
plugins: sourceAgent.plugins,
provider: sourceAgent.provider,
// Session group
sessionGroupId: sourceAgent.sessionGroupId,
systemRole: sourceAgent.systemRole,
tags: sourceAgent.tags,
// Metadata
title: newTitle || (sourceAgent.title ? `${sourceAgent.title} (Copy)` : 'Copy'),
tts: sourceAgent.tts,
// User
userId: this.userId,
})
.returning();
return { agentId: newAgent.id };
};
/**
* Get a builtin agent by slug, creating it if it doesn't exist.
* Builtin agents are standalone agents not bound to sessions.
*
*/
getBuiltinAgent = async (slug: string): Promise<AgentItem | null> => {
// 1. First try to find existing agent by slug
const existing = await this.db.query.agents.findFirst({
where: and(eq(agents.slug, slug), eq(agents.userId, this.userId)),
});
if (existing) return existing;
// For inbox agent, it has special compatibility handling:
// Historical inbox was stored as session with slug='inbox' and linked agent via agentsToSessions
// If found, update the agent's slug to 'inbox' for future direct queries
if (slug === INBOX_SESSION_ID) {
// Use join query for better performance instead of multiple findFirst calls
const result = await this.db
.select({ agent: agents })
.from(sessions)
.innerJoin(agentsToSessions, eq(sessions.id, agentsToSessions.sessionId))
.innerJoin(agents, eq(agentsToSessions.agentId, agents.id))
.where(and(eq(sessions.slug, INBOX_SESSION_ID), eq(sessions.userId, this.userId)))
.limit(1);
if (result.length > 0 && result[0].agent) {
// Update the agent's slug to 'inbox' for future direct queries
// Use both id and userId to ensure we only update current user's agent
const [updatedAgent] = await this.db
.update(agents)
.set({ slug: INBOX_SESSION_ID, virtual: true })
.where(eq(agents.id, result[0].agent.id))
.returning();
return updatedAgent;
}
}
// 3. Check if this is a known builtin agent
const persistConfig = getAgentPersistConfig(slug);
if (!persistConfig) return null;
// 4. Create the builtin agent with persist config
const result = await this.db
.insert(agents)
.values({
model: persistConfig.model,
provider: persistConfig.provider,
slug: persistConfig.slug,
userId: this.userId,
virtual: true,
})
.returning();
return result[0];
};
}