4651 lines
128 KiB
TypeScript
4651 lines
128 KiB
TypeScript
import "server-only";
|
||
|
||
import {
|
||
and,
|
||
asc,
|
||
count,
|
||
desc,
|
||
eq,
|
||
gt,
|
||
gte,
|
||
inArray,
|
||
like,
|
||
lt,
|
||
or,
|
||
sql,
|
||
type SQL,
|
||
} from "drizzle-orm";
|
||
import { aliasedTable } from "drizzle-orm/alias";
|
||
import { drizzle } from "drizzle-orm/postgres-js";
|
||
import postgres from "postgres";
|
||
import type { ArtifactKind } from "@/components/artifact";
|
||
import type { VisibilityType } from "@/components/visibility-selector";
|
||
import { ChatSDKError } from "../errors";
|
||
import type { AppUsage } from "../usage";
|
||
import { generateUUID } from "../utils";
|
||
import {
|
||
agentPreset,
|
||
agentPresetCategory,
|
||
agentPresetExecutionSnapshot,
|
||
agentPresetReferenceAsset,
|
||
agentPresetSlot,
|
||
type Chat,
|
||
chat,
|
||
type DBMessage,
|
||
document,
|
||
message,
|
||
type Suggestion,
|
||
stream,
|
||
suggestion,
|
||
systemSettings,
|
||
toolConfig,
|
||
type ToolConfig,
|
||
type SystemSettings,
|
||
type User,
|
||
user,
|
||
vote,
|
||
aiCadWorkspace,
|
||
type AiCadWorkspace,
|
||
flowBoardDraft,
|
||
type FlowBoardDraft,
|
||
type FlowBoardSnapshotData,
|
||
aiProvider,
|
||
type AiProvider,
|
||
aiModel,
|
||
type AiModel,
|
||
storageConfig,
|
||
type StorageConfig,
|
||
siteSettings,
|
||
navLinks,
|
||
authSettings,
|
||
loginToken,
|
||
generatedImage,
|
||
pointsLedger,
|
||
tutorialVideos,
|
||
messageDeprecated,
|
||
conversationWorkingState,
|
||
type ConversationWorkingState,
|
||
messageTargetResolution,
|
||
type MessageTargetResolution,
|
||
mediaAsset,
|
||
type MediaAsset,
|
||
mediaAssetRelation,
|
||
type MediaAssetRelation,
|
||
type AgentPreset,
|
||
type AgentPresetCategory,
|
||
type AgentPresetExecutionSnapshot,
|
||
type AgentPresetReferenceAsset,
|
||
type AgentPresetSlot,
|
||
assetMemorySnapshot,
|
||
type AssetMemorySnapshot,
|
||
canvasNode,
|
||
canvasProject,
|
||
} from "./schema";
|
||
import { generateHashedPassword } from "./utils";
|
||
import {
|
||
isAiModelAgentAuto,
|
||
isAiModelMultimodal,
|
||
withAgentAutoModelFlag,
|
||
withAgentCodingModelId,
|
||
withMultimodalModelFlag,
|
||
} from "./ai-model-flags";
|
||
import {
|
||
defaultPointsSystemConfig,
|
||
normalizePointsSystemConfig,
|
||
POINTS_SYSTEM_CONFIG_KEY,
|
||
} from "../points/config";
|
||
import { inferOriginalImageUrl } from "../image-url";
|
||
import { getAgentRequiredUploadCount } from "../agents/preset-mode-config";
|
||
import { isVideoModelDescriptor } from "../ai/model-classification";
|
||
|
||
// Optionally, if not using email/pass login, you can
|
||
// use the Drizzle adapter for Auth.js / NextAuth
|
||
// https://authjs.dev/reference/adapter/drizzle
|
||
|
||
// biome-ignore lint: Forbidden non-null assertion.
|
||
import { db } from "@/lib/db";
|
||
|
||
|
||
export async function getUser(email: string): Promise<User[]> {
|
||
const normalizedEmail = email.trim().toLowerCase();
|
||
if (!normalizedEmail) {
|
||
return [];
|
||
}
|
||
|
||
try {
|
||
return await db
|
||
.select()
|
||
.from(user)
|
||
.where(sql`lower(${user.email}) = ${normalizedEmail}`)
|
||
.orderBy(desc(user.createdAt));
|
||
} catch (_error) {
|
||
throw new ChatSDKError(
|
||
"bad_request:database",
|
||
"Failed to get user by email"
|
||
);
|
||
}
|
||
}
|
||
|
||
export async function createUser(email: string, password: string) {
|
||
const hashedPassword = generateHashedPassword(password);
|
||
const normalizedEmail = email.trim().toLowerCase();
|
||
|
||
try {
|
||
return await db.insert(user).values({ email: normalizedEmail, password: hashedPassword });
|
||
} catch (_error) {
|
||
throw new ChatSDKError("bad_request:database", "Failed to create user");
|
||
}
|
||
}
|
||
|
||
export async function createGuestUser() {
|
||
const email = `guest-${Date.now()}`;
|
||
const password = generateHashedPassword(generateUUID());
|
||
|
||
try {
|
||
return await db.insert(user).values({ email, password, role: "guest" }).returning({
|
||
id: user.id,
|
||
email: user.email,
|
||
});
|
||
} catch (_error) {
|
||
throw new ChatSDKError(
|
||
"bad_request:database",
|
||
"Failed to create guest user"
|
||
);
|
||
}
|
||
}
|
||
|
||
export async function saveChat({
|
||
id,
|
||
userId,
|
||
title,
|
||
visibility,
|
||
}: {
|
||
id: string;
|
||
userId: string;
|
||
title: string;
|
||
visibility: VisibilityType;
|
||
}) {
|
||
try {
|
||
return await db.insert(chat).values({
|
||
id,
|
||
createdAt: new Date(),
|
||
userId,
|
||
title,
|
||
visibility,
|
||
});
|
||
} catch (_error) {
|
||
throw new ChatSDKError("bad_request:database", "Failed to save chat");
|
||
}
|
||
}
|
||
|
||
export async function deleteChatById({ id }: { id: string }) {
|
||
try {
|
||
await db.delete(vote).where(eq(vote.chatId, id));
|
||
await db.delete(message).where(eq(message.chatId, id));
|
||
await db.delete(stream).where(eq(stream.chatId, id));
|
||
|
||
const [chatsDeleted] = await db
|
||
.delete(chat)
|
||
.where(eq(chat.id, id))
|
||
.returning();
|
||
return chatsDeleted;
|
||
} catch (_error) {
|
||
throw new ChatSDKError(
|
||
"bad_request:database",
|
||
"Failed to delete chat by id"
|
||
);
|
||
}
|
||
}
|
||
|
||
export async function deleteAllChatsByUserId({ userId }: { userId: string }) {
|
||
try {
|
||
const userChats = await db
|
||
.select({ id: chat.id })
|
||
.from(chat)
|
||
.where(eq(chat.userId, userId));
|
||
|
||
if (userChats.length === 0) {
|
||
return { deletedCount: 0 };
|
||
}
|
||
|
||
const chatIds = userChats.map((c) => c.id);
|
||
|
||
await db.delete(vote).where(inArray(vote.chatId, chatIds));
|
||
await db.delete(message).where(inArray(message.chatId, chatIds));
|
||
await db.delete(stream).where(inArray(stream.chatId, chatIds));
|
||
|
||
const deletedChats = await db
|
||
.delete(chat)
|
||
.where(eq(chat.userId, userId))
|
||
.returning();
|
||
|
||
return { deletedCount: deletedChats.length };
|
||
} catch (_error) {
|
||
throw new ChatSDKError(
|
||
"bad_request:database",
|
||
"Failed to delete all chats by user id"
|
||
);
|
||
}
|
||
}
|
||
|
||
export async function getChatsByUserId({
|
||
id,
|
||
limit,
|
||
startingAfter,
|
||
endingBefore,
|
||
}: {
|
||
id: string;
|
||
limit: number;
|
||
startingAfter: string | null;
|
||
endingBefore: string | null;
|
||
}) {
|
||
try {
|
||
const extendedLimit = limit + 1;
|
||
|
||
const query = (whereCondition?: SQL<any>) =>
|
||
db
|
||
.select()
|
||
.from(chat)
|
||
.where(
|
||
whereCondition
|
||
? and(whereCondition, eq(chat.userId, id))
|
||
: eq(chat.userId, id)
|
||
)
|
||
.orderBy(desc(chat.createdAt))
|
||
.limit(extendedLimit);
|
||
|
||
let filteredChats: Chat[] = [];
|
||
|
||
if (startingAfter) {
|
||
const [selectedChat] = await db
|
||
.select()
|
||
.from(chat)
|
||
.where(eq(chat.id, startingAfter))
|
||
.limit(1);
|
||
|
||
if (!selectedChat) {
|
||
throw new ChatSDKError(
|
||
"not_found:database",
|
||
`Chat with id ${startingAfter} not found`
|
||
);
|
||
}
|
||
|
||
filteredChats = await query(gt(chat.createdAt, selectedChat.createdAt));
|
||
} else if (endingBefore) {
|
||
const [selectedChat] = await db
|
||
.select()
|
||
.from(chat)
|
||
.where(eq(chat.id, endingBefore))
|
||
.limit(1);
|
||
|
||
if (!selectedChat) {
|
||
throw new ChatSDKError(
|
||
"not_found:database",
|
||
`Chat with id ${endingBefore} not found`
|
||
);
|
||
}
|
||
|
||
filteredChats = await query(lt(chat.createdAt, selectedChat.createdAt));
|
||
} else {
|
||
filteredChats = await query();
|
||
}
|
||
|
||
const hasMore = filteredChats.length > limit;
|
||
|
||
return {
|
||
chats: hasMore ? filteredChats.slice(0, limit) : filteredChats,
|
||
hasMore,
|
||
};
|
||
} catch (_error) {
|
||
throw new ChatSDKError(
|
||
"bad_request:database",
|
||
"Failed to get chats by user id"
|
||
);
|
||
}
|
||
}
|
||
|
||
export async function getChatById({ id }: { id: string }) {
|
||
try {
|
||
const [selectedChat] = await db.select().from(chat).where(eq(chat.id, id));
|
||
if (!selectedChat) {
|
||
return null;
|
||
}
|
||
|
||
return selectedChat;
|
||
} catch (_error) {
|
||
throw new ChatSDKError("bad_request:database", "Failed to get chat by id");
|
||
}
|
||
}
|
||
|
||
export async function saveMessages({ messages }: { messages: DBMessage[] }) {
|
||
try {
|
||
return await db.insert(message).values(messages);
|
||
} catch (_error) {
|
||
throw new ChatSDKError("bad_request:database", "Failed to save messages");
|
||
}
|
||
}
|
||
|
||
export async function updateMessageContent({
|
||
messageId,
|
||
chatId,
|
||
newContent,
|
||
}: {
|
||
messageId: string;
|
||
chatId: string;
|
||
newContent: string;
|
||
}) {
|
||
try {
|
||
// 更新消息的 parts 字段(将新内容作为 text part)
|
||
return await db
|
||
.update(message)
|
||
.set({
|
||
parts: [{ type: "text", text: newContent }],
|
||
})
|
||
.where(and(eq(message.id, messageId), eq(message.chatId, chatId)));
|
||
} catch (_error) {
|
||
throw new ChatSDKError("bad_request:database", "Failed to update message content");
|
||
}
|
||
}
|
||
|
||
export async function updateMessageById({
|
||
messageId,
|
||
chatId,
|
||
parts,
|
||
videoStorageStatus,
|
||
temporaryVideoUrl,
|
||
permanentVideoUrl,
|
||
archivedAt,
|
||
videoMetadata,
|
||
}: {
|
||
messageId: string;
|
||
chatId: string;
|
||
parts?: DBMessage["parts"];
|
||
videoStorageStatus?: DBMessage["videoStorageStatus"] | null;
|
||
temporaryVideoUrl?: string | null;
|
||
permanentVideoUrl?: string | null;
|
||
archivedAt?: Date | null;
|
||
videoMetadata?: DBMessage["videoMetadata"] | null;
|
||
}) {
|
||
try {
|
||
const updateData: Partial<DBMessage> = {};
|
||
|
||
if (parts !== undefined) {
|
||
updateData.parts = parts;
|
||
}
|
||
if (videoStorageStatus !== undefined) {
|
||
updateData.videoStorageStatus = videoStorageStatus;
|
||
}
|
||
if (temporaryVideoUrl !== undefined) {
|
||
updateData.temporaryVideoUrl = temporaryVideoUrl;
|
||
}
|
||
if (permanentVideoUrl !== undefined) {
|
||
updateData.permanentVideoUrl = permanentVideoUrl;
|
||
}
|
||
if (archivedAt !== undefined) {
|
||
updateData.archivedAt = archivedAt;
|
||
}
|
||
if (videoMetadata !== undefined) {
|
||
updateData.videoMetadata = videoMetadata;
|
||
}
|
||
|
||
if (Object.keys(updateData).length === 0) {
|
||
return [];
|
||
}
|
||
|
||
return await db
|
||
.update(message)
|
||
.set(updateData)
|
||
.where(and(eq(message.id, messageId), eq(message.chatId, chatId)))
|
||
.returning();
|
||
} catch (_error) {
|
||
throw new ChatSDKError("bad_request:database", "Failed to update message");
|
||
}
|
||
}
|
||
|
||
export async function getPendingVideoMessagesByChatId({
|
||
chatId,
|
||
}: {
|
||
chatId: string;
|
||
}) {
|
||
try {
|
||
return await db
|
||
.select()
|
||
.from(message)
|
||
.where(
|
||
and(
|
||
eq(message.chatId, chatId),
|
||
eq(message.role, "assistant"),
|
||
sql`${message.videoMetadata} is not null`,
|
||
sql`coalesce(${message.videoMetadata} ->> 'status', '') in ('queued', 'running')`,
|
||
sql`coalesce(${message.videoMetadata} ->> 'taskKind', '') <> 'fal_gpt_image_2_edit'`
|
||
)
|
||
)
|
||
.orderBy(desc(message.createdAt));
|
||
} catch (_error) {
|
||
throw new ChatSDKError(
|
||
"bad_request:database",
|
||
"Failed to get pending video messages by chat id"
|
||
);
|
||
}
|
||
}
|
||
|
||
export async function getMessagesByChatId({ id }: { id: string }) {
|
||
try {
|
||
return await db
|
||
.select()
|
||
.from(message)
|
||
.where(eq(message.chatId, id))
|
||
.orderBy(asc(message.createdAt));
|
||
} catch (_error) {
|
||
throw new ChatSDKError(
|
||
"bad_request:database",
|
||
"Failed to get messages by chat id"
|
||
);
|
||
}
|
||
}
|
||
|
||
export async function voteMessage({
|
||
chatId,
|
||
messageId,
|
||
type,
|
||
}: {
|
||
chatId: string;
|
||
messageId: string;
|
||
type: "up" | "down";
|
||
}) {
|
||
try {
|
||
const [existingVote] = await db
|
||
.select()
|
||
.from(vote)
|
||
.where(and(eq(vote.messageId, messageId)));
|
||
|
||
if (existingVote) {
|
||
return await db
|
||
.update(vote)
|
||
.set({ isUpvoted: type === "up" })
|
||
.where(and(eq(vote.messageId, messageId), eq(vote.chatId, chatId)));
|
||
}
|
||
return await db.insert(vote).values({
|
||
chatId,
|
||
messageId,
|
||
isUpvoted: type === "up",
|
||
});
|
||
} catch (_error) {
|
||
throw new ChatSDKError("bad_request:database", "Failed to vote message");
|
||
}
|
||
}
|
||
|
||
export async function getVotesByChatId({ id }: { id: string }) {
|
||
try {
|
||
return await db.select().from(vote).where(eq(vote.chatId, id));
|
||
} catch (_error) {
|
||
throw new ChatSDKError(
|
||
"bad_request:database",
|
||
"Failed to get votes by chat id"
|
||
);
|
||
}
|
||
}
|
||
|
||
export async function saveDocument({
|
||
id,
|
||
title,
|
||
kind,
|
||
content,
|
||
userId,
|
||
}: {
|
||
id: string;
|
||
title: string;
|
||
kind: ArtifactKind;
|
||
content: string;
|
||
userId: string;
|
||
}) {
|
||
try {
|
||
return await db
|
||
.insert(document)
|
||
.values({
|
||
id,
|
||
title,
|
||
kind,
|
||
content,
|
||
userId,
|
||
createdAt: new Date(),
|
||
})
|
||
.returning();
|
||
} catch (_error) {
|
||
throw new ChatSDKError("bad_request:database", "Failed to save document");
|
||
}
|
||
}
|
||
|
||
export async function getDocumentsById({ id }: { id: string }) {
|
||
try {
|
||
const documents = await db
|
||
.select()
|
||
.from(document)
|
||
.where(eq(document.id, id))
|
||
.orderBy(asc(document.createdAt));
|
||
|
||
return documents;
|
||
} catch (_error) {
|
||
throw new ChatSDKError(
|
||
"bad_request:database",
|
||
"Failed to get documents by id"
|
||
);
|
||
}
|
||
}
|
||
|
||
export async function getDocumentById({ id }: { id: string }) {
|
||
try {
|
||
const [selectedDocument] = await db
|
||
.select()
|
||
.from(document)
|
||
.where(eq(document.id, id))
|
||
.orderBy(desc(document.createdAt));
|
||
|
||
return selectedDocument;
|
||
} catch (_error) {
|
||
throw new ChatSDKError(
|
||
"bad_request:database",
|
||
"Failed to get document by id"
|
||
);
|
||
}
|
||
}
|
||
|
||
export async function deleteDocumentsByIdAfterTimestamp({
|
||
id,
|
||
timestamp,
|
||
}: {
|
||
id: string;
|
||
timestamp: Date;
|
||
}) {
|
||
try {
|
||
await db
|
||
.delete(suggestion)
|
||
.where(
|
||
and(
|
||
eq(suggestion.documentId, id),
|
||
gt(suggestion.documentCreatedAt, timestamp)
|
||
)
|
||
);
|
||
|
||
return await db
|
||
.delete(document)
|
||
.where(and(eq(document.id, id), gt(document.createdAt, timestamp)))
|
||
.returning();
|
||
} catch (_error) {
|
||
throw new ChatSDKError(
|
||
"bad_request:database",
|
||
"Failed to delete documents by id after timestamp"
|
||
);
|
||
}
|
||
}
|
||
|
||
export async function saveSuggestions({
|
||
suggestions,
|
||
}: {
|
||
suggestions: Suggestion[];
|
||
}) {
|
||
try {
|
||
return await db.insert(suggestion).values(suggestions);
|
||
} catch (_error) {
|
||
throw new ChatSDKError(
|
||
"bad_request:database",
|
||
"Failed to save suggestions"
|
||
);
|
||
}
|
||
}
|
||
|
||
export async function getSuggestionsByDocumentId({
|
||
documentId,
|
||
}: {
|
||
documentId: string;
|
||
}) {
|
||
try {
|
||
return await db
|
||
.select()
|
||
.from(suggestion)
|
||
.where(eq(suggestion.documentId, documentId));
|
||
} catch (_error) {
|
||
throw new ChatSDKError(
|
||
"bad_request:database",
|
||
"Failed to get suggestions by document id"
|
||
);
|
||
}
|
||
}
|
||
|
||
export async function getMessageById({ id }: { id: string }) {
|
||
try {
|
||
return await db.select().from(message).where(eq(message.id, id));
|
||
} catch (_error) {
|
||
throw new ChatSDKError(
|
||
"bad_request:database",
|
||
"Failed to get message by id"
|
||
);
|
||
}
|
||
}
|
||
|
||
export async function deleteMessagesByChatIdAfterTimestamp({
|
||
chatId,
|
||
timestamp,
|
||
}: {
|
||
chatId: string;
|
||
timestamp: Date;
|
||
}) {
|
||
try {
|
||
const messagesToDelete = await db
|
||
.select({ id: message.id })
|
||
.from(message)
|
||
.where(
|
||
and(eq(message.chatId, chatId), gte(message.createdAt, timestamp))
|
||
);
|
||
|
||
const messageIds = messagesToDelete.map(
|
||
(currentMessage) => currentMessage.id
|
||
);
|
||
|
||
if (messageIds.length > 0) {
|
||
await db
|
||
.delete(vote)
|
||
.where(
|
||
and(eq(vote.chatId, chatId), inArray(vote.messageId, messageIds))
|
||
);
|
||
|
||
return await db
|
||
.delete(message)
|
||
.where(
|
||
and(eq(message.chatId, chatId), inArray(message.id, messageIds))
|
||
);
|
||
}
|
||
} catch (_error) {
|
||
throw new ChatSDKError(
|
||
"bad_request:database",
|
||
"Failed to delete messages by chat id after timestamp"
|
||
);
|
||
}
|
||
}
|
||
|
||
export async function updateChatVisibilityById({
|
||
chatId,
|
||
visibility,
|
||
}: {
|
||
chatId: string;
|
||
visibility: "private" | "public";
|
||
}) {
|
||
try {
|
||
return await db.update(chat).set({ visibility }).where(eq(chat.id, chatId));
|
||
} catch (_error) {
|
||
throw new ChatSDKError(
|
||
"bad_request:database",
|
||
"Failed to update chat visibility by id"
|
||
);
|
||
}
|
||
}
|
||
|
||
export async function updateChatLastContextById({
|
||
chatId,
|
||
context,
|
||
}: {
|
||
chatId: string;
|
||
// Store merged server-enriched usage object
|
||
context: AppUsage;
|
||
}) {
|
||
try {
|
||
return await db
|
||
.update(chat)
|
||
.set({ lastContext: context })
|
||
.where(eq(chat.id, chatId));
|
||
} catch (error) {
|
||
console.warn("Failed to update lastContext for chat", chatId, error);
|
||
return;
|
||
}
|
||
}
|
||
|
||
function normalizeStringArray(value?: string[] | null): string[] {
|
||
if (!Array.isArray(value)) return [];
|
||
const deduped = new Set<string>();
|
||
for (const item of value) {
|
||
if (typeof item !== "string") continue;
|
||
const trimmed = item.trim();
|
||
if (!trimmed) continue;
|
||
deduped.add(trimmed);
|
||
}
|
||
return Array.from(deduped);
|
||
}
|
||
|
||
function normalizeMediaUrl(value: string): string {
|
||
const raw = String(value || "").trim();
|
||
if (!raw) return "";
|
||
const canonical = inferOriginalImageUrl(raw);
|
||
|
||
try {
|
||
const parsed = new URL(canonical);
|
||
return `${parsed.origin}${parsed.pathname}`.replace(/\/+$/, "").toLowerCase();
|
||
} catch {
|
||
return canonical
|
||
.split("#")[0]
|
||
.split("?")[0]
|
||
.replace(/\/+$/, "")
|
||
.toLowerCase();
|
||
}
|
||
}
|
||
|
||
export async function getConversationWorkingStateByChatId({
|
||
chatId,
|
||
}: {
|
||
chatId: string;
|
||
}): Promise<ConversationWorkingState | null> {
|
||
try {
|
||
const rows = await db
|
||
.select()
|
||
.from(conversationWorkingState)
|
||
.where(eq(conversationWorkingState.chatId, chatId))
|
||
.limit(1);
|
||
return rows[0] ?? null;
|
||
} catch (_error) {
|
||
throw new ChatSDKError(
|
||
"bad_request:database",
|
||
"Failed to get conversation working state"
|
||
);
|
||
}
|
||
}
|
||
|
||
export async function listAiCadWorkspacesByUserId({
|
||
userId,
|
||
}: {
|
||
userId: string;
|
||
}): Promise<AiCadWorkspace[]> {
|
||
try {
|
||
return await db
|
||
.select()
|
||
.from(aiCadWorkspace)
|
||
.where(eq(aiCadWorkspace.userId, userId))
|
||
.orderBy(desc(aiCadWorkspace.updatedAt));
|
||
} catch (_error) {
|
||
throw new ChatSDKError(
|
||
"bad_request:database",
|
||
"Failed to list AI-CAD workspaces"
|
||
);
|
||
}
|
||
}
|
||
|
||
export async function getAiCadWorkspaceById(params: {
|
||
userId: string;
|
||
workspaceId: string;
|
||
}): Promise<AiCadWorkspace | null> {
|
||
try {
|
||
const [result] = await db
|
||
.select()
|
||
.from(aiCadWorkspace)
|
||
.where(
|
||
and(
|
||
eq(aiCadWorkspace.userId, params.userId),
|
||
eq(aiCadWorkspace.id, params.workspaceId)
|
||
)
|
||
)
|
||
.limit(1);
|
||
|
||
return result ?? null;
|
||
} catch (_error) {
|
||
throw new ChatSDKError(
|
||
"bad_request:database",
|
||
"Failed to get AI-CAD workspace by id"
|
||
);
|
||
}
|
||
}
|
||
|
||
export async function createAiCadWorkspace(params: {
|
||
userId: string;
|
||
name: string;
|
||
}): Promise<AiCadWorkspace | null> {
|
||
try {
|
||
const now = new Date();
|
||
const [result] = await db
|
||
.insert(aiCadWorkspace)
|
||
.values({
|
||
userId: params.userId,
|
||
name: params.name,
|
||
prompt: "",
|
||
generationProvider: "kittycad-cad",
|
||
modelVersion: "Turbo-v1.0-20250506",
|
||
viewerSettings: {
|
||
envPreset: "studio",
|
||
lightIntensity: 1.1,
|
||
autoRotate: true,
|
||
showGrid: true,
|
||
wireframe: false,
|
||
},
|
||
messages: [],
|
||
versions: [],
|
||
activeVersionId: null,
|
||
createdAt: now,
|
||
updatedAt: now,
|
||
})
|
||
.returning();
|
||
|
||
return result ?? null;
|
||
} catch (_error) {
|
||
throw new ChatSDKError(
|
||
"bad_request:database",
|
||
"Failed to create AI-CAD workspace"
|
||
);
|
||
}
|
||
}
|
||
|
||
export async function updateAiCadWorkspace(params: {
|
||
workspaceId: string;
|
||
userId: string;
|
||
name?: string;
|
||
prompt: string;
|
||
generationProvider: "kittycad-cad" | "tripo-3d";
|
||
modelVersion: string;
|
||
viewerSettings: Record<string, unknown>;
|
||
messages: Record<string, unknown>[];
|
||
versions: Record<string, unknown>[];
|
||
activeVersionId?: string | null;
|
||
}): Promise<AiCadWorkspace | null> {
|
||
try {
|
||
const now = new Date();
|
||
const [result] = await db
|
||
.update(aiCadWorkspace)
|
||
.set({
|
||
...(params.name ? { name: params.name } : {}),
|
||
prompt: params.prompt,
|
||
generationProvider: params.generationProvider,
|
||
modelVersion: params.modelVersion,
|
||
viewerSettings: params.viewerSettings,
|
||
messages: params.messages,
|
||
versions: params.versions,
|
||
activeVersionId: params.activeVersionId ?? null,
|
||
updatedAt: now,
|
||
})
|
||
.where(
|
||
and(
|
||
eq(aiCadWorkspace.id, params.workspaceId),
|
||
eq(aiCadWorkspace.userId, params.userId)
|
||
)
|
||
)
|
||
.returning();
|
||
|
||
return result ?? null;
|
||
} catch (_error) {
|
||
throw new ChatSDKError(
|
||
"bad_request:database",
|
||
"Failed to update AI-CAD workspace"
|
||
);
|
||
}
|
||
}
|
||
|
||
export async function deleteAiCadWorkspaceById(params: {
|
||
workspaceId: string;
|
||
userId: string;
|
||
}): Promise<AiCadWorkspace | null> {
|
||
try {
|
||
const [result] = await db
|
||
.delete(aiCadWorkspace)
|
||
.where(
|
||
and(
|
||
eq(aiCadWorkspace.id, params.workspaceId),
|
||
eq(aiCadWorkspace.userId, params.userId)
|
||
)
|
||
)
|
||
.returning();
|
||
|
||
return result ?? null;
|
||
} catch (_error) {
|
||
throw new ChatSDKError(
|
||
"bad_request:database",
|
||
"Failed to delete AI-CAD workspace"
|
||
);
|
||
}
|
||
}
|
||
|
||
export async function getFlowBoardDraftByUserId({
|
||
userId,
|
||
}: {
|
||
userId: string;
|
||
}): Promise<FlowBoardDraft | null> {
|
||
try {
|
||
const [result] = await db
|
||
.select()
|
||
.from(flowBoardDraft)
|
||
.where(eq(flowBoardDraft.userId, userId))
|
||
.orderBy(desc(flowBoardDraft.updatedAt))
|
||
.limit(1);
|
||
|
||
return result ?? null;
|
||
} catch (_error) {
|
||
throw new ChatSDKError(
|
||
"bad_request:database",
|
||
"Failed to get flow board draft"
|
||
);
|
||
}
|
||
}
|
||
|
||
export async function listFlowBoardDraftsByUserId({
|
||
userId,
|
||
}: {
|
||
userId: string;
|
||
}): Promise<FlowBoardDraft[]> {
|
||
try {
|
||
return await db
|
||
.select()
|
||
.from(flowBoardDraft)
|
||
.where(eq(flowBoardDraft.userId, userId))
|
||
.orderBy(desc(flowBoardDraft.updatedAt));
|
||
} catch (_error) {
|
||
throw new ChatSDKError(
|
||
"bad_request:database",
|
||
"Failed to list flow board drafts"
|
||
);
|
||
}
|
||
}
|
||
|
||
export async function getFlowBoardDraftById(params: {
|
||
userId: string;
|
||
draftId: string;
|
||
}): Promise<FlowBoardDraft | null> {
|
||
try {
|
||
const [result] = await db
|
||
.select()
|
||
.from(flowBoardDraft)
|
||
.where(
|
||
and(
|
||
eq(flowBoardDraft.id, params.draftId),
|
||
eq(flowBoardDraft.userId, params.userId)
|
||
)
|
||
)
|
||
.limit(1);
|
||
|
||
return result ?? null;
|
||
} catch (_error) {
|
||
throw new ChatSDKError(
|
||
"bad_request:database",
|
||
"Failed to get flow board draft by id"
|
||
);
|
||
}
|
||
}
|
||
|
||
export async function createFlowBoardDraft(params: {
|
||
userId: string;
|
||
title: string;
|
||
snapshot: FlowBoardSnapshotData;
|
||
}): Promise<FlowBoardDraft | null> {
|
||
try {
|
||
const now = new Date();
|
||
const [result] = await db
|
||
.insert(flowBoardDraft)
|
||
.values({
|
||
userId: params.userId,
|
||
title: params.title,
|
||
snapshot: params.snapshot,
|
||
createdAt: now,
|
||
updatedAt: now,
|
||
})
|
||
.returning();
|
||
|
||
return result ?? null;
|
||
} catch (_error) {
|
||
throw new ChatSDKError(
|
||
"bad_request:database",
|
||
"Failed to create flow board draft"
|
||
);
|
||
}
|
||
}
|
||
|
||
export async function updateFlowBoardDraftById(params: {
|
||
userId: string;
|
||
draftId: string;
|
||
title?: string;
|
||
snapshot?: FlowBoardSnapshotData;
|
||
}): Promise<FlowBoardDraft | null> {
|
||
try {
|
||
const updateData: Partial<typeof flowBoardDraft.$inferInsert> = {
|
||
updatedAt: new Date(),
|
||
};
|
||
if (params.title !== undefined) {
|
||
updateData.title = params.title;
|
||
}
|
||
if (params.snapshot !== undefined) {
|
||
updateData.snapshot = params.snapshot;
|
||
}
|
||
|
||
const [result] = await db
|
||
.update(flowBoardDraft)
|
||
.set(updateData)
|
||
.where(
|
||
and(
|
||
eq(flowBoardDraft.id, params.draftId),
|
||
eq(flowBoardDraft.userId, params.userId)
|
||
)
|
||
)
|
||
.returning();
|
||
|
||
return result ?? null;
|
||
} catch (_error) {
|
||
throw new ChatSDKError(
|
||
"bad_request:database",
|
||
"Failed to update flow board draft"
|
||
);
|
||
}
|
||
}
|
||
|
||
export async function deleteFlowBoardDraftById(params: {
|
||
userId: string;
|
||
draftId: string;
|
||
}): Promise<FlowBoardDraft | null> {
|
||
try {
|
||
const [result] = await db
|
||
.delete(flowBoardDraft)
|
||
.where(
|
||
and(
|
||
eq(flowBoardDraft.id, params.draftId),
|
||
eq(flowBoardDraft.userId, params.userId)
|
||
)
|
||
)
|
||
.returning();
|
||
|
||
return result ?? null;
|
||
} catch (_error) {
|
||
throw new ChatSDKError(
|
||
"bad_request:database",
|
||
"Failed to delete flow board draft"
|
||
);
|
||
}
|
||
}
|
||
|
||
export async function upsertFlowBoardDraftByUserId(params: {
|
||
userId: string;
|
||
snapshot: FlowBoardSnapshotData;
|
||
}): Promise<FlowBoardDraft | null> {
|
||
try {
|
||
const existing = await getFlowBoardDraftByUserId({
|
||
userId: params.userId,
|
||
});
|
||
if (existing) {
|
||
return await updateFlowBoardDraftById({
|
||
userId: params.userId,
|
||
draftId: existing.id,
|
||
snapshot: params.snapshot,
|
||
});
|
||
}
|
||
|
||
return await createFlowBoardDraft({
|
||
userId: params.userId,
|
||
title: "流程白板",
|
||
snapshot: params.snapshot,
|
||
});
|
||
} catch (_error) {
|
||
throw new ChatSDKError(
|
||
"bad_request:database",
|
||
"Failed to save flow board draft"
|
||
);
|
||
}
|
||
}
|
||
|
||
export async function upsertConversationWorkingState(params: {
|
||
userId: string;
|
||
chatId: string;
|
||
projectId?: string | null;
|
||
currentIntent?: string | null;
|
||
currentSubIntent?: string | null;
|
||
currentFocusAssetIds?: string[] | null;
|
||
currentFocusImageUrls?: string[] | null;
|
||
selectedAssetIds?: string[] | null;
|
||
selectedImageUrls?: string[] | null;
|
||
lastUploadedAssetIds?: string[] | null;
|
||
lastUploadedImageUrls?: string[] | null;
|
||
lastGeneratedAssetIds?: string[] | null;
|
||
lastGeneratedImageUrls?: string[] | null;
|
||
lastReferencedAssetIds?: string[] | null;
|
||
lastReferencedImageUrls?: string[] | null;
|
||
lastResolvedTargetAssetIds?: string[] | null;
|
||
lastResolvedTargetImageUrls?: string[] | null;
|
||
activeCanvasNodeIds?: string[] | null;
|
||
lastCanvasSnapshot?: any;
|
||
lastTurnSummary?: any;
|
||
lastMessageId?: string | null;
|
||
incrementTurnCount?: boolean;
|
||
}): Promise<ConversationWorkingState | null> {
|
||
try {
|
||
const existing = await getConversationWorkingStateByChatId({
|
||
chatId: params.chatId,
|
||
});
|
||
const now = new Date();
|
||
|
||
const values = {
|
||
userId: params.userId,
|
||
chatId: params.chatId,
|
||
projectId:
|
||
params.projectId !== undefined ? params.projectId : existing?.projectId ?? null,
|
||
currentIntent:
|
||
params.currentIntent !== undefined
|
||
? params.currentIntent
|
||
: existing?.currentIntent ?? null,
|
||
currentSubIntent:
|
||
params.currentSubIntent !== undefined
|
||
? params.currentSubIntent
|
||
: existing?.currentSubIntent ?? null,
|
||
currentFocusAssetIds:
|
||
params.currentFocusAssetIds !== undefined
|
||
? normalizeStringArray(params.currentFocusAssetIds)
|
||
: normalizeStringArray(existing?.currentFocusAssetIds),
|
||
currentFocusImageUrls:
|
||
params.currentFocusImageUrls !== undefined
|
||
? normalizeStringArray(params.currentFocusImageUrls)
|
||
: normalizeStringArray(existing?.currentFocusImageUrls),
|
||
selectedAssetIds:
|
||
params.selectedAssetIds !== undefined
|
||
? normalizeStringArray(params.selectedAssetIds)
|
||
: normalizeStringArray(existing?.selectedAssetIds),
|
||
selectedImageUrls:
|
||
params.selectedImageUrls !== undefined
|
||
? normalizeStringArray(params.selectedImageUrls)
|
||
: normalizeStringArray(existing?.selectedImageUrls),
|
||
lastUploadedAssetIds:
|
||
params.lastUploadedAssetIds !== undefined
|
||
? normalizeStringArray(params.lastUploadedAssetIds)
|
||
: normalizeStringArray(existing?.lastUploadedAssetIds),
|
||
lastUploadedImageUrls:
|
||
params.lastUploadedImageUrls !== undefined
|
||
? normalizeStringArray(params.lastUploadedImageUrls)
|
||
: normalizeStringArray(existing?.lastUploadedImageUrls),
|
||
lastGeneratedAssetIds:
|
||
params.lastGeneratedAssetIds !== undefined
|
||
? normalizeStringArray(params.lastGeneratedAssetIds)
|
||
: normalizeStringArray(existing?.lastGeneratedAssetIds),
|
||
lastGeneratedImageUrls:
|
||
params.lastGeneratedImageUrls !== undefined
|
||
? normalizeStringArray(params.lastGeneratedImageUrls)
|
||
: normalizeStringArray(existing?.lastGeneratedImageUrls),
|
||
lastReferencedAssetIds:
|
||
params.lastReferencedAssetIds !== undefined
|
||
? normalizeStringArray(params.lastReferencedAssetIds)
|
||
: normalizeStringArray(existing?.lastReferencedAssetIds),
|
||
lastReferencedImageUrls:
|
||
params.lastReferencedImageUrls !== undefined
|
||
? normalizeStringArray(params.lastReferencedImageUrls)
|
||
: normalizeStringArray(existing?.lastReferencedImageUrls),
|
||
lastResolvedTargetAssetIds:
|
||
params.lastResolvedTargetAssetIds !== undefined
|
||
? normalizeStringArray(params.lastResolvedTargetAssetIds)
|
||
: normalizeStringArray(existing?.lastResolvedTargetAssetIds),
|
||
lastResolvedTargetImageUrls:
|
||
params.lastResolvedTargetImageUrls !== undefined
|
||
? normalizeStringArray(params.lastResolvedTargetImageUrls)
|
||
: normalizeStringArray(existing?.lastResolvedTargetImageUrls),
|
||
activeCanvasNodeIds:
|
||
params.activeCanvasNodeIds !== undefined
|
||
? normalizeStringArray(params.activeCanvasNodeIds)
|
||
: normalizeStringArray(existing?.activeCanvasNodeIds),
|
||
lastCanvasSnapshot:
|
||
params.lastCanvasSnapshot !== undefined
|
||
? params.lastCanvasSnapshot
|
||
: existing?.lastCanvasSnapshot ?? null,
|
||
lastTurnSummary:
|
||
params.lastTurnSummary !== undefined
|
||
? params.lastTurnSummary
|
||
: existing?.lastTurnSummary ?? null,
|
||
lastMessageId:
|
||
params.lastMessageId !== undefined
|
||
? params.lastMessageId
|
||
: existing?.lastMessageId ?? null,
|
||
turnCount:
|
||
(existing?.turnCount ?? 0) + (params.incrementTurnCount ? 1 : 0),
|
||
createdAt: existing?.createdAt ?? now,
|
||
updatedAt: now,
|
||
};
|
||
|
||
const [result] = await db
|
||
.insert(conversationWorkingState)
|
||
.values(values)
|
||
.onConflictDoUpdate({
|
||
target: conversationWorkingState.chatId,
|
||
set: {
|
||
userId: values.userId,
|
||
projectId: values.projectId,
|
||
currentIntent: values.currentIntent,
|
||
currentSubIntent: values.currentSubIntent,
|
||
currentFocusAssetIds: values.currentFocusAssetIds,
|
||
currentFocusImageUrls: values.currentFocusImageUrls,
|
||
selectedAssetIds: values.selectedAssetIds,
|
||
selectedImageUrls: values.selectedImageUrls,
|
||
lastUploadedAssetIds: values.lastUploadedAssetIds,
|
||
lastUploadedImageUrls: values.lastUploadedImageUrls,
|
||
lastGeneratedAssetIds: values.lastGeneratedAssetIds,
|
||
lastGeneratedImageUrls: values.lastGeneratedImageUrls,
|
||
lastReferencedAssetIds: values.lastReferencedAssetIds,
|
||
lastReferencedImageUrls: values.lastReferencedImageUrls,
|
||
lastResolvedTargetAssetIds: values.lastResolvedTargetAssetIds,
|
||
lastResolvedTargetImageUrls: values.lastResolvedTargetImageUrls,
|
||
activeCanvasNodeIds: values.activeCanvasNodeIds,
|
||
lastCanvasSnapshot: values.lastCanvasSnapshot,
|
||
lastTurnSummary: values.lastTurnSummary,
|
||
lastMessageId: values.lastMessageId,
|
||
turnCount: values.turnCount,
|
||
updatedAt: values.updatedAt,
|
||
},
|
||
})
|
||
.returning();
|
||
|
||
return result ?? null;
|
||
} catch (_error) {
|
||
throw new ChatSDKError(
|
||
"bad_request:database",
|
||
"Failed to upsert conversation working state"
|
||
);
|
||
}
|
||
}
|
||
|
||
export async function createMessageTargetResolution(params: {
|
||
userId: string;
|
||
chatId: string;
|
||
messageId?: string | null;
|
||
projectId?: string | null;
|
||
rawText: string;
|
||
uploadedAssetIds?: string[] | null;
|
||
uploadedImageUrls?: string[] | null;
|
||
selectedAssetIds?: string[] | null;
|
||
selectedImageUrls?: string[] | null;
|
||
candidateAssetIds?: string[] | null;
|
||
candidateImageUrls?: string[] | null;
|
||
resolvedAssetIds?: string[] | null;
|
||
resolvedImageUrls?: string[] | null;
|
||
resolutionMode: string;
|
||
confidence?: number | null;
|
||
needsClarification?: boolean;
|
||
reasoning?: any;
|
||
}): Promise<MessageTargetResolution | null> {
|
||
try {
|
||
const [result] = await db
|
||
.insert(messageTargetResolution)
|
||
.values({
|
||
userId: params.userId,
|
||
chatId: params.chatId,
|
||
messageId: params.messageId ?? null,
|
||
projectId: params.projectId ?? null,
|
||
rawText: params.rawText,
|
||
uploadedAssetIds: normalizeStringArray(params.uploadedAssetIds),
|
||
uploadedImageUrls: normalizeStringArray(params.uploadedImageUrls),
|
||
selectedAssetIds: normalizeStringArray(params.selectedAssetIds),
|
||
selectedImageUrls: normalizeStringArray(params.selectedImageUrls),
|
||
candidateAssetIds: normalizeStringArray(params.candidateAssetIds),
|
||
candidateImageUrls: normalizeStringArray(params.candidateImageUrls),
|
||
resolvedAssetIds: normalizeStringArray(params.resolvedAssetIds),
|
||
resolvedImageUrls: normalizeStringArray(params.resolvedImageUrls),
|
||
resolutionMode: params.resolutionMode,
|
||
confidence: Math.max(0, Math.min(100, Math.round(params.confidence ?? 0))),
|
||
needsClarification: Boolean(params.needsClarification),
|
||
reasoning: params.reasoning ?? null,
|
||
createdAt: new Date(),
|
||
})
|
||
.returning();
|
||
|
||
return result ?? null;
|
||
} catch (_error) {
|
||
throw new ChatSDKError(
|
||
"bad_request:database",
|
||
"Failed to create message target resolution"
|
||
);
|
||
}
|
||
}
|
||
|
||
export async function getMediaAssetByUserAndUrl(params: {
|
||
userId: string;
|
||
url: string;
|
||
}): Promise<MediaAsset | null> {
|
||
const normalizedUrl = normalizeMediaUrl(params.url);
|
||
if (!normalizedUrl) return null;
|
||
try {
|
||
const rows = await db
|
||
.select()
|
||
.from(mediaAsset)
|
||
.where(
|
||
and(
|
||
eq(mediaAsset.userId, params.userId),
|
||
eq(mediaAsset.normalizedUrl, normalizedUrl),
|
||
),
|
||
)
|
||
.limit(1);
|
||
return rows[0] ?? null;
|
||
} catch (_error) {
|
||
throw new ChatSDKError(
|
||
"bad_request:database",
|
||
"Failed to get media asset by url"
|
||
);
|
||
}
|
||
}
|
||
|
||
export async function getMediaAssetsByUserAndUrls(params: {
|
||
userId: string;
|
||
urls: string[];
|
||
}): Promise<MediaAsset[]> {
|
||
const normalizedUrls = Array.from(
|
||
new Set(params.urls.map((url) => normalizeMediaUrl(url)).filter(Boolean)),
|
||
);
|
||
if (!normalizedUrls.length) return [];
|
||
try {
|
||
return await db
|
||
.select()
|
||
.from(mediaAsset)
|
||
.where(
|
||
and(
|
||
eq(mediaAsset.userId, params.userId),
|
||
inArray(mediaAsset.normalizedUrl, normalizedUrls),
|
||
),
|
||
);
|
||
} catch (_error) {
|
||
throw new ChatSDKError(
|
||
"bad_request:database",
|
||
"Failed to get media assets by urls"
|
||
);
|
||
}
|
||
}
|
||
|
||
export async function getMediaAssetsByIds(params: {
|
||
ids: string[];
|
||
}): Promise<MediaAsset[]> {
|
||
const ids = Array.from(
|
||
new Set(
|
||
(params.ids || [])
|
||
.map((id) => String(id || "").trim())
|
||
.filter(Boolean),
|
||
),
|
||
);
|
||
if (ids.length === 0) return [];
|
||
|
||
try {
|
||
return await db.select().from(mediaAsset).where(inArray(mediaAsset.id, ids));
|
||
} catch (_error) {
|
||
throw new ChatSDKError(
|
||
"bad_request:database",
|
||
"Failed to get media assets by ids"
|
||
);
|
||
}
|
||
}
|
||
|
||
export async function upsertMediaAsset(params: {
|
||
userId: string;
|
||
chatId?: string | null;
|
||
projectId?: string | null;
|
||
mediaType: string;
|
||
sourceType: string;
|
||
status?: string;
|
||
originalUrl: string;
|
||
previewUrl?: string | null;
|
||
thumbnailUrl?: string | null;
|
||
mimeType?: string | null;
|
||
width?: number | null;
|
||
height?: number | null;
|
||
durationMs?: number | null;
|
||
displayName?: string | null;
|
||
prompt?: string | null;
|
||
modelId?: string | null;
|
||
sourceMessageId?: string | null;
|
||
sourceTable?: string | null;
|
||
sourceRowId?: string | null;
|
||
sourceNodeId?: string | null;
|
||
rootAssetId?: string | null;
|
||
primaryParentAssetId?: string | null;
|
||
summary?: any;
|
||
metadata?: any;
|
||
}): Promise<MediaAsset | null> {
|
||
const originalUrl = String(params.originalUrl || "").trim();
|
||
const normalizedUrl = normalizeMediaUrl(originalUrl);
|
||
if (!originalUrl || !normalizedUrl) return null;
|
||
|
||
try {
|
||
const now = new Date();
|
||
const [result] = await db
|
||
.insert(mediaAsset)
|
||
.values({
|
||
userId: params.userId,
|
||
chatId: params.chatId ?? null,
|
||
projectId: params.projectId ?? null,
|
||
mediaType: params.mediaType,
|
||
sourceType: params.sourceType,
|
||
status: params.status || "ready",
|
||
originalUrl,
|
||
normalizedUrl,
|
||
previewUrl: params.previewUrl ?? null,
|
||
thumbnailUrl: params.thumbnailUrl ?? null,
|
||
mimeType: params.mimeType ?? null,
|
||
width: params.width ?? null,
|
||
height: params.height ?? null,
|
||
durationMs: params.durationMs ?? null,
|
||
displayName: params.displayName ?? null,
|
||
prompt: params.prompt ?? null,
|
||
modelId: params.modelId ?? null,
|
||
sourceMessageId: params.sourceMessageId ?? null,
|
||
sourceTable: params.sourceTable ?? null,
|
||
sourceRowId: params.sourceRowId ?? null,
|
||
sourceNodeId: params.sourceNodeId ?? null,
|
||
rootAssetId: params.rootAssetId ?? null,
|
||
primaryParentAssetId: params.primaryParentAssetId ?? null,
|
||
summary: params.summary ?? null,
|
||
metadata: params.metadata ?? null,
|
||
createdAt: now,
|
||
updatedAt: now,
|
||
})
|
||
.onConflictDoUpdate({
|
||
target: [mediaAsset.userId, mediaAsset.normalizedUrl],
|
||
set: {
|
||
chatId: params.chatId ?? undefined,
|
||
projectId: params.projectId ?? undefined,
|
||
mediaType: params.mediaType,
|
||
sourceType: params.sourceType,
|
||
status: params.status || "ready",
|
||
originalUrl,
|
||
previewUrl: params.previewUrl ?? null,
|
||
thumbnailUrl: params.thumbnailUrl ?? null,
|
||
mimeType: params.mimeType ?? null,
|
||
width: params.width ?? null,
|
||
height: params.height ?? null,
|
||
durationMs: params.durationMs ?? null,
|
||
displayName: params.displayName ?? null,
|
||
prompt: params.prompt ?? null,
|
||
modelId: params.modelId ?? null,
|
||
sourceMessageId: params.sourceMessageId ?? null,
|
||
sourceTable: params.sourceTable ?? null,
|
||
sourceRowId: params.sourceRowId ?? null,
|
||
sourceNodeId: params.sourceNodeId ?? null,
|
||
rootAssetId: params.rootAssetId ?? null,
|
||
primaryParentAssetId: params.primaryParentAssetId ?? null,
|
||
summary: params.summary ?? null,
|
||
metadata: params.metadata ?? null,
|
||
updatedAt: now,
|
||
},
|
||
})
|
||
.returning();
|
||
|
||
return result ?? null;
|
||
} catch (_error) {
|
||
throw new ChatSDKError(
|
||
"bad_request:database",
|
||
"Failed to upsert media asset"
|
||
);
|
||
}
|
||
}
|
||
|
||
export async function createMediaAssetRelation(params: {
|
||
userId: string;
|
||
fromAssetId: string;
|
||
toAssetId: string;
|
||
relationType: string;
|
||
chatId?: string | null;
|
||
projectId?: string | null;
|
||
sourceMessageId?: string | null;
|
||
sourceNodeId?: string | null;
|
||
confidence?: number | null;
|
||
metadata?: any;
|
||
}): Promise<MediaAssetRelation | null> {
|
||
try {
|
||
const [result] = await db
|
||
.insert(mediaAssetRelation)
|
||
.values({
|
||
userId: params.userId,
|
||
chatId: params.chatId ?? null,
|
||
projectId: params.projectId ?? null,
|
||
fromAssetId: params.fromAssetId,
|
||
toAssetId: params.toAssetId,
|
||
relationType: params.relationType,
|
||
sourceMessageId: params.sourceMessageId ?? null,
|
||
sourceNodeId: params.sourceNodeId ?? null,
|
||
confidence: Math.max(0, Math.min(100, Math.round(params.confidence ?? 100))),
|
||
metadata: params.metadata ?? null,
|
||
createdAt: new Date(),
|
||
})
|
||
.returning();
|
||
return result ?? null;
|
||
} catch (_error) {
|
||
throw new ChatSDKError(
|
||
"bad_request:database",
|
||
"Failed to create media asset relation"
|
||
);
|
||
}
|
||
}
|
||
|
||
export async function getAssetMemorySnapshotsByAssetIds(params: {
|
||
assetIds: string[];
|
||
snapshotType?: string | null;
|
||
status?: string | null;
|
||
}): Promise<AssetMemorySnapshot[]> {
|
||
const assetIds = Array.from(
|
||
new Set(
|
||
(params.assetIds || [])
|
||
.map((id) => String(id || "").trim())
|
||
.filter(Boolean),
|
||
),
|
||
);
|
||
if (assetIds.length === 0) return [];
|
||
|
||
try {
|
||
const conditions = [inArray(assetMemorySnapshot.assetId, assetIds)];
|
||
if (params.snapshotType) {
|
||
conditions.push(eq(assetMemorySnapshot.snapshotType, params.snapshotType));
|
||
}
|
||
if (params.status) {
|
||
conditions.push(eq(assetMemorySnapshot.status, params.status));
|
||
}
|
||
|
||
return await db
|
||
.select()
|
||
.from(assetMemorySnapshot)
|
||
.where(and(...conditions))
|
||
.orderBy(desc(assetMemorySnapshot.updatedAt));
|
||
} catch (_error) {
|
||
throw new ChatSDKError(
|
||
"bad_request:database",
|
||
"Failed to get asset memory snapshots by asset ids"
|
||
);
|
||
}
|
||
}
|
||
|
||
export async function getAssetMemorySnapshotsByUserAndUrls(params: {
|
||
userId: string;
|
||
urls: string[];
|
||
snapshotType?: string | null;
|
||
status?: string | null;
|
||
}): Promise<AssetMemorySnapshot[]> {
|
||
const normalizedUrls = Array.from(
|
||
new Set(
|
||
(params.urls || [])
|
||
.map((url) => normalizeMediaUrl(String(url || "").trim()))
|
||
.filter(Boolean),
|
||
),
|
||
);
|
||
if (!params.userId || normalizedUrls.length === 0) return [];
|
||
|
||
try {
|
||
const conditions = [
|
||
eq(assetMemorySnapshot.userId, params.userId),
|
||
inArray(assetMemorySnapshot.normalizedUrl, normalizedUrls),
|
||
];
|
||
if (params.snapshotType) {
|
||
conditions.push(eq(assetMemorySnapshot.snapshotType, params.snapshotType));
|
||
}
|
||
if (params.status) {
|
||
conditions.push(eq(assetMemorySnapshot.status, params.status));
|
||
}
|
||
|
||
return await db
|
||
.select()
|
||
.from(assetMemorySnapshot)
|
||
.where(and(...conditions))
|
||
.orderBy(desc(assetMemorySnapshot.updatedAt));
|
||
} catch (_error) {
|
||
throw new ChatSDKError(
|
||
"bad_request:database",
|
||
"Failed to get asset memory snapshots by urls"
|
||
);
|
||
}
|
||
}
|
||
|
||
export async function upsertAssetMemorySnapshot(params: {
|
||
userId: string;
|
||
assetId?: string | null;
|
||
chatId?: string | null;
|
||
projectId?: string | null;
|
||
snapshotType?: string | null;
|
||
status?: string | null;
|
||
normalizedUrl: string;
|
||
summaryText?: string | null;
|
||
subjectTags?: string[] | null;
|
||
sceneTags?: string[] | null;
|
||
styleTags?: string[] | null;
|
||
lightingTags?: string[] | null;
|
||
confidence?: number | null;
|
||
sourceModelId?: string | null;
|
||
metadata?: any;
|
||
}): Promise<AssetMemorySnapshot | null> {
|
||
const normalizedUrl = normalizeMediaUrl(String(params.normalizedUrl || "").trim());
|
||
if (!params.userId || !normalizedUrl) return null;
|
||
|
||
try {
|
||
const now = new Date();
|
||
const snapshotType = String(params.snapshotType || "visual_summary").trim() || "visual_summary";
|
||
const status = String(params.status || "ready").trim() || "ready";
|
||
const [result] = await db
|
||
.insert(assetMemorySnapshot)
|
||
.values({
|
||
userId: params.userId,
|
||
assetId: params.assetId ?? null,
|
||
chatId: params.chatId ?? null,
|
||
projectId: params.projectId ?? null,
|
||
snapshotType,
|
||
status,
|
||
normalizedUrl,
|
||
summaryText: params.summaryText ?? null,
|
||
subjectTags: normalizeStringArray(params.subjectTags),
|
||
sceneTags: normalizeStringArray(params.sceneTags),
|
||
styleTags: normalizeStringArray(params.styleTags),
|
||
lightingTags: normalizeStringArray(params.lightingTags),
|
||
confidence: Math.max(0, Math.min(100, Math.round(params.confidence ?? 0))),
|
||
sourceModelId: params.sourceModelId ?? null,
|
||
metadata: params.metadata ?? null,
|
||
createdAt: now,
|
||
updatedAt: now,
|
||
})
|
||
.onConflictDoUpdate({
|
||
target: [
|
||
assetMemorySnapshot.userId,
|
||
assetMemorySnapshot.normalizedUrl,
|
||
assetMemorySnapshot.snapshotType,
|
||
],
|
||
set: {
|
||
assetId: params.assetId ?? undefined,
|
||
chatId: params.chatId ?? undefined,
|
||
projectId: params.projectId ?? undefined,
|
||
status,
|
||
summaryText: params.summaryText ?? null,
|
||
subjectTags: normalizeStringArray(params.subjectTags),
|
||
sceneTags: normalizeStringArray(params.sceneTags),
|
||
styleTags: normalizeStringArray(params.styleTags),
|
||
lightingTags: normalizeStringArray(params.lightingTags),
|
||
confidence: Math.max(0, Math.min(100, Math.round(params.confidence ?? 0))),
|
||
sourceModelId: params.sourceModelId ?? null,
|
||
metadata: params.metadata ?? null,
|
||
updatedAt: now,
|
||
},
|
||
})
|
||
.returning();
|
||
|
||
return result ?? null;
|
||
} catch (_error) {
|
||
throw new ChatSDKError(
|
||
"bad_request:database",
|
||
"Failed to upsert asset memory snapshot"
|
||
);
|
||
}
|
||
}
|
||
|
||
const UUID_REGEX =
|
||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||
|
||
export async function syncCanvasNodesForProject(params: {
|
||
projectId: string;
|
||
userId: string;
|
||
canvasData: {
|
||
nodes?: any[];
|
||
edges?: any[];
|
||
viewport?: { x: number; y: number; zoom: number };
|
||
} | null | undefined;
|
||
}) {
|
||
const nodes = Array.isArray(params.canvasData?.nodes) ? params.canvasData?.nodes : [];
|
||
const assetUrls = Array.from(
|
||
new Set(
|
||
nodes
|
||
.map((node) => String(node?.data?.src || "").trim())
|
||
.filter(Boolean),
|
||
),
|
||
);
|
||
const matchedAssets = assetUrls.length
|
||
? await getMediaAssetsByUserAndUrls({
|
||
userId: params.userId,
|
||
urls: assetUrls,
|
||
})
|
||
: [];
|
||
const assetByUrl = new Map<string, MediaAsset>();
|
||
for (const asset of matchedAssets) {
|
||
assetByUrl.set(asset.normalizedUrl, asset);
|
||
}
|
||
|
||
const enrichedNodes = nodes.map((node) => {
|
||
const src = String(node?.data?.src || "").trim();
|
||
const normalizedUrl = normalizeMediaUrl(src);
|
||
const matchedAsset = normalizedUrl ? assetByUrl.get(normalizedUrl) : null;
|
||
const nextData =
|
||
matchedAsset && !node?.data?.assetId
|
||
? {
|
||
...(node?.data || {}),
|
||
assetId: matchedAsset.id,
|
||
}
|
||
: node?.data;
|
||
return {
|
||
...node,
|
||
data: nextData,
|
||
};
|
||
});
|
||
|
||
await db.delete(mediaAssetRelation).where(
|
||
and(
|
||
eq(mediaAssetRelation.projectId, params.projectId),
|
||
eq(mediaAssetRelation.relationType, "located_on_canvas"),
|
||
),
|
||
);
|
||
await db.delete(canvasNode).where(eq(canvasNode.projectId, params.projectId));
|
||
|
||
const now = new Date();
|
||
const insertableNodes = enrichedNodes.map((node) => {
|
||
const nodeId =
|
||
typeof node?.id === "string" && UUID_REGEX.test(node.id)
|
||
? node.id
|
||
: generateUUID();
|
||
return {
|
||
id: nodeId,
|
||
projectId: params.projectId,
|
||
type: String(node?.type || "text").slice(0, 32),
|
||
position: node?.position || null,
|
||
size: node?.size || null,
|
||
data: node?.data || null,
|
||
connections: Array.isArray(node?.connections)
|
||
? node.connections.filter((value: unknown) => typeof value === "string")
|
||
: [],
|
||
parentId:
|
||
typeof node?.parentId === "string" && UUID_REGEX.test(node.parentId)
|
||
? node.parentId
|
||
: null,
|
||
createdAt:
|
||
node?.createdAt instanceof Date
|
||
? node.createdAt
|
||
: typeof node?.createdAt === "string"
|
||
? new Date(node.createdAt)
|
||
: now,
|
||
updatedAt: now,
|
||
};
|
||
});
|
||
|
||
if (insertableNodes.length > 0) {
|
||
await db.insert(canvasNode).values(insertableNodes);
|
||
}
|
||
|
||
for (const node of enrichedNodes) {
|
||
const assetId =
|
||
typeof node?.data?.assetId === "string" && node.data.assetId.trim()
|
||
? node.data.assetId.trim()
|
||
: null;
|
||
if (!assetId) continue;
|
||
await createMediaAssetRelation({
|
||
userId: params.userId,
|
||
fromAssetId: assetId,
|
||
toAssetId: assetId,
|
||
relationType: "located_on_canvas",
|
||
projectId: params.projectId,
|
||
sourceNodeId: typeof node.id === "string" ? node.id : null,
|
||
metadata: {
|
||
nodeType: node.type,
|
||
position: node.position || null,
|
||
size: node.size || null,
|
||
},
|
||
});
|
||
}
|
||
|
||
return {
|
||
...params.canvasData,
|
||
nodes: enrichedNodes,
|
||
};
|
||
}
|
||
|
||
export async function getMessageCountByUserId({
|
||
id,
|
||
differenceInHours,
|
||
}: {
|
||
id: string;
|
||
differenceInHours: number;
|
||
}) {
|
||
try {
|
||
const twentyFourHoursAgo = new Date(
|
||
Date.now() - differenceInHours * 60 * 60 * 1000
|
||
);
|
||
|
||
const [stats] = await db
|
||
.select({ count: count(message.id) })
|
||
.from(message)
|
||
.innerJoin(chat, eq(message.chatId, chat.id))
|
||
.where(
|
||
and(
|
||
eq(chat.userId, id),
|
||
gte(message.createdAt, twentyFourHoursAgo),
|
||
eq(message.role, "user")
|
||
)
|
||
)
|
||
.execute();
|
||
|
||
return stats?.count ?? 0;
|
||
} catch (_error) {
|
||
throw new ChatSDKError(
|
||
"bad_request:database",
|
||
"Failed to get message count by user id"
|
||
);
|
||
}
|
||
}
|
||
|
||
export async function createStreamId({
|
||
streamId,
|
||
chatId,
|
||
}: {
|
||
streamId: string;
|
||
chatId: string;
|
||
}) {
|
||
try {
|
||
await db
|
||
.insert(stream)
|
||
.values({ id: streamId, chatId, createdAt: new Date() });
|
||
} catch (_error) {
|
||
throw new ChatSDKError(
|
||
"bad_request:database",
|
||
"Failed to create stream id"
|
||
);
|
||
}
|
||
}
|
||
|
||
export async function getStreamIdsByChatId({ chatId }: { chatId: string }) {
|
||
try {
|
||
const streamIds = await db
|
||
.select({ id: stream.id })
|
||
.from(stream)
|
||
.where(eq(stream.chatId, chatId))
|
||
.orderBy(asc(stream.createdAt))
|
||
.execute();
|
||
|
||
return streamIds.map(({ id }) => id);
|
||
} catch (_error) {
|
||
throw new ChatSDKError(
|
||
"bad_request:database",
|
||
"Failed to get stream ids by chat id"
|
||
);
|
||
}
|
||
}
|
||
|
||
// ==================== Admin Functions ====================
|
||
|
||
// User Management
|
||
export async function getAllUsers({
|
||
limit = 10,
|
||
offset = 0,
|
||
search = "",
|
||
}: {
|
||
limit?: number;
|
||
offset?: number;
|
||
search?: string;
|
||
}) {
|
||
try {
|
||
// 构建搜索条件
|
||
const searchConditions = search
|
||
? or(
|
||
like(user.email, `%${search}%`),
|
||
like(user.name, `%${search}%`)
|
||
)
|
||
: undefined;
|
||
|
||
// 获取用户列表
|
||
const users = await db
|
||
.select({
|
||
id: user.id,
|
||
email: user.email,
|
||
name: user.name,
|
||
department: user.department,
|
||
dingtalkUserId: user.dingtalkUserId,
|
||
role: user.role,
|
||
isHighVip: user.isHighVip,
|
||
isActive: user.isActive,
|
||
monthlyImageLimit: user.monthlyImageLimit,
|
||
pointsBalance: user.pointsBalance,
|
||
pointsGiftedTotal: user.pointsGiftedTotal,
|
||
createdAt: user.createdAt,
|
||
updatedAt: user.updatedAt,
|
||
})
|
||
.from(user)
|
||
.where(searchConditions)
|
||
.orderBy(desc(user.createdAt))
|
||
.limit(limit)
|
||
.offset(offset);
|
||
|
||
// 获取总数
|
||
const [{ count }] = await db
|
||
.select({ count: sql<number>`count(*)::int` })
|
||
.from(user)
|
||
.where(searchConditions);
|
||
|
||
return { users, total: count };
|
||
} catch (_error) {
|
||
throw new ChatSDKError(
|
||
"bad_request:database",
|
||
"Failed to get all users"
|
||
);
|
||
}
|
||
}
|
||
|
||
export async function getUserById({ id }: { id: string }) {
|
||
try {
|
||
const users = await db.select().from(user).where(eq(user.id, id)).limit(1);
|
||
return users[0];
|
||
} catch (_error) {
|
||
throw new ChatSDKError("bad_request:database", "Failed to get user by id");
|
||
}
|
||
}
|
||
|
||
export async function updateUser({
|
||
id,
|
||
email,
|
||
role,
|
||
isHighVip,
|
||
isActive,
|
||
monthlyImageLimit,
|
||
pointsBalance,
|
||
giftPoints,
|
||
deductPoints,
|
||
pointsNote,
|
||
operatorId,
|
||
}: {
|
||
id: string;
|
||
email?: string;
|
||
role?: "guest" | "regular" | "admin" | "agent_admin";
|
||
isHighVip?: boolean;
|
||
isActive?: boolean;
|
||
monthlyImageLimit?: number | null;
|
||
pointsBalance?: number | null;
|
||
giftPoints?: number;
|
||
deductPoints?: number;
|
||
pointsNote?: string;
|
||
operatorId?: string;
|
||
}) {
|
||
try {
|
||
return await db.transaction(async (tx) => {
|
||
if (giftPoints !== undefined && deductPoints !== undefined) {
|
||
throw new ChatSDKError(
|
||
"bad_request:database",
|
||
"giftPoints and deductPoints cannot be used together"
|
||
);
|
||
}
|
||
|
||
const updateData: Record<string, unknown> = { updatedAt: new Date() };
|
||
if (email !== undefined) updateData.email = email;
|
||
if (role !== undefined) updateData.role = role;
|
||
if (isHighVip !== undefined) updateData.isHighVip = isHighVip;
|
||
if (isActive !== undefined) updateData.isActive = isActive;
|
||
if (monthlyImageLimit !== undefined) {
|
||
// 0 或空转为 null(表示无限制)
|
||
updateData.monthlyImageLimit = monthlyImageLimit || null;
|
||
}
|
||
if (pointsBalance !== undefined) {
|
||
updateData.pointsBalance = Math.max(0, Number(pointsBalance) || 0);
|
||
}
|
||
if (giftPoints !== undefined && giftPoints !== 0) {
|
||
updateData.pointsBalance = sql`GREATEST(0, COALESCE(${user.pointsBalance}, 0) + ${giftPoints})`;
|
||
updateData.pointsGiftedTotal = sql`GREATEST(0, COALESCE(${user.pointsGiftedTotal}, 0) + ${Math.max(
|
||
0,
|
||
giftPoints
|
||
)})`;
|
||
}
|
||
if (deductPoints !== undefined && deductPoints !== 0) {
|
||
const [currentUser] = await tx
|
||
.select({
|
||
pointsBalance: user.pointsBalance,
|
||
})
|
||
.from(user)
|
||
.where(eq(user.id, id))
|
||
.limit(1);
|
||
|
||
if (!currentUser) {
|
||
return [];
|
||
}
|
||
|
||
const currentBalance = Math.max(0, Number(currentUser.pointsBalance || 0));
|
||
if (currentBalance < deductPoints) {
|
||
throw new ChatSDKError(
|
||
"bad_request:database",
|
||
`Insufficient balance for deduction: current ${currentBalance}, need ${deductPoints}`
|
||
);
|
||
}
|
||
|
||
updateData.pointsBalance = sql`GREATEST(0, COALESCE(${user.pointsBalance}, 0) - ${deductPoints})`;
|
||
}
|
||
|
||
const updatedUsers = await tx
|
||
.update(user)
|
||
.set(updateData)
|
||
.where(eq(user.id, id))
|
||
.returning();
|
||
|
||
if (updatedUsers.length > 0 && giftPoints !== undefined && giftPoints !== 0) {
|
||
await tx.insert(pointsLedger).values({
|
||
userId: id,
|
||
amount: giftPoints,
|
||
reason: "gift",
|
||
note: pointsNote || "管理员赠送积分",
|
||
createdBy: operatorId,
|
||
});
|
||
}
|
||
if (updatedUsers.length > 0 && deductPoints !== undefined && deductPoints !== 0) {
|
||
await tx.insert(pointsLedger).values({
|
||
userId: id,
|
||
amount: -Math.abs(deductPoints),
|
||
reason: "deduct",
|
||
note: pointsNote || "管理员扣减积分",
|
||
createdBy: operatorId,
|
||
});
|
||
}
|
||
|
||
return updatedUsers;
|
||
});
|
||
} catch (_error) {
|
||
throw new ChatSDKError("bad_request:database", "Failed to update user");
|
||
}
|
||
}
|
||
|
||
export async function deleteUserById({ id }: { id: string }) {
|
||
try {
|
||
return await db.transaction(async (tx) => {
|
||
const userChats = await tx
|
||
.select({ id: chat.id })
|
||
.from(chat)
|
||
.where(eq(chat.userId, id));
|
||
const chatIds = userChats.map((row) => row.id);
|
||
|
||
if (chatIds.length > 0) {
|
||
await tx.delete(vote).where(inArray(vote.chatId, chatIds));
|
||
await tx.delete(message).where(inArray(message.chatId, chatIds));
|
||
await tx.delete(messageDeprecated).where(inArray(messageDeprecated.chatId, chatIds));
|
||
await tx.delete(stream).where(inArray(stream.chatId, chatIds));
|
||
}
|
||
|
||
await tx.delete(chat).where(eq(chat.userId, id));
|
||
await tx.delete(suggestion).where(eq(suggestion.userId, id));
|
||
await tx.delete(document).where(eq(document.userId, id));
|
||
await tx.delete(generatedImage).where(eq(generatedImage.userId, id));
|
||
|
||
// 清理“操作人/更新人”外键引用,避免删除被阻塞
|
||
await tx.update(pointsLedger).set({ createdBy: null }).where(eq(pointsLedger.createdBy, id));
|
||
await tx.update(generatedImage).set({ featuredBy: null }).where(eq(generatedImage.featuredBy, id));
|
||
await tx.update(aiProvider).set({ updatedBy: null }).where(eq(aiProvider.updatedBy, id));
|
||
await tx.update(storageConfig).set({ updatedBy: null }).where(eq(storageConfig.updatedBy, id));
|
||
await tx.update(systemSettings).set({ updatedBy: null }).where(eq(systemSettings.updatedBy, id));
|
||
await tx.update(toolConfig).set({ updatedBy: null }).where(eq(toolConfig.updatedBy, id));
|
||
await tx.update(siteSettings).set({ updatedBy: null }).where(eq(siteSettings.updatedBy, id));
|
||
await tx.update(authSettings).set({ updatedBy: null }).where(eq(authSettings.updatedBy, id));
|
||
await tx.update(navLinks).set({ createdBy: null }).where(eq(navLinks.createdBy, id));
|
||
await tx.update(tutorialVideos).set({ createdBy: null }).where(eq(tutorialVideos.createdBy, id));
|
||
|
||
return await tx.delete(user).where(eq(user.id, id)).returning();
|
||
});
|
||
} catch (_error) {
|
||
throw new ChatSDKError("bad_request:database", "Failed to delete user");
|
||
}
|
||
}
|
||
|
||
// System Settings
|
||
export async function getSystemSetting({ key }: { key: string }) {
|
||
try {
|
||
const settings = await db
|
||
.select()
|
||
.from(systemSettings)
|
||
.where(eq(systemSettings.key, key))
|
||
.limit(1);
|
||
return settings[0];
|
||
} catch (_error) {
|
||
throw new ChatSDKError(
|
||
"bad_request:database",
|
||
"Failed to get system setting"
|
||
);
|
||
}
|
||
}
|
||
|
||
export async function getPointsSystemConfig() {
|
||
try {
|
||
const setting = await getSystemSetting({ key: POINTS_SYSTEM_CONFIG_KEY });
|
||
return setting?.value
|
||
? normalizePointsSystemConfig(setting.value)
|
||
: defaultPointsSystemConfig;
|
||
} catch (_error) {
|
||
return defaultPointsSystemConfig;
|
||
}
|
||
}
|
||
|
||
type AgentPresetVisibility = "public" | "private";
|
||
type AgentPresetKind = "image" | "video" | "text";
|
||
type AgentPresetScope = "all" | AgentPresetVisibility;
|
||
type AgentCategoryScope = "all" | "public" | "private" | "global";
|
||
type AgentPresetSort = "latest" | "hottest" | "most_used";
|
||
function normalizeAgentCategoryName(value: string) {
|
||
return String(value || "").trim().replace(/\s+/g, " ");
|
||
}
|
||
|
||
function normalizeAgentCategorySlug(value: string) {
|
||
return normalizeAgentCategoryName(value)
|
||
.normalize("NFKD")
|
||
.toLowerCase()
|
||
.replace(/\s+/g, "-")
|
||
.replace(/-+/g, "-")
|
||
.replace(/^-|-$/g, "");
|
||
}
|
||
|
||
async function validateAgentDefaultModel(params: {
|
||
modelId: string;
|
||
presetType: AgentPresetKind;
|
||
tx?: any;
|
||
}) {
|
||
const executor = params.tx || db;
|
||
const rows = await executor
|
||
.select({
|
||
id: aiModel.id,
|
||
modelId: aiModel.modelId,
|
||
displayName: aiModel.displayName,
|
||
supportsImageGen: aiModel.supportsImageGen,
|
||
capabilities: aiModel.capabilities,
|
||
defaultParams: aiModel.defaultParams,
|
||
isEnabled: aiModel.isEnabled,
|
||
providerType: aiProvider.type,
|
||
providerEnabled: aiProvider.isEnabled,
|
||
})
|
||
.from(aiModel)
|
||
.innerJoin(aiProvider, eq(aiModel.providerId, aiProvider.id))
|
||
.where(eq(aiModel.id, params.modelId))
|
||
.limit(1);
|
||
|
||
const model = rows[0];
|
||
if (!model || !model.isEnabled || !model.providerEnabled) {
|
||
throw new ChatSDKError("bad_request:database", "Invalid default model");
|
||
}
|
||
|
||
const isVideoModel = isVideoModelDescriptor({
|
||
providerType: model.providerType,
|
||
modelId: model.modelId,
|
||
capabilities: model.capabilities,
|
||
supportsImageGen: model.supportsImageGen,
|
||
});
|
||
|
||
if (params.presetType === "video") {
|
||
if (!isVideoModel) {
|
||
throw new ChatSDKError(
|
||
"bad_request:database",
|
||
"视频风格化只能选择视频模型"
|
||
);
|
||
}
|
||
return model;
|
||
}
|
||
|
||
if (params.presetType === "text") {
|
||
const isMultimodal = isAiModelMultimodal({
|
||
capabilities: model.capabilities,
|
||
defaultParams: model.defaultParams,
|
||
});
|
||
if (isVideoModel || !isMultimodal) {
|
||
throw new ChatSDKError(
|
||
"bad_request:database",
|
||
"生文风格化只能选择带多模态标签的模型"
|
||
);
|
||
}
|
||
return model;
|
||
}
|
||
|
||
if (isVideoModel || !model.supportsImageGen) {
|
||
throw new ChatSDKError(
|
||
"bad_request:database",
|
||
"生图风格化只能选择支持生图的模型"
|
||
);
|
||
}
|
||
|
||
return model;
|
||
}
|
||
|
||
function getAgentCategoryAccessCondition(params: {
|
||
userId: string;
|
||
scope: AgentCategoryScope;
|
||
}): SQL {
|
||
const publicCondition = or(
|
||
eq(agentPresetCategory.scope, "public"),
|
||
eq(agentPresetCategory.scope, "global")
|
||
)!;
|
||
const privateCondition = and(
|
||
eq(agentPresetCategory.scope, "private"),
|
||
eq(agentPresetCategory.createdBy, params.userId)
|
||
)!;
|
||
|
||
if (params.scope === "public") {
|
||
return publicCondition;
|
||
}
|
||
if (params.scope === "private") {
|
||
return privateCondition;
|
||
}
|
||
if (params.scope === "global") {
|
||
return eq(agentPresetCategory.scope, "global");
|
||
}
|
||
|
||
return or(publicCondition, privateCondition)!;
|
||
}
|
||
|
||
function getAgentPresetAccessCondition(params: {
|
||
userId: string;
|
||
scope: AgentPresetScope;
|
||
}): SQL {
|
||
const publicCondition = eq(agentPreset.visibility, "public");
|
||
const privateCondition = and(
|
||
eq(agentPreset.visibility, "private"),
|
||
eq(agentPreset.ownerUserId, params.userId)
|
||
)!;
|
||
|
||
if (params.scope === "public") {
|
||
return publicCondition;
|
||
}
|
||
if (params.scope === "private") {
|
||
return privateCondition;
|
||
}
|
||
|
||
return or(publicCondition, privateCondition)!;
|
||
}
|
||
|
||
type HydratedAgentSlot = {
|
||
id: string;
|
||
slotIndex: number;
|
||
title: string | null;
|
||
promptTemplate: string;
|
||
defaultParams?: Record<string, any> | null;
|
||
requireUpload: boolean;
|
||
uploadLabel: string | null;
|
||
referenceAssets: Array<{
|
||
id: string;
|
||
assetId: string;
|
||
sortOrder: number;
|
||
originalUrl: string;
|
||
previewUrl: string | null;
|
||
thumbnailUrl: string | null;
|
||
mimeType: string | null;
|
||
displayName: string | null;
|
||
}>;
|
||
referenceAssetIds: string[];
|
||
};
|
||
|
||
async function getAgentSlotsForPresetIds(presetIds: string[]) {
|
||
if (presetIds.length === 0) {
|
||
return new Map<string, HydratedAgentSlot[]>();
|
||
}
|
||
|
||
const slotRows = await db
|
||
.select()
|
||
.from(agentPresetSlot)
|
||
.where(inArray(agentPresetSlot.presetId, presetIds))
|
||
.orderBy(asc(agentPresetSlot.presetId), asc(agentPresetSlot.slotIndex), asc(agentPresetSlot.createdAt));
|
||
|
||
const referenceRows = await db
|
||
.select({
|
||
presetId: agentPresetReferenceAsset.presetId,
|
||
slotId: agentPresetReferenceAsset.slotId,
|
||
id: agentPresetReferenceAsset.id,
|
||
assetId: mediaAsset.id,
|
||
sortOrder: agentPresetReferenceAsset.sortOrder,
|
||
originalUrl: mediaAsset.originalUrl,
|
||
previewUrl: mediaAsset.previewUrl,
|
||
thumbnailUrl: mediaAsset.thumbnailUrl,
|
||
mimeType: mediaAsset.mimeType,
|
||
displayName: mediaAsset.displayName,
|
||
})
|
||
.from(agentPresetReferenceAsset)
|
||
.innerJoin(mediaAsset, eq(agentPresetReferenceAsset.assetId, mediaAsset.id))
|
||
.where(inArray(agentPresetReferenceAsset.presetId, presetIds))
|
||
.orderBy(
|
||
asc(agentPresetReferenceAsset.presetId),
|
||
asc(agentPresetReferenceAsset.slotId),
|
||
asc(agentPresetReferenceAsset.sortOrder),
|
||
asc(agentPresetReferenceAsset.createdAt)
|
||
);
|
||
|
||
const referenceMap = new Map<
|
||
string,
|
||
Array<{
|
||
id: string;
|
||
assetId: string;
|
||
sortOrder: number;
|
||
originalUrl: string;
|
||
previewUrl: string | null;
|
||
thumbnailUrl: string | null;
|
||
mimeType: string | null;
|
||
displayName: string | null;
|
||
}>
|
||
>();
|
||
|
||
for (const row of referenceRows) {
|
||
const slotId = String(row.slotId || "").trim();
|
||
if (!slotId) continue;
|
||
const current = referenceMap.get(slotId) || [];
|
||
current.push({
|
||
id: row.id,
|
||
assetId: row.assetId,
|
||
sortOrder: row.sortOrder,
|
||
originalUrl: row.originalUrl,
|
||
previewUrl: row.previewUrl,
|
||
thumbnailUrl: row.thumbnailUrl,
|
||
mimeType: row.mimeType,
|
||
displayName: row.displayName,
|
||
});
|
||
referenceMap.set(slotId, current);
|
||
}
|
||
|
||
const grouped = new Map<string, HydratedAgentSlot[]>();
|
||
for (const slot of slotRows) {
|
||
const references = referenceMap.get(slot.id) || [];
|
||
const current = grouped.get(slot.presetId) || [];
|
||
current.push({
|
||
id: slot.id,
|
||
slotIndex: slot.slotIndex,
|
||
title: slot.title || null,
|
||
promptTemplate: slot.promptTemplate,
|
||
defaultParams: slot.defaultParams || null,
|
||
requireUpload: slot.requireUpload,
|
||
uploadLabel: slot.uploadLabel || null,
|
||
referenceAssets: references,
|
||
referenceAssetIds: references.map((item) => item.assetId),
|
||
});
|
||
grouped.set(slot.presetId, current);
|
||
}
|
||
|
||
return grouped;
|
||
}
|
||
|
||
async function getAgentAvatarAssetMap(coverAssetIds: string[]) {
|
||
const uniqueIds = Array.from(new Set(coverAssetIds.filter(Boolean)));
|
||
if (uniqueIds.length === 0) {
|
||
return new Map<
|
||
string,
|
||
{
|
||
assetId: string;
|
||
originalUrl: string;
|
||
previewUrl: string | null;
|
||
thumbnailUrl: string | null;
|
||
}
|
||
>();
|
||
}
|
||
|
||
const rows = await db
|
||
.select({
|
||
assetId: mediaAsset.id,
|
||
originalUrl: mediaAsset.originalUrl,
|
||
previewUrl: mediaAsset.previewUrl,
|
||
thumbnailUrl: mediaAsset.thumbnailUrl,
|
||
})
|
||
.from(mediaAsset)
|
||
.where(inArray(mediaAsset.id, uniqueIds));
|
||
|
||
return new Map(
|
||
rows.map((row) => [
|
||
row.assetId,
|
||
{
|
||
assetId: row.assetId,
|
||
originalUrl: row.originalUrl,
|
||
previewUrl: row.previewUrl,
|
||
thumbnailUrl: row.thumbnailUrl,
|
||
},
|
||
])
|
||
);
|
||
}
|
||
|
||
async function hydrateAgentPresetRows(
|
||
rows: Array<{
|
||
preset: AgentPreset;
|
||
ownerId: string;
|
||
ownerName: string | null;
|
||
ownerEmail: string;
|
||
categoryId: string | null;
|
||
categoryName: string | null;
|
||
categorySlug: string | null;
|
||
categoryScope: "public" | "private" | "global" | null;
|
||
defaultModelId: string | null;
|
||
defaultModelModelId: string | null;
|
||
defaultModelDisplayName: string | null;
|
||
defaultModelAvatarUrl: string | null;
|
||
}>
|
||
) {
|
||
const presetIds = rows.map((row) => row.preset.id);
|
||
const slotMap = await getAgentSlotsForPresetIds(presetIds);
|
||
const avatarMap = await getAgentAvatarAssetMap(
|
||
rows.map((row) => String(row.preset.coverAssetId || "").trim()).filter(Boolean)
|
||
);
|
||
|
||
return rows.map((row) => {
|
||
const slots = slotMap.get(row.preset.id) || [];
|
||
const references = slots.flatMap((slot) => slot.referenceAssets);
|
||
const avatarAssetId = String(row.preset.coverAssetId || "").trim() || null;
|
||
const avatarAsset = avatarAssetId ? avatarMap.get(avatarAssetId) : null;
|
||
return {
|
||
...row.preset,
|
||
avatarAssetId,
|
||
avatarUrl:
|
||
avatarAsset?.thumbnailUrl ||
|
||
avatarAsset?.previewUrl ||
|
||
avatarAsset?.originalUrl ||
|
||
null,
|
||
owner: {
|
||
id: row.ownerId,
|
||
name: row.ownerName,
|
||
email: row.ownerEmail,
|
||
},
|
||
category: row.categoryId
|
||
? {
|
||
id: row.categoryId,
|
||
name: row.categoryName,
|
||
slug: row.categorySlug,
|
||
scope: row.categoryScope,
|
||
}
|
||
: null,
|
||
defaultModel: row.defaultModelId
|
||
? {
|
||
id: row.defaultModelId,
|
||
modelId: row.defaultModelModelId,
|
||
displayName: row.defaultModelDisplayName,
|
||
avatarUrl: row.defaultModelAvatarUrl,
|
||
}
|
||
: null,
|
||
slots,
|
||
referenceAssets: references,
|
||
referenceAssetIds: references.map((item) => item.assetId),
|
||
referenceAssetCount: references.length,
|
||
};
|
||
});
|
||
}
|
||
|
||
async function validateOwnedReferenceAssets(params: {
|
||
tx: any;
|
||
userId: string;
|
||
assetIds: string[];
|
||
}) {
|
||
const dedupedAssetIds = Array.from(
|
||
new Set(params.assetIds.map((item) => String(item || "").trim()).filter(Boolean))
|
||
);
|
||
|
||
if (dedupedAssetIds.length === 0) {
|
||
return [] as Array<{ id: string }>;
|
||
}
|
||
|
||
const rows = await params.tx
|
||
.select({
|
||
id: mediaAsset.id,
|
||
})
|
||
.from(mediaAsset)
|
||
.where(
|
||
and(
|
||
inArray(mediaAsset.id, dedupedAssetIds),
|
||
eq(mediaAsset.userId, params.userId)
|
||
)
|
||
);
|
||
|
||
if (rows.length !== dedupedAssetIds.length) {
|
||
throw new ChatSDKError("bad_request:database", "Invalid reference assets");
|
||
}
|
||
|
||
const rowIdSet = new Set(rows.map((item: { id: string }) => item.id));
|
||
return dedupedAssetIds
|
||
.filter((assetId) => rowIdSet.has(assetId))
|
||
.map((assetId) => ({ id: assetId }));
|
||
}
|
||
|
||
type AgentPresetSlotInput = {
|
||
title?: string | null;
|
||
promptTemplate: string;
|
||
defaultParams?: Record<string, any> | null;
|
||
requireUpload?: boolean;
|
||
uploadLabel?: string | null;
|
||
referenceAssetIds?: string[];
|
||
};
|
||
|
||
async function validateAgentPresetSlots(params: {
|
||
tx: any;
|
||
userId: string;
|
||
slots: AgentPresetSlotInput[];
|
||
}) {
|
||
const slots = Array.isArray(params.slots) ? params.slots : [];
|
||
if (slots.length === 0) {
|
||
throw new ChatSDKError("bad_request:database", "风格化至少需要配置 1 个槽位");
|
||
}
|
||
if (slots.length > 20) {
|
||
throw new ChatSDKError("bad_request:database", "风格化槽位数量不能超过 20");
|
||
}
|
||
|
||
const allAssetIds = Array.from(
|
||
new Set(
|
||
slots
|
||
.flatMap((slot) => slot.referenceAssetIds || [])
|
||
.map((item) => String(item || "").trim())
|
||
.filter(Boolean)
|
||
)
|
||
);
|
||
const validatedAssets = await validateOwnedReferenceAssets({
|
||
tx: params.tx,
|
||
userId: params.userId,
|
||
assetIds: allAssetIds,
|
||
});
|
||
const validAssetIdSet = new Set(validatedAssets.map((item) => item.id));
|
||
|
||
return slots.map((slot, index) => {
|
||
const promptTemplate = String(slot.promptTemplate || "").trim();
|
||
if (!promptTemplate) {
|
||
throw new ChatSDKError(
|
||
"bad_request:database",
|
||
`第 ${index + 1} 个槽位缺少提示词`
|
||
);
|
||
}
|
||
|
||
const referenceAssetIds = Array.from(
|
||
new Set(
|
||
(slot.referenceAssetIds || [])
|
||
.map((item) => String(item || "").trim())
|
||
.filter(Boolean)
|
||
)
|
||
);
|
||
for (const assetId of referenceAssetIds) {
|
||
if (!validAssetIdSet.has(assetId)) {
|
||
throw new ChatSDKError(
|
||
"bad_request:database",
|
||
`第 ${index + 1} 个槽位包含无效参考图`
|
||
);
|
||
}
|
||
}
|
||
|
||
return {
|
||
slotIndex: index,
|
||
title: String(slot.title || "").trim() || null,
|
||
promptTemplate,
|
||
defaultParams: slot.defaultParams || null,
|
||
requireUpload: slot.requireUpload !== false,
|
||
uploadLabel: String(slot.uploadLabel || "").trim() || null,
|
||
referenceAssetIds,
|
||
};
|
||
});
|
||
}
|
||
|
||
async function resolveAgentCategory(params: {
|
||
tx: any;
|
||
userId: string;
|
||
visibility: AgentPresetVisibility;
|
||
categoryId?: string | null;
|
||
newCategoryName?: string | null;
|
||
}) {
|
||
const requestedCategoryId = String(params.categoryId || "").trim();
|
||
const requestedNewCategoryName = normalizeAgentCategoryName(
|
||
String(params.newCategoryName || "")
|
||
);
|
||
|
||
if (requestedCategoryId) {
|
||
const categories = await params.tx
|
||
.select()
|
||
.from(agentPresetCategory)
|
||
.where(
|
||
and(
|
||
eq(agentPresetCategory.id, requestedCategoryId),
|
||
eq(agentPresetCategory.isEnabled, true),
|
||
getAgentCategoryAccessCondition({
|
||
userId: params.userId,
|
||
scope: "all",
|
||
})
|
||
)
|
||
)
|
||
.limit(1);
|
||
|
||
const category = categories[0] || null;
|
||
if (!category) {
|
||
throw new ChatSDKError("bad_request:database", "Invalid agent category");
|
||
}
|
||
if (params.visibility === "public" && category.scope === "private") {
|
||
throw new ChatSDKError(
|
||
"bad_request:database",
|
||
"Public agent cannot use private category"
|
||
);
|
||
}
|
||
return category;
|
||
}
|
||
|
||
if (!requestedNewCategoryName) {
|
||
if (params.visibility === "public") {
|
||
throw new ChatSDKError(
|
||
"bad_request:database",
|
||
"Public agent category is required"
|
||
);
|
||
}
|
||
return null;
|
||
}
|
||
|
||
const slug = normalizeAgentCategorySlug(requestedNewCategoryName);
|
||
if (!slug) {
|
||
throw new ChatSDKError("bad_request:database", "Invalid category name");
|
||
}
|
||
|
||
const preferredScope = params.visibility === "public" ? "public" : "private";
|
||
const reusablePublicCategories = await params.tx
|
||
.select()
|
||
.from(agentPresetCategory)
|
||
.where(
|
||
and(
|
||
eq(agentPresetCategory.slug, slug),
|
||
eq(agentPresetCategory.isEnabled, true),
|
||
or(
|
||
eq(agentPresetCategory.scope, "public"),
|
||
eq(agentPresetCategory.scope, "global")
|
||
)
|
||
)
|
||
)
|
||
.limit(1);
|
||
|
||
if (reusablePublicCategories[0]) {
|
||
return reusablePublicCategories[0];
|
||
}
|
||
|
||
if (preferredScope === "private") {
|
||
const reusablePrivateCategories = await params.tx
|
||
.select()
|
||
.from(agentPresetCategory)
|
||
.where(
|
||
and(
|
||
eq(agentPresetCategory.slug, slug),
|
||
eq(agentPresetCategory.scope, "private"),
|
||
eq(agentPresetCategory.createdBy, params.userId),
|
||
eq(agentPresetCategory.isEnabled, true)
|
||
)
|
||
)
|
||
.limit(1);
|
||
|
||
if (reusablePrivateCategories[0]) {
|
||
return reusablePrivateCategories[0];
|
||
}
|
||
}
|
||
|
||
const [createdCategory] = await params.tx
|
||
.insert(agentPresetCategory)
|
||
.values({
|
||
name: requestedNewCategoryName,
|
||
slug,
|
||
scope: preferredScope,
|
||
createdBy: params.userId,
|
||
sortOrder: 0,
|
||
isEnabled: true,
|
||
updatedAt: new Date(),
|
||
})
|
||
.returning();
|
||
|
||
return createdCategory;
|
||
}
|
||
|
||
export async function getVisibleAgentCategories(params: {
|
||
userId: string;
|
||
scope?: AgentCategoryScope;
|
||
}) {
|
||
try {
|
||
return await db
|
||
.select()
|
||
.from(agentPresetCategory)
|
||
.where(
|
||
and(
|
||
eq(agentPresetCategory.isEnabled, true),
|
||
getAgentCategoryAccessCondition({
|
||
userId: params.userId,
|
||
scope: params.scope || "all",
|
||
})
|
||
)
|
||
)
|
||
.orderBy(
|
||
asc(agentPresetCategory.sortOrder),
|
||
asc(agentPresetCategory.name),
|
||
asc(agentPresetCategory.createdAt)
|
||
);
|
||
} catch (_error) {
|
||
throw new ChatSDKError(
|
||
"bad_request:database",
|
||
"Failed to get visible agent categories"
|
||
);
|
||
}
|
||
}
|
||
|
||
export async function createAgentCategory(params: {
|
||
userId: string;
|
||
name: string;
|
||
scope: Exclude<AgentCategoryScope, "all">;
|
||
}) {
|
||
try {
|
||
return await db.transaction(async (tx) => {
|
||
const category = await resolveAgentCategory({
|
||
tx,
|
||
userId: params.userId,
|
||
visibility: params.scope === "public" || params.scope === "global" ? "public" : "private",
|
||
newCategoryName: params.name,
|
||
});
|
||
return category;
|
||
});
|
||
} catch (_error) {
|
||
throw new ChatSDKError(
|
||
"bad_request:database",
|
||
"Failed to create agent category"
|
||
);
|
||
}
|
||
}
|
||
|
||
export async function getVisibleAgentPresets(params: {
|
||
userId: string;
|
||
scope?: AgentPresetScope;
|
||
type?: AgentPresetKind | null;
|
||
categoryId?: string | null;
|
||
keyword?: string | null;
|
||
sort?: AgentPresetSort;
|
||
}) {
|
||
try {
|
||
const filters: SQL[] = [
|
||
eq(agentPreset.status, "active"),
|
||
getAgentPresetAccessCondition({
|
||
userId: params.userId,
|
||
scope: params.scope || "all",
|
||
}),
|
||
];
|
||
|
||
if (params.type) {
|
||
filters.push(eq(agentPreset.type, params.type));
|
||
}
|
||
|
||
const normalizedCategoryId = String(params.categoryId || "").trim();
|
||
if (normalizedCategoryId) {
|
||
filters.push(eq(agentPreset.categoryId, normalizedCategoryId));
|
||
}
|
||
|
||
const normalizedKeyword = String(params.keyword || "").trim().toLowerCase();
|
||
if (normalizedKeyword) {
|
||
const keywordPattern = `%${normalizedKeyword}%`;
|
||
filters.push(
|
||
or(
|
||
sql`lower(${agentPreset.name}) like ${keywordPattern}`,
|
||
sql`lower(coalesce(${agentPreset.description}, '')) like ${keywordPattern}`,
|
||
sql`lower(coalesce(${agentPreset.promptTemplate}, '')) like ${keywordPattern}`
|
||
)!
|
||
);
|
||
}
|
||
|
||
const orderBy =
|
||
params.sort === "hottest" || params.sort === "most_used"
|
||
? [desc(agentPreset.usageCount), desc(agentPreset.updatedAt), desc(agentPreset.createdAt)]
|
||
: [desc(agentPreset.updatedAt), desc(agentPreset.createdAt)];
|
||
|
||
const rows = await db
|
||
.select({
|
||
preset: agentPreset,
|
||
ownerId: user.id,
|
||
ownerName: user.name,
|
||
ownerEmail: user.email,
|
||
categoryId: agentPresetCategory.id,
|
||
categoryName: agentPresetCategory.name,
|
||
categorySlug: agentPresetCategory.slug,
|
||
categoryScope: agentPresetCategory.scope,
|
||
defaultModelId: aiModel.id,
|
||
defaultModelModelId: aiModel.modelId,
|
||
defaultModelDisplayName: aiModel.displayName,
|
||
defaultModelAvatarUrl: aiModel.avatarUrl,
|
||
})
|
||
.from(agentPreset)
|
||
.innerJoin(user, eq(agentPreset.ownerUserId, user.id))
|
||
.leftJoin(agentPresetCategory, eq(agentPreset.categoryId, agentPresetCategory.id))
|
||
.leftJoin(aiModel, eq(agentPreset.defaultModelId, aiModel.id))
|
||
.where(and(...filters))
|
||
.orderBy(...orderBy);
|
||
|
||
return await hydrateAgentPresetRows(rows);
|
||
} catch (_error) {
|
||
throw new ChatSDKError(
|
||
"bad_request:database",
|
||
"Failed to get visible agent presets"
|
||
);
|
||
}
|
||
}
|
||
|
||
export async function getAccessibleAgentPresetById(params: {
|
||
id: string;
|
||
userId: string;
|
||
}) {
|
||
try {
|
||
const rows = await db
|
||
.select({
|
||
preset: agentPreset,
|
||
ownerId: user.id,
|
||
ownerName: user.name,
|
||
ownerEmail: user.email,
|
||
categoryId: agentPresetCategory.id,
|
||
categoryName: agentPresetCategory.name,
|
||
categorySlug: agentPresetCategory.slug,
|
||
categoryScope: agentPresetCategory.scope,
|
||
defaultModelId: aiModel.id,
|
||
defaultModelModelId: aiModel.modelId,
|
||
defaultModelDisplayName: aiModel.displayName,
|
||
defaultModelAvatarUrl: aiModel.avatarUrl,
|
||
})
|
||
.from(agentPreset)
|
||
.innerJoin(user, eq(agentPreset.ownerUserId, user.id))
|
||
.leftJoin(agentPresetCategory, eq(agentPreset.categoryId, agentPresetCategory.id))
|
||
.leftJoin(aiModel, eq(agentPreset.defaultModelId, aiModel.id))
|
||
.where(
|
||
and(
|
||
eq(agentPreset.id, params.id),
|
||
getAgentPresetAccessCondition({
|
||
userId: params.userId,
|
||
scope: "all",
|
||
})
|
||
)
|
||
)
|
||
.limit(1);
|
||
|
||
const hydrated = await hydrateAgentPresetRows(rows);
|
||
return hydrated[0] || null;
|
||
} catch (_error) {
|
||
throw new ChatSDKError(
|
||
"bad_request:database",
|
||
"Failed to get agent preset by id"
|
||
);
|
||
}
|
||
}
|
||
|
||
export async function createAgentPreset(params: {
|
||
userId: string;
|
||
name: string;
|
||
type: AgentPresetKind;
|
||
secondaryType?: string | null;
|
||
visibility: AgentPresetVisibility;
|
||
categoryPath?: {
|
||
sectionId?: string | null;
|
||
sectionName?: string | null;
|
||
level1Id?: string | null;
|
||
level1Name?: string | null;
|
||
level2Id?: string | null;
|
||
level2Name?: string | null;
|
||
level3Id?: string | null;
|
||
level3Name?: string | null;
|
||
productCategory?: string | null;
|
||
audienceTag?: string | null;
|
||
usageScenario?: string | null;
|
||
brand?: string | null;
|
||
} | null;
|
||
description?: string | null;
|
||
defaultModelId: string;
|
||
defaultParams?: Record<string, any> | null;
|
||
avatarAssetId?: string | null;
|
||
slots: AgentPresetSlotInput[];
|
||
}) {
|
||
try {
|
||
const presetId = await db.transaction(async (tx) => {
|
||
await validateAgentDefaultModel({
|
||
tx,
|
||
modelId: params.defaultModelId,
|
||
presetType: params.type,
|
||
});
|
||
|
||
const validatedSlots = await validateAgentPresetSlots({
|
||
tx,
|
||
userId: params.userId,
|
||
slots: params.slots,
|
||
});
|
||
|
||
const hasUploadRequirement = validatedSlots.some((slot) => slot.requireUpload !== false);
|
||
const requiredUploadCount = hasUploadRequirement
|
||
? getAgentRequiredUploadCount({
|
||
presetType: params.type,
|
||
defaultParams: params.defaultParams || null,
|
||
fallback: 3,
|
||
})
|
||
: 0;
|
||
const avatarAssetId = String(params.avatarAssetId || "").trim() || null;
|
||
if (avatarAssetId) {
|
||
await validateOwnedReferenceAssets({
|
||
tx,
|
||
userId: params.userId,
|
||
assetIds: [avatarAssetId],
|
||
});
|
||
}
|
||
|
||
const [createdPreset] = await tx
|
||
.insert(agentPreset)
|
||
.values({
|
||
ownerUserId: params.userId,
|
||
name: params.name.trim(),
|
||
type: params.type,
|
||
secondaryType: params.secondaryType || null,
|
||
visibility: params.visibility,
|
||
categoryId: null,
|
||
categoryPath: params.categoryPath || null,
|
||
promptTemplate: validatedSlots[0]?.promptTemplate || "",
|
||
description: params.description || null,
|
||
defaultModelId: params.defaultModelId,
|
||
defaultParams: params.defaultParams || null,
|
||
slotCount: validatedSlots.length,
|
||
requiredUploadCount,
|
||
coverAssetId: avatarAssetId,
|
||
status: "active",
|
||
sortOrder: 0,
|
||
updatedAt: new Date(),
|
||
})
|
||
.returning({ id: agentPreset.id });
|
||
|
||
const insertedSlots =
|
||
validatedSlots.length > 0
|
||
? await tx
|
||
.insert(agentPresetSlot)
|
||
.values(
|
||
validatedSlots.map((slot) => ({
|
||
presetId: createdPreset.id,
|
||
slotIndex: slot.slotIndex,
|
||
title: slot.title,
|
||
promptTemplate: slot.promptTemplate,
|
||
defaultParams: slot.defaultParams || null,
|
||
requireUpload: slot.requireUpload !== false,
|
||
uploadLabel: slot.uploadLabel,
|
||
updatedAt: new Date(),
|
||
}))
|
||
)
|
||
.returning({ id: agentPresetSlot.id, slotIndex: agentPresetSlot.slotIndex })
|
||
: [];
|
||
|
||
if (insertedSlots.length > 0) {
|
||
const slotIdByIndex = new Map(insertedSlots.map((slot) => [slot.slotIndex, slot.id]));
|
||
const referenceRows = validatedSlots.flatMap((slot) =>
|
||
slot.referenceAssetIds.map((assetId, assetIndex) => ({
|
||
presetId: createdPreset.id,
|
||
slotId: slotIdByIndex.get(slot.slotIndex) || null,
|
||
assetId,
|
||
sortOrder: assetIndex,
|
||
}))
|
||
);
|
||
|
||
if (referenceRows.length > 0) {
|
||
await tx.insert(agentPresetReferenceAsset).values(referenceRows as any);
|
||
}
|
||
}
|
||
|
||
return createdPreset.id;
|
||
});
|
||
|
||
return await getAccessibleAgentPresetById({ id: presetId, userId: params.userId });
|
||
} catch (_error) {
|
||
throw new ChatSDKError(
|
||
"bad_request:database",
|
||
"Failed to create agent preset"
|
||
);
|
||
}
|
||
}
|
||
|
||
export async function updateAgentPreset(params: {
|
||
id: string;
|
||
userId: string;
|
||
name?: string;
|
||
type?: AgentPresetKind;
|
||
secondaryType?: string | null;
|
||
visibility?: AgentPresetVisibility;
|
||
categoryPath?: {
|
||
sectionId?: string | null;
|
||
sectionName?: string | null;
|
||
level1Id?: string | null;
|
||
level1Name?: string | null;
|
||
level2Id?: string | null;
|
||
level2Name?: string | null;
|
||
level3Id?: string | null;
|
||
level3Name?: string | null;
|
||
productCategory?: string | null;
|
||
audienceTag?: string | null;
|
||
usageScenario?: string | null;
|
||
brand?: string | null;
|
||
} | null;
|
||
description?: string | null;
|
||
defaultModelId?: string;
|
||
defaultParams?: Record<string, any> | null;
|
||
avatarAssetId?: string | null;
|
||
slots?: AgentPresetSlotInput[];
|
||
status?: "active" | "disabled" | "hidden" | "blocked";
|
||
}) {
|
||
try {
|
||
const presetId = await db.transaction(async (tx) => {
|
||
const existingRows = await tx
|
||
.select()
|
||
.from(agentPreset)
|
||
.where(
|
||
and(
|
||
eq(agentPreset.id, params.id),
|
||
eq(agentPreset.ownerUserId, params.userId)
|
||
)
|
||
)
|
||
.limit(1);
|
||
|
||
const existing = existingRows[0];
|
||
if (!existing) {
|
||
throw new ChatSDKError("bad_request:database", "Agent preset not found");
|
||
}
|
||
|
||
const nextVisibility = params.visibility || existing.visibility;
|
||
const nextType = params.type || existing.type;
|
||
const nextDefaultModelId = params.defaultModelId || existing.defaultModelId;
|
||
const existingSlots = await tx
|
||
.select()
|
||
.from(agentPresetSlot)
|
||
.where(eq(agentPresetSlot.presetId, params.id))
|
||
.orderBy(asc(agentPresetSlot.slotIndex), asc(agentPresetSlot.createdAt));
|
||
if (!nextDefaultModelId) {
|
||
throw new ChatSDKError(
|
||
"bad_request:database",
|
||
"请选择对应的默认模型"
|
||
);
|
||
}
|
||
|
||
await validateAgentDefaultModel({
|
||
tx,
|
||
modelId: nextDefaultModelId,
|
||
presetType: nextType,
|
||
});
|
||
|
||
const nextSlotsInput =
|
||
params.slots !== undefined
|
||
? params.slots
|
||
: existingSlots.map((slot) => ({
|
||
title: slot.title,
|
||
promptTemplate: slot.promptTemplate,
|
||
defaultParams: slot.defaultParams || null,
|
||
requireUpload: slot.requireUpload,
|
||
uploadLabel: slot.uploadLabel,
|
||
referenceAssetIds: [],
|
||
}));
|
||
|
||
const existingReferenceRows =
|
||
params.slots === undefined
|
||
? await tx
|
||
.select({
|
||
slotId: agentPresetReferenceAsset.slotId,
|
||
assetId: agentPresetReferenceAsset.assetId,
|
||
})
|
||
.from(agentPresetReferenceAsset)
|
||
.where(eq(agentPresetReferenceAsset.presetId, params.id))
|
||
: [];
|
||
|
||
const existingRefBySlotId = new Map<string, string[]>();
|
||
for (const row of existingReferenceRows) {
|
||
const slotId = String(row.slotId || "").trim();
|
||
if (!slotId) continue;
|
||
const current = existingRefBySlotId.get(slotId) || [];
|
||
current.push(row.assetId);
|
||
existingRefBySlotId.set(slotId, current);
|
||
}
|
||
|
||
const normalizedSlotsInput =
|
||
params.slots !== undefined
|
||
? params.slots
|
||
: existingSlots.map((slot) => ({
|
||
title: slot.title,
|
||
promptTemplate: slot.promptTemplate,
|
||
defaultParams: slot.defaultParams || null,
|
||
requireUpload: slot.requireUpload,
|
||
uploadLabel: slot.uploadLabel,
|
||
referenceAssetIds: existingRefBySlotId.get(slot.id) || [],
|
||
}));
|
||
|
||
const validatedSlots = await validateAgentPresetSlots({
|
||
tx,
|
||
userId: params.userId,
|
||
slots: normalizedSlotsInput,
|
||
});
|
||
const nextDefaultParams =
|
||
params.defaultParams !== undefined
|
||
? params.defaultParams || null
|
||
: (existing.defaultParams as Record<string, any> | null);
|
||
const hasUploadRequirement = validatedSlots.some((slot) => slot.requireUpload !== false);
|
||
const requiredUploadCount = hasUploadRequirement
|
||
? getAgentRequiredUploadCount({
|
||
presetType: nextType,
|
||
defaultParams: nextDefaultParams,
|
||
fallback: 3,
|
||
})
|
||
: 0;
|
||
const nextAvatarAssetId =
|
||
params.avatarAssetId !== undefined
|
||
? String(params.avatarAssetId || "").trim() || null
|
||
: String(existing.coverAssetId || "").trim() || null;
|
||
if (nextAvatarAssetId) {
|
||
await validateOwnedReferenceAssets({
|
||
tx,
|
||
userId: params.userId,
|
||
assetIds: [nextAvatarAssetId],
|
||
});
|
||
}
|
||
|
||
const updateData: Record<string, unknown> = {
|
||
updatedAt: new Date(),
|
||
};
|
||
|
||
if (params.name !== undefined) updateData.name = params.name.trim();
|
||
if (params.type !== undefined) updateData.type = params.type;
|
||
if (params.secondaryType !== undefined) {
|
||
updateData.secondaryType = params.secondaryType || null;
|
||
}
|
||
if (params.visibility !== undefined) updateData.visibility = params.visibility;
|
||
if (params.categoryPath !== undefined) updateData.categoryPath = params.categoryPath || null;
|
||
if (params.description !== undefined) updateData.description = params.description || null;
|
||
if (params.defaultModelId !== undefined) {
|
||
updateData.defaultModelId = params.defaultModelId;
|
||
}
|
||
if (params.defaultParams !== undefined) {
|
||
updateData.defaultParams = params.defaultParams || null;
|
||
}
|
||
if (params.status !== undefined) updateData.status = params.status;
|
||
updateData.promptTemplate = validatedSlots[0]?.promptTemplate || "";
|
||
updateData.slotCount = validatedSlots.length;
|
||
updateData.requiredUploadCount = requiredUploadCount;
|
||
updateData.coverAssetId = nextAvatarAssetId;
|
||
|
||
await tx
|
||
.update(agentPreset)
|
||
.set(updateData)
|
||
.where(eq(agentPreset.id, params.id));
|
||
|
||
await tx
|
||
.delete(agentPresetReferenceAsset)
|
||
.where(eq(agentPresetReferenceAsset.presetId, params.id));
|
||
await tx.delete(agentPresetSlot).where(eq(agentPresetSlot.presetId, params.id));
|
||
|
||
const insertedSlots =
|
||
validatedSlots.length > 0
|
||
? await tx
|
||
.insert(agentPresetSlot)
|
||
.values(
|
||
validatedSlots.map((slot) => ({
|
||
presetId: params.id,
|
||
slotIndex: slot.slotIndex,
|
||
title: slot.title,
|
||
promptTemplate: slot.promptTemplate,
|
||
defaultParams: slot.defaultParams || null,
|
||
requireUpload: slot.requireUpload !== false,
|
||
uploadLabel: slot.uploadLabel,
|
||
updatedAt: new Date(),
|
||
}))
|
||
)
|
||
.returning({ id: agentPresetSlot.id, slotIndex: agentPresetSlot.slotIndex })
|
||
: [];
|
||
|
||
if (insertedSlots.length > 0) {
|
||
const slotIdByIndex = new Map(insertedSlots.map((slot) => [slot.slotIndex, slot.id]));
|
||
const referenceRows = validatedSlots.flatMap((slot) =>
|
||
slot.referenceAssetIds.map((assetId, assetIndex) => ({
|
||
presetId: params.id,
|
||
slotId: slotIdByIndex.get(slot.slotIndex) || null,
|
||
assetId,
|
||
sortOrder: assetIndex,
|
||
}))
|
||
);
|
||
|
||
if (referenceRows.length > 0) {
|
||
await tx.insert(agentPresetReferenceAsset).values(referenceRows as any);
|
||
}
|
||
}
|
||
|
||
return params.id;
|
||
});
|
||
|
||
return await getAccessibleAgentPresetById({ id: presetId, userId: params.userId });
|
||
} catch (_error) {
|
||
throw new ChatSDKError(
|
||
"bad_request:database",
|
||
"Failed to update agent preset"
|
||
);
|
||
}
|
||
}
|
||
|
||
export async function deleteAgentPresetById(params: {
|
||
id: string;
|
||
userId: string;
|
||
}) {
|
||
try {
|
||
const rows = await db
|
||
.delete(agentPreset)
|
||
.where(
|
||
and(eq(agentPreset.id, params.id), eq(agentPreset.ownerUserId, params.userId))
|
||
)
|
||
.returning();
|
||
|
||
return rows[0] || null;
|
||
} catch (_error) {
|
||
throw new ChatSDKError(
|
||
"bad_request:database",
|
||
"Failed to delete agent preset"
|
||
);
|
||
}
|
||
}
|
||
|
||
export async function forkAgentPreset(params: {
|
||
id: string;
|
||
userId: string;
|
||
name?: string | null;
|
||
}) {
|
||
try {
|
||
const forkedId = await db.transaction(async (tx) => {
|
||
const sourceRows = await tx
|
||
.select()
|
||
.from(agentPreset)
|
||
.where(
|
||
and(
|
||
eq(agentPreset.id, params.id),
|
||
getAgentPresetAccessCondition({
|
||
userId: params.userId,
|
||
scope: "all",
|
||
})
|
||
)
|
||
)
|
||
.limit(1);
|
||
|
||
const source = sourceRows[0];
|
||
if (!source) {
|
||
throw new ChatSDKError("bad_request:database", "Agent preset not found");
|
||
}
|
||
|
||
const sourceReferences = await tx
|
||
.select({
|
||
slotId: agentPresetReferenceAsset.slotId,
|
||
assetId: agentPresetReferenceAsset.assetId,
|
||
sortOrder: agentPresetReferenceAsset.sortOrder,
|
||
})
|
||
.from(agentPresetReferenceAsset)
|
||
.where(eq(agentPresetReferenceAsset.presetId, source.id))
|
||
.orderBy(asc(agentPresetReferenceAsset.sortOrder));
|
||
const sourceSlots = await tx
|
||
.select()
|
||
.from(agentPresetSlot)
|
||
.where(eq(agentPresetSlot.presetId, source.id))
|
||
.orderBy(asc(agentPresetSlot.slotIndex), asc(agentPresetSlot.createdAt));
|
||
|
||
const [forkedPreset] = await tx
|
||
.insert(agentPreset)
|
||
.values({
|
||
ownerUserId: params.userId,
|
||
name: String(params.name || "").trim() || `${source.name}(副本)`,
|
||
type: source.type,
|
||
secondaryType: source.secondaryType,
|
||
visibility: "private",
|
||
categoryId: source.categoryId,
|
||
categoryPath: source.categoryPath,
|
||
promptTemplate: source.promptTemplate,
|
||
description: source.description,
|
||
defaultModelId: source.defaultModelId,
|
||
defaultParams: source.defaultParams,
|
||
slotCount: source.slotCount,
|
||
requiredUploadCount: source.requiredUploadCount,
|
||
coverAssetId: source.coverAssetId,
|
||
status: "active",
|
||
sortOrder: 0,
|
||
forkedFromPresetId: source.id,
|
||
updatedAt: new Date(),
|
||
})
|
||
.returning({ id: agentPreset.id });
|
||
|
||
const insertedSlots =
|
||
sourceSlots.length > 0
|
||
? await tx
|
||
.insert(agentPresetSlot)
|
||
.values(
|
||
sourceSlots.map((slot) => ({
|
||
presetId: forkedPreset.id,
|
||
slotIndex: slot.slotIndex,
|
||
title: slot.title,
|
||
promptTemplate: slot.promptTemplate,
|
||
defaultParams: slot.defaultParams || null,
|
||
requireUpload: slot.requireUpload,
|
||
uploadLabel: slot.uploadLabel,
|
||
updatedAt: new Date(),
|
||
}))
|
||
)
|
||
.returning({ id: agentPresetSlot.id, slotIndex: agentPresetSlot.slotIndex })
|
||
: [];
|
||
|
||
if (sourceReferences.length > 0) {
|
||
const sourceSlotIndexById = new Map(sourceSlots.map((slot) => [slot.id, slot.slotIndex]));
|
||
const targetSlotIdByIndex = new Map(insertedSlots.map((slot) => [slot.slotIndex, slot.id]));
|
||
await tx.insert(agentPresetReferenceAsset).values(
|
||
sourceReferences.map((reference) => ({
|
||
presetId: forkedPreset.id,
|
||
slotId: reference.slotId
|
||
? targetSlotIdByIndex.get(sourceSlotIndexById.get(reference.slotId) || -1) || null
|
||
: null,
|
||
assetId: reference.assetId,
|
||
sortOrder: reference.sortOrder,
|
||
})) as any
|
||
);
|
||
}
|
||
|
||
return forkedPreset.id;
|
||
});
|
||
|
||
return await getAccessibleAgentPresetById({ id: forkedId, userId: params.userId });
|
||
} catch (_error) {
|
||
throw new ChatSDKError(
|
||
"bad_request:database",
|
||
"Failed to fork agent preset"
|
||
);
|
||
}
|
||
}
|
||
|
||
export async function createAgentPresetExecutionSnapshot(params: {
|
||
presetId: string;
|
||
userId: string;
|
||
projectId?: string | null;
|
||
chatId?: string | null;
|
||
messageId?: string | null;
|
||
presetName: string;
|
||
presetType: AgentPresetKind;
|
||
secondaryType?: string | null;
|
||
visibility: AgentPresetVisibility;
|
||
categoryId?: string | null;
|
||
categoryName?: string | null;
|
||
promptTemplate: string;
|
||
defaultModelId?: string | null;
|
||
resolvedModelId?: string | null;
|
||
defaultParams?: Record<string, any> | null;
|
||
slotCount?: number;
|
||
requiredUploadCount?: number;
|
||
slots?: Array<{
|
||
slotIndex: number;
|
||
title?: string | null;
|
||
promptTemplate: string;
|
||
defaultParams?: Record<string, any> | null;
|
||
requireUpload?: boolean;
|
||
uploadLabel?: string | null;
|
||
referenceAssetIds: string[];
|
||
referenceImageUrls: string[];
|
||
}>;
|
||
referenceAssetIds?: string[];
|
||
referenceImageUrls?: string[];
|
||
userInputText?: string | null;
|
||
requestPayload?: Record<string, any> | null;
|
||
}) {
|
||
try {
|
||
const [snapshot] = await db
|
||
.insert(agentPresetExecutionSnapshot)
|
||
.values({
|
||
presetId: params.presetId,
|
||
projectId: params.projectId || null,
|
||
chatId: params.chatId || null,
|
||
messageId: params.messageId || null,
|
||
userId: params.userId,
|
||
presetName: params.presetName,
|
||
presetType: params.presetType,
|
||
secondaryType: params.secondaryType || null,
|
||
visibility: params.visibility,
|
||
categoryId: params.categoryId || null,
|
||
categoryName: params.categoryName || null,
|
||
promptTemplate: params.promptTemplate,
|
||
defaultModelId: params.defaultModelId || null,
|
||
resolvedModelId: params.resolvedModelId || null,
|
||
defaultParams: params.defaultParams || null,
|
||
slotCount: params.slotCount || 1,
|
||
requiredUploadCount: params.requiredUploadCount || 1,
|
||
slots: params.slots || [],
|
||
referenceAssetIds: params.referenceAssetIds || [],
|
||
referenceImageUrls: params.referenceImageUrls || [],
|
||
userInputText: params.userInputText || null,
|
||
requestPayload: params.requestPayload || null,
|
||
})
|
||
.returning();
|
||
|
||
return snapshot;
|
||
} catch (_error) {
|
||
throw new ChatSDKError(
|
||
"bad_request:database",
|
||
"Failed to create agent preset execution snapshot"
|
||
);
|
||
}
|
||
}
|
||
|
||
export async function getAccessibleAgentPresetExecutionSnapshotById(params: {
|
||
id: string;
|
||
userId: string;
|
||
}) {
|
||
try {
|
||
const defaultModelAlias = aliasedTable(aiModel, "agentSnapshotDefaultModel");
|
||
const resolvedModelAlias = aliasedTable(aiModel, "agentSnapshotResolvedModel");
|
||
|
||
const rows = await db
|
||
.select({
|
||
snapshot: agentPresetExecutionSnapshot,
|
||
defaultModelId: defaultModelAlias.id,
|
||
defaultModelModelId: defaultModelAlias.modelId,
|
||
defaultModelDisplayName: defaultModelAlias.displayName,
|
||
defaultModelAvatarUrl: defaultModelAlias.avatarUrl,
|
||
resolvedModelId: resolvedModelAlias.id,
|
||
resolvedModelModelId: resolvedModelAlias.modelId,
|
||
resolvedModelDisplayName: resolvedModelAlias.displayName,
|
||
resolvedModelAvatarUrl: resolvedModelAlias.avatarUrl,
|
||
})
|
||
.from(agentPresetExecutionSnapshot)
|
||
.leftJoin(defaultModelAlias, eq(agentPresetExecutionSnapshot.defaultModelId, defaultModelAlias.id))
|
||
.leftJoin(resolvedModelAlias, eq(agentPresetExecutionSnapshot.resolvedModelId, resolvedModelAlias.id))
|
||
.where(
|
||
and(
|
||
eq(agentPresetExecutionSnapshot.id, params.id),
|
||
eq(agentPresetExecutionSnapshot.userId, params.userId)
|
||
)
|
||
)
|
||
.limit(1);
|
||
|
||
const row = rows[0];
|
||
if (!row) return null;
|
||
|
||
return {
|
||
...row.snapshot,
|
||
defaultModel: row.defaultModelId
|
||
? {
|
||
id: row.defaultModelId,
|
||
modelId: row.defaultModelModelId,
|
||
displayName: row.defaultModelDisplayName,
|
||
avatarUrl: row.defaultModelAvatarUrl,
|
||
}
|
||
: null,
|
||
resolvedModel: row.resolvedModelId
|
||
? {
|
||
id: row.resolvedModelId,
|
||
modelId: row.resolvedModelModelId,
|
||
displayName: row.resolvedModelDisplayName,
|
||
avatarUrl: row.resolvedModelAvatarUrl,
|
||
}
|
||
: null,
|
||
};
|
||
} catch (_error) {
|
||
throw new ChatSDKError(
|
||
"bad_request:database",
|
||
"Failed to get agent preset execution snapshot"
|
||
);
|
||
}
|
||
}
|
||
|
||
export async function consumeUserPoints({
|
||
userId,
|
||
amount,
|
||
reason = "image_generation",
|
||
modelId,
|
||
chatId,
|
||
messageId,
|
||
note,
|
||
}: {
|
||
userId: string;
|
||
amount: number;
|
||
reason?: string;
|
||
modelId?: string | null;
|
||
chatId?: string | null;
|
||
messageId?: string | null;
|
||
note?: string;
|
||
}) {
|
||
if (!Number.isFinite(amount) || amount <= 0) {
|
||
return { ok: true, balance: null as number | null, skipped: false };
|
||
}
|
||
|
||
try {
|
||
return await db.transaction(async (tx) => {
|
||
const currentUsers = await tx
|
||
.select({
|
||
balance: user.pointsBalance,
|
||
isHighVip: user.isHighVip,
|
||
})
|
||
.from(user)
|
||
.where(eq(user.id, userId))
|
||
.limit(1);
|
||
|
||
const currentUser = currentUsers[0];
|
||
if (!currentUser) {
|
||
return { ok: false, balance: null as number | null, skipped: false };
|
||
}
|
||
|
||
if (currentUser.isHighVip) {
|
||
return {
|
||
ok: true,
|
||
balance: Math.max(0, Number(currentUser.balance || 0)),
|
||
skipped: true,
|
||
};
|
||
}
|
||
|
||
const updated = await tx
|
||
.update(user)
|
||
.set({
|
||
pointsBalance: sql`${user.pointsBalance} - ${amount}`,
|
||
updatedAt: new Date(),
|
||
})
|
||
.where(and(eq(user.id, userId), gte(user.pointsBalance, amount)))
|
||
.returning({
|
||
balance: user.pointsBalance,
|
||
});
|
||
|
||
if (updated.length === 0) {
|
||
return { ok: false, balance: null as number | null, skipped: false };
|
||
}
|
||
|
||
await tx.insert(pointsLedger).values({
|
||
userId,
|
||
amount: -amount,
|
||
reason,
|
||
modelId: modelId || null,
|
||
chatId: chatId || null,
|
||
messageId: messageId || null,
|
||
note: note || "图片生成消耗积分",
|
||
});
|
||
|
||
return { ok: true, balance: updated[0]?.balance ?? null, skipped: false };
|
||
});
|
||
} catch (_error) {
|
||
throw new ChatSDKError("bad_request:database", "Failed to consume user points");
|
||
}
|
||
}
|
||
|
||
export async function getPointsLedgerByUserId({
|
||
userId,
|
||
limit = 100,
|
||
}: {
|
||
userId: string;
|
||
limit?: number;
|
||
}) {
|
||
try {
|
||
return await db
|
||
.select({
|
||
id: pointsLedger.id,
|
||
amount: pointsLedger.amount,
|
||
reason: pointsLedger.reason,
|
||
modelId: pointsLedger.modelId,
|
||
chatId: pointsLedger.chatId,
|
||
messageId: pointsLedger.messageId,
|
||
note: pointsLedger.note,
|
||
createdAt: pointsLedger.createdAt,
|
||
})
|
||
.from(pointsLedger)
|
||
.where(eq(pointsLedger.userId, userId))
|
||
.orderBy(desc(pointsLedger.createdAt))
|
||
.limit(limit);
|
||
} catch (_error) {
|
||
throw new ChatSDKError(
|
||
"bad_request:database",
|
||
"Failed to get points ledger by user id"
|
||
);
|
||
}
|
||
}
|
||
|
||
export async function getAllSystemSettings() {
|
||
try {
|
||
return await db.select().from(systemSettings);
|
||
} catch (_error) {
|
||
console.error("getAllSystemSettings error:", _error);
|
||
throw new ChatSDKError(
|
||
"bad_request:database",
|
||
typeof _error === "object" ? JSON.stringify(_error) : String(_error)
|
||
);
|
||
}
|
||
}
|
||
|
||
export async function upsertSystemSetting({
|
||
key,
|
||
value,
|
||
updatedBy,
|
||
}: {
|
||
key: string;
|
||
value: any;
|
||
updatedBy: string;
|
||
}) {
|
||
try {
|
||
return await db
|
||
.insert(systemSettings)
|
||
.values({
|
||
key,
|
||
value,
|
||
updatedBy,
|
||
updatedAt: new Date(),
|
||
})
|
||
.onConflictDoUpdate({
|
||
target: systemSettings.key,
|
||
set: {
|
||
value,
|
||
updatedBy,
|
||
updatedAt: new Date(),
|
||
},
|
||
})
|
||
.returning();
|
||
} catch (_error) {
|
||
throw new ChatSDKError(
|
||
"bad_request:database",
|
||
"Failed to upsert system setting"
|
||
);
|
||
}
|
||
}
|
||
|
||
// Tool Configuration
|
||
export async function getAllToolConfigs() {
|
||
try {
|
||
return await db.select().from(toolConfig).orderBy(asc(toolConfig.toolName));
|
||
} catch (_error) {
|
||
// Log to surface the actual database error for debugging
|
||
console.error("getAllToolConfigs error:", _error);
|
||
throw new ChatSDKError(
|
||
"bad_request:database",
|
||
typeof _error === "object" ? JSON.stringify(_error) : String(_error)
|
||
);
|
||
}
|
||
}
|
||
|
||
export async function getToolConfig({ toolName }: { toolName: string }) {
|
||
try {
|
||
const configs = await db
|
||
.select()
|
||
.from(toolConfig)
|
||
.where(eq(toolConfig.toolName, toolName))
|
||
.limit(1);
|
||
return configs[0];
|
||
} catch (_error) {
|
||
throw new ChatSDKError(
|
||
"bad_request:database",
|
||
"Failed to get tool config"
|
||
);
|
||
}
|
||
}
|
||
|
||
export async function upsertToolConfig({
|
||
toolName,
|
||
isEnabled,
|
||
description,
|
||
config,
|
||
updatedBy,
|
||
}: {
|
||
toolName: string;
|
||
isEnabled: boolean;
|
||
description?: string;
|
||
config?: any;
|
||
updatedBy: string;
|
||
}) {
|
||
try {
|
||
return await db
|
||
.insert(toolConfig)
|
||
.values({
|
||
toolName,
|
||
isEnabled,
|
||
description,
|
||
config,
|
||
updatedBy,
|
||
updatedAt: new Date(),
|
||
})
|
||
.onConflictDoUpdate({
|
||
target: toolConfig.toolName,
|
||
set: {
|
||
isEnabled,
|
||
description,
|
||
config,
|
||
updatedBy,
|
||
updatedAt: new Date(),
|
||
},
|
||
})
|
||
.returning();
|
||
} catch (_error) {
|
||
throw new ChatSDKError(
|
||
"bad_request:database",
|
||
"Failed to upsert tool config"
|
||
);
|
||
}
|
||
}
|
||
|
||
// ==================== AI Provider CRUD ====================
|
||
|
||
export async function getAllAiProviders(): Promise<AiProvider[]> {
|
||
try {
|
||
return await db
|
||
.select()
|
||
.from(aiProvider)
|
||
.orderBy(asc(aiProvider.createdAt));
|
||
} catch (_error) {
|
||
console.error("getAllAiProviders error:", _error);
|
||
throw new ChatSDKError(
|
||
"bad_request:database",
|
||
typeof _error === "object" ? JSON.stringify(_error) : String(_error)
|
||
);
|
||
}
|
||
}
|
||
|
||
export async function getEnabledAiProviders(): Promise<AiProvider[]> {
|
||
try {
|
||
return await db
|
||
.select()
|
||
.from(aiProvider)
|
||
.where(eq(aiProvider.isEnabled, true))
|
||
.orderBy(asc(aiProvider.createdAt));
|
||
} catch (_error) {
|
||
console.error("getEnabledAiProviders error:", _error);
|
||
throw new ChatSDKError(
|
||
"bad_request:database",
|
||
typeof _error === "object" ? JSON.stringify(_error) : String(_error)
|
||
);
|
||
}
|
||
}
|
||
|
||
export async function getAiProvidersByType({
|
||
type,
|
||
enabledOnly = false,
|
||
}: {
|
||
type: AiProvider["type"];
|
||
enabledOnly?: boolean;
|
||
}): Promise<AiProvider[]> {
|
||
try {
|
||
return await db
|
||
.select()
|
||
.from(aiProvider)
|
||
.where(
|
||
enabledOnly
|
||
? and(eq(aiProvider.type, type), eq(aiProvider.isEnabled, true))
|
||
: eq(aiProvider.type, type)
|
||
)
|
||
.orderBy(asc(aiProvider.createdAt));
|
||
} catch (_error) {
|
||
console.error("getAiProvidersByType error:", _error);
|
||
throw new ChatSDKError(
|
||
"bad_request:database",
|
||
typeof _error === "object" ? JSON.stringify(_error) : String(_error)
|
||
);
|
||
}
|
||
}
|
||
|
||
export async function getAiProviderById({
|
||
id,
|
||
}: {
|
||
id: string;
|
||
}): Promise<AiProvider | undefined> {
|
||
try {
|
||
const providers = await db
|
||
.select()
|
||
.from(aiProvider)
|
||
.where(eq(aiProvider.id, id))
|
||
.limit(1);
|
||
return providers[0];
|
||
} catch (_error) {
|
||
throw new ChatSDKError(
|
||
"bad_request:database",
|
||
"Failed to get AI provider by id"
|
||
);
|
||
}
|
||
}
|
||
|
||
export async function createAiProvider({
|
||
name,
|
||
type,
|
||
baseUrl,
|
||
apiKey,
|
||
isEnabled,
|
||
updatedBy,
|
||
}: {
|
||
name: string;
|
||
type: "alibaba" | "openai" | "custom" | "local" | "gemini" | "grsai-gpt-image" | "grsai-video" | "huanke-gemini" | "qianwen-video" | "doubao-video" | "ruxa-video" | "volcano-vision" | "volcano-video" | "alibaba-image" | "bfl-image" | "ruxa-image" | "fal-image" | "openai-image" | "midjourney" | "fal-video" | "zhipu" | "tripo-3d" | "worldlabs-3d" | "kittycad-cad" | "runway" | "krea";
|
||
baseUrl: string;
|
||
apiKey: string;
|
||
isEnabled?: boolean;
|
||
updatedBy?: string;
|
||
}): Promise<AiProvider[]> {
|
||
try {
|
||
return await db
|
||
.insert(aiProvider)
|
||
.values({
|
||
name,
|
||
type,
|
||
baseUrl,
|
||
apiKey,
|
||
isEnabled: isEnabled ?? true,
|
||
updatedBy,
|
||
})
|
||
.returning();
|
||
} catch (_error) {
|
||
console.error("createAiProvider error:", _error);
|
||
throw new ChatSDKError(
|
||
"bad_request:database",
|
||
typeof _error === "object" ? JSON.stringify(_error) : String(_error)
|
||
);
|
||
}
|
||
}
|
||
|
||
export async function updateAiProvider({
|
||
id,
|
||
name,
|
||
type,
|
||
baseUrl,
|
||
apiKey,
|
||
isEnabled,
|
||
updatedBy,
|
||
}: {
|
||
id: string;
|
||
name?: string;
|
||
type?: "alibaba" | "openai" | "custom" | "local" | "gemini" | "grsai-gpt-image" | "grsai-video" | "huanke-gemini" | "qianwen-video" | "doubao-video" | "ruxa-video" | "volcano-vision" | "volcano-video" | "alibaba-image" | "bfl-image" | "ruxa-image" | "fal-image" | "openai-image" | "midjourney" | "fal-video" | "zhipu" | "tripo-3d" | "worldlabs-3d" | "kittycad-cad" | "runway" | "krea";
|
||
baseUrl?: string;
|
||
apiKey?: string;
|
||
isEnabled?: boolean;
|
||
updatedBy?: string;
|
||
}): Promise<AiProvider[]> {
|
||
try {
|
||
const updateData: any = { updatedAt: new Date() };
|
||
if (name !== undefined) updateData.name = name;
|
||
if (type !== undefined) updateData.type = type;
|
||
if (baseUrl !== undefined) updateData.baseUrl = baseUrl;
|
||
if (apiKey !== undefined) updateData.apiKey = apiKey;
|
||
if (isEnabled !== undefined) updateData.isEnabled = isEnabled;
|
||
if (updatedBy !== undefined) updateData.updatedBy = updatedBy;
|
||
|
||
return await db
|
||
.update(aiProvider)
|
||
.set(updateData)
|
||
.where(eq(aiProvider.id, id))
|
||
.returning();
|
||
} catch (_error) {
|
||
throw new ChatSDKError(
|
||
"bad_request:database",
|
||
"Failed to update AI provider"
|
||
);
|
||
}
|
||
}
|
||
|
||
export async function deleteAiProvider({ id }: { id: string }) {
|
||
try {
|
||
return await db.delete(aiProvider).where(eq(aiProvider.id, id)).returning();
|
||
} catch (_error) {
|
||
throw new ChatSDKError(
|
||
"bad_request:database",
|
||
"Failed to delete AI provider"
|
||
);
|
||
}
|
||
}
|
||
|
||
// ==================== AI Model CRUD ====================
|
||
|
||
export async function getAllAiModels(): Promise<AiModel[]> {
|
||
try {
|
||
// 暂时移除排序以避免 sortOrder 类型问题
|
||
return await db.select().from(aiModel);
|
||
} catch (_error) {
|
||
console.error("getAllAiModels error:", _error);
|
||
throw new ChatSDKError(
|
||
"bad_request:database",
|
||
typeof _error === "object" ? JSON.stringify(_error) : String(_error)
|
||
);
|
||
}
|
||
}
|
||
|
||
export async function getEnabledAiModels(): Promise<AiModel[]> {
|
||
try {
|
||
return await db
|
||
.select()
|
||
.from(aiModel)
|
||
.where(eq(aiModel.isEnabled, true));
|
||
} catch (_error) {
|
||
throw new ChatSDKError(
|
||
"bad_request:database",
|
||
"Failed to get enabled AI models"
|
||
);
|
||
}
|
||
}
|
||
|
||
export async function getAiModelsByProviderId({
|
||
providerId,
|
||
}: {
|
||
providerId: string;
|
||
}): Promise<AiModel[]> {
|
||
try {
|
||
return await db
|
||
.select()
|
||
.from(aiModel)
|
||
.where(eq(aiModel.providerId, providerId));
|
||
} catch (_error) {
|
||
throw new ChatSDKError(
|
||
"bad_request:database",
|
||
"Failed to get AI models by provider"
|
||
);
|
||
}
|
||
}
|
||
|
||
export async function getAiModelById({
|
||
id,
|
||
}: {
|
||
id: string;
|
||
}): Promise<AiModel | undefined> {
|
||
try {
|
||
const models = await db
|
||
.select()
|
||
.from(aiModel)
|
||
.where(eq(aiModel.id, id))
|
||
.limit(1);
|
||
return models[0];
|
||
} catch (_error) {
|
||
throw new ChatSDKError(
|
||
"bad_request:database",
|
||
"Failed to get AI model by id"
|
||
);
|
||
}
|
||
}
|
||
|
||
export async function getDefaultAiModel(): Promise<AiModel | undefined> {
|
||
try {
|
||
const models = await db
|
||
.select()
|
||
.from(aiModel)
|
||
.where(and(eq(aiModel.isDefault, true), eq(aiModel.isEnabled, true)))
|
||
.limit(1);
|
||
return models[0];
|
||
} catch (_error) {
|
||
throw new ChatSDKError(
|
||
"bad_request:database",
|
||
"Failed to get default AI model"
|
||
);
|
||
}
|
||
}
|
||
|
||
// 图片生成模型特性配置类型
|
||
interface ImageGenConfig {
|
||
maxImages?: number;
|
||
supportsSize?: boolean;
|
||
supports1K?: boolean;
|
||
supports2K?: boolean;
|
||
supports4K?: boolean;
|
||
supportsPromptExtend?: boolean;
|
||
supportsNegativePrompt?: boolean;
|
||
supportsWatermark?: boolean;
|
||
supportsSeed?: boolean;
|
||
creditCostPerImage?: number;
|
||
creditCostPerVideo?: number;
|
||
videoDurationOptions?: number[];
|
||
creditCostPerVideoByDuration?: Record<string, number>;
|
||
creditCostPerVideoByResolution?: Record<string, number>;
|
||
enableLoadingPlaceholder?: boolean;
|
||
loadingColor?: string;
|
||
}
|
||
|
||
export async function createAiModel({
|
||
providerId,
|
||
modelId,
|
||
displayName,
|
||
avatarUrl,
|
||
description,
|
||
supportsVision,
|
||
supportsImageGen,
|
||
isEnabled,
|
||
isHidden,
|
||
isDefault,
|
||
isDefaultTextEditModel,
|
||
sortOrder,
|
||
fallbackModelId1,
|
||
fallbackModelId2,
|
||
excludeFromFailover,
|
||
imageGenConfig,
|
||
capabilities,
|
||
isAgentModel,
|
||
isMultimodal,
|
||
agentCodingModelId,
|
||
}: {
|
||
providerId: string;
|
||
modelId: string;
|
||
displayName: string;
|
||
avatarUrl?: string;
|
||
description?: string;
|
||
supportsVision?: boolean;
|
||
supportsImageGen?: boolean;
|
||
isEnabled?: boolean;
|
||
isHidden?: boolean;
|
||
isDefault?: boolean;
|
||
isDefaultTextEditModel?: boolean;
|
||
sortOrder?: number;
|
||
fallbackModelId1?: string;
|
||
fallbackModelId2?: string;
|
||
excludeFromFailover?: boolean;
|
||
imageGenConfig?: ImageGenConfig;
|
||
capabilities?: Record<string, unknown> | null;
|
||
isAgentModel?: boolean;
|
||
isMultimodal?: boolean;
|
||
agentCodingModelId?: string;
|
||
}): Promise<AiModel[]> {
|
||
try {
|
||
// 如果设置为默认,先取消其他默认
|
||
if (isDefault) {
|
||
await db
|
||
.update(aiModel)
|
||
.set({ isDefault: false })
|
||
.where(eq(aiModel.isDefault, true));
|
||
}
|
||
|
||
if (isDefaultTextEditModel) {
|
||
await db
|
||
.update(aiModel)
|
||
.set({ isDefaultTextEditModel: false })
|
||
.where(eq(aiModel.isDefaultTextEditModel, true));
|
||
}
|
||
|
||
const defaultParams =
|
||
agentCodingModelId !== undefined
|
||
? withAgentCodingModelId(
|
||
isAgentModel === undefined ? null : withAgentAutoModelFlag(null, isAgentModel),
|
||
agentCodingModelId
|
||
)
|
||
: isAgentModel === undefined
|
||
? null
|
||
: withAgentAutoModelFlag(null, isAgentModel);
|
||
|
||
return await db
|
||
.insert(aiModel)
|
||
.values({
|
||
providerId,
|
||
modelId,
|
||
displayName,
|
||
avatarUrl: avatarUrl || null,
|
||
description,
|
||
supportsVision: supportsVision ?? false,
|
||
supportsImageGen: supportsImageGen ?? false,
|
||
isEnabled: isEnabled ?? true,
|
||
isHidden: isHidden ?? false,
|
||
isDefault: isDefault ?? false,
|
||
isDefaultTextEditModel: isDefaultTextEditModel ?? false,
|
||
sortOrder: sortOrder ?? 0,
|
||
fallbackModelId1: fallbackModelId1 || null,
|
||
fallbackModelId2: fallbackModelId2 || null,
|
||
excludeFromFailover: excludeFromFailover ?? false,
|
||
imageGenConfig: imageGenConfig || null,
|
||
defaultParams,
|
||
capabilities:
|
||
isMultimodal === undefined
|
||
? capabilities || null
|
||
: withMultimodalModelFlag(capabilities || null, isMultimodal),
|
||
})
|
||
.returning();
|
||
} catch (_error) {
|
||
throw new ChatSDKError(
|
||
"bad_request:database",
|
||
"Failed to create AI model"
|
||
);
|
||
}
|
||
}
|
||
|
||
export async function updateAiModel({
|
||
id,
|
||
providerId,
|
||
modelId,
|
||
displayName,
|
||
avatarUrl,
|
||
description,
|
||
supportsVision,
|
||
supportsImageGen,
|
||
isEnabled,
|
||
isHidden,
|
||
isDefault,
|
||
isDefaultTextEditModel,
|
||
sortOrder,
|
||
fallbackModelId1,
|
||
fallbackModelId2,
|
||
excludeFromFailover,
|
||
imageGenConfig,
|
||
capabilities,
|
||
isAgentModel,
|
||
isMultimodal,
|
||
agentCodingModelId,
|
||
}: {
|
||
id: string;
|
||
providerId?: string;
|
||
modelId?: string;
|
||
displayName?: string;
|
||
avatarUrl?: string;
|
||
description?: string;
|
||
supportsVision?: boolean;
|
||
supportsImageGen?: boolean;
|
||
isEnabled?: boolean;
|
||
isHidden?: boolean;
|
||
isDefault?: boolean;
|
||
isDefaultTextEditModel?: boolean;
|
||
sortOrder?: number;
|
||
fallbackModelId1?: string;
|
||
fallbackModelId2?: string;
|
||
excludeFromFailover?: boolean;
|
||
imageGenConfig?: ImageGenConfig;
|
||
capabilities?: Record<string, unknown> | null;
|
||
isAgentModel?: boolean;
|
||
isMultimodal?: boolean;
|
||
agentCodingModelId?: string;
|
||
}): Promise<AiModel[]> {
|
||
try {
|
||
// 如果设置为默认,先取消其他默认
|
||
if (isDefault) {
|
||
await db
|
||
.update(aiModel)
|
||
.set({ isDefault: false })
|
||
.where(eq(aiModel.isDefault, true));
|
||
}
|
||
|
||
if (isDefaultTextEditModel) {
|
||
await db
|
||
.update(aiModel)
|
||
.set({ isDefaultTextEditModel: false })
|
||
.where(eq(aiModel.isDefaultTextEditModel, true));
|
||
}
|
||
|
||
const updateData: any = { updatedAt: new Date() };
|
||
if (providerId !== undefined) updateData.providerId = providerId;
|
||
if (modelId !== undefined) updateData.modelId = modelId;
|
||
if (displayName !== undefined) updateData.displayName = displayName;
|
||
if (avatarUrl !== undefined) updateData.avatarUrl = avatarUrl || null;
|
||
if (description !== undefined) updateData.description = description;
|
||
if (supportsVision !== undefined) updateData.supportsVision = supportsVision;
|
||
if (supportsImageGen !== undefined)
|
||
updateData.supportsImageGen = supportsImageGen;
|
||
if (isEnabled !== undefined) updateData.isEnabled = isEnabled;
|
||
if (isHidden !== undefined) updateData.isHidden = isHidden;
|
||
if (isDefault !== undefined) updateData.isDefault = isDefault;
|
||
if (isDefaultTextEditModel !== undefined) {
|
||
updateData.isDefaultTextEditModel = isDefaultTextEditModel;
|
||
}
|
||
if (sortOrder !== undefined) updateData.sortOrder = sortOrder;
|
||
// 备用模型 ID(空字符串转为 null)
|
||
if (fallbackModelId1 !== undefined) updateData.fallbackModelId1 = fallbackModelId1 || null;
|
||
if (fallbackModelId2 !== undefined) updateData.fallbackModelId2 = fallbackModelId2 || null;
|
||
if (excludeFromFailover !== undefined) updateData.excludeFromFailover = excludeFromFailover;
|
||
// 🆕 图片生成配置
|
||
if (imageGenConfig !== undefined) updateData.imageGenConfig = imageGenConfig || null;
|
||
if (
|
||
isAgentModel !== undefined ||
|
||
isMultimodal !== undefined ||
|
||
agentCodingModelId !== undefined ||
|
||
capabilities !== undefined
|
||
) {
|
||
const [existingModel] = await db
|
||
.select({
|
||
defaultParams: aiModel.defaultParams,
|
||
capabilities: aiModel.capabilities,
|
||
})
|
||
.from(aiModel)
|
||
.where(eq(aiModel.id, id))
|
||
.limit(1);
|
||
|
||
if (!existingModel) {
|
||
return [];
|
||
}
|
||
|
||
let nextDefaultParams = existingModel.defaultParams;
|
||
if (isAgentModel !== undefined) {
|
||
nextDefaultParams = withAgentAutoModelFlag(
|
||
nextDefaultParams,
|
||
isAgentModel
|
||
);
|
||
}
|
||
if (agentCodingModelId !== undefined) {
|
||
nextDefaultParams = withAgentCodingModelId(
|
||
nextDefaultParams,
|
||
agentCodingModelId
|
||
);
|
||
}
|
||
if (isAgentModel !== undefined || agentCodingModelId !== undefined) {
|
||
updateData.defaultParams = nextDefaultParams;
|
||
}
|
||
|
||
if (capabilities !== undefined || isMultimodal !== undefined) {
|
||
const nextCapabilities =
|
||
capabilities !== undefined ? capabilities || null : existingModel.capabilities;
|
||
updateData.capabilities =
|
||
isMultimodal !== undefined
|
||
? withMultimodalModelFlag(nextCapabilities, isMultimodal)
|
||
: nextCapabilities;
|
||
}
|
||
}
|
||
|
||
return await db
|
||
.update(aiModel)
|
||
.set(updateData)
|
||
.where(eq(aiModel.id, id))
|
||
.returning();
|
||
} catch (_error) {
|
||
throw new ChatSDKError(
|
||
"bad_request:database",
|
||
"Failed to update AI model"
|
||
);
|
||
}
|
||
}
|
||
|
||
export async function deleteAiModel({ id }: { id: string }) {
|
||
try {
|
||
return await db.delete(aiModel).where(eq(aiModel.id, id)).returning();
|
||
} catch (_error) {
|
||
throw new ChatSDKError(
|
||
"bad_request:database",
|
||
"Failed to delete AI model"
|
||
);
|
||
}
|
||
}
|
||
|
||
// ==================== Storage Config CRUD ====================
|
||
|
||
export async function getAllStorageConfigs(): Promise<StorageConfig[]> {
|
||
try {
|
||
return await db
|
||
.select()
|
||
.from(storageConfig)
|
||
.orderBy(asc(storageConfig.createdAt));
|
||
} catch (_error) {
|
||
console.error("getAllStorageConfigs error:", _error);
|
||
throw new ChatSDKError(
|
||
"bad_request:database",
|
||
typeof _error === "object" ? JSON.stringify(_error) : String(_error)
|
||
);
|
||
}
|
||
}
|
||
|
||
export async function getDefaultStorageConfig(): Promise<
|
||
StorageConfig | undefined
|
||
> {
|
||
try {
|
||
const configs = await db
|
||
.select()
|
||
.from(storageConfig)
|
||
.where(
|
||
and(eq(storageConfig.isDefault, true), eq(storageConfig.isEnabled, true))
|
||
)
|
||
.limit(1);
|
||
return configs[0];
|
||
} catch (_error) {
|
||
throw new ChatSDKError(
|
||
"bad_request:database",
|
||
"Failed to get default storage config"
|
||
);
|
||
}
|
||
}
|
||
|
||
export async function getStorageConfigById({
|
||
id,
|
||
}: {
|
||
id: string;
|
||
}): Promise<StorageConfig | undefined> {
|
||
try {
|
||
const configs = await db
|
||
.select()
|
||
.from(storageConfig)
|
||
.where(eq(storageConfig.id, id))
|
||
.limit(1);
|
||
return configs[0];
|
||
} catch (_error) {
|
||
throw new ChatSDKError(
|
||
"bad_request:database",
|
||
"Failed to get storage config by id"
|
||
);
|
||
}
|
||
}
|
||
|
||
export async function createStorageConfig({
|
||
name,
|
||
type,
|
||
endpoint,
|
||
region,
|
||
bucket,
|
||
accessKeyId,
|
||
secretAccessKey,
|
||
publicUrl,
|
||
isDefault,
|
||
isEnabled,
|
||
updatedBy,
|
||
}: {
|
||
name: string;
|
||
type: "s3" | "local";
|
||
endpoint?: string;
|
||
region?: string;
|
||
bucket?: string;
|
||
accessKeyId?: string;
|
||
secretAccessKey?: string;
|
||
publicUrl?: string;
|
||
isDefault?: boolean;
|
||
isEnabled?: boolean;
|
||
updatedBy?: string;
|
||
}): Promise<StorageConfig[]> {
|
||
try {
|
||
// 如果设置为默认,先取消其他默认
|
||
if (isDefault) {
|
||
await db
|
||
.update(storageConfig)
|
||
.set({ isDefault: false })
|
||
.where(eq(storageConfig.isDefault, true));
|
||
}
|
||
|
||
return await db
|
||
.insert(storageConfig)
|
||
.values({
|
||
name,
|
||
type,
|
||
endpoint,
|
||
region,
|
||
bucket,
|
||
accessKeyId,
|
||
secretAccessKey,
|
||
publicUrl,
|
||
isDefault: isDefault ?? false,
|
||
isEnabled: isEnabled ?? true,
|
||
updatedBy,
|
||
})
|
||
.returning();
|
||
} catch (_error) {
|
||
throw new ChatSDKError(
|
||
"bad_request:database",
|
||
"Failed to create storage config"
|
||
);
|
||
}
|
||
}
|
||
|
||
export async function updateStorageConfig({
|
||
id,
|
||
name,
|
||
type,
|
||
endpoint,
|
||
region,
|
||
bucket,
|
||
accessKeyId,
|
||
secretAccessKey,
|
||
publicUrl,
|
||
isDefault,
|
||
isEnabled,
|
||
updatedBy,
|
||
}: {
|
||
id: string;
|
||
name?: string;
|
||
type?: "s3" | "local";
|
||
endpoint?: string;
|
||
region?: string;
|
||
bucket?: string;
|
||
accessKeyId?: string;
|
||
secretAccessKey?: string;
|
||
publicUrl?: string;
|
||
isDefault?: boolean;
|
||
isEnabled?: boolean;
|
||
updatedBy?: string;
|
||
}): Promise<StorageConfig[]> {
|
||
try {
|
||
// 如果设置为默认,先取消其他默认
|
||
if (isDefault) {
|
||
await db
|
||
.update(storageConfig)
|
||
.set({ isDefault: false })
|
||
.where(eq(storageConfig.isDefault, true));
|
||
}
|
||
|
||
const updateData: any = { updatedAt: new Date() };
|
||
if (name !== undefined) updateData.name = name;
|
||
if (type !== undefined) updateData.type = type;
|
||
if (endpoint !== undefined) updateData.endpoint = endpoint;
|
||
if (region !== undefined) updateData.region = region;
|
||
if (bucket !== undefined) updateData.bucket = bucket;
|
||
if (accessKeyId !== undefined) updateData.accessKeyId = accessKeyId;
|
||
if (secretAccessKey !== undefined)
|
||
updateData.secretAccessKey = secretAccessKey;
|
||
if (publicUrl !== undefined) updateData.publicUrl = publicUrl;
|
||
if (isDefault !== undefined) updateData.isDefault = isDefault;
|
||
if (isEnabled !== undefined) updateData.isEnabled = isEnabled;
|
||
if (updatedBy !== undefined) updateData.updatedBy = updatedBy;
|
||
|
||
return await db
|
||
.update(storageConfig)
|
||
.set(updateData)
|
||
.where(eq(storageConfig.id, id))
|
||
.returning();
|
||
} catch (_error) {
|
||
throw new ChatSDKError(
|
||
"bad_request:database",
|
||
"Failed to update storage config"
|
||
);
|
||
}
|
||
}
|
||
|
||
export async function deleteStorageConfig({ id }: { id: string }) {
|
||
try {
|
||
return await db
|
||
.delete(storageConfig)
|
||
.where(eq(storageConfig.id, id))
|
||
.returning();
|
||
} catch (_error) {
|
||
throw new ChatSDKError(
|
||
"bad_request:database",
|
||
"Failed to delete storage config"
|
||
);
|
||
}
|
||
}
|
||
|
||
// 获取启用的 AI 模型及其提供商信息(用于前端展示)
|
||
export async function getEnabledAiModelsWithProvider(
|
||
includeSubModels: boolean = false,
|
||
includeHidden: boolean = true
|
||
) {
|
||
try {
|
||
const conditions = [
|
||
eq(aiModel.isEnabled, true),
|
||
eq(aiProvider.isEnabled, true),
|
||
];
|
||
|
||
// 如果不包含子模型,添加过滤条件
|
||
if (!includeSubModels) {
|
||
conditions.push(eq(aiModel.isSubModel, false));
|
||
}
|
||
if (!includeHidden) {
|
||
conditions.push(eq(aiModel.isHidden, false));
|
||
}
|
||
|
||
const models = await db
|
||
.select({
|
||
model: aiModel,
|
||
provider: aiProvider,
|
||
})
|
||
.from(aiModel)
|
||
.innerJoin(aiProvider, eq(aiModel.providerId, aiProvider.id))
|
||
.where(and(...conditions));
|
||
|
||
return models;
|
||
} catch (_error) {
|
||
throw new ChatSDKError(
|
||
"bad_request:database",
|
||
"Failed to get enabled AI models with provider"
|
||
);
|
||
}
|
||
}
|
||
|
||
export async function getAgentAiModelWithProvider() {
|
||
const models = await getEnabledAiModelsWithProvider(false);
|
||
const candidates = models
|
||
.filter(({ model }) => isAiModelAgentAuto(model))
|
||
.sort((a, b) => {
|
||
if (a.model.isDefault !== b.model.isDefault) {
|
||
return Number(b.model.isDefault) - Number(a.model.isDefault);
|
||
}
|
||
return (a.model.sortOrder ?? 0) - (b.model.sortOrder ?? 0);
|
||
});
|
||
|
||
return candidates[0];
|
||
}
|
||
|
||
/**
|
||
* 🆕 获取指定父模型的所有子模型
|
||
* 用于火山引擎视频等需要智能选择模型的场景
|
||
*/
|
||
export async function getSubModelsByParentModelId(parentModelId: string) {
|
||
try {
|
||
const subModels = await db
|
||
.select({
|
||
model: aiModel,
|
||
provider: aiProvider,
|
||
})
|
||
.from(aiModel)
|
||
.innerJoin(aiProvider, eq(aiModel.providerId, aiProvider.id))
|
||
.where(
|
||
and(
|
||
eq(aiModel.isEnabled, true),
|
||
eq(aiProvider.isEnabled, true),
|
||
eq(aiModel.isSubModel, true),
|
||
eq(aiModel.parentModelId, parentModelId)
|
||
)
|
||
);
|
||
|
||
return subModels;
|
||
} catch (_error) {
|
||
throw new ChatSDKError(
|
||
"bad_request:database",
|
||
"Failed to get sub models"
|
||
);
|
||
}
|
||
}
|
||
|
||
export async function getSiteSettings() {
|
||
const defaultSettings = {
|
||
siteName: "AI Chatbot",
|
||
siteLogo: null,
|
||
siteLogoDark: null,
|
||
favicon: null,
|
||
defaultTheme: "system",
|
||
bannerEnabled: true,
|
||
bannerText: "升级会员, Nano Banana Pro 免费365天!",
|
||
bannerLink: null,
|
||
toolbox1Name: "工具箱",
|
||
toolbox1Enabled: true,
|
||
toolbox2Name: "工具箱2",
|
||
toolbox2Enabled: false,
|
||
tutorialVideoUrl: null,
|
||
tutorialEnabled: false,
|
||
rankingEnabled: true,
|
||
productPlanningEnabled: true,
|
||
agentEnabled: true,
|
||
videoCanvasEnabled: true,
|
||
canvasDefaultModelId: null,
|
||
canvasAutoModeDefaultModelId: null,
|
||
agentWorkflowDefaultModelId: null,
|
||
canvasDetailDefaultModelId: null,
|
||
canvasDetailAgentModelId: null,
|
||
lovartPromoText: "现在升级,立享 Kling 3.0 与 Nano Banana Pro,最高 5 折优惠",
|
||
lovartPromoActionText: "立即升级",
|
||
lovartHeroTitle: "由壹点AI 让设计更简单",
|
||
lovartHeroSubtitle: "懂你的设计代理,帮你搞定一切",
|
||
};
|
||
try {
|
||
const [settings, videoCanvasEnabledSetting, videoCanvasSidebarEnabledSetting] =
|
||
await Promise.all([
|
||
db.select().from(siteSettings).limit(1).then((rows) => rows[0]),
|
||
getSystemSetting({ key: "video_canvas_enabled" }).catch(() => null),
|
||
getSystemSetting({ key: "video_canvas_sidebar_enabled" }).catch(() => null),
|
||
]);
|
||
const videoCanvasEnabled =
|
||
videoCanvasEnabledSetting?.value !== undefined &&
|
||
videoCanvasEnabledSetting?.value !== null
|
||
? videoCanvasEnabledSetting.value !== false
|
||
: videoCanvasSidebarEnabledSetting?.value !== false;
|
||
|
||
return { ...(settings || defaultSettings), videoCanvasEnabled };
|
||
} catch (_error) {
|
||
return defaultSettings;
|
||
}
|
||
}
|
||
|
||
export async function getNavLinks() {
|
||
try {
|
||
return await db
|
||
.select()
|
||
.from(navLinks)
|
||
.where(eq(navLinks.isEnabled, true))
|
||
.orderBy(navLinks.order);
|
||
} catch (_error) {
|
||
return [];
|
||
}
|
||
}
|
||
|
||
export async function getAuthSettings() {
|
||
try {
|
||
const [settings] = await db.select().from(authSettings).limit(1);
|
||
|
||
if (settings) {
|
||
return {
|
||
...settings,
|
||
dingTalkOrganizations: settings.dingTalkOrganizations ?? [],
|
||
};
|
||
}
|
||
|
||
return {
|
||
dingTalkAppKey: "",
|
||
dingTalkAppSecret: "",
|
||
googleClientId: "",
|
||
googleClientSecret: "",
|
||
authUrl: process.env.AUTH_URL || "",
|
||
dingTalkOrganizations: [],
|
||
isDingTalkEnabled: false,
|
||
isGoogleEnabled: false,
|
||
isRegistrationEnabled: true,
|
||
isEmailVerificationEnabled: false,
|
||
emailVerificationExpiresMinutes: 10,
|
||
resendApiKey: "",
|
||
resendFromEmail: "",
|
||
};
|
||
} catch (_error) {
|
||
return {
|
||
dingTalkAppKey: "",
|
||
dingTalkAppSecret: "",
|
||
googleClientId: "",
|
||
googleClientSecret: "",
|
||
authUrl: process.env.AUTH_URL || "",
|
||
dingTalkOrganizations: [],
|
||
isDingTalkEnabled: false,
|
||
isGoogleEnabled: false,
|
||
isRegistrationEnabled: true,
|
||
isEmailVerificationEnabled: false,
|
||
emailVerificationExpiresMinutes: 10,
|
||
resendApiKey: "",
|
||
resendFromEmail: "",
|
||
};
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 获取用户当月已生成图片数量
|
||
*/
|
||
export async function getUserMonthlyImageCount(userId: string): Promise<number> {
|
||
try {
|
||
const now = new Date();
|
||
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
|
||
|
||
const result = await db
|
||
.select({ count: sql<number>`count(*)::int` })
|
||
.from(generatedImage)
|
||
.where(and(
|
||
eq(generatedImage.userId, userId),
|
||
gte(generatedImage.createdAt, startOfMonth)
|
||
));
|
||
|
||
return result[0]?.count ?? 0;
|
||
} catch (_error) {
|
||
return 0;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 检查用户图片生成配额
|
||
* @returns 配额状态对象
|
||
*/
|
||
export async function checkUserImageQuota(userId: string): Promise<{
|
||
allowed: boolean;
|
||
used: number;
|
||
limit: number | null;
|
||
remaining: number | null;
|
||
}> {
|
||
try {
|
||
// 获取用户信息
|
||
const [userData] = await db
|
||
.select({ monthlyImageLimit: user.monthlyImageLimit })
|
||
.from(user)
|
||
.where(eq(user.id, userId));
|
||
|
||
const limit = userData?.monthlyImageLimit;
|
||
const used = await getUserMonthlyImageCount(userId);
|
||
|
||
// null 或 0 表示无限制
|
||
if (!limit || limit === 0) {
|
||
return {
|
||
allowed: true,
|
||
used,
|
||
limit: null,
|
||
remaining: null
|
||
};
|
||
}
|
||
|
||
return {
|
||
allowed: used < limit,
|
||
used,
|
||
limit,
|
||
remaining: Math.max(0, limit - used),
|
||
};
|
||
} catch (_error) {
|
||
// 出错时默认允许
|
||
return { allowed: true, used: 0, limit: null, remaining: null };
|
||
}
|
||
}
|
||
|
||
// 获取视频教程列表
|
||
export async function getTutorialVideos() {
|
||
try {
|
||
const { tutorialVideos } = await import("./schema");
|
||
const { eq, asc } = await import("drizzle-orm");
|
||
|
||
const videos = await db
|
||
.select()
|
||
.from(tutorialVideos)
|
||
.where(eq(tutorialVideos.isEnabled, true))
|
||
.orderBy(asc(tutorialVideos.order));
|
||
|
||
return videos;
|
||
} catch (_error) {
|
||
return [];
|
||
}
|
||
}
|