ai-chatbot/lib/db/queries.ts
Your Name d72ca0b344
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
fix: polish style pro model visibility and billing
2026-05-20 14:26:24 +08:00

4878 lines
135 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import "server-only";
import {
and,
asc,
count,
desc,
eq,
gt,
gte,
inArray,
isNotNull,
like,
lt,
notInArray,
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 { reviveAiModelsFromCooldown } from "@/lib/ai/runtime/model-health";
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,
})
.onConflictDoNothing({
target: chat.id,
});
} 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).onConflictDoNothing({
target: message.id,
});
} 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"),
),
);
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)
.onConflictDoUpdate({
target: canvasNode.id,
set: {
projectId: sql`excluded."projectId"`,
type: sql`excluded."type"`,
position: sql`excluded."position"`,
size: sql`excluded."size"`,
data: sql`excluded."data"`,
connections: sql`excluded."connections"`,
parentId: sql`excluded."parentId"`,
updatedAt: sql`excluded."updatedAt"`,
},
});
const activeNodeIds = insertableNodes.map((node) => node.id);
await db.delete(canvasNode).where(
and(
eq(canvasNode.projectId, params.projectId),
notInArray(canvasNode.id, activeNodeIds),
),
);
} else {
await db.delete(canvasNode).where(eq(canvasNode.projectId, params.projectId));
}
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 };
}
const normalizedMessageId =
typeof messageId === "string" && messageId.trim() ? messageId.trim() : null;
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,
};
}
if (normalizedMessageId) {
const existingLedgerRows = await tx
.select({ id: pointsLedger.id })
.from(pointsLedger)
.where(
and(
eq(pointsLedger.userId, userId),
eq(pointsLedger.messageId, normalizedMessageId),
eq(pointsLedger.reason, reason),
eq(pointsLedger.amount, -amount)
)
)
.limit(1);
if (existingLedgerRows.length > 0) {
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: normalizedMessageId,
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 refundUserPoints({
userId,
amount,
reason = "points_refund",
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 updated = await tx
.update(user)
.set({
pointsBalance: sql`${user.pointsBalance} + ${amount}`,
updatedAt: new Date(),
})
.where(eq(user.id, userId))
.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,
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 refund 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" | "stepfun-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" | "stepfun-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 {
await reviveAiModelsFromCooldown();
// 暂时移除排序以避免 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 {
await reviveAiModelsFromCooldown();
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 {
await reviveAiModelsFromCooldown();
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 {
await reviveAiModelsFromCooldown({ modelId: id });
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 {
await reviveAiModelsFromCooldown();
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;
creditDiscountRate?: 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 (isEnabled) {
updateData.healthStatus = "healthy";
updateData.healthConsecutiveFailures = 0;
updateData.healthLastEventType = "success";
updateData.healthLastError = null;
updateData.healthLastFailureAt = null;
updateData.healthAutoDisabledAt = null;
updateData.healthAutoDisabledReason = null;
updateData.healthRecoverySuccessStreak = 0;
updateData.healthObservationStartedAt = null;
updateData.healthUpdatedAt = new Date();
} else {
updateData.healthStatus = "healthy";
updateData.healthConsecutiveFailures = 0;
updateData.healthLastEventType = null;
updateData.healthLastError = null;
updateData.healthLastFailureAt = null;
updateData.healthAutoDisabledAt = null;
updateData.healthAutoDisabledReason = null;
updateData.healthRecoverySuccessStreak = 0;
updateData.healthObservationStartedAt = null;
updateData.healthUpdatedAt = new Date();
}
}
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 {
await reviveAiModelsFromCooldown();
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));
const healthPriority = (status?: string | null) => {
if (status === "healthy") return 0;
if (status === "degraded") return 1;
if (status === "auto_disabled") return 2;
return 3;
};
return [...models].sort((left, right) => {
const byHealth =
healthPriority(left.model.healthStatus) -
healthPriority(right.model.healthStatus);
if (byHealth !== 0) return byHealth;
if (left.model.isDefault !== right.model.isDefault) {
return Number(right.model.isDefault) - Number(left.model.isDefault);
}
return (left.model.sortOrder ?? 0) - (right.model.sortOrder ?? 0);
});
} catch (_error) {
throw new ChatSDKError(
"bad_request:database",
"Failed to get enabled AI models with provider"
);
}
}
// 获取前端展示用模型列表:包含可用模型,以及被健康检查临时封禁的维护中模型。
export async function getFrontendDisplayAiModelsWithProvider(
includeSubModels: boolean = false,
includeHidden: boolean = true
) {
try {
await reviveAiModelsFromCooldown();
const visibilityCondition = or(
eq(aiModel.isEnabled, true),
and(
eq(aiModel.isEnabled, false),
eq(aiModel.healthStatus, "auto_disabled"),
isNotNull(aiModel.healthAutoDisabledAt)
)
);
const conditions: SQL[] = [
eq(aiProvider.isEnabled, true),
];
if (visibilityCondition) {
conditions.push(visibilityCondition);
}
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));
const healthPriority = (status?: string | null) => {
if (status === "healthy") return 0;
if (status === "degraded") return 1;
if (status === "auto_disabled") return 2;
return 3;
};
return [...models].sort((left, right) => {
const byHealth =
healthPriority(left.model.healthStatus) -
healthPriority(right.model.healthStatus);
if (byHealth !== 0) return byHealth;
if (left.model.isDefault !== right.model.isDefault) {
return Number(right.model.isDefault) - Number(left.model.isDefault);
}
return (left.model.sortOrder ?? 0) - (right.model.sortOrder ?? 0);
});
} catch (_error) {
throw new ChatSDKError(
"bad_request:database",
"Failed to get frontend display 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 {
await reviveAiModelsFromCooldown();
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 [];
}
}