🎉 first commit
This commit is contained in:
427
app/lib/.server/auth.ts
Normal file
427
app/lib/.server/auth.ts
Normal file
@@ -0,0 +1,427 @@
|
||||
import type { IdTokenClaims } from '@logto/node';
|
||||
import { type LogtoContext, makeLogtoRemix } from '@logto/remix';
|
||||
import type { LoaderFunctionArgs } from '@remix-run/node';
|
||||
import { createCookieSessionStorage, redirect } from '@remix-run/node';
|
||||
import { createScopedLogger } from '~/lib/.server/logger.server';
|
||||
import type { LogtoUser } from '~/types/logto';
|
||||
|
||||
const logger = createScopedLogger('auth.server');
|
||||
|
||||
/**
|
||||
* 认证相关类型定义
|
||||
*/
|
||||
interface LogtoConfig {
|
||||
endpoint: string;
|
||||
appId: string;
|
||||
appSecret: string;
|
||||
baseUrl: string;
|
||||
scopes?: string[];
|
||||
}
|
||||
|
||||
interface MockUser extends Pick<LogtoContext, 'isAuthenticated' | 'userInfo' | 'claims'> {}
|
||||
|
||||
/**
|
||||
* 虚拟用户接口,与 MockUser 类似但代表真实存在于 logto 的用户
|
||||
*/
|
||||
interface VirtualUser extends Pick<LogtoContext, 'isAuthenticated' | 'userInfo' | 'claims'> {
|
||||
isVirtual: true;
|
||||
}
|
||||
|
||||
// Logto路由配置类型
|
||||
interface LogtoRoutes {
|
||||
'sign-in': { path: string; redirectBackTo: string };
|
||||
'sign-in-callback': { path: string; redirectBackTo: string };
|
||||
'sign-out': { path: string; redirectBackTo: string };
|
||||
'sign-up': { path: string; redirectBackTo: string };
|
||||
}
|
||||
|
||||
/**
|
||||
* 公共 Cookie 配置基础
|
||||
*/
|
||||
const baseCookieOptions = {
|
||||
httpOnly: true,
|
||||
path: '/',
|
||||
sameSite: 'lax' as const,
|
||||
secrets: [process.env.LOGTO_COOKIE_SECRET || 's3cr3t'],
|
||||
};
|
||||
|
||||
/**
|
||||
* 创建认证 session 存储
|
||||
*/
|
||||
const sessionStorage = createCookieSessionStorage({
|
||||
cookie: {
|
||||
...baseCookieOptions,
|
||||
name: 'logto_session',
|
||||
maxAge: 60 * 60 * 24 * 30, // 30 天过期
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* 创建虚拟用户 session 存储
|
||||
*/
|
||||
const virtualUserStorage = createCookieSessionStorage({
|
||||
cookie: {
|
||||
...baseCookieOptions,
|
||||
name: 'virtual_user',
|
||||
maxAge: 60 * 60 * 24 * 30,
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* 创建认证错误信息 session 存储
|
||||
*/
|
||||
const errorSessionStorage = createCookieSessionStorage({
|
||||
cookie: {
|
||||
...baseCookieOptions,
|
||||
name: 'auth_error',
|
||||
maxAge: 60, // 1分钟后过期,错误信息不需要长期保存
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* 创建 Logto 配置
|
||||
*/
|
||||
const config: LogtoConfig = {
|
||||
endpoint: process.env.LOGTO_ENDPOINT || '',
|
||||
appId: process.env.LOGTO_APP_ID || '',
|
||||
appSecret: process.env.LOGTO_APP_SECRET || '',
|
||||
baseUrl: process.env.LOGTO_BASE_URL || 'http://localhost:5173',
|
||||
scopes: ['email', 'profile'],
|
||||
};
|
||||
|
||||
// 创建原始 Logto 实例(私有,不直接导出)
|
||||
const originalLogto = makeLogtoRemix(config, { sessionStorage });
|
||||
|
||||
export function shouldEnforceAuth(): boolean {
|
||||
return process.env.LOGTO_ENABLE_DEV !== 'false';
|
||||
}
|
||||
|
||||
function getMockDevUser(): MockUser {
|
||||
return {
|
||||
isAuthenticated: true,
|
||||
userInfo: {
|
||||
iss: 'https://mock.issuer.com',
|
||||
sub: 'mock-user-id',
|
||||
aud: 'mock-audience',
|
||||
exp: Math.floor(Date.now() / 1000) + 3600,
|
||||
iat: Math.floor(Date.now() / 1000),
|
||||
name: 'Mock User',
|
||||
username: 'user',
|
||||
email: 'mock@example.com',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置认证错误信息到会话中
|
||||
*/
|
||||
export async function setAuthError(errorMessage: string): Promise<string> {
|
||||
const session = await errorSessionStorage.getSession();
|
||||
session.set('authError', errorMessage);
|
||||
return errorSessionStorage.commitSession(session);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取并清除认证错误信息
|
||||
*/
|
||||
export async function getAuthError(
|
||||
request: Request,
|
||||
): Promise<{ errorMessage?: string; headers: { 'Set-Cookie': string } }> {
|
||||
const session = await errorSessionStorage.getSession(request.headers.get('Cookie'));
|
||||
const errorMessage = session.get('authError') as string | undefined;
|
||||
|
||||
// 清除错误信息
|
||||
return {
|
||||
errorMessage,
|
||||
headers: {
|
||||
'Set-Cookie': await errorSessionStorage.destroySession(session),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 增强版的 Logto 对象,添加错误处理和开发环境跳过
|
||||
*/
|
||||
export const logto = {
|
||||
...originalLogto,
|
||||
|
||||
/**
|
||||
* 增强版的 handleAuthRoutes 方法
|
||||
*/
|
||||
handleAuthRoutes: (routes: LogtoRoutes) => {
|
||||
const originalHandler = originalLogto.handleAuthRoutes(routes);
|
||||
|
||||
return async (args: LoaderFunctionArgs) => {
|
||||
try {
|
||||
// 特殊处理退出登录路由
|
||||
const { request } = args;
|
||||
const url = new URL(request.url);
|
||||
const path = url.pathname;
|
||||
|
||||
// 如果是退出登录路由,检查是否是虚拟用户
|
||||
if (path === routes['sign-out'].path) {
|
||||
const virtualUser = await getVirtualUser(request);
|
||||
|
||||
if (virtualUser?.isVirtual) {
|
||||
logger.info('[Auth] 虚拟用户退出登录');
|
||||
const clearCookie = await clearVirtualUser();
|
||||
return redirect(routes['sign-out'].redirectBackTo, {
|
||||
headers: {
|
||||
'Set-Cookie': clearCookie,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
return await originalHandler(args);
|
||||
} catch (error) {
|
||||
logger.error('[Auth] 认证服务错误:', error);
|
||||
return handleAuthError(error, args);
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
getContext: originalLogto.getContext,
|
||||
};
|
||||
|
||||
/**
|
||||
* 处理认证过程中的错误
|
||||
*/
|
||||
async function handleAuthError(error: unknown, args: LoaderFunctionArgs) {
|
||||
// 判断错误类型
|
||||
let errorMessage = '认证服务暂时不可用,请稍后再试';
|
||||
|
||||
// 处理网络错误(认证服务器不可用)
|
||||
if (
|
||||
error instanceof Error &&
|
||||
(error.message.includes('fetch failed') ||
|
||||
error.message.includes('network') ||
|
||||
error.message.includes('ECONNREFUSED') ||
|
||||
error.message.includes('timeout'))
|
||||
) {
|
||||
errorMessage = '无法连接到认证服务器,请检查网络连接';
|
||||
}
|
||||
// 其他类型的错误
|
||||
else if (error instanceof Error) {
|
||||
errorMessage = '登录服务出现异常,请稍后再试';
|
||||
}
|
||||
|
||||
// 确定重定向URL
|
||||
const redirectUrl = determineRedirectUrl(args.request);
|
||||
|
||||
// 生成带有错误信息的Cookie
|
||||
const cookie = await setAuthError(errorMessage);
|
||||
|
||||
// 重定向回原始页面,同时携带错误会话Cookie
|
||||
return redirect(redirectUrl, {
|
||||
headers: {
|
||||
'Set-Cookie': cookie,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据请求确定最合适的重定向URL
|
||||
*/
|
||||
function determineRedirectUrl(request: Request): string {
|
||||
// 默认重定向到首页
|
||||
const defaultUrl = '/';
|
||||
|
||||
try {
|
||||
const url = new URL(request.url);
|
||||
const redirectTo = url.searchParams.get('redirectTo');
|
||||
|
||||
if (redirectTo && redirectTo.startsWith('/') && !redirectTo.startsWith('//')) {
|
||||
// 确保只重定向到应用内部URL
|
||||
return redirectTo;
|
||||
}
|
||||
|
||||
// 如果有referer头,也可以考虑使用它
|
||||
const referer = request.headers.get('referer');
|
||||
if (referer) {
|
||||
try {
|
||||
const refererUrl = new URL(referer);
|
||||
// 确保是同一域名下的URL
|
||||
if (refererUrl.hostname === url.hostname) {
|
||||
return refererUrl.pathname + refererUrl.search;
|
||||
}
|
||||
} catch {
|
||||
// 忽略无效的referer
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// 如果URL解析失败,使用默认的根路径
|
||||
}
|
||||
|
||||
return defaultUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置虚拟用户信息到 cookie
|
||||
* @param userInfo 用户信息
|
||||
* @returns cookie 字符串,可用于 HTTP 响应头
|
||||
*/
|
||||
export async function setVirtualUser(userInfo: LogtoUser): Promise<string> {
|
||||
const session = await virtualUserStorage.getSession();
|
||||
|
||||
const virtualUserInfo = {
|
||||
id: userInfo.id,
|
||||
iss: process.env.LOGTO_ENDPOINT || 'https://auth.upage.io',
|
||||
sub: userInfo.id,
|
||||
aud: process.env.LOGTO_APP_ID || 'virtual-app',
|
||||
// 30天后过期
|
||||
exp: Math.floor(Date.now() / 1000) + 60 * 60 * 24 * 30,
|
||||
iat: Math.floor(Date.now() / 1000),
|
||||
name: userInfo.name || null,
|
||||
email: userInfo.primaryEmail || null,
|
||||
phone_number: userInfo.primaryPhone || null,
|
||||
username: userInfo.username || null,
|
||||
picture: userInfo.avatar || null,
|
||||
} as IdTokenClaims;
|
||||
|
||||
session.set('userInfo', virtualUserInfo);
|
||||
session.set('isAuthenticated', true);
|
||||
|
||||
return virtualUserStorage.commitSession(session);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取虚拟用户信息
|
||||
* @param request 请求对象
|
||||
* @returns 虚拟用户信息,如果不存在则返回 null
|
||||
*/
|
||||
export async function getVirtualUser(request: Request): Promise<VirtualUser | null> {
|
||||
const cookieHeader = request.headers.get('Cookie');
|
||||
const session = await virtualUserStorage.getSession(cookieHeader);
|
||||
|
||||
const isAuthenticated = session.get('isAuthenticated');
|
||||
const userInfo = session.get('userInfo') as IdTokenClaims;
|
||||
|
||||
if (!isAuthenticated || !userInfo) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 验证用户信息是否有效
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
if (userInfo.exp && userInfo.exp < now) {
|
||||
// 用户信息已过期,清除 session
|
||||
await clearVirtualUser();
|
||||
return null;
|
||||
}
|
||||
|
||||
// 获取上次验证时间
|
||||
const lastVerified = session.get('lastVerified') || 0;
|
||||
const verifyInterval = 60 * 60 * 1000;
|
||||
|
||||
// 如果距离上次验证时间不足验证间隔,则跳过 Logto 验证
|
||||
if (now * 1000 - lastVerified < verifyInterval) {
|
||||
return {
|
||||
isAuthenticated: true,
|
||||
userInfo,
|
||||
isVirtual: true,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
// 更新验证时间
|
||||
session.set('lastVerified', Date.now());
|
||||
await virtualUserStorage.commitSession(session);
|
||||
|
||||
return {
|
||||
isAuthenticated: true,
|
||||
userInfo,
|
||||
isVirtual: true,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('[Auth] 验证虚拟用户失败:', error);
|
||||
// 如果验证过程中出现错误,仍然返回用户信息
|
||||
return {
|
||||
isAuthenticated: true,
|
||||
userInfo,
|
||||
isVirtual: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除虚拟用户信息
|
||||
* @returns cookie 字符串,用于清除虚拟用户 cookie
|
||||
*/
|
||||
export async function clearVirtualUser(): Promise<string> {
|
||||
const session = await virtualUserStorage.getSession();
|
||||
return virtualUserStorage.destroySession(session);
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查用户是否已认证,如果未认证,则重定向到登录页面
|
||||
*/
|
||||
export async function requireUser(request: Request) {
|
||||
const context = await getUser(request);
|
||||
|
||||
if (!context.isAuthenticated) {
|
||||
return redirect('/api/auth/sign-in');
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前用户信息
|
||||
* 按优先级依次检查:
|
||||
* 1. 开发环境模拟用户
|
||||
* 2. 虚拟用户
|
||||
* 3. Logto 认证用户
|
||||
*/
|
||||
export async function getUser(request: Request) {
|
||||
// 首先检查是否为开发环境
|
||||
if (!shouldEnforceAuth()) {
|
||||
return getMockDevUser();
|
||||
}
|
||||
|
||||
// 检查是否存在虚拟用户
|
||||
const virtualUser = await getVirtualUser(request);
|
||||
if (virtualUser) {
|
||||
return virtualUser;
|
||||
}
|
||||
|
||||
// 继续原有的 logto 认证流程
|
||||
return await logto.getContext({
|
||||
fetchUserInfo: true,
|
||||
getAccessToken: true,
|
||||
})(request);
|
||||
}
|
||||
|
||||
/**
|
||||
* 通用权限验证中间件
|
||||
* 用于API和页面路由的权限验证
|
||||
*
|
||||
* 返回json错误或重定向到登录页面
|
||||
*/
|
||||
export async function requireAuth(request: Request, options: { isApi?: boolean; redirectTo?: string } = {}) {
|
||||
const { isApi = false, redirectTo = '/api/auth/sign-in' } = options;
|
||||
|
||||
const context = await getUser(request);
|
||||
|
||||
if (!context.isAuthenticated) {
|
||||
if (isApi) {
|
||||
// API路由返回JSON错误
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: 'Unauthorized',
|
||||
message: '请先登录',
|
||||
code: 401,
|
||||
}),
|
||||
{
|
||||
status: 401,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// 页面路由重定向到登录页面
|
||||
return redirect(redirectTo);
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
329
app/lib/.server/chat.ts
Normal file
329
app/lib/.server/chat.ts
Normal file
@@ -0,0 +1,329 @@
|
||||
import { createScopedLogger } from '~/lib/.server/logger';
|
||||
import { prisma } from '~/lib/.server/prisma';
|
||||
|
||||
const logger = createScopedLogger('chat.server');
|
||||
|
||||
/**
|
||||
* 聊天创建参数接口
|
||||
*/
|
||||
export interface ChatCreateParams {
|
||||
userId: string;
|
||||
id?: string;
|
||||
// 聊天URL ID
|
||||
urlId?: string;
|
||||
// 聊天描述
|
||||
description?: string;
|
||||
// 包含额外信息的元数据
|
||||
metadata?: Record<string, any>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 聊天更新参数接口
|
||||
*/
|
||||
export interface ChatUpdateParams {
|
||||
description?: string;
|
||||
metadata?: Record<string, any>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 聊天创建或更新参数接口
|
||||
*/
|
||||
export interface ChatUpsertParams {
|
||||
id: string;
|
||||
userId: string;
|
||||
urlId?: string;
|
||||
description?: string;
|
||||
metadata?: Record<string, any>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建新的聊天
|
||||
* @param params 聊天创建参数
|
||||
* @returns 创建的聊天记录
|
||||
*/
|
||||
export async function createChat(params: ChatCreateParams) {
|
||||
const { userId, id, urlId, description, metadata } = params;
|
||||
|
||||
try {
|
||||
const chat = await prisma.chat.create({
|
||||
data: {
|
||||
...(id ? { id } : {}),
|
||||
userId,
|
||||
urlId,
|
||||
description,
|
||||
metadata,
|
||||
},
|
||||
});
|
||||
|
||||
logger.info(`[Chat] 创建了用户 ${userId} 的聊天: ${chat.id}`);
|
||||
return chat;
|
||||
} catch (error) {
|
||||
logger.error('[Chat] 创建聊天失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据ID获取聊天
|
||||
* @param id 聊天ID
|
||||
* @returns 聊天记录
|
||||
*/
|
||||
export async function getChatById(id: string) {
|
||||
try {
|
||||
const chat = await prisma.chat.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
messages: {
|
||||
where: {
|
||||
isDiscarded: false,
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'asc',
|
||||
},
|
||||
include: {
|
||||
sections: true,
|
||||
page: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return chat;
|
||||
} catch (error) {
|
||||
logger.error(`[Chat] 获取聊天 ${id} 失败:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据 URL ID 获取聊天
|
||||
* @param urlId 聊天的 URL ID
|
||||
* @returns 聊天记录
|
||||
*/
|
||||
export async function getChatByUrlId(urlId: string) {
|
||||
try {
|
||||
const chat = await prisma.chat.findUnique({
|
||||
where: { urlId },
|
||||
include: {
|
||||
messages: {
|
||||
where: {
|
||||
isDiscarded: false,
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'asc',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return chat;
|
||||
} catch (error) {
|
||||
logger.error(`[Chat] 获取聊天 URL ${urlId} 失败:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户的所有聊天
|
||||
* @param userId 用户ID
|
||||
* @param limit 限制返回记录数量
|
||||
* @param offset 偏移量
|
||||
* @returns 聊天记录列表
|
||||
*/
|
||||
export async function getUserChats(userId: string, limit = 20, offset = 0) {
|
||||
try {
|
||||
const chats = await prisma.chat.findMany({
|
||||
where: { userId },
|
||||
include: {
|
||||
messages: {
|
||||
where: {
|
||||
isDiscarded: false,
|
||||
},
|
||||
take: 1,
|
||||
orderBy: {
|
||||
createdAt: 'asc',
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
updatedAt: 'desc',
|
||||
},
|
||||
skip: offset,
|
||||
take: limit,
|
||||
});
|
||||
|
||||
const total = await prisma.chat.count({
|
||||
where: { userId },
|
||||
});
|
||||
|
||||
return {
|
||||
chats,
|
||||
total,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error(`[Chat] 获取用户 ${userId} 的聊天列表失败:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新聊天信息
|
||||
* @param id 聊天ID
|
||||
* @param params 更新参数
|
||||
* @returns 更新后的聊天记录
|
||||
*/
|
||||
export async function updateChat(id: string, params: ChatUpdateParams) {
|
||||
try {
|
||||
const updatedChat = await prisma.chat.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...params,
|
||||
version: {
|
||||
increment: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
logger.info(`[Chat] 更新了聊天 ${id}`);
|
||||
return updatedChat;
|
||||
} catch (error) {
|
||||
logger.error(`[Chat] 更新聊天 ${id} 失败:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除聊天
|
||||
* @param id 聊天ID
|
||||
* @returns 删除结果
|
||||
*
|
||||
* 注意:由于在 Prisma Schema 中配置了级联删除关系:
|
||||
*
|
||||
* 1. 删除 Chat 会自动级联删除所有关联的 Message 记录
|
||||
* 2. 删除 Message 会自动级联删除关联的 Section 记录
|
||||
*/
|
||||
export async function deleteChat(id: string) {
|
||||
try {
|
||||
const chatToDelete = await prisma.chat.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
_count: {
|
||||
select: {
|
||||
messages: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!chatToDelete) {
|
||||
logger.info(`[Chat] 未找到ID为 ${id} 的聊天,无法删除`);
|
||||
return false;
|
||||
}
|
||||
|
||||
await prisma.chat.delete({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
logger.info(`[Chat] 删除了聊天 ${id},级联删除了 ${chatToDelete._count.messages} 条关联消息及其项目数据`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error(`[Chat] 删除聊天 ${id} 失败:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取或创建指定ID的聊天
|
||||
* @param chatId 指定的聊天ID
|
||||
* @param params 创建聊天所需的参数
|
||||
* @returns 聊天记录
|
||||
*/
|
||||
export async function getOrCreateChat(chatId: string, params: Omit<ChatCreateParams, 'id'>) {
|
||||
try {
|
||||
// 尝试查找现有聊天
|
||||
const existingChat = await getChatById(chatId);
|
||||
|
||||
if (existingChat) {
|
||||
logger.info(`[Chat] 找到现有聊天: ${chatId}`);
|
||||
return existingChat;
|
||||
}
|
||||
|
||||
// 聊天不存在,创建新聊天,使用指定的ID
|
||||
const newChat = await createChat({
|
||||
...params,
|
||||
id: chatId,
|
||||
});
|
||||
logger.info(`[Chat] 聊天不存在,创建新聊天: ${newChat.id}`);
|
||||
return newChat;
|
||||
} catch (error) {
|
||||
logger.error(`[Chat] 获取或创建聊天失败:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据 ID 获取当前用户的聊天
|
||||
* @param id 聊天ID
|
||||
* @returns 聊天记录
|
||||
*/
|
||||
export async function getUserChatById(id: string, userId: string) {
|
||||
try {
|
||||
const chat = await prisma.chat.findUnique({
|
||||
where: { id, userId },
|
||||
include: {
|
||||
messages: {
|
||||
where: {
|
||||
isDiscarded: false,
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'asc',
|
||||
},
|
||||
include: {
|
||||
sections: true,
|
||||
page: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return chat;
|
||||
} catch (error) {
|
||||
logger.error(`[Chat] 获取聊天 ${id} 失败:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据ID创建或更新聊天(upsert操作)
|
||||
* @param params 聊天创建或更新参数
|
||||
* @returns 创建或更新后的聊天记录
|
||||
*/
|
||||
export async function upsertChat(params: ChatUpsertParams) {
|
||||
const { id, userId, urlId, description, metadata } = params;
|
||||
|
||||
try {
|
||||
const chat = await prisma.chat.upsert({
|
||||
where: { id },
|
||||
update: {
|
||||
version: {
|
||||
increment: 1,
|
||||
},
|
||||
description,
|
||||
metadata,
|
||||
},
|
||||
create: {
|
||||
id,
|
||||
userId,
|
||||
urlId,
|
||||
description,
|
||||
metadata,
|
||||
},
|
||||
});
|
||||
|
||||
logger.info(`[Chat] 创建或更新了聊天 ${id}`);
|
||||
return chat;
|
||||
} catch (error) {
|
||||
logger.error(`[Chat] 创建或更新聊天 ${id} 失败:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
303
app/lib/.server/chatUsage.ts
Normal file
303
app/lib/.server/chatUsage.ts
Normal file
@@ -0,0 +1,303 @@
|
||||
import { createScopedLogger } from '~/lib/.server/logger';
|
||||
import { prisma } from '~/lib/.server/prisma';
|
||||
|
||||
const logger = createScopedLogger('chatUsage.server');
|
||||
|
||||
/**
|
||||
* 聊天使用量状态
|
||||
*/
|
||||
export enum ChatUsageStatus {
|
||||
SUCCESS = 'SUCCESS',
|
||||
FAILED = 'FAILED',
|
||||
PENDING = 'PENDING',
|
||||
ABORTED = 'ABORTED',
|
||||
}
|
||||
|
||||
/**
|
||||
* 聊天使用量记录参数接口
|
||||
*/
|
||||
export interface ChatUsageParams {
|
||||
userId: string;
|
||||
chatId: string;
|
||||
messageId: string;
|
||||
status: ChatUsageStatus;
|
||||
inputTokens?: number;
|
||||
outputTokens?: number;
|
||||
cachedTokens?: number;
|
||||
reasoningTokens?: number;
|
||||
totalTokens?: number;
|
||||
modelName?: string;
|
||||
prompt?: string;
|
||||
metadata?: Record<string, any>;
|
||||
}
|
||||
/**
|
||||
* 日期过滤器类型
|
||||
*/
|
||||
interface DateFilter {
|
||||
calledAt?: {
|
||||
gte?: Date;
|
||||
lte?: Date;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录聊天使用量
|
||||
* @param params 使用量参数
|
||||
* @returns 创建的记录
|
||||
*/
|
||||
export async function recordUsage(params: ChatUsageParams) {
|
||||
const {
|
||||
userId,
|
||||
chatId,
|
||||
messageId,
|
||||
inputTokens = 0,
|
||||
outputTokens = 0,
|
||||
cachedTokens = 0,
|
||||
reasoningTokens = 0,
|
||||
status,
|
||||
prompt,
|
||||
metadata,
|
||||
modelName,
|
||||
} = params;
|
||||
|
||||
// 计算总token量
|
||||
const totalTokens = inputTokens + outputTokens;
|
||||
|
||||
try {
|
||||
// 创建记录
|
||||
const record = await prisma.chatUsage.create({
|
||||
data: {
|
||||
userId,
|
||||
messageId,
|
||||
chatId,
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
cachedTokens,
|
||||
reasoningTokens,
|
||||
totalTokens,
|
||||
status,
|
||||
prompt,
|
||||
metadata,
|
||||
modelName,
|
||||
},
|
||||
});
|
||||
|
||||
if (status === ChatUsageStatus.PENDING) {
|
||||
logger.info(`[ChatUsage] 初始化用户 ${userId} 的 ${modelName} 模型聊天使用量`);
|
||||
} else {
|
||||
logger.info(
|
||||
`[ChatUsage] 记录了用户 ${userId} 的 ${modelName} 模型聊天使用量: ${totalTokens} tokens,状态: ${status}`,
|
||||
);
|
||||
}
|
||||
return record;
|
||||
} catch (error) {
|
||||
logger.error('[ChatUsage] 记录聊天使用量失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取按天统计的使用数据
|
||||
* @param userId 用户ID
|
||||
* @param days 天数,默认为30天
|
||||
* @returns 每日使用统计数据
|
||||
*/
|
||||
export async function getDailyUsageStats(userId: string, days = 30) {
|
||||
try {
|
||||
// 计算结束日期为今天(当天结束)
|
||||
const endDate = new Date();
|
||||
endDate.setHours(23, 59, 59, 999);
|
||||
|
||||
// 计算开始日期为 endDate 前推 days-1 天的开始时间
|
||||
const startDate = new Date(endDate);
|
||||
startDate.setDate(endDate.getDate() - (days - 1));
|
||||
startDate.setHours(0, 0, 0, 0);
|
||||
|
||||
const records = await prisma.chatUsage.findMany({
|
||||
where: {
|
||||
userId,
|
||||
calledAt: {
|
||||
gte: startDate,
|
||||
lte: endDate,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
calledAt: true,
|
||||
totalTokens: true,
|
||||
},
|
||||
});
|
||||
|
||||
const dateMap: Record<string, { count: number; totalTokens: number }> = {};
|
||||
|
||||
// 创建从startDate到endDate的每一天映射
|
||||
const currentDate = new Date(startDate);
|
||||
while (currentDate <= endDate) {
|
||||
const dateStr = currentDate.toISOString().split('T')[0];
|
||||
dateMap[dateStr] = { count: 0, totalTokens: 0 };
|
||||
|
||||
// 增加一天
|
||||
currentDate.setDate(currentDate.getDate() + 1);
|
||||
}
|
||||
|
||||
// 统计数据
|
||||
records.forEach((record) => {
|
||||
const dateStr = record.calledAt.toISOString().split('T')[0];
|
||||
|
||||
if (dateMap[dateStr]) {
|
||||
dateMap[dateStr].count += 1;
|
||||
dateMap[dateStr].totalTokens += record.totalTokens;
|
||||
}
|
||||
});
|
||||
|
||||
return Object.entries(dateMap).map(([date, stats]) => ({
|
||||
date,
|
||||
count: stats.count,
|
||||
totalTokens: stats.totalTokens,
|
||||
}));
|
||||
} catch (error) {
|
||||
logger.error('[ChatUsage] 获取每日使用统计失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户的聊天使用统计
|
||||
* @param userId 用户ID
|
||||
* @param startDate 开始日期
|
||||
* @param endDate 结束日期
|
||||
* @returns 使用统计数据
|
||||
*/
|
||||
export async function getUserUsageStats(userId: string, startDate?: Date, endDate?: Date) {
|
||||
const dateFilter: DateFilter = {};
|
||||
|
||||
if (startDate || endDate) {
|
||||
dateFilter.calledAt = {};
|
||||
|
||||
if (startDate) {
|
||||
dateFilter.calledAt.gte = startDate;
|
||||
}
|
||||
|
||||
if (endDate) {
|
||||
dateFilter.calledAt.lte = endDate;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// 获取总体使用量
|
||||
const stats = await prisma.chatUsage.aggregate({
|
||||
where: {
|
||||
userId,
|
||||
...dateFilter,
|
||||
},
|
||||
_sum: {
|
||||
inputTokens: true,
|
||||
outputTokens: true,
|
||||
cachedTokens: true,
|
||||
reasoningTokens: true,
|
||||
totalTokens: true,
|
||||
},
|
||||
_count: true,
|
||||
});
|
||||
|
||||
// 按状态分组
|
||||
const statusStats = await prisma.chatUsage.groupBy({
|
||||
by: ['status'],
|
||||
where: {
|
||||
userId,
|
||||
...dateFilter,
|
||||
},
|
||||
_count: true,
|
||||
_sum: {
|
||||
totalTokens: true,
|
||||
},
|
||||
});
|
||||
|
||||
// 按聊天分组
|
||||
const chatStats = await prisma.chatUsage.groupBy({
|
||||
by: ['chatId'],
|
||||
where: {
|
||||
userId,
|
||||
...dateFilter,
|
||||
},
|
||||
_sum: {
|
||||
totalTokens: true,
|
||||
},
|
||||
_count: true,
|
||||
});
|
||||
|
||||
// 获取按天统计的数据
|
||||
const dailyStats = await getDailyUsageStats(userId, 7);
|
||||
|
||||
return {
|
||||
total: stats,
|
||||
byStatus: statusStats,
|
||||
byChat: chatStats,
|
||||
byDate: dailyStats,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('[ChatUsage] 获取用户使用统计失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新使用记录的状态
|
||||
* @param id 记录ID
|
||||
* @param status 新状态
|
||||
* @param additionalData 额外要更新的数据
|
||||
* @returns 更新后的记录
|
||||
*/
|
||||
export async function updateUsageStatus(
|
||||
id: string,
|
||||
status: ChatUsageStatus,
|
||||
additionalData?: Partial<ChatUsageParams>,
|
||||
) {
|
||||
try {
|
||||
const updatedRecord = await prisma.chatUsage.update({
|
||||
where: { id },
|
||||
data: {
|
||||
status,
|
||||
...additionalData,
|
||||
},
|
||||
});
|
||||
|
||||
return updatedRecord;
|
||||
} catch (error) {
|
||||
logger.error('[ChatUsage] 更新使用记录状态失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateUsageError(id: string, error: string, additionalData?: Partial<ChatUsageParams>) {
|
||||
return updateUsageStatus(id, ChatUsageStatus.FAILED, {
|
||||
...additionalData,
|
||||
metadata: {
|
||||
error: error || '未知错误',
|
||||
} as unknown as Record<string, any>,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取最近的使用记录
|
||||
* @param userId 用户ID
|
||||
* @param limit 限制返回记录数量
|
||||
* @returns 使用记录列表
|
||||
*/
|
||||
export async function getRecentUsage(userId: string, limit = 10) {
|
||||
try {
|
||||
const records = await prisma.chatUsage.findMany({
|
||||
where: {
|
||||
userId,
|
||||
},
|
||||
orderBy: {
|
||||
calledAt: 'desc',
|
||||
},
|
||||
take: limit,
|
||||
});
|
||||
|
||||
return records;
|
||||
} catch (error) {
|
||||
logger.error('[ChatUsage] 获取最近使用记录失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
235
app/lib/.server/connectionSettings.ts
Normal file
235
app/lib/.server/connectionSettings.ts
Normal file
@@ -0,0 +1,235 @@
|
||||
import { deleteUserSetting, deleteUserSettings, getUserSetting, setUserSetting } from '~/lib/.server/userSettings';
|
||||
import { createScopedLogger } from '~/utils/logger';
|
||||
|
||||
const logger = createScopedLogger('connectionSettings.server');
|
||||
|
||||
/**
|
||||
* 1Panel 连接设置
|
||||
*/
|
||||
export const ONEPANEL_SETTINGS = {
|
||||
CATEGORY: 'connectivity',
|
||||
SERVER_URL_KEY: '1panel_server_url',
|
||||
API_KEY_KEY: '1panel_api_key',
|
||||
};
|
||||
|
||||
/**
|
||||
* Netlify 连接设置
|
||||
*/
|
||||
export const NETLIFY_SETTINGS = {
|
||||
CATEGORY: 'connectivity',
|
||||
TOKEN_KEY: 'netlify_token',
|
||||
};
|
||||
|
||||
/**
|
||||
* Vercel 连接设置
|
||||
*/
|
||||
export const VERCEL_SETTINGS = {
|
||||
CATEGORY: 'connectivity',
|
||||
TOKEN_KEY: 'vercel_token',
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取1Panel连接设置
|
||||
* @param userId 用户ID
|
||||
* @returns 包含serverUrl和apiKey的对象,如果未设置则返回null
|
||||
*/
|
||||
export async function get1PanelConnectionSettings(
|
||||
userId: string,
|
||||
): Promise<{ serverUrl: string; apiKey: string } | null> {
|
||||
try {
|
||||
const serverUrlSetting = await getUserSetting(userId, ONEPANEL_SETTINGS.CATEGORY, ONEPANEL_SETTINGS.SERVER_URL_KEY);
|
||||
const apiKeySetting = await getUserSetting(userId, ONEPANEL_SETTINGS.CATEGORY, ONEPANEL_SETTINGS.API_KEY_KEY);
|
||||
|
||||
if (!serverUrlSetting || !apiKeySetting) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
serverUrl: serverUrlSetting.value,
|
||||
apiKey: apiKeySetting.value,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error(`[1Panel] 获取用户 ${userId} 的连接设置失败:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存1Panel连接设置
|
||||
* @param userId 用户ID
|
||||
* @param serverUrl 服务器URL
|
||||
* @param apiKey API密钥
|
||||
*/
|
||||
export async function save1PanelConnectionSettings(userId: string, serverUrl: string, apiKey: string): Promise<void> {
|
||||
try {
|
||||
await setUserSetting({
|
||||
userId,
|
||||
category: ONEPANEL_SETTINGS.CATEGORY,
|
||||
key: ONEPANEL_SETTINGS.SERVER_URL_KEY,
|
||||
value: serverUrl,
|
||||
});
|
||||
|
||||
await setUserSetting({
|
||||
userId,
|
||||
category: ONEPANEL_SETTINGS.CATEGORY,
|
||||
key: ONEPANEL_SETTINGS.API_KEY_KEY,
|
||||
value: apiKey,
|
||||
isSecret: true,
|
||||
});
|
||||
|
||||
logger.info(`[1Panel] 保存用户 ${userId} 的连接设置成功`);
|
||||
} catch (error) {
|
||||
logger.error(`[1Panel] 保存用户 ${userId} 的连接设置失败:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除1Panel连接设置
|
||||
* @param userId 用户ID
|
||||
*/
|
||||
export async function delete1PanelConnectionSettings(userId: string): Promise<void> {
|
||||
try {
|
||||
await deleteUserSetting(userId, ONEPANEL_SETTINGS.CATEGORY, ONEPANEL_SETTINGS.SERVER_URL_KEY);
|
||||
await deleteUserSetting(userId, ONEPANEL_SETTINGS.CATEGORY, ONEPANEL_SETTINGS.API_KEY_KEY);
|
||||
|
||||
logger.info(`[1Panel] 删除用户 ${userId} 的连接设置成功`);
|
||||
} catch (error) {
|
||||
logger.error(`[1Panel] 删除用户 ${userId} 的连接设置失败:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取Netlify连接设置
|
||||
* @param userId 用户ID
|
||||
* @returns 包含token的对象,如果未设置则返回null
|
||||
*/
|
||||
export async function getNetlifyConnectionSettings(userId: string): Promise<{ token: string } | null> {
|
||||
try {
|
||||
const tokenSetting = await getUserSetting(userId, NETLIFY_SETTINGS.CATEGORY, NETLIFY_SETTINGS.TOKEN_KEY);
|
||||
|
||||
if (!tokenSetting) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
token: tokenSetting.value,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error(`[Netlify] 获取用户 ${userId} 的连接设置失败:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存Netlify连接设置
|
||||
* @param userId 用户ID
|
||||
* @param token 访问令牌
|
||||
*/
|
||||
export async function saveNetlifyConnectionSettings(userId: string, token: string): Promise<void> {
|
||||
try {
|
||||
await setUserSetting({
|
||||
userId,
|
||||
category: NETLIFY_SETTINGS.CATEGORY,
|
||||
key: NETLIFY_SETTINGS.TOKEN_KEY,
|
||||
value: token,
|
||||
isSecret: true,
|
||||
});
|
||||
|
||||
logger.info(`[Netlify] 保存用户 ${userId} 的连接设置成功`);
|
||||
} catch (error) {
|
||||
logger.error(`[Netlify] 保存用户 ${userId} 的连接设置失败:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除Netlify连接设置
|
||||
* @param userId 用户ID
|
||||
*/
|
||||
export async function deleteNetlifyConnectionSettings(userId: string): Promise<void> {
|
||||
try {
|
||||
await deleteUserSetting(userId, NETLIFY_SETTINGS.CATEGORY, NETLIFY_SETTINGS.TOKEN_KEY);
|
||||
|
||||
logger.info(`[Netlify] 删除用户 ${userId} 的连接设置成功`);
|
||||
} catch (error) {
|
||||
logger.error(`[Netlify] 删除用户 ${userId} 的连接设置失败:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取Vercel连接设置
|
||||
* @param userId 用户ID
|
||||
* @returns 包含token的对象,如果未设置则返回null
|
||||
*/
|
||||
export async function getVercelConnectionSettings(userId: string): Promise<{ token: string } | null> {
|
||||
try {
|
||||
const tokenSetting = await getUserSetting(userId, VERCEL_SETTINGS.CATEGORY, VERCEL_SETTINGS.TOKEN_KEY);
|
||||
|
||||
if (!tokenSetting) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
token: tokenSetting.value,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error(`[Vercel] 获取用户 ${userId} 的连接设置失败:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存Vercel连接设置
|
||||
* @param userId 用户ID
|
||||
* @param token 访问令牌
|
||||
*/
|
||||
export async function saveVercelConnectionSettings(userId: string, token: string): Promise<void> {
|
||||
try {
|
||||
await setUserSetting({
|
||||
userId,
|
||||
category: VERCEL_SETTINGS.CATEGORY,
|
||||
key: VERCEL_SETTINGS.TOKEN_KEY,
|
||||
value: token,
|
||||
isSecret: true,
|
||||
});
|
||||
|
||||
logger.info(`[Vercel] 保存用户 ${userId} 的连接设置成功`);
|
||||
} catch (error) {
|
||||
logger.error(`[Vercel] 保存用户 ${userId} 的连接设置失败:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除Vercel连接设置
|
||||
* @param userId 用户ID
|
||||
*/
|
||||
export async function deleteVercelConnectionSettings(userId: string): Promise<void> {
|
||||
try {
|
||||
await deleteUserSetting(userId, VERCEL_SETTINGS.CATEGORY, VERCEL_SETTINGS.TOKEN_KEY);
|
||||
|
||||
logger.info(`[Vercel] 删除用户 ${userId} 的连接设置成功`);
|
||||
} catch (error) {
|
||||
logger.error(`[Vercel] 删除用户 ${userId} 的连接设置失败:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除所有连接设置
|
||||
* @param userId 用户ID
|
||||
*/
|
||||
export async function deleteAllConnectionSettings(userId: string): Promise<void> {
|
||||
try {
|
||||
// 使用 deleteUserSettings 删除 'connectivity' 类别下的所有设置
|
||||
await deleteUserSettings(userId, 'connectivity');
|
||||
|
||||
logger.info(`[连接设置] 删除用户 ${userId} 的所有连接设置成功`);
|
||||
} catch (error) {
|
||||
logger.error(`[连接设置] 删除用户 ${userId} 的所有连接设置失败:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
316
app/lib/.server/deployment.ts
Normal file
316
app/lib/.server/deployment.ts
Normal file
@@ -0,0 +1,316 @@
|
||||
import { prisma } from '~/lib/.server/prisma';
|
||||
import type { DeploymentPlatform } from '~/types/deployment';
|
||||
import { createScopedLogger } from '~/utils/logger';
|
||||
|
||||
const logger = createScopedLogger('deployment.server');
|
||||
|
||||
/**
|
||||
* 部署记录创建参数接口
|
||||
*/
|
||||
export interface DeploymentCreateParams {
|
||||
userId: string;
|
||||
chatId: string;
|
||||
platform: DeploymentPlatform;
|
||||
deploymentId: string;
|
||||
url: string;
|
||||
status: string;
|
||||
metadata?: Record<string, any>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建或更新部署记录
|
||||
* 当 userId、chatId 和 platform 都匹配时,更新现有记录而不是创建新记录
|
||||
*
|
||||
* @param params 部署记录创建参数
|
||||
* @returns 创建或更新的部署记录
|
||||
*/
|
||||
export async function createOrUpdateDeployment(params: DeploymentCreateParams) {
|
||||
const { userId, chatId, platform, deploymentId, url, status, metadata } = params;
|
||||
|
||||
try {
|
||||
const existingDeployment = await prisma.deployment.findFirst({
|
||||
where: {
|
||||
userId,
|
||||
chatId,
|
||||
platform,
|
||||
},
|
||||
});
|
||||
|
||||
let deployment;
|
||||
|
||||
if (existingDeployment) {
|
||||
deployment = await prisma.deployment.update({
|
||||
where: { id: existingDeployment.id },
|
||||
data: {
|
||||
deploymentId,
|
||||
url,
|
||||
status,
|
||||
metadata,
|
||||
},
|
||||
});
|
||||
logger.info(`[Deployment] 更新了用户 ${userId} 的部署记录: ${deployment.id}, 平台: ${platform}`);
|
||||
} else {
|
||||
deployment = await prisma.deployment.create({
|
||||
data: {
|
||||
userId,
|
||||
chatId,
|
||||
platform,
|
||||
deploymentId,
|
||||
url,
|
||||
status,
|
||||
metadata,
|
||||
},
|
||||
});
|
||||
logger.info(`[Deployment] 创建了用户 ${userId} 的部署记录: ${deployment.id}, 平台: ${platform}`);
|
||||
}
|
||||
|
||||
return deployment;
|
||||
} catch (error) {
|
||||
logger.error('[Deployment] 创建或更新部署记录失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据ID获取部署记录
|
||||
* @param id 部署记录ID
|
||||
* @returns 部署记录
|
||||
*/
|
||||
export async function getDeploymentById(id: string) {
|
||||
try {
|
||||
const deployment = await prisma.deployment.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
return deployment;
|
||||
} catch (error) {
|
||||
logger.error(`[Deployment] 获取部署记录 ${id} 失败:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据ID删除部署记录
|
||||
* @param id 部署记录ID
|
||||
* @returns 删除的部署记录
|
||||
*/
|
||||
export async function deleteDeploymentById(id: string) {
|
||||
try {
|
||||
const deployment = await prisma.deployment.delete({
|
||||
where: { id },
|
||||
});
|
||||
return deployment;
|
||||
} catch (error) {
|
||||
logger.error(`[Deployment] 删除部署记录 ${id} 失败:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户的所有部署记录
|
||||
* @param userId 用户ID
|
||||
* @param limit 限制返回记录数量
|
||||
* @param offset 偏移量
|
||||
* @returns 部署记录列表
|
||||
*/
|
||||
export async function getUserDeployments(userId: string, limit = 20, offset = 0) {
|
||||
try {
|
||||
const deployments = await prisma.deployment.findMany({
|
||||
where: { userId },
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
skip: offset,
|
||||
take: limit,
|
||||
});
|
||||
|
||||
const total = await prisma.deployment.count({
|
||||
where: { userId },
|
||||
});
|
||||
|
||||
return {
|
||||
deployments,
|
||||
total,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error(`[Deployment] 获取用户 ${userId} 的部署记录列表失败:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取特定聊天的所有部署记录
|
||||
* @param chatId 聊天ID
|
||||
* @returns 部署记录列表
|
||||
*/
|
||||
export async function getChatDeployments(chatId: string) {
|
||||
try {
|
||||
const deployments = await prisma.deployment.findMany({
|
||||
where: { chatId },
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
});
|
||||
|
||||
return deployments;
|
||||
} catch (error) {
|
||||
logger.error(`[Deployment] 获取聊天 ${chatId} 的部署记录列表失败:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户在特定平台的所有部署记录
|
||||
* @param userId 用户ID
|
||||
* @param platform 平台名称
|
||||
* @returns 部署记录列表
|
||||
*/
|
||||
export async function getUserPlatformDeployments(userId: string, platform: DeploymentPlatform) {
|
||||
try {
|
||||
const deployments = await prisma.deployment.findMany({
|
||||
where: {
|
||||
userId,
|
||||
platform,
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
});
|
||||
|
||||
return deployments;
|
||||
} catch (error) {
|
||||
logger.error(`[Deployment] 获取用户 ${userId} 在平台 ${platform} 的部署记录列表失败:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户在特定平台和聊天的最新部署记录
|
||||
* @param userId 用户ID
|
||||
* @param chatId 聊天ID
|
||||
* @param platform 平台名称
|
||||
* @returns 最新的部署记录,如果不存在则返回 null
|
||||
*/
|
||||
export async function getLatestDeployment(userId: string, chatId: string, platform: DeploymentPlatform) {
|
||||
try {
|
||||
const deployment = await prisma.deployment.findFirst({
|
||||
where: {
|
||||
userId,
|
||||
chatId,
|
||||
platform,
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
});
|
||||
|
||||
return deployment;
|
||||
} catch (error) {
|
||||
logger.error(`[Deployment] 获取用户 ${userId} 在平台 ${platform} 的最新部署记录失败:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新部署记录状态
|
||||
* @param id 部署记录ID
|
||||
* @param status 新状态
|
||||
* @param metadata 可选的元数据更新
|
||||
* @returns 更新后的部署记录
|
||||
*/
|
||||
export async function updateDeploymentStatus(id: string, status: string, metadata?: Record<string, any>) {
|
||||
try {
|
||||
const updatedDeployment = await prisma.deployment.update({
|
||||
where: { id },
|
||||
data: {
|
||||
status,
|
||||
...(metadata ? { metadata } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
logger.info(`[Deployment] 更新了部署记录 ${id} 的状态为 ${status}`);
|
||||
return updatedDeployment;
|
||||
} catch (error) {
|
||||
logger.error(`[Deployment] 更新部署记录 ${id} 状态失败:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据平台和平台特定的ID删除所有相关的部署记录
|
||||
*
|
||||
* @param platform 平台名称
|
||||
* @param platformId 平台特定的ID
|
||||
* @returns 删除的记录数量
|
||||
*/
|
||||
export async function deleteDeploymentsByPlatformAndId(platform: DeploymentPlatform, platformId: string | number) {
|
||||
try {
|
||||
// 将 platformId 转换为字符串,因为在数据库中 deploymentId 是字符串类型
|
||||
const deploymentId = String(platformId);
|
||||
|
||||
const result = await prisma.deployment.deleteMany({
|
||||
where: {
|
||||
platform,
|
||||
deploymentId,
|
||||
},
|
||||
});
|
||||
|
||||
logger.info(`[Deployment] 删除了平台 ${platform} 上 ID 为 ${platformId} 的 ${result.count} 条部署记录`);
|
||||
return result.count;
|
||||
} catch (error) {
|
||||
logger.error(`[Deployment] 删除平台 ${platform} 上 ID 为 ${platformId} 的部署记录失败:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 分页获取用户在特定平台的部署记录
|
||||
* @param userId 用户ID
|
||||
* @param platform 平台名称(可选)
|
||||
* @param limit 每页记录数
|
||||
* @param offset 偏移量
|
||||
* @returns 部署记录列表和总数
|
||||
*/
|
||||
export async function getUserPlatformDeploymentsWithPagination(
|
||||
userId: string,
|
||||
platform?: DeploymentPlatform,
|
||||
limit = 10,
|
||||
offset = 0,
|
||||
) {
|
||||
try {
|
||||
const where = {
|
||||
userId,
|
||||
...(platform ? { platform } : {}),
|
||||
};
|
||||
|
||||
const deployments = await prisma.deployment.findMany({
|
||||
where,
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
skip: offset,
|
||||
take: limit,
|
||||
include: {
|
||||
chat: {
|
||||
select: {
|
||||
id: true,
|
||||
description: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const total = await prisma.deployment.count({ where });
|
||||
|
||||
return {
|
||||
deployments,
|
||||
total,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`[Deployment] 分页获取用户 ${userId} ${platform ? `在平台 ${platform} ` : ''}的部署记录列表失败:`,
|
||||
error,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
149
app/lib/.server/llm/chat-stream-text.ts
Normal file
149
app/lib/.server/llm/chat-stream-text.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
import {
|
||||
streamText as _streamText,
|
||||
type CallSettings,
|
||||
convertToModelMessages,
|
||||
type LanguageModel,
|
||||
type LanguageModelUsage,
|
||||
type StreamTextOnFinishCallback,
|
||||
stepCountIs,
|
||||
} from 'ai';
|
||||
import { getSystemPrompt } from '~/lib/common/prompts/prompts';
|
||||
import type { ElementInfo } from '~/routes/api.chat/chat.server';
|
||||
import type { UPageUIMessage } from '~/types/message';
|
||||
import { approximatePromptTokenCount, encode } from '~/utils/token';
|
||||
import { MAX_TOKENS } from './constants';
|
||||
import { tools } from './tools';
|
||||
|
||||
export type ChatStreamTextProps = CallSettings & {
|
||||
messages: UPageUIMessage[];
|
||||
summary: string;
|
||||
pageSummary: string;
|
||||
context?: Record<string, string[]>;
|
||||
model: LanguageModel;
|
||||
maxTokens?: number;
|
||||
elementInfo?: ElementInfo;
|
||||
onFinish?: StreamTextOnFinishCallback<any>;
|
||||
onAbort?: (params: { event: any; totalUsage: LanguageModelUsage }) => void;
|
||||
};
|
||||
|
||||
export async function chatStreamText({
|
||||
messages,
|
||||
summary,
|
||||
pageSummary,
|
||||
context,
|
||||
model,
|
||||
maxTokens,
|
||||
elementInfo,
|
||||
abortSignal,
|
||||
onFinish,
|
||||
onAbort,
|
||||
}: ChatStreamTextProps) {
|
||||
let systemPrompt = getSystemPrompt();
|
||||
|
||||
if (pageSummary) {
|
||||
systemPrompt = `${systemPrompt}
|
||||
以下是截止目前为止的页面摘要:
|
||||
PAGE SUMMARY:
|
||||
---
|
||||
${pageSummary}
|
||||
---
|
||||
`;
|
||||
}
|
||||
|
||||
if (summary) {
|
||||
systemPrompt = `${systemPrompt}
|
||||
以下是截至目前为止的聊天记录摘要:
|
||||
CHAT SUMMARY:
|
||||
---
|
||||
${summary}
|
||||
---
|
||||
`;
|
||||
}
|
||||
|
||||
if (context) {
|
||||
systemPrompt = `${systemPrompt}
|
||||
以下是根据用户的聊天记录和任务分析出的可能对此次任务有帮助的代码片段,按页面名称区分
|
||||
CONTEXT:
|
||||
---
|
||||
${Object.entries(context)
|
||||
.map(([key, value]) => `${key}: ${value.join('\n')}\n`)
|
||||
.join('\n')}
|
||||
---
|
||||
`;
|
||||
}
|
||||
|
||||
if (elementInfo) {
|
||||
systemPrompt = `${systemPrompt}
|
||||
${createElementEditPrompt(elementInfo)}
|
||||
`;
|
||||
}
|
||||
|
||||
return _streamText({
|
||||
model,
|
||||
tools,
|
||||
system: systemPrompt,
|
||||
maxOutputTokens: maxTokens || MAX_TOKENS,
|
||||
messages: convertToModelMessages(messages),
|
||||
stopWhen: stepCountIs(3),
|
||||
prepareStep: async ({ messages }) => {
|
||||
if (messages.length > 20) {
|
||||
return {
|
||||
messages: messages.slice(-10),
|
||||
};
|
||||
}
|
||||
return {};
|
||||
},
|
||||
abortSignal,
|
||||
onFinish,
|
||||
onAbort(event) {
|
||||
// 由于 AI SDK 没有提供在 onAbort 中计算 Token 消耗的方法。所以这里手动计算。
|
||||
let inoutTokens = 0;
|
||||
inoutTokens += approximatePromptTokenCount(messages);
|
||||
inoutTokens += encode(systemPrompt).length;
|
||||
onAbort?.({
|
||||
event,
|
||||
totalUsage: {
|
||||
inputTokens: inoutTokens,
|
||||
outputTokens: 0,
|
||||
totalTokens: inoutTokens,
|
||||
reasoningTokens: 0,
|
||||
cachedInputTokens: 0,
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据元素编辑信息创建相应的系统提示
|
||||
* @param elementEdit 元素编辑信息
|
||||
* @returns 系统提示字符串
|
||||
*/
|
||||
function createElementEditPrompt({ tagName, className, id }: ElementInfo): string {
|
||||
// 构建元素选择器描述
|
||||
const elementSelector = [tagName.toLowerCase(), id ? `#${id}` : '', className ? `.${className.split(' ')[0]}` : '']
|
||||
.filter(Boolean)
|
||||
.join('');
|
||||
|
||||
return `
|
||||
<element_edit_context>
|
||||
用户当前正在编辑特定元素。请将您的响应限制在此元素的范围内。
|
||||
|
||||
当前编辑的元素: ${elementSelector}
|
||||
|
||||
请严格遵循以下规则:
|
||||
1. 仅修改用户当前选中的元素或其子元素
|
||||
2. 不要修改页面上的其他元素
|
||||
3. 如果是添加操作,仅在当前选中元素内添加内容
|
||||
4. 如果是更新操作,确保使用最小化更新,并保留元素的 domId
|
||||
5. 如果是删除操作,仅删除当前选中元素或其子元素
|
||||
6. 保持页面的整体风格和一致性
|
||||
7. 确保所有生成的 HTML 元素都有唯一的 domId,不要使用相同的 domId
|
||||
|
||||
元素详细信息:
|
||||
- 标签名: ${tagName.toLowerCase()}
|
||||
${id ? `- ID: ${id}` : ''}
|
||||
${className ? `- 类名: ${className}` : ''}
|
||||
</element_edit_context>
|
||||
`;
|
||||
}
|
||||
5
app/lib/.server/llm/constants.ts
Normal file
5
app/lib/.server/llm/constants.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
// see https://docs.anthropic.com/en/docs/about-claude/models
|
||||
export const MAX_TOKENS = 8000;
|
||||
|
||||
// limits the number of model responses that can be returned in a single request
|
||||
export const MAX_RESPONSE_SEGMENTS = 3;
|
||||
148
app/lib/.server/llm/create-summary.ts
Normal file
148
app/lib/.server/llm/create-summary.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
import { type CallSettings, generateText, type LanguageModel } from 'ai';
|
||||
import { createScopedLogger } from '~/lib/.server/logger';
|
||||
import type { UPageUIMessage } from '~/types/message';
|
||||
import { extractCurrentContext, getUserMessageContent, simplifyUPageActions } from './utils';
|
||||
|
||||
const logger = createScopedLogger('create-summary');
|
||||
|
||||
export async function createSummary({
|
||||
messages,
|
||||
model,
|
||||
abortSignal,
|
||||
}: {
|
||||
messages: UPageUIMessage[];
|
||||
model: LanguageModel;
|
||||
} & CallSettings) {
|
||||
const processedMessages = messages.map((message) => {
|
||||
if (message.role === 'user') {
|
||||
const content = getUserMessageContent(message);
|
||||
|
||||
return { ...message, content };
|
||||
}
|
||||
|
||||
if (message.role == 'assistant') {
|
||||
for (const part of message.parts) {
|
||||
if (part.type === 'text') {
|
||||
part.text = simplifyUPageActions(part.text);
|
||||
part.text = part.text.replace(/<div class=\\"__uPageThought__\\">.*?<\/div>/s, '');
|
||||
}
|
||||
if (part.type === 'reasoning') {
|
||||
part.text = part.text.replace(/<think>.*?<\/think>/s, '');
|
||||
}
|
||||
}
|
||||
|
||||
return message;
|
||||
}
|
||||
|
||||
return message;
|
||||
});
|
||||
|
||||
let slicedMessages = processedMessages;
|
||||
const { summary } = extractCurrentContext(processedMessages);
|
||||
let summaryText: string | undefined = undefined;
|
||||
let chatId: string | undefined = undefined;
|
||||
|
||||
if (summary) {
|
||||
chatId = summary.chatId;
|
||||
summaryText = `以下是截至目前为止的聊天摘要,将其作为历史消息参考使用。
|
||||
${summary.summary}`;
|
||||
|
||||
if (chatId) {
|
||||
let index = 0;
|
||||
|
||||
for (let i = 0; i < processedMessages.length; i++) {
|
||||
if (processedMessages[i].id === chatId) {
|
||||
index = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
slicedMessages = processedMessages.slice(index + 1);
|
||||
}
|
||||
}
|
||||
|
||||
logger.debug('切片消息长度:', slicedMessages.length);
|
||||
|
||||
const extractTextContent = (message: UPageUIMessage) =>
|
||||
message.parts
|
||||
.map((part) => {
|
||||
if (part.type === 'text') {
|
||||
return part.text;
|
||||
}
|
||||
return '';
|
||||
})
|
||||
.join('\n');
|
||||
|
||||
return await generateText({
|
||||
system: `
|
||||
你是一名软件工程师。你正在参与一个项目。你需要总结目前的工作内容,并提供截至目前对话的摘要。
|
||||
|
||||
请仅使用以下格式生成摘要:
|
||||
---
|
||||
# 项目概览
|
||||
- **项目名称**: {project_name} - {brief_description}
|
||||
- **当前阶段**: {phase}
|
||||
|
||||
# 对话上下文
|
||||
- **最近讨论点**: {main_discussion_point}
|
||||
- **重要决策**: {important_decisions_made}
|
||||
|
||||
# 实现状态
|
||||
## 当前状态
|
||||
- **活跃功能**: {feature_in_development}
|
||||
- **进展**: {what_works_and_what_doesn't}
|
||||
- **障碍**: {current_challenges}
|
||||
|
||||
## 代码演化
|
||||
- **最近修改**: {latest_modifications}
|
||||
|
||||
# 需求
|
||||
- **已实现**: {completed_features}
|
||||
- **进行中**: {current_focus}
|
||||
- **待定**: {upcoming_features}
|
||||
|
||||
# 关键记忆
|
||||
- **必须保留**: {crucial_technical_context}
|
||||
- **用户需求**: {specific_user_needs}
|
||||
- **已知问题**: {documented_problems}
|
||||
|
||||
# 下一步行动
|
||||
- **立即行动**: {next_steps}
|
||||
- **待解决的问题**: {unresolved_issues}
|
||||
|
||||
---
|
||||
Note:
|
||||
4. 保持条目简洁,重点记录确保工作连续性所需的信息。
|
||||
|
||||
|
||||
---
|
||||
|
||||
RULES:
|
||||
* 仅提供截至目前为止的聊天摘要。
|
||||
* 不要提供任何新信息。
|
||||
* 不需要过多思考,立即开始写作
|
||||
* 不要写任何与提供的结构不同的摘要
|
||||
`,
|
||||
prompt: `
|
||||
|
||||
以下是之前的聊天摘要:
|
||||
<old_summary>
|
||||
${summaryText}
|
||||
</old_summary>
|
||||
|
||||
以下是之后的聊天记录:
|
||||
---
|
||||
<new_chats>
|
||||
${slicedMessages
|
||||
.map((x) => {
|
||||
return `---\n[${x.role}] ${extractTextContent(x)}\n---`;
|
||||
})
|
||||
.join('\n')}
|
||||
</new_chats>
|
||||
---
|
||||
|
||||
请提供截至目前聊天的摘要,包括聊天的历史记录摘要。
|
||||
`,
|
||||
model,
|
||||
abortSignal,
|
||||
});
|
||||
}
|
||||
124
app/lib/.server/llm/select-context.ts
Normal file
124
app/lib/.server/llm/select-context.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import { type CallSettings, generateText, type LanguageModel } from 'ai';
|
||||
import { createScopedLogger } from '~/lib/.server/logger';
|
||||
|
||||
import type { Page } from '~/types/actions';
|
||||
import type { UPageUIMessage } from '~/types/message';
|
||||
|
||||
const logger = createScopedLogger('select-context');
|
||||
|
||||
export async function selectContext({
|
||||
messages,
|
||||
pages,
|
||||
summary,
|
||||
model,
|
||||
abortSignal,
|
||||
}: {
|
||||
messages: UPageUIMessage[];
|
||||
pages: Page[];
|
||||
summary: string;
|
||||
model: LanguageModel;
|
||||
} & CallSettings) {
|
||||
const extractTextContent = (message: UPageUIMessage) =>
|
||||
message.parts.find((part) => part.type === 'text')?.text || '';
|
||||
|
||||
const lastUserMessage = messages.filter((x) => x.role == 'user').pop();
|
||||
if (!lastUserMessage) {
|
||||
throw new Error('未找到用户消息');
|
||||
}
|
||||
|
||||
const pagesContent = pages.map((page) => {
|
||||
return `
|
||||
---
|
||||
页面名称:${page.name}
|
||||
---
|
||||
页面内容:${page.content}
|
||||
`;
|
||||
});
|
||||
|
||||
const resp = await generateText({
|
||||
system: `
|
||||
你是一名软件工程师。你正在从事一个 HTML 项目,该项目包含多个页面,每个页面内容中包含多个 Section。这些 Section 可能是 HTML、style、JavaScript 片段。
|
||||
提供给你的为 Body 内容,每个处于根节点下的 HTML 标签,都包含一个唯一的 domId 属性,并且为单独的一个 Section。
|
||||
|
||||
${pagesContent.join('\n')}
|
||||
|
||||
---
|
||||
|
||||
现在,你将获得一个任务。你需要从上述页面列表中选择与任务相关的页面与其相关的 Section。
|
||||
|
||||
RESPONSE FORMAT:
|
||||
你的回复应严格遵循以下格式:
|
||||
---
|
||||
<updateContextBuffer>
|
||||
<selectPage pageName="pageName">
|
||||
<selectSection>
|
||||
...section content...
|
||||
</selectSection>
|
||||
...
|
||||
</selectPage>
|
||||
...
|
||||
</updateContextBuffer>
|
||||
---
|
||||
* 你应该从 <updateContextBuffer> 开始,以 </updateContextBuffer> 结束。
|
||||
* 你可以在回复中包含多个 <selectPage> 标签,每个 <selectPage> 标签中也可以包含多个 <selectSection> 标签。
|
||||
* 你需要在 <selectPage> 标签中包含页面名称,但每个页面名称只能出现一次。
|
||||
* 你需要在 <selectSection> 标签中包含完整的 Section 内容,只做选择,但不要对 Section 内容进行任何修改。
|
||||
* 如果不需要任何更改,你可以留下空的 updateContextBuffer 标签。
|
||||
`,
|
||||
prompt: `
|
||||
以下是截至目前聊天的摘要: ${summary}
|
||||
|
||||
用户当前任务: ${extractTextContent(lastUserMessage)}
|
||||
|
||||
请根据当前页面与 Section 的详细代码,选择与任务相关的页面以及 Section。
|
||||
`,
|
||||
model,
|
||||
abortSignal,
|
||||
});
|
||||
|
||||
const response = resp.text;
|
||||
const updateContextBuffer = response.match(/<updateContextBuffer>([\s\S]*?)<\/updateContextBuffer>/);
|
||||
|
||||
if (!updateContextBuffer) {
|
||||
throw new Error('无效响应。请遵循响应格式');
|
||||
}
|
||||
|
||||
const updateContextBufferContent = updateContextBuffer[1];
|
||||
const selectedPages: Record<string, string[]> = {};
|
||||
|
||||
const selectPageRegex = /<selectPage\s+pageName="([^"]+)">([\s\S]*?)<\/selectPage>/g;
|
||||
let selectPageMatch;
|
||||
|
||||
while ((selectPageMatch = selectPageRegex.exec(updateContextBufferContent)) !== null) {
|
||||
const pageName = selectPageMatch[1];
|
||||
const pageContent = selectPageMatch[2];
|
||||
|
||||
if (!pageName) {
|
||||
logger.warn('页面名称为空');
|
||||
continue;
|
||||
}
|
||||
|
||||
const selectSectionRegex = /<selectSection>([\s\S]*?)<\/selectSection>/g;
|
||||
const sections: string[] = [];
|
||||
let selectSectionMatch;
|
||||
|
||||
while ((selectSectionMatch = selectSectionRegex.exec(pageContent)) !== null) {
|
||||
const sectionContent = selectSectionMatch[1];
|
||||
if (sectionContent.trim()) {
|
||||
sections.push(sectionContent.trim());
|
||||
}
|
||||
}
|
||||
|
||||
if (sections.length > 0) {
|
||||
selectedPages[pageName] = sections;
|
||||
}
|
||||
}
|
||||
|
||||
const { text, content, totalUsage } = resp;
|
||||
return {
|
||||
text,
|
||||
content,
|
||||
totalUsage,
|
||||
context: selectedPages,
|
||||
};
|
||||
}
|
||||
45
app/lib/.server/llm/stream-enhancer.ts
Normal file
45
app/lib/.server/llm/stream-enhancer.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { convertToModelMessages, type LanguageModel, streamText, type UIMessage } from 'ai';
|
||||
import { createScopedLogger } from '~/lib/.server/logger';
|
||||
import { DEFAULT_PROVIDER } from '~/utils/constants';
|
||||
import { stripIndents } from '~/utils/strip-indent';
|
||||
|
||||
const logger = createScopedLogger('stream-enhancer');
|
||||
|
||||
export async function streamEnhancer(props: { messages: UIMessage[]; model: LanguageModel; maxTokens?: number }) {
|
||||
const { messages, model, maxTokens } = props;
|
||||
|
||||
logger.info(`发送 llm 调用至 ${DEFAULT_PROVIDER.name} 使用模型 ${model}`);
|
||||
|
||||
const systemMessage = stripIndents`
|
||||
你是一位专业提示工程师,专注于制作精确、有效的提示。
|
||||
你的任务是增强提示,使其更加具体、可操作且有效。
|
||||
|
||||
对于有效的提示:
|
||||
- 使指令明确且无歧义
|
||||
- 添加相关上下文和约束
|
||||
- 删除冗余信息
|
||||
- 保持核心意图
|
||||
- 确保提示自包含
|
||||
- 使用专业语言
|
||||
|
||||
对于无效或不明确的提示:
|
||||
- 提供清晰、专业的指导
|
||||
- 保持响应简洁且可操作
|
||||
- 保持有帮助、建设性的语气
|
||||
- 专注于用户应该提供的内容
|
||||
- 使用标准模板保持一致
|
||||
|
||||
<output_format>
|
||||
1. 响应必须仅包含增强后的提示文本。
|
||||
2. 不要包含任何解释、元数据或包装标签。
|
||||
</output_format>
|
||||
`;
|
||||
|
||||
const result = streamText({
|
||||
model,
|
||||
system: systemMessage,
|
||||
maxOutputTokens: maxTokens,
|
||||
messages: convertToModelMessages(messages),
|
||||
});
|
||||
return result.toUIMessageStreamResponse();
|
||||
}
|
||||
149
app/lib/.server/llm/structured-page-snapshot.ts
Normal file
149
app/lib/.server/llm/structured-page-snapshot.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
import { type CallSettings, generateText, type LanguageModel } from 'ai';
|
||||
import type { Page } from '~/types/actions';
|
||||
|
||||
export async function structuredPageSnapshot({
|
||||
pages,
|
||||
model,
|
||||
abortSignal,
|
||||
}: {
|
||||
pages: Page[];
|
||||
model: LanguageModel;
|
||||
} & CallSettings) {
|
||||
return await generateText({
|
||||
system: `
|
||||
你是一名严谨的前端页面审阅与摘要助手。你将收到多个页面的 Body 片段,可能包含 script 与 style。你的任务是:
|
||||
1) 从这些页面内容中提取结构化信息;
|
||||
2) 以严格的 XML 风格(非 JSON、非 Markdown、无额外说明文本)输出一个“现有页面摘要快照”;
|
||||
3) 仅基于给定内容进行总结,不得臆测未出现的信息;不回显原始全文;
|
||||
4) 若某项信息无法确定,请输出空元素,不要编造。
|
||||
|
||||
输出必须严格遵循以下 XML 模板(标签名与层级必须一致;可重复的节点可按需要重复;所有一级大纲必须保留):
|
||||
<snapshot>
|
||||
<generated_at></generated_at>
|
||||
<pages_count></pages_count>
|
||||
<global_overview>
|
||||
<shared_components></shared_components>
|
||||
<shared_styles></shared_styles>
|
||||
<shared_scripts></shared_scripts>
|
||||
<navigation_overview></navigation_overview>
|
||||
<notable_assets></notable_assets>
|
||||
</global_overview>
|
||||
<pages>
|
||||
<page>
|
||||
<name></name>
|
||||
<summary></summary>
|
||||
<layout>
|
||||
<structure></structure>
|
||||
<sections></sections>
|
||||
<components>
|
||||
<component>
|
||||
<name></name>
|
||||
<role></role>
|
||||
<props></props>
|
||||
<events></events>
|
||||
</component>
|
||||
</components>
|
||||
</layout>
|
||||
<content>
|
||||
<headings>
|
||||
<h1></h1>
|
||||
<h2_list>
|
||||
<h2></h2>
|
||||
</h2_list>
|
||||
</headings>
|
||||
<text_stats>
|
||||
<char_count></char_count>
|
||||
<word_count></word_count>
|
||||
<language_guess></language_guess>
|
||||
</text_stats>
|
||||
<links>
|
||||
<a href="" text=""></a>
|
||||
</links>
|
||||
<forms>
|
||||
<form id="" action="" method="">
|
||||
<fields>
|
||||
<field name="" type="" required=""></field>
|
||||
</fields>
|
||||
<validation></validation>
|
||||
<submit_targets></submit_targets>
|
||||
</form>
|
||||
</forms>
|
||||
<media>
|
||||
<img src="" alt=""></img>
|
||||
<video src="" title=""></video>
|
||||
</media>
|
||||
<tables>
|
||||
<table summary=""></table>
|
||||
</tables>
|
||||
</content>
|
||||
<interactions>
|
||||
<events>
|
||||
<event type="" target="" handler_summary=""></event>
|
||||
</events>
|
||||
<state>
|
||||
<variables>
|
||||
<variable name="" initial=""></variable>
|
||||
</variables>
|
||||
<persistence></persistence>
|
||||
</state>
|
||||
</interactions>
|
||||
<data_flow>
|
||||
<inputs></inputs>
|
||||
<outputs></outputs>
|
||||
<api_calls>
|
||||
<api url="" method="" when=""></api>
|
||||
</api_calls>
|
||||
</data_flow>
|
||||
<style_summary>
|
||||
<inline_styles></inline_styles>
|
||||
<classes></classes>
|
||||
<themes></themes>
|
||||
</style_summary>
|
||||
<script_summary>
|
||||
<libraries></libraries>
|
||||
<modules></modules>
|
||||
<security_notes></security_notes>
|
||||
</script_summary>
|
||||
<seo>
|
||||
<title></title>
|
||||
<meta_description></meta_description>
|
||||
<canonical></canonical>
|
||||
<h_tags></h_tags>
|
||||
</seo>
|
||||
<i18n>
|
||||
<locales_detected></locales_detected>
|
||||
<hardcoded_texts></hardcoded_texts>
|
||||
</i18n>
|
||||
<issues>
|
||||
<problem severity="">
|
||||
<desc></desc>
|
||||
<evidence></evidence>
|
||||
<suggestion></suggestion>
|
||||
</problem>
|
||||
</issues>
|
||||
<complexity score=""></complexity>
|
||||
<confidence score=""></confidence>
|
||||
</page>
|
||||
</pages>
|
||||
</snapshot>
|
||||
|
||||
严格输出规则:
|
||||
- 仅输出上述 XML,不要输出任何解释性文字、代码块符号或 Markdown;
|
||||
- 标签名、层级和顺序必须与模板保持一致;
|
||||
- 允许重复的子节点按需要重复;
|
||||
- 内容以中文撰写;
|
||||
- 不得包含未在输入中出现的臆测信息;
|
||||
- 无法确定的信息保留为空元素。
|
||||
`,
|
||||
prompt: `
|
||||
以下是页面内容:
|
||||
---
|
||||
<pages>
|
||||
${pages.map((page) => `<page_name>${page.name}</page_name><page_content>${page.content}</page_content>`).join('\n --- \n')}
|
||||
</pages>
|
||||
---
|
||||
`,
|
||||
model,
|
||||
abortSignal,
|
||||
});
|
||||
}
|
||||
66
app/lib/.server/llm/switchable-stream.ts
Normal file
66
app/lib/.server/llm/switchable-stream.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
export default class SwitchableStream extends TransformStream {
|
||||
private _controller: TransformStreamDefaultController | null = null;
|
||||
private _currentReader: ReadableStreamDefaultReader | null = null;
|
||||
private _switches = 0;
|
||||
|
||||
constructor() {
|
||||
let controllerRef: TransformStreamDefaultController | undefined;
|
||||
|
||||
super({
|
||||
start(controller) {
|
||||
controllerRef = controller;
|
||||
},
|
||||
});
|
||||
|
||||
if (controllerRef === undefined) {
|
||||
throw new Error('Controller not properly initialized');
|
||||
}
|
||||
|
||||
this._controller = controllerRef;
|
||||
}
|
||||
|
||||
async switchSource(newStream: ReadableStream) {
|
||||
if (this._currentReader) {
|
||||
await this._currentReader.cancel();
|
||||
}
|
||||
|
||||
this._currentReader = newStream.getReader();
|
||||
|
||||
this._pumpStream();
|
||||
|
||||
this._switches++;
|
||||
}
|
||||
|
||||
private async _pumpStream() {
|
||||
if (!this._currentReader || !this._controller) {
|
||||
throw new Error('Stream is not properly initialized');
|
||||
}
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await this._currentReader.read();
|
||||
|
||||
if (done) {
|
||||
break;
|
||||
}
|
||||
|
||||
this._controller.enqueue(value);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
this._controller.error(error);
|
||||
}
|
||||
}
|
||||
|
||||
close() {
|
||||
if (this._currentReader) {
|
||||
this._currentReader.cancel();
|
||||
}
|
||||
|
||||
this._controller?.terminate();
|
||||
}
|
||||
|
||||
get switches() {
|
||||
return this._switches;
|
||||
}
|
||||
}
|
||||
9
app/lib/.server/llm/tools/index.ts
Normal file
9
app/lib/.server/llm/tools/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { serperTool } from './serper';
|
||||
import { weatherTool } from './weather';
|
||||
|
||||
export const tools = {
|
||||
serper: serperTool,
|
||||
weather: weatherTool,
|
||||
};
|
||||
|
||||
export { serperTool, weatherTool };
|
||||
54
app/lib/.server/llm/tools/serper.ts
Normal file
54
app/lib/.server/llm/tools/serper.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* 由于 agentic 暂时不支持 AI SDK v5,因此使用自定义的 Serper 工具。
|
||||
* @see https://docs.agentic.so/marketplace/ts-sdks/ai-sdk
|
||||
*/
|
||||
import { tool } from 'ai';
|
||||
import { z } from 'zod';
|
||||
|
||||
const API_BASE_URL = 'https://google.serper.dev';
|
||||
|
||||
const searchParamsSchema = z.object({
|
||||
q: z.string().describe('搜索查询词'),
|
||||
autocorrect: z.boolean().optional().default(true).describe('是否自动纠正拼写错误'),
|
||||
gl: z.string().optional().default('us').describe('地理位置代码,如"us"表示美国'),
|
||||
hl: z.string().optional().default('en').describe('语言代码,如"en"表示英语'),
|
||||
page: z.number().optional().default(1).describe('页码'),
|
||||
num: z.number().optional().default(10).describe('结果数量'),
|
||||
type: z
|
||||
.enum(['search', 'images', 'videos', 'places', 'news', 'shopping'])
|
||||
.optional()
|
||||
.default('search')
|
||||
.describe('搜索类型'),
|
||||
});
|
||||
|
||||
export const serperTool = tool({
|
||||
description: '使用Google搜索获取最新信息。适用于查找新闻、事实、数据和当前事件等实时信息。',
|
||||
inputSchema: searchParamsSchema,
|
||||
execute: async ({ q, ...params }) => {
|
||||
const apiKey = process.env.SERPER_API_KEY || '';
|
||||
if (!apiKey) {
|
||||
throw new Error('Missing SERPER_API_KEY');
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/search`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-API-KEY': apiKey,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ q, ...params }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Serper API responded with status: ${response.status}`);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (error: unknown) {
|
||||
console.error('Serper API error:', error);
|
||||
const errorMessage = error instanceof Error ? error.message : '未知错误';
|
||||
throw new Error(`搜索失败: ${errorMessage}`);
|
||||
}
|
||||
},
|
||||
});
|
||||
43
app/lib/.server/llm/tools/weather.ts
Normal file
43
app/lib/.server/llm/tools/weather.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* 由于 agentic 暂时不支持 AI SDK v5,因此使用自定义的 Weather 工具。
|
||||
* @see https://docs.agentic.so/marketplace/ts-sdks/ai-sdk
|
||||
*/
|
||||
import { tool } from 'ai';
|
||||
import { z } from 'zod';
|
||||
|
||||
const API_BASE_URL = 'https://api.weatherapi.com/v1';
|
||||
|
||||
const weatherParamsSchema = z.object({
|
||||
q: z
|
||||
.string()
|
||||
.describe('位置查询,可以是城市名称、邮政编码、IP地址或经纬度坐标。必须使用英语或拼音。例如:"London"、"Beijing"'),
|
||||
});
|
||||
|
||||
export const weatherTool = tool({
|
||||
description: '获取指定位置的天气信息',
|
||||
inputSchema: weatherParamsSchema,
|
||||
execute: async ({ q }) => {
|
||||
const apiKey = process.env.WEATHER_API_KEY || '';
|
||||
if (!apiKey) {
|
||||
throw new Error('Missing WEATHER_API_KEY');
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(`${API_BASE_URL}/current.json`);
|
||||
url.searchParams.append('key', apiKey);
|
||||
url.searchParams.append('q', q);
|
||||
|
||||
const response = await fetch(url.toString());
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Weather API responded with status: ${response.status}`);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (error: unknown) {
|
||||
console.error('Weather API error:', error);
|
||||
const errorMessage = error instanceof Error ? error.message : '未知错误';
|
||||
throw new Error(`获取天气信息失败: ${errorMessage}`);
|
||||
}
|
||||
},
|
||||
});
|
||||
92
app/lib/.server/llm/utils.ts
Normal file
92
app/lib/.server/llm/utils.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import type { PageMap, SectionMap } from '~/lib/stores/pages';
|
||||
import type { Section } from '~/types/actions';
|
||||
import type { SummaryAnnotation, UPageUIMessage } from '~/types/message';
|
||||
|
||||
export function getUserMessageContent(message: Omit<UPageUIMessage, 'id'>): string {
|
||||
if (message.role !== 'user') {
|
||||
throw new Error('Message is not a user message');
|
||||
}
|
||||
|
||||
return message.parts
|
||||
.map((part) => {
|
||||
if (part.type === 'text') {
|
||||
return part.text;
|
||||
}
|
||||
return '';
|
||||
})
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
export function simplifyUPageActions(input: string): string {
|
||||
// Using regex to match uPageAction tags that have type="page"
|
||||
const regex = /(<uPageAction[^>]*type="page"[^>]*>)([\s\S]*?)(<\/uPageAction>)/g;
|
||||
|
||||
// Replace each matching occurrence
|
||||
return input.replace(regex, (_0, openingTag, _2, closingTag) => {
|
||||
return `${openingTag}\n ...\n ${closingTag}`;
|
||||
});
|
||||
}
|
||||
|
||||
export function getSectionByPageName(sections: SectionMap) {
|
||||
return Object.values(sections).reduce(
|
||||
(acc, section) => {
|
||||
if (section) {
|
||||
const pageName = section.pageName;
|
||||
acc[pageName] = [...(acc[pageName] || []), section];
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, Section[]>,
|
||||
);
|
||||
}
|
||||
|
||||
export function createPagesContext(pages: PageMap, sections: SectionMap) {
|
||||
const pagePaths = Object.keys(pages);
|
||||
const sectionGroupByPageName = Object.values(sections).reduce(
|
||||
(acc, section) => {
|
||||
if (section) {
|
||||
const pageName = section.pageName;
|
||||
if (pagePaths.includes(pageName)) {
|
||||
acc[section.pageName] = [...(acc[section.pageName] || []), section];
|
||||
}
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, Section[]>,
|
||||
);
|
||||
|
||||
const pageContexts = Object.entries(sectionGroupByPageName).map(([pageName, sections]) => {
|
||||
return `<uPageAction id="${pageName}" title="Code Content">${sections
|
||||
.map((section) => {
|
||||
return `<uPageAction id="${section.domId}" type="page" pageName="${pageName}" action="${section.action}" domId="${section.domId}">${section.content}</uPageAction>`;
|
||||
})
|
||||
.join('\n')}</uPageAction>`;
|
||||
});
|
||||
return pageContexts.join('\n');
|
||||
}
|
||||
|
||||
export function extractCurrentContext(messages: UPageUIMessage[]) {
|
||||
const lastAssistantMessage = messages.filter((x) => x.role == 'assistant').slice(-1)[0];
|
||||
|
||||
if (!lastAssistantMessage) {
|
||||
return { summary: undefined };
|
||||
}
|
||||
|
||||
let summary: SummaryAnnotation | undefined;
|
||||
|
||||
if (!lastAssistantMessage.parts?.length) {
|
||||
return { summary: undefined };
|
||||
}
|
||||
|
||||
const parts = lastAssistantMessage.parts;
|
||||
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
const part = parts[i];
|
||||
if (part.type === 'data-summary') {
|
||||
summary = part.data;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return { summary };
|
||||
}
|
||||
175
app/lib/.server/logger.server.ts
Normal file
175
app/lib/.server/logger.server.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
import { Chalk } from 'chalk';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as winston from 'winston';
|
||||
import DailyRotateFile from 'winston-daily-rotate-file';
|
||||
import type { DebugLevel } from '~/utils/logger';
|
||||
|
||||
const chalk = new Chalk({ level: 3 });
|
||||
|
||||
type LoggerFunction = (...messages: any[]) => void;
|
||||
|
||||
interface Logger {
|
||||
trace: LoggerFunction;
|
||||
debug: LoggerFunction;
|
||||
info: LoggerFunction;
|
||||
warn: LoggerFunction;
|
||||
error: LoggerFunction;
|
||||
setLevel: (level: DebugLevel) => void;
|
||||
}
|
||||
|
||||
let currentLevel: DebugLevel =
|
||||
(process.env.LOG_LEVEL as DebugLevel | undefined) || (import.meta.env.DEV ? 'debug' : 'info');
|
||||
|
||||
// 文件日志配置
|
||||
const enableFileLogging = process.env.USAGE_LOG_FILE === 'true' || import.meta.env.DEV;
|
||||
const logDir = path.join(process.cwd(), 'logs');
|
||||
|
||||
// 确保日志目录存在
|
||||
if (enableFileLogging) {
|
||||
try {
|
||||
if (!fs.existsSync(logDir)) {
|
||||
fs.mkdirSync(logDir, { recursive: true });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to create logs directory:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 创建 Winston logger 实例
|
||||
const winstonLogger = enableFileLogging
|
||||
? winston.createLogger({
|
||||
level: currentLevel,
|
||||
format: winston.format.combine(
|
||||
winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
|
||||
winston.format.printf((info) => {
|
||||
const { timestamp, level, message, scope } = info;
|
||||
return `${timestamp} [${level.toUpperCase()}]${scope ? ` [${scope}]` : ''}: ${message}`;
|
||||
}),
|
||||
),
|
||||
transports: [
|
||||
// 按日期分割的错误日志文件
|
||||
new DailyRotateFile({
|
||||
filename: path.join(logDir, 'error-%DATE%.log'),
|
||||
datePattern: 'YYYY-MM-DD',
|
||||
level: 'error',
|
||||
maxSize: '10m', // 10MB
|
||||
maxFiles: 14, // 保留14天
|
||||
format: winston.format.combine(winston.format.timestamp(), winston.format.json()),
|
||||
}) as winston.transport,
|
||||
// 所有级别日志
|
||||
new DailyRotateFile({
|
||||
filename: path.join(logDir, 'combined-%DATE%.log'),
|
||||
datePattern: 'YYYY-MM-DD',
|
||||
maxSize: '20m', // 20MB
|
||||
maxFiles: 7, // 保留7天
|
||||
format: winston.format.combine(winston.format.timestamp(), winston.format.json()),
|
||||
}) as winston.transport,
|
||||
],
|
||||
})
|
||||
: null;
|
||||
|
||||
export const logger: Logger = {
|
||||
trace: (...messages: any[]) => log('trace', undefined, messages),
|
||||
debug: (...messages: any[]) => log('debug', undefined, messages),
|
||||
info: (...messages: any[]) => log('info', undefined, messages),
|
||||
warn: (...messages: any[]) => log('warn', undefined, messages),
|
||||
error: (...messages: any[]) => log('error', undefined, messages),
|
||||
setLevel,
|
||||
};
|
||||
|
||||
export function createScopedLogger(scope: string): Logger {
|
||||
return {
|
||||
trace: (...messages: any[]) => log('trace', scope, messages),
|
||||
debug: (...messages: any[]) => log('debug', scope, messages),
|
||||
info: (...messages: any[]) => log('info', scope, messages),
|
||||
warn: (...messages: any[]) => log('warn', scope, messages),
|
||||
error: (...messages: any[]) => log('error', scope, messages),
|
||||
setLevel,
|
||||
};
|
||||
}
|
||||
|
||||
function setLevel(level: DebugLevel) {
|
||||
if ((level === 'trace' || level === 'debug') && import.meta.env.PROD) {
|
||||
return;
|
||||
}
|
||||
|
||||
currentLevel = level;
|
||||
|
||||
// 更新 Winston logger 级别
|
||||
if (winstonLogger) {
|
||||
winstonLogger.level = level;
|
||||
}
|
||||
}
|
||||
|
||||
function log(level: DebugLevel, scope: string | undefined, messages: any[]) {
|
||||
const levelOrder: DebugLevel[] = ['trace', 'debug', 'info', 'warn', 'error'];
|
||||
|
||||
if (levelOrder.indexOf(level) < levelOrder.indexOf(currentLevel)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const allMessages = messages.reduce((acc, current) => {
|
||||
if (acc.endsWith('\n')) {
|
||||
return acc + current;
|
||||
}
|
||||
|
||||
if (!acc) {
|
||||
return current;
|
||||
}
|
||||
|
||||
return `${acc} ${current}`;
|
||||
}, '');
|
||||
|
||||
const labelBackgroundColor = getColorForLevel(level);
|
||||
const labelTextColor = level === 'warn' ? '#000000' : '#FFFFFF';
|
||||
|
||||
let labelText = formatText(` ${level.toUpperCase()} `, labelTextColor, labelBackgroundColor);
|
||||
|
||||
if (scope) {
|
||||
labelText = `${labelText} ${formatText(` ${scope} `, '#FFFFFF', '77828D')}`;
|
||||
}
|
||||
|
||||
// 控制台日志
|
||||
console.log(`${labelText}`, allMessages);
|
||||
|
||||
// 写入文件日志
|
||||
if (winstonLogger) {
|
||||
try {
|
||||
winstonLogger.log({
|
||||
level,
|
||||
message: allMessages,
|
||||
scope,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to write to log file:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function formatText(text: string, color: string, bg: string) {
|
||||
return chalk.bgHex(bg)(chalk.hex(color)(text));
|
||||
}
|
||||
|
||||
function getColorForLevel(level: DebugLevel): string {
|
||||
switch (level) {
|
||||
case 'trace':
|
||||
case 'debug': {
|
||||
return '#77828D';
|
||||
}
|
||||
case 'info': {
|
||||
return '#1389FD';
|
||||
}
|
||||
case 'warn': {
|
||||
return '#FFDB6C';
|
||||
}
|
||||
case 'error': {
|
||||
return '#EE4744';
|
||||
}
|
||||
default: {
|
||||
return '#000000';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const renderLogger = createScopedLogger('Render');
|
||||
175
app/lib/.server/logger.ts
Normal file
175
app/lib/.server/logger.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
import { Chalk } from 'chalk';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as winston from 'winston';
|
||||
import DailyRotateFile from 'winston-daily-rotate-file';
|
||||
import type { DebugLevel } from '~/utils/logger';
|
||||
|
||||
const chalk = new Chalk({ level: 3 });
|
||||
|
||||
type LoggerFunction = (...messages: any[]) => void;
|
||||
|
||||
interface Logger {
|
||||
trace: LoggerFunction;
|
||||
debug: LoggerFunction;
|
||||
info: LoggerFunction;
|
||||
warn: LoggerFunction;
|
||||
error: LoggerFunction;
|
||||
setLevel: (level: DebugLevel) => void;
|
||||
}
|
||||
|
||||
let currentLevel: DebugLevel =
|
||||
(process.env.LOG_LEVEL as DebugLevel | undefined) || (import.meta.env.DEV ? 'debug' : 'info');
|
||||
|
||||
// 文件日志配置
|
||||
const enableFileLogging = process.env.USAGE_LOG_FILE === 'true' || import.meta.env.DEV;
|
||||
const logDir = path.join(process.cwd(), 'logs');
|
||||
|
||||
// 确保日志目录存在
|
||||
if (enableFileLogging) {
|
||||
try {
|
||||
if (!fs.existsSync(logDir)) {
|
||||
fs.mkdirSync(logDir, { recursive: true });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to create logs directory:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 创建 Winston logger 实例
|
||||
const winstonLogger = enableFileLogging
|
||||
? winston.createLogger({
|
||||
level: currentLevel,
|
||||
format: winston.format.combine(
|
||||
winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
|
||||
winston.format.printf((info) => {
|
||||
const { timestamp, level, message, scope } = info;
|
||||
return `${timestamp} [${level.toUpperCase()}]${scope ? ` [${scope}]` : ''}: ${message}`;
|
||||
}),
|
||||
),
|
||||
transports: [
|
||||
// 按日期分割的错误日志文件
|
||||
new DailyRotateFile({
|
||||
filename: path.join(logDir, 'error-%DATE%.log'),
|
||||
datePattern: 'YYYY-MM-DD',
|
||||
level: 'error',
|
||||
maxSize: '10m', // 10MB
|
||||
maxFiles: 14, // 保留14天
|
||||
format: winston.format.combine(winston.format.timestamp(), winston.format.json()),
|
||||
}) as winston.transport,
|
||||
// 所有级别日志
|
||||
new DailyRotateFile({
|
||||
filename: path.join(logDir, 'combined-%DATE%.log'),
|
||||
datePattern: 'YYYY-MM-DD',
|
||||
maxSize: '20m', // 20MB
|
||||
maxFiles: 7, // 保留7天
|
||||
format: winston.format.combine(winston.format.timestamp(), winston.format.json()),
|
||||
}) as winston.transport,
|
||||
],
|
||||
})
|
||||
: null;
|
||||
|
||||
export const logger: Logger = {
|
||||
trace: (...messages: any[]) => log('trace', undefined, messages),
|
||||
debug: (...messages: any[]) => log('debug', undefined, messages),
|
||||
info: (...messages: any[]) => log('info', undefined, messages),
|
||||
warn: (...messages: any[]) => log('warn', undefined, messages),
|
||||
error: (...messages: any[]) => log('error', undefined, messages),
|
||||
setLevel,
|
||||
};
|
||||
|
||||
export function createScopedLogger(scope: string): Logger {
|
||||
return {
|
||||
trace: (...messages: any[]) => log('trace', scope, messages),
|
||||
debug: (...messages: any[]) => log('debug', scope, messages),
|
||||
info: (...messages: any[]) => log('info', scope, messages),
|
||||
warn: (...messages: any[]) => log('warn', scope, messages),
|
||||
error: (...messages: any[]) => log('error', scope, messages),
|
||||
setLevel,
|
||||
};
|
||||
}
|
||||
|
||||
function setLevel(level: DebugLevel) {
|
||||
if ((level === 'trace' || level === 'debug') && import.meta.env.PROD) {
|
||||
return;
|
||||
}
|
||||
|
||||
currentLevel = level;
|
||||
|
||||
// 更新 Winston logger 级别
|
||||
if (winstonLogger) {
|
||||
winstonLogger.level = level;
|
||||
}
|
||||
}
|
||||
|
||||
function log(level: DebugLevel, scope: string | undefined, messages: any[]) {
|
||||
const levelOrder: DebugLevel[] = ['trace', 'debug', 'info', 'warn', 'error'];
|
||||
|
||||
if (levelOrder.indexOf(level) < levelOrder.indexOf(currentLevel)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const allMessages = messages.reduce((acc, current) => {
|
||||
if (acc.endsWith('\n')) {
|
||||
return acc + current;
|
||||
}
|
||||
|
||||
if (!acc) {
|
||||
return current;
|
||||
}
|
||||
|
||||
return `${acc} ${current}`;
|
||||
}, '');
|
||||
|
||||
const labelBackgroundColor = getColorForLevel(level);
|
||||
const labelTextColor = level === 'warn' ? '#000000' : '#FFFFFF';
|
||||
|
||||
let labelText = formatText(` ${level.toUpperCase()} `, labelTextColor, labelBackgroundColor);
|
||||
|
||||
if (scope) {
|
||||
labelText = `${labelText} ${formatText(` ${scope} `, '#FFFFFF', '77828D')}`;
|
||||
}
|
||||
|
||||
// 控制台日志
|
||||
console.log(`${labelText}`, allMessages);
|
||||
|
||||
// 写入文件日志
|
||||
if (winstonLogger) {
|
||||
try {
|
||||
winstonLogger.log({
|
||||
level,
|
||||
message: allMessages,
|
||||
scope,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to write to log file:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function formatText(text: string, color: string, bg: string) {
|
||||
return chalk.bgHex(bg)(chalk.hex(color)(text));
|
||||
}
|
||||
|
||||
function getColorForLevel(level: DebugLevel): string {
|
||||
switch (level) {
|
||||
case 'trace':
|
||||
case 'debug': {
|
||||
return '#77828D';
|
||||
}
|
||||
case 'info': {
|
||||
return '#1389FD';
|
||||
}
|
||||
case 'warn': {
|
||||
return '#FFDB6C';
|
||||
}
|
||||
case 'error': {
|
||||
return '#EE4744';
|
||||
}
|
||||
default: {
|
||||
return '#000000';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const renderLogger = createScopedLogger('Render');
|
||||
317
app/lib/.server/message.ts
Normal file
317
app/lib/.server/message.ts
Normal file
@@ -0,0 +1,317 @@
|
||||
import type { Message } from '@prisma/client';
|
||||
import type { JsonArray } from '@prisma/client/runtime/library';
|
||||
import type { TextUIPart, UIMessagePart } from 'ai';
|
||||
import { createScopedLogger } from '~/lib/.server/logger';
|
||||
import { prisma } from '~/lib/.server/prisma';
|
||||
import type { SummaryAnnotation, UPageDataParts, UPageUIMessage } from '~/types/message';
|
||||
|
||||
const logger = createScopedLogger('message.server');
|
||||
|
||||
/**
|
||||
* 消息创建参数接口
|
||||
*/
|
||||
export interface MessageCreateParams {
|
||||
chatId: string;
|
||||
userId: string;
|
||||
role: string;
|
||||
content: string;
|
||||
revisionId?: string;
|
||||
annotations?: any[];
|
||||
version?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 消息更新参数接口
|
||||
*/
|
||||
export interface MessageUpdateParams {
|
||||
content?: string;
|
||||
revisionId?: string;
|
||||
annotations?: any[];
|
||||
version?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 消息创建或更新参数接口
|
||||
*/
|
||||
export interface MessageUpsertParams {
|
||||
id: string;
|
||||
chatId: string;
|
||||
userId: string;
|
||||
role: string;
|
||||
content: string;
|
||||
revisionId?: string;
|
||||
annotations?: any[];
|
||||
version?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据ID创建或更新消息(upsert操作)
|
||||
* @param params 消息创建或更新参数
|
||||
* @returns 创建或更新后的消息记录
|
||||
*/
|
||||
export async function upsertMessage(params: MessageUpsertParams) {
|
||||
const { id, chatId, userId, role, content, revisionId, annotations } = params;
|
||||
|
||||
try {
|
||||
const message = await prisma.message.upsert({
|
||||
where: { id },
|
||||
update: {
|
||||
content,
|
||||
revisionId,
|
||||
annotations,
|
||||
},
|
||||
create: {
|
||||
id,
|
||||
chatId,
|
||||
userId,
|
||||
role,
|
||||
content,
|
||||
revisionId,
|
||||
annotations,
|
||||
},
|
||||
});
|
||||
|
||||
logger.info(`[Message] 创建或更新了消息 ${id}`);
|
||||
return message;
|
||||
} catch (error) {
|
||||
logger.error(`[Message] 创建或更新消息 ${id} 失败:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新消息为遗弃消息。
|
||||
*
|
||||
* 此方法将会更新同一 {@param chatId} 下 startMessageId(不含)与 endMessageId 之间(不含)的所有消息为遗弃消息。
|
||||
*
|
||||
* @param chatId 聊天ID
|
||||
* @param startMessageId 开始消息ID
|
||||
* @param endMessageId 结束消息ID
|
||||
*/
|
||||
export async function updateDiscardedMessage(chatId: string, startMessageId: string) {
|
||||
try {
|
||||
const startMessage = await prisma.message.findUnique({
|
||||
where: { id: startMessageId },
|
||||
select: { createdAt: true },
|
||||
});
|
||||
|
||||
if (!startMessage) {
|
||||
logger.error(`[Message] 找不到开始消息 ${startMessageId}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
// 更新 startMessageId 之后的所有消息为遗弃消息
|
||||
const result = await prisma.message.updateMany({
|
||||
where: {
|
||||
chatId,
|
||||
createdAt: {
|
||||
gt: startMessage?.createdAt,
|
||||
},
|
||||
},
|
||||
data: {
|
||||
isDiscarded: true,
|
||||
},
|
||||
});
|
||||
|
||||
logger.info(`[Message] 已将聊天 ${chatId} 中 ${startMessageId} 之后的 ${result.count} 条消息标记为遗弃`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error(`[Message] 更新遗弃消息失败:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取历史聊天消息接口参数
|
||||
*/
|
||||
export interface GetHistoryChatMessagesParams {
|
||||
chatId: string;
|
||||
rewindTo?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取从第一条消息到指定消息之间的所有历史消息
|
||||
* @param params 包含 chatId 和可选的 rewindTo 参数
|
||||
* @returns 消息记录列表
|
||||
*/
|
||||
export async function getHistoryChatMessages(params: GetHistoryChatMessagesParams): Promise<UPageUIMessage[]> {
|
||||
const { chatId, rewindTo } = params;
|
||||
|
||||
try {
|
||||
// 如果指定了 rewindTo,则获取该消息的创建时间
|
||||
if (rewindTo) {
|
||||
const rewindToMessage = await prisma.message.findUnique({
|
||||
where: { id: rewindTo },
|
||||
select: { createdAt: true },
|
||||
});
|
||||
|
||||
if (!rewindToMessage) {
|
||||
logger.warn(`[Message] 获取历史消息: 找不到指定的 rewindTo 消息 ${rewindTo}`);
|
||||
// 如果找不到指定消息,则返回所有消息
|
||||
return await getAllChatMessages(chatId);
|
||||
}
|
||||
|
||||
// 获取所有在 rewindTo 消息创建时间之前(包括该消息)的消息
|
||||
const messages = await prisma.message.findMany({
|
||||
where: {
|
||||
chatId,
|
||||
isDiscarded: false,
|
||||
createdAt: {
|
||||
lte: rewindToMessage.createdAt,
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'asc',
|
||||
},
|
||||
});
|
||||
|
||||
logger.info(`[Message] 获取了聊天 ${chatId} 中直到消息 ${rewindTo} 的 ${messages.length} 条历史消息`);
|
||||
return messages.map(convertToUIMessage);
|
||||
} else {
|
||||
// 如果没有指定 rewindTo,则获取所有消息
|
||||
return await getAllChatMessages(chatId);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`[Message] 获取聊天 ${chatId} 的历史消息失败:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function convertToUIMessage(message: Message): UPageUIMessage {
|
||||
if (message.version === 2) {
|
||||
return {
|
||||
id: message.id,
|
||||
role: message.role as 'user' | 'assistant',
|
||||
parts: message.parts as any[],
|
||||
metadata: message.metadata as any,
|
||||
};
|
||||
}
|
||||
|
||||
const parts: UIMessagePart<UPageDataParts, never>[] = [];
|
||||
if (message.role === 'user') {
|
||||
const content = JSON.parse(message.content) as TextUIPart;
|
||||
parts.push({
|
||||
type: 'text',
|
||||
text: content.text,
|
||||
});
|
||||
} else {
|
||||
parts.push({
|
||||
type: 'text',
|
||||
text: message.content,
|
||||
});
|
||||
}
|
||||
|
||||
if (message.annotations) {
|
||||
const messageAnnotations = message.annotations as JsonArray;
|
||||
messageAnnotations.forEach((annotation) => {
|
||||
const { type } = annotation as { type: string };
|
||||
if (type === 'chatSummary') {
|
||||
parts.push({
|
||||
type: 'data-summary',
|
||||
data: annotation as unknown as SummaryAnnotation,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
return {
|
||||
id: message.id,
|
||||
role: message.role as 'user' | 'assistant',
|
||||
parts,
|
||||
metadata: message.metadata as any,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取聊天的所有消息(内部辅助方法)
|
||||
* @param chatId 聊天ID
|
||||
* @returns 消息记录列表
|
||||
*/
|
||||
async function getAllChatMessages(chatId: string): Promise<UPageUIMessage[]> {
|
||||
const messages = await prisma.message.findMany({
|
||||
where: {
|
||||
chatId,
|
||||
isDiscarded: false,
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'asc',
|
||||
},
|
||||
});
|
||||
|
||||
logger.info(`[Message] 获取了聊天 ${chatId} 的所有 ${messages.length} 条历史消息`);
|
||||
return messages.map(convertToUIMessage);
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存聊天消息列表到数据库
|
||||
* @param chatId 聊天ID
|
||||
* @param messages 消息列表(UPageUIMessage[])
|
||||
* @returns 保存结果
|
||||
*/
|
||||
export async function saveChatMessages(chatId: string, messages: UPageUIMessage[]): Promise<number> {
|
||||
if (!messages || messages.length === 0) {
|
||||
logger.warn('[Message] 保存聊天消息: 没有提供消息数据');
|
||||
return 0;
|
||||
}
|
||||
|
||||
try {
|
||||
// 获取聊天的用户ID
|
||||
const chat = await prisma.chat.findUnique({
|
||||
where: { id: chatId },
|
||||
select: { userId: true },
|
||||
});
|
||||
|
||||
if (!chat) {
|
||||
logger.error(`[Message] 保存聊天消息: 找不到聊天 ${chatId}`);
|
||||
throw new Error(`找不到聊天 ${chatId}`);
|
||||
}
|
||||
|
||||
const userId = chat.userId;
|
||||
let savedCount = 0;
|
||||
|
||||
// 逐条保存消息
|
||||
for (const message of messages) {
|
||||
// 跳过没有ID的消息
|
||||
if (!message.id) {
|
||||
logger.warn('[Message] 保存聊天消息: 跳过没有ID的消息');
|
||||
continue;
|
||||
}
|
||||
|
||||
// 提取消息的文本内容
|
||||
const textPart = message.parts.find((part) => part.type === 'text');
|
||||
const content = textPart?.text || '';
|
||||
|
||||
// 创建或更新消息
|
||||
const updateData: any = {
|
||||
content,
|
||||
parts: message.parts,
|
||||
metadata: message.metadata,
|
||||
version: 2,
|
||||
};
|
||||
|
||||
const createData: any = {
|
||||
id: message.id,
|
||||
chatId,
|
||||
userId,
|
||||
role: message.role,
|
||||
content,
|
||||
parts: message.parts,
|
||||
metadata: message.metadata,
|
||||
version: 2,
|
||||
};
|
||||
|
||||
await prisma.message.upsert({
|
||||
where: { id: message.id },
|
||||
update: updateData,
|
||||
create: createData,
|
||||
});
|
||||
|
||||
savedCount++;
|
||||
}
|
||||
|
||||
logger.info(`[Message] 成功保存了聊天 ${chatId} 的 ${savedCount} 条消息`);
|
||||
return savedCount;
|
||||
} catch (error) {
|
||||
logger.error(`[Message] 保存聊天消息失败:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
278
app/lib/.server/page.ts
Normal file
278
app/lib/.server/page.ts
Normal file
@@ -0,0 +1,278 @@
|
||||
import type { JsonArray, JsonObject } from '@prisma/client/runtime/library';
|
||||
import type { Page } from '~/types/actions';
|
||||
import { createScopedLogger } from './logger';
|
||||
import { prisma } from './prisma';
|
||||
|
||||
const logger = createScopedLogger('page.server');
|
||||
|
||||
/**
|
||||
* 页面创建参数接口
|
||||
*/
|
||||
export interface PageCreateParams extends Page {
|
||||
messageId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 页面更新参数接口
|
||||
*/
|
||||
export interface PageUpdateParams {
|
||||
pages?: Page[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建新的页面
|
||||
* @param params 页面创建参数
|
||||
* @returns 创建的页面记录
|
||||
*/
|
||||
export async function createPage(params: PageCreateParams) {
|
||||
const { messageId, name, title, content, actionIds } = params;
|
||||
|
||||
try {
|
||||
const pageData = [
|
||||
{
|
||||
name,
|
||||
title,
|
||||
content,
|
||||
actionIds,
|
||||
},
|
||||
];
|
||||
|
||||
const page = await prisma.page.create({
|
||||
data: {
|
||||
messageId,
|
||||
pages: JSON.parse(JSON.stringify(pageData)),
|
||||
},
|
||||
});
|
||||
|
||||
logger.info(`[Page] 创建了消息 ${messageId} 的页面: ${page.id}`);
|
||||
return page;
|
||||
} catch (error) {
|
||||
logger.error('[Page] 创建页面失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建或更新页面
|
||||
* @param params 页面创建参数
|
||||
* @returns 创建或更新的页面记录
|
||||
*/
|
||||
export async function createOrUpdatePage(params: PageCreateParams) {
|
||||
const { messageId, name, title, content, actionIds } = params;
|
||||
|
||||
try {
|
||||
const existingPage = await getPageByMessageId(messageId);
|
||||
|
||||
if (existingPage) {
|
||||
const updatedPage = await updatePageByMessageId(messageId, {
|
||||
pages: [
|
||||
{
|
||||
name,
|
||||
title,
|
||||
content,
|
||||
actionIds,
|
||||
},
|
||||
],
|
||||
});
|
||||
return updatedPage;
|
||||
}
|
||||
const newPage = await createPage(params);
|
||||
return newPage;
|
||||
} catch (error) {
|
||||
logger.error('[Page] 创建或更新页面失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建多个页面
|
||||
* @param messageId 消息ID
|
||||
* @param pages 页面数组
|
||||
* @returns 创建的页面记录
|
||||
*/
|
||||
export async function createPages(messageId: string, pages: Page[]) {
|
||||
try {
|
||||
const page = await prisma.page.create({
|
||||
data: {
|
||||
messageId,
|
||||
pages: JSON.parse(JSON.stringify(pages)),
|
||||
},
|
||||
});
|
||||
|
||||
logger.info(`[Page] 为消息 ${messageId} 创建了 ${pages.length} 个页面: ${page.id}`);
|
||||
return page;
|
||||
} catch (error) {
|
||||
logger.error('[Page] 创建多个页面失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建或更新多个页面
|
||||
* @param messageId 消息ID
|
||||
* @param pages 页面数组
|
||||
* @returns 创建或更新的页面记录
|
||||
*/
|
||||
export async function createOrUpdatePages(messageId: string, pages: Page[]) {
|
||||
try {
|
||||
const existingPage = await getPageByMessageId(messageId);
|
||||
if (existingPage) {
|
||||
const updatedPage = await updatePageByMessageId(messageId, { pages });
|
||||
return updatedPage;
|
||||
}
|
||||
const newPage = await createPages(messageId, pages);
|
||||
return newPage;
|
||||
} catch (error) {
|
||||
logger.error('[Page] 创建或更新多个页面失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据ID获取页面
|
||||
* @param id 页面ID
|
||||
* @returns 页面记录
|
||||
*/
|
||||
export async function getPageById(id: string) {
|
||||
try {
|
||||
const page = await prisma.page.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
return page;
|
||||
} catch (error) {
|
||||
logger.error(`[Page] 获取页面 ${id} 失败:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据消息ID获取页面
|
||||
* @param messageId 消息ID
|
||||
* @returns 页面记录
|
||||
*/
|
||||
export async function getPageByMessageId(messageId: string) {
|
||||
try {
|
||||
const page = await prisma.page.findUnique({
|
||||
where: { messageId },
|
||||
});
|
||||
|
||||
return page;
|
||||
} catch (error) {
|
||||
logger.error(`[Page] 获取消息 ${messageId} 的页面失败:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getPageByMessageIdAndName(messageId: string, name: string): Promise<JsonObject | null> {
|
||||
try {
|
||||
const page = await getPageByMessageId(messageId);
|
||||
if (!page) {
|
||||
return null;
|
||||
}
|
||||
const pages = page.pages as JsonArray;
|
||||
const pageData = pages.find((p) => {
|
||||
const page = p as JsonObject;
|
||||
return page.name === name;
|
||||
});
|
||||
if (!pageData) {
|
||||
return null;
|
||||
}
|
||||
return pageData as JsonObject;
|
||||
} catch (error) {
|
||||
logger.error(`[Page] 获取消息 ${messageId} 的页面 ${name} 失败:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新页面信息
|
||||
* @param id 页面ID
|
||||
* @param params 更新参数
|
||||
* @returns 更新后的页面记录
|
||||
*/
|
||||
export async function updatePage(id: string, params: PageUpdateParams) {
|
||||
try {
|
||||
const updateData: any = {};
|
||||
|
||||
if (params.pages) {
|
||||
updateData.pages = JSON.parse(JSON.stringify(params.pages));
|
||||
}
|
||||
|
||||
const updatedPage = await prisma.page.update({
|
||||
where: { id },
|
||||
data: updateData,
|
||||
});
|
||||
|
||||
logger.info(`[Page] 更新了页面 ${id}`);
|
||||
return updatedPage;
|
||||
} catch (error) {
|
||||
logger.error(`[Page] 更新页面 ${id} 失败:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据消息ID更新页面信息
|
||||
* @param messageId 消息ID
|
||||
* @param params 更新参数
|
||||
* @returns 更新后的页面记录
|
||||
*/
|
||||
export async function updatePageByMessageId(messageId: string, params: PageUpdateParams) {
|
||||
try {
|
||||
const updateData: any = {};
|
||||
|
||||
if (params.pages) {
|
||||
updateData.pages = JSON.parse(JSON.stringify(params.pages));
|
||||
}
|
||||
|
||||
const updatedPage = await prisma.page.update({
|
||||
where: { messageId },
|
||||
data: updateData,
|
||||
});
|
||||
|
||||
logger.info(`[Page] 更新了消息 ${messageId} 的页面`);
|
||||
return updatedPage;
|
||||
} catch (error) {
|
||||
logger.error(`[Page] 更新消息 ${messageId} 的页面失败:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除页面
|
||||
* @param id 页面ID
|
||||
* @returns 删除结果
|
||||
*/
|
||||
export async function deletePage(id: string) {
|
||||
try {
|
||||
await prisma.page.delete({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
logger.info(`[Page] 删除了页面 ${id}`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error(`[Page] 删除页面 ${id} 失败:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据消息ID删除页面
|
||||
* @param messageId 消息ID
|
||||
* @returns 删除结果
|
||||
*/
|
||||
export async function deletePageByMessageId(messageId: string) {
|
||||
try {
|
||||
await prisma.page.delete({
|
||||
where: { messageId },
|
||||
});
|
||||
|
||||
logger.info(`[Page] 删除了消息 ${messageId} 的页面`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error(`[Page] 删除消息 ${messageId} 的页面失败:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
22
app/lib/.server/prisma.ts
Normal file
22
app/lib/.server/prisma.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
// 创建PrismaClient实例
|
||||
let prisma: PrismaClient;
|
||||
|
||||
declare global {
|
||||
var __db: PrismaClient | undefined;
|
||||
}
|
||||
|
||||
// 在开发环境中使用全局变量,避免热重载时创建多个实例
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
prisma = new PrismaClient();
|
||||
} else {
|
||||
if (!global.__db) {
|
||||
global.__db = new PrismaClient({
|
||||
log: ['query', 'error', 'warn'],
|
||||
});
|
||||
}
|
||||
prisma = global.__db;
|
||||
}
|
||||
|
||||
export { prisma };
|
||||
142
app/lib/.server/projectService.ts
Normal file
142
app/lib/.server/projectService.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
import { createScopedLogger } from '~/lib/.server/logger';
|
||||
import type { Page } from '~/types/actions';
|
||||
import { createOrUpdatePages, getPageByMessageId } from './page';
|
||||
import { createSection, deleteMessageSections, getMessageSections, type SectionCreateParams } from './section';
|
||||
|
||||
const logger = createScopedLogger('projectService');
|
||||
|
||||
/**
|
||||
* 保存项目数据接口
|
||||
*/
|
||||
export interface SaveProjectParams {
|
||||
messageId: string;
|
||||
projectData: Record<string, any>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存项目部分接口
|
||||
*/
|
||||
export interface SaveSectionsParams {
|
||||
messageId: string;
|
||||
sections: SectionCreateParams[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存页面数据接口
|
||||
*/
|
||||
export interface SavePagesParams {
|
||||
messageId: string;
|
||||
pages: Page[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存项目和部分数据接口
|
||||
*/
|
||||
export interface SaveProjectAndSectionsParams {
|
||||
messageId: string;
|
||||
projectData: Record<string, any>;
|
||||
sections: SectionCreateParams[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存页面和部分数据接口
|
||||
*/
|
||||
export interface SavePagesAndSectionsParams {
|
||||
messageId: string;
|
||||
pages: Page[];
|
||||
sections: SectionCreateParams[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存页面数据
|
||||
* @param params 保存页面参数
|
||||
* @returns 保存结果
|
||||
*/
|
||||
export async function savePages(params: SavePagesParams) {
|
||||
const { messageId, pages } = params;
|
||||
|
||||
try {
|
||||
// 检查页面是否已存在
|
||||
const existingPage = await getPageByMessageId(messageId);
|
||||
|
||||
// 创建或更新页面
|
||||
const page = await createOrUpdatePages(messageId, pages);
|
||||
|
||||
if (existingPage) {
|
||||
logger.info(`更新了消息 ${messageId} 的页面`);
|
||||
return { success: true, message: '页面已更新', id: page.id };
|
||||
} else {
|
||||
logger.info(`创建了消息 ${messageId} 的页面: ${page.id}`);
|
||||
return { success: true, message: '页面已创建', id: page.id };
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('保存页面数据失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存项目部分数据
|
||||
* @param params 保存部分参数
|
||||
* @returns 保存结果
|
||||
*/
|
||||
export async function saveSections(params: SaveSectionsParams) {
|
||||
const { messageId, sections } = params;
|
||||
|
||||
try {
|
||||
// 获取现有部分
|
||||
const existingSections = await getMessageSections(messageId);
|
||||
|
||||
// 如果有现有部分,则先删除
|
||||
if (existingSections.length > 0) {
|
||||
await deleteMessageSections(messageId);
|
||||
logger.info(`删除了消息 ${messageId} 的现有部分数据`);
|
||||
}
|
||||
|
||||
// 创建新部分
|
||||
const createdSections = await Promise.all(
|
||||
sections.map((section) =>
|
||||
createSection({
|
||||
...section,
|
||||
messageId,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
logger.info(`为消息 ${messageId} 创建了 ${createdSections.length} 个部分`);
|
||||
return {
|
||||
success: true,
|
||||
message: `已保存 ${createdSections.length} 个部分`,
|
||||
count: createdSections.length,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('保存部分数据失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存页面和部分数据
|
||||
* @param params 保存页面和部分参数
|
||||
* @returns 保存结果
|
||||
*/
|
||||
export async function savePagesAndSections(params: SavePagesAndSectionsParams) {
|
||||
const { messageId, pages, sections } = params;
|
||||
|
||||
try {
|
||||
// 保存页面数据
|
||||
const pagesResult = await savePages({ messageId, pages });
|
||||
|
||||
// 保存部分数据
|
||||
const sectionsResult = await saveSections({ messageId, sections });
|
||||
|
||||
return {
|
||||
success: true,
|
||||
pages: pagesResult,
|
||||
sections: sectionsResult,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('保存页面和部分数据失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
234
app/lib/.server/section.ts
Normal file
234
app/lib/.server/section.ts
Normal file
@@ -0,0 +1,234 @@
|
||||
import { createScopedLogger } from '~/lib/.server/logger';
|
||||
import { prisma } from '~/lib/.server/prisma';
|
||||
import type { Section } from '~/types/actions';
|
||||
|
||||
const logger = createScopedLogger('section.server');
|
||||
|
||||
/**
|
||||
* section 创建参数接口
|
||||
*/
|
||||
export interface SectionCreateParams extends Section {
|
||||
messageId: string;
|
||||
actionId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* section 更新参数接口
|
||||
*/
|
||||
export interface SectionUpdateParams {
|
||||
type?: string;
|
||||
action?: string;
|
||||
actionId?: string;
|
||||
pageName?: string;
|
||||
content?: string;
|
||||
domId?: string;
|
||||
rootDomId?: string;
|
||||
sort?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建新的 section
|
||||
* @param params section 创建参数
|
||||
* @returns 创建的 section 记录
|
||||
*/
|
||||
export async function createSection(params: SectionCreateParams) {
|
||||
const { messageId, action = 'add', actionId, pageName = '', content, domId, rootDomId, sort = 0 } = params;
|
||||
|
||||
try {
|
||||
const section = await prisma.section.create({
|
||||
data: {
|
||||
messageId,
|
||||
action,
|
||||
actionId,
|
||||
pageName,
|
||||
content,
|
||||
domId,
|
||||
rootDomId,
|
||||
sort,
|
||||
},
|
||||
});
|
||||
|
||||
logger.info(`[Section] 创建了消息 ${messageId} 的 section : ${section.id}`);
|
||||
return section;
|
||||
} catch (error) {
|
||||
logger.error('[Section] 创建 section 失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量创建多个 section
|
||||
* @param params section 创建参数数组
|
||||
* @returns 创建的 section 数量
|
||||
*/
|
||||
export async function createManySections(params: SectionCreateParams[]) {
|
||||
if (!params || params.length === 0) {
|
||||
logger.warn('[Section] 批量创建 section : 没有提供 section 数据');
|
||||
return 0;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await prisma.section.createMany({
|
||||
data: params.map(
|
||||
({ messageId, action = 'add', actionId, pageName = '', content, domId, rootDomId, sort = 0 }) => ({
|
||||
messageId,
|
||||
action,
|
||||
actionId,
|
||||
pageName,
|
||||
content,
|
||||
domId,
|
||||
rootDomId,
|
||||
sort,
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
logger.info(`[Section] 批量创建了 ${result.count} 个 section `);
|
||||
return result.count;
|
||||
} catch (error) {
|
||||
logger.error('[Section] 批量创建 section 失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据ID获取 section
|
||||
* @param id section ID
|
||||
* @returns section 记录
|
||||
*/
|
||||
export async function getSectionById(id: string) {
|
||||
try {
|
||||
const section = await prisma.section.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
return section;
|
||||
} catch (error) {
|
||||
logger.error(`[Section] 获取 section ${id} 失败:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取消息的所有 section
|
||||
* @param messageId 消息ID
|
||||
* @returns section 记录列表
|
||||
*/
|
||||
export async function getMessageSections(messageId: string) {
|
||||
try {
|
||||
const sections = await prisma.section.findMany({
|
||||
where: { messageId },
|
||||
orderBy: {
|
||||
sort: 'asc',
|
||||
},
|
||||
});
|
||||
|
||||
return sections;
|
||||
} catch (error) {
|
||||
logger.error(`[Section] 获取消息 ${messageId} 的 section 列表失败:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据DOM ID获取 section
|
||||
* @param domId DOM ID
|
||||
* @returns section 记录
|
||||
*/
|
||||
export async function getSectionByDomId(domId: string) {
|
||||
try {
|
||||
const sections = await prisma.section.findMany({
|
||||
where: { domId },
|
||||
orderBy: {
|
||||
updatedAt: 'desc',
|
||||
},
|
||||
});
|
||||
|
||||
return sections;
|
||||
} catch (error) {
|
||||
logger.error(`[Section] 获取DOM ID ${domId} 的 section 失败:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取特定页面的所有 section
|
||||
* @param pageName 页面名称
|
||||
* @returns section 记录列表
|
||||
*/
|
||||
export async function getPageSections(pageName: string) {
|
||||
try {
|
||||
const sections = await prisma.section.findMany({
|
||||
where: { pageName },
|
||||
orderBy: {
|
||||
sort: 'asc',
|
||||
},
|
||||
});
|
||||
|
||||
return sections;
|
||||
} catch (error) {
|
||||
logger.error(`[Section] 获取页面 ${pageName} 的 section 列表失败:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新 section 信息
|
||||
* @param id section ID
|
||||
* @param params 更新参数
|
||||
* @returns 更新后的 section 记录
|
||||
*/
|
||||
export async function updateSection(id: string, params: SectionUpdateParams) {
|
||||
try {
|
||||
const updatedSection = await prisma.section.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...params,
|
||||
},
|
||||
});
|
||||
|
||||
logger.info(`[Section] 更新了 section ${id}`);
|
||||
return updatedSection;
|
||||
} catch (error) {
|
||||
logger.error(`[Section] 更新 section ${id} 失败:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除 section
|
||||
* @param id section ID
|
||||
* @returns 删除结果
|
||||
*/
|
||||
export async function deleteSection(id: string) {
|
||||
try {
|
||||
await prisma.section.delete({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
logger.info(`[Section] 删除了 section ${id}`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error(`[Section] 删除 section ${id} 失败:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除消息的所有 section
|
||||
* @param messageId 消息ID
|
||||
* @returns 删除结果
|
||||
*/
|
||||
export async function deleteMessageSections(messageId: string) {
|
||||
try {
|
||||
const result = await prisma.section.deleteMany({
|
||||
where: { messageId },
|
||||
});
|
||||
|
||||
logger.info(`[Section] 删除了消息 ${messageId} 的 ${result.count} 个 section `);
|
||||
return result.count > 0;
|
||||
} catch (error) {
|
||||
logger.error(`[Section] 删除消息 ${messageId} 的 section 失败:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
181
app/lib/.server/userSettings.ts
Normal file
181
app/lib/.server/userSettings.ts
Normal file
@@ -0,0 +1,181 @@
|
||||
import { prisma } from '~/lib/.server/prisma';
|
||||
import { createScopedLogger } from '~/utils/logger';
|
||||
|
||||
const logger = createScopedLogger('userSettings.server');
|
||||
|
||||
/**
|
||||
* 用户设置创建/更新参数接口
|
||||
*/
|
||||
export interface UserSettingParams {
|
||||
userId: string;
|
||||
category: string;
|
||||
key: string;
|
||||
value: string;
|
||||
isSecret?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户设置查询参数接口
|
||||
*/
|
||||
export interface UserSettingQueryParams {
|
||||
userId: string;
|
||||
category?: string;
|
||||
key?: string;
|
||||
includeSecrets?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建或更新用户设置
|
||||
*
|
||||
* @param params 用户设置参数
|
||||
* @returns 创建或更新的用户设置
|
||||
*/
|
||||
export async function setUserSetting(params: UserSettingParams) {
|
||||
const { userId, category, key, value, isSecret = false } = params;
|
||||
|
||||
try {
|
||||
const setting = await prisma.userSetting.upsert({
|
||||
where: {
|
||||
userId_category_key: {
|
||||
userId,
|
||||
category,
|
||||
key,
|
||||
},
|
||||
},
|
||||
update: {
|
||||
value,
|
||||
isSecret,
|
||||
},
|
||||
create: {
|
||||
userId,
|
||||
category,
|
||||
key,
|
||||
value,
|
||||
isSecret,
|
||||
},
|
||||
});
|
||||
|
||||
logger.info(`[UserSetting] 设置用户 ${userId} 的 ${category}.${key} 成功`);
|
||||
return setting;
|
||||
} catch (error) {
|
||||
logger.error(`[UserSetting] 设置用户 ${userId} 的 ${category}.${key} 失败:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户设置
|
||||
* @param params 查询参数
|
||||
* @returns 用户设置列表
|
||||
*/
|
||||
export async function getUserSettings(params: UserSettingQueryParams) {
|
||||
const { userId, category, key, includeSecrets = false } = params;
|
||||
|
||||
try {
|
||||
const where: any = { userId };
|
||||
|
||||
if (category) {
|
||||
where.category = category;
|
||||
}
|
||||
|
||||
if (key) {
|
||||
where.key = key;
|
||||
}
|
||||
|
||||
// 如果不包含敏感信息,则过滤掉敏感设置
|
||||
if (!includeSecrets) {
|
||||
where.isSecret = false;
|
||||
}
|
||||
|
||||
const settings = await prisma.userSetting.findMany({
|
||||
where,
|
||||
orderBy: [{ category: 'asc' }, { key: 'asc' }],
|
||||
});
|
||||
|
||||
return settings;
|
||||
} catch (error) {
|
||||
logger.error(`[UserSetting] 获取用户 ${userId} 的设置失败:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取单个用户设置
|
||||
* @param userId 用户ID
|
||||
* @param category 设置类别
|
||||
* @param key 设置键名
|
||||
* @returns 用户设置,如果不存在则返回null
|
||||
*/
|
||||
export async function getUserSetting(userId: string, category: string, key: string) {
|
||||
try {
|
||||
const setting = await prisma.userSetting.findUnique({
|
||||
where: {
|
||||
userId_category_key: {
|
||||
userId,
|
||||
category,
|
||||
key,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return setting;
|
||||
} catch (error) {
|
||||
logger.error(`[UserSetting] 获取用户 ${userId} 的 ${category}.${key} 失败:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除用户设置
|
||||
* @param userId 用户ID
|
||||
* @param category 设置类别
|
||||
* @param key 设置键名
|
||||
* @returns 删除的用户设置
|
||||
*/
|
||||
export async function deleteUserSetting(userId: string, category: string, key: string) {
|
||||
try {
|
||||
const setting = await prisma.userSetting.delete({
|
||||
where: {
|
||||
userId_category_key: {
|
||||
userId,
|
||||
category,
|
||||
key,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
logger.info(`[UserSetting] 删除用户 ${userId} 的 ${category}.${key} 成功`);
|
||||
return setting;
|
||||
} catch (error) {
|
||||
logger.error(`[UserSetting] 删除用户 ${userId} 的 ${category}.${key} 失败:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除用户的所有设置
|
||||
* @param userId 用户ID
|
||||
* @param category 可选的设置类别,如果提供则只删除该类别的设置
|
||||
* @returns 删除的设置数量
|
||||
*/
|
||||
export async function deleteUserSettings(userId: string, category?: string) {
|
||||
try {
|
||||
const where: any = { userId };
|
||||
|
||||
if (category) {
|
||||
where.category = category;
|
||||
}
|
||||
|
||||
const result = await prisma.userSetting.deleteMany({
|
||||
where,
|
||||
});
|
||||
|
||||
logger.info(
|
||||
`[UserSetting] 删除用户 ${userId} 的${category ? ` ${category}` : '所有'}设置成功,共 ${result.count} 条`,
|
||||
);
|
||||
return result.count;
|
||||
} catch (error) {
|
||||
logger.error(`[UserSetting] 删除用户 ${userId} 的${category ? ` ${category}` : '所有'}设置失败:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user