🎉 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;
|
||||
}
|
||||
}
|
||||
63
app/lib/api/connection.ts
Normal file
63
app/lib/api/connection.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
export interface ConnectionStatus {
|
||||
connected: boolean;
|
||||
latency: number;
|
||||
lastChecked: string;
|
||||
}
|
||||
|
||||
export const checkConnection = async (): Promise<ConnectionStatus> => {
|
||||
try {
|
||||
// Check if we have network connectivity
|
||||
const online = navigator.onLine;
|
||||
|
||||
if (!online) {
|
||||
return {
|
||||
connected: false,
|
||||
latency: 0,
|
||||
lastChecked: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
// Try multiple endpoints in case one fails
|
||||
const endpoints = [
|
||||
'/api/health',
|
||||
'/', // Fallback to root route
|
||||
'/favicon.ico', // Another common fallback
|
||||
];
|
||||
|
||||
let latency = 0;
|
||||
let connected = false;
|
||||
|
||||
for (const endpoint of endpoints) {
|
||||
try {
|
||||
const start = performance.now();
|
||||
const response = await fetch(endpoint, {
|
||||
method: 'HEAD',
|
||||
cache: 'no-cache',
|
||||
});
|
||||
const end = performance.now();
|
||||
|
||||
if (response.ok) {
|
||||
latency = Math.round(end - start);
|
||||
connected = true;
|
||||
break;
|
||||
}
|
||||
} catch (endpointError) {
|
||||
console.debug(`Failed to connect to ${endpoint}:`, endpointError);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
connected,
|
||||
latency,
|
||||
lastChecked: new Date().toISOString(),
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Connection check failed:', error);
|
||||
return {
|
||||
connected: false,
|
||||
latency: 0,
|
||||
lastChecked: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
};
|
||||
33
app/lib/api/cookies.ts
Normal file
33
app/lib/api/cookies.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
export function parseCookies(cookieHeader: string | null) {
|
||||
const cookies: Record<string, string> = {};
|
||||
|
||||
if (!cookieHeader) {
|
||||
return cookies;
|
||||
}
|
||||
|
||||
// Split the cookie string by semicolons and spaces
|
||||
const items = cookieHeader.split(';').map((cookie) => cookie.trim());
|
||||
|
||||
items.forEach((item) => {
|
||||
const [name, ...rest] = item.split('=');
|
||||
|
||||
if (name && rest.length > 0) {
|
||||
// Decode the name and value, and join value parts in case it contains '='
|
||||
const decodedName = decodeURIComponent(name.trim());
|
||||
const decodedValue = decodeURIComponent(rest.join('=').trim());
|
||||
cookies[decodedName] = decodedValue;
|
||||
}
|
||||
});
|
||||
|
||||
return cookies;
|
||||
}
|
||||
|
||||
export function getApiKeysFromCookie(cookieHeader: string | null): Record<string, string> {
|
||||
const cookies = parseCookies(cookieHeader);
|
||||
return cookies.apiKeys ? JSON.parse(cookies.apiKeys) : {};
|
||||
}
|
||||
|
||||
export function getProviderSettingsFromCookie(cookieHeader: string | null): Record<string, any> {
|
||||
const cookies = parseCookies(cookieHeader);
|
||||
return cookies.providers ? JSON.parse(cookies.providers) : {};
|
||||
}
|
||||
121
app/lib/api/debug.ts
Normal file
121
app/lib/api/debug.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
export interface DebugWarning {
|
||||
id: string;
|
||||
message: string;
|
||||
timestamp: string;
|
||||
code: string;
|
||||
}
|
||||
|
||||
export interface DebugError {
|
||||
id: string;
|
||||
message: string;
|
||||
timestamp: string;
|
||||
stack?: string;
|
||||
}
|
||||
|
||||
export interface DebugStatus {
|
||||
warnings: DebugIssue[];
|
||||
errors: DebugIssue[];
|
||||
}
|
||||
|
||||
export interface DebugIssue {
|
||||
id: string;
|
||||
message: string;
|
||||
type: 'warning' | 'error';
|
||||
timestamp: string;
|
||||
details?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
// Keep track of acknowledged issues
|
||||
const acknowledgedIssues = new Set<string>();
|
||||
|
||||
export const getDebugStatus = async (): Promise<DebugStatus> => {
|
||||
const issues: DebugStatus = {
|
||||
warnings: [],
|
||||
errors: [],
|
||||
};
|
||||
|
||||
try {
|
||||
// Check memory usage
|
||||
if (performance && 'memory' in performance) {
|
||||
const memory = (performance as any).memory;
|
||||
|
||||
if (memory.usedJSHeapSize > memory.jsHeapSizeLimit * 0.8) {
|
||||
issues.warnings.push({
|
||||
id: 'high-memory-usage',
|
||||
message: 'High memory usage detected',
|
||||
type: 'warning',
|
||||
timestamp: new Date().toISOString(),
|
||||
details: {
|
||||
used: memory.usedJSHeapSize,
|
||||
total: memory.jsHeapSizeLimit,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Check storage quota
|
||||
if (navigator.storage && navigator.storage.estimate) {
|
||||
const estimate = await navigator.storage.estimate();
|
||||
const usageRatio = (estimate.usage || 0) / (estimate.quota || 1);
|
||||
|
||||
if (usageRatio > 0.9) {
|
||||
issues.warnings.push({
|
||||
id: 'storage-quota-warning',
|
||||
message: 'Storage quota nearly reached',
|
||||
type: 'warning',
|
||||
timestamp: new Date().toISOString(),
|
||||
details: {
|
||||
used: estimate.usage,
|
||||
quota: estimate.quota,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Check for console errors (if any)
|
||||
const errorLogs = localStorage.getItem('error_logs');
|
||||
|
||||
if (errorLogs) {
|
||||
const errors = JSON.parse(errorLogs);
|
||||
errors.forEach((error: any) => {
|
||||
issues.errors.push({
|
||||
id: `error-${error.timestamp}`,
|
||||
message: error.message,
|
||||
type: 'error',
|
||||
timestamp: error.timestamp,
|
||||
details: error.details,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Filter out acknowledged issues
|
||||
issues.warnings = issues.warnings.filter((warning) => !acknowledgedIssues.has(warning.id));
|
||||
issues.errors = issues.errors.filter((error) => !acknowledgedIssues.has(error.id));
|
||||
|
||||
return issues;
|
||||
} catch (error) {
|
||||
console.error('Error getting debug status:', error);
|
||||
return issues;
|
||||
}
|
||||
};
|
||||
|
||||
export const acknowledgeWarning = async (id: string): Promise<void> => {
|
||||
acknowledgedIssues.add(id);
|
||||
};
|
||||
|
||||
export const acknowledgeError = async (id: string): Promise<void> => {
|
||||
acknowledgedIssues.add(id);
|
||||
|
||||
// Also remove from error logs if present
|
||||
try {
|
||||
const errorLogs = localStorage.getItem('error_logs');
|
||||
|
||||
if (errorLogs) {
|
||||
const errors = JSON.parse(errorLogs);
|
||||
const updatedErrors = errors.filter((error: any) => `error-${error.timestamp}` !== id);
|
||||
localStorage.setItem('error_logs', JSON.stringify(updatedErrors));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error acknowledging error:', error);
|
||||
}
|
||||
};
|
||||
35
app/lib/api/features.ts
Normal file
35
app/lib/api/features.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
export interface Feature {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
viewed: boolean;
|
||||
releaseDate: string;
|
||||
}
|
||||
|
||||
export const getFeatureFlags = async (): Promise<Feature[]> => {
|
||||
/*
|
||||
* TODO: Implement actual feature flags logic
|
||||
* This is a mock implementation
|
||||
*/
|
||||
return [
|
||||
{
|
||||
id: 'feature-1',
|
||||
name: 'Dark Mode',
|
||||
description: 'Enable dark mode for better night viewing',
|
||||
viewed: true,
|
||||
releaseDate: '2024-03-15',
|
||||
},
|
||||
{
|
||||
id: 'feature-2',
|
||||
name: 'Tab Management',
|
||||
description: 'Customize your tab layout',
|
||||
viewed: false,
|
||||
releaseDate: '2024-03-20',
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
export const markFeatureViewed = async (featureId: string): Promise<void> => {
|
||||
/* TODO: Implement actual feature viewed logic */
|
||||
console.log(`Marking feature ${featureId} as viewed`);
|
||||
};
|
||||
58
app/lib/api/notifications.ts
Normal file
58
app/lib/api/notifications.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import type { LogEntry } from '~/lib/stores/logs';
|
||||
import { logStore } from '~/lib/stores/logs';
|
||||
|
||||
export interface Notification {
|
||||
id: string;
|
||||
title: string;
|
||||
message: string;
|
||||
type: 'info' | 'warning' | 'error' | 'success';
|
||||
timestamp: string;
|
||||
read: boolean;
|
||||
details?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface LogEntryWithRead extends LogEntry {
|
||||
read: boolean;
|
||||
}
|
||||
|
||||
export const getNotifications = async (): Promise<Notification[]> => {
|
||||
// Get notifications from the log store
|
||||
const logs = Object.values(logStore.logs.get());
|
||||
|
||||
return logs
|
||||
.filter((log) => log.category !== 'system') // Filter out system logs
|
||||
.map((log) => ({
|
||||
id: log.id,
|
||||
title: (log.details?.title as string) || log.message.split('\n')[0],
|
||||
message: log.message,
|
||||
type: log.level as 'info' | 'warning' | 'error' | 'success',
|
||||
timestamp: log.timestamp,
|
||||
read: logStore.isRead(log.id),
|
||||
details: log.details,
|
||||
}))
|
||||
.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
|
||||
};
|
||||
|
||||
export const markNotificationRead = async (notificationId: string): Promise<void> => {
|
||||
logStore.markAsRead(notificationId);
|
||||
};
|
||||
|
||||
export const clearNotifications = async (): Promise<void> => {
|
||||
logStore.clearLogs();
|
||||
};
|
||||
|
||||
export const getUnreadCount = (): number => {
|
||||
const logs = Object.values(logStore.logs.get()) as LogEntryWithRead[];
|
||||
|
||||
return logs.filter((log) => {
|
||||
if (!logStore.isRead(log.id)) {
|
||||
if (log.details?.type === 'update') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return log.level === 'error' || log.level === 'warning';
|
||||
}
|
||||
|
||||
return false;
|
||||
}).length;
|
||||
};
|
||||
187
app/lib/bridge/index.ts
Normal file
187
app/lib/bridge/index.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
import type { ActionState } from '../runtime/action-runner';
|
||||
|
||||
interface BridgeContext {
|
||||
loaded: boolean;
|
||||
}
|
||||
|
||||
export const bridgeContext: BridgeContext = import.meta.hot?.data.editorBridgeContext ?? {
|
||||
loaded: false,
|
||||
};
|
||||
|
||||
if (import.meta.hot) {
|
||||
import.meta.hot.data.editorBridgeContext = bridgeContext;
|
||||
}
|
||||
|
||||
export type EventPayload = {
|
||||
pageName: string;
|
||||
} & Record<string, any>;
|
||||
type EventHandler = (payload: EventPayload) => void;
|
||||
type EventMap = Map<string, Set<EventHandler>>;
|
||||
|
||||
type Page = {
|
||||
name: string;
|
||||
title: string;
|
||||
actionIds?: string[];
|
||||
};
|
||||
|
||||
type SectionProps = {
|
||||
id: string;
|
||||
pageName: string;
|
||||
content: string;
|
||||
domId: string;
|
||||
rootDomId: string;
|
||||
sort?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* 构筑一个运行在 node 环境下的,editor 的 bridge。
|
||||
* 所有操作编辑器的方法,都需要经由此 bridge 进行广播。
|
||||
* 其内部所保存的 pages 与 sections 均为虚拟数据,与 editor 所需的实际数据有一定差异。
|
||||
*/
|
||||
export class EditorBridge {
|
||||
#pages: Map<string, Page> = new Map();
|
||||
#sections: Map<string, SectionProps> = new Map();
|
||||
#events: EventMap = new Map();
|
||||
#watchHandlers: Set<(event: { type: string; payload: EventPayload }) => void> = new Set();
|
||||
|
||||
/**
|
||||
* 监听事件
|
||||
* @param event 事件名称
|
||||
* @param handler 处理函数
|
||||
*/
|
||||
on(event: string, handler: EventHandler) {
|
||||
if (!this.#events.has(event)) {
|
||||
this.#events.set(event, new Set());
|
||||
}
|
||||
|
||||
this.#events.get(event)?.add(handler);
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除事件监听
|
||||
* @param event 事件名称
|
||||
* @param handler 处理函数
|
||||
*/
|
||||
off(event: string, handler?: EventHandler) {
|
||||
if (!handler) {
|
||||
this.#events.delete(event);
|
||||
} else if (this.#events.has(event)) {
|
||||
this.#events.get(event)?.delete(handler);
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* 触发事件
|
||||
* @param event 事件名称
|
||||
* @param payload 事件负载对象
|
||||
*/
|
||||
#emit(event: string, payload: EventPayload) {
|
||||
this.#events.get(event)?.forEach((handler) => handler(payload));
|
||||
|
||||
// 同时通知所有 watch 监听器
|
||||
this.#watchHandlers.forEach((handler) => handler({ type: event, payload }));
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建页面
|
||||
* @param pageName 页面名称
|
||||
*/
|
||||
async createPage(name: string, { title, actionIds }: { title?: string; actionIds?: string[] } = {}) {
|
||||
const newPage: Page = { name, title: title ?? '未命名页面', actionIds: actionIds ?? [] };
|
||||
this.#pages.set(name, newPage);
|
||||
|
||||
// 发出 add_page 事件,其他地方可通过 editorBridge.on('add_page', (payload) => {}) 监听
|
||||
this.#emit('add_page', {
|
||||
pageName: name,
|
||||
...newPage,
|
||||
});
|
||||
}
|
||||
|
||||
async removePage(pageName: string) {
|
||||
this.#pages.delete(pageName);
|
||||
|
||||
// 发出 remove_page 事件,其他地方可通过 editorBridge.on('remove_page', (payload) => {}) 监听
|
||||
this.#emit('remove_page', { pageName });
|
||||
}
|
||||
|
||||
async updateSection(action: ActionState) {
|
||||
this.#sections.set(action.id, action);
|
||||
// 发出 add_page_section 事件,其他地方可通过 editorBridge.on('add_page_section', (payload) => {}) 监听
|
||||
this.#emit('update_section', { pageName: action.pageName, id: action.id, section: action });
|
||||
}
|
||||
|
||||
async upsertPageAction(pageName: string, pageTitle: string, actionId: string) {
|
||||
const page = this.#pages.get(pageName);
|
||||
const pageProps = page
|
||||
? {
|
||||
...page,
|
||||
actionIds: [...(page.actionIds ?? []), actionId],
|
||||
}
|
||||
: {
|
||||
name: pageName,
|
||||
title: pageTitle || '未命名页面',
|
||||
actionIds: [actionId],
|
||||
};
|
||||
pageProps.actionIds = [...new Set(pageProps.actionIds)];
|
||||
|
||||
this.#pages.set(pageName, pageProps);
|
||||
// 发出 update_page 事件,其他地方可通过 editorBridge.on('update_page', (payload) => {}) 监听
|
||||
this.#emit('upsert_page', {
|
||||
pageName,
|
||||
...pageProps,
|
||||
});
|
||||
}
|
||||
|
||||
// /**
|
||||
// * 获取页面历史记录
|
||||
// * @param pageName 页面名称
|
||||
// * @returns 页面历史
|
||||
// */
|
||||
// async getPageHistory(pageName: string): Promise<string> {
|
||||
// return this.#pages.get(pageName) || '{}';
|
||||
// }
|
||||
|
||||
/**
|
||||
* 监听所有事件
|
||||
* @param handler 处理所有事件的函数
|
||||
*/
|
||||
watch(handler: (event: { type: string; payload: EventPayload }) => void) {
|
||||
this.#watchHandlers.add(handler);
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除 watch 监听
|
||||
* @param handler 处理函数
|
||||
*/
|
||||
unwatch(handler: (event: { type: string; payload: EventPayload }) => void) {
|
||||
this.#watchHandlers.delete(handler);
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
export let editorBridge: Promise<EditorBridge> = new Promise(() => {
|
||||
// noop for ssr
|
||||
});
|
||||
|
||||
if (!import.meta.env.SSR) {
|
||||
editorBridge =
|
||||
import.meta.hot?.data.editorBridge ??
|
||||
Promise.resolve()
|
||||
.then(() => {
|
||||
return new EditorBridge();
|
||||
})
|
||||
.then(async (editorBridge) => {
|
||||
bridgeContext.loaded = true;
|
||||
return editorBridge;
|
||||
});
|
||||
|
||||
if (import.meta.hot) {
|
||||
import.meta.hot.data.editorBridge = editorBridge;
|
||||
}
|
||||
}
|
||||
43
app/lib/common/prompt-library.ts
Normal file
43
app/lib/common/prompt-library.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { getSystemPrompt } from './prompts/prompts';
|
||||
|
||||
export interface PromptOptions {
|
||||
cwd: string;
|
||||
allowedHtmlElements: string[];
|
||||
modificationTagName: string;
|
||||
}
|
||||
|
||||
export class PromptLibrary {
|
||||
static library: Record<
|
||||
string,
|
||||
{
|
||||
label: string;
|
||||
description: string;
|
||||
get: (options: PromptOptions) => string;
|
||||
}
|
||||
> = {
|
||||
default: {
|
||||
label: 'Default Prompt',
|
||||
description: 'This is the battle tested default system Prompt',
|
||||
get: () => getSystemPrompt(),
|
||||
},
|
||||
};
|
||||
static getList() {
|
||||
return Object.entries(this.library).map(([key, value]) => {
|
||||
const { label, description } = value;
|
||||
return {
|
||||
id: key,
|
||||
label,
|
||||
description,
|
||||
};
|
||||
});
|
||||
}
|
||||
static getPropmtFromLibrary(promptId: string, options: PromptOptions) {
|
||||
const prompt = this.library[promptId];
|
||||
|
||||
if (!prompt) {
|
||||
throw 'Prompt Now Found';
|
||||
}
|
||||
|
||||
return this.library[promptId]?.get(options);
|
||||
}
|
||||
}
|
||||
328
app/lib/common/prompts/prompts.ts
Normal file
328
app/lib/common/prompts/prompts.ts
Normal file
@@ -0,0 +1,328 @@
|
||||
import { allowedHTMLElements } from '~/utils/markdown';
|
||||
import { stripIndents } from '~/utils/strip-indent';
|
||||
|
||||
export const getSystemPrompt = () => `
|
||||
你是 UPage - 专家级 AI 助手,精通 HTML、CSS、JavaScript 及现代网页设计。
|
||||
当前时间为 ${new Date().toLocaleString()}
|
||||
|
||||
<system_constraints>
|
||||
你正在基于 HTML、CSS、JavaScript 来生成多个或单个页面。以下是系统规则,请严格遵守。部分规则有更详细的指南与要求。
|
||||
|
||||
基本规则:
|
||||
- 绝对不生成任何后端相关代码。
|
||||
- 如果是多页面项目,需生成所有页面,保证每页都有完整内容。
|
||||
- 不要啰嗦,除非用户要求更多信息,否则不要解释任何内容。
|
||||
- 永远不要使用 "artifact" 这个词。
|
||||
- 仅对所有回答使用有效的 markdown,除了构件外不要使用 HTML 标签!
|
||||
|
||||
页面规则:
|
||||
- 仅使用原生 HTML、CSS 与 JS 构建前端页面,不使用任何框架。
|
||||
- 使用Tailwind CSS填充样式。
|
||||
- 如果有图标,则使用iconify-icon库提供所需的图标。
|
||||
- 如果需要占位图,则使用 https://picsum.photos 提供占位图。
|
||||
- 保持移动端的适配性,确保在不同尺寸的设备上能够正常显示。
|
||||
- 非常重要:首个页面的 name 一定是 index,title 一定是首页。
|
||||
|
||||
内容更新策略:
|
||||
- 首次创建页面时提供完整丰富的内容结构。
|
||||
- 修改现有内容时使用精确的增量更新,只按照结构要求生成需要更改的最小元素内容。
|
||||
- 确保增量更新时保留原有的设计风格和视觉一致性。
|
||||
- 添加新 section 时考虑与当前页面的视觉协调性。
|
||||
- 更新时始终保持元素的 domId 不变。
|
||||
|
||||
严格禁止:
|
||||
- 不添加任何代码注释
|
||||
- 除占位符链接外,不添加任何外部链接
|
||||
- 不回答与网页构建无关的问题
|
||||
|
||||
拒绝回答格式:十分抱歉,我是由凌霞软件开发的网页构建工具 UPage,专注与网页构建,因此我无法回答与网页构建无关的问题。
|
||||
</system_constraints>
|
||||
|
||||
<execute_steps>
|
||||
以下是系统的执行步骤,请严格遵守:
|
||||
|
||||
1. 提供解决方案前,先概述你的实现步骤。
|
||||
2. 在生成或更新页面时,思考需要处理的页面数量,然后按照特定类型依次处理。
|
||||
2.1 在生成或更新具体页面内容时,先确定页面类型,然后确定页面需要包含的section类型与数量,然后依次处理section。
|
||||
2.2 生成或更新section时,需要确定section的结构,然后按照特定类型依次处理。
|
||||
2.3 每个section处理完毕后,基于用户提示,然后处理下一个section。
|
||||
3. 每个页面生成完毕后,简介地总结当前页面更改的内容,然后处理下一个页面。
|
||||
4. 所有页面生成完毕后,简介地总结此次所有更改的内容。
|
||||
</execute_steps>
|
||||
|
||||
<usage_guide>
|
||||
Tailwind CSS 使用指导:
|
||||
- 项目中已提前引入 Tailwind CSS 3.4.17 版本,不要重复引入。
|
||||
- Tailwind CSS 的文档地址为:https://v3.tailwindcss.com/docs/installation,如需帮助,请参考文档。
|
||||
- 如果需要自定义配置,请使用 \`<script></script>\` 标签来配置。例如:
|
||||
<script>
|
||||
tailwind.config = {
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
clifford: '#da373d',
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
- 如果需要自定义 CSS,则可以通过 \`type="text/tailwindcss"\` 来自定义 CSS。例如:
|
||||
<style type="text/tailwindcss">
|
||||
@layer utilities {
|
||||
.content-auto {
|
||||
content-visibility: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
iconify-icon 使用指导:
|
||||
- 当项目中有图标需求时,请务必使用 iconify 图标。
|
||||
- 项目已提前引入 iconify-icon 库,不要重复引入。
|
||||
- iconify-icon 的文档地址为:https://iconify.design/docs/iconify-icon/ ,如需帮助,请参考文档。
|
||||
- 如果代码中需要图标,请使用 \`<iconify-icon>\` 标签来引入。例如:
|
||||
<iconify-icon icon="mdi:home"></iconify-icon>
|
||||
|
||||
picsum-photos 使用指导:
|
||||
- picsum-photos 是一个在线的免费占位图网站,如果项目中存在占位图需求,请务必使用 picsum-photos 提供的占位图。
|
||||
- picsum-photos 的文档地址为:https://picsum.photos/ ,如需帮助,请参考文档。
|
||||
- 对于项目中的占位图,避免使用随机图片。
|
||||
</usage_guide>
|
||||
|
||||
<design_guidelines>
|
||||
根据页面类型生成专业级设计,确保用户感官体验。
|
||||
|
||||
视觉设计:
|
||||
- 创造令人眼前一亮的视觉体验,使用丰富的设计元素和创意布局
|
||||
- 运用现代设计趋势:新拟态UI、扁平化设计、微妙渐变、3D元素、不规则形状等丰富的视觉元素
|
||||
- 设计丰富的交互体验:精致的悬停效果、流畅的动画过渡、视差滚动
|
||||
- 创建深度层次感:通过阴影、重叠元素、z-index分层实现视觉深度
|
||||
- 运用高级视觉技巧:背景混合模式、蒙版效果、动态色彩变化
|
||||
- 精心设计微交互,为用户提供愉悦的互动体验
|
||||
- 实现滚动感知设计:顶部导航区域在滚动时变化(如背景透明度、高度缩小、阴影增强等),创造动态视觉体验
|
||||
- 设计滚动触发动画:元素随页面滚动逐渐显现、移动或变化,增强页面生命力
|
||||
- 图标语义关联:所有选择的图标需要与当前内容有明确的语义联系,确保图标直观地表达相应的概念或功能
|
||||
- 结构复杂性:section内部尽可能呈现复杂的结构层次,包含主要内容区、辅助内容区、装饰元素区,形成丰富的视觉层次和信息层次
|
||||
- 内容组织多样化:section可以采用多种不同的内容组织方式,如网格布局、列表、卡片、时间线、对比展示、图文混排等,避免单调的内容呈现
|
||||
- 非常重要:确保section具有独特视觉特色,同时保持整个页面设计风格一致性
|
||||
|
||||
设计关键点:
|
||||
- Header:如果具有导航栏,则滚动时导航栏要跟随滚动,且为用户呈现适当的交互体验。
|
||||
- 色彩一致性:根据产品特性和用户描述确定明确的主题色和辅助色方案,在整个页面中严格遵循这一配色方案,确保视觉一致性
|
||||
- 内容密度:每个section必须包含至少6个精心设计的子元素,构建多层次结构。
|
||||
- 交互体验:添加微交互、悬停效果、滚动动画,创造沉浸式体验
|
||||
- 视觉层次:通过大小、颜色、间距创建清晰的视觉引导路径
|
||||
- 现代元素:使用玻璃态效果、柔和阴影、渐变、3D元素、不规则形状
|
||||
- 内容展示多样性:为相似内容创造多种展示形式(如卡片、时间线、网格、交错布局、轮播),避免重复的视觉模式
|
||||
- 用户反馈区域创新:使用多样的布局(如对角线排列、交错网格、环形布局),结合丰富的视觉元素(如引用符号、个性化头像框、背景装饰)
|
||||
|
||||
高级设计技巧:
|
||||
- 创意布局:打破传统网格,使用不对称、重叠元素、交错排列
|
||||
- 精致细节:添加微妙动画、状态转换、视差效果
|
||||
- 沉浸体验:使用全屏背景、视频背景、互动元素
|
||||
- 情感设计:通过色彩、形状、动效激发特定情感反应
|
||||
- 品牌一致性:确保所有元素遵循统一的设计语言,包括一致的主题色系(主色、辅色、强调色)应用于按钮、标题、边框、背景等各个元素
|
||||
- 关键区域设计:为重要行动区域创造视觉焦点,使用多层次设计(背景图案、前景元素、悬浮组件)、动态色彩渐变、微妙动画效果
|
||||
- 页面底部增强:设计多列复杂结构,融合交互元素(如订阅框、社交媒体互动、微型导航)和视觉吸引力(如背景变化、几何图形装饰)
|
||||
- 内容层次构建:在每个section中创建至少 3 层内容层次,包括主要信息、支持数据、辅助说明、视觉强化元素等,形成信息的深度和广度
|
||||
- 内容密度优化:确保每个section的内容密度适中且丰富,避免空白过多,同时巧妙利用负空间引导视觉流动
|
||||
|
||||
根据页面类型生成足够数量的section:
|
||||
- 企业/产品页面:至少8个不同功能的section
|
||||
- 电商页面:至少8个不同功能的section
|
||||
- 博客/内容页面:至少6个不同功能的section
|
||||
- 简单表单页面:至少1个section,但确保功能完整
|
||||
- 仪表盘/管理界面:至少6个不同功能的section
|
||||
- 个人名片:至少1个section,但确保功能完整
|
||||
|
||||
简单页面通常只需要包含主体内容,而网站网页内容通常需要包含 header、主体内容、footer。
|
||||
</design_guidelines>
|
||||
|
||||
<message_formatting_info>
|
||||
在概述实现步骤、上下文摘要、总结中,请仅使用以下 HTML 元素:
|
||||
${allowedHTMLElements.map((tagName) => `<${tagName}>`).join(', ')}
|
||||
|
||||
生成的 section 中的 HTML 不受此限制。
|
||||
</message_formatting_info>
|
||||
|
||||
<chain_of_thought_instructions>
|
||||
在提供解决方案之前,简要概述你的实现步骤。这有助于确保系统性思维和清晰的沟通。你的计划应该:
|
||||
- 列出你将采取的具体步骤
|
||||
- 确定所需的关键风格和设计元素
|
||||
- 不要出现专业名词,仅使用自然语言为用户描述。
|
||||
- 注意潜在的挑战
|
||||
- 保持简洁(最多 2-4 行)
|
||||
</chain_of_thought_instructions>
|
||||
|
||||
<artifact_info>
|
||||
为每个页面创建一个单一的、全面的构件,其相当于一个页面。该构件包含当前页面所有必要的组件和步骤。每个页面都是一个 \`<uPageArtifact>\` 标签。
|
||||
|
||||
<artifact_instructions>
|
||||
1. 在创建页面构件前全面思考:考虑所有相关页面、审查之前的内容更改、分析整个项目上下文,分析已有页面。
|
||||
2. 每个页面将由多个section组成。
|
||||
3. 内容修改时始终使用最新的修改。
|
||||
4. 使用 \`<uPageArtifact>\` 标签包装内容,包含更具体的 \`<uPageAction>\` 元素。
|
||||
5. 每个页面将具有一个唯一的 \`id\` 属性(使用 kebab-case),此 id 将在此构件整个生命周期中一致使用,即使在更新或迭代构件时也是如此。
|
||||
6. 使用 \`<uPageArtifact>\` 标签定义具体操作,拥有以下属性:
|
||||
- 必填 \`id\`:当前页面的唯一 \`id\` 属性。
|
||||
- 必填 \`name\`:指定页面名称,全局唯一,表示为页面文件名,不含后缀。例如 "index"、"pricing"、"contact" 等。首个页面构件必须使用 index。
|
||||
- 必填 \`title\`:指定页面标题,使用对用户友好的名称作为标题,例如 "首页"、"定价页面"、"联系我们" 等,并且在单个页面中,title 必须保持一致。
|
||||
7. 使用编码最佳实践,页面应尽可能完善且满足用户要求。
|
||||
8. 多页面项目确保链接或锚点能够正确跳转。
|
||||
9. 页面内不生成完整的 HTML 文档结构(DOCTYPE、html、head、body),只生成实际的section。
|
||||
10. 每个 \`<uPageArtifact>\` 下包含有若干个 \`<uPageAction>\` 标签。
|
||||
11. \`uPageArtifact\` 生成完后,简洁地总结描述本次生成的内容。
|
||||
</artifact_instructions>
|
||||
</artifact_info>
|
||||
|
||||
<action_info>
|
||||
为每个section创建一个单一的、全面的构件。该构件包含当前section所有必要的组件和步骤。每个section都是一个 \`<uPageAction>\` 标签。
|
||||
|
||||
<action_instructions>
|
||||
1. 在创建构件前全面思考:考虑当前页面一致性、审查之前的内容更改、分析当前页面的上下文。
|
||||
2. 修改时始终使用最新的修改。
|
||||
3. 使用 \`<uPageAction>\` 标签包装内容,内容包括HTML、CSS或JavaScript。
|
||||
4. 每个 \`<uPageAction>\` 必定包含有唯一的父节点,即拥有唯一的页面。
|
||||
5. 每个 \`<uPageAction>\` 下只能有一个根 HTML 元素。
|
||||
6. 十分重要:为每个HTML元素生成唯一的 domId 属性,并确保在整个页面中唯一。
|
||||
7. 使用 \`<uPageAction>\` 标签定义具体操作,添加以下属性:
|
||||
- 必填 \`id\`:为当前 uPageAction 添加唯一标识符(kebab-case),该 id 将在此构件整个生命周期中一致使用,即使在更新或迭代构件时也是如此。
|
||||
- 必填 \`pageName\`:指定唯一父节点 id。
|
||||
- 必填 \`action\`:指定当前的操作类型("add"、"remove"、"update")
|
||||
- 必填 \`domId\`:操作元素的唯一标识符,确保在整个页面中唯一。 在新增操作时,domId 为父节点 id,在更新与删除操作时,domId 为当前操作节点 id。
|
||||
- 必填 \`rootDomId\`:根节点 id,必须与唯一根节点的 id 一致。在删除操作时,与 domId 一致。
|
||||
- 可选 \`sort\`:当前元素在同级元素中的排序位置(从0开始)
|
||||
8. 如果section是header或footer,则唯一根节点的标签必定为 \`<header>\` 或 \`<footer>\`,否则为 \`<section>\`。
|
||||
9. 使用编码最佳实践,section应尽可能丰富且满足用户要求。
|
||||
10. 如果页面中具有页内链接,请确保生成的section domId 与链接的 domId 一致,确保链接能够正确跳转。
|
||||
11. 如果 \`action\` 为删除操作,则 \`<uPageAction>\` 下无需有任何内容。
|
||||
12. 如果 \`action\` 为更新操作,请确保其为最小化更新。
|
||||
13. 如果包装内容是JavaScript,则其必定处于 \`<uPageAction>\` 的最后一个位置。
|
||||
14. 不要在每个\`action\` 中间穿插说明。
|
||||
|
||||
非常重要:关于 domId 的额外说明。
|
||||
- 在新增操作时,domId 为父节点 id,在更新与删除操作时,domId 为当前操作节点 id。
|
||||
- 如果 \`action\` 为删除操作,\`domId\` 为当前待删除节点的 id。
|
||||
- 如果 \`action\` 为更新操作,\`domId\` 为当前待更新节点的 id。
|
||||
- 如果 \`action\` 为新增操作,\`domId\` 为当前待新增节点的父节点 id。如果是页面下新增一个根节点,则 \`domId\` 为 \`page-{pageName}\`。如果非页面根节点,则 \`domId\` 为待操作节点的父节点 id。
|
||||
</action_info>
|
||||
|
||||
</examples>
|
||||
以下是正确使用构件的示例。请注意在生成时不要生成注释,注释仅用于辅助理解。
|
||||
<example>
|
||||
<user_query>构建一个定价页面</user_query>
|
||||
|
||||
<assistant_response>
|
||||
当然!我很乐意帮助你构建一个定价页面。让我们一步步创建这个页面。
|
||||
|
||||
<uPageArtifact id="pricing-page" name="index" title="Pricing Page in HTML and CSS">
|
||||
<!-- 添加样式,domId 为根节点 id,即 page-index -->
|
||||
<uPageAction id="pricing-style-section" pageName="index" action="add" domId="page-index" rootDomId="stdz14" sort="0">
|
||||
<style id="stdz14">
|
||||
...
|
||||
</style>
|
||||
</uPageAction>
|
||||
<uPageAction id="pricing-header-section" pageName="index" action="add" domId="page-index" rootDomId="f8g9h0" sort="0">
|
||||
<header id="f8g9h0" class="text-center py-10">
|
||||
<h1 id="fdxv12">Pricing</h1>
|
||||
...
|
||||
</header>
|
||||
</uPageAction>
|
||||
<uPageAction id="pricing-plans-section" pageName="index" action="add" domId="page-index" rootDomId="d2e3f4" sort="1">
|
||||
<section id="d2e3f4" class="flex justify-center gap-8 py-10">
|
||||
...
|
||||
</section>
|
||||
</uPageAction>
|
||||
<uPageAction id="pricing-script-section" pageName="index" action="add" domId="page-index" rootDomId="x8dv2x" sort="0">
|
||||
<script id="x8dv2x">
|
||||
...
|
||||
</script>
|
||||
</uPageAction>
|
||||
</uPageArtifact>
|
||||
|
||||
现在你可以在预览中查看定价界面,点击月/年按钮可以切换不同的定价方案。
|
||||
</assistant_response>
|
||||
</example>
|
||||
|
||||
<example>
|
||||
<user_query>为定价页面新增一个产品介绍</user_query>
|
||||
|
||||
<assistant_response>
|
||||
我将从您的定价页面上添加一个全新的产品介绍模块
|
||||
|
||||
<!-- uPageArtifact 标签及其属性与当前页面的 uPageArtifact 标签保持一致 -->
|
||||
<uPageArtifact id="pricing-page" name="index" title="Pricing Page in HTML and CSS">
|
||||
<uPageAction id="add-product-intro" pageName="index" action="add" domId="page-index" rootDomId="c2d3e4" sort="2">
|
||||
<section id="c2d3e4" ...>
|
||||
...
|
||||
</section>
|
||||
</uPageAction>
|
||||
</uPageArtifact>
|
||||
|
||||
已根据要求添加了新的产品介绍模块。
|
||||
</assistant_response>
|
||||
</example>
|
||||
|
||||
<example>
|
||||
<user_query>增加一个新的定价方案,按照每日的价格方案。</user_query>
|
||||
|
||||
<assistant_response>
|
||||
我将从您的定价页面上补充一个新的定价方案
|
||||
|
||||
<!-- uPageArtifact 标签及其属性与当前页面的 uPageArtifact 标签保持一致 -->
|
||||
<uPageArtifact id="pricing-page" name="index" title="Pricing Page in HTML and CSS">
|
||||
<!-- 在非根节点新增一个 HTML 元素,domId 为父节点 id,即 d2e3f4 -->
|
||||
<uPageAction id="add-daily-pricing" pageName="index" action="add" domId="d2e3f4" rootDomId="z8x9y0" sort="0">
|
||||
<div id="z8x9y0" ...>
|
||||
...
|
||||
</div>
|
||||
</uPageAction>
|
||||
</uPageArtifact>
|
||||
|
||||
已添加新的定价方案。
|
||||
</assistant_response>
|
||||
</example>
|
||||
|
||||
<example>
|
||||
<user_query>从我的个人主页上删除联系方式模块</user_query>
|
||||
|
||||
<assistant_response>
|
||||
我将从您的个人主页上删除联系方式模块。
|
||||
|
||||
<uPageArtifact id="personal-homepage" name="index" title="个人主页">
|
||||
<!-- 删除操作,domId 为当前待删除节点的 id,即 p6q7r8 -->
|
||||
<uPageAction id="remove-contact-link" pageName="index" action="remove" domId="p6q7r8" rootDomId="p6q7r8">
|
||||
<!-- 这里为空,因为是删除操作 -->
|
||||
</uPageAction>
|
||||
</uPageArtifact>
|
||||
|
||||
已删除联系方式模块。如果您之后想要恢复,请告诉我。
|
||||
</assistant_response>
|
||||
</example>
|
||||
|
||||
<example>
|
||||
<user_query>更新我的定价页面中的价格信息</user_query>
|
||||
|
||||
<assistant_response>
|
||||
我将更新您定价页面中的价格信息。
|
||||
|
||||
<uPageArtifact id="pricing-page" name="index" title="Pricing Page in HTML and CSS">
|
||||
<!-- 更新操作,domId 为当前待更新节点的 id,即 m1n2o3 -->
|
||||
<uPageAction id="update-basic-price" pageName="pricing" action="update" domId="m1n2o3" rootDomId="m1n2o3">
|
||||
<p id="m1n2o3" class="text-3xl font-bold mt-4">¥129<span id="p4q5r6" class="text-sm">/月</span></p>
|
||||
</uPageAction>
|
||||
|
||||
<uPageAction id="update-pro-price" pageName="index" action="update" domId="q1r2s3" rootDomId="q1r2s3">
|
||||
<p id="q1r2s3" class="text-3xl font-bold mt-4">¥259<span id="t4u5v6" class="text-sm">/月</span></p>
|
||||
</uPageAction>
|
||||
</uPageArtifact>
|
||||
|
||||
价格信息已更新,基础版从¥99/月调整为¥129/月,专业版从¥199/月调整为¥259/月。
|
||||
</assistant_response>
|
||||
</example>
|
||||
<examples>
|
||||
`;
|
||||
|
||||
export const CONTINUE_PROMPT = stripIndents`
|
||||
Continue your prior response. IMPORTANT: Immediately begin from where you left off without any interruptions.
|
||||
Do not repeat any content, including artifact and action tags.
|
||||
`;
|
||||
58
app/lib/crypto.ts
Normal file
58
app/lib/crypto.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
const encoder = new TextEncoder();
|
||||
const decoder = new TextDecoder();
|
||||
const IV_LENGTH = 16;
|
||||
|
||||
export async function encrypt(key: string, data: string) {
|
||||
const iv = crypto.getRandomValues(new Uint8Array(IV_LENGTH));
|
||||
const cryptoKey = await getKey(key);
|
||||
|
||||
const ciphertext = await crypto.subtle.encrypt(
|
||||
{
|
||||
name: 'AES-CBC',
|
||||
iv,
|
||||
},
|
||||
cryptoKey,
|
||||
encoder.encode(data),
|
||||
);
|
||||
|
||||
const bundle = new Uint8Array(IV_LENGTH + ciphertext.byteLength);
|
||||
|
||||
bundle.set(new Uint8Array(ciphertext));
|
||||
bundle.set(iv, ciphertext.byteLength);
|
||||
|
||||
return decodeBase64(bundle);
|
||||
}
|
||||
|
||||
export async function decrypt(key: string, payload: string) {
|
||||
const bundle = encodeBase64(payload);
|
||||
|
||||
const iv = new Uint8Array(bundle.buffer, bundle.byteLength - IV_LENGTH);
|
||||
const ciphertext = new Uint8Array(bundle.buffer, 0, bundle.byteLength - IV_LENGTH);
|
||||
|
||||
const cryptoKey = await getKey(key);
|
||||
|
||||
const plaintext = await crypto.subtle.decrypt(
|
||||
{
|
||||
name: 'AES-CBC',
|
||||
iv,
|
||||
},
|
||||
cryptoKey,
|
||||
ciphertext,
|
||||
);
|
||||
|
||||
return decoder.decode(plaintext);
|
||||
}
|
||||
|
||||
async function getKey(key: string) {
|
||||
return await crypto.subtle.importKey('raw', encodeBase64(key), { name: 'AES-CBC' }, false, ['encrypt', 'decrypt']);
|
||||
}
|
||||
|
||||
function decodeBase64(encoded: Uint8Array) {
|
||||
const byteChars = Array.from(encoded, (byte) => String.fromCodePoint(byte));
|
||||
|
||||
return btoa(byteChars.join(''));
|
||||
}
|
||||
|
||||
function encodeBase64(data: string) {
|
||||
return Uint8Array.from(atob(data), (ch) => ch.codePointAt(0)!);
|
||||
}
|
||||
10
app/lib/fetch.ts
Normal file
10
app/lib/fetch.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
type CommonRequest = Omit<RequestInit, 'body'> & { body?: URLSearchParams | FormData | string };
|
||||
|
||||
export async function request(url: string, init?: CommonRequest) {
|
||||
const nodeFetch = await import('node-fetch');
|
||||
const https = await import('node:https');
|
||||
|
||||
const agent = url.startsWith('https') ? new https.Agent({ rejectUnauthorized: false }) : undefined;
|
||||
|
||||
return nodeFetch.default(url, { ...init, agent });
|
||||
}
|
||||
10
app/lib/hooks/index.ts
Normal file
10
app/lib/hooks/index.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export { useAuth } from './useAuth';
|
||||
export { useDebugStatus } from './useDebugStatus';
|
||||
export * from './useEditChatDescription';
|
||||
export * from './useEditorCommands';
|
||||
export * from './useMessageParser';
|
||||
export { useNotifications } from './useNotifications';
|
||||
export * from './usePromptEnhancer';
|
||||
export * from './useShortcuts';
|
||||
export * from './useSnapScroll';
|
||||
export { default } from './useViewport';
|
||||
79
app/lib/hooks/useAuth.ts
Normal file
79
app/lib/hooks/useAuth.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { useFetcher, useRouteLoaderData } from '@remix-run/react';
|
||||
/*
|
||||
* 用户认证Hook
|
||||
*/
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
export interface UserInfo {
|
||||
sub?: string;
|
||||
name?: string;
|
||||
// 用户登录名,如果未启用用户名登录则可能为空
|
||||
username?: string;
|
||||
picture?: string;
|
||||
// 用户邮箱,可能为空
|
||||
email?: string;
|
||||
// 用户手机号,可能为空
|
||||
phone_number?: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
interface AuthUserResponse {
|
||||
isAuthenticated: boolean;
|
||||
claims?: UserInfo;
|
||||
}
|
||||
|
||||
/**
|
||||
* useAuth Hook - 获取和管理用户认证状态
|
||||
*
|
||||
* 优先使用根加载器数据,然后再进行客户端API请求
|
||||
*/
|
||||
export function useAuth() {
|
||||
// 尝试从根加载器获取数据
|
||||
const rootData = useRouteLoaderData<{ auth?: { isAuthenticated: boolean; userInfo: UserInfo | null } }>('root');
|
||||
|
||||
const [isAuthenticated, setIsAuthenticated] = useState<boolean>(rootData?.auth?.isAuthenticated || false);
|
||||
const [userInfo, setUserInfo] = useState<UserInfo | null>(rootData?.auth?.userInfo || null);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(!rootData?.auth);
|
||||
|
||||
const fetcher = useFetcher<AuthUserResponse>();
|
||||
|
||||
useEffect(() => {
|
||||
if (!rootData?.auth && fetcher.state === 'idle' && !fetcher.data) {
|
||||
fetcher.load('/api/auth/user');
|
||||
}
|
||||
}, [fetcher, rootData]);
|
||||
|
||||
// 当获取数据后更新认证状态
|
||||
useEffect(() => {
|
||||
if (fetcher.data) {
|
||||
setIsAuthenticated(fetcher.data.isAuthenticated);
|
||||
setUserInfo(fetcher.data.isAuthenticated ? fetcher.data.claims || null : null);
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [fetcher.data]);
|
||||
|
||||
// 登录
|
||||
const signIn = useCallback((callbackUrl = '/api/auth/callback') => {
|
||||
window.location.href = `/api/auth/sign-in?redirectTo=${encodeURIComponent(callbackUrl)}`;
|
||||
}, []);
|
||||
|
||||
// 登出
|
||||
const signOut = useCallback(() => {
|
||||
window.location.href = '/api/auth/sign-out';
|
||||
}, []);
|
||||
|
||||
// 刷新用户信息
|
||||
const refreshUserInfo = useCallback(() => {
|
||||
setIsLoading(true);
|
||||
fetcher.load('/api/auth/user');
|
||||
}, [fetcher]);
|
||||
|
||||
return {
|
||||
isAuthenticated,
|
||||
isLoading,
|
||||
userInfo,
|
||||
signIn,
|
||||
signOut,
|
||||
refreshUserInfo,
|
||||
};
|
||||
}
|
||||
18
app/lib/hooks/useChatDeployment.ts
Normal file
18
app/lib/hooks/useChatDeployment.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import type { Deployment } from '@prisma/client';
|
||||
import { useLoaderData } from '@remix-run/react';
|
||||
import type { DeploymentPlatform } from '~/types/deployment';
|
||||
|
||||
/**
|
||||
* 仅支持 Chat 路由中的部署记录
|
||||
*/
|
||||
export function useChatDeployment() {
|
||||
const { deployments } = useLoaderData<{ deployments?: Deployment[] }>();
|
||||
|
||||
const getDeploymentByPlatform = (platform: DeploymentPlatform) => {
|
||||
return deployments?.find((deployment) => deployment.platform === platform);
|
||||
};
|
||||
|
||||
return {
|
||||
getDeploymentByPlatform,
|
||||
};
|
||||
}
|
||||
83
app/lib/hooks/useChatEntries.ts
Normal file
83
app/lib/hooks/useChatEntries.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { useFetcher } from '@remix-run/react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import type { ApiResponse } from '~/types/global';
|
||||
import { debounce } from '~/utils/debounce';
|
||||
|
||||
export interface ServerChatListResponse {
|
||||
chats: ServerChatItem[];
|
||||
total: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
}
|
||||
|
||||
export interface ServerChatItem {
|
||||
id: string;
|
||||
urlId?: string;
|
||||
description?: string;
|
||||
timestamp: string;
|
||||
lastMessage?: string;
|
||||
}
|
||||
|
||||
export function useChatEntries() {
|
||||
const chatListFetcher = useFetcher<ApiResponse<ServerChatListResponse>>();
|
||||
|
||||
const [lastFetchedQuery, setLastFetchedQuery] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [entries, setEntries] = useState<ServerChatItem[]>([]);
|
||||
|
||||
/**
|
||||
* 从后端调用接口查询列表数据
|
||||
* @param query 查询条件
|
||||
* @returns
|
||||
*/
|
||||
const loadServerChatEntries = useCallback(
|
||||
debounce((query = '') => {
|
||||
// 避免重复请求相同查询
|
||||
if (lastFetchedQuery === query && chatListFetcher.state === 'loading') {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setLastFetchedQuery(query);
|
||||
|
||||
chatListFetcher.load(`/api/chat/list?q=${encodeURIComponent(query)}`);
|
||||
}, 300),
|
||||
[chatListFetcher, lastFetchedQuery],
|
||||
);
|
||||
|
||||
// 在 chatListFetcher 数据加载完成后处理结果
|
||||
useEffect(() => {
|
||||
if (chatListFetcher.state === 'idle' && chatListFetcher.data) {
|
||||
try {
|
||||
const { data } = chatListFetcher.data;
|
||||
const serverChats = data?.chats || [];
|
||||
setEntries(serverChats);
|
||||
} catch (error) {
|
||||
console.error('Error processing server chats:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
}, [chatListFetcher, chatListFetcher]);
|
||||
|
||||
/**
|
||||
* 获取聊天列表
|
||||
* @param query 查询条件
|
||||
*/
|
||||
const loadChatEntries = useCallback(
|
||||
(query = '') => {
|
||||
// 从服务端加载搜索结果(如果搜索词长度>=2或为空)
|
||||
try {
|
||||
setIsLoading(true);
|
||||
if (query.length >= 2 || query === '') {
|
||||
loadServerChatEntries(query);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load chats from server:', error);
|
||||
}
|
||||
},
|
||||
[loadServerChatEntries],
|
||||
);
|
||||
|
||||
return { entries, isLoading, loadChatEntries };
|
||||
}
|
||||
75
app/lib/hooks/useChatHistory.ts
Normal file
75
app/lib/hooks/useChatHistory.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { useLoaderData, useSearchParams } from '@remix-run/react';
|
||||
import { useCallback } from 'react';
|
||||
import type { Page, Section } from '~/types/actions';
|
||||
import type { ChatWithMessages } from '~/types/chat';
|
||||
import { useEditorStorage } from '../persistence/editor';
|
||||
|
||||
export interface ProjectData {
|
||||
pages?: Page[];
|
||||
sections?: Section[];
|
||||
projectData?: any;
|
||||
}
|
||||
|
||||
export function useChatHistory() {
|
||||
const { chat } = useLoaderData<{ chat?: ChatWithMessages }>();
|
||||
const { loadEditorProject } = useEditorStorage();
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
/**
|
||||
* 加载项目数据,先从本地缓存中加载,如果本地缓存没有数据,则从服务器加载。
|
||||
*
|
||||
* @returns 项目数据。
|
||||
*/
|
||||
const getLoadProject = useCallback(async (): Promise<ProjectData | undefined> => {
|
||||
// 加载最新数据
|
||||
const pages = await loadEditorProject();
|
||||
if (pages) {
|
||||
return {
|
||||
pages,
|
||||
};
|
||||
}
|
||||
if (!chat) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { messages } = chat;
|
||||
if (!messages || messages.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 返回特定消息 ID 的项目数据
|
||||
const currentMessageId = searchParams.get('rewindTo');
|
||||
if (currentMessageId) {
|
||||
const data = messages?.find((message) => message.id === currentMessageId);
|
||||
const pages = data?.page?.pages;
|
||||
if (pages) {
|
||||
return {
|
||||
pages: pages as unknown as Page[],
|
||||
};
|
||||
}
|
||||
}
|
||||
// 没有指定消息 ID,返回最新的项目数据
|
||||
const lastMessage = messages[messages.length - 1];
|
||||
if (lastMessage.page) {
|
||||
return {
|
||||
pages: lastMessage.page.pages as unknown as Page[],
|
||||
};
|
||||
}
|
||||
}, [chat, searchParams]);
|
||||
|
||||
/**
|
||||
* 获取聊天最新描述
|
||||
* @param chatId
|
||||
* @returns
|
||||
*/
|
||||
const getChatLatestDescription = useCallback(() => {
|
||||
if (!chat) {
|
||||
return '';
|
||||
}
|
||||
return chat.description || '';
|
||||
}, [chat]);
|
||||
return {
|
||||
getLoadProject,
|
||||
getChatLatestDescription,
|
||||
};
|
||||
}
|
||||
221
app/lib/hooks/useChatMessage.ts
Normal file
221
app/lib/hooks/useChatMessage.ts
Normal file
@@ -0,0 +1,221 @@
|
||||
import { useChat } from '@ai-sdk/react';
|
||||
import { useSearchParams } from '@remix-run/react';
|
||||
import { DefaultChatTransport, type FileUIPart } from 'ai';
|
||||
import { animate } from 'framer-motion';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import type { ChatMessage } from '~/types/chat';
|
||||
import type { ProgressAnnotation, UPageUIMessage } from '~/types/message';
|
||||
import { cubicEasingFn } from '~/utils/easings';
|
||||
import { createScopedLogger } from '~/utils/logger';
|
||||
import { pagesToArtifacts } from '~/utils/page';
|
||||
import {
|
||||
getChatStarted,
|
||||
setAborted,
|
||||
setChatId,
|
||||
setChatStarted,
|
||||
setShowChat,
|
||||
setStreamingState,
|
||||
updateParseMessages,
|
||||
} from '../stores/ai-state';
|
||||
import { type SendChatMessageParams, setSendChatMessage } from '../stores/chat-message';
|
||||
import { webBuilderStore } from '../stores/web-builder';
|
||||
import { useChatUsage } from './useChatUsage';
|
||||
import { useMessageParser } from './useMessageParser';
|
||||
import { useProject } from './useProject';
|
||||
|
||||
const logger = createScopedLogger('useChatMessage');
|
||||
|
||||
export function useChatMessage({
|
||||
initialId,
|
||||
initialMessages,
|
||||
}: {
|
||||
initialId?: string;
|
||||
initialMessages?: ChatMessage[];
|
||||
}) {
|
||||
const SAVE_PROJECT_DELAY_MS = 1000;
|
||||
|
||||
const [searchParams] = useSearchParams();
|
||||
const { saveProject } = useProject();
|
||||
const { refreshUsageStats } = useChatUsage();
|
||||
const { parsedMessages, parseMessages } = useMessageParser();
|
||||
const [progressAnnotations, setProgressAnnotations] = useState<ProgressAnnotation[]>([]);
|
||||
const { id, messages, status, stop, sendMessage } = useChat<UPageUIMessage>({
|
||||
messages: initialMessages as unknown as UPageUIMessage[],
|
||||
transport: new DefaultChatTransport({
|
||||
api: '/api/chat',
|
||||
prepareSendMessagesRequest({ messages, body }) {
|
||||
return { body: { message: messages[messages.length - 1], ...body } };
|
||||
},
|
||||
}),
|
||||
// 节流,每 50ms 渲染一次 messages。
|
||||
experimental_throttle: 50,
|
||||
onData: (dataPart) => {
|
||||
if (dataPart.type === 'data-progress') {
|
||||
addProgressMessage(dataPart.data);
|
||||
}
|
||||
},
|
||||
onError: (e) => {
|
||||
logger.error('Request failed\n\n', e.message);
|
||||
toast.error('请求处理失败: ' + (e.message ? e.message : '没有返回详细信息'), { position: 'bottom-right' });
|
||||
|
||||
addStoppedProgressMessage('网络连接中断,响应已停止');
|
||||
},
|
||||
onFinish: ({ message }) => {
|
||||
setTimeout(() => {
|
||||
// 保存 editor project
|
||||
saveProject(message.id);
|
||||
}, SAVE_PROJECT_DELAY_MS);
|
||||
refreshUsageStats();
|
||||
logger.debug('Finished streaming');
|
||||
},
|
||||
});
|
||||
|
||||
const isLoading = useMemo(() => {
|
||||
return status === 'streaming';
|
||||
}, [status]);
|
||||
|
||||
const currentChatId = useMemo(() => {
|
||||
return initialId || id;
|
||||
}, [initialId, id]);
|
||||
|
||||
useEffect(() => {
|
||||
setSendChatMessage(sendChatMessage);
|
||||
if (initialMessages && initialMessages.length > 0) {
|
||||
setShowChat(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (messages.length > 0) {
|
||||
parseMessages(messages, isLoading);
|
||||
const url = new URL(window.location.href);
|
||||
url.pathname = `/chat/${currentChatId}`;
|
||||
window.history.replaceState({}, '', url);
|
||||
setChatId(currentChatId);
|
||||
}
|
||||
}, [messages, isLoading, parseMessages]);
|
||||
|
||||
useEffect(() => {
|
||||
if (messages.length > 0) {
|
||||
updateParseMessages(messages, parsedMessages);
|
||||
}
|
||||
}, [parsedMessages]);
|
||||
|
||||
useEffect(() => {
|
||||
setStreamingState(status === 'streaming');
|
||||
}, [status]);
|
||||
|
||||
const addProgressMessage = (progress: ProgressAnnotation) => {
|
||||
setProgressAnnotations((prev) => [...prev, progress]);
|
||||
};
|
||||
|
||||
const addStoppedProgressMessage = (message: string) => {
|
||||
if (progressAnnotations.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const lastProgressMessage = progressAnnotations[progressAnnotations.length - 1];
|
||||
const newProgressMessage = {
|
||||
type: 'progress',
|
||||
label: lastProgressMessage.label,
|
||||
status: 'stopped',
|
||||
order: lastProgressMessage.order + 1,
|
||||
message,
|
||||
} as ProgressAnnotation;
|
||||
addProgressMessage(newProgressMessage);
|
||||
};
|
||||
|
||||
const abort = () => {
|
||||
stop();
|
||||
setAborted(true);
|
||||
webBuilderStore.chatStore.abortAllActions();
|
||||
addStoppedProgressMessage('响应已中断');
|
||||
logger.debug('Chat response aborted');
|
||||
};
|
||||
|
||||
const runAnimation = async () => {
|
||||
if (getChatStarted()) {
|
||||
return;
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
animate('#examples', { opacity: 0, display: 'none' }, { duration: 0.1 }),
|
||||
animate('#intro', { opacity: 0, flex: 1 }, { duration: 0.2, ease: cubicEasingFn }),
|
||||
]);
|
||||
|
||||
setChatStarted(true);
|
||||
};
|
||||
|
||||
const fileToBase64 = (file: File): Promise<string | ArrayBuffer | null> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(reader.result);
|
||||
reader.onerror = reject;
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
};
|
||||
|
||||
const filesToFileUIPart = async (files: File[]): Promise<FileUIPart[]> => {
|
||||
const fileParts: FileUIPart[] = [];
|
||||
|
||||
await Promise.all(
|
||||
files.map(async (file) => {
|
||||
const base64 = await fileToBase64(file);
|
||||
fileParts.push({
|
||||
type: 'file',
|
||||
mediaType: file.type,
|
||||
filename: file.name,
|
||||
url: base64 as string,
|
||||
});
|
||||
}),
|
||||
);
|
||||
return fileParts;
|
||||
};
|
||||
|
||||
const sendChatMessage = async ({ messageContent, files, metadata }: SendChatMessageParams) => {
|
||||
if (!messageContent?.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
abort();
|
||||
return;
|
||||
}
|
||||
|
||||
const fileDataList = await filesToFileUIPart(files);
|
||||
|
||||
runAnimation();
|
||||
|
||||
const modifiedPages = webBuilderStore.pagesStore.getModifiedPages();
|
||||
const sections = webBuilderStore.pagesStore.sections;
|
||||
|
||||
const userUpdateArtifact = modifiedPages !== undefined ? pagesToArtifacts(modifiedPages, sections) : '';
|
||||
|
||||
sendMessage(
|
||||
{
|
||||
text: modifiedPages !== undefined ? `${userUpdateArtifact}${messageContent}` : messageContent,
|
||||
metadata,
|
||||
files: fileDataList,
|
||||
},
|
||||
{
|
||||
body: {
|
||||
chatId: currentChatId,
|
||||
rewindTo: searchParams.get('rewindTo'),
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (modifiedPages !== undefined) {
|
||||
webBuilderStore.pagesStore.resetPageModifications();
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
messages,
|
||||
progressAnnotations,
|
||||
isLoading,
|
||||
abort,
|
||||
sendChatMessage,
|
||||
};
|
||||
}
|
||||
186
app/lib/hooks/useChatOperate.ts
Normal file
186
app/lib/hooks/useChatOperate.ts
Normal file
@@ -0,0 +1,186 @@
|
||||
import { useFetcher, useNavigate } from '@remix-run/react';
|
||||
import { useCallback } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
deleteEditorProject,
|
||||
duplicateEditorProject,
|
||||
forkEditorProject,
|
||||
openEditorDatabase,
|
||||
} from '../persistence/editor';
|
||||
import { getChatId } from '../stores/ai-state';
|
||||
import { useProject } from './useProject';
|
||||
|
||||
export const editorDb = await openEditorDatabase();
|
||||
|
||||
export function useChatOperate() {
|
||||
const navigate = useNavigate();
|
||||
const deleteChatFetcher = useFetcher();
|
||||
const updateChatFetcher = useFetcher();
|
||||
|
||||
const { forkChat: forkRemoteChat } = useProject();
|
||||
/**
|
||||
* 聊天分叉功能
|
||||
*
|
||||
* @param chatId 要复制的聊天ID
|
||||
* @param messageId 消息ID,指定复制到哪条消息为止
|
||||
* @returns 新聊天的ID
|
||||
*/
|
||||
const forkMessage = async (chatId: string, messageId: string) => {
|
||||
if (!chatId) {
|
||||
return;
|
||||
}
|
||||
// 后端 fork 聊天信息,并返回新的聊天 ID
|
||||
const newId = await forkRemoteChat(chatId, messageId);
|
||||
|
||||
// 前端 fork editor 项目信息
|
||||
if (newId && editorDb) {
|
||||
await forkEditorProject(editorDb, chatId, messageId, newId);
|
||||
}
|
||||
|
||||
return newId;
|
||||
};
|
||||
|
||||
/**
|
||||
* 根据 ID 复制聊天
|
||||
*
|
||||
* @param listItemId 聊天 ID,如果不提供则复制当前聊天
|
||||
* @returns
|
||||
*/
|
||||
const duplicateCurrentChat = async (chatId?: string) => {
|
||||
if (!chatId && !getChatId()) {
|
||||
return;
|
||||
}
|
||||
const duplicateChatId = (chatId || getChatId()) as string;
|
||||
try {
|
||||
const newId = await forkRemoteChat(duplicateChatId);
|
||||
if (newId && editorDb) {
|
||||
await duplicateEditorProject(editorDb, duplicateChatId, newId);
|
||||
}
|
||||
navigate(`/chat/${newId}`, { replace: true });
|
||||
toast.success('聊天复制成功');
|
||||
} catch (error) {
|
||||
toast.error('复制聊天失败');
|
||||
console.log(error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 根据聊天 ID 删除聊天
|
||||
* @param chatId 聊天 ID
|
||||
* @returns
|
||||
*/
|
||||
const deleteChat = async (chatId: string): Promise<void> => {
|
||||
try {
|
||||
// 尝试通过API删除
|
||||
deleteChatFetcher.submit({ chatId }, { method: 'POST', action: '/api/chat/delete' });
|
||||
|
||||
// 同时从本地删除
|
||||
if (editorDb) {
|
||||
await deleteEditorProject(editorDb, chatId);
|
||||
}
|
||||
|
||||
console.log('Successfully deleted chat:', chatId);
|
||||
return Promise.resolve();
|
||||
} catch (error) {
|
||||
console.error('Failed to delete chat:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 根据选择的聊天 ID 批量删除聊天
|
||||
* @param itemsToDeleteIds 要删除的聊天 ID 列表
|
||||
* @returns
|
||||
*/
|
||||
const deleteSelectedItems = async (chatIds: string[]) => {
|
||||
if (chatIds.length === 0) {
|
||||
console.log('跳过批量删除: 没有要删除的聊天');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`开始批量删除 ${chatIds.length} 个聊天`, chatIds);
|
||||
|
||||
// 通过 API 删除多个聊天
|
||||
deleteChatFetcher.submit({ ids: JSON.stringify(chatIds) }, { method: 'POST', action: '/api/chat/delete' });
|
||||
|
||||
// 同时从本地删除
|
||||
if (editorDb) {
|
||||
let deletedCount = 0;
|
||||
const errors: string[] = [];
|
||||
|
||||
for (const id of chatIds) {
|
||||
try {
|
||||
await deleteEditorProject(editorDb, id);
|
||||
|
||||
deletedCount++;
|
||||
} catch (error) {
|
||||
console.error(`Error deleting local chat ${id}:`, error);
|
||||
errors.push(id);
|
||||
}
|
||||
}
|
||||
|
||||
// 日志本地删除结果
|
||||
if (errors.length === 0) {
|
||||
console.log(`Local deletion: ${deletedCount} chats deleted successfully`);
|
||||
} else {
|
||||
console.warn(`Local deletion: ${deletedCount} chats deleted. ${errors.length} failed.`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 通过API更新聊天描述
|
||||
* @param chatId 待更新的聊天 ID
|
||||
* @param description 更新后的描述
|
||||
* @returns
|
||||
*/
|
||||
const updateDescriptionViaApi = useCallback(
|
||||
async (chatId: string, description: string): Promise<boolean> => {
|
||||
try {
|
||||
// 使用表单格式提交数据
|
||||
updateChatFetcher.submit(
|
||||
{
|
||||
id: chatId,
|
||||
description: description,
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
action: '/api/chat/update',
|
||||
},
|
||||
);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Failed to update description via API:', error);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[updateChatFetcher],
|
||||
);
|
||||
|
||||
/**
|
||||
* 更新 Chat 描述
|
||||
* @param chatId 待更新的聊天 ID
|
||||
* @param description 更新后的描述
|
||||
*/
|
||||
const updateChatDescription = async (description: string, chatId?: string) => {
|
||||
const id = chatId || getChatId();
|
||||
if (!id) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await updateDescriptionViaApi(id, description);
|
||||
} catch (error) {
|
||||
toast.error('更新聊天描述失败: ' + (error as Error).message);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
updateChatFetcher,
|
||||
deleteChatFetcher,
|
||||
deleteChat,
|
||||
deleteSelectedItems,
|
||||
forkMessage,
|
||||
duplicateCurrentChat,
|
||||
updateChatDescription,
|
||||
};
|
||||
}
|
||||
70
app/lib/hooks/useChatUsage.ts
Normal file
70
app/lib/hooks/useChatUsage.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { useRevalidator, useRouteLoaderData } from '@remix-run/react';
|
||||
import { useState } from 'react';
|
||||
import { useAuth } from './useAuth';
|
||||
|
||||
/**
|
||||
* 聊天使用量统计类型定义
|
||||
*/
|
||||
export interface ChatUsageStats {
|
||||
total: {
|
||||
_sum: {
|
||||
inputTokens: number | null;
|
||||
outputTokens: number | null;
|
||||
cachedTokens: number | null;
|
||||
totalTokens: number | null;
|
||||
};
|
||||
_count: number;
|
||||
};
|
||||
byStatus: Array<{
|
||||
status: string;
|
||||
_count: number;
|
||||
_sum: {
|
||||
totalTokens: number | null;
|
||||
};
|
||||
}>;
|
||||
byChat: Array<{
|
||||
chatId: string;
|
||||
_count: number;
|
||||
_sum: {
|
||||
totalTokens: number | null;
|
||||
};
|
||||
}>;
|
||||
byDate: Array<{
|
||||
date: string;
|
||||
count: number;
|
||||
totalTokens: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
/**
|
||||
* useChatUsage Hook - 获取用户聊天使用量统计
|
||||
*/
|
||||
export function useChatUsage() {
|
||||
const rootData = useRouteLoaderData<{ chatUsage?: ChatUsageStats }>('root');
|
||||
const { isAuthenticated } = useAuth();
|
||||
const revalidator = useRevalidator();
|
||||
|
||||
const usageStats = isAuthenticated ? rootData?.chatUsage || null : null;
|
||||
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||
|
||||
/**
|
||||
* 刷新聊天使用统计数据
|
||||
* 通过 Remix 的 revalidator 重新验证根路由数据
|
||||
*/
|
||||
const refreshUsageStats = () => {
|
||||
setIsLoading(true);
|
||||
revalidator.revalidate();
|
||||
setIsLoading(revalidator.state === 'loading');
|
||||
};
|
||||
|
||||
// 当 revalidator 状态变化时更新 loading 状态
|
||||
if (revalidator.state === 'idle' && isLoading) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
|
||||
return {
|
||||
usageStats,
|
||||
isLoading: isLoading || revalidator.state === 'loading',
|
||||
refreshUsageStats,
|
||||
};
|
||||
}
|
||||
89
app/lib/hooks/useDebugStatus.ts
Normal file
89
app/lib/hooks/useDebugStatus.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { acknowledgeError, acknowledgeWarning, type DebugIssue, getDebugStatus } from '~/lib/api/debug';
|
||||
|
||||
const ACKNOWLEDGED_DEBUG_ISSUES_KEY = 'upage_acknowledged_debug_issues';
|
||||
|
||||
const getAcknowledgedIssues = (): string[] => {
|
||||
try {
|
||||
const stored = localStorage.getItem(ACKNOWLEDGED_DEBUG_ISSUES_KEY);
|
||||
return stored ? JSON.parse(stored) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
const setAcknowledgedIssues = (issueIds: string[]) => {
|
||||
try {
|
||||
localStorage.setItem(ACKNOWLEDGED_DEBUG_ISSUES_KEY, JSON.stringify(issueIds));
|
||||
} catch (error) {
|
||||
console.error('Failed to persist acknowledged debug issues:', error);
|
||||
}
|
||||
};
|
||||
|
||||
export const useDebugStatus = () => {
|
||||
const [hasActiveWarnings, setHasActiveWarnings] = useState(false);
|
||||
const [activeIssues, setActiveIssues] = useState<DebugIssue[]>([]);
|
||||
const [acknowledgedIssueIds, setAcknowledgedIssueIds] = useState<string[]>(() => getAcknowledgedIssues());
|
||||
|
||||
const checkDebugStatus = async () => {
|
||||
try {
|
||||
const status = await getDebugStatus();
|
||||
const issues: DebugIssue[] = [
|
||||
...status.warnings.map((w) => ({ ...w, type: 'warning' as const })),
|
||||
...status.errors.map((e) => ({ ...e, type: 'error' as const })),
|
||||
].filter((issue) => !acknowledgedIssueIds.includes(issue.id));
|
||||
|
||||
setActiveIssues(issues);
|
||||
setHasActiveWarnings(issues.length > 0);
|
||||
} catch (error) {
|
||||
console.error('Failed to check debug status:', error);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// Check immediately and then every 5 seconds
|
||||
checkDebugStatus();
|
||||
|
||||
const interval = setInterval(checkDebugStatus, 5 * 1000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [acknowledgedIssueIds]);
|
||||
|
||||
const acknowledgeIssue = async (issue: DebugIssue) => {
|
||||
try {
|
||||
if (issue.type === 'warning') {
|
||||
await acknowledgeWarning(issue.id);
|
||||
} else {
|
||||
await acknowledgeError(issue.id);
|
||||
}
|
||||
|
||||
const newAcknowledgedIds = [...acknowledgedIssueIds, issue.id];
|
||||
setAcknowledgedIssueIds(newAcknowledgedIds);
|
||||
setAcknowledgedIssues(newAcknowledgedIds);
|
||||
setActiveIssues((prev) => prev.filter((i) => i.id !== issue.id));
|
||||
setHasActiveWarnings(activeIssues.length > 1);
|
||||
} catch (error) {
|
||||
console.error('Failed to acknowledge issue:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const acknowledgeAllIssues = async () => {
|
||||
try {
|
||||
await Promise.all(
|
||||
activeIssues.map((issue) =>
|
||||
issue.type === 'warning' ? acknowledgeWarning(issue.id) : acknowledgeError(issue.id),
|
||||
),
|
||||
);
|
||||
|
||||
const newAcknowledgedIds = [...acknowledgedIssueIds, ...activeIssues.map((i) => i.id)];
|
||||
setAcknowledgedIssueIds(newAcknowledgedIds);
|
||||
setAcknowledgedIssues(newAcknowledgedIds);
|
||||
setActiveIssues([]);
|
||||
setHasActiveWarnings(false);
|
||||
} catch (error) {
|
||||
console.error('Failed to acknowledge all issues:', error);
|
||||
}
|
||||
};
|
||||
|
||||
return { hasActiveWarnings, activeIssues, acknowledgeIssue, acknowledgeAllIssues };
|
||||
};
|
||||
228
app/lib/hooks/useDeploymentRecords.ts
Normal file
228
app/lib/hooks/useDeploymentRecords.ts
Normal file
@@ -0,0 +1,228 @@
|
||||
import { useRevalidator } from '@remix-run/react';
|
||||
import { useCallback, useState } from 'react';
|
||||
import type { DeploymentPlatform } from '~/types/deployment';
|
||||
import { DeploymentPlatformEnum } from '~/types/deployment';
|
||||
import { useAuth } from './useAuth';
|
||||
|
||||
export interface DeploymentRecord {
|
||||
id: string;
|
||||
userId: string;
|
||||
chatId: string;
|
||||
platform: string;
|
||||
deploymentId: string;
|
||||
url: string;
|
||||
status: string;
|
||||
metadata?: Record<string, any>;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
chat?: {
|
||||
id: string;
|
||||
description: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
export interface DeploymentStats {
|
||||
totalSites: number;
|
||||
totalDays: number;
|
||||
totalVisits: number;
|
||||
totalBytes?: number;
|
||||
lastAccess?: string | null;
|
||||
sitesByPlatform?: Record<string, number>;
|
||||
}
|
||||
|
||||
const platformEndpoints: Record<string, string> = {
|
||||
[DeploymentPlatformEnum._1PANEL]: '/api/1panel',
|
||||
[DeploymentPlatformEnum.NETLIFY]: '/api/netlify',
|
||||
[DeploymentPlatformEnum.VERCEL]: '/api/vercel',
|
||||
};
|
||||
function getPlatformEndpoint(platform: string, action: string): string {
|
||||
const baseEndpoint = platformEndpoints[platform];
|
||||
if (!baseEndpoint) {
|
||||
throw new Error(`不支持的平台: ${platform}`);
|
||||
}
|
||||
return `${baseEndpoint}/${action}`;
|
||||
}
|
||||
|
||||
export function useDeploymentRecords() {
|
||||
const { revalidate } = useRevalidator();
|
||||
const { userInfo, isAuthenticated } = useAuth();
|
||||
const [deploymentRecords, setDeploymentRecords] = useState<Record<string, DeploymentRecord[]>>({});
|
||||
const [totals, setTotals] = useState<Record<string, number>>({});
|
||||
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||
const [loadingPlatforms, setLoadingPlatforms] = useState<Record<string, boolean>>({});
|
||||
const [stats, setStats] = useState<DeploymentStats>({
|
||||
totalSites: 0,
|
||||
totalDays: 30,
|
||||
totalVisits: 0,
|
||||
sitesByPlatform: {},
|
||||
});
|
||||
|
||||
const loadPlatformRecords = useCallback(
|
||||
async ({ offset = 0, limit = 10, platform }: { offset?: number; limit?: number; platform: DeploymentPlatform }) => {
|
||||
if (!isAuthenticated || !userInfo?.sub) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (platform) {
|
||||
setLoadingPlatforms((prev) => ({ ...prev, [platform]: true }));
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/deployments?offset=${offset}&limit=${limit}&platform=${platform}`);
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch deployment records');
|
||||
}
|
||||
|
||||
const responseData = await response.json();
|
||||
if (!responseData.success) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { deployments } = responseData.data;
|
||||
|
||||
setDeploymentRecords((prev) => ({
|
||||
...prev,
|
||||
[platform]: offset === 0 ? deployments : [...(prev[platform] || []), ...deployments],
|
||||
}));
|
||||
|
||||
setTotals((prev) => ({
|
||||
...prev,
|
||||
[platform]: offset === 0 ? deployments.length : prev[platform] + deployments.length,
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('Error loading deployment records:', error);
|
||||
} finally {
|
||||
if (platform) {
|
||||
setLoadingPlatforms((prev) => ({ ...prev, [platform]: false }));
|
||||
}
|
||||
}
|
||||
},
|
||||
[isAuthenticated, userInfo],
|
||||
);
|
||||
|
||||
const loadStats = useCallback(async () => {
|
||||
if (!isAuthenticated || !userInfo?.sub) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
setStats({
|
||||
totalSites: 0,
|
||||
totalDays: 30,
|
||||
totalVisits: 0,
|
||||
sitesByPlatform: {},
|
||||
});
|
||||
|
||||
const response = await fetch('/api/deployments/stats');
|
||||
if (response.ok) {
|
||||
const responseData = await response.json();
|
||||
if (!responseData.success) {
|
||||
return;
|
||||
}
|
||||
const data = responseData.data;
|
||||
|
||||
setStats((prev) => ({
|
||||
...prev,
|
||||
totalSites: data.totalSites || 0,
|
||||
totalDays: data.totalDays || 30,
|
||||
totalVisits: data.totalVisits || 0,
|
||||
totalBytes: data.totalBytes || 0,
|
||||
lastAccess: data.lastAccess || null,
|
||||
sitesByPlatform: data.sitesByPlatform || {},
|
||||
}));
|
||||
|
||||
if (data.sitesByPlatform) {
|
||||
setTotals((prev) => ({
|
||||
...prev,
|
||||
...data.sitesByPlatform,
|
||||
}));
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading deployment stats:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [isAuthenticated, userInfo]);
|
||||
|
||||
const refreshDeploymentRecords = useCallback(() => {
|
||||
loadStats();
|
||||
for (const platform of Object.values(DeploymentPlatformEnum)) {
|
||||
loadPlatformRecords({ platform });
|
||||
}
|
||||
}, [loadStats, loadPlatformRecords]);
|
||||
|
||||
const toggleAccess = useCallback(
|
||||
async (id: string, platform: string) => {
|
||||
try {
|
||||
const endpoint = getPlatformEndpoint(platform, 'toggle-access');
|
||||
const response = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ id }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.message || '操作失败');
|
||||
}
|
||||
|
||||
const responseData = await response.json();
|
||||
if (!responseData.success) {
|
||||
throw new Error(responseData.message || '操作失败');
|
||||
}
|
||||
|
||||
return responseData.data;
|
||||
} catch (error) {
|
||||
console.error('切换访问状态失败:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[isAuthenticated, userInfo],
|
||||
);
|
||||
|
||||
const deletePage = useCallback(async (id: string, platform: string) => {
|
||||
try {
|
||||
const endpoint = getPlatformEndpoint(platform, 'delete');
|
||||
const response = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ id }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.message || '删除失败');
|
||||
}
|
||||
|
||||
const responseData = await response.json();
|
||||
if (!responseData.success) {
|
||||
throw new Error(responseData.message || '删除失败');
|
||||
}
|
||||
|
||||
revalidate();
|
||||
return responseData.data;
|
||||
} catch (error) {
|
||||
console.error('删除部署失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}, []);
|
||||
|
||||
return {
|
||||
deploymentRecords,
|
||||
totals,
|
||||
stats,
|
||||
isLoading,
|
||||
loadingPlatforms,
|
||||
loadPlatformRecords,
|
||||
refreshDeploymentRecords,
|
||||
isPlatformLoading: (platform: string) => loadingPlatforms[platform] || false,
|
||||
toggleAccess,
|
||||
deletePage,
|
||||
};
|
||||
}
|
||||
126
app/lib/hooks/useEditChatDescription.ts
Normal file
126
app/lib/hooks/useEditChatDescription.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { useChatOperate } from './useChatOperate';
|
||||
|
||||
interface EditChatDescriptionOptions {
|
||||
initialDescription: string;
|
||||
chatId?: string;
|
||||
}
|
||||
|
||||
type EditChatDescriptionHook = {
|
||||
editing: boolean;
|
||||
setCurrentDescription: (description: string) => void;
|
||||
handleChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
handleBlur: () => Promise<void>;
|
||||
handleSubmit: (event: React.FormEvent) => Promise<void>;
|
||||
handleKeyDown: (event: React.KeyboardEvent<HTMLInputElement>) => Promise<void>;
|
||||
currentDescription: string | undefined;
|
||||
toggleEditMode: () => void;
|
||||
updateChatDescription: (description: string, chatId?: string) => Promise<void>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to manage the state and behavior for editing chat descriptions.
|
||||
*
|
||||
* Offers functions to:
|
||||
* - Switch between edit and view modes.
|
||||
* - Manage input changes, blur, and form submission events.
|
||||
* - Save updates to backend API, fallback to IndexedDB and optionally to the global application state.
|
||||
*
|
||||
* @param {Object} options
|
||||
* @param {string} options.initialDescription - The current chat description.
|
||||
* @param {string} options.customChatId - Optional ID for updating the description via the sidebar.
|
||||
* @returns {EditChatDescriptionHook} Methods and state for managing description edits.
|
||||
*/
|
||||
export function useEditChatDescription({
|
||||
initialDescription,
|
||||
chatId,
|
||||
}: EditChatDescriptionOptions): EditChatDescriptionHook {
|
||||
const { updateChatDescription } = useChatOperate();
|
||||
// 从 messages 中获取到的描述
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [currentDescription, setCurrentDescription] = useState(initialDescription);
|
||||
|
||||
const toggleEditMode = useCallback(() => setEditing((prev) => !prev), []);
|
||||
|
||||
const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setCurrentDescription(e.target.value);
|
||||
}, []);
|
||||
|
||||
const handleBlur = useCallback(async () => {
|
||||
setCurrentDescription(initialDescription);
|
||||
toggleEditMode();
|
||||
}, [toggleEditMode]);
|
||||
|
||||
const isValidDescription = useCallback(
|
||||
(desc: string): boolean => {
|
||||
const trimmedDesc = desc.trim();
|
||||
|
||||
if (trimmedDesc === initialDescription) {
|
||||
toggleEditMode();
|
||||
return false; // No change, skip validation
|
||||
}
|
||||
|
||||
const lengthValid = trimmedDesc.length > 0 && trimmedDesc.length <= 100;
|
||||
|
||||
// 允许中文字符、字母、数字、空格和常见标点符号,排除可能引起问题的字符
|
||||
const characterValid = /^[\u4e00-\u9fa5a-zA-Z0-9\s\-_.,!?()[\]{}'"]+$/.test(trimmedDesc);
|
||||
|
||||
if (!lengthValid) {
|
||||
toast.error('描述必须介于 1 和 100 个字符之间。');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!characterValid) {
|
||||
toast.error('描述只能包含字母、数字、空格和基本标点符号。');
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
[initialDescription, toggleEditMode],
|
||||
);
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
async (event?: React.FormEvent) => {
|
||||
event?.preventDefault();
|
||||
|
||||
if (!isValidDescription(currentDescription!)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (!currentDescription) {
|
||||
return;
|
||||
}
|
||||
updateChatDescription(currentDescription!, chatId);
|
||||
} catch (error) {
|
||||
toast.error('更新聊天描述失败: ' + (error as Error).message);
|
||||
}
|
||||
|
||||
toggleEditMode();
|
||||
},
|
||||
[currentDescription, chatId, toggleEditMode, updateChatDescription, isValidDescription],
|
||||
);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
async (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Escape') {
|
||||
await handleBlur();
|
||||
}
|
||||
},
|
||||
[handleBlur],
|
||||
);
|
||||
|
||||
return {
|
||||
editing,
|
||||
setCurrentDescription,
|
||||
handleChange,
|
||||
handleBlur,
|
||||
handleSubmit,
|
||||
handleKeyDown,
|
||||
currentDescription,
|
||||
toggleEditMode,
|
||||
updateChatDescription,
|
||||
};
|
||||
}
|
||||
53
app/lib/hooks/useEditorCommands.ts
Normal file
53
app/lib/hooks/useEditorCommands.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { type RefObject, useCallback, useEffect } from 'react';
|
||||
import { editorCommands } from '~/lib/stores/editor';
|
||||
import type { Editor } from '~/types/editor';
|
||||
import { logger } from '~/utils/logger';
|
||||
|
||||
/**
|
||||
* 用于监听编辑器命令的自定义 hook
|
||||
* @param editorRef 编辑器实例引用
|
||||
* @returns 包含处理特定元素的方法
|
||||
*/
|
||||
export function useEditorCommands(editorRef: RefObject<Editor | null>) {
|
||||
// 处理滚动到指定元素
|
||||
const scrollToElement = useCallback(
|
||||
(domId: string) => {
|
||||
const editor = editorRef.current;
|
||||
if (!editor) {
|
||||
return;
|
||||
}
|
||||
editor.scrollToElement(`#${domId}`);
|
||||
},
|
||||
[editorRef],
|
||||
);
|
||||
|
||||
// 监听编辑器命令
|
||||
useEffect(() => {
|
||||
const unsubscribe = editorCommands.listen((command) => {
|
||||
if (!command) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (command.type) {
|
||||
case 'scrollToElement': {
|
||||
const { domId } = command.payload;
|
||||
scrollToElement(domId);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
logger.warn('未知的编辑器命令类型', command);
|
||||
}
|
||||
|
||||
// 处理完命令后重置
|
||||
editorCommands.set(null);
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
}, [scrollToElement]);
|
||||
|
||||
return {
|
||||
scrollToElement,
|
||||
};
|
||||
}
|
||||
106
app/lib/hooks/useMessageParser.ts
Normal file
106
app/lib/hooks/useMessageParser.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
import { StreamingMessageParser } from '~/lib/runtime/message-parser';
|
||||
import { webBuilderStore } from '~/lib/stores/web-builder';
|
||||
import type { UPageUIMessage } from '~/types/message';
|
||||
import { createScopedLogger } from '~/utils/logger';
|
||||
|
||||
const logger = createScopedLogger('useMessageParser');
|
||||
|
||||
const chatStore = webBuilderStore.chatStore;
|
||||
const messageParser = new StreamingMessageParser({
|
||||
callbacks: {
|
||||
onArtifactOpen: (data) => {
|
||||
logger.trace('onArtifactOpen', data);
|
||||
|
||||
webBuilderStore.showWorkbench.set(true);
|
||||
chatStore.addArtifact(data);
|
||||
chatStore.setCurrentMessageId(data.messageId);
|
||||
},
|
||||
onArtifactClose: (data) => {
|
||||
logger.trace('onArtifactClose');
|
||||
|
||||
chatStore.updateArtifact(data, { closed: true });
|
||||
},
|
||||
onActionOpen: (data) => {
|
||||
logger.trace('onActionOpen', data.action);
|
||||
chatStore.addAction(data);
|
||||
},
|
||||
onActionStream: (data) => {
|
||||
logger.trace('onActionStream', data.action);
|
||||
chatStore.runAction(data, true);
|
||||
},
|
||||
onActionClose: (data) => {
|
||||
logger.trace('onActionClose', data.action);
|
||||
chatStore.addAction(data);
|
||||
chatStore.runAction(data);
|
||||
},
|
||||
},
|
||||
});
|
||||
const extractTextContent = (message: UPageUIMessage) =>
|
||||
message.parts
|
||||
.filter((part) => part.type === 'text')
|
||||
.map((part) => part.text)
|
||||
.join('\n');
|
||||
|
||||
export function useMessageParser() {
|
||||
const [parsedMessages, setParsedMessages] = useState<{ [key: number]: string }>({});
|
||||
const messageIdMap = useRef<Map<number, string>>(new Map());
|
||||
|
||||
const parseMessages = useCallback((messages: UPageUIMessage[], isLoading: boolean) => {
|
||||
let reset = false;
|
||||
|
||||
if (import.meta.env.DEV && !isLoading) {
|
||||
reset = true;
|
||||
messageParser.reset();
|
||||
}
|
||||
|
||||
for (const [index, message] of messages.entries()) {
|
||||
if (message.role === 'assistant' || message.role === 'user') {
|
||||
if (!messageIdMap.current.has(index)) {
|
||||
messageIdMap.current.set(index, message.id);
|
||||
}
|
||||
// 当对应位置的 message id 发生变化时,重置解析
|
||||
if (messageIdMap.current.get(index) !== message.id) {
|
||||
reset = true;
|
||||
messageParser.reset();
|
||||
messageIdMap.current.set(index, message.id);
|
||||
}
|
||||
|
||||
try {
|
||||
const textContent = extractTextContent(message);
|
||||
// 检查消息内容是否存在
|
||||
if (textContent === undefined || textContent === null) {
|
||||
logger.warn(`Message ${message.id} has no text content`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 解析消息内容
|
||||
const newParsedContent = messageParser.parse(message.id, textContent);
|
||||
if (!newParsedContent) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 更新解析后的消息
|
||||
setParsedMessages((prevParsed) => {
|
||||
const updatedContent = !reset ? (prevParsed[index] || '') + newParsedContent : newParsedContent;
|
||||
return {
|
||||
...prevParsed,
|
||||
[index]: updatedContent,
|
||||
};
|
||||
});
|
||||
} catch (error) {
|
||||
// 捕获并记录解析过程中的错误
|
||||
logger.error(`Error parsing message ${message.id}:`, error);
|
||||
|
||||
// 出错时保留原始消息内容
|
||||
setParsedMessages((prevParsed) => ({
|
||||
...prevParsed,
|
||||
[index]: extractTextContent(message),
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
return { parsedMessages, parseMessages };
|
||||
}
|
||||
51
app/lib/hooks/useNotifications.ts
Normal file
51
app/lib/hooks/useNotifications.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { getNotifications, markNotificationRead, type Notification } from '~/lib/api/notifications';
|
||||
import { logStore } from '~/lib/stores/logs';
|
||||
|
||||
export const useNotifications = () => {
|
||||
const [hasUnreadNotifications, setHasUnreadNotifications] = useState(false);
|
||||
const [unreadNotifications, setUnreadNotifications] = useState<Notification[]>([]);
|
||||
const logs = useStore(logStore.logs);
|
||||
|
||||
const checkNotifications = async () => {
|
||||
try {
|
||||
const notifications = await getNotifications();
|
||||
const unread = notifications.filter((n) => !logStore.isRead(n.id));
|
||||
setUnreadNotifications(unread);
|
||||
setHasUnreadNotifications(unread.length > 0);
|
||||
} catch (error) {
|
||||
console.error('Failed to check notifications:', error);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// Check immediately and then every minute
|
||||
checkNotifications();
|
||||
|
||||
const interval = setInterval(checkNotifications, 60 * 1000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [logs]); // Re-run when logs change
|
||||
|
||||
const markAsRead = async (notificationId: string) => {
|
||||
try {
|
||||
await markNotificationRead(notificationId);
|
||||
await checkNotifications();
|
||||
} catch (error) {
|
||||
console.error('Failed to mark notification as read:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const markAllAsRead = async () => {
|
||||
try {
|
||||
const notifications = await getNotifications();
|
||||
await Promise.all(notifications.map((n) => markNotificationRead(n.id)));
|
||||
await checkNotifications();
|
||||
} catch (error) {
|
||||
console.error('Failed to mark all notifications as read:', error);
|
||||
}
|
||||
};
|
||||
|
||||
return { hasUnreadNotifications, unreadNotifications, markAsRead, markAllAsRead };
|
||||
};
|
||||
108
app/lib/hooks/useProject.ts
Normal file
108
app/lib/hooks/useProject.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import { useFetcher } from '@remix-run/react';
|
||||
import type { ApiResponse } from '~/types/global';
|
||||
import { createScopedLogger } from '~/utils/logger';
|
||||
import { useEditorStorage } from '../persistence/editor';
|
||||
import { webBuilderStore } from '../stores/web-builder';
|
||||
|
||||
const logger = createScopedLogger('useGrapesProject');
|
||||
|
||||
export function useProject() {
|
||||
const fetcher = useFetcher();
|
||||
const { saveEditorProject } = useEditorStorage();
|
||||
|
||||
/**
|
||||
* 保存项目数据到后端数据库
|
||||
*
|
||||
* @param messageId 消息ID
|
||||
* @param projectData GrapesJS项目数据
|
||||
* @param sections 页面区块数据
|
||||
* @returns 保存是否成功
|
||||
*/
|
||||
async function saveProject(messageId: string) {
|
||||
if (!messageId) {
|
||||
logger.error('保存项目失败: 消息ID不能为空');
|
||||
return false;
|
||||
}
|
||||
|
||||
// 保存之前,先保存所有页面
|
||||
await webBuilderStore.saveAllPages();
|
||||
const projectPages = Object.values(webBuilderStore.pagesStore.pages.get()).filter((page) => page !== undefined);
|
||||
const projectSections = Object.values(webBuilderStore.pagesStore.sections.get())
|
||||
.filter((section) => section !== undefined)
|
||||
.map((section) => ({
|
||||
...section,
|
||||
actionId: section.id,
|
||||
}));
|
||||
if (projectPages.length === 0 || projectSections.length === 0) {
|
||||
logger.error('保存项目失败: 页面或 Section 不能为空');
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
// 先保存在本地数据中
|
||||
saveEditorProject(messageId, projectPages, projectSections);
|
||||
// 再调用远程接口保存到后端数据库
|
||||
// 使用fetcher调用API保存项目数据
|
||||
fetcher.submit(
|
||||
{
|
||||
messageId,
|
||||
pages: JSON.stringify(projectPages),
|
||||
sections: JSON.stringify(projectSections),
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
action: '/api/project',
|
||||
},
|
||||
);
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error('保存GrapesJS项目失败:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 复制聊天及其相关内容(消息、GrapesJS项目数据和区块)
|
||||
*
|
||||
* @param chatId 要复制的聊天ID
|
||||
* @param messageId 可选参数,当提供时只复制到该消息为止的消息(包含该消息);不提供时复制整个聊天
|
||||
* @returns 成功时返回新聊天的ID,失败时返回undefined
|
||||
*/
|
||||
async function forkChat(chatId: string, messageId?: string) {
|
||||
if (!chatId) {
|
||||
logger.error('复制聊天失败: 聊天ID不能为空');
|
||||
return undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
// 调用后端API复制聊天
|
||||
const response = await fetch('/api/chat/fork', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
sourceChatId: chatId,
|
||||
messageId,
|
||||
}),
|
||||
});
|
||||
|
||||
const { data, success, message } = (await response.json()) as ApiResponse<string>;
|
||||
|
||||
if (!response.ok || !success) {
|
||||
logger.error('复制聊天失败:', message);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
logger.info(`成功复制聊天 ${chatId},新聊天ID: ${data}`);
|
||||
return data;
|
||||
} catch (error) {
|
||||
logger.error('复制聊天过程中发生错误:', error);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
saveProject,
|
||||
forkChat,
|
||||
};
|
||||
}
|
||||
50
app/lib/hooks/usePromptEnhancer.ts
Normal file
50
app/lib/hooks/usePromptEnhancer.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { useChat } from '@ai-sdk/react';
|
||||
import { DefaultChatTransport, type UIMessage } from 'ai';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { createScopedLogger } from '~/utils/logger';
|
||||
|
||||
const logger = createScopedLogger('usePromptEnhancement');
|
||||
|
||||
export function usePromptEnhancer() {
|
||||
const { messages, sendMessage } = useChat<UIMessage>({
|
||||
transport: new DefaultChatTransport({
|
||||
api: '/api/enhancer',
|
||||
}),
|
||||
onError: (error) => {
|
||||
logger.error('Error enhancing prompt:', error);
|
||||
toast.error('提示词优化失败');
|
||||
},
|
||||
onFinish: () => {
|
||||
setIsLoading(false);
|
||||
toast.success('提示词优化成功');
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (messages.length > 0) {
|
||||
const lastMessage = messages[messages.length - 1];
|
||||
if (lastMessage.role === 'assistant') {
|
||||
const content = lastMessage.parts.find((part) => part.type === 'text')?.text;
|
||||
setEnhancedInput(content || '');
|
||||
}
|
||||
}
|
||||
}, [messages]);
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [enhancedInput, setEnhancedInput] = useState('');
|
||||
|
||||
const resetEnhancer = () => {
|
||||
setIsLoading(false);
|
||||
setEnhancedInput('');
|
||||
};
|
||||
|
||||
const enhancePrompt = async (originalInput: string) => {
|
||||
setIsLoading(true);
|
||||
sendMessage({
|
||||
text: originalInput,
|
||||
});
|
||||
};
|
||||
|
||||
return { enhancedInput, isLoading, enhancePrompt, resetEnhancer };
|
||||
}
|
||||
148
app/lib/hooks/useSettings.ts
Normal file
148
app/lib/hooks/useSettings.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
import { useStore } from '@nanostores/react';
|
||||
import Cookies from 'js-cookie';
|
||||
import { useCallback, useState } from 'react';
|
||||
import type { TabVisibilityConfig, TabWindowConfig } from '~/components/@settings/core/types';
|
||||
import { getLocalStorage, setLocalStorage } from '~/lib/persistence';
|
||||
import { logStore } from '~/lib/stores/logs';
|
||||
import {
|
||||
isDebugMode,
|
||||
isEventLogsEnabled,
|
||||
latestBranchStore,
|
||||
promptStore,
|
||||
resetTabConfiguration as resetTabConfig,
|
||||
tabConfigurationStore,
|
||||
updateEventLogs,
|
||||
updateLatestBranch,
|
||||
updatePromptId,
|
||||
updateTabConfiguration as updateTabConfig,
|
||||
} from '~/lib/stores/settings';
|
||||
|
||||
export interface Settings {
|
||||
theme: 'light' | 'dark' | 'system';
|
||||
language: string;
|
||||
notifications: boolean;
|
||||
eventLogs: boolean;
|
||||
timezone: string;
|
||||
tabConfiguration: TabWindowConfig;
|
||||
}
|
||||
|
||||
export interface UseSettingsReturn {
|
||||
// Theme and UI settings
|
||||
setTheme: (theme: Settings['theme']) => void;
|
||||
setLanguage: (language: string) => void;
|
||||
setNotifications: (enabled: boolean) => void;
|
||||
setEventLogs: (enabled: boolean) => void;
|
||||
setTimezone: (timezone: string) => void;
|
||||
settings: Settings;
|
||||
|
||||
// Debug and development settings
|
||||
debug: boolean;
|
||||
enableDebugMode: (enabled: boolean) => void;
|
||||
eventLogs: boolean;
|
||||
promptId: string;
|
||||
setPromptId: (promptId: string) => void;
|
||||
isLatestBranch: boolean;
|
||||
enableLatestBranch: (enabled: boolean) => void;
|
||||
|
||||
// Tab configuration
|
||||
tabConfiguration: TabWindowConfig;
|
||||
updateTabConfiguration: (config: TabVisibilityConfig) => void;
|
||||
resetTabConfiguration: () => void;
|
||||
}
|
||||
|
||||
export function useSettings(): UseSettingsReturn {
|
||||
const debug = useStore(isDebugMode);
|
||||
const eventLogs = useStore(isEventLogsEnabled);
|
||||
const promptId = useStore(promptStore);
|
||||
const isLatestBranch = useStore(latestBranchStore);
|
||||
const tabConfiguration = useStore(tabConfigurationStore);
|
||||
const [settings, setSettings] = useState<Settings>(() => {
|
||||
const storedSettings = getLocalStorage('settings');
|
||||
return {
|
||||
theme: storedSettings?.theme || 'system',
|
||||
language: storedSettings?.language || 'en',
|
||||
notifications: storedSettings?.notifications ?? true,
|
||||
eventLogs: storedSettings?.eventLogs ?? true,
|
||||
timezone: storedSettings?.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
tabConfiguration,
|
||||
};
|
||||
});
|
||||
|
||||
const saveSettings = useCallback((newSettings: Partial<Settings>) => {
|
||||
setSettings((prev) => {
|
||||
const updated = { ...prev, ...newSettings };
|
||||
setLocalStorage('settings', updated);
|
||||
|
||||
return updated;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const enableDebugMode = useCallback((enabled: boolean) => {
|
||||
isDebugMode.set(enabled);
|
||||
logStore.logSystem(`Debug mode ${enabled ? 'enabled' : 'disabled'}`);
|
||||
Cookies.set('isDebugEnabled', String(enabled));
|
||||
}, []);
|
||||
|
||||
const setEventLogs = useCallback((enabled: boolean) => {
|
||||
updateEventLogs(enabled);
|
||||
logStore.logSystem(`Event logs ${enabled ? 'enabled' : 'disabled'}`);
|
||||
}, []);
|
||||
|
||||
const setPromptId = useCallback((id: string) => {
|
||||
updatePromptId(id);
|
||||
logStore.logSystem(`Prompt template updated to ${id}`);
|
||||
}, []);
|
||||
|
||||
const enableLatestBranch = useCallback((enabled: boolean) => {
|
||||
updateLatestBranch(enabled);
|
||||
logStore.logSystem(`Main branch updates ${enabled ? 'enabled' : 'disabled'}`);
|
||||
}, []);
|
||||
|
||||
const setTheme = useCallback(
|
||||
(theme: Settings['theme']) => {
|
||||
saveSettings({ theme });
|
||||
},
|
||||
[saveSettings],
|
||||
);
|
||||
|
||||
const setLanguage = useCallback(
|
||||
(language: string) => {
|
||||
saveSettings({ language });
|
||||
},
|
||||
[saveSettings],
|
||||
);
|
||||
|
||||
const setNotifications = useCallback(
|
||||
(enabled: boolean) => {
|
||||
saveSettings({ notifications: enabled });
|
||||
},
|
||||
[saveSettings],
|
||||
);
|
||||
|
||||
const setTimezone = useCallback(
|
||||
(timezone: string) => {
|
||||
saveSettings({ timezone });
|
||||
},
|
||||
[saveSettings],
|
||||
);
|
||||
|
||||
return {
|
||||
...settings,
|
||||
debug,
|
||||
enableDebugMode,
|
||||
eventLogs,
|
||||
setEventLogs,
|
||||
promptId,
|
||||
setPromptId,
|
||||
isLatestBranch,
|
||||
enableLatestBranch,
|
||||
setTheme,
|
||||
setLanguage,
|
||||
setNotifications,
|
||||
setTimezone,
|
||||
settings,
|
||||
tabConfiguration,
|
||||
updateTabConfiguration: updateTabConfig,
|
||||
resetTabConfiguration: resetTabConfig,
|
||||
};
|
||||
}
|
||||
93
app/lib/hooks/useShortcuts.ts
Normal file
93
app/lib/hooks/useShortcuts.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { useEffect } from 'react';
|
||||
import { type Shortcuts, shortcutsStore } from '~/lib/stores/settings';
|
||||
import { isMac } from '~/utils/os';
|
||||
|
||||
// List of keys that should not trigger shortcuts when typing in input/textarea
|
||||
const INPUT_ELEMENTS = ['input', 'textarea'];
|
||||
|
||||
class ShortcutEventEmitter {
|
||||
#emitter = new EventTarget();
|
||||
|
||||
dispatch(type: keyof Shortcuts) {
|
||||
this.#emitter.dispatchEvent(new Event(type));
|
||||
}
|
||||
|
||||
on(type: keyof Shortcuts, cb: VoidFunction) {
|
||||
this.#emitter.addEventListener(type, cb);
|
||||
|
||||
return () => {
|
||||
this.#emitter.removeEventListener(type, cb);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const shortcutEventEmitter = new ShortcutEventEmitter();
|
||||
|
||||
export function useShortcuts(): void {
|
||||
const shortcuts = useStore(shortcutsStore);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent): void => {
|
||||
// Don't trigger shortcuts when typing in input fields
|
||||
if (
|
||||
document.activeElement &&
|
||||
INPUT_ELEMENTS.includes(document.activeElement.tagName.toLowerCase()) &&
|
||||
!event.altKey && // Allow Alt combinations even in input fields
|
||||
!event.metaKey && // Allow Cmd/Win combinations even in input fields
|
||||
!event.ctrlKey // Allow Ctrl combinations even in input fields
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Debug logging in development only
|
||||
if (import.meta.env.DEV) {
|
||||
console.log('Key pressed:', {
|
||||
key: event.key,
|
||||
code: event.code,
|
||||
ctrlKey: event.ctrlKey,
|
||||
shiftKey: event.shiftKey,
|
||||
altKey: event.altKey,
|
||||
metaKey: event.metaKey,
|
||||
target: event.target,
|
||||
});
|
||||
}
|
||||
|
||||
// Handle shortcuts
|
||||
for (const [name, shortcut] of Object.entries(shortcuts)) {
|
||||
const keyMatches =
|
||||
shortcut.key.toLowerCase() === event.key.toLowerCase() || `Key${shortcut.key.toUpperCase()}` === event.code;
|
||||
|
||||
// Handle ctrlOrMetaKey based on OS
|
||||
const ctrlOrMetaKeyMatches = shortcut.ctrlOrMetaKey
|
||||
? (isMac && event.metaKey) || (!isMac && event.ctrlKey)
|
||||
: true;
|
||||
|
||||
const modifiersMatch =
|
||||
ctrlOrMetaKeyMatches &&
|
||||
(shortcut.ctrlKey === undefined || shortcut.ctrlKey === event.ctrlKey) &&
|
||||
(shortcut.metaKey === undefined || shortcut.metaKey === event.metaKey) &&
|
||||
(shortcut.shiftKey === undefined || shortcut.shiftKey === event.shiftKey) &&
|
||||
(shortcut.altKey === undefined || shortcut.altKey === event.altKey);
|
||||
|
||||
if (keyMatches && modifiersMatch) {
|
||||
// Prevent default browser behavior if specified
|
||||
if (shortcut.isPreventDefault) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}
|
||||
|
||||
shortcutEventEmitter.dispatch(name as keyof Shortcuts);
|
||||
shortcut.action();
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [shortcuts]);
|
||||
}
|
||||
177
app/lib/hooks/useSnapScroll.ts
Normal file
177
app/lib/hooks/useSnapScroll.ts
Normal file
@@ -0,0 +1,177 @@
|
||||
import { useCallback, useRef } from 'react';
|
||||
import { throttle } from '~/utils/throttle';
|
||||
|
||||
interface ScrollOptions {
|
||||
duration?: number;
|
||||
easing?: 'ease' | 'ease-in' | 'ease-out' | 'ease-in-out' | 'cubic-bezier';
|
||||
cubicBezier?: [number, number, number, number];
|
||||
bottomThreshold?: number;
|
||||
throttleTime?: number;
|
||||
}
|
||||
|
||||
export function useSnapScroll(options: ScrollOptions = {}) {
|
||||
const {
|
||||
duration = 800,
|
||||
easing = 'ease-in-out',
|
||||
cubicBezier = [0.42, 0, 0.58, 1],
|
||||
bottomThreshold = 50,
|
||||
throttleTime = 200,
|
||||
} = options;
|
||||
|
||||
const autoScrollRef = useRef(true);
|
||||
const scrollNodeRef = useRef<HTMLDivElement | null>(null);
|
||||
const onScrollRef = useRef<() => void>();
|
||||
const observerRef = useRef<ResizeObserver | null>(null);
|
||||
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const animationFrameRef = useRef<number>(0);
|
||||
const lastScrollTopRef = useRef<number>(0);
|
||||
const throttledSmoothScrollRef = useRef<(...args: any[]) => void>();
|
||||
|
||||
const smoothScroll = useCallback(
|
||||
(element: HTMLDivElement, targetPosition: number, duration: number, easingFunction: string) => {
|
||||
const startPosition = element.scrollTop;
|
||||
const distance = targetPosition - startPosition;
|
||||
const startTime = performance.now();
|
||||
|
||||
const bezierPoints = easingFunction === 'cubic-bezier' ? cubicBezier : [0.42, 0, 0.58, 1];
|
||||
|
||||
const cubicBezierFunction = (t: number): number => {
|
||||
const [, y1, , y2] = bezierPoints;
|
||||
|
||||
/*
|
||||
* const cx = 3 * x1;
|
||||
* const bx = 3 * (x2 - x1) - cx;
|
||||
* const ax = 1 - cx - bx;
|
||||
*/
|
||||
|
||||
const cy = 3 * y1;
|
||||
const by = 3 * (y2 - y1) - cy;
|
||||
const ay = 1 - cy - by;
|
||||
|
||||
// const sampleCurveX = (t: number) => ((ax * t + bx) * t + cx) * t;
|
||||
const sampleCurveY = (t: number) => ((ay * t + by) * t + cy) * t;
|
||||
|
||||
return sampleCurveY(t);
|
||||
};
|
||||
|
||||
const animation = (currentTime: number) => {
|
||||
const elapsedTime = currentTime - startTime;
|
||||
const progress = Math.min(elapsedTime / duration, 1);
|
||||
|
||||
const easedProgress = cubicBezierFunction(progress);
|
||||
const newPosition = startPosition + distance * easedProgress;
|
||||
|
||||
// Only scroll if auto-scroll is still enabled
|
||||
if (autoScrollRef.current) {
|
||||
element.scrollTop = newPosition;
|
||||
}
|
||||
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
|
||||
if (progress < 1 && autoScrollRef.current) {
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
animation(performance.now());
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
animation(performance.now());
|
||||
});
|
||||
},
|
||||
[cubicBezier],
|
||||
);
|
||||
|
||||
// 创建节流版本的 smoothScroll 函数
|
||||
useCallback(() => {
|
||||
throttledSmoothScrollRef.current = throttle(
|
||||
(element: HTMLDivElement, targetPosition: number, duration: number, easingFunction: string) => {
|
||||
smoothScroll(element, targetPosition, duration, easingFunction);
|
||||
},
|
||||
throttleTime,
|
||||
);
|
||||
}, [smoothScroll, throttleTime])();
|
||||
|
||||
const isScrolledToBottom = useCallback(
|
||||
(element: HTMLDivElement): boolean => {
|
||||
const { scrollTop, scrollHeight, clientHeight } = element;
|
||||
return scrollHeight - scrollTop - clientHeight <= bottomThreshold;
|
||||
},
|
||||
[bottomThreshold],
|
||||
);
|
||||
|
||||
const messageRef = useCallback(
|
||||
(node: HTMLDivElement | null) => {
|
||||
if (node) {
|
||||
const observer = new ResizeObserver(() => {
|
||||
if (autoScrollRef.current && scrollNodeRef.current && throttledSmoothScrollRef.current) {
|
||||
const { scrollHeight, clientHeight } = scrollNodeRef.current;
|
||||
const scrollTarget = scrollHeight - clientHeight;
|
||||
throttledSmoothScrollRef.current(scrollNodeRef.current, scrollTarget, duration, easing);
|
||||
}
|
||||
});
|
||||
|
||||
observer.observe(node);
|
||||
observerRef.current = observer;
|
||||
return;
|
||||
}
|
||||
observerRef.current?.disconnect();
|
||||
observerRef.current = null;
|
||||
|
||||
if (animationFrameRef.current) {
|
||||
cancelAnimationFrame(animationFrameRef.current);
|
||||
animationFrameRef.current = 0;
|
||||
}
|
||||
},
|
||||
[duration, easing],
|
||||
);
|
||||
|
||||
const scrollRef = useCallback(
|
||||
(node: HTMLDivElement | null) => {
|
||||
if (node) {
|
||||
onScrollRef.current = () => {
|
||||
const { scrollTop } = node;
|
||||
|
||||
// Detect scroll direction
|
||||
const isScrollingUp = scrollTop < lastScrollTopRef.current;
|
||||
|
||||
// Update auto-scroll based on scroll direction and position
|
||||
if (isScrollingUp) {
|
||||
// Disable auto-scroll when scrolling up
|
||||
autoScrollRef.current = false;
|
||||
} else if (isScrolledToBottom(node)) {
|
||||
// Re-enable auto-scroll when manually scrolled to bottom
|
||||
autoScrollRef.current = true;
|
||||
}
|
||||
|
||||
// Store current scroll position for next comparison
|
||||
lastScrollTopRef.current = scrollTop;
|
||||
};
|
||||
|
||||
node.addEventListener('scroll', onScrollRef.current);
|
||||
scrollNodeRef.current = node;
|
||||
} else {
|
||||
if (onScrollRef.current && scrollNodeRef.current) {
|
||||
scrollNodeRef.current.removeEventListener('scroll', onScrollRef.current);
|
||||
}
|
||||
|
||||
if (animationFrameRef.current) {
|
||||
cancelAnimationFrame(animationFrameRef.current);
|
||||
animationFrameRef.current = 0;
|
||||
}
|
||||
|
||||
scrollNodeRef.current = null;
|
||||
onScrollRef.current = undefined;
|
||||
}
|
||||
},
|
||||
[isScrolledToBottom],
|
||||
);
|
||||
|
||||
return [messageRef, scrollRef] as const;
|
||||
}
|
||||
18
app/lib/hooks/useViewport.ts
Normal file
18
app/lib/hooks/useViewport.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
const useViewport = (threshold = 1024) => {
|
||||
const [isSmallViewport, setIsSmallViewport] = useState(window.innerWidth < threshold);
|
||||
|
||||
useEffect(() => {
|
||||
const handleResize = () => setIsSmallViewport(window.innerWidth < threshold);
|
||||
window.addEventListener('resize', handleResize);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', handleResize);
|
||||
};
|
||||
}, [threshold]);
|
||||
|
||||
return isSmallViewport;
|
||||
};
|
||||
|
||||
export default useViewport;
|
||||
112
app/lib/modules/llm/base-provider.ts
Normal file
112
app/lib/modules/llm/base-provider.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { createOpenAI } from '@ai-sdk/openai';
|
||||
import type { LanguageModel } from 'ai';
|
||||
import type { IProviderSetting } from '~/types/model';
|
||||
import type { ModelInfo, ProviderConfig, ProviderInfo } from './types';
|
||||
|
||||
export abstract class BaseProvider implements ProviderInfo {
|
||||
abstract name: string;
|
||||
abstract staticModels: ModelInfo[];
|
||||
abstract config: ProviderConfig;
|
||||
cachedDynamicModels?: {
|
||||
cacheId: string;
|
||||
models: ModelInfo[];
|
||||
};
|
||||
|
||||
getApiKeyLink?: string;
|
||||
labelForGetApiKey?: string;
|
||||
icon?: string;
|
||||
|
||||
getProviderBaseUrlAndKey(options: {
|
||||
apiKeys?: Record<string, string>;
|
||||
providerSettings?: IProviderSetting;
|
||||
defaultBaseUrlKey: string;
|
||||
defaultApiTokenKey: string;
|
||||
}) {
|
||||
const { apiKeys, providerSettings, defaultBaseUrlKey, defaultApiTokenKey } = options;
|
||||
let settingsBaseUrl = providerSettings?.baseUrl;
|
||||
|
||||
if (settingsBaseUrl && settingsBaseUrl.length == 0) {
|
||||
settingsBaseUrl = undefined;
|
||||
}
|
||||
|
||||
const baseUrlKey = this.config.baseUrlKey || defaultBaseUrlKey;
|
||||
let baseUrl =
|
||||
settingsBaseUrl || process?.env?.[baseUrlKey] || (import.meta.env as any)?.[baseUrlKey] || this.config.baseUrl;
|
||||
|
||||
if (baseUrl && baseUrl.endsWith('/')) {
|
||||
baseUrl = baseUrl.slice(0, -1);
|
||||
}
|
||||
|
||||
const apiTokenKey = this.config.apiTokenKey || defaultApiTokenKey;
|
||||
const apiKey = apiKeys?.[this.name] || process?.env?.[apiTokenKey] || (import.meta.env as any)?.[apiTokenKey];
|
||||
|
||||
return {
|
||||
baseUrl,
|
||||
apiKey,
|
||||
};
|
||||
}
|
||||
getModelsFromCache(options: {
|
||||
apiKeys?: Record<string, string>;
|
||||
providerSettings?: Record<string, IProviderSetting>;
|
||||
}): ModelInfo[] | null {
|
||||
if (!this.cachedDynamicModels) {
|
||||
// console.log('no dynamic models',this.name);
|
||||
return null;
|
||||
}
|
||||
|
||||
const cacheKey = this.cachedDynamicModels.cacheId;
|
||||
const generatedCacheKey = this.getDynamicModelsCacheKey(options);
|
||||
|
||||
if (cacheKey !== generatedCacheKey) {
|
||||
// console.log('cache key mismatch',this.name,cacheKey,generatedCacheKey);
|
||||
this.cachedDynamicModels = undefined;
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.cachedDynamicModels.models;
|
||||
}
|
||||
getDynamicModelsCacheKey(options: {
|
||||
apiKeys?: Record<string, string>;
|
||||
providerSettings?: Record<string, IProviderSetting>;
|
||||
}) {
|
||||
return JSON.stringify({
|
||||
apiKeys: options.apiKeys?.[this.name],
|
||||
providerSettings: options.providerSettings?.[this.name],
|
||||
});
|
||||
}
|
||||
storeDynamicModels(
|
||||
options: {
|
||||
apiKeys?: Record<string, string>;
|
||||
providerSettings?: Record<string, IProviderSetting>;
|
||||
},
|
||||
models: ModelInfo[],
|
||||
) {
|
||||
const cacheId = this.getDynamicModelsCacheKey(options);
|
||||
|
||||
// console.log('caching dynamic models',this.name,cacheId);
|
||||
this.cachedDynamicModels = {
|
||||
cacheId,
|
||||
models,
|
||||
};
|
||||
}
|
||||
|
||||
// Declare the optional getDynamicModels method
|
||||
getDynamicModels?(apiKeys?: Record<string, string>, settings?: IProviderSetting): Promise<ModelInfo[]>;
|
||||
|
||||
abstract getModelInstance(options: {
|
||||
model: string;
|
||||
apiKeys?: Record<string, string>;
|
||||
providerSettings?: Record<string, IProviderSetting>;
|
||||
}): LanguageModel;
|
||||
}
|
||||
|
||||
type OptionalApiKey = string | undefined;
|
||||
|
||||
export function getOpenAILikeModel(baseURL: string, apiKey: OptionalApiKey, model: string) {
|
||||
const openai = createOpenAI({
|
||||
baseURL,
|
||||
apiKey,
|
||||
});
|
||||
|
||||
return openai(model);
|
||||
}
|
||||
279
app/lib/modules/llm/manager.ts
Normal file
279
app/lib/modules/llm/manager.ts
Normal file
@@ -0,0 +1,279 @@
|
||||
import type { IProviderSetting } from '~/types/model';
|
||||
import { createScopedLogger } from '~/utils/logger';
|
||||
import { BaseProvider } from './base-provider';
|
||||
import * as providers from './registry';
|
||||
import type { ModelInfo, ProviderInfo } from './types';
|
||||
|
||||
const logger = createScopedLogger('LLMManager');
|
||||
export class LLMManager {
|
||||
private static _instance: LLMManager;
|
||||
private _providers: Map<string, BaseProvider> = new Map();
|
||||
private _modelList: ModelInfo[] = [];
|
||||
|
||||
private constructor() {
|
||||
this._registerProvidersFromDirectory();
|
||||
}
|
||||
|
||||
static getInstance(): LLMManager {
|
||||
if (!LLMManager._instance) {
|
||||
LLMManager._instance = new LLMManager();
|
||||
}
|
||||
|
||||
return LLMManager._instance;
|
||||
}
|
||||
|
||||
// 从环境变量中读取配置的辅助方法
|
||||
private _getEnvConfig<T>(key: string, defaultValue: T): T {
|
||||
const value = process?.env?.[key] || (import.meta.env as any)?.[key];
|
||||
|
||||
if (value === undefined || value === null || value === '') {
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
if (typeof defaultValue === 'boolean') {
|
||||
return (value === 'true' || value === true) as unknown as T;
|
||||
} else if (typeof defaultValue === 'number') {
|
||||
return Number(value) as unknown as T;
|
||||
} else if (Array.isArray(defaultValue)) {
|
||||
return (value
|
||||
? String(value)
|
||||
.split(',')
|
||||
.map((item) => item.trim())
|
||||
: []) as unknown as T;
|
||||
}
|
||||
|
||||
return value as T;
|
||||
}
|
||||
|
||||
private _registerProvidersFromDirectory() {
|
||||
const allProviders: BaseProvider[] = Object.values(providers).map((providerClass) => new providerClass());
|
||||
|
||||
// 获取环境变量中的启用提供商列表
|
||||
const enabledProviders = this._getEnvConfig<string[]>('LLM_ENABLED_PROVIDERS', []);
|
||||
|
||||
// 过滤提供商,仅保留配置中启用的提供商
|
||||
const filteredProviders =
|
||||
enabledProviders.length > 0
|
||||
? allProviders.filter((provider) => enabledProviders.includes(provider.name))
|
||||
: allProviders;
|
||||
|
||||
for (const provider of filteredProviders) {
|
||||
this.registerProvider(provider);
|
||||
}
|
||||
}
|
||||
|
||||
registerProvider(provider: BaseProvider) {
|
||||
if (this._providers.has(provider.name)) {
|
||||
logger.warn(`Provider ${provider.name} is already registered. Skipping.`);
|
||||
return;
|
||||
}
|
||||
|
||||
this._providers.set(provider.name, provider);
|
||||
this._modelList = [...this._modelList, ...provider.staticModels];
|
||||
}
|
||||
|
||||
getProvider(name: string): BaseProvider | undefined {
|
||||
return this._providers.get(name);
|
||||
}
|
||||
|
||||
getAllProviders(): BaseProvider[] {
|
||||
return Array.from(this._providers.values());
|
||||
}
|
||||
|
||||
getDefaultProvider(): BaseProvider {
|
||||
const defaultProviderName = this._getEnvConfig<string>('LLM_DEFAULT_PROVIDER', '');
|
||||
|
||||
if (defaultProviderName && this._providers.has(defaultProviderName)) {
|
||||
return this._providers.get(defaultProviderName)!;
|
||||
}
|
||||
|
||||
return Array.from(this._providers.values())[0];
|
||||
}
|
||||
|
||||
getDefaultModel(): string {
|
||||
return this._getEnvConfig<string>('LLM_DEFAULT_MODEL', '');
|
||||
}
|
||||
|
||||
getMinorModel(): string {
|
||||
return this._getEnvConfig<string>('LLM_MINOR_MODEL', '');
|
||||
}
|
||||
|
||||
getConfiguredApiKeys(): Record<string, string> {
|
||||
const apiKeys: Record<string, string> = {};
|
||||
|
||||
const allProviders = this.getAllProviders();
|
||||
|
||||
for (const provider of allProviders) {
|
||||
if (!provider.config.apiTokenKey) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const apiTokenKey = provider.config.apiTokenKey;
|
||||
|
||||
const apiKey = process?.env?.[apiTokenKey] || (import.meta.env as any)?.[apiTokenKey];
|
||||
|
||||
if (apiKey) {
|
||||
apiKeys[provider.name] = apiKey;
|
||||
logger.debug(`Found API key for provider ${provider.name} in environment variables`);
|
||||
}
|
||||
}
|
||||
|
||||
return apiKeys;
|
||||
}
|
||||
|
||||
getConfiguredProviderSettings(): Record<string, IProviderSetting> {
|
||||
const providerSettings: Record<string, IProviderSetting> = {};
|
||||
|
||||
// 获取所有注册的提供商
|
||||
const allProviders = this.getAllProviders();
|
||||
|
||||
for (const provider of allProviders) {
|
||||
const providerName = provider.name;
|
||||
const settings: IProviderSetting = { enabled: true };
|
||||
|
||||
if (provider.config.baseUrlKey) {
|
||||
const baseUrlKey = provider.config.baseUrlKey;
|
||||
const baseUrl = process?.env?.[baseUrlKey] || (import.meta.env as any)?.[baseUrlKey];
|
||||
|
||||
if (baseUrl) {
|
||||
settings.baseUrl = baseUrl;
|
||||
logger.debug(`Found base URL for provider ${providerName} in environment variables: ${baseUrl}`);
|
||||
}
|
||||
}
|
||||
|
||||
const enabledKey = `${providerName.toUpperCase()}_ENABLED`;
|
||||
const isEnabled = this._getEnvConfig<boolean>(enabledKey, true);
|
||||
settings.enabled = isEnabled;
|
||||
|
||||
if (Object.keys(settings).length > 1 || settings.enabled === false) {
|
||||
providerSettings[providerName] = settings;
|
||||
}
|
||||
}
|
||||
|
||||
return providerSettings;
|
||||
}
|
||||
|
||||
getModelList(): ModelInfo[] {
|
||||
return this._modelList;
|
||||
}
|
||||
|
||||
async updateModelList(options: {
|
||||
apiKeys?: Record<string, string>;
|
||||
providerSettings?: Record<string, IProviderSetting>;
|
||||
}): Promise<ModelInfo[]> {
|
||||
const { apiKeys, providerSettings } = options;
|
||||
|
||||
let enabledProviders = Array.from(this._providers.values()).map((p) => p.name);
|
||||
|
||||
if (providerSettings && Object.keys(providerSettings).length > 0) {
|
||||
enabledProviders = enabledProviders.filter((p) => providerSettings[p]?.enabled);
|
||||
}
|
||||
|
||||
// Get dynamic models from all providers that support them
|
||||
const dynamicModels = await Promise.all(
|
||||
Array.from(this._providers.values())
|
||||
.filter((provider) => enabledProviders.includes(provider.name))
|
||||
.filter(
|
||||
(provider): provider is BaseProvider & Required<Pick<ProviderInfo, 'getDynamicModels'>> =>
|
||||
!!provider.getDynamicModels,
|
||||
)
|
||||
.map(async (provider) => {
|
||||
const cachedModels = provider.getModelsFromCache(options);
|
||||
|
||||
if (cachedModels) {
|
||||
return cachedModels;
|
||||
}
|
||||
|
||||
const dynamicModels = await provider
|
||||
.getDynamicModels(apiKeys, providerSettings?.[provider.name])
|
||||
.then((models) => {
|
||||
logger.info(`Caching ${models.length} dynamic models for ${provider.name}`);
|
||||
provider.storeDynamicModels(options, models);
|
||||
|
||||
return models;
|
||||
})
|
||||
.catch((err) => {
|
||||
logger.error(`Error getting dynamic models ${provider.name} :`, err);
|
||||
return [];
|
||||
});
|
||||
|
||||
return dynamicModels;
|
||||
}),
|
||||
);
|
||||
const staticModels = Array.from(this._providers.values()).flatMap((p) => p.staticModels || []);
|
||||
const dynamicModelsFlat = dynamicModels.flat();
|
||||
const dynamicModelKeys = dynamicModelsFlat.map((d) => `${d.name}-${d.provider}`);
|
||||
const filteredStaticModesl = staticModels.filter((m) => !dynamicModelKeys.includes(`${m.name}-${m.provider}`));
|
||||
|
||||
// Combine static and dynamic models
|
||||
const modelList = [...dynamicModelsFlat, ...filteredStaticModesl];
|
||||
modelList.sort((a, b) => a.name.localeCompare(b.name));
|
||||
this._modelList = modelList;
|
||||
|
||||
return modelList;
|
||||
}
|
||||
getStaticModelList() {
|
||||
return [...this._providers.values()].flatMap((p) => p.staticModels || []);
|
||||
}
|
||||
async getModelListFromProvider(
|
||||
providerArg: BaseProvider,
|
||||
options: {
|
||||
apiKeys?: Record<string, string>;
|
||||
providerSettings?: Record<string, IProviderSetting>;
|
||||
},
|
||||
): Promise<ModelInfo[]> {
|
||||
const provider = this._providers.get(providerArg.name);
|
||||
|
||||
if (!provider) {
|
||||
throw new Error(`Provider ${providerArg.name} not found`);
|
||||
}
|
||||
|
||||
const staticModels = provider.staticModels || [];
|
||||
|
||||
if (!provider.getDynamicModels) {
|
||||
return staticModels;
|
||||
}
|
||||
|
||||
const { apiKeys, providerSettings } = options;
|
||||
|
||||
const cachedModels = provider.getModelsFromCache({
|
||||
apiKeys,
|
||||
providerSettings,
|
||||
});
|
||||
|
||||
if (cachedModels) {
|
||||
logger.info(`Found ${cachedModels.length} cached models for ${provider.name}`);
|
||||
return [...cachedModels, ...staticModels];
|
||||
}
|
||||
|
||||
logger.info(`Getting dynamic models for ${provider.name}`);
|
||||
|
||||
const dynamicModels = await provider
|
||||
.getDynamicModels?.(apiKeys, providerSettings?.[provider.name])
|
||||
.then((models) => {
|
||||
logger.info(`Got ${models.length} dynamic models for ${provider.name}`);
|
||||
provider.storeDynamicModels(options, models);
|
||||
|
||||
return models;
|
||||
})
|
||||
.catch((err) => {
|
||||
logger.error(`Error getting dynamic models ${provider.name} :`, err);
|
||||
return [];
|
||||
});
|
||||
const dynamicModelsName = dynamicModels.map((d) => d.name);
|
||||
const filteredStaticList = staticModels.filter((m) => !dynamicModelsName.includes(m.name));
|
||||
const modelList = [...dynamicModels, ...filteredStaticList];
|
||||
modelList.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
return modelList;
|
||||
}
|
||||
getStaticModelListFromProvider(providerArg: BaseProvider) {
|
||||
const provider = this._providers.get(providerArg.name);
|
||||
|
||||
if (!provider) {
|
||||
throw new Error(`Provider ${providerArg.name} not found`);
|
||||
}
|
||||
|
||||
return [...(provider.staticModels || [])];
|
||||
}
|
||||
}
|
||||
117
app/lib/modules/llm/providers/amazon-bedrock.ts
Normal file
117
app/lib/modules/llm/providers/amazon-bedrock.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import { createAmazonBedrock } from '@ai-sdk/amazon-bedrock';
|
||||
import { type LanguageModel } from 'ai';
|
||||
import { BaseProvider } from '~/lib/modules/llm/base-provider';
|
||||
import type { ModelInfo } from '~/lib/modules/llm/types';
|
||||
import type { IProviderSetting } from '~/types/model';
|
||||
|
||||
interface AWSBedRockConfig {
|
||||
region: string;
|
||||
accessKeyId: string;
|
||||
secretAccessKey: string;
|
||||
sessionToken?: string;
|
||||
}
|
||||
|
||||
export default class AmazonBedrockProvider extends BaseProvider {
|
||||
name = 'AmazonBedrock';
|
||||
getApiKeyLink = 'https://console.aws.amazon.com/iam/home';
|
||||
|
||||
config = {
|
||||
apiTokenKey: 'AWS_BEDROCK_CONFIG',
|
||||
};
|
||||
|
||||
staticModels: ModelInfo[] = [
|
||||
{
|
||||
name: 'anthropic.claude-3-5-sonnet-20241022-v2:0',
|
||||
label: 'Claude 3.5 Sonnet v2 (Bedrock)',
|
||||
provider: 'AmazonBedrock',
|
||||
maxTokenAllowed: 200000,
|
||||
},
|
||||
{
|
||||
name: 'anthropic.claude-3-5-sonnet-20240620-v1:0',
|
||||
label: 'Claude 3.5 Sonnet (Bedrock)',
|
||||
provider: 'AmazonBedrock',
|
||||
maxTokenAllowed: 4096,
|
||||
},
|
||||
{
|
||||
name: 'anthropic.claude-3-sonnet-20240229-v1:0',
|
||||
label: 'Claude 3 Sonnet (Bedrock)',
|
||||
provider: 'AmazonBedrock',
|
||||
maxTokenAllowed: 4096,
|
||||
},
|
||||
{
|
||||
name: 'anthropic.claude-3-haiku-20240307-v1:0',
|
||||
label: 'Claude 3 Haiku (Bedrock)',
|
||||
provider: 'AmazonBedrock',
|
||||
maxTokenAllowed: 4096,
|
||||
},
|
||||
{
|
||||
name: 'amazon.nova-pro-v1:0',
|
||||
label: 'Amazon Nova Pro (Bedrock)',
|
||||
provider: 'AmazonBedrock',
|
||||
maxTokenAllowed: 5120,
|
||||
},
|
||||
{
|
||||
name: 'amazon.nova-lite-v1:0',
|
||||
label: 'Amazon Nova Lite (Bedrock)',
|
||||
provider: 'AmazonBedrock',
|
||||
maxTokenAllowed: 5120,
|
||||
},
|
||||
{
|
||||
name: 'mistral.mistral-large-2402-v1:0',
|
||||
label: 'Mistral Large 24.02 (Bedrock)',
|
||||
provider: 'AmazonBedrock',
|
||||
maxTokenAllowed: 8192,
|
||||
},
|
||||
];
|
||||
|
||||
private _parseAndValidateConfig(apiKey: string): AWSBedRockConfig {
|
||||
let parsedConfig: AWSBedRockConfig;
|
||||
|
||||
try {
|
||||
parsedConfig = JSON.parse(apiKey);
|
||||
} catch {
|
||||
throw new Error(
|
||||
'Invalid AWS Bedrock configuration format. Please provide a valid JSON string containing region, accessKeyId, and secretAccessKey.',
|
||||
);
|
||||
}
|
||||
|
||||
const { region, accessKeyId, secretAccessKey, sessionToken } = parsedConfig;
|
||||
|
||||
if (!region || !accessKeyId || !secretAccessKey) {
|
||||
throw new Error(
|
||||
'Missing required AWS credentials. Configuration must include region, accessKeyId, and secretAccessKey.',
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
region,
|
||||
accessKeyId,
|
||||
secretAccessKey,
|
||||
...(sessionToken && { sessionToken }),
|
||||
};
|
||||
}
|
||||
|
||||
getModelInstance(options: {
|
||||
model: string;
|
||||
apiKeys?: Record<string, string>;
|
||||
providerSettings?: Record<string, IProviderSetting>;
|
||||
}): LanguageModel {
|
||||
const { model, apiKeys, providerSettings } = options;
|
||||
|
||||
const { apiKey } = this.getProviderBaseUrlAndKey({
|
||||
apiKeys,
|
||||
providerSettings: providerSettings?.[this.name],
|
||||
defaultBaseUrlKey: '',
|
||||
defaultApiTokenKey: 'AWS_BEDROCK_CONFIG',
|
||||
});
|
||||
|
||||
if (!apiKey) {
|
||||
throw new Error(`Missing API key for ${this.name} provider`);
|
||||
}
|
||||
|
||||
const config = this._parseAndValidateConfig(apiKey);
|
||||
const bedrock = createAmazonBedrock(config);
|
||||
|
||||
return bedrock(model);
|
||||
}
|
||||
}
|
||||
95
app/lib/modules/llm/providers/anthropic.ts
Normal file
95
app/lib/modules/llm/providers/anthropic.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { createAnthropic } from '@ai-sdk/anthropic';
|
||||
import type { LanguageModel } from 'ai';
|
||||
import { BaseProvider } from '~/lib/modules/llm/base-provider';
|
||||
import type { ModelInfo } from '~/lib/modules/llm/types';
|
||||
import type { IProviderSetting } from '~/types/model';
|
||||
|
||||
export default class AnthropicProvider extends BaseProvider {
|
||||
name = 'Anthropic';
|
||||
getApiKeyLink = 'https://console.anthropic.com/settings/keys';
|
||||
|
||||
config = {
|
||||
apiTokenKey: 'ANTHROPIC_API_KEY',
|
||||
};
|
||||
|
||||
staticModels: ModelInfo[] = [
|
||||
{
|
||||
name: 'claude-3-7-sonnet-20250219',
|
||||
label: 'Claude 3.7 Sonnet',
|
||||
provider: 'Anthropic',
|
||||
maxTokenAllowed: 8000,
|
||||
},
|
||||
{
|
||||
name: 'claude-3-5-sonnet-latest',
|
||||
label: 'Claude 3.5 Sonnet (new)',
|
||||
provider: 'Anthropic',
|
||||
maxTokenAllowed: 8000,
|
||||
},
|
||||
{
|
||||
name: 'claude-3-5-sonnet-20240620',
|
||||
label: 'Claude 3.5 Sonnet (old)',
|
||||
provider: 'Anthropic',
|
||||
maxTokenAllowed: 8000,
|
||||
},
|
||||
{
|
||||
name: 'claude-3-5-haiku-latest',
|
||||
label: 'Claude 3.5 Haiku (new)',
|
||||
provider: 'Anthropic',
|
||||
maxTokenAllowed: 8000,
|
||||
},
|
||||
{ name: 'claude-3-opus-latest', label: 'Claude 3 Opus', provider: 'Anthropic', maxTokenAllowed: 8000 },
|
||||
{ name: 'claude-3-sonnet-20240229', label: 'Claude 3 Sonnet', provider: 'Anthropic', maxTokenAllowed: 8000 },
|
||||
{ name: 'claude-3-haiku-20240307', label: 'Claude 3 Haiku', provider: 'Anthropic', maxTokenAllowed: 8000 },
|
||||
];
|
||||
|
||||
async getDynamicModels(apiKeys?: Record<string, string>, settings?: IProviderSetting): Promise<ModelInfo[]> {
|
||||
const { apiKey } = this.getProviderBaseUrlAndKey({
|
||||
apiKeys,
|
||||
providerSettings: settings,
|
||||
defaultBaseUrlKey: '',
|
||||
defaultApiTokenKey: 'ANTHROPIC_API_KEY',
|
||||
});
|
||||
|
||||
if (!apiKey) {
|
||||
throw `Missing Api Key configuration for ${this.name} provider`;
|
||||
}
|
||||
|
||||
const response = await fetch(`https://api.anthropic.com/v1/models`, {
|
||||
headers: {
|
||||
'x-api-key': `${apiKey}`,
|
||||
'anthropic-version': '2023-06-01',
|
||||
},
|
||||
});
|
||||
|
||||
const res = (await response.json()) as any;
|
||||
const staticModelIds = this.staticModels.map((m) => m.name);
|
||||
|
||||
const data = res.data.filter((model: any) => model.type === 'model' && !staticModelIds.includes(model.id));
|
||||
|
||||
return data.map((m: any) => ({
|
||||
name: m.id,
|
||||
label: `${m.display_name}`,
|
||||
provider: this.name,
|
||||
maxTokenAllowed: 32000,
|
||||
}));
|
||||
}
|
||||
|
||||
getModelInstance: (options: {
|
||||
model: string;
|
||||
apiKeys?: Record<string, string>;
|
||||
providerSettings?: Record<string, IProviderSetting>;
|
||||
}) => LanguageModel = (options) => {
|
||||
const { apiKeys, providerSettings, model } = options;
|
||||
const { apiKey } = this.getProviderBaseUrlAndKey({
|
||||
apiKeys,
|
||||
providerSettings,
|
||||
defaultBaseUrlKey: '',
|
||||
defaultApiTokenKey: 'ANTHROPIC_API_KEY',
|
||||
});
|
||||
const anthropic = createAnthropic({
|
||||
apiKey,
|
||||
});
|
||||
|
||||
return anthropic(model);
|
||||
};
|
||||
}
|
||||
52
app/lib/modules/llm/providers/cohere.ts
Normal file
52
app/lib/modules/llm/providers/cohere.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { createCohere } from '@ai-sdk/cohere';
|
||||
import type { LanguageModel } from 'ai';
|
||||
import { BaseProvider } from '~/lib/modules/llm/base-provider';
|
||||
import type { ModelInfo } from '~/lib/modules/llm/types';
|
||||
import type { IProviderSetting } from '~/types/model';
|
||||
|
||||
export default class CohereProvider extends BaseProvider {
|
||||
name = 'Cohere';
|
||||
getApiKeyLink = 'https://dashboard.cohere.com/api-keys';
|
||||
|
||||
config = {
|
||||
apiTokenKey: 'COHERE_API_KEY',
|
||||
};
|
||||
|
||||
staticModels: ModelInfo[] = [
|
||||
{ name: 'command-r-plus-08-2024', label: 'Command R plus Latest', provider: 'Cohere', maxTokenAllowed: 4096 },
|
||||
{ name: 'command-r-08-2024', label: 'Command R Latest', provider: 'Cohere', maxTokenAllowed: 4096 },
|
||||
{ name: 'command-r-plus', label: 'Command R plus', provider: 'Cohere', maxTokenAllowed: 4096 },
|
||||
{ name: 'command-r', label: 'Command R', provider: 'Cohere', maxTokenAllowed: 4096 },
|
||||
{ name: 'command', label: 'Command', provider: 'Cohere', maxTokenAllowed: 4096 },
|
||||
{ name: 'command-nightly', label: 'Command Nightly', provider: 'Cohere', maxTokenAllowed: 4096 },
|
||||
{ name: 'command-light', label: 'Command Light', provider: 'Cohere', maxTokenAllowed: 4096 },
|
||||
{ name: 'command-light-nightly', label: 'Command Light Nightly', provider: 'Cohere', maxTokenAllowed: 4096 },
|
||||
{ name: 'c4ai-aya-expanse-8b', label: 'c4AI Aya Expanse 8b', provider: 'Cohere', maxTokenAllowed: 4096 },
|
||||
{ name: 'c4ai-aya-expanse-32b', label: 'c4AI Aya Expanse 32b', provider: 'Cohere', maxTokenAllowed: 4096 },
|
||||
];
|
||||
|
||||
getModelInstance(options: {
|
||||
model: string;
|
||||
apiKeys?: Record<string, string>;
|
||||
providerSettings?: Record<string, IProviderSetting>;
|
||||
}): LanguageModel {
|
||||
const { model, apiKeys, providerSettings } = options;
|
||||
|
||||
const { apiKey } = this.getProviderBaseUrlAndKey({
|
||||
apiKeys,
|
||||
providerSettings: providerSettings?.[this.name],
|
||||
defaultBaseUrlKey: '',
|
||||
defaultApiTokenKey: 'COHERE_API_KEY',
|
||||
});
|
||||
|
||||
if (!apiKey) {
|
||||
throw new Error(`Missing API key for ${this.name} provider`);
|
||||
}
|
||||
|
||||
const cohere = createCohere({
|
||||
apiKey,
|
||||
});
|
||||
|
||||
return cohere(model);
|
||||
}
|
||||
}
|
||||
45
app/lib/modules/llm/providers/deepseek.ts
Normal file
45
app/lib/modules/llm/providers/deepseek.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { createDeepSeek } from '@ai-sdk/deepseek';
|
||||
import type { LanguageModel } from 'ai';
|
||||
import { BaseProvider } from '~/lib/modules/llm/base-provider';
|
||||
import type { ModelInfo } from '~/lib/modules/llm/types';
|
||||
import type { IProviderSetting } from '~/types/model';
|
||||
|
||||
export default class DeepseekProvider extends BaseProvider {
|
||||
name = 'Deepseek';
|
||||
getApiKeyLink = 'https://platform.deepseek.com/apiKeys';
|
||||
|
||||
config = {
|
||||
apiTokenKey: 'DEEPSEEK_API_KEY',
|
||||
};
|
||||
|
||||
staticModels: ModelInfo[] = [
|
||||
{ name: 'deepseek-coder', label: 'Deepseek-Coder', provider: 'Deepseek', maxTokenAllowed: 8000 },
|
||||
{ name: 'deepseek-chat', label: 'Deepseek-Chat', provider: 'Deepseek', maxTokenAllowed: 8000 },
|
||||
{ name: 'deepseek-reasoner', label: 'Deepseek-Reasoner', provider: 'Deepseek', maxTokenAllowed: 8000 },
|
||||
];
|
||||
|
||||
getModelInstance(options: {
|
||||
model: string;
|
||||
apiKeys?: Record<string, string>;
|
||||
providerSettings?: Record<string, IProviderSetting>;
|
||||
}): LanguageModel {
|
||||
const { model, apiKeys, providerSettings } = options;
|
||||
|
||||
const { apiKey } = this.getProviderBaseUrlAndKey({
|
||||
apiKeys,
|
||||
providerSettings: providerSettings?.[this.name],
|
||||
defaultBaseUrlKey: '',
|
||||
defaultApiTokenKey: 'DEEPSEEK_API_KEY',
|
||||
});
|
||||
|
||||
if (!apiKey) {
|
||||
throw new Error(`Missing API key for ${this.name} provider`);
|
||||
}
|
||||
|
||||
const deepseek = createDeepSeek({
|
||||
apiKey,
|
||||
});
|
||||
|
||||
return deepseek(model);
|
||||
}
|
||||
}
|
||||
51
app/lib/modules/llm/providers/github.ts
Normal file
51
app/lib/modules/llm/providers/github.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { createOpenAI } from '@ai-sdk/openai';
|
||||
import type { LanguageModel } from 'ai';
|
||||
import { BaseProvider } from '~/lib/modules/llm/base-provider';
|
||||
import type { ModelInfo } from '~/lib/modules/llm/types';
|
||||
import type { IProviderSetting } from '~/types/model';
|
||||
|
||||
export default class GithubProvider extends BaseProvider {
|
||||
name = 'Github';
|
||||
getApiKeyLink = 'https://github.com/settings/personal-access-tokens';
|
||||
|
||||
config = {
|
||||
apiTokenKey: 'GITHUB_API_KEY',
|
||||
};
|
||||
|
||||
// find more in https://github.com/marketplace?type=models
|
||||
staticModels: ModelInfo[] = [
|
||||
{ name: 'gpt-4o', label: 'GPT-4o', provider: 'Github', maxTokenAllowed: 8000 },
|
||||
{ name: 'o1', label: 'o1-preview', provider: 'Github', maxTokenAllowed: 100000 },
|
||||
{ name: 'o1-mini', label: 'o1-mini', provider: 'Github', maxTokenAllowed: 8000 },
|
||||
{ name: 'gpt-4o-mini', label: 'GPT-4o Mini', provider: 'Github', maxTokenAllowed: 8000 },
|
||||
{ name: 'gpt-4-turbo', label: 'GPT-4 Turbo', provider: 'Github', maxTokenAllowed: 8000 },
|
||||
{ name: 'gpt-4', label: 'GPT-4', provider: 'Github', maxTokenAllowed: 8000 },
|
||||
{ name: 'gpt-3.5-turbo', label: 'GPT-3.5 Turbo', provider: 'Github', maxTokenAllowed: 8000 },
|
||||
];
|
||||
|
||||
getModelInstance(options: {
|
||||
model: string;
|
||||
apiKeys?: Record<string, string>;
|
||||
providerSettings?: Record<string, IProviderSetting>;
|
||||
}): LanguageModel {
|
||||
const { model, apiKeys, providerSettings } = options;
|
||||
|
||||
const { apiKey } = this.getProviderBaseUrlAndKey({
|
||||
apiKeys,
|
||||
providerSettings: providerSettings?.[this.name],
|
||||
defaultBaseUrlKey: '',
|
||||
defaultApiTokenKey: 'GITHUB_API_KEY',
|
||||
});
|
||||
|
||||
if (!apiKey) {
|
||||
throw new Error(`Missing API key for ${this.name} provider`);
|
||||
}
|
||||
|
||||
const openai = createOpenAI({
|
||||
baseURL: 'https://models.inference.ai.azure.com',
|
||||
apiKey,
|
||||
});
|
||||
|
||||
return openai(model);
|
||||
}
|
||||
}
|
||||
86
app/lib/modules/llm/providers/google.ts
Normal file
86
app/lib/modules/llm/providers/google.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { createGoogleGenerativeAI } from '@ai-sdk/google';
|
||||
import type { LanguageModel } from 'ai';
|
||||
import { BaseProvider } from '~/lib/modules/llm/base-provider';
|
||||
import type { ModelInfo } from '~/lib/modules/llm/types';
|
||||
import type { IProviderSetting } from '~/types/model';
|
||||
|
||||
export default class GoogleProvider extends BaseProvider {
|
||||
name = 'Google';
|
||||
getApiKeyLink = 'https://aistudio.google.com/app/apikey';
|
||||
|
||||
config = {
|
||||
apiTokenKey: 'GOOGLE_GENERATIVE_AI_API_KEY',
|
||||
};
|
||||
|
||||
staticModels: ModelInfo[] = [
|
||||
{ name: 'gemini-1.5-flash-latest', label: 'Gemini 1.5 Flash', provider: 'Google', maxTokenAllowed: 8192 },
|
||||
{
|
||||
name: 'gemini-2.0-flash-thinking-exp-01-21',
|
||||
label: 'Gemini 2.0 Flash-thinking-exp-01-21',
|
||||
provider: 'Google',
|
||||
maxTokenAllowed: 65536,
|
||||
},
|
||||
{ name: 'gemini-2.0-flash-exp', label: 'Gemini 2.0 Flash', provider: 'Google', maxTokenAllowed: 8192 },
|
||||
{ name: 'gemini-1.5-flash-002', label: 'Gemini 1.5 Flash-002', provider: 'Google', maxTokenAllowed: 8192 },
|
||||
{ name: 'gemini-1.5-flash-8b', label: 'Gemini 1.5 Flash-8b', provider: 'Google', maxTokenAllowed: 8192 },
|
||||
{ name: 'gemini-1.5-pro-latest', label: 'Gemini 1.5 Pro', provider: 'Google', maxTokenAllowed: 8192 },
|
||||
{ name: 'gemini-1.5-pro-002', label: 'Gemini 1.5 Pro-002', provider: 'Google', maxTokenAllowed: 8192 },
|
||||
{ name: 'gemini-exp-1206', label: 'Gemini exp-1206', provider: 'Google', maxTokenAllowed: 8192 },
|
||||
];
|
||||
|
||||
async getDynamicModels(apiKeys?: Record<string, string>, settings?: IProviderSetting): Promise<ModelInfo[]> {
|
||||
const { apiKey } = this.getProviderBaseUrlAndKey({
|
||||
apiKeys,
|
||||
providerSettings: settings,
|
||||
defaultBaseUrlKey: '',
|
||||
defaultApiTokenKey: 'GOOGLE_GENERATIVE_AI_API_KEY',
|
||||
});
|
||||
|
||||
if (!apiKey) {
|
||||
throw `Missing Api Key configuration for ${this.name} provider`;
|
||||
}
|
||||
|
||||
const response = await fetch(`https://generativelanguage.googleapis.com/v1beta/models?key=${apiKey}`, {
|
||||
headers: {
|
||||
// biome-ignore lint: ignore Content-Type header
|
||||
['Content-Type']: 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
const res = (await response.json()) as any;
|
||||
|
||||
const data = res.models.filter((model: any) => model.outputTokenLimit > 8000);
|
||||
|
||||
return data.map((m: any) => ({
|
||||
name: m.name.replace('models/', ''),
|
||||
label: `${m.displayName} - context ${Math.floor((m.inputTokenLimit + m.outputTokenLimit) / 1000) + 'k'}`,
|
||||
provider: this.name,
|
||||
maxTokenAllowed: m.inputTokenLimit + m.outputTokenLimit || 8000,
|
||||
}));
|
||||
}
|
||||
|
||||
getModelInstance(options: {
|
||||
model: string;
|
||||
apiKeys?: Record<string, string>;
|
||||
providerSettings?: Record<string, IProviderSetting>;
|
||||
}): LanguageModel {
|
||||
const { model, apiKeys, providerSettings } = options;
|
||||
|
||||
const { apiKey } = this.getProviderBaseUrlAndKey({
|
||||
apiKeys,
|
||||
providerSettings: providerSettings?.[this.name],
|
||||
defaultBaseUrlKey: '',
|
||||
defaultApiTokenKey: 'GOOGLE_GENERATIVE_AI_API_KEY',
|
||||
});
|
||||
|
||||
if (!apiKey) {
|
||||
throw new Error(`Missing API key for ${this.name} provider`);
|
||||
}
|
||||
|
||||
const google = createGoogleGenerativeAI({
|
||||
apiKey,
|
||||
});
|
||||
|
||||
return google(model);
|
||||
}
|
||||
}
|
||||
87
app/lib/modules/llm/providers/groq.ts
Normal file
87
app/lib/modules/llm/providers/groq.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { createOpenAI } from '@ai-sdk/openai';
|
||||
import type { LanguageModel } from 'ai';
|
||||
import { BaseProvider } from '~/lib/modules/llm/base-provider';
|
||||
import type { ModelInfo } from '~/lib/modules/llm/types';
|
||||
import type { IProviderSetting } from '~/types/model';
|
||||
|
||||
export default class GroqProvider extends BaseProvider {
|
||||
name = 'Groq';
|
||||
getApiKeyLink = 'https://console.groq.com/keys';
|
||||
|
||||
config = {
|
||||
apiTokenKey: 'GROQ_API_KEY',
|
||||
};
|
||||
|
||||
staticModels: ModelInfo[] = [
|
||||
{ name: 'llama-3.1-8b-instant', label: 'Llama 3.1 8b (Groq)', provider: 'Groq', maxTokenAllowed: 8000 },
|
||||
{ name: 'llama-3.2-11b-vision-preview', label: 'Llama 3.2 11b (Groq)', provider: 'Groq', maxTokenAllowed: 8000 },
|
||||
{ name: 'llama-3.2-90b-vision-preview', label: 'Llama 3.2 90b (Groq)', provider: 'Groq', maxTokenAllowed: 8000 },
|
||||
{ name: 'llama-3.2-3b-preview', label: 'Llama 3.2 3b (Groq)', provider: 'Groq', maxTokenAllowed: 8000 },
|
||||
{ name: 'llama-3.2-1b-preview', label: 'Llama 3.2 1b (Groq)', provider: 'Groq', maxTokenAllowed: 8000 },
|
||||
{ name: 'llama-3.3-70b-versatile', label: 'Llama 3.3 70b (Groq)', provider: 'Groq', maxTokenAllowed: 8000 },
|
||||
{
|
||||
name: 'deepseek-r1-distill-llama-70b',
|
||||
label: 'Deepseek R1 Distill Llama 70b (Groq)',
|
||||
provider: 'Groq',
|
||||
maxTokenAllowed: 131072,
|
||||
},
|
||||
];
|
||||
|
||||
async getDynamicModels(apiKeys?: Record<string, string>, settings?: IProviderSetting): Promise<ModelInfo[]> {
|
||||
const { apiKey } = this.getProviderBaseUrlAndKey({
|
||||
apiKeys,
|
||||
providerSettings: settings,
|
||||
defaultBaseUrlKey: '',
|
||||
defaultApiTokenKey: 'GROQ_API_KEY',
|
||||
});
|
||||
|
||||
if (!apiKey) {
|
||||
throw `Missing Api Key configuration for ${this.name} provider`;
|
||||
}
|
||||
|
||||
const response = await fetch(`https://api.groq.com/openai/v1/models`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
},
|
||||
});
|
||||
|
||||
const res = (await response.json()) as any;
|
||||
|
||||
const data = res.data.filter(
|
||||
(model: any) => model.object === 'model' && model.active && model.context_window > 8000,
|
||||
);
|
||||
|
||||
return data.map((m: any) => ({
|
||||
name: m.id,
|
||||
label: `${m.id} - context ${m.context_window ? Math.floor(m.context_window / 1000) + 'k' : 'N/A'} [ by ${m.owned_by}]`,
|
||||
provider: this.name,
|
||||
maxTokenAllowed: m.context_window || 8000,
|
||||
}));
|
||||
}
|
||||
|
||||
getModelInstance(options: {
|
||||
model: string;
|
||||
apiKeys?: Record<string, string>;
|
||||
providerSettings?: Record<string, IProviderSetting>;
|
||||
}): LanguageModel {
|
||||
const { model, apiKeys, providerSettings } = options;
|
||||
|
||||
const { apiKey } = this.getProviderBaseUrlAndKey({
|
||||
apiKeys,
|
||||
providerSettings: providerSettings?.[this.name],
|
||||
defaultBaseUrlKey: '',
|
||||
defaultApiTokenKey: 'GROQ_API_KEY',
|
||||
});
|
||||
|
||||
if (!apiKey) {
|
||||
throw new Error(`Missing API key for ${this.name} provider`);
|
||||
}
|
||||
|
||||
const openai = createOpenAI({
|
||||
baseURL: 'https://api.groq.com/openai/v1',
|
||||
apiKey,
|
||||
});
|
||||
|
||||
return openai(model);
|
||||
}
|
||||
}
|
||||
109
app/lib/modules/llm/providers/huggingface.ts
Normal file
109
app/lib/modules/llm/providers/huggingface.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import { createOpenAI } from '@ai-sdk/openai';
|
||||
import type { LanguageModel } from 'ai';
|
||||
import { BaseProvider } from '~/lib/modules/llm/base-provider';
|
||||
import type { ModelInfo } from '~/lib/modules/llm/types';
|
||||
import type { IProviderSetting } from '~/types/model';
|
||||
|
||||
export default class HuggingFaceProvider extends BaseProvider {
|
||||
name = 'HuggingFace';
|
||||
getApiKeyLink = 'https://huggingface.co/settings/tokens';
|
||||
|
||||
config = {
|
||||
apiTokenKey: 'HuggingFace_API_KEY',
|
||||
};
|
||||
|
||||
staticModels: ModelInfo[] = [
|
||||
{
|
||||
name: 'Qwen/Qwen2.5-Coder-32B-Instruct',
|
||||
label: 'Qwen2.5-Coder-32B-Instruct (HuggingFace)',
|
||||
provider: 'HuggingFace',
|
||||
maxTokenAllowed: 8000,
|
||||
},
|
||||
{
|
||||
name: '01-ai/Yi-1.5-34B-Chat',
|
||||
label: 'Yi-1.5-34B-Chat (HuggingFace)',
|
||||
provider: 'HuggingFace',
|
||||
maxTokenAllowed: 8000,
|
||||
},
|
||||
{
|
||||
name: 'codellama/CodeLlama-34b-Instruct-hf',
|
||||
label: 'CodeLlama-34b-Instruct (HuggingFace)',
|
||||
provider: 'HuggingFace',
|
||||
maxTokenAllowed: 8000,
|
||||
},
|
||||
{
|
||||
name: 'NousResearch/Hermes-3-Llama-3.1-8B',
|
||||
label: 'Hermes-3-Llama-3.1-8B (HuggingFace)',
|
||||
provider: 'HuggingFace',
|
||||
maxTokenAllowed: 8000,
|
||||
},
|
||||
{
|
||||
name: 'Qwen/Qwen2.5-Coder-32B-Instruct',
|
||||
label: 'Qwen2.5-Coder-32B-Instruct (HuggingFace)',
|
||||
provider: 'HuggingFace',
|
||||
maxTokenAllowed: 8000,
|
||||
},
|
||||
{
|
||||
name: 'Qwen/Qwen2.5-72B-Instruct',
|
||||
label: 'Qwen2.5-72B-Instruct (HuggingFace)',
|
||||
provider: 'HuggingFace',
|
||||
maxTokenAllowed: 8000,
|
||||
},
|
||||
{
|
||||
name: 'meta-llama/Llama-3.1-70B-Instruct',
|
||||
label: 'Llama-3.1-70B-Instruct (HuggingFace)',
|
||||
provider: 'HuggingFace',
|
||||
maxTokenAllowed: 8000,
|
||||
},
|
||||
{
|
||||
name: 'meta-llama/Llama-3.1-405B',
|
||||
label: 'Llama-3.1-405B (HuggingFace)',
|
||||
provider: 'HuggingFace',
|
||||
maxTokenAllowed: 8000,
|
||||
},
|
||||
{
|
||||
name: '01-ai/Yi-1.5-34B-Chat',
|
||||
label: 'Yi-1.5-34B-Chat (HuggingFace)',
|
||||
provider: 'HuggingFace',
|
||||
maxTokenAllowed: 8000,
|
||||
},
|
||||
{
|
||||
name: 'codellama/CodeLlama-34b-Instruct-hf',
|
||||
label: 'CodeLlama-34b-Instruct (HuggingFace)',
|
||||
provider: 'HuggingFace',
|
||||
maxTokenAllowed: 8000,
|
||||
},
|
||||
{
|
||||
name: 'NousResearch/Hermes-3-Llama-3.1-8B',
|
||||
label: 'Hermes-3-Llama-3.1-8B (HuggingFace)',
|
||||
provider: 'HuggingFace',
|
||||
maxTokenAllowed: 8000,
|
||||
},
|
||||
];
|
||||
|
||||
getModelInstance(options: {
|
||||
model: string;
|
||||
apiKeys?: Record<string, string>;
|
||||
providerSettings?: Record<string, IProviderSetting>;
|
||||
}): LanguageModel {
|
||||
const { model, apiKeys, providerSettings } = options;
|
||||
|
||||
const { apiKey } = this.getProviderBaseUrlAndKey({
|
||||
apiKeys,
|
||||
providerSettings: providerSettings?.[this.name],
|
||||
defaultBaseUrlKey: '',
|
||||
defaultApiTokenKey: 'HuggingFace_API_KEY',
|
||||
});
|
||||
|
||||
if (!apiKey) {
|
||||
throw new Error(`Missing API key for ${this.name} provider`);
|
||||
}
|
||||
|
||||
const openai = createOpenAI({
|
||||
baseURL: 'https://api-inference.huggingface.co/v1/',
|
||||
apiKey,
|
||||
});
|
||||
|
||||
return openai(model);
|
||||
}
|
||||
}
|
||||
104
app/lib/modules/llm/providers/hyperbolic.ts
Normal file
104
app/lib/modules/llm/providers/hyperbolic.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { createOpenAI } from '@ai-sdk/openai';
|
||||
import type { LanguageModel } from 'ai';
|
||||
import { BaseProvider } from '~/lib/modules/llm/base-provider';
|
||||
import type { ModelInfo } from '~/lib/modules/llm/types';
|
||||
import type { IProviderSetting } from '~/types/model';
|
||||
|
||||
export default class HyperbolicProvider extends BaseProvider {
|
||||
name = 'Hyperbolic';
|
||||
getApiKeyLink = 'https://app.hyperbolic.xyz/settings';
|
||||
|
||||
config = {
|
||||
apiTokenKey: 'HYPERBOLIC_API_KEY',
|
||||
};
|
||||
|
||||
staticModels: ModelInfo[] = [
|
||||
{
|
||||
name: 'Qwen/Qwen2.5-Coder-32B-Instruct',
|
||||
label: 'Qwen 2.5 Coder 32B Instruct',
|
||||
provider: 'Hyperbolic',
|
||||
maxTokenAllowed: 8192,
|
||||
},
|
||||
{
|
||||
name: 'Qwen/Qwen2.5-72B-Instruct',
|
||||
label: 'Qwen2.5-72B-Instruct',
|
||||
provider: 'Hyperbolic',
|
||||
maxTokenAllowed: 8192,
|
||||
},
|
||||
{
|
||||
name: 'deepseek-ai/DeepSeek-V2.5',
|
||||
label: 'DeepSeek-V2.5',
|
||||
provider: 'Hyperbolic',
|
||||
maxTokenAllowed: 8192,
|
||||
},
|
||||
{
|
||||
name: 'Qwen/QwQ-32B-Preview',
|
||||
label: 'QwQ-32B-Preview',
|
||||
provider: 'Hyperbolic',
|
||||
maxTokenAllowed: 8192,
|
||||
},
|
||||
{
|
||||
name: 'Qwen/Qwen2-VL-72B-Instruct',
|
||||
label: 'Qwen2-VL-72B-Instruct',
|
||||
provider: 'Hyperbolic',
|
||||
maxTokenAllowed: 8192,
|
||||
},
|
||||
];
|
||||
|
||||
async getDynamicModels(apiKeys?: Record<string, string>, settings?: IProviderSetting): Promise<ModelInfo[]> {
|
||||
const { baseUrl: fetchBaseUrl, apiKey } = this.getProviderBaseUrlAndKey({
|
||||
apiKeys,
|
||||
providerSettings: settings,
|
||||
defaultBaseUrlKey: '',
|
||||
defaultApiTokenKey: 'HYPERBOLIC_API_KEY',
|
||||
});
|
||||
const baseUrl = fetchBaseUrl || 'https://api.hyperbolic.xyz/v1';
|
||||
|
||||
if (!apiKey) {
|
||||
throw `Missing Api Key configuration for ${this.name} provider`;
|
||||
}
|
||||
|
||||
const response = await fetch(`${baseUrl}/models`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
},
|
||||
});
|
||||
|
||||
const res = (await response.json()) as any;
|
||||
|
||||
const data = res.data.filter((model: any) => model.object === 'model' && model.supports_chat);
|
||||
|
||||
return data.map((m: any) => ({
|
||||
name: m.id,
|
||||
label: `${m.id} - context ${m.context_length ? Math.floor(m.context_length / 1000) + 'k' : 'N/A'}`,
|
||||
provider: this.name,
|
||||
maxTokenAllowed: m.context_length || 8000,
|
||||
}));
|
||||
}
|
||||
|
||||
getModelInstance(options: {
|
||||
model: string;
|
||||
apiKeys?: Record<string, string>;
|
||||
providerSettings?: Record<string, IProviderSetting>;
|
||||
}): LanguageModel {
|
||||
const { model, apiKeys, providerSettings } = options;
|
||||
|
||||
const { apiKey } = this.getProviderBaseUrlAndKey({
|
||||
apiKeys,
|
||||
providerSettings: providerSettings?.[this.name],
|
||||
defaultBaseUrlKey: '',
|
||||
defaultApiTokenKey: 'HYPERBOLIC_API_KEY',
|
||||
});
|
||||
|
||||
if (!apiKey) {
|
||||
throw `Missing Api Key configuration for ${this.name} provider`;
|
||||
}
|
||||
|
||||
const openai = createOpenAI({
|
||||
baseURL: 'https://api.hyperbolic.xyz/v1/',
|
||||
apiKey,
|
||||
});
|
||||
|
||||
return openai(model);
|
||||
}
|
||||
}
|
||||
87
app/lib/modules/llm/providers/lmstudio.ts
Normal file
87
app/lib/modules/llm/providers/lmstudio.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { createOpenAI } from '@ai-sdk/openai';
|
||||
import type { LanguageModel } from 'ai';
|
||||
import { BaseProvider } from '~/lib/modules/llm/base-provider';
|
||||
import type { ModelInfo } from '~/lib/modules/llm/types';
|
||||
import type { IProviderSetting } from '~/types/model';
|
||||
import { logger } from '~/utils/logger';
|
||||
|
||||
export default class LMStudioProvider extends BaseProvider {
|
||||
name = 'LMStudio';
|
||||
getApiKeyLink = 'https://lmstudio.ai/';
|
||||
labelForGetApiKey = 'Get LMStudio';
|
||||
icon = 'i-ph:cloud-arrow-down';
|
||||
|
||||
config = {
|
||||
baseUrlKey: 'LMSTUDIO_API_BASE_URL',
|
||||
baseUrl: 'http://localhost:1234/',
|
||||
};
|
||||
|
||||
staticModels: ModelInfo[] = [];
|
||||
|
||||
async getDynamicModels(apiKeys?: Record<string, string>, settings?: IProviderSetting): Promise<ModelInfo[]> {
|
||||
let { baseUrl } = this.getProviderBaseUrlAndKey({
|
||||
apiKeys,
|
||||
providerSettings: settings,
|
||||
defaultBaseUrlKey: 'LMSTUDIO_API_BASE_URL',
|
||||
defaultApiTokenKey: '',
|
||||
});
|
||||
|
||||
if (!baseUrl) {
|
||||
throw new Error('No baseUrl found for LMStudio provider');
|
||||
}
|
||||
|
||||
if (typeof window === 'undefined') {
|
||||
/*
|
||||
* Running in Server
|
||||
* Backend: Check if we're running in Docker
|
||||
*/
|
||||
const isDocker = process?.env?.RUNNING_IN_DOCKER === 'true';
|
||||
|
||||
baseUrl = isDocker ? baseUrl.replace('localhost', 'host.docker.internal') : baseUrl;
|
||||
baseUrl = isDocker ? baseUrl.replace('127.0.0.1', 'host.docker.internal') : baseUrl;
|
||||
}
|
||||
|
||||
const response = await fetch(`${baseUrl}/v1/models`);
|
||||
const data = (await response.json()) as { data: Array<{ id: string }> };
|
||||
|
||||
return data.data.map((model) => ({
|
||||
name: model.id,
|
||||
label: model.id,
|
||||
provider: this.name,
|
||||
maxTokenAllowed: 8000,
|
||||
}));
|
||||
}
|
||||
getModelInstance: (options: {
|
||||
model: string;
|
||||
apiKeys?: Record<string, string>;
|
||||
providerSettings?: Record<string, IProviderSetting>;
|
||||
}) => LanguageModel = (options) => {
|
||||
const { apiKeys, providerSettings, model } = options;
|
||||
let { baseUrl } = this.getProviderBaseUrlAndKey({
|
||||
apiKeys,
|
||||
providerSettings: providerSettings?.[this.name],
|
||||
defaultBaseUrlKey: 'LMSTUDIO_API_BASE_URL',
|
||||
defaultApiTokenKey: '',
|
||||
});
|
||||
|
||||
if (!baseUrl) {
|
||||
throw new Error('No baseUrl found for LMStudio provider');
|
||||
}
|
||||
|
||||
const isDocker = process?.env?.RUNNING_IN_DOCKER === 'true';
|
||||
|
||||
if (typeof window === 'undefined') {
|
||||
baseUrl = isDocker ? baseUrl.replace('localhost', 'host.docker.internal') : baseUrl;
|
||||
baseUrl = isDocker ? baseUrl.replace('127.0.0.1', 'host.docker.internal') : baseUrl;
|
||||
}
|
||||
|
||||
logger.debug('LMStudio Base Url used: ', baseUrl);
|
||||
|
||||
const lmstudio = createOpenAI({
|
||||
baseURL: `${baseUrl}/v1`,
|
||||
apiKey: '',
|
||||
});
|
||||
|
||||
return lmstudio(model);
|
||||
};
|
||||
}
|
||||
51
app/lib/modules/llm/providers/mistral.ts
Normal file
51
app/lib/modules/llm/providers/mistral.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { createMistral } from '@ai-sdk/mistral';
|
||||
import type { LanguageModel } from 'ai';
|
||||
import { BaseProvider } from '~/lib/modules/llm/base-provider';
|
||||
import type { ModelInfo } from '~/lib/modules/llm/types';
|
||||
import type { IProviderSetting } from '~/types/model';
|
||||
|
||||
export default class MistralProvider extends BaseProvider {
|
||||
name = 'Mistral';
|
||||
getApiKeyLink = 'https://console.mistral.ai/api-keys/';
|
||||
|
||||
config = {
|
||||
apiTokenKey: 'MISTRAL_API_KEY',
|
||||
};
|
||||
|
||||
staticModels: ModelInfo[] = [
|
||||
{ name: 'open-mistral-7b', label: 'Mistral 7B', provider: 'Mistral', maxTokenAllowed: 8000 },
|
||||
{ name: 'open-mixtral-8x7b', label: 'Mistral 8x7B', provider: 'Mistral', maxTokenAllowed: 8000 },
|
||||
{ name: 'open-mixtral-8x22b', label: 'Mistral 8x22B', provider: 'Mistral', maxTokenAllowed: 8000 },
|
||||
{ name: 'open-codestral-mamba', label: 'Codestral Mamba', provider: 'Mistral', maxTokenAllowed: 8000 },
|
||||
{ name: 'open-mistral-nemo', label: 'Mistral Nemo', provider: 'Mistral', maxTokenAllowed: 8000 },
|
||||
{ name: 'ministral-8b-latest', label: 'Mistral 8B', provider: 'Mistral', maxTokenAllowed: 8000 },
|
||||
{ name: 'mistral-small-latest', label: 'Mistral Small', provider: 'Mistral', maxTokenAllowed: 8000 },
|
||||
{ name: 'codestral-latest', label: 'Codestral', provider: 'Mistral', maxTokenAllowed: 8000 },
|
||||
{ name: 'mistral-large-latest', label: 'Mistral Large Latest', provider: 'Mistral', maxTokenAllowed: 8000 },
|
||||
];
|
||||
|
||||
getModelInstance(options: {
|
||||
model: string;
|
||||
apiKeys?: Record<string, string>;
|
||||
providerSettings?: Record<string, IProviderSetting>;
|
||||
}): LanguageModel {
|
||||
const { model, apiKeys, providerSettings } = options;
|
||||
|
||||
const { apiKey } = this.getProviderBaseUrlAndKey({
|
||||
apiKeys,
|
||||
providerSettings: providerSettings?.[this.name],
|
||||
defaultBaseUrlKey: '',
|
||||
defaultApiTokenKey: 'MISTRAL_API_KEY',
|
||||
});
|
||||
|
||||
if (!apiKey) {
|
||||
throw new Error(`Missing API key for ${this.name} provider`);
|
||||
}
|
||||
|
||||
const mistral = createMistral({
|
||||
apiKey,
|
||||
});
|
||||
|
||||
return mistral(model);
|
||||
}
|
||||
}
|
||||
113
app/lib/modules/llm/providers/ollama.ts
Normal file
113
app/lib/modules/llm/providers/ollama.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import type { LanguageModel } from 'ai';
|
||||
import { createOllama } from 'ollama-ai-provider-v2';
|
||||
import { BaseProvider } from '~/lib/modules/llm/base-provider';
|
||||
import type { ModelInfo } from '~/lib/modules/llm/types';
|
||||
import type { IProviderSetting } from '~/types/model';
|
||||
import { logger } from '~/utils/logger';
|
||||
|
||||
interface OllamaModelDetails {
|
||||
parent_model: string;
|
||||
format: string;
|
||||
family: string;
|
||||
families: string[];
|
||||
parameter_size: string;
|
||||
quantization_level: string;
|
||||
}
|
||||
|
||||
export interface OllamaModel {
|
||||
name: string;
|
||||
model: string;
|
||||
modified_at: string;
|
||||
size: number;
|
||||
digest: string;
|
||||
details: OllamaModelDetails;
|
||||
}
|
||||
|
||||
export interface OllamaApiResponse {
|
||||
models: OllamaModel[];
|
||||
}
|
||||
|
||||
export default class OllamaProvider extends BaseProvider {
|
||||
name = 'Ollama';
|
||||
getApiKeyLink = 'https://ollama.com/download';
|
||||
labelForGetApiKey = 'Download Ollama';
|
||||
icon = 'i-ph:cloud-arrow-down';
|
||||
|
||||
config = {
|
||||
baseUrlKey: 'OLLAMA_API_BASE_URL',
|
||||
};
|
||||
|
||||
staticModels: ModelInfo[] = [];
|
||||
|
||||
getDefaultNumCtx(): number {
|
||||
return process.env.DEFAULT_NUM_CTX ? parseInt(process.env.DEFAULT_NUM_CTX, 10) : 32768;
|
||||
}
|
||||
|
||||
async getDynamicModels(apiKeys?: Record<string, string>, settings?: IProviderSetting): Promise<ModelInfo[]> {
|
||||
let { baseUrl } = this.getProviderBaseUrlAndKey({
|
||||
apiKeys,
|
||||
providerSettings: settings,
|
||||
defaultBaseUrlKey: 'OLLAMA_API_BASE_URL',
|
||||
defaultApiTokenKey: '',
|
||||
});
|
||||
|
||||
if (!baseUrl) {
|
||||
throw new Error('No baseUrl found for OLLAMA provider');
|
||||
}
|
||||
|
||||
if (typeof window === 'undefined') {
|
||||
/*
|
||||
* Running in Server
|
||||
* Backend: Check if we're running in Docker
|
||||
*/
|
||||
const isDocker = process?.env?.RUNNING_IN_DOCKER === 'true';
|
||||
|
||||
baseUrl = isDocker ? baseUrl.replace('localhost', 'host.docker.internal') : baseUrl;
|
||||
baseUrl = isDocker ? baseUrl.replace('127.0.0.1', 'host.docker.internal') : baseUrl;
|
||||
}
|
||||
|
||||
const response = await fetch(`${baseUrl}/api/tags`);
|
||||
const data = (await response.json()) as OllamaApiResponse;
|
||||
|
||||
// console.log({ ollamamodels: data.models });
|
||||
|
||||
return data.models.map((model: OllamaModel) => ({
|
||||
name: model.name,
|
||||
label: `${model.name} (${model.details.parameter_size})`,
|
||||
provider: this.name,
|
||||
maxTokenAllowed: 8000,
|
||||
}));
|
||||
}
|
||||
|
||||
getModelInstance: (options: {
|
||||
model: string;
|
||||
apiKeys?: Record<string, string>;
|
||||
providerSettings?: Record<string, IProviderSetting>;
|
||||
}) => LanguageModel = (options) => {
|
||||
const { apiKeys, providerSettings, model } = options;
|
||||
|
||||
let { baseUrl } = this.getProviderBaseUrlAndKey({
|
||||
apiKeys,
|
||||
providerSettings: providerSettings?.[this.name],
|
||||
defaultBaseUrlKey: 'OLLAMA_API_BASE_URL',
|
||||
defaultApiTokenKey: '',
|
||||
});
|
||||
|
||||
// Backend: Check if we're running in Docker
|
||||
if (!baseUrl) {
|
||||
throw new Error('No baseUrl found for OLLAMA provider');
|
||||
}
|
||||
|
||||
const isDocker = process?.env?.RUNNING_IN_DOCKER === 'true';
|
||||
baseUrl = isDocker ? baseUrl.replace('localhost', 'host.docker.internal') : baseUrl;
|
||||
baseUrl = isDocker ? baseUrl.replace('127.0.0.1', 'host.docker.internal') : baseUrl;
|
||||
|
||||
logger.debug('Ollama Base Url used: ', baseUrl);
|
||||
|
||||
const ollama = createOllama({
|
||||
baseURL: `${baseUrl}/api`,
|
||||
});
|
||||
|
||||
return ollama(model);
|
||||
};
|
||||
}
|
||||
128
app/lib/modules/llm/providers/open-router.ts
Normal file
128
app/lib/modules/llm/providers/open-router.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import { createOpenRouter } from '@openrouter/ai-sdk-provider';
|
||||
import type { LanguageModel } from 'ai';
|
||||
import { BaseProvider } from '~/lib/modules/llm/base-provider';
|
||||
import type { ModelInfo } from '~/lib/modules/llm/types';
|
||||
import type { IProviderSetting } from '~/types/model';
|
||||
|
||||
interface OpenRouterModel {
|
||||
name: string;
|
||||
id: string;
|
||||
context_length: number;
|
||||
pricing: {
|
||||
prompt: number;
|
||||
completion: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface OpenRouterModelsResponse {
|
||||
data: OpenRouterModel[];
|
||||
}
|
||||
|
||||
export default class OpenRouterProvider extends BaseProvider {
|
||||
name = 'OpenRouter';
|
||||
getApiKeyLink = 'https://openrouter.ai/settings/keys';
|
||||
|
||||
config = {
|
||||
apiTokenKey: 'OPEN_ROUTER_API_KEY',
|
||||
};
|
||||
|
||||
staticModels: ModelInfo[] = [
|
||||
{
|
||||
name: 'anthropic/claude-3.5-sonnet',
|
||||
label: 'Anthropic: Claude 3.5 Sonnet (OpenRouter)',
|
||||
provider: 'OpenRouter',
|
||||
maxTokenAllowed: 8000,
|
||||
},
|
||||
{
|
||||
name: 'anthropic/claude-3-haiku',
|
||||
label: 'Anthropic: Claude 3 Haiku (OpenRouter)',
|
||||
provider: 'OpenRouter',
|
||||
maxTokenAllowed: 8000,
|
||||
},
|
||||
{
|
||||
name: 'deepseek/deepseek-coder',
|
||||
label: 'Deepseek-Coder V2 236B (OpenRouter)',
|
||||
provider: 'OpenRouter',
|
||||
maxTokenAllowed: 8000,
|
||||
},
|
||||
{
|
||||
name: 'google/gemini-flash-1.5',
|
||||
label: 'Google Gemini Flash 1.5 (OpenRouter)',
|
||||
provider: 'OpenRouter',
|
||||
maxTokenAllowed: 8000,
|
||||
},
|
||||
{
|
||||
name: 'google/gemini-pro-1.5',
|
||||
label: 'Google Gemini Pro 1.5 (OpenRouter)',
|
||||
provider: 'OpenRouter',
|
||||
maxTokenAllowed: 8000,
|
||||
},
|
||||
{ name: 'x-ai/grok-beta', label: 'xAI Grok Beta (OpenRouter)', provider: 'OpenRouter', maxTokenAllowed: 8000 },
|
||||
{
|
||||
name: 'mistralai/mistral-nemo',
|
||||
label: 'OpenRouter Mistral Nemo (OpenRouter)',
|
||||
provider: 'OpenRouter',
|
||||
maxTokenAllowed: 8000,
|
||||
},
|
||||
{
|
||||
name: 'qwen/qwen-110b-chat',
|
||||
label: 'OpenRouter Qwen 110b Chat (OpenRouter)',
|
||||
provider: 'OpenRouter',
|
||||
maxTokenAllowed: 8000,
|
||||
},
|
||||
{ name: 'cohere/command', label: 'Cohere Command (OpenRouter)', provider: 'OpenRouter', maxTokenAllowed: 4096 },
|
||||
];
|
||||
|
||||
async getDynamicModels(
|
||||
_apiKeys?: Record<string, string>,
|
||||
_settings?: IProviderSetting,
|
||||
_serverEnv: Record<string, string> = {},
|
||||
): Promise<ModelInfo[]> {
|
||||
try {
|
||||
const response = await fetch('https://openrouter.ai/api/v1/models', {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
const data = (await response.json()) as OpenRouterModelsResponse;
|
||||
|
||||
return data.data
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
.map((m) => ({
|
||||
name: m.id,
|
||||
label: `${m.name} - in:$${(m.pricing.prompt * 1_000_000).toFixed(2)} out:$${(m.pricing.completion * 1_000_000).toFixed(2)} - context ${Math.floor(m.context_length / 1000)}k`,
|
||||
provider: this.name,
|
||||
maxTokenAllowed: 8000,
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('Error getting OpenRouter models:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
getModelInstance(options: {
|
||||
model: string;
|
||||
apiKeys?: Record<string, string>;
|
||||
providerSettings?: Record<string, IProviderSetting>;
|
||||
}): LanguageModel {
|
||||
const { model, apiKeys, providerSettings } = options;
|
||||
|
||||
const { apiKey } = this.getProviderBaseUrlAndKey({
|
||||
apiKeys,
|
||||
providerSettings: providerSettings?.[this.name],
|
||||
defaultBaseUrlKey: '',
|
||||
defaultApiTokenKey: 'OPEN_ROUTER_API_KEY',
|
||||
});
|
||||
|
||||
if (!apiKey) {
|
||||
throw new Error(`Missing API key for ${this.name} provider`);
|
||||
}
|
||||
|
||||
const openRouter = createOpenRouter({
|
||||
apiKey,
|
||||
});
|
||||
|
||||
return openRouter.chat(model);
|
||||
}
|
||||
}
|
||||
65
app/lib/modules/llm/providers/openai-like.ts
Normal file
65
app/lib/modules/llm/providers/openai-like.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import type { LanguageModel } from 'ai';
|
||||
import { BaseProvider, getOpenAILikeModel } from '~/lib/modules/llm/base-provider';
|
||||
import type { ModelInfo } from '~/lib/modules/llm/types';
|
||||
import type { IProviderSetting } from '~/types/model';
|
||||
|
||||
export default class OpenAILikeProvider extends BaseProvider {
|
||||
name = 'OpenAILike';
|
||||
getApiKeyLink = undefined;
|
||||
|
||||
config = {
|
||||
baseUrlKey: 'OPENAI_LIKE_API_BASE_URL',
|
||||
apiTokenKey: 'OPENAI_LIKE_API_KEY',
|
||||
};
|
||||
|
||||
staticModels: ModelInfo[] = [];
|
||||
|
||||
async getDynamicModels(apiKeys?: Record<string, string>, settings?: IProviderSetting): Promise<ModelInfo[]> {
|
||||
const { baseUrl, apiKey } = this.getProviderBaseUrlAndKey({
|
||||
apiKeys,
|
||||
providerSettings: settings,
|
||||
defaultBaseUrlKey: 'OPENAI_LIKE_API_BASE_URL',
|
||||
defaultApiTokenKey: 'OPENAI_LIKE_API_KEY',
|
||||
});
|
||||
|
||||
if (!baseUrl || !apiKey) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const response = await fetch(`${baseUrl}/models`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
},
|
||||
});
|
||||
|
||||
const res = (await response.json()) as any;
|
||||
|
||||
return res.data.map((model: any) => ({
|
||||
name: model.id,
|
||||
label: model.id,
|
||||
provider: this.name,
|
||||
maxTokenAllowed: 8000,
|
||||
}));
|
||||
}
|
||||
|
||||
getModelInstance(options: {
|
||||
model: string;
|
||||
apiKeys?: Record<string, string>;
|
||||
providerSettings?: Record<string, IProviderSetting>;
|
||||
}): LanguageModel {
|
||||
const { model, apiKeys, providerSettings } = options;
|
||||
|
||||
const { baseUrl, apiKey } = this.getProviderBaseUrlAndKey({
|
||||
apiKeys,
|
||||
providerSettings: providerSettings?.[this.name],
|
||||
defaultBaseUrlKey: 'OPENAI_LIKE_API_BASE_URL',
|
||||
defaultApiTokenKey: 'OPENAI_LIKE_API_KEY',
|
||||
});
|
||||
|
||||
if (!baseUrl || !apiKey) {
|
||||
throw new Error(`Missing configuration for ${this.name} provider`);
|
||||
}
|
||||
|
||||
return getOpenAILikeModel(baseUrl, apiKey, model);
|
||||
}
|
||||
}
|
||||
83
app/lib/modules/llm/providers/openai.ts
Normal file
83
app/lib/modules/llm/providers/openai.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { createOpenAI } from '@ai-sdk/openai';
|
||||
import type { LanguageModel } from 'ai';
|
||||
import { BaseProvider } from '~/lib/modules/llm/base-provider';
|
||||
import type { ModelInfo } from '~/lib/modules/llm/types';
|
||||
import type { IProviderSetting } from '~/types/model';
|
||||
|
||||
export default class OpenAIProvider extends BaseProvider {
|
||||
name = 'OpenAI';
|
||||
getApiKeyLink = 'https://platform.openai.com/api-keys';
|
||||
|
||||
config = {
|
||||
apiTokenKey: 'OPENAI_API_KEY',
|
||||
};
|
||||
|
||||
staticModels: ModelInfo[] = [
|
||||
{ name: 'gpt-4o', label: 'GPT-4o', provider: 'OpenAI', maxTokenAllowed: 8000 },
|
||||
{ name: 'gpt-4o-mini', label: 'GPT-4o Mini', provider: 'OpenAI', maxTokenAllowed: 8000 },
|
||||
{ name: 'gpt-4-turbo', label: 'GPT-4 Turbo', provider: 'OpenAI', maxTokenAllowed: 8000 },
|
||||
{ name: 'gpt-4', label: 'GPT-4', provider: 'OpenAI', maxTokenAllowed: 8000 },
|
||||
{ name: 'gpt-3.5-turbo', label: 'GPT-3.5 Turbo', provider: 'OpenAI', maxTokenAllowed: 8000 },
|
||||
];
|
||||
|
||||
async getDynamicModels(apiKeys?: Record<string, string>, settings?: IProviderSetting): Promise<ModelInfo[]> {
|
||||
const { apiKey } = this.getProviderBaseUrlAndKey({
|
||||
apiKeys,
|
||||
providerSettings: settings,
|
||||
defaultBaseUrlKey: '',
|
||||
defaultApiTokenKey: 'OPENAI_API_KEY',
|
||||
});
|
||||
|
||||
if (!apiKey) {
|
||||
throw `Missing Api Key configuration for ${this.name} provider`;
|
||||
}
|
||||
|
||||
const response = await fetch(`https://api.openai.com/v1/models`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
},
|
||||
});
|
||||
|
||||
const res = (await response.json()) as any;
|
||||
const staticModelIds = this.staticModels.map((m) => m.name);
|
||||
|
||||
const data = res.data.filter(
|
||||
(model: any) =>
|
||||
model.object === 'model' &&
|
||||
(model.id.startsWith('gpt-') || model.id.startsWith('o') || model.id.startsWith('chatgpt-')) &&
|
||||
!staticModelIds.includes(model.id),
|
||||
);
|
||||
|
||||
return data.map((m: any) => ({
|
||||
name: m.id,
|
||||
label: `${m.id}`,
|
||||
provider: this.name,
|
||||
maxTokenAllowed: m.context_window || 32000,
|
||||
}));
|
||||
}
|
||||
|
||||
getModelInstance(options: {
|
||||
model: string;
|
||||
apiKeys?: Record<string, string>;
|
||||
providerSettings?: Record<string, IProviderSetting>;
|
||||
}): LanguageModel {
|
||||
const { model, apiKeys, providerSettings } = options;
|
||||
|
||||
const { apiKey } = this.getProviderBaseUrlAndKey({
|
||||
apiKeys,
|
||||
providerSettings: providerSettings?.[this.name],
|
||||
defaultBaseUrlKey: '',
|
||||
defaultApiTokenKey: 'OPENAI_API_KEY',
|
||||
});
|
||||
|
||||
if (!apiKey) {
|
||||
throw new Error(`Missing API key for ${this.name} provider`);
|
||||
}
|
||||
|
||||
const openai = createOpenAI({
|
||||
apiKey,
|
||||
});
|
||||
|
||||
return openai(model);
|
||||
}
|
||||
}
|
||||
61
app/lib/modules/llm/providers/perplexity.ts
Normal file
61
app/lib/modules/llm/providers/perplexity.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { createOpenAI } from '@ai-sdk/openai';
|
||||
import type { LanguageModel } from 'ai';
|
||||
import { BaseProvider } from '~/lib/modules/llm/base-provider';
|
||||
import type { ModelInfo } from '~/lib/modules/llm/types';
|
||||
import type { IProviderSetting } from '~/types/model';
|
||||
|
||||
export default class PerplexityProvider extends BaseProvider {
|
||||
name = 'Perplexity';
|
||||
getApiKeyLink = 'https://www.perplexity.ai/settings/api';
|
||||
|
||||
config = {
|
||||
apiTokenKey: 'PERPLEXITY_API_KEY',
|
||||
};
|
||||
|
||||
staticModels: ModelInfo[] = [
|
||||
{
|
||||
name: 'llama-3.1-sonar-small-128k-online',
|
||||
label: 'Sonar Small Online',
|
||||
provider: 'Perplexity',
|
||||
maxTokenAllowed: 8192,
|
||||
},
|
||||
{
|
||||
name: 'llama-3.1-sonar-large-128k-online',
|
||||
label: 'Sonar Large Online',
|
||||
provider: 'Perplexity',
|
||||
maxTokenAllowed: 8192,
|
||||
},
|
||||
{
|
||||
name: 'llama-3.1-sonar-huge-128k-online',
|
||||
label: 'Sonar Huge Online',
|
||||
provider: 'Perplexity',
|
||||
maxTokenAllowed: 8192,
|
||||
},
|
||||
];
|
||||
|
||||
getModelInstance(options: {
|
||||
model: string;
|
||||
apiKeys?: Record<string, string>;
|
||||
providerSettings?: Record<string, IProviderSetting>;
|
||||
}): LanguageModel {
|
||||
const { model, apiKeys, providerSettings } = options;
|
||||
|
||||
const { apiKey } = this.getProviderBaseUrlAndKey({
|
||||
apiKeys,
|
||||
providerSettings: providerSettings?.[this.name],
|
||||
defaultBaseUrlKey: '',
|
||||
defaultApiTokenKey: 'PERPLEXITY_API_KEY',
|
||||
});
|
||||
|
||||
if (!apiKey) {
|
||||
throw new Error(`Missing API key for ${this.name} provider`);
|
||||
}
|
||||
|
||||
const perplexity = createOpenAI({
|
||||
baseURL: 'https://api.perplexity.ai/',
|
||||
apiKey,
|
||||
});
|
||||
|
||||
return perplexity(model);
|
||||
}
|
||||
}
|
||||
88
app/lib/modules/llm/providers/together.ts
Normal file
88
app/lib/modules/llm/providers/together.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import type { LanguageModel } from 'ai';
|
||||
import { BaseProvider, getOpenAILikeModel } from '~/lib/modules/llm/base-provider';
|
||||
import type { ModelInfo } from '~/lib/modules/llm/types';
|
||||
import type { IProviderSetting } from '~/types/model';
|
||||
|
||||
export default class TogetherProvider extends BaseProvider {
|
||||
name = 'Together';
|
||||
getApiKeyLink = 'https://api.together.xyz/settings/api-keys';
|
||||
|
||||
config = {
|
||||
baseUrlKey: 'TOGETHER_API_BASE_URL',
|
||||
apiTokenKey: 'TOGETHER_API_KEY',
|
||||
};
|
||||
|
||||
staticModels: ModelInfo[] = [
|
||||
{
|
||||
name: 'Qwen/Qwen2.5-Coder-32B-Instruct',
|
||||
label: 'Qwen/Qwen2.5-Coder-32B-Instruct',
|
||||
provider: 'Together',
|
||||
maxTokenAllowed: 8000,
|
||||
},
|
||||
{
|
||||
name: 'meta-llama/Llama-3.2-90B-Vision-Instruct-Turbo',
|
||||
label: 'meta-llama/Llama-3.2-90B-Vision-Instruct-Turbo',
|
||||
provider: 'Together',
|
||||
maxTokenAllowed: 8000,
|
||||
},
|
||||
{
|
||||
name: 'mistralai/Mixtral-8x7B-Instruct-v0.1',
|
||||
label: 'Mixtral 8x7B Instruct',
|
||||
provider: 'Together',
|
||||
maxTokenAllowed: 8192,
|
||||
},
|
||||
];
|
||||
|
||||
async getDynamicModels(apiKeys?: Record<string, string>, settings?: IProviderSetting): Promise<ModelInfo[]> {
|
||||
const { baseUrl: fetchBaseUrl, apiKey } = this.getProviderBaseUrlAndKey({
|
||||
apiKeys,
|
||||
providerSettings: settings,
|
||||
defaultBaseUrlKey: 'TOGETHER_API_BASE_URL',
|
||||
defaultApiTokenKey: 'TOGETHER_API_KEY',
|
||||
});
|
||||
const baseUrl = fetchBaseUrl || 'https://api.together.xyz/v1';
|
||||
|
||||
if (!baseUrl || !apiKey) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// console.log({ baseUrl, apiKey });
|
||||
|
||||
const response = await fetch(`${baseUrl}/models`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
},
|
||||
});
|
||||
|
||||
const res = (await response.json()) as any;
|
||||
const data = (res || []).filter((model: any) => model.type === 'chat');
|
||||
|
||||
return data.map((m: any) => ({
|
||||
name: m.id,
|
||||
label: `${m.display_name} - in:$${m.pricing.input.toFixed(2)} out:$${m.pricing.output.toFixed(2)} - context ${Math.floor(m.context_length / 1000)}k`,
|
||||
provider: this.name,
|
||||
maxTokenAllowed: 8000,
|
||||
}));
|
||||
}
|
||||
|
||||
getModelInstance(options: {
|
||||
model: string;
|
||||
apiKeys?: Record<string, string>;
|
||||
providerSettings?: Record<string, IProviderSetting>;
|
||||
}): LanguageModel {
|
||||
const { model, apiKeys, providerSettings } = options;
|
||||
|
||||
const { baseUrl, apiKey } = this.getProviderBaseUrlAndKey({
|
||||
apiKeys,
|
||||
providerSettings: providerSettings?.[this.name],
|
||||
defaultBaseUrlKey: 'TOGETHER_API_BASE_URL',
|
||||
defaultApiTokenKey: 'TOGETHER_API_KEY',
|
||||
});
|
||||
|
||||
if (!baseUrl || !apiKey) {
|
||||
throw new Error(`Missing configuration for ${this.name} provider`);
|
||||
}
|
||||
|
||||
return getOpenAILikeModel(baseUrl, apiKey, model);
|
||||
}
|
||||
}
|
||||
45
app/lib/modules/llm/providers/xai.ts
Normal file
45
app/lib/modules/llm/providers/xai.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { createOpenAI } from '@ai-sdk/openai';
|
||||
import type { LanguageModel } from 'ai';
|
||||
import { BaseProvider } from '~/lib/modules/llm/base-provider';
|
||||
import type { ModelInfo } from '~/lib/modules/llm/types';
|
||||
import type { IProviderSetting } from '~/types/model';
|
||||
|
||||
export default class XAIProvider extends BaseProvider {
|
||||
name = 'xAI';
|
||||
getApiKeyLink = 'https://docs.x.ai/docs/quickstart#creating-an-api-key';
|
||||
|
||||
config = {
|
||||
apiTokenKey: 'XAI_API_KEY',
|
||||
};
|
||||
|
||||
staticModels: ModelInfo[] = [
|
||||
{ name: 'grok-beta', label: 'xAI Grok Beta', provider: 'xAI', maxTokenAllowed: 8000 },
|
||||
{ name: 'grok-2-1212', label: 'xAI Grok2 1212', provider: 'xAI', maxTokenAllowed: 8000 },
|
||||
];
|
||||
|
||||
getModelInstance(options: {
|
||||
model: string;
|
||||
apiKeys?: Record<string, string>;
|
||||
providerSettings?: Record<string, IProviderSetting>;
|
||||
}): LanguageModel {
|
||||
const { model, apiKeys, providerSettings } = options;
|
||||
|
||||
const { apiKey } = this.getProviderBaseUrlAndKey({
|
||||
apiKeys,
|
||||
providerSettings: providerSettings?.[this.name],
|
||||
defaultBaseUrlKey: '',
|
||||
defaultApiTokenKey: 'XAI_API_KEY',
|
||||
});
|
||||
|
||||
if (!apiKey) {
|
||||
throw new Error(`Missing API key for ${this.name} provider`);
|
||||
}
|
||||
|
||||
const openai = createOpenAI({
|
||||
baseURL: 'https://api.x.ai/v1',
|
||||
apiKey,
|
||||
});
|
||||
|
||||
return openai(model);
|
||||
}
|
||||
}
|
||||
39
app/lib/modules/llm/registry.ts
Normal file
39
app/lib/modules/llm/registry.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import AmazonBedrockProvider from './providers/amazon-bedrock';
|
||||
import AnthropicProvider from './providers/anthropic';
|
||||
import CohereProvider from './providers/cohere';
|
||||
import DeepseekProvider from './providers/deepseek';
|
||||
import GithubProvider from './providers/github';
|
||||
import GoogleProvider from './providers/google';
|
||||
import GroqProvider from './providers/groq';
|
||||
import HuggingFaceProvider from './providers/huggingface';
|
||||
import HyperbolicProvider from './providers/hyperbolic';
|
||||
import LMStudioProvider from './providers/lmstudio';
|
||||
import MistralProvider from './providers/mistral';
|
||||
import OllamaProvider from './providers/ollama';
|
||||
import OpenRouterProvider from './providers/open-router';
|
||||
import OpenAIProvider from './providers/openai';
|
||||
import OpenAILikeProvider from './providers/openai-like';
|
||||
import PerplexityProvider from './providers/perplexity';
|
||||
import TogetherProvider from './providers/together';
|
||||
import XAIProvider from './providers/xai';
|
||||
|
||||
export {
|
||||
AnthropicProvider,
|
||||
CohereProvider,
|
||||
DeepseekProvider,
|
||||
GoogleProvider,
|
||||
GroqProvider,
|
||||
HuggingFaceProvider,
|
||||
HyperbolicProvider,
|
||||
MistralProvider,
|
||||
OllamaProvider,
|
||||
OpenAIProvider,
|
||||
OpenRouterProvider,
|
||||
OpenAILikeProvider,
|
||||
PerplexityProvider,
|
||||
XAIProvider,
|
||||
TogetherProvider,
|
||||
LMStudioProvider,
|
||||
AmazonBedrockProvider,
|
||||
GithubProvider,
|
||||
};
|
||||
28
app/lib/modules/llm/types.ts
Normal file
28
app/lib/modules/llm/types.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import type { LanguageModel } from 'ai';
|
||||
import type { IProviderSetting } from '~/types/model';
|
||||
|
||||
export interface ModelInfo {
|
||||
name: string;
|
||||
label: string;
|
||||
provider: string;
|
||||
maxTokenAllowed: number;
|
||||
}
|
||||
|
||||
export interface ProviderInfo {
|
||||
name: string;
|
||||
staticModels: ModelInfo[];
|
||||
getDynamicModels?: (apiKeys?: Record<string, string>, settings?: IProviderSetting) => Promise<ModelInfo[]>;
|
||||
getModelInstance: (options: {
|
||||
model: string;
|
||||
apiKeys?: Record<string, string>;
|
||||
providerSettings?: Record<string, IProviderSetting>;
|
||||
}) => LanguageModel;
|
||||
getApiKeyLink?: string;
|
||||
labelForGetApiKey?: string;
|
||||
icon?: string;
|
||||
}
|
||||
export interface ProviderConfig {
|
||||
baseUrlKey?: string;
|
||||
baseUrl?: string;
|
||||
apiTokenKey?: string;
|
||||
}
|
||||
514
app/lib/persistence/editor.ts
Normal file
514
app/lib/persistence/editor.ts
Normal file
@@ -0,0 +1,514 @@
|
||||
import { useSearchParams } from '@remix-run/react';
|
||||
import type { Page, Section } from '~/types/actions';
|
||||
import { createScopedLogger } from '~/utils/logger';
|
||||
import { getChatId } from '../stores/ai-state';
|
||||
|
||||
/**
|
||||
* 序列化标记常量
|
||||
*/
|
||||
export const SERIALIZATION_MARKERS = {
|
||||
FUNCTION_PREFIX: '__FUNCTION__:',
|
||||
ABORT_SIGNAL_PREFIX: '__ABORT_SIGNAL__',
|
||||
ABORT_CONTROLLER_PREFIX: '__ABORT_CONTROLLER__',
|
||||
};
|
||||
|
||||
/**
|
||||
* 将对象序列化为可存储在 IndexedDB 中的格式
|
||||
* 将函数转换为特殊格式的字符串
|
||||
* @param data 需要序列化的数据
|
||||
* @returns 序列化后的数据(可存储在 IndexedDB 中)
|
||||
*/
|
||||
function serializeForIndexedDB<T>(data: T): any {
|
||||
if (data === null || data === undefined) {
|
||||
return data;
|
||||
}
|
||||
|
||||
if (typeof data === 'function') {
|
||||
const funcStr = data.toString();
|
||||
if (funcStr.includes('abortController.abort()')) {
|
||||
return `${SERIALIZATION_MARKERS.FUNCTION_PREFIX}abort`;
|
||||
}
|
||||
return `${SERIALIZATION_MARKERS.FUNCTION_PREFIX}${funcStr}`;
|
||||
}
|
||||
|
||||
if (data && typeof data === 'object' && 'aborted' in data && 'onabort' in data) {
|
||||
return SERIALIZATION_MARKERS.ABORT_SIGNAL_PREFIX;
|
||||
}
|
||||
|
||||
if (data && typeof data === 'object' && 'signal' in data && 'abort' in data) {
|
||||
return SERIALIZATION_MARKERS.ABORT_CONTROLLER_PREFIX;
|
||||
}
|
||||
|
||||
if (data instanceof Date) {
|
||||
return {
|
||||
__type: 'Date',
|
||||
value: data.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
if (Array.isArray(data)) {
|
||||
return data.map((item) => serializeForIndexedDB(item));
|
||||
}
|
||||
|
||||
if (typeof data === 'object') {
|
||||
const serializedObject: Record<string, any> = {};
|
||||
|
||||
for (const key in data) {
|
||||
if (Object.prototype.hasOwnProperty.call(data, key)) {
|
||||
serializedObject[key] = serializeForIndexedDB((data as Record<string, any>)[key]);
|
||||
}
|
||||
}
|
||||
|
||||
return serializedObject;
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将从 IndexedDB 中读取的数据反序列化
|
||||
* 将特殊格式的字符串转换回函数
|
||||
* @param data 需要反序列化的数据
|
||||
* @returns 反序列化后的数据
|
||||
*/
|
||||
function deserializeFromIndexedDB<T>(data: any): T {
|
||||
if (data === null || data === undefined) {
|
||||
return data as T;
|
||||
}
|
||||
|
||||
if (typeof data === 'string') {
|
||||
if (data.startsWith(SERIALIZATION_MARKERS.FUNCTION_PREFIX)) {
|
||||
const funcBody = data.substring(SERIALIZATION_MARKERS.FUNCTION_PREFIX.length);
|
||||
|
||||
if (funcBody === 'abort') {
|
||||
const abortController = new AbortController();
|
||||
return function () {
|
||||
abortController.abort();
|
||||
} as unknown as T;
|
||||
}
|
||||
|
||||
try {
|
||||
return new Function(`return ${funcBody}`)() as T;
|
||||
} catch (error) {
|
||||
console.error('Failed to deserialize function:', error);
|
||||
return (() => {
|
||||
// ignore error
|
||||
return undefined;
|
||||
}) as unknown as T;
|
||||
}
|
||||
}
|
||||
|
||||
if (data === SERIALIZATION_MARKERS.ABORT_SIGNAL_PREFIX) {
|
||||
return new AbortController().signal as unknown as T;
|
||||
}
|
||||
|
||||
if (data === SERIALIZATION_MARKERS.ABORT_CONTROLLER_PREFIX) {
|
||||
return new AbortController() as unknown as T;
|
||||
}
|
||||
}
|
||||
|
||||
if (data && typeof data === 'object') {
|
||||
if (data.__type === 'Date' && data.value) {
|
||||
return new Date(data.value) as unknown as T;
|
||||
}
|
||||
|
||||
if (Array.isArray(data)) {
|
||||
return data.map((item) => deserializeFromIndexedDB(item)) as unknown as T;
|
||||
}
|
||||
|
||||
const deserializedObject: Record<string, any> = {};
|
||||
|
||||
for (const key in data) {
|
||||
if (Object.prototype.hasOwnProperty.call(data, key)) {
|
||||
deserializedObject[key] = deserializeFromIndexedDB(data[key]);
|
||||
}
|
||||
}
|
||||
|
||||
return deserializedObject as T;
|
||||
}
|
||||
|
||||
return data as T;
|
||||
}
|
||||
|
||||
export interface IEditorMessageProject {
|
||||
messageId: string;
|
||||
pages: Page[];
|
||||
sections: Section[];
|
||||
}
|
||||
|
||||
export interface IProject {
|
||||
id: string;
|
||||
messageProjects: IEditorMessageProject[];
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
const logger = createScopedLogger('EditorProjects');
|
||||
|
||||
/**
|
||||
* 打开 editor 本地数据库。
|
||||
* @returns editor 本地数据库。
|
||||
*/
|
||||
export async function openEditorDatabase(): Promise<IDBDatabase | undefined> {
|
||||
if (typeof indexedDB === 'undefined') {
|
||||
logger.error('indexedDB 在当前环境中不可用');
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const request = indexedDB.open('editorProjects', 1);
|
||||
|
||||
request.onupgradeneeded = (event: IDBVersionChangeEvent) => {
|
||||
const db = (event.target as IDBOpenDBRequest).result;
|
||||
const oldVersion = event.oldVersion;
|
||||
|
||||
if (oldVersion < 1) {
|
||||
if (!db.objectStoreNames.contains('projects')) {
|
||||
const store = db.createObjectStore('projects', { keyPath: 'id' });
|
||||
store.createIndex('id', 'id', { unique: true });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
request.onsuccess = (event: Event) => {
|
||||
resolve((event.target as IDBOpenDBRequest).result);
|
||||
};
|
||||
|
||||
request.onerror = (event: Event) => {
|
||||
resolve(undefined);
|
||||
logger.error((event.target as IDBOpenDBRequest).error);
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// 保存项目数据
|
||||
export async function saveProject(
|
||||
db: IDBDatabase,
|
||||
messageId: string,
|
||||
pages: Page[],
|
||||
sections: Section[],
|
||||
): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
// 序列化数据,处理不可序列化的内容
|
||||
const serializedPages = serializeForIndexedDB(pages);
|
||||
const serializedSections = serializeForIndexedDB(sections);
|
||||
|
||||
const transaction = db.transaction('projects', 'readwrite');
|
||||
const store = transaction.objectStore('projects');
|
||||
|
||||
// 首先尝试获取现有记录
|
||||
const getRequest = store.get(getChatId()!);
|
||||
|
||||
getRequest.onsuccess = () => {
|
||||
const existingData = getRequest.result as IProject | undefined;
|
||||
const timestamp = new Date().toISOString();
|
||||
|
||||
if (existingData) {
|
||||
/*
|
||||
* 如果记录存在
|
||||
* 检查是否已存在相同 messageId 的项目
|
||||
*/
|
||||
const existingIndex = existingData.messageProjects.findIndex((p) => p.messageId === messageId);
|
||||
|
||||
let messageProjects;
|
||||
|
||||
if (existingIndex !== -1) {
|
||||
// 如果找到了相同 messageId 的项目,则更新它
|
||||
messageProjects = existingData.messageProjects.map((p, index) =>
|
||||
index === existingIndex ? { ...p, pages: serializedPages, sections: serializedSections } : p,
|
||||
);
|
||||
} else {
|
||||
// 如果没有找到相同 messageId 的项目,则添加新项目
|
||||
messageProjects = [
|
||||
...existingData.messageProjects,
|
||||
{ messageId, pages: serializedPages, sections: serializedSections },
|
||||
];
|
||||
}
|
||||
|
||||
const updatedData = {
|
||||
...existingData,
|
||||
messageProjects,
|
||||
timestamp,
|
||||
};
|
||||
|
||||
const putRequest = store.put(updatedData);
|
||||
putRequest.onsuccess = () => resolve();
|
||||
putRequest.onerror = () => reject(putRequest.error);
|
||||
} else {
|
||||
// 创建新记录
|
||||
const newData: IProject = {
|
||||
id: getChatId()!,
|
||||
messageProjects: [{ messageId, pages: serializedPages, sections: serializedSections }],
|
||||
timestamp,
|
||||
};
|
||||
|
||||
const putRequest = store.put(newData);
|
||||
putRequest.onsuccess = () => resolve();
|
||||
putRequest.onerror = () => reject(putRequest.error);
|
||||
}
|
||||
};
|
||||
|
||||
getRequest.onerror = () => reject(getRequest.error);
|
||||
});
|
||||
}
|
||||
|
||||
export async function setEditorProjects(db: IDBDatabase, id: string, projects: IEditorMessageProject[]): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
// 序列化项目数据,处理不可序列化的内容
|
||||
const serializedProjects = serializeForIndexedDB(projects);
|
||||
|
||||
const transaction = db.transaction('projects', 'readwrite');
|
||||
const store = transaction.objectStore('projects');
|
||||
const request = store.put({
|
||||
id,
|
||||
messageProjects: serializedProjects,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
request.onsuccess = () => resolve();
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
export async function getEditorProjects(db: IDBDatabase, chatId: string): Promise<IProject> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction('projects', 'readonly');
|
||||
const store = transaction.objectStore('projects');
|
||||
const request = store.get(chatId);
|
||||
|
||||
request.onsuccess = () => {
|
||||
const project = request.result as IProject;
|
||||
|
||||
if (project && project.messageProjects) {
|
||||
const deserializedProject = {
|
||||
...project,
|
||||
messageProjects: project.messageProjects.map((mp) => ({
|
||||
...mp,
|
||||
pages: mp.pages ? deserializeFromIndexedDB<Page[]>(mp.pages) : [],
|
||||
sections: mp.sections ? deserializeFromIndexedDB<Section[]>(mp.sections) : [],
|
||||
})),
|
||||
};
|
||||
resolve(deserializedProject);
|
||||
} else {
|
||||
resolve(project);
|
||||
}
|
||||
};
|
||||
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
// 获取项目数据
|
||||
export async function getEditorProject(
|
||||
db: IDBDatabase,
|
||||
messageId?: string,
|
||||
): Promise<{ pages: Page[]; sections: Section[] | undefined; project?: IProject } | undefined> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction('projects', 'readonly');
|
||||
const store = transaction.objectStore('projects');
|
||||
const request = store.get(getChatId()!);
|
||||
|
||||
request.onsuccess = () => {
|
||||
const project = request.result as IProject;
|
||||
|
||||
if (!project) {
|
||||
resolve(undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
if (messageId) {
|
||||
// 返回特定消息 ID 的项目数据
|
||||
const data = project.messageProjects?.find((p) => p.messageId === messageId);
|
||||
|
||||
const deserializedPages = data?.pages ? deserializeFromIndexedDB<Page[]>(data.pages) : [];
|
||||
const deserializedSections = data?.sections ? deserializeFromIndexedDB<Section[]>(data.sections) : undefined;
|
||||
|
||||
resolve({
|
||||
pages: deserializedPages,
|
||||
sections: deserializedSections,
|
||||
project,
|
||||
});
|
||||
} else {
|
||||
// 没有指定消息 ID,返回最新的项目数据
|
||||
const messageIds = project.messageProjects.map((p) => p.messageId);
|
||||
|
||||
if (messageIds.length === 0) {
|
||||
resolve({ pages: [], sections: undefined, project });
|
||||
} else {
|
||||
// 按时间戳排序(如果有时间戳),或者取最后一个
|
||||
const lastMessageId = messageIds[messageIds.length - 1];
|
||||
const lastMessageProject = project.messageProjects.find((p) => p.messageId === lastMessageId);
|
||||
|
||||
const deserializedPages = lastMessageProject?.pages
|
||||
? deserializeFromIndexedDB<Page[]>(lastMessageProject.pages)
|
||||
: [];
|
||||
const deserializedSections = lastMessageProject?.sections
|
||||
? deserializeFromIndexedDB<Section[]>(lastMessageProject.sections)
|
||||
: undefined;
|
||||
|
||||
resolve({
|
||||
pages: deserializedPages,
|
||||
sections: deserializedSections,
|
||||
project,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
// 删除项目数据
|
||||
export async function deleteEditorProject(db: IDBDatabase, chatId: string, messageId?: string): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction('projects', 'readwrite');
|
||||
const store = transaction.objectStore('projects');
|
||||
|
||||
if (messageId) {
|
||||
// 只删除特定消息 ID 的项目数据
|
||||
const getRequest = store.get(chatId);
|
||||
|
||||
getRequest.onsuccess = () => {
|
||||
const project = getRequest.result as IProject;
|
||||
|
||||
if (project && project.messageProjects && project.messageProjects.find((p) => p.messageId === messageId)) {
|
||||
// 删除特定消息的项目数据
|
||||
project.messageProjects = project.messageProjects.filter((p) => p.messageId !== messageId);
|
||||
|
||||
if (project.messageProjects.length === 0) {
|
||||
// 如果没有剩余项目,删除整个记录
|
||||
const deleteRequest = store.delete(chatId);
|
||||
deleteRequest.onsuccess = () => resolve();
|
||||
deleteRequest.onerror = () => reject(deleteRequest.error);
|
||||
} else {
|
||||
// 更新记录
|
||||
project.timestamp = new Date().toISOString();
|
||||
|
||||
const putRequest = store.put(project);
|
||||
putRequest.onsuccess = () => resolve();
|
||||
putRequest.onerror = () => reject(putRequest.error);
|
||||
}
|
||||
} else {
|
||||
resolve(); // 项目不存在或消息 ID 不存在,视为删除成功
|
||||
}
|
||||
};
|
||||
|
||||
getRequest.onerror = () => reject(getRequest.error);
|
||||
} else {
|
||||
// 删除整个聊天 ID 的所有项目数据
|
||||
const request = store.delete(chatId);
|
||||
request.onsuccess = () => resolve();
|
||||
request.onerror = () => reject(request.error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 获取所有项目数据
|
||||
export async function getAllEditorProjects(db: IDBDatabase): Promise<IProject[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction('projects', 'readonly');
|
||||
const store = transaction.objectStore('projects');
|
||||
const request = store.getAll();
|
||||
|
||||
request.onsuccess = () => {
|
||||
const projects = request.result as IProject[];
|
||||
resolve(projects);
|
||||
};
|
||||
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
export async function createEditorProjectFromMessages(
|
||||
db: IDBDatabase,
|
||||
chatId: string,
|
||||
messageProjects: IEditorMessageProject[],
|
||||
): Promise<void> {
|
||||
await setEditorProjects(db, chatId, messageProjects);
|
||||
}
|
||||
|
||||
export async function forkEditorProject(
|
||||
db: IDBDatabase,
|
||||
chatId: string,
|
||||
messageId: string,
|
||||
newChatId: string,
|
||||
): Promise<void> {
|
||||
const project = await getEditorProjects(db, chatId);
|
||||
if (!project) {
|
||||
console.warn('editor project not found, It may be old project data.');
|
||||
return;
|
||||
}
|
||||
|
||||
const messageIndex = project.messageProjects.findIndex((msg) => msg.messageId === messageId);
|
||||
|
||||
if (messageIndex === -1) {
|
||||
throw new Error('Message not found');
|
||||
}
|
||||
|
||||
const messages = project.messageProjects.slice(0, messageIndex + 1);
|
||||
|
||||
await createEditorProjectFromMessages(db, newChatId, messages);
|
||||
}
|
||||
|
||||
export async function duplicateEditorProject(db: IDBDatabase, id: string, newChatId: string): Promise<void> {
|
||||
const project = await getEditorProjects(db, id);
|
||||
if (!project) {
|
||||
console.warn('editor project not found, It may be old project data.');
|
||||
return;
|
||||
}
|
||||
|
||||
createEditorProjectFromMessages(db, newChatId, project.messageProjects);
|
||||
}
|
||||
|
||||
// 在 GrapesEditor 中使用的Hook
|
||||
export function useEditorStorage() {
|
||||
const [searchParams] = useSearchParams();
|
||||
const currentMessageId = searchParams.get('rewindTo');
|
||||
|
||||
// 保存项目至本地数据库
|
||||
const saveEditorProject = async (messageId: string | undefined, pages: Page[], sections: Section[]) => {
|
||||
const db = await openEditorDatabase();
|
||||
|
||||
if (!db || !messageId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
await saveProject(db, messageId, pages, sections);
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error('保存 editor 项目失败', error);
|
||||
return false;
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 加载 editor 项目。
|
||||
* @returns editor 项目数据。
|
||||
*/
|
||||
const loadEditorProject = async (): Promise<Page[] | undefined> => {
|
||||
const db = await openEditorDatabase();
|
||||
|
||||
if (!db) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const messageId = currentMessageId || undefined;
|
||||
try {
|
||||
const result = await getEditorProject(db, messageId);
|
||||
return result?.pages;
|
||||
} catch (error) {
|
||||
logger.error('加载 editor 项目失败', error);
|
||||
return undefined;
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
saveEditorProject,
|
||||
loadEditorProject,
|
||||
};
|
||||
}
|
||||
2
app/lib/persistence/index.ts
Normal file
2
app/lib/persistence/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from '../hooks/useChatHistory';
|
||||
export * from './local-storage';
|
||||
28
app/lib/persistence/local-storage.ts
Normal file
28
app/lib/persistence/local-storage.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
// Client-side storage utilities
|
||||
const isClient = typeof window !== 'undefined' && typeof localStorage !== 'undefined';
|
||||
|
||||
export function getLocalStorage(key: string): any | null {
|
||||
if (!isClient) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const item = localStorage.getItem(key);
|
||||
return item ? JSON.parse(item) : null;
|
||||
} catch (error) {
|
||||
console.error(`Error reading from localStorage key "${key}":`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function setLocalStorage(key: string, value: any): void {
|
||||
if (!isClient) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
localStorage.setItem(key, JSON.stringify(value));
|
||||
} catch (error) {
|
||||
console.error(`Error writing to localStorage key "${key}":`, error);
|
||||
}
|
||||
}
|
||||
238
app/lib/runtime/__snapshots__/message-parser.spec.ts.snap
Normal file
238
app/lib/runtime/__snapshots__/message-parser.spec.ts.snap
Normal file
@@ -0,0 +1,238 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`StreamingMessageParser > valid artifacts with actions > should correctly parse chunks and strip out uPage artifacts (0) > onActionClose 1`] = `
|
||||
{
|
||||
"action": {
|
||||
"content": "npm install",
|
||||
"type": "shell",
|
||||
},
|
||||
"actionId": "0",
|
||||
"artifactId": "artifact_1",
|
||||
"messageId": "message_1",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`StreamingMessageParser > valid artifacts with actions > should correctly parse chunks and strip out uPage artifacts (0) > onActionOpen 1`] = `
|
||||
{
|
||||
"action": {
|
||||
"content": "",
|
||||
"type": "shell",
|
||||
},
|
||||
"actionId": "0",
|
||||
"artifactId": "artifact_1",
|
||||
"messageId": "message_1",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`StreamingMessageParser > valid artifacts with actions > should correctly parse chunks and strip out uPage artifacts (0) > onArtifactClose 1`] = `
|
||||
{
|
||||
"id": "artifact_1",
|
||||
"messageId": "message_1",
|
||||
"title": "Some title",
|
||||
"type": undefined,
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`StreamingMessageParser > valid artifacts with actions > should correctly parse chunks and strip out uPage artifacts (0) > onArtifactOpen 1`] = `
|
||||
{
|
||||
"id": "artifact_1",
|
||||
"messageId": "message_1",
|
||||
"title": "Some title",
|
||||
"type": undefined,
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`StreamingMessageParser > valid artifacts with actions > should correctly parse chunks and strip out uPage artifacts (1) > onActionClose 1`] = `
|
||||
{
|
||||
"action": {
|
||||
"content": "npm install",
|
||||
"type": "shell",
|
||||
},
|
||||
"actionId": "0",
|
||||
"artifactId": "artifact_1",
|
||||
"messageId": "message_1",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`StreamingMessageParser > valid artifacts with actions > should correctly parse chunks and strip out uPage artifacts (1) > onActionClose 2`] = `
|
||||
{
|
||||
"action": {
|
||||
"content": "some content
|
||||
",
|
||||
"filePath": "index.js",
|
||||
"type": "file",
|
||||
},
|
||||
"actionId": "1",
|
||||
"artifactId": "artifact_1",
|
||||
"messageId": "message_1",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`StreamingMessageParser > valid artifacts with actions > should correctly parse chunks and strip out uPage artifacts (1) > onActionOpen 1`] = `
|
||||
{
|
||||
"action": {
|
||||
"content": "",
|
||||
"type": "shell",
|
||||
},
|
||||
"actionId": "0",
|
||||
"artifactId": "artifact_1",
|
||||
"messageId": "message_1",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`StreamingMessageParser > valid artifacts with actions > should correctly parse chunks and strip out uPage artifacts (1) > onActionOpen 2`] = `
|
||||
{
|
||||
"action": {
|
||||
"content": "",
|
||||
"filePath": "index.js",
|
||||
"type": "file",
|
||||
},
|
||||
"actionId": "1",
|
||||
"artifactId": "artifact_1",
|
||||
"messageId": "message_1",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`StreamingMessageParser > valid artifacts with actions > should correctly parse chunks and strip out uPage artifacts (1) > onArtifactClose 1`] = `
|
||||
{
|
||||
"id": "artifact_1",
|
||||
"messageId": "message_1",
|
||||
"title": "Some title",
|
||||
"type": undefined,
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`StreamingMessageParser > valid artifacts with actions > should correctly parse chunks and strip out uPage artifacts (1) > onArtifactOpen 1`] = `
|
||||
{
|
||||
"id": "artifact_1",
|
||||
"messageId": "message_1",
|
||||
"title": "Some title",
|
||||
"type": undefined,
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`StreamingMessageParser > valid artifacts without actions > should correctly parse chunks and strip out uPage artifacts (0) > onArtifactClose 1`] = `
|
||||
{
|
||||
"id": "artifact_1",
|
||||
"messageId": "message_1",
|
||||
"title": "Some title",
|
||||
"type": undefined,
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`StreamingMessageParser > valid artifacts without actions > should correctly parse chunks and strip out uPage artifacts (0) > onArtifactOpen 1`] = `
|
||||
{
|
||||
"id": "artifact_1",
|
||||
"messageId": "message_1",
|
||||
"title": "Some title",
|
||||
"type": undefined,
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`StreamingMessageParser > valid artifacts without actions > should correctly parse chunks and strip out uPage artifacts (1) > onArtifactClose 1`] = `
|
||||
{
|
||||
"id": "artifact_1",
|
||||
"messageId": "message_1",
|
||||
"title": "Some title",
|
||||
"type": "bundled",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`StreamingMessageParser > valid artifacts without actions > should correctly parse chunks and strip out uPage artifacts (1) > onArtifactOpen 1`] = `
|
||||
{
|
||||
"id": "artifact_1",
|
||||
"messageId": "message_1",
|
||||
"title": "Some title",
|
||||
"type": "bundled",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`StreamingMessageParser > valid artifacts without actions > should correctly parse chunks and strip out uPage artifacts (2) > onArtifactClose 1`] = `
|
||||
{
|
||||
"id": "artifact_1",
|
||||
"messageId": "message_1",
|
||||
"title": "Some title",
|
||||
"type": undefined,
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`StreamingMessageParser > valid artifacts without actions > should correctly parse chunks and strip out uPage artifacts (2) > onArtifactOpen 1`] = `
|
||||
{
|
||||
"id": "artifact_1",
|
||||
"messageId": "message_1",
|
||||
"title": "Some title",
|
||||
"type": undefined,
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`StreamingMessageParser > valid artifacts without actions > should correctly parse chunks and strip out uPage artifacts (3) > onArtifactClose 1`] = `
|
||||
{
|
||||
"id": "artifact_1",
|
||||
"messageId": "message_1",
|
||||
"title": "Some title",
|
||||
"type": undefined,
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`StreamingMessageParser > valid artifacts without actions > should correctly parse chunks and strip out uPage artifacts (3) > onArtifactOpen 1`] = `
|
||||
{
|
||||
"id": "artifact_1",
|
||||
"messageId": "message_1",
|
||||
"title": "Some title",
|
||||
"type": undefined,
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`StreamingMessageParser > valid artifacts without actions > should correctly parse chunks and strip out uPage artifacts (4) > onArtifactClose 1`] = `
|
||||
{
|
||||
"id": "artifact_1",
|
||||
"messageId": "message_1",
|
||||
"title": "Some title",
|
||||
"type": undefined,
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`StreamingMessageParser > valid artifacts without actions > should correctly parse chunks and strip out uPage artifacts (4) > onArtifactOpen 1`] = `
|
||||
{
|
||||
"id": "artifact_1",
|
||||
"messageId": "message_1",
|
||||
"title": "Some title",
|
||||
"type": undefined,
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`StreamingMessageParser > valid artifacts without actions > should correctly parse chunks and strip out uPage artifacts (5) > onArtifactClose 1`] = `
|
||||
{
|
||||
"id": "artifact_1",
|
||||
"messageId": "message_1",
|
||||
"title": "Some title",
|
||||
"type": undefined,
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`StreamingMessageParser > valid artifacts without actions > should correctly parse chunks and strip out uPage artifacts (5) > onArtifactOpen 1`] = `
|
||||
{
|
||||
"id": "artifact_1",
|
||||
"messageId": "message_1",
|
||||
"title": "Some title",
|
||||
"type": undefined,
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`StreamingMessageParser > valid artifacts without actions > should correctly parse chunks and strip out uPage artifacts (6) > onArtifactClose 1`] = `
|
||||
{
|
||||
"id": "artifact_1",
|
||||
"messageId": "message_1",
|
||||
"title": "Some title",
|
||||
"type": undefined,
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`StreamingMessageParser > valid artifacts without actions > should correctly parse chunks and strip out uPage artifacts (6) > onArtifactOpen 1`] = `
|
||||
{
|
||||
"id": "artifact_1",
|
||||
"messageId": "message_1",
|
||||
"title": "Some title",
|
||||
"type": undefined,
|
||||
}
|
||||
`;
|
||||
237
app/lib/runtime/action-runner.ts
Normal file
237
app/lib/runtime/action-runner.ts
Normal file
@@ -0,0 +1,237 @@
|
||||
import { atom, type MapStore, map } from 'nanostores';
|
||||
import type { EditorBridge } from '~/lib/bridge';
|
||||
import type { ActionAlert, UPageAction } from '~/types/actions';
|
||||
import { isValidContent } from '~/utils/html-parse';
|
||||
import { createScopedLogger } from '~/utils/logger';
|
||||
import { unreachable } from '~/utils/unreachable';
|
||||
import type { ActionCallbackData } from './message-parser';
|
||||
|
||||
export type ActionPage = {
|
||||
id: string;
|
||||
name: string;
|
||||
title: string;
|
||||
};
|
||||
|
||||
const logger = createScopedLogger('ActionRunner');
|
||||
|
||||
export type ActionStatus = 'pending' | 'running' | 'complete' | 'aborted' | 'failed';
|
||||
|
||||
export type BaseActionState = UPageAction & {
|
||||
status: Exclude<ActionStatus, 'failed'>;
|
||||
abort: () => void;
|
||||
executed: boolean;
|
||||
abortSignal: AbortSignal;
|
||||
};
|
||||
|
||||
export type FailedActionState = UPageAction &
|
||||
Omit<BaseActionState, 'status'> & {
|
||||
status: Extract<ActionStatus, 'failed'>;
|
||||
error: string;
|
||||
};
|
||||
|
||||
export type ActionState = BaseActionState | FailedActionState;
|
||||
|
||||
type BaseActionUpdate = Partial<Pick<BaseActionState, 'status' | 'abort' | 'executed'>>;
|
||||
|
||||
export type ActionStateUpdate =
|
||||
| BaseActionUpdate
|
||||
| (Omit<BaseActionUpdate, 'status'> & { status: 'failed'; error: string });
|
||||
|
||||
type ActionsMap = MapStore<Record<string, ActionState>>;
|
||||
|
||||
class ActionCommandError extends Error {
|
||||
readonly _output: string;
|
||||
readonly _header: string;
|
||||
|
||||
constructor(message: string, output: string) {
|
||||
// Create a formatted message that includes both the error message and output
|
||||
const formattedMessage = `Failed To Execute Shell Command: ${message}\n\nOutput:\n${output}`;
|
||||
super(formattedMessage);
|
||||
|
||||
// Set the output separately so it can be accessed programmatically
|
||||
this._header = message;
|
||||
this._output = output;
|
||||
|
||||
// Maintain proper prototype chain
|
||||
Object.setPrototypeOf(this, ActionCommandError.prototype);
|
||||
|
||||
// Set the name of the error for better debugging
|
||||
this.name = 'ActionCommandError';
|
||||
}
|
||||
|
||||
// Optional: Add a method to get just the terminal output
|
||||
get output() {
|
||||
return this._output;
|
||||
}
|
||||
get header() {
|
||||
return this._header;
|
||||
}
|
||||
}
|
||||
|
||||
export class ActionRunner {
|
||||
#editorBridge: Promise<EditorBridge>;
|
||||
#currentExecutionPromise: Promise<void> = Promise.resolve();
|
||||
#page: ActionPage;
|
||||
|
||||
runnerId = atom<string>(`${Date.now()}`);
|
||||
actions: ActionsMap = map({});
|
||||
onAlert?: (alert: ActionAlert) => void;
|
||||
buildOutput?: { path: string; exitCode: number; output: string };
|
||||
|
||||
constructor(editorBridgePromise: Promise<EditorBridge>, page: ActionPage, onAlert?: (alert: ActionAlert) => void) {
|
||||
this.#editorBridge = editorBridgePromise;
|
||||
|
||||
this.onAlert = onAlert;
|
||||
this.#page = page;
|
||||
}
|
||||
|
||||
addAction(data: ActionCallbackData) {
|
||||
const { actionId } = data;
|
||||
|
||||
const actions = this.actions.get();
|
||||
const action = actions[actionId];
|
||||
|
||||
if (action) {
|
||||
// action already added
|
||||
return;
|
||||
}
|
||||
|
||||
const abortController = new AbortController();
|
||||
this.actions.setKey(actionId, {
|
||||
...data.action,
|
||||
status: 'pending',
|
||||
executed: false,
|
||||
abort: () => {
|
||||
abortController.abort();
|
||||
this.#updateAction(actionId, { status: 'aborted' });
|
||||
},
|
||||
abortSignal: abortController.signal,
|
||||
});
|
||||
|
||||
this.#currentExecutionPromise.then(() => {
|
||||
this.#updateAction(actionId, { status: 'running' });
|
||||
});
|
||||
}
|
||||
|
||||
async runAction(data: ActionCallbackData, isRunning: boolean = false) {
|
||||
const { actionId } = data;
|
||||
const action = this.actions.get()[actionId];
|
||||
|
||||
if (!action) {
|
||||
unreachable(`Action ${actionId} not found`);
|
||||
}
|
||||
|
||||
if (action.executed) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.#updateAction(actionId, { ...action, ...data.action, executed: !isRunning });
|
||||
|
||||
this.#currentExecutionPromise = this.#currentExecutionPromise
|
||||
.then(() => {
|
||||
return this.#executeAction(actionId, isRunning);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Action failed:', error);
|
||||
});
|
||||
|
||||
await this.#currentExecutionPromise;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
async #executeAction(actionId: string, isRunning: boolean = false) {
|
||||
let action = this.actions.get()[actionId];
|
||||
|
||||
this.#updateAction(actionId, { status: 'running' });
|
||||
const newAction = this.updateSectionRootDomId(actionId, action);
|
||||
if (newAction) {
|
||||
action = newAction;
|
||||
}
|
||||
try {
|
||||
await this.runPageAction(action);
|
||||
this.#updateAction(actionId, {
|
||||
status: isRunning ? 'running' : action.abortSignal.aborted ? 'aborted' : 'complete',
|
||||
});
|
||||
} catch (error) {
|
||||
if (action.abortSignal.aborted) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.#updateAction(actionId, { status: 'failed', error: 'Action failed' });
|
||||
logger.error(`Action failed\n\n`, error);
|
||||
|
||||
if (!(error instanceof ActionCommandError)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.onAlert?.({
|
||||
type: 'error',
|
||||
title: 'Dev Server Failed',
|
||||
description: error.header,
|
||||
content: error.output,
|
||||
});
|
||||
|
||||
// re-throw the error to be caught in the promise chain
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async #runPageSectionAction(action: ActionState) {
|
||||
const editorBridge = await this.#editorBridge;
|
||||
try {
|
||||
await editorBridge.updateSection(action);
|
||||
logger.debug(`Page Section written ${action.pageName}`);
|
||||
} catch (error) {
|
||||
logger.error('Failed to write page section\n\n', error);
|
||||
}
|
||||
}
|
||||
|
||||
async runPageAction(action: ActionState) {
|
||||
const editorBridge = await this.#editorBridge;
|
||||
try {
|
||||
// 新增或更新 Pages
|
||||
await editorBridge.upsertPageAction(action.pageName, this.#page.title, action.id);
|
||||
logger.debug(`Page written ${action.pageName}`);
|
||||
} catch (error) {
|
||||
logger.error('Failed to write page\n\n', error);
|
||||
}
|
||||
|
||||
this.#runPageSectionAction(action);
|
||||
}
|
||||
|
||||
private updateSectionRootDomId(actionId: string, action: ActionState) {
|
||||
if (action.validRootDomId) {
|
||||
return;
|
||||
}
|
||||
if (action.action === 'remove') {
|
||||
this.actions.setKey(action.id, { ...action, rootDomId: action.domId, validRootDomId: true });
|
||||
return this.actions.get()[actionId];
|
||||
}
|
||||
const content = action.content;
|
||||
const isValid = isValidContent(content);
|
||||
if (!isValid) {
|
||||
return;
|
||||
}
|
||||
const div = document.createElement('div');
|
||||
div.innerHTML = content;
|
||||
const rootDomId = div.firstElementChild?.id;
|
||||
if (!rootDomId) {
|
||||
return;
|
||||
}
|
||||
const oldRootDomId = action.rootDomId;
|
||||
if (oldRootDomId && oldRootDomId === rootDomId) {
|
||||
this.actions.setKey(actionId, { ...action, validRootDomId: true });
|
||||
} else {
|
||||
this.actions.setKey(actionId, { ...action, rootDomId });
|
||||
}
|
||||
return this.actions.get()[actionId];
|
||||
}
|
||||
|
||||
#updateAction(id: string, newState: ActionStateUpdate) {
|
||||
const actions = this.actions.get();
|
||||
const actionState = actions[id];
|
||||
|
||||
this.actions.setKey(id, { ...actionState, ...newState });
|
||||
}
|
||||
}
|
||||
212
app/lib/runtime/message-parser.spec.ts
Normal file
212
app/lib/runtime/message-parser.spec.ts
Normal file
@@ -0,0 +1,212 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { type ActionCallback, type ArtifactCallback, StreamingMessageParser } from './message-parser';
|
||||
|
||||
interface ExpectedResult {
|
||||
output: string;
|
||||
callbacks?: {
|
||||
onArtifactOpen?: number;
|
||||
onArtifactClose?: number;
|
||||
onActionOpen?: number;
|
||||
onActionClose?: number;
|
||||
};
|
||||
}
|
||||
|
||||
describe('StreamingMessageParser', () => {
|
||||
it('should pass through normal text', () => {
|
||||
const parser = new StreamingMessageParser();
|
||||
expect(parser.parse('test_id', 'Hello, world!')).toBe('Hello, world!');
|
||||
});
|
||||
|
||||
it('should allow normal HTML tags', () => {
|
||||
const parser = new StreamingMessageParser();
|
||||
expect(parser.parse('test_id', 'Hello <strong>world</strong>!')).toBe('Hello <strong>world</strong>!');
|
||||
});
|
||||
|
||||
describe('no artifacts', () => {
|
||||
it.each<[string | string[], ExpectedResult | string]>([
|
||||
['Foo bar', 'Foo bar'],
|
||||
['Foo bar <', 'Foo bar '],
|
||||
['Foo bar <p', 'Foo bar <p'],
|
||||
[['Foo bar <', 's', 'p', 'an>some text</span>'], 'Foo bar <span>some text</span>'],
|
||||
])('should correctly parse chunks and strip out upage artifacts (%#)', (input, expected) => {
|
||||
runTest(input, expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('invalid or incomplete artifacts', () => {
|
||||
it.each<[string | string[], ExpectedResult | string]>([
|
||||
['Foo bar <u', 'Foo bar '],
|
||||
['Foo bar <uP', 'Foo bar <uP'],
|
||||
['Foo bar <uPa', 'Foo bar '],
|
||||
['Foo bar <uPag', 'Foo bar '],
|
||||
['Foo bar <uPage', 'Foo bar <uPage'],
|
||||
['Foo bar <uPageA', 'Foo bar '],
|
||||
['Foo bar <uPageArtifacs></uPageArtifact>', 'Foo bar <uPageArtifacs></uPageArtifact>'],
|
||||
['Before <PageArtfiact>foo</uPageArtifact> After', 'Before <PageArtfiact>foo</uPageArtifact> After'],
|
||||
['Before <uPageArtifactt>foo</uPageArtifact> After', 'Before <uPageArtifactt>foo</uPageArtifact> After'],
|
||||
])('should correctly parse chunks and strip out upage artifacts (%#)', (input, expected) => {
|
||||
runTest(input, expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('valid artifacts without actions', () => {
|
||||
it.each<[string | string[], ExpectedResult | string]>([
|
||||
[
|
||||
'Some text before <uPageArtifact id="artifact_1" name="index" title="Some title">foo bar</uPageArtifact> Some more text',
|
||||
{
|
||||
output: 'Some text before Some more text',
|
||||
callbacks: { onArtifactOpen: 1, onArtifactClose: 1, onActionOpen: 0, onActionClose: 0 },
|
||||
},
|
||||
],
|
||||
[
|
||||
[
|
||||
'Some text before <uPageArti',
|
||||
'fact',
|
||||
' name="index" title="Some title" id="artifact_1">foo</uPageArtifact> Some more text',
|
||||
],
|
||||
{
|
||||
output: 'Some text before Some more text',
|
||||
callbacks: { onArtifactOpen: 1, onArtifactClose: 1, onActionOpen: 0, onActionClose: 0 },
|
||||
},
|
||||
],
|
||||
[
|
||||
[
|
||||
'Some text before <uPageArti',
|
||||
'fac',
|
||||
't title="Some title" id="artifact_1" name="index"',
|
||||
' ',
|
||||
'>',
|
||||
'foo</uPageArtifact> Some more text',
|
||||
],
|
||||
{
|
||||
output: 'Some text before Some more text',
|
||||
callbacks: { onArtifactOpen: 1, onArtifactClose: 1, onActionOpen: 0, onActionClose: 0 },
|
||||
},
|
||||
],
|
||||
[
|
||||
[
|
||||
'Some text before <uPageArti',
|
||||
'fact',
|
||||
' title="Some title" id="artifact_1" name="index"',
|
||||
' >fo',
|
||||
'o</uPageArtifact> Some more text',
|
||||
],
|
||||
{
|
||||
output: 'Some text before Some more text',
|
||||
callbacks: { onArtifactOpen: 1, onArtifactClose: 1, onActionOpen: 0, onActionClose: 0 },
|
||||
},
|
||||
],
|
||||
[
|
||||
[
|
||||
'Some text before <uPageArti',
|
||||
'fact tit',
|
||||
'le="Some ',
|
||||
'title" id="artifact_1" name="index">fo',
|
||||
'o',
|
||||
'<',
|
||||
'/uPageArtifact> Some more text',
|
||||
],
|
||||
{
|
||||
output: 'Some text before Some more text',
|
||||
callbacks: { onArtifactOpen: 1, onArtifactClose: 1, onActionOpen: 0, onActionClose: 0 },
|
||||
},
|
||||
],
|
||||
[
|
||||
[
|
||||
'Some text before <uPageArti',
|
||||
'fact title="Some title" id="artif',
|
||||
' name="index"',
|
||||
'act_1">fo',
|
||||
'o<',
|
||||
'/uPageArtifact> Some more text',
|
||||
],
|
||||
{
|
||||
output: 'Some text before Some more text',
|
||||
callbacks: { onArtifactOpen: 1, onArtifactClose: 1, onActionOpen: 0, onActionClose: 0 },
|
||||
},
|
||||
],
|
||||
[
|
||||
'Before <uPageArtifact title="Some title" id="artifact_1" name="index">foo</uPageArtifact> After',
|
||||
{
|
||||
output: 'Before After',
|
||||
callbacks: { onArtifactOpen: 1, onArtifactClose: 1, onActionOpen: 0, onActionClose: 0 },
|
||||
},
|
||||
],
|
||||
])('should correctly parse chunks and strip out uPage artifacts (%#)', (input, expected) => {
|
||||
runTest(input, expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('valid artifacts with actions', () => {
|
||||
it.each<[string | string[], ExpectedResult | string]>([
|
||||
[
|
||||
'Before <uPageArtifact title="Some title" id="artifact_1" name="index"><uPageAction type="shell">npm install</uPageAction></uPageArtifact> After',
|
||||
{
|
||||
output: 'Before After',
|
||||
callbacks: { onArtifactOpen: 1, onArtifactClose: 1, onActionOpen: 1, onActionClose: 1 },
|
||||
},
|
||||
],
|
||||
[
|
||||
'Before <uPageArtifact title="Some title" id="artifact_1"><uPageAction type="shell">npm install</uPageAction><uPageAction type="file" filePath="index.js">some content</uPageAction></uPageArtifact> After',
|
||||
{
|
||||
output: 'Before After',
|
||||
callbacks: { onArtifactOpen: 1, onArtifactClose: 1, onActionOpen: 2, onActionClose: 2 },
|
||||
},
|
||||
],
|
||||
])('should correctly parse chunks and strip out uPage artifacts (%#)', (input, expected) => {
|
||||
runTest(input, expected);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function runTest(input: string | string[], outputOrExpectedResult: string | ExpectedResult) {
|
||||
let expected: ExpectedResult;
|
||||
|
||||
if (typeof outputOrExpectedResult === 'string') {
|
||||
expected = { output: outputOrExpectedResult };
|
||||
} else {
|
||||
expected = outputOrExpectedResult;
|
||||
}
|
||||
|
||||
const callbacks = {
|
||||
onArtifactOpen: vi.fn<ArtifactCallback>((data) => {
|
||||
expect(data).toMatchSnapshot('onArtifactOpen');
|
||||
}),
|
||||
onArtifactClose: vi.fn<ArtifactCallback>((data) => {
|
||||
expect(data).toMatchSnapshot('onArtifactClose');
|
||||
}),
|
||||
onActionOpen: vi.fn<ActionCallback>((data) => {
|
||||
expect(data).toMatchSnapshot('onActionOpen');
|
||||
}),
|
||||
onActionClose: vi.fn<ActionCallback>((data) => {
|
||||
expect(data).toMatchSnapshot('onActionClose');
|
||||
}),
|
||||
};
|
||||
|
||||
const parser = new StreamingMessageParser({
|
||||
artifactElement: () => '',
|
||||
callbacks,
|
||||
});
|
||||
|
||||
let message = '';
|
||||
|
||||
let result = '';
|
||||
|
||||
const chunks = Array.isArray(input) ? input : input.split('');
|
||||
|
||||
for (const chunk of chunks) {
|
||||
message += chunk;
|
||||
|
||||
result += parser.parse('message_1', message);
|
||||
}
|
||||
|
||||
for (const name in expected.callbacks) {
|
||||
const callbackName = name;
|
||||
|
||||
expect(callbacks[callbackName as keyof typeof callbacks]).toHaveBeenCalledTimes(
|
||||
expected.callbacks[callbackName as keyof typeof expected.callbacks] ?? 0,
|
||||
);
|
||||
}
|
||||
|
||||
expect(result).toEqual(expected.output);
|
||||
}
|
||||
325
app/lib/runtime/message-parser.ts
Normal file
325
app/lib/runtime/message-parser.ts
Normal file
@@ -0,0 +1,325 @@
|
||||
import type { UPageAction, UPageActionData } from '~/types/actions';
|
||||
import type { UPageArtifactData } from '~/types/artifact';
|
||||
import { createScopedLogger } from '~/utils/logger';
|
||||
import { unreachable } from '~/utils/unreachable';
|
||||
|
||||
const ARTIFACT_TAG_OPEN = '<uPageArtifact';
|
||||
const ARTIFACT_TAG_CLOSE = '</uPageArtifact>';
|
||||
const ARTIFACT_ACTION_TAG_OPEN = '<uPageAction';
|
||||
const ARTIFACT_ACTION_TAG_CLOSE = '</uPageAction>';
|
||||
|
||||
const logger = createScopedLogger('MessageParser');
|
||||
|
||||
export interface ArtifactCallbackData extends UPageArtifactData {
|
||||
messageId: string;
|
||||
}
|
||||
|
||||
export interface ActionCallbackData {
|
||||
artifactId: string;
|
||||
messageId: string;
|
||||
actionId: string;
|
||||
action: UPageAction;
|
||||
}
|
||||
|
||||
export type ArtifactCallback = (data: ArtifactCallbackData) => void;
|
||||
export type ActionCallback = (data: ActionCallbackData) => void;
|
||||
|
||||
export interface ParserCallbacks {
|
||||
onArtifactOpen?: ArtifactCallback;
|
||||
onArtifactClose?: ArtifactCallback;
|
||||
onActionOpen?: ActionCallback;
|
||||
onActionStream?: ActionCallback;
|
||||
onActionClose?: ActionCallback;
|
||||
}
|
||||
|
||||
interface ElementFactoryProps {
|
||||
messageId: string;
|
||||
}
|
||||
|
||||
type ElementFactory = (props: ElementFactoryProps) => string;
|
||||
|
||||
export interface StreamingMessageParserOptions {
|
||||
callbacks?: ParserCallbacks;
|
||||
artifactElement?: ElementFactory;
|
||||
}
|
||||
|
||||
interface MessageState {
|
||||
position: number;
|
||||
insideArtifact: boolean;
|
||||
insideAction: boolean;
|
||||
currentArtifact?: UPageArtifactData;
|
||||
currentAction: UPageActionData;
|
||||
actionId: number;
|
||||
}
|
||||
|
||||
export class StreamingMessageParser {
|
||||
#messages = new Map<string, MessageState>();
|
||||
|
||||
constructor(private _options: StreamingMessageParserOptions = {}) {}
|
||||
|
||||
parse(messageId: string, input: string) {
|
||||
let state = this.#messages.get(messageId);
|
||||
|
||||
if (!state) {
|
||||
state = {
|
||||
position: 0,
|
||||
insideAction: false,
|
||||
insideArtifact: false,
|
||||
currentAction: { content: '' },
|
||||
actionId: 0,
|
||||
};
|
||||
|
||||
this.#messages.set(messageId, state);
|
||||
}
|
||||
|
||||
let output = '';
|
||||
let i = state.position;
|
||||
let earlyBreak = false;
|
||||
|
||||
while (i < input.length) {
|
||||
if (state.insideArtifact) {
|
||||
const currentArtifact = state.currentArtifact;
|
||||
|
||||
if (currentArtifact === undefined) {
|
||||
unreachable('Artifact not initialized');
|
||||
}
|
||||
|
||||
if (state.insideAction) {
|
||||
const closeIndex = input.indexOf(ARTIFACT_ACTION_TAG_CLOSE, i);
|
||||
|
||||
const currentAction = state.currentAction;
|
||||
|
||||
if (closeIndex !== -1) {
|
||||
currentAction.content += input.slice(i, closeIndex);
|
||||
|
||||
currentAction.content = currentAction.content.trim();
|
||||
|
||||
this._options.callbacks?.onActionClose?.({
|
||||
artifactId: currentArtifact.id,
|
||||
messageId,
|
||||
|
||||
/**
|
||||
* We decrement the id because it's been incremented already
|
||||
* when `onActionOpen` was emitted to make sure the ids are
|
||||
* the same.
|
||||
*/
|
||||
actionId: String(state.actionId - 1),
|
||||
|
||||
action: currentAction as UPageAction,
|
||||
});
|
||||
|
||||
state.insideAction = false;
|
||||
state.currentAction = { content: '' };
|
||||
|
||||
i = closeIndex + ARTIFACT_ACTION_TAG_CLOSE.length;
|
||||
} else {
|
||||
const content = input.slice(i);
|
||||
this._options.callbacks?.onActionStream?.({
|
||||
artifactId: currentArtifact.id,
|
||||
messageId,
|
||||
actionId: String(state.actionId - 1),
|
||||
action: {
|
||||
...(currentAction as UPageAction),
|
||||
content,
|
||||
},
|
||||
});
|
||||
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
const actionOpenIndex = input.indexOf(ARTIFACT_ACTION_TAG_OPEN, i);
|
||||
const artifactCloseIndex = input.indexOf(ARTIFACT_TAG_CLOSE, i);
|
||||
|
||||
if (actionOpenIndex !== -1 && (artifactCloseIndex === -1 || actionOpenIndex < artifactCloseIndex)) {
|
||||
const actionEndIndex = input.indexOf('>', actionOpenIndex);
|
||||
|
||||
if (actionEndIndex !== -1) {
|
||||
state.insideAction = true;
|
||||
|
||||
state.currentAction = this.#parseActionTag(input, actionOpenIndex, actionEndIndex);
|
||||
|
||||
this._options.callbacks?.onActionOpen?.({
|
||||
artifactId: currentArtifact.id,
|
||||
messageId,
|
||||
actionId: String(state.actionId++),
|
||||
action: state.currentAction as UPageAction,
|
||||
});
|
||||
|
||||
i = actionEndIndex + 1;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
} else if (artifactCloseIndex !== -1) {
|
||||
this._options.callbacks?.onArtifactClose?.({ messageId, ...currentArtifact });
|
||||
|
||||
state.insideArtifact = false;
|
||||
state.currentArtifact = undefined;
|
||||
|
||||
i = artifactCloseIndex + ARTIFACT_TAG_CLOSE.length;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else if (input[i] === '<' && input[i + 1] !== '/') {
|
||||
let j = i;
|
||||
let potentialTag = '';
|
||||
|
||||
while (j < input.length && potentialTag.length < ARTIFACT_TAG_OPEN.length) {
|
||||
potentialTag += input[j];
|
||||
|
||||
if (potentialTag === ARTIFACT_TAG_OPEN) {
|
||||
const nextChar = input[j + 1];
|
||||
|
||||
if (nextChar && nextChar !== '>' && nextChar !== ' ') {
|
||||
output += input.slice(i, j + 1);
|
||||
i = j + 1;
|
||||
break;
|
||||
}
|
||||
|
||||
const openTagEnd = input.indexOf('>', j);
|
||||
|
||||
if (openTagEnd !== -1) {
|
||||
const artifactTag = input.slice(i, openTagEnd + 1);
|
||||
|
||||
const artifactId = this.#extractAttribute(artifactTag, 'id') as string;
|
||||
const artifactName = this.#extractAttribute(artifactTag, 'name') as string;
|
||||
const artifactTitle = this.#extractAttribute(artifactTag, 'title') as string;
|
||||
|
||||
if (!artifactId || !artifactName) {
|
||||
logger.warn('Artifact id or name missing');
|
||||
}
|
||||
|
||||
if (!artifactTitle) {
|
||||
logger.warn('Artifact title missing');
|
||||
}
|
||||
|
||||
state.insideArtifact = true;
|
||||
|
||||
const currentArtifact = {
|
||||
id: artifactId,
|
||||
name: artifactName,
|
||||
title: artifactTitle,
|
||||
} satisfies UPageArtifactData;
|
||||
|
||||
state.currentArtifact = currentArtifact;
|
||||
|
||||
this._options.callbacks?.onArtifactOpen?.({ messageId, ...currentArtifact });
|
||||
|
||||
const artifactFactory = this._options.artifactElement ?? createArtifactElement;
|
||||
|
||||
output += artifactFactory({ messageId });
|
||||
|
||||
i = openTagEnd + 1;
|
||||
} else {
|
||||
earlyBreak = true;
|
||||
}
|
||||
|
||||
break;
|
||||
} else if (!ARTIFACT_TAG_OPEN.startsWith(potentialTag)) {
|
||||
output += input.slice(i, j + 1);
|
||||
i = j + 1;
|
||||
break;
|
||||
}
|
||||
|
||||
j++;
|
||||
}
|
||||
|
||||
if (j === input.length && ARTIFACT_TAG_OPEN.startsWith(potentialTag)) {
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
output += input[i];
|
||||
i++;
|
||||
}
|
||||
|
||||
if (earlyBreak) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
state.position = i;
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.#messages.clear();
|
||||
}
|
||||
|
||||
#parseActionTag(input: string, actionOpenIndex: number, actionEndIndex: number) {
|
||||
const actionTag = input.slice(actionOpenIndex, actionEndIndex + 1);
|
||||
|
||||
const actionAttributes: UPageAction = {
|
||||
id: '',
|
||||
pageName: '',
|
||||
action: 'add',
|
||||
domId: '',
|
||||
content: '',
|
||||
rootDomId: '',
|
||||
validRootDomId: false,
|
||||
};
|
||||
|
||||
const id = this.#extractAttribute(actionTag, 'id') as string;
|
||||
if (!id) {
|
||||
logger.warn('Page id not specified');
|
||||
throw new Error('Page id not specified');
|
||||
}
|
||||
|
||||
const pageName = this.#extractAttribute(actionTag, 'pageName') as string;
|
||||
if (!pageName) {
|
||||
logger.warn('Page Name not specified');
|
||||
}
|
||||
|
||||
const action = this.#extractAttribute(actionTag, 'action') as UPageAction['action'];
|
||||
if (!action) {
|
||||
logger.warn('Action not specified');
|
||||
}
|
||||
|
||||
if (!['add', 'remove', 'update'].includes(action)) {
|
||||
logger.warn(`Invalid action '${action}'`);
|
||||
throw new Error(`Invalid action: ${action}`);
|
||||
}
|
||||
|
||||
const domId = this.#extractAttribute(actionTag, 'domId') as string;
|
||||
if (!domId) {
|
||||
logger.warn('domId not specified');
|
||||
}
|
||||
|
||||
const rootDomId = this.#extractAttribute(actionTag, 'rootDomId') as string;
|
||||
if (!rootDomId) {
|
||||
logger.warn('rootDomId not specified');
|
||||
} else {
|
||||
actionAttributes.validRootDomId = true;
|
||||
}
|
||||
|
||||
const sort = this.#extractAttribute(actionTag, 'sort');
|
||||
|
||||
actionAttributes.id = id;
|
||||
actionAttributes.pageName = pageName;
|
||||
actionAttributes.action = action;
|
||||
actionAttributes.domId = domId;
|
||||
actionAttributes.rootDomId = rootDomId;
|
||||
actionAttributes.sort = sort ? parseInt(sort) : undefined;
|
||||
return actionAttributes;
|
||||
}
|
||||
|
||||
#extractAttribute(tag: string, attributeName: string): string | undefined {
|
||||
const match = tag.match(new RegExp(`${attributeName}="([^"]*)"`, 'i'));
|
||||
return match ? match[1] : undefined;
|
||||
}
|
||||
}
|
||||
|
||||
const createArtifactElement: ElementFactory = (props) => {
|
||||
const elementProps = [
|
||||
'class="__uPageArtifact__"',
|
||||
...Object.entries(props).map(([key, value]) => {
|
||||
return `data-${camelToDashCase(key)}=${JSON.stringify(value)}`;
|
||||
}),
|
||||
];
|
||||
|
||||
return `<div ${elementProps.join(' ')}></div>`;
|
||||
};
|
||||
|
||||
function camelToDashCase(input: string) {
|
||||
return input.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase();
|
||||
}
|
||||
66
app/lib/storage/base-provider.server.ts
Normal file
66
app/lib/storage/base-provider.server.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import type { StorageFile, StorageProvider, StorageUploadOptions } from './types';
|
||||
|
||||
/**
|
||||
* 存储提供者类
|
||||
* 提供通用的文件存储功能实现
|
||||
*/
|
||||
export abstract class BaseStorageProvider implements StorageProvider {
|
||||
/**
|
||||
* 上传文件
|
||||
* @param options 上传选项
|
||||
*/
|
||||
abstract uploadFile(options: StorageUploadOptions): Promise<StorageFile>;
|
||||
|
||||
/**
|
||||
* 获取文件
|
||||
* @param userId 用户ID
|
||||
* @param filename 文件名
|
||||
*/
|
||||
abstract getFile(userId: string, filename: string): Promise<StorageFile | null>;
|
||||
|
||||
/**
|
||||
* 删除文件
|
||||
* @param userId 用户ID
|
||||
* @param filename 文件名
|
||||
*/
|
||||
abstract deleteFile(userId: string, filename: string): Promise<boolean>;
|
||||
|
||||
/**
|
||||
* 检查文件是否存在
|
||||
* @param userId 用户ID
|
||||
* @param filename 文件名
|
||||
*/
|
||||
abstract fileExists(userId: string, filename: string): Promise<boolean>;
|
||||
|
||||
/**
|
||||
* 生成文件访问URL
|
||||
* @param userId 用户ID
|
||||
* @param filename 文件名
|
||||
* @returns 文件访问URL
|
||||
*/
|
||||
getFileUrl(userId: string, filename: string): string {
|
||||
return `/assets/${userId}/${filename}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成唯一文件名
|
||||
* @param originalFilename 原始文件名
|
||||
* @returns 唯一文件名
|
||||
*/
|
||||
protected generateUniqueFilename(originalFilename: string): string {
|
||||
const timestamp = Date.now();
|
||||
const randomStr = Math.random().toString(36).substring(2, 8);
|
||||
const extension = this.getFileExtension(originalFilename);
|
||||
return `${timestamp}-${randomStr}${extension}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取文件扩展名
|
||||
* @param filename 文件名
|
||||
* @returns 文件扩展名(包含点号)
|
||||
*/
|
||||
protected getFileExtension(filename: string): string {
|
||||
const lastDotIndex = filename.lastIndexOf('.');
|
||||
return lastDotIndex !== -1 ? filename.substring(lastDotIndex) : '';
|
||||
}
|
||||
}
|
||||
35
app/lib/storage/index.server.ts
Normal file
35
app/lib/storage/index.server.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { createScopedLogger } from '~/lib/.server/logger';
|
||||
import { LocalStorageProvider } from './local-provider.server';
|
||||
import type { StorageProvider } from './types';
|
||||
|
||||
const logger = createScopedLogger('storage');
|
||||
|
||||
/**
|
||||
* 获取存储目录配置
|
||||
* @returns 存储目录路径
|
||||
*/
|
||||
const getStorageDir = (): string | undefined => {
|
||||
// 如果在Docker环境中运行,使用环境变量中的配置
|
||||
if (process.env.RUNNING_IN_DOCKER === 'true' && process.env.STORAGE_DIR) {
|
||||
logger.debug('使用Docker环境中的存储目录', JSON.stringify({ dir: process.env.STORAGE_DIR }));
|
||||
return process.env.STORAGE_DIR;
|
||||
}
|
||||
|
||||
// 使用环境变量中的配置
|
||||
if (process.env.STORAGE_DIR) {
|
||||
logger.debug('使用环境变量中的存储目录', JSON.stringify({ dir: process.env.STORAGE_DIR }));
|
||||
return process.env.STORAGE_DIR;
|
||||
}
|
||||
|
||||
// 默认使用项目根目录下的 public/uploads 目录
|
||||
logger.debug('使用默认存储目录');
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const createStorageProvider = (): StorageProvider => {
|
||||
return new LocalStorageProvider(getStorageDir());
|
||||
};
|
||||
|
||||
export const storageProvider = createStorageProvider();
|
||||
|
||||
export * from './types';
|
||||
176
app/lib/storage/local-provider.server.ts
Normal file
176
app/lib/storage/local-provider.server.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { createScopedLogger } from '~/lib/.server/logger';
|
||||
import { BaseStorageProvider } from './base-provider.server';
|
||||
import type { StorageFile, StorageUploadOptions } from './types';
|
||||
|
||||
const logger = createScopedLogger('storage.local-provider');
|
||||
|
||||
/**
|
||||
* 本地文件存储提供者
|
||||
* 将文件存储在本地文件系统中
|
||||
*/
|
||||
export class LocalStorageProvider extends BaseStorageProvider {
|
||||
private baseDir: string;
|
||||
|
||||
constructor(baseDir?: string) {
|
||||
super();
|
||||
// 默认使用项目根目录下的 public/uploads 目录
|
||||
this.baseDir = baseDir || path.join(process.cwd(), 'public', 'uploads');
|
||||
this.ensureDirectoryExists(this.baseDir);
|
||||
logger.debug('本地存储初始化', JSON.stringify({ baseDir: this.baseDir }));
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传文件
|
||||
* @param options 上传选项
|
||||
* @returns 存储文件信息
|
||||
*/
|
||||
async uploadFile(options: StorageUploadOptions): Promise<StorageFile> {
|
||||
const { userId, contentType, filename, data } = options;
|
||||
|
||||
// 生成唯一文件名
|
||||
const uniqueFilename = this.generateUniqueFilename(filename);
|
||||
// 确保用户目录存在
|
||||
const userDir = path.join(this.baseDir, userId);
|
||||
this.ensureDirectoryExists(userDir);
|
||||
|
||||
const filePath = path.join(userDir, uniqueFilename);
|
||||
|
||||
try {
|
||||
if (typeof data === 'string') {
|
||||
// base64 数据
|
||||
if (data.startsWith('data:')) {
|
||||
const base64Data = data.split(',')[1];
|
||||
await fs.promises.writeFile(filePath, Buffer.from(base64Data, 'base64'));
|
||||
} else {
|
||||
await fs.promises.writeFile(filePath, data);
|
||||
}
|
||||
} else if (Buffer.isBuffer(data)) {
|
||||
await fs.promises.writeFile(filePath, data);
|
||||
} else {
|
||||
// 处理 Blob 类型
|
||||
const arrayBuffer = await data.arrayBuffer();
|
||||
await fs.promises.writeFile(filePath, Buffer.from(arrayBuffer));
|
||||
}
|
||||
|
||||
// 获取文件大小
|
||||
const stats = await fs.promises.stat(filePath);
|
||||
|
||||
logger.debug('文件上传成功', { userId, filename: uniqueFilename, size: stats.size });
|
||||
|
||||
return {
|
||||
filename: uniqueFilename,
|
||||
contentType,
|
||||
size: stats.size,
|
||||
path: filePath,
|
||||
metadata: options.metadata,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('文件上传失败', { userId, filename, error });
|
||||
throw new Error(`文件上传失败: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取文件
|
||||
* @param userId 用户ID
|
||||
* @param filename 文件名
|
||||
* @returns 存储文件信息,如果不存在则返回null
|
||||
*/
|
||||
async getFile(userId: string, filename: string): Promise<StorageFile | null> {
|
||||
const filePath = path.join(this.baseDir, userId, filename);
|
||||
|
||||
try {
|
||||
const stats = await fs.promises.stat(filePath);
|
||||
|
||||
if (!stats.isFile()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const contentType = this.getContentTypeFromFilename(filename);
|
||||
|
||||
return {
|
||||
filename,
|
||||
contentType,
|
||||
size: stats.size,
|
||||
path: filePath,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('获取文件失败', { userId, filename, error });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除文件
|
||||
* @param userId 用户ID
|
||||
* @param filename 文件名
|
||||
* @returns 是否删除成功
|
||||
*/
|
||||
async deleteFile(userId: string, filename: string): Promise<boolean> {
|
||||
const filePath = path.join(this.baseDir, userId, filename);
|
||||
|
||||
try {
|
||||
await fs.promises.unlink(filePath);
|
||||
logger.debug('文件删除成功', { userId, filename });
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error('删除文件失败', { userId, filename, error });
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查文件是否存在
|
||||
* @param userId 用户ID
|
||||
* @param filename 文件名
|
||||
* @returns 文件是否存在
|
||||
*/
|
||||
async fileExists(userId: string, filename: string): Promise<boolean> {
|
||||
const filePath = path.join(this.baseDir, userId, filename);
|
||||
|
||||
try {
|
||||
await fs.promises.access(filePath, fs.constants.F_OK);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 确保目录存在
|
||||
* @param dir 目录路径
|
||||
*/
|
||||
private ensureDirectoryExists(dir: string): void {
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据文件名获取内容类型
|
||||
* @param filename 文件名
|
||||
* @returns 内容类型
|
||||
*/
|
||||
private getContentTypeFromFilename(filename: string): string {
|
||||
const extension = this.getFileExtension(filename).toLowerCase();
|
||||
|
||||
// 常见文件类型映射
|
||||
const mimeTypes: Record<string, string> = {
|
||||
'.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.png': 'image/png',
|
||||
'.gif': 'image/gif',
|
||||
'.webp': 'image/webp',
|
||||
'.svg': 'image/svg+xml',
|
||||
'.mp4': 'video/mp4',
|
||||
'.webm': 'video/webm',
|
||||
'.mp3': 'audio/mpeg',
|
||||
'.wav': 'audio/wav',
|
||||
'.pdf': 'application/pdf',
|
||||
};
|
||||
|
||||
return mimeTypes[extension] || 'application/octet-stream';
|
||||
}
|
||||
}
|
||||
75
app/lib/storage/types.ts
Normal file
75
app/lib/storage/types.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* 存储文件信息接口
|
||||
*/
|
||||
export interface StorageFile {
|
||||
/** 文件名 */
|
||||
filename: string;
|
||||
/** 内容类型 */
|
||||
contentType: string;
|
||||
/** 文件大小(字节) */
|
||||
size: number;
|
||||
/** 文件路径 */
|
||||
path: string;
|
||||
/** 元数据 */
|
||||
metadata?: Record<string, any>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 存储上传选项接口
|
||||
*/
|
||||
export interface StorageUploadOptions {
|
||||
/** 用户ID */
|
||||
userId: string;
|
||||
/** 内容类型 */
|
||||
contentType: string;
|
||||
/** 文件名 */
|
||||
filename: string;
|
||||
/** 文件数据 */
|
||||
data: Buffer | Blob | string;
|
||||
/** 元数据 */
|
||||
metadata?: Record<string, any>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 存储提供者接口
|
||||
*/
|
||||
export interface StorageProvider {
|
||||
/**
|
||||
* 上传文件
|
||||
* @param options 上传选项
|
||||
* @returns 存储文件信息
|
||||
*/
|
||||
uploadFile(options: StorageUploadOptions): Promise<StorageFile>;
|
||||
|
||||
/**
|
||||
* 获取文件
|
||||
* @param userId 用户ID
|
||||
* @param filename 文件名
|
||||
* @returns 存储文件信息,如果不存在则返回null
|
||||
*/
|
||||
getFile(userId: string, filename: string): Promise<StorageFile | null>;
|
||||
|
||||
/**
|
||||
* 删除文件
|
||||
* @param userId 用户ID
|
||||
* @param filename 文件名
|
||||
* @returns 是否删除成功
|
||||
*/
|
||||
deleteFile(userId: string, filename: string): Promise<boolean>;
|
||||
|
||||
/**
|
||||
* 检查文件是否存在
|
||||
* @param userId 用户ID
|
||||
* @param filename 文件名
|
||||
* @returns 文件是否存在
|
||||
*/
|
||||
fileExists(userId: string, filename: string): Promise<boolean>;
|
||||
|
||||
/**
|
||||
* 生成文件访问URL
|
||||
* @param userId 用户ID
|
||||
* @param filename 文件名
|
||||
* @returns 文件访问URL
|
||||
*/
|
||||
getFileUrl(userId: string, filename: string): string;
|
||||
}
|
||||
80
app/lib/stores/1panel.ts
Normal file
80
app/lib/stores/1panel.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { atom } from 'nanostores';
|
||||
import { toast } from 'sonner';
|
||||
import type { _1PanelStats } from '~/types/1panel';
|
||||
|
||||
export interface _1PanelUser {
|
||||
projectName?: string;
|
||||
serverUrl?: string;
|
||||
}
|
||||
|
||||
interface _1PanelConnectionState {
|
||||
isConnect: boolean;
|
||||
stats?: _1PanelStats;
|
||||
serverUrl?: string;
|
||||
}
|
||||
|
||||
const storedConnection = typeof window !== 'undefined' ? localStorage.getItem('1panel_connection') : null;
|
||||
const initialConnection: _1PanelConnectionState = storedConnection
|
||||
? JSON.parse(storedConnection)
|
||||
: {
|
||||
isConnect: false,
|
||||
serverUrl: '',
|
||||
};
|
||||
|
||||
export const _1PanelConnectionStore = atom<_1PanelConnectionState>(initialConnection);
|
||||
|
||||
export const isConnecting = atom<boolean>(initialConnection.isConnect);
|
||||
export const isFetchingStats = atom<boolean>(false);
|
||||
|
||||
export const update1PanelConnection = (updates: Partial<_1PanelConnectionState>) => {
|
||||
if (updates.serverUrl) {
|
||||
updates.serverUrl = updates.serverUrl.replace(/\/$/, '');
|
||||
}
|
||||
const currentState = _1PanelConnectionStore.get();
|
||||
const newState = { ...currentState, ...updates };
|
||||
_1PanelConnectionStore.set(newState);
|
||||
|
||||
// Persist to localStorage
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem('1panel_connection', JSON.stringify(newState));
|
||||
}
|
||||
};
|
||||
|
||||
export function reset1PanelConfig() {
|
||||
update1PanelConnection({ isConnect: false, serverUrl: '' });
|
||||
}
|
||||
|
||||
export async function fetch1PanelStats() {
|
||||
try {
|
||||
isFetchingStats.set(true);
|
||||
|
||||
const response = await fetch('/api/1panel/stats', {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
const { data, success, message } = await response.json();
|
||||
|
||||
if (!response.ok || !success) {
|
||||
throw new Error(`获取站点数据失败: ${message || response.status}`);
|
||||
}
|
||||
|
||||
const websites = data.websites ?? [];
|
||||
const currentState = _1PanelConnectionStore.get();
|
||||
|
||||
update1PanelConnection({
|
||||
...currentState,
|
||||
isConnect: true,
|
||||
stats: data,
|
||||
});
|
||||
|
||||
return websites;
|
||||
} catch (error) {
|
||||
console.error('1Panel API Error:', error);
|
||||
toast.error(`获取 1Panel 站点信息失败: ${error instanceof Error ? error.message : '未知错误'}`);
|
||||
} finally {
|
||||
isFetchingStats.set(false);
|
||||
}
|
||||
}
|
||||
135
app/lib/stores/ai-state.ts
Normal file
135
app/lib/stores/ai-state.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import { map } from 'nanostores';
|
||||
import type { UPageUIMessage } from '~/types/message';
|
||||
|
||||
export type ParsedUIMessage = UPageUIMessage & {
|
||||
content?: string;
|
||||
};
|
||||
|
||||
export type UIState = {
|
||||
// 是否显示聊天
|
||||
showChat: boolean;
|
||||
};
|
||||
|
||||
export type AiState = {
|
||||
// 聊天是否已经开始
|
||||
chatStarted: boolean;
|
||||
// 是否正在流式传输
|
||||
isStreaming: boolean;
|
||||
// 是否已经初始化
|
||||
isInitialized: boolean;
|
||||
// 是否中止聊天
|
||||
aborted: boolean;
|
||||
// 当前的聊天 ID
|
||||
chatId: string | undefined;
|
||||
// 当前的消息 ID
|
||||
messageId: string | undefined;
|
||||
// 当前聊天的消息列表,包含解析后的消息内容,仅用于前端渲染
|
||||
parseMessages: ParsedUIMessage[];
|
||||
};
|
||||
|
||||
/**
|
||||
* AI 状态管理存储
|
||||
* 用于跟踪 AI 相关的状态信息,包括:
|
||||
* - isStreaming: 是否正在生成内容
|
||||
* - chatId: 当前的聊天 ID
|
||||
* - messageId: 当前的消息 ID
|
||||
* - chatMessages: 当前聊天的消息列表,包含解析后的消息内容,仅用于前端渲染
|
||||
*/
|
||||
export const aiState = map<AiState & UIState>({
|
||||
chatStarted: false,
|
||||
isStreaming: false,
|
||||
chatId: undefined,
|
||||
messageId: undefined,
|
||||
isInitialized: false,
|
||||
parseMessages: [],
|
||||
aborted: false,
|
||||
showChat: true,
|
||||
});
|
||||
|
||||
/**
|
||||
* 更新聊天消息列表
|
||||
* @param messages 原始消息列表
|
||||
* @param parsedMessages 解析后的消息内容映射
|
||||
*/
|
||||
export function updateParseMessages(messages: UPageUIMessage[], parsedMessages: { [key: number]: string }) {
|
||||
const updatedMessages = messages.map((message, i) => {
|
||||
if (message.role === 'user') {
|
||||
return message;
|
||||
}
|
||||
|
||||
return {
|
||||
...message,
|
||||
content: parsedMessages[i] || '',
|
||||
};
|
||||
});
|
||||
|
||||
aiState.setKey('parseMessages', updatedMessages);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前的聊天消息列表
|
||||
* @returns 当前的聊天消息列表
|
||||
*/
|
||||
export function getParseMessages(): ParsedUIMessage[] {
|
||||
return aiState.get().parseMessages;
|
||||
}
|
||||
|
||||
export function setChatStarted(chatStarted: boolean) {
|
||||
aiState.setKey('chatStarted', chatStarted);
|
||||
}
|
||||
|
||||
export function getChatStarted(): boolean {
|
||||
return aiState.get().chatStarted;
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新 AI 的流式状态
|
||||
* @param streaming 是否正在流式传输
|
||||
*/
|
||||
export function setStreamingState(streaming: boolean) {
|
||||
aiState.setKey('isStreaming', streaming);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前 AI 的流式状态
|
||||
* @returns 是否正在流式传输
|
||||
*/
|
||||
export function getStreamingState(): boolean {
|
||||
return aiState.get().isStreaming;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置当前的聊天 ID
|
||||
* @param id 聊天 ID
|
||||
*/
|
||||
export function setChatId(id: string | undefined) {
|
||||
aiState.setKey('chatId', id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前的聊天 ID
|
||||
* @returns 当前的聊天 ID
|
||||
*/
|
||||
export function getChatId(): string | undefined {
|
||||
return aiState.get().chatId;
|
||||
}
|
||||
|
||||
export function getMessageId(): string | undefined {
|
||||
return aiState.get().messageId;
|
||||
}
|
||||
|
||||
export function setShowChat(showChat: boolean) {
|
||||
aiState.setKey('showChat', showChat);
|
||||
}
|
||||
|
||||
export function getShowChat(): boolean {
|
||||
return aiState.get().showChat;
|
||||
}
|
||||
|
||||
export function setAborted(aborted: boolean) {
|
||||
aiState.setKey('aborted', aborted);
|
||||
}
|
||||
|
||||
export function getAborted(): boolean {
|
||||
return aiState.get().aborted;
|
||||
}
|
||||
16
app/lib/stores/chat-message.ts
Normal file
16
app/lib/stores/chat-message.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { atom } from 'nanostores';
|
||||
import type { UPageMessageMetadata } from '~/types/message';
|
||||
|
||||
export type SendChatMessageParams = {
|
||||
messageContent: string;
|
||||
files: File[];
|
||||
metadata?: UPageMessageMetadata;
|
||||
};
|
||||
|
||||
export type SendChatMessageFunction = (params: SendChatMessageParams) => Promise<void>;
|
||||
|
||||
export const sendChatMessageStore = atom<SendChatMessageFunction | null>(null);
|
||||
|
||||
export function setSendChatMessage(fn: SendChatMessageFunction) {
|
||||
sendChatMessageStore.set(fn);
|
||||
}
|
||||
211
app/lib/stores/chat.ts
Normal file
211
app/lib/stores/chat.ts
Normal file
@@ -0,0 +1,211 @@
|
||||
import { atom, type MapStore, map, type WritableAtom } from 'nanostores';
|
||||
import type { ActionAlert } from '~/types/actions';
|
||||
import { createSampler } from '~/utils/sampler';
|
||||
import { unreachable } from '~/utils/unreachable';
|
||||
import { editorBridge } from '../bridge';
|
||||
import { ActionRunner } from '../runtime/action-runner';
|
||||
import type { ActionCallbackData, ArtifactCallbackData } from '../runtime/message-parser';
|
||||
import type { PagesStore } from './pages';
|
||||
import type { WebBuilderStore } from './web-builder';
|
||||
|
||||
export interface ArtifactState {
|
||||
id: string;
|
||||
name: string;
|
||||
title: string;
|
||||
type?: string;
|
||||
closed: boolean;
|
||||
runner: ActionRunner;
|
||||
}
|
||||
|
||||
export type ArtifactUpdateState = Pick<ArtifactState, 'title' | 'closed'>;
|
||||
|
||||
type Artifacts = MapStore<Record<string, ArtifactState>>;
|
||||
|
||||
export class ChatStore {
|
||||
private globalExecutionQueue = Promise.resolve();
|
||||
private reloadedMessages = new Set<string>();
|
||||
|
||||
// 当前消息 id
|
||||
currentMessageId: WritableAtom<string | undefined> =
|
||||
import.meta.hot?.data.currentMessageId ?? atom<string | undefined>(undefined);
|
||||
currentDescription: WritableAtom<string | undefined> =
|
||||
import.meta.hot?.data.currentDescription ?? atom<string | undefined>(undefined);
|
||||
|
||||
artifacts: Artifacts = import.meta.hot?.data.artifacts ?? map({});
|
||||
artifactIdList: string[] = [];
|
||||
actionAlert: WritableAtom<ActionAlert | undefined> =
|
||||
import.meta.hot?.data.actionAlert ?? atom<ActionAlert | undefined>(undefined);
|
||||
|
||||
// 添加对webBuilderStore和pagesStore的引用
|
||||
readonly webBuilderStore: WebBuilderStore;
|
||||
readonly pagesStore: PagesStore;
|
||||
|
||||
constructor(webBuilderStore: WebBuilderStore, pagesStore: PagesStore) {
|
||||
this.webBuilderStore = webBuilderStore;
|
||||
this.pagesStore = pagesStore;
|
||||
|
||||
if (import.meta.hot) {
|
||||
import.meta.hot.data.artifacts = this.artifacts;
|
||||
import.meta.hot.data.actionAlert = this.actionAlert;
|
||||
import.meta.hot.data.currentDescription = this.currentDescription;
|
||||
}
|
||||
|
||||
this.setupCoordination();
|
||||
}
|
||||
|
||||
private setupCoordination() {
|
||||
this.artifacts.listen(() => {
|
||||
this.currentDescription.set(this.firstArtifact?.title || '未命名页面');
|
||||
});
|
||||
}
|
||||
|
||||
get firstArtifact(): ArtifactState | undefined {
|
||||
return this.getArtifact(this.artifactIdList[0]);
|
||||
}
|
||||
|
||||
get description() {
|
||||
return this.currentDescription;
|
||||
}
|
||||
|
||||
get alert() {
|
||||
return this.actionAlert;
|
||||
}
|
||||
|
||||
clearAlert() {
|
||||
this.actionAlert.set(undefined);
|
||||
}
|
||||
|
||||
abortAllActions() {
|
||||
// TODO: what do we wanna do and how do we wanna recover from this?
|
||||
const artifacts = this.artifacts.get();
|
||||
|
||||
Object.values(artifacts).forEach((artifact) => {
|
||||
const actions = artifact.runner.actions.get();
|
||||
|
||||
Object.values(actions).forEach((action) => {
|
||||
if (action.status === 'running' || action.status === 'pending') {
|
||||
action.abort();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
addArtifact({ messageId, name, title, id }: ArtifactCallbackData) {
|
||||
const artifact = this.getArtifact(messageId);
|
||||
if (artifact) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.artifactIdList.includes(messageId)) {
|
||||
this.artifactIdList.push(messageId);
|
||||
}
|
||||
|
||||
this.artifacts.setKey(messageId, {
|
||||
id,
|
||||
name,
|
||||
title,
|
||||
closed: false,
|
||||
runner: new ActionRunner(editorBridge, { id, name, title }, (alert) => {
|
||||
if (this.reloadedMessages.has(messageId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.actionAlert.set(alert);
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
updateArtifact({ messageId }: ArtifactCallbackData, state: Partial<ArtifactUpdateState>) {
|
||||
const artifact = this.getArtifact(messageId);
|
||||
if (!artifact) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.artifacts.setKey(messageId, { ...artifact, ...state });
|
||||
}
|
||||
|
||||
private getArtifact(id: string) {
|
||||
const artifacts = this.artifacts.get();
|
||||
return artifacts[id];
|
||||
}
|
||||
|
||||
setReloadedMessages(messages: string[]) {
|
||||
this.reloadedMessages = new Set(messages);
|
||||
}
|
||||
|
||||
addAction(data: ActionCallbackData) {
|
||||
this._addAction(data);
|
||||
}
|
||||
|
||||
private async _addAction(data: ActionCallbackData) {
|
||||
const { messageId } = data;
|
||||
const artifact = this.getArtifact(messageId);
|
||||
|
||||
if (!artifact) {
|
||||
unreachable('Artifact not found');
|
||||
}
|
||||
|
||||
return artifact.runner.addAction(data);
|
||||
}
|
||||
|
||||
runAction(data: ActionCallbackData, isStreaming: boolean = false) {
|
||||
if (isStreaming) {
|
||||
this.actionStreamSampler(data, isStreaming);
|
||||
} else {
|
||||
this.addToExecutionQueue(() => this._runAction(data, isStreaming));
|
||||
}
|
||||
}
|
||||
|
||||
async _runAction(data: ActionCallbackData, isRunning: boolean = false) {
|
||||
const { messageId } = data;
|
||||
|
||||
const artifact = this.getArtifact(messageId);
|
||||
if (!artifact) {
|
||||
unreachable('Artifact not found');
|
||||
}
|
||||
|
||||
const action = artifact.runner.actions.get()[data.actionId];
|
||||
if (!action || action.executed) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { pageName, id } = data.action;
|
||||
|
||||
if (this.pagesStore.activeSection.get() !== id) {
|
||||
this.pagesStore.setActiveSection(id);
|
||||
}
|
||||
|
||||
if (this.pagesStore.activePage.get() !== pageName) {
|
||||
this.pagesStore.setActivePage(pageName);
|
||||
}
|
||||
|
||||
if (this.webBuilderStore.currentView.get() !== 'code') {
|
||||
this.webBuilderStore.currentView.set('code');
|
||||
}
|
||||
|
||||
const actionId = data.action.id;
|
||||
const section = this.pagesStore.sections.get()[actionId];
|
||||
if (!section) {
|
||||
await artifact.runner.runAction(data, isRunning);
|
||||
}
|
||||
|
||||
this.pagesStore.updateSection(actionId, data.action.content);
|
||||
|
||||
if (!isRunning) {
|
||||
await artifact.runner.runAction(data);
|
||||
this.pagesStore.resetPageModifications();
|
||||
}
|
||||
}
|
||||
|
||||
actionStreamSampler = createSampler(async (data: ActionCallbackData, isStreaming: boolean = false) => {
|
||||
return await this._runAction(data, isStreaming);
|
||||
}, 100); // TODO: remove this magic number to have it configurable
|
||||
|
||||
addToExecutionQueue(callback: () => Promise<void>) {
|
||||
this.globalExecutionQueue = this.globalExecutionQueue.then(() => callback());
|
||||
}
|
||||
|
||||
setCurrentMessageId(id: string | undefined) {
|
||||
this.currentMessageId.set(id);
|
||||
}
|
||||
}
|
||||
194
app/lib/stores/editor.ts
Normal file
194
app/lib/stores/editor.ts
Normal file
@@ -0,0 +1,194 @@
|
||||
import { atom, computed, type MapStore, map, type WritableAtom } from 'nanostores';
|
||||
import type { Section } from '~/types/actions';
|
||||
import type { DocumentProperties, Editor } from '~/types/editor';
|
||||
import type { PageMap, PagesStore } from './pages';
|
||||
|
||||
/**
|
||||
* 编辑器文档,结构为 <pageName, pageProperties>
|
||||
*/
|
||||
export type EditorDocuments = Record<string, DocumentProperties>;
|
||||
|
||||
type SelectedDocument = WritableAtom<string | undefined>;
|
||||
|
||||
export type EditorSection = Record<string, Section>;
|
||||
|
||||
// 编辑器命令类型
|
||||
export type EditorCommandType = 'scrollToElement' | 'exportToZip';
|
||||
|
||||
// 编辑器命令接口
|
||||
export interface EditorCommand {
|
||||
type: EditorCommandType;
|
||||
payload: any;
|
||||
}
|
||||
|
||||
// 创建一个用于发送编辑器命令的atom
|
||||
export const editorCommands = atom<EditorCommand | null>(null);
|
||||
|
||||
/**
|
||||
* 与 Editor 进行对接的 store。
|
||||
* 其内部保存的数据可以直接由 editor 使用与操作,并且当前数据与 editor 实时同步。
|
||||
*/
|
||||
export class EditorStore {
|
||||
private readonly pagesStore: PagesStore;
|
||||
|
||||
editorInstance: WritableAtom<Editor | null> = import.meta.hot?.data.editorInstance ?? atom<Editor | null>(null);
|
||||
// 编辑器中当前选中的文档。
|
||||
selectedDocument: SelectedDocument = import.meta.hot?.data.selectedPage ?? atom<string | undefined>();
|
||||
// 编辑器文档数据,始终是与编辑器所保持的最新数据,但此数据不一定执行了保存。
|
||||
editorDocuments: MapStore<EditorDocuments> = import.meta.hot?.data.documents ?? map({});
|
||||
// 当前编辑器文档,基于 editorDocuments 和 selectedDocument 计算而来。始终是与编辑器所保持的最新数据,但此数据不一定执行了保存。
|
||||
currentDocument = computed([this.editorDocuments, this.selectedDocument], (documents, selectedDocument) => {
|
||||
if (!selectedDocument) {
|
||||
return undefined;
|
||||
}
|
||||
return documents[selectedDocument];
|
||||
});
|
||||
// 当前编辑器未保存的页面
|
||||
unsavedDocuments: WritableAtom<Set<string>> = import.meta.hot?.data.unsavedDocuments ?? atom(new Set<string>());
|
||||
// 编辑器文档最后保存时间
|
||||
documentLastSaved: WritableAtom<Record<string, number>> =
|
||||
import.meta.hot?.data.documentLastSaved ?? atom<Record<string, number>>({});
|
||||
|
||||
constructor(pagesStore: PagesStore) {
|
||||
this.pagesStore = pagesStore;
|
||||
|
||||
if (import.meta.hot) {
|
||||
import.meta.hot.data.unsavedDocuments = this.unsavedDocuments;
|
||||
import.meta.hot.data.selectedDocument = this.selectedDocument;
|
||||
import.meta.hot.data.editorDocuments = this.editorDocuments;
|
||||
import.meta.hot.data.documentLastSaved = this.documentLastSaved;
|
||||
}
|
||||
|
||||
this.setupCoordination();
|
||||
}
|
||||
|
||||
private setupCoordination() {
|
||||
// 监听 pagesStore 的 pages 变化
|
||||
this.pagesStore.pages.listen((pages) => {
|
||||
this.setDocuments(pages);
|
||||
});
|
||||
|
||||
// 监听 pagesStore 的 activePage 变化
|
||||
this.pagesStore.activePage.listen((pageName) => {
|
||||
this.selectedDocument.set(pageName);
|
||||
});
|
||||
}
|
||||
|
||||
setEditorInstance(editor: Editor) {
|
||||
this.editorInstance.set(editor);
|
||||
}
|
||||
|
||||
getEditorInstance() {
|
||||
return this.editorInstance.get();
|
||||
}
|
||||
|
||||
setDocuments(pages: PageMap, updateContent: boolean = false) {
|
||||
const documents = this.editorDocuments.get();
|
||||
this.editorDocuments.set(
|
||||
Object.fromEntries<DocumentProperties>(
|
||||
Object.entries(pages)
|
||||
.map(([pageName, page]) => {
|
||||
if (page === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const oldDocument = documents[pageName];
|
||||
if (oldDocument && !updateContent) {
|
||||
return [pageName, { ...oldDocument, name: pageName, title: page.title }];
|
||||
}
|
||||
|
||||
return [
|
||||
pageName,
|
||||
{
|
||||
name: pageName,
|
||||
title: page.title,
|
||||
content: page.content,
|
||||
},
|
||||
] as [string, DocumentProperties];
|
||||
})
|
||||
.filter(Boolean) as Array<[string, DocumentProperties]>,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
updatePageState(pageName: string, page: Omit<DocumentProperties, 'content'>) {
|
||||
const documents = this.editorDocuments.get();
|
||||
const oldDocumentState = documents[pageName];
|
||||
if (!oldDocumentState) {
|
||||
return;
|
||||
}
|
||||
|
||||
const content = oldDocumentState.content;
|
||||
this.editorDocuments.setKey(pageName, { ...oldDocumentState, ...page, content });
|
||||
}
|
||||
|
||||
updateDocumentContent(pageName: string, newContent: string) {
|
||||
const documents = this.editorDocuments.get();
|
||||
const oldDocumentState = documents[pageName];
|
||||
|
||||
if (!oldDocumentState) {
|
||||
return;
|
||||
}
|
||||
|
||||
const oldContent = oldDocumentState.content;
|
||||
const contentChanged = oldContent !== newContent;
|
||||
if (contentChanged) {
|
||||
this.editorDocuments.setKey(pageName, {
|
||||
...oldDocumentState,
|
||||
content: newContent,
|
||||
});
|
||||
}
|
||||
this.updateUnsavedDocuments(pageName, newContent);
|
||||
}
|
||||
|
||||
private updateUnsavedDocuments(pageName: string, newContent: string) {
|
||||
const savedContent = this.pagesStore.getPage(pageName)?.content;
|
||||
// 是否存在未保存的更改
|
||||
const unsavedChanges = savedContent === undefined || savedContent !== newContent;
|
||||
const currentDocument = this.currentDocument.get();
|
||||
if (!currentDocument) {
|
||||
return;
|
||||
}
|
||||
// 保存数据至未保存中
|
||||
const previousUnsavedPages = this.unsavedDocuments.get();
|
||||
// 如果已经将此页面标记为未保存,则不进行更新。
|
||||
if (unsavedChanges && previousUnsavedPages.has(pageName)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newUnsavedPages = new Set(previousUnsavedPages);
|
||||
|
||||
// 如果存在未保存的更改,则将此页面标记为未保存。否则,将此页面从未保存中移除。
|
||||
if (unsavedChanges) {
|
||||
newUnsavedPages.add(pageName);
|
||||
} else {
|
||||
newUnsavedPages.delete(pageName);
|
||||
}
|
||||
|
||||
this.unsavedDocuments.set(newUnsavedPages);
|
||||
}
|
||||
|
||||
removeUnsavedDocument(pageName: string, saved: boolean = false) {
|
||||
const newUnsavedPages = new Set(this.unsavedDocuments.get());
|
||||
newUnsavedPages.delete(pageName);
|
||||
this.unsavedDocuments.set(newUnsavedPages);
|
||||
|
||||
if (!saved) {
|
||||
return;
|
||||
}
|
||||
// 记录保存时间
|
||||
const currentTime = Date.now();
|
||||
const lastSavedTimes = this.documentLastSaved.get();
|
||||
this.documentLastSaved.set({
|
||||
...lastSavedTimes,
|
||||
[pageName]: currentTime,
|
||||
});
|
||||
}
|
||||
|
||||
scrollToElement(domId: string) {
|
||||
editorCommands.set({
|
||||
type: 'scrollToElement',
|
||||
payload: { domId },
|
||||
});
|
||||
}
|
||||
}
|
||||
519
app/lib/stores/logs.ts
Normal file
519
app/lib/stores/logs.ts
Normal file
@@ -0,0 +1,519 @@
|
||||
import Cookies from 'js-cookie';
|
||||
import { atom, map } from 'nanostores';
|
||||
import { createScopedLogger } from '~/utils/logger';
|
||||
|
||||
const logger = createScopedLogger('LogStore');
|
||||
|
||||
export interface LogEntry {
|
||||
id: string;
|
||||
timestamp: string;
|
||||
level: 'info' | 'warning' | 'error' | 'debug';
|
||||
message: string;
|
||||
details?: Record<string, any>;
|
||||
category:
|
||||
| 'system'
|
||||
| 'provider'
|
||||
| 'user'
|
||||
| 'error'
|
||||
| 'api'
|
||||
| 'auth'
|
||||
| 'database'
|
||||
| 'network'
|
||||
| 'performance'
|
||||
| 'settings'
|
||||
| 'task'
|
||||
| 'update'
|
||||
| 'feature';
|
||||
subCategory?: string;
|
||||
duration?: number;
|
||||
statusCode?: number;
|
||||
source?: string;
|
||||
stack?: string;
|
||||
metadata?: {
|
||||
component?: string;
|
||||
action?: string;
|
||||
userId?: string;
|
||||
sessionId?: string;
|
||||
previousValue?: any;
|
||||
newValue?: any;
|
||||
};
|
||||
}
|
||||
|
||||
interface LogDetails extends Record<string, any> {
|
||||
type: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
const MAX_LOGS = 1000; // Maximum number of logs to keep in memory
|
||||
|
||||
class LogStore {
|
||||
private _logs = map<Record<string, LogEntry>>({});
|
||||
showLogs = atom(true);
|
||||
private _readLogs = new Set<string>();
|
||||
|
||||
constructor() {
|
||||
// Load saved logs from cookies on initialization
|
||||
this._loadLogs();
|
||||
|
||||
// Only load read logs in browser environment
|
||||
if (typeof window !== 'undefined') {
|
||||
this._loadReadLogs();
|
||||
}
|
||||
}
|
||||
|
||||
// Expose the logs store for subscription
|
||||
get logs() {
|
||||
return this._logs;
|
||||
}
|
||||
|
||||
private _loadLogs() {
|
||||
const savedLogs = Cookies.get('eventLogs');
|
||||
|
||||
if (savedLogs) {
|
||||
try {
|
||||
const parsedLogs = JSON.parse(savedLogs);
|
||||
this._logs.set(parsedLogs);
|
||||
} catch (error) {
|
||||
logger.error('Failed to parse logs from cookies:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private _loadReadLogs() {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
const savedReadLogs = localStorage.getItem('upage_read_logs');
|
||||
|
||||
if (savedReadLogs) {
|
||||
try {
|
||||
const parsedReadLogs = JSON.parse(savedReadLogs);
|
||||
this._readLogs = new Set(parsedReadLogs);
|
||||
} catch (error) {
|
||||
logger.error('Failed to parse read logs:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private _saveLogs() {
|
||||
const currentLogs = this._logs.get();
|
||||
Cookies.set('eventLogs', JSON.stringify(currentLogs));
|
||||
}
|
||||
|
||||
private _saveReadLogs() {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
localStorage.setItem('upage_read_logs', JSON.stringify(Array.from(this._readLogs)));
|
||||
}
|
||||
|
||||
private _generateId(): string {
|
||||
return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
}
|
||||
|
||||
private _trimLogs() {
|
||||
const currentLogs = Object.entries(this._logs.get());
|
||||
|
||||
if (currentLogs.length > MAX_LOGS) {
|
||||
const sortedLogs = currentLogs.sort(
|
||||
([, a], [, b]) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime(),
|
||||
);
|
||||
const newLogs = Object.fromEntries(sortedLogs.slice(0, MAX_LOGS));
|
||||
this._logs.set(newLogs);
|
||||
}
|
||||
}
|
||||
|
||||
// Base log method for general logging
|
||||
private _addLog(
|
||||
message: string,
|
||||
level: LogEntry['level'],
|
||||
category: LogEntry['category'],
|
||||
details?: Record<string, any>,
|
||||
metadata?: LogEntry['metadata'],
|
||||
) {
|
||||
const id = this._generateId();
|
||||
const entry: LogEntry = {
|
||||
id,
|
||||
timestamp: new Date().toISOString(),
|
||||
level,
|
||||
message,
|
||||
details,
|
||||
category,
|
||||
metadata,
|
||||
};
|
||||
|
||||
this._logs.setKey(id, entry);
|
||||
this._trimLogs();
|
||||
this._saveLogs();
|
||||
|
||||
return id;
|
||||
}
|
||||
|
||||
// Specialized method for API logging
|
||||
private _addApiLog(
|
||||
message: string,
|
||||
method: string,
|
||||
_url: string,
|
||||
details: {
|
||||
method: string;
|
||||
url: string;
|
||||
statusCode: number;
|
||||
duration: number;
|
||||
request: any;
|
||||
response: any;
|
||||
},
|
||||
) {
|
||||
const statusCode = details.statusCode;
|
||||
return this._addLog(message, statusCode >= 400 ? 'error' : 'info', 'api', details, {
|
||||
component: 'api',
|
||||
action: method,
|
||||
});
|
||||
}
|
||||
|
||||
// System events
|
||||
logSystem(message: string, details?: Record<string, any>) {
|
||||
return this._addLog(message, 'info', 'system', details);
|
||||
}
|
||||
|
||||
// Provider events
|
||||
logProvider(message: string, details?: Record<string, any>) {
|
||||
return this._addLog(message, 'info', 'provider', details);
|
||||
}
|
||||
|
||||
// User actions
|
||||
logUserAction(message: string, details?: Record<string, any>) {
|
||||
return this._addLog(message, 'info', 'user', details);
|
||||
}
|
||||
|
||||
// API Connection Logging
|
||||
logAPIRequest(endpoint: string, method: string, duration: number, statusCode: number, details?: Record<string, any>) {
|
||||
const message = `${method} ${endpoint} - ${statusCode} (${duration}ms)`;
|
||||
const level = statusCode >= 400 ? 'error' : statusCode >= 300 ? 'warning' : 'info';
|
||||
|
||||
return this._addLog(message, level, 'api', {
|
||||
...details,
|
||||
endpoint,
|
||||
method,
|
||||
duration,
|
||||
statusCode,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
// Authentication Logging
|
||||
logAuth(
|
||||
action: 'login' | 'logout' | 'token_refresh' | 'key_validation',
|
||||
success: boolean,
|
||||
details?: Record<string, any>,
|
||||
) {
|
||||
const message = `Auth ${action} - ${success ? 'Success' : 'Failed'}`;
|
||||
const level = success ? 'info' : 'error';
|
||||
|
||||
return this._addLog(message, level, 'auth', {
|
||||
...details,
|
||||
action,
|
||||
success,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
// Network Status Logging
|
||||
logNetworkStatus(status: 'online' | 'offline' | 'reconnecting' | 'connected', details?: Record<string, any>) {
|
||||
const message = `Network ${status}`;
|
||||
const level = status === 'offline' ? 'error' : status === 'reconnecting' ? 'warning' : 'info';
|
||||
|
||||
return this._addLog(message, level, 'network', {
|
||||
...details,
|
||||
status,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
// Database Operations Logging
|
||||
logDatabase(operation: string, success: boolean, duration: number, details?: Record<string, any>) {
|
||||
const message = `DB ${operation} - ${success ? 'Success' : 'Failed'} (${duration}ms)`;
|
||||
const level = success ? 'info' : 'error';
|
||||
|
||||
return this._addLog(message, level, 'database', {
|
||||
...details,
|
||||
operation,
|
||||
success,
|
||||
duration,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
// Error events
|
||||
logError(message: string, error?: Error | unknown, details?: Record<string, any>) {
|
||||
const errorDetails =
|
||||
error instanceof Error
|
||||
? {
|
||||
name: error.name,
|
||||
message: error.message,
|
||||
stack: error.stack,
|
||||
...details,
|
||||
}
|
||||
: { error, ...details };
|
||||
|
||||
return this._addLog(message, 'error', 'error', errorDetails);
|
||||
}
|
||||
|
||||
// Warning events
|
||||
logWarning(message: string, details?: Record<string, any>) {
|
||||
return this._addLog(message, 'warning', 'system', details);
|
||||
}
|
||||
|
||||
// Debug events
|
||||
logDebug(message: string, details?: Record<string, any>) {
|
||||
return this._addLog(message, 'debug', 'system', details);
|
||||
}
|
||||
|
||||
clearLogs() {
|
||||
this._logs.set({});
|
||||
this._saveLogs();
|
||||
}
|
||||
|
||||
getLogs() {
|
||||
return Object.values(this._logs.get()).sort(
|
||||
(a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime(),
|
||||
);
|
||||
}
|
||||
|
||||
getFilteredLogs(level?: LogEntry['level'], category?: LogEntry['category'], searchQuery?: string) {
|
||||
return this.getLogs().filter((log) => {
|
||||
const matchesLevel = !level || level === 'debug' || log.level === level;
|
||||
const matchesCategory = !category || log.category === category;
|
||||
const matchesSearch =
|
||||
!searchQuery ||
|
||||
log.message.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
JSON.stringify(log.details).toLowerCase().includes(searchQuery.toLowerCase());
|
||||
|
||||
return matchesLevel && matchesCategory && matchesSearch;
|
||||
});
|
||||
}
|
||||
|
||||
markAsRead(logId: string) {
|
||||
this._readLogs.add(logId);
|
||||
this._saveReadLogs();
|
||||
}
|
||||
|
||||
isRead(logId: string): boolean {
|
||||
return this._readLogs.has(logId);
|
||||
}
|
||||
|
||||
clearReadLogs() {
|
||||
this._readLogs.clear();
|
||||
this._saveReadLogs();
|
||||
}
|
||||
|
||||
// API interactions
|
||||
logApiCall(
|
||||
method: string,
|
||||
endpoint: string,
|
||||
statusCode: number,
|
||||
duration: number,
|
||||
requestData?: any,
|
||||
responseData?: any,
|
||||
) {
|
||||
return this._addLog(
|
||||
`API ${method} ${endpoint}`,
|
||||
statusCode >= 400 ? 'error' : 'info',
|
||||
'api',
|
||||
{
|
||||
method,
|
||||
endpoint,
|
||||
statusCode,
|
||||
duration,
|
||||
request: requestData,
|
||||
response: responseData,
|
||||
},
|
||||
{
|
||||
component: 'api',
|
||||
action: method,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Network operations
|
||||
logNetworkRequest(
|
||||
method: string,
|
||||
url: string,
|
||||
statusCode: number,
|
||||
duration: number,
|
||||
requestData?: any,
|
||||
responseData?: any,
|
||||
) {
|
||||
return this._addLog(
|
||||
`${method} ${url}`,
|
||||
statusCode >= 400 ? 'error' : 'info',
|
||||
'network',
|
||||
{
|
||||
method,
|
||||
url,
|
||||
statusCode,
|
||||
duration,
|
||||
request: requestData,
|
||||
response: responseData,
|
||||
},
|
||||
{
|
||||
component: 'network',
|
||||
action: method,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Authentication events
|
||||
logAuthEvent(event: string, success: boolean, details?: Record<string, any>) {
|
||||
return this._addLog(
|
||||
`Auth ${event} ${success ? 'succeeded' : 'failed'}`,
|
||||
success ? 'info' : 'error',
|
||||
'auth',
|
||||
details,
|
||||
{
|
||||
component: 'auth',
|
||||
action: event,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Performance tracking
|
||||
logPerformance(operation: string, duration: number, details?: Record<string, any>) {
|
||||
return this._addLog(
|
||||
`Performance: ${operation}`,
|
||||
duration > 1000 ? 'warning' : 'info',
|
||||
'performance',
|
||||
{
|
||||
operation,
|
||||
duration,
|
||||
...details,
|
||||
},
|
||||
{
|
||||
component: 'performance',
|
||||
action: 'metric',
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Error handling
|
||||
logErrorWithStack(error: Error, category: LogEntry['category'] = 'error', details?: Record<string, any>) {
|
||||
return this._addLog(
|
||||
error.message,
|
||||
'error',
|
||||
category,
|
||||
{
|
||||
...details,
|
||||
name: error.name,
|
||||
stack: error.stack,
|
||||
},
|
||||
{
|
||||
component: category,
|
||||
action: 'error',
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Refresh logs (useful for real-time updates)
|
||||
refreshLogs() {
|
||||
const currentLogs = this._logs.get();
|
||||
this._logs.set({ ...currentLogs });
|
||||
}
|
||||
|
||||
// Enhanced logging methods
|
||||
logInfo(message: string, details: LogDetails) {
|
||||
return this._addLog(message, 'info', 'system', details);
|
||||
}
|
||||
|
||||
logSuccess(message: string, details: LogDetails) {
|
||||
return this._addLog(message, 'info', 'system', { ...details, success: true });
|
||||
}
|
||||
|
||||
logApiRequest(
|
||||
method: string,
|
||||
url: string,
|
||||
details: {
|
||||
method: string;
|
||||
url: string;
|
||||
statusCode: number;
|
||||
duration: number;
|
||||
request: any;
|
||||
response: any;
|
||||
},
|
||||
) {
|
||||
return this._addApiLog(`API ${method} ${url}`, method, url, details);
|
||||
}
|
||||
|
||||
logSettingsChange(component: string, setting: string, oldValue: any, newValue: any) {
|
||||
return this._addLog(
|
||||
`Settings changed in ${component}: ${setting}`,
|
||||
'info',
|
||||
'settings',
|
||||
{
|
||||
setting,
|
||||
previousValue: oldValue,
|
||||
newValue,
|
||||
},
|
||||
{
|
||||
component,
|
||||
action: 'settings_change',
|
||||
previousValue: oldValue,
|
||||
newValue,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
logFeatureToggle(featureId: string, enabled: boolean) {
|
||||
return this._addLog(
|
||||
`Feature ${featureId} ${enabled ? 'enabled' : 'disabled'}`,
|
||||
'info',
|
||||
'feature',
|
||||
{ featureId, enabled },
|
||||
{
|
||||
component: 'features',
|
||||
action: 'feature_toggle',
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
logTaskOperation(taskId: string, operation: string, status: string, details?: any) {
|
||||
return this._addLog(
|
||||
`Task ${taskId}: ${operation} - ${status}`,
|
||||
'info',
|
||||
'task',
|
||||
{ taskId, operation, status, ...details },
|
||||
{
|
||||
component: 'task-manager',
|
||||
action: 'task_operation',
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
logProviderAction(provider: string, action: string, success: boolean, details?: any) {
|
||||
return this._addLog(
|
||||
`Provider ${provider}: ${action} - ${success ? 'Success' : 'Failed'}`,
|
||||
success ? 'info' : 'error',
|
||||
'provider',
|
||||
{ provider, action, success, ...details },
|
||||
{
|
||||
component: 'providers',
|
||||
action: 'provider_action',
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
logPerformanceMetric(component: string, operation: string, duration: number, details?: any) {
|
||||
return this._addLog(
|
||||
`Performance: ${component} - ${operation} took ${duration}ms`,
|
||||
duration > 1000 ? 'warning' : 'info',
|
||||
'performance',
|
||||
{ component, operation, duration, ...details },
|
||||
{
|
||||
component,
|
||||
action: 'performance_metric',
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const logStore = new LogStore();
|
||||
64
app/lib/stores/netlify.ts
Normal file
64
app/lib/stores/netlify.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { atom } from 'nanostores';
|
||||
import { toast } from 'sonner';
|
||||
import type { NetlifyConnection } from '~/types/netlify';
|
||||
import { logStore } from './logs';
|
||||
|
||||
// Initialize with stored connection or environment variable
|
||||
const storedConnection = typeof window !== 'undefined' ? localStorage.getItem('netlify_connection') : null;
|
||||
|
||||
// If we have an environment token but no stored connection, initialize with the env token
|
||||
const initialConnection: NetlifyConnection = storedConnection
|
||||
? JSON.parse(storedConnection)
|
||||
: {
|
||||
isConnect: false,
|
||||
stats: undefined,
|
||||
};
|
||||
|
||||
export const netlifyConnection = atom<NetlifyConnection>(initialConnection);
|
||||
export const isConnecting = atom<boolean>(initialConnection.isConnect);
|
||||
export const isFetchingStats = atom<boolean>(false);
|
||||
|
||||
export const updateNetlifyConnection = (updates: Partial<NetlifyConnection>) => {
|
||||
const currentState = netlifyConnection.get();
|
||||
const newState = { ...currentState, ...updates };
|
||||
netlifyConnection.set(newState);
|
||||
|
||||
// Persist to localStorage
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem('netlify_connection', JSON.stringify(newState));
|
||||
}
|
||||
};
|
||||
|
||||
export async function fetchNetlifyStats() {
|
||||
try {
|
||||
isFetchingStats.set(true);
|
||||
|
||||
const response = await fetch('/api/netlify/stats');
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`获取统计信息失败: ${response.status}`);
|
||||
}
|
||||
|
||||
const { data, success, message } = await response.json();
|
||||
|
||||
if (!success) {
|
||||
throw new Error(message || '获取统计信息失败');
|
||||
}
|
||||
|
||||
const currentState = netlifyConnection.get();
|
||||
updateNetlifyConnection({
|
||||
...currentState,
|
||||
stats: data,
|
||||
});
|
||||
|
||||
toast.success('Netlify 统计信息更新成功');
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error('Netlify API Error:', error);
|
||||
logStore.logError('Failed to fetch Netlify stats', { error });
|
||||
toast.error(`获取 Netlify 统计信息失败: ${error instanceof Error ? error.message : '未知错误'}`);
|
||||
throw error;
|
||||
} finally {
|
||||
isFetchingStats.set(false);
|
||||
}
|
||||
}
|
||||
423
app/lib/stores/pages.ts
Normal file
423
app/lib/stores/pages.ts
Normal file
@@ -0,0 +1,423 @@
|
||||
import { diffLines } from 'diff';
|
||||
import { atom, computed, type MapStore, map, type WritableAtom } from 'nanostores';
|
||||
import { type EditorBridge, type EventPayload, editorBridge } from '~/lib/bridge';
|
||||
import type { Page, PageHistory, Section } from '~/types/actions';
|
||||
import { computePageModifications, diffPages } from '~/utils/diff';
|
||||
import { isValidContent } from '~/utils/html-parse';
|
||||
import { createScopedLogger } from '~/utils/logger';
|
||||
|
||||
const logger = createScopedLogger('PagesStore');
|
||||
|
||||
export type PageSection = Section & {
|
||||
validRootDomId?: boolean;
|
||||
};
|
||||
|
||||
export type PageMap = Record<string, Page | undefined>;
|
||||
type ActiveSection = WritableAtom<string | undefined>;
|
||||
type ActivePage = WritableAtom<string | undefined>;
|
||||
export type SectionMap = Record<string, PageSection | undefined>;
|
||||
|
||||
/**
|
||||
* 保存与 AI 交互的页面数据, AI 生成的页面数据会保存在此处。
|
||||
* 当用户在两个消息之间修改 editor 时,也会讲修改的页面和内容保存在此处。
|
||||
*/
|
||||
export class PagesStore {
|
||||
private readonly editorBridge: Promise<EditorBridge> = editorBridge;
|
||||
|
||||
/**
|
||||
* 跟踪页面数量
|
||||
*/
|
||||
private size = 0;
|
||||
|
||||
/**
|
||||
* @note 跟踪所有自上次用户消息以来被修改的文件及其原始内容,以便模型感知这些更改。
|
||||
* 当用户发送另一条消息且所有更改都需要提交时,需要重置。
|
||||
*/
|
||||
private modifiedPages: Map<string, string> = import.meta.hot?.data.modifiedPages ?? new Map();
|
||||
|
||||
/**
|
||||
* 跟踪已删除的页面,防止它们在重新加载时重新出现
|
||||
*/
|
||||
private deletedPages: Set<string> = import.meta.hot?.data.deletedPages ?? new Set();
|
||||
|
||||
/**
|
||||
* 页面映射,与 AI 做交互,基于 artifacts 数据解析而来。
|
||||
* 因此,此数据表示与数据库通信的底层数据,未保存的数据将不会在此处体现。
|
||||
* 如果在编辑器中确定保存了数据,则需要实时同步进 #modifiedPages 中。
|
||||
*/
|
||||
pages: MapStore<PageMap> = import.meta.hot?.data.pages ?? map({});
|
||||
|
||||
/**
|
||||
* 页面历史记录,用于 diff 视图。
|
||||
* 每次页面保存时,会保存上一次的页面内容。
|
||||
*/
|
||||
pageHistory: MapStore<Record<string, PageHistory>> = import.meta.hot?.data.pageHistory ?? map({});
|
||||
|
||||
activePage: ActivePage = import.meta.hot?.data.activePage ?? atom<string | undefined>();
|
||||
currentPage = computed([this.pages, this.activePage], (pages, activePage) => {
|
||||
if (!activePage) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return pages[activePage];
|
||||
});
|
||||
/**
|
||||
* 基于 action 的 section 映射,作为与 AI 交互的底层数据,基于 actions 数据解析而来。
|
||||
*/
|
||||
sections: MapStore<SectionMap> = import.meta.hot?.data.sections ?? map({});
|
||||
/**
|
||||
* 当前活跃的 section。
|
||||
*/
|
||||
activeSection: ActiveSection = import.meta.hot?.data.activeSection ?? atom<string | undefined>();
|
||||
|
||||
currentSection = computed([this.sections, this.activeSection], (sections, activeSection) => {
|
||||
if (!activeSection) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return sections[activeSection];
|
||||
});
|
||||
|
||||
get pagesCount() {
|
||||
return this.size;
|
||||
}
|
||||
|
||||
constructor() {
|
||||
// Load deleted paths from localStorage if available
|
||||
try {
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
const deletedPagesJson = localStorage.getItem('upage-deleted-pages');
|
||||
|
||||
if (deletedPagesJson) {
|
||||
const deletedPages = JSON.parse(deletedPagesJson);
|
||||
|
||||
if (Array.isArray(deletedPages)) {
|
||||
deletedPages.forEach((path) => this.deletedPages.add(path));
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to load deleted paths from localStorage', error);
|
||||
}
|
||||
|
||||
if (import.meta.hot) {
|
||||
// Persist our state across hot reloads
|
||||
import.meta.hot.data.pages = this.pages;
|
||||
import.meta.hot.data.modifiedPages = this.modifiedPages;
|
||||
import.meta.hot.data.deletedPages = this.deletedPages;
|
||||
import.meta.hot.data.sections = this.sections;
|
||||
import.meta.hot.data.pageHistory = this.pageHistory;
|
||||
}
|
||||
|
||||
this.#init();
|
||||
this.setupCoordination();
|
||||
}
|
||||
|
||||
private setupCoordination() {
|
||||
this.sections.listen(() => {
|
||||
const currentPage = this.activePage.get();
|
||||
|
||||
if (currentPage && this.currentSection.get() === undefined) {
|
||||
const pageActions = this.getPage(currentPage)?.actionIds;
|
||||
if (pageActions) {
|
||||
this.setActiveSection(pageActions[pageActions.length - 1]);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
getPage(pageName: string) {
|
||||
return this.pages.get()[pageName];
|
||||
}
|
||||
|
||||
getPageModifications() {
|
||||
return computePageModifications(this.pages.get(), this.modifiedPages);
|
||||
}
|
||||
|
||||
getModifiedPages() {
|
||||
let modifiedPages: { [pageName: string]: Page } | undefined = undefined;
|
||||
|
||||
for (const [pageName, originalContent] of this.modifiedPages) {
|
||||
const page = this.pages.get()[pageName];
|
||||
if (!page) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (page.content === originalContent) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!modifiedPages) {
|
||||
modifiedPages = {};
|
||||
}
|
||||
|
||||
modifiedPages[pageName] = page;
|
||||
}
|
||||
|
||||
return modifiedPages;
|
||||
}
|
||||
|
||||
resetPageModifications() {
|
||||
this.modifiedPages.clear();
|
||||
}
|
||||
|
||||
async savePage(pageName: string, content: string) {
|
||||
const page = this.getPage(pageName);
|
||||
if (!page) {
|
||||
return false;
|
||||
}
|
||||
// 保存上一次的页面内容
|
||||
this.savePageHistory(pageName, content);
|
||||
try {
|
||||
this.pages.setKey(pageName, { ...page, content });
|
||||
logger.info('Page updated');
|
||||
} catch (error) {
|
||||
logger.error('Failed to update page content\n\n', error);
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async savePageHistory(pageName: string, newContent: string) {
|
||||
const page = this.getPage(pageName);
|
||||
if (!page) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pageHistory = this.pageHistory.get()[pageName];
|
||||
// 如果不存在历史记录,则创建一个新的历史记录
|
||||
const normalizedCurrentContent = newContent?.replace(/\r\n/g, '\n').trim();
|
||||
const originalContent = pageHistory?.originalContent || page.content!;
|
||||
if (!originalContent) {
|
||||
return;
|
||||
}
|
||||
const normalizedOriginalContent = (pageHistory?.originalContent || page.content!).replace(/\r\n/g, '\n').trim();
|
||||
if (!pageHistory) {
|
||||
if (normalizedCurrentContent !== normalizedOriginalContent) {
|
||||
const newChanges = diffLines(page.content!, newContent);
|
||||
const newHistory: PageHistory = {
|
||||
originalContent: page.content!,
|
||||
lastModified: Date.now(),
|
||||
changes: newChanges,
|
||||
versions: [
|
||||
{
|
||||
timestamp: Date.now(),
|
||||
content: newContent,
|
||||
},
|
||||
],
|
||||
changeSource: 'auto-save',
|
||||
};
|
||||
this.pageHistory.setKey(pageName, newHistory);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 如果存在历史记录,则检查自上次版本以来是否有实际变化
|
||||
const lastVersion = pageHistory.versions[pageHistory.versions.length - 1];
|
||||
const normalizedLastContent = lastVersion?.content.replace(/\r\n/g, '\n').trim();
|
||||
if (normalizedCurrentContent === normalizedLastContent) {
|
||||
return;
|
||||
}
|
||||
|
||||
const unifiedDiff = diffPages(pageName, pageHistory.originalContent, newContent);
|
||||
if (!unifiedDiff) {
|
||||
return;
|
||||
}
|
||||
const newChanges = diffLines(pageHistory.originalContent, newContent);
|
||||
|
||||
// 检查是否有显著变化
|
||||
const hasSignificantChanges = newChanges.some(
|
||||
(change) => (change.added || change.removed) && change.value.trim().length > 0,
|
||||
);
|
||||
if (!hasSignificantChanges) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newHistory: PageHistory = {
|
||||
originalContent: pageHistory.originalContent,
|
||||
lastModified: Date.now(),
|
||||
changes: [...pageHistory.changes, ...newChanges].slice(-100), // Limitar histórico de mudanças
|
||||
versions: [
|
||||
...pageHistory.versions,
|
||||
{
|
||||
timestamp: Date.now(),
|
||||
content: newContent,
|
||||
},
|
||||
].slice(-10), // 只保留最近的 10 个版本
|
||||
changeSource: 'auto-save',
|
||||
};
|
||||
|
||||
this.pageHistory.setKey(pageName, newHistory);
|
||||
}
|
||||
|
||||
async #init() {
|
||||
const grapesBridge = await this.editorBridge;
|
||||
|
||||
this.#cleanupDeletedPages();
|
||||
|
||||
grapesBridge.watch(({ type, payload }) => this.#processGrapesBridgeEvent(type, payload));
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes any deleted files/folders from the store
|
||||
*/
|
||||
#cleanupDeletedPages() {
|
||||
if (this.deletedPages.size === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentPages = this.pages.get();
|
||||
|
||||
for (const deletedPageName of this.deletedPages) {
|
||||
if (currentPages[deletedPageName]) {
|
||||
this.pages.setKey(deletedPageName, undefined);
|
||||
this.size--;
|
||||
}
|
||||
|
||||
for (const [path] of Object.entries(currentPages)) {
|
||||
if (path.startsWith(deletedPageName + '/')) {
|
||||
this.pages.setKey(path, undefined);
|
||||
|
||||
this.size--;
|
||||
|
||||
if (this.modifiedPages.has(path)) {
|
||||
this.modifiedPages.delete(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#processGrapesBridgeEvent(type: string, payload: EventPayload) {
|
||||
const { pageName } = payload;
|
||||
|
||||
// Skip processing if this page was explicitly deleted
|
||||
if (this.deletedPages.has(pageName)) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (type) {
|
||||
case 'add_page': {
|
||||
const { title: pageTitle, actionIds = [] } = payload;
|
||||
this.pages.setKey(pageName, {
|
||||
name: pageName,
|
||||
title: pageTitle,
|
||||
content: '',
|
||||
actionIds,
|
||||
});
|
||||
|
||||
this.size++;
|
||||
logger.info(`Page created: ${pageName}`);
|
||||
break;
|
||||
}
|
||||
case 'upsert_page': {
|
||||
const { title: pageTitle, actionIds = [] } = payload;
|
||||
this.pages.setKey(pageName, {
|
||||
name: pageName,
|
||||
title: pageTitle,
|
||||
actionIds,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'remove_page': {
|
||||
this.deletedPages.add(pageName);
|
||||
|
||||
this.pages.setKey(pageName, undefined);
|
||||
this.size--;
|
||||
|
||||
if (this.modifiedPages.has(pageName)) {
|
||||
this.modifiedPages.delete(pageName);
|
||||
}
|
||||
|
||||
this.#persistDeletedPages();
|
||||
|
||||
logger.info(`Page deleted: ${pageName}`);
|
||||
break;
|
||||
}
|
||||
case 'update_section': {
|
||||
const { id, section } = payload;
|
||||
this.sections.setKey(id, { id, type: 'section', ...section });
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async createPage(pageName: string, pageTitle: string) {
|
||||
await this.editorBridge.then((grapesBridge) => grapesBridge.createPage(pageName, { title: pageTitle }));
|
||||
return true;
|
||||
}
|
||||
|
||||
async deletePage(pageName: string) {
|
||||
await this.editorBridge.then((grapesBridge) => grapesBridge.removePage(pageName));
|
||||
return true;
|
||||
}
|
||||
|
||||
// method to persist deleted paths to localStorage
|
||||
#persistDeletedPages() {
|
||||
try {
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
localStorage.setItem('upage-deleted-pages', JSON.stringify([...this.deletedPages]));
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to persist deleted paths to localStorage', error);
|
||||
}
|
||||
}
|
||||
|
||||
updateSection(actionId: string, sectionContent: string) {
|
||||
const sections = this.sections.get();
|
||||
const sectionState = sections[actionId];
|
||||
if (!sectionState) {
|
||||
return;
|
||||
}
|
||||
|
||||
const oldContent = sectionState.content;
|
||||
const contentChanged = oldContent !== sectionContent;
|
||||
|
||||
if (contentChanged) {
|
||||
this.sections.setKey(actionId, { ...sectionState, content: sectionContent });
|
||||
}
|
||||
this.updateSectionRootDomId(actionId, sectionState, sectionContent);
|
||||
}
|
||||
|
||||
private updateSectionRootDomId(actionId: string, section: PageSection, sectionContent: string) {
|
||||
if (section.validRootDomId) {
|
||||
return;
|
||||
}
|
||||
if (section.action === 'remove') {
|
||||
this.sections.setKey(actionId, { ...section, rootDomId: section.domId, validRootDomId: true });
|
||||
return;
|
||||
}
|
||||
const isValid = isValidContent(sectionContent);
|
||||
if (!isValid) {
|
||||
return;
|
||||
}
|
||||
const div = document.createElement('div');
|
||||
div.innerHTML = sectionContent;
|
||||
const rootDomId = div.firstElementChild?.id;
|
||||
if (!rootDomId) {
|
||||
return;
|
||||
}
|
||||
const oldRootDomId = section.rootDomId;
|
||||
if (oldRootDomId && oldRootDomId === rootDomId) {
|
||||
this.sections.setKey(actionId, { ...section, validRootDomId: true });
|
||||
} else {
|
||||
this.sections.setKey(actionId, { ...section, rootDomId });
|
||||
}
|
||||
}
|
||||
|
||||
setActiveSection(sectionId: string | undefined) {
|
||||
this.activeSection.set(sectionId);
|
||||
}
|
||||
|
||||
setActivePage(pageName: string | undefined) {
|
||||
this.activePage.set(pageName);
|
||||
}
|
||||
|
||||
setPage(pageName: string, page: Page) {
|
||||
const oldPage = this.getPage(pageName);
|
||||
if (!oldPage) {
|
||||
return;
|
||||
}
|
||||
this.pages.setKey(pageName, { ...oldPage, ...page });
|
||||
}
|
||||
}
|
||||
39
app/lib/stores/previews.ts
Normal file
39
app/lib/stores/previews.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { atom } from 'nanostores';
|
||||
import type { ExportEditorFile } from './web-builder';
|
||||
|
||||
export interface PreviewInfo {
|
||||
content: string;
|
||||
filename: string;
|
||||
mimeType: string;
|
||||
}
|
||||
|
||||
// 当页面保存时,由 save 方法主动调用并 setPreviews 来更新 previews 中的数据
|
||||
export class PreviewsStore {
|
||||
previews = atom<PreviewInfo[]>([]);
|
||||
currentPreview = atom<string | null>(null);
|
||||
|
||||
setPreviews(files: ExportEditorFile[]) {
|
||||
this.previews.set(
|
||||
files.map((file) => ({
|
||||
content: file.content,
|
||||
filename: file.filename,
|
||||
mimeType: file.mimeType,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
setCurrentPreview(filename: string) {
|
||||
this.currentPreview.set(filename);
|
||||
}
|
||||
}
|
||||
|
||||
// Create a singleton instance
|
||||
let previewsStore: PreviewsStore | null = null;
|
||||
|
||||
export function usePreviewStore() {
|
||||
if (!previewsStore) {
|
||||
previewsStore = new PreviewsStore();
|
||||
}
|
||||
|
||||
return previewsStore;
|
||||
}
|
||||
28
app/lib/stores/profile.ts
Normal file
28
app/lib/stores/profile.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { atom } from 'nanostores';
|
||||
|
||||
interface Profile {
|
||||
username: string;
|
||||
bio: string;
|
||||
avatar: string;
|
||||
}
|
||||
|
||||
// Initialize with stored profile or defaults
|
||||
const storedProfile = typeof window !== 'undefined' ? localStorage.getItem('upage_profile') : null;
|
||||
const initialProfile: Profile = storedProfile
|
||||
? JSON.parse(storedProfile)
|
||||
: {
|
||||
username: '',
|
||||
bio: '',
|
||||
avatar: '',
|
||||
};
|
||||
|
||||
export const profileStore = atom<Profile>(initialProfile);
|
||||
|
||||
export const updateProfile = (updates: Partial<Profile>) => {
|
||||
profileStore.set({ ...profileStore.get(), ...updates });
|
||||
|
||||
// Persist to localStorage
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem('upage_profile', JSON.stringify(profileStore.get()));
|
||||
}
|
||||
};
|
||||
217
app/lib/stores/settings.ts
Normal file
217
app/lib/stores/settings.ts
Normal file
@@ -0,0 +1,217 @@
|
||||
import Cookies from 'js-cookie';
|
||||
import { atom, map } from 'nanostores';
|
||||
import { DEFAULT_TAB_CONFIG } from '~/components/@settings/core/constants';
|
||||
import type {
|
||||
DevTabConfig,
|
||||
TabVisibilityConfig,
|
||||
TabWindowConfig,
|
||||
UserTabConfig,
|
||||
} from '~/components/@settings/core/types';
|
||||
import { toggleTheme } from './theme';
|
||||
|
||||
export interface Shortcut {
|
||||
key: string;
|
||||
ctrlKey?: boolean;
|
||||
shiftKey?: boolean;
|
||||
altKey?: boolean;
|
||||
metaKey?: boolean;
|
||||
ctrlOrMetaKey?: boolean;
|
||||
action: () => void;
|
||||
description?: string; // Description of what the shortcut does
|
||||
isPreventDefault?: boolean; // Whether to prevent default browser behavior
|
||||
}
|
||||
|
||||
export interface Shortcuts {
|
||||
toggleTheme: Shortcut;
|
||||
}
|
||||
|
||||
export const URL_CONFIGURABLE_PROVIDERS = ['Ollama', 'LMStudio', 'OpenAILike'];
|
||||
export const LOCAL_PROVIDERS = ['OpenAILike', 'LMStudio', 'Ollama'];
|
||||
|
||||
// Simplified shortcuts store with only theme toggle
|
||||
export const shortcutsStore = map<Shortcuts>({
|
||||
toggleTheme: {
|
||||
key: 'd',
|
||||
metaKey: true,
|
||||
altKey: true,
|
||||
shiftKey: true,
|
||||
action: () => toggleTheme(),
|
||||
description: 'Toggle theme',
|
||||
isPreventDefault: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Add this helper function at the top of the file
|
||||
const isBrowser = typeof window !== 'undefined';
|
||||
|
||||
export const isDebugMode = atom(false);
|
||||
|
||||
export const isProduction = atom(
|
||||
isBrowser ? window.ENV.OPERATING_ENV === 'production' : process.env.OPERATING_ENV === 'production',
|
||||
);
|
||||
export const isDevelopment = atom(
|
||||
isBrowser ? window.ENV.OPERATING_ENV === 'development' : process.env.OPERATING_ENV === 'development',
|
||||
);
|
||||
export const isTest = atom(isBrowser ? window.ENV.OPERATING_ENV === 'test' : process.env.OPERATING_ENV === 'test');
|
||||
|
||||
// Define keys for localStorage
|
||||
const SETTINGS_KEYS = {
|
||||
LATEST_BRANCH: 'isLatestBranch',
|
||||
EVENT_LOGS: 'isEventLogsEnabled',
|
||||
PROMPT_ID: 'promptId',
|
||||
DEVELOPER_MODE: 'isDeveloperMode',
|
||||
} as const;
|
||||
|
||||
// Initialize settings from localStorage or defaults
|
||||
const getInitialSettings = () => {
|
||||
const getStoredBoolean = (key: string, defaultValue: boolean): boolean => {
|
||||
if (!isBrowser) {
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
const stored = localStorage.getItem(key);
|
||||
|
||||
if (stored === null) {
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(stored);
|
||||
} catch {
|
||||
return defaultValue;
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
latestBranch: getStoredBoolean(SETTINGS_KEYS.LATEST_BRANCH, false),
|
||||
eventLogs: getStoredBoolean(SETTINGS_KEYS.EVENT_LOGS, true),
|
||||
promptId: isBrowser ? localStorage.getItem(SETTINGS_KEYS.PROMPT_ID) || 'default' : 'default',
|
||||
developerMode: getStoredBoolean(SETTINGS_KEYS.DEVELOPER_MODE, false),
|
||||
};
|
||||
};
|
||||
|
||||
// Initialize stores with persisted values
|
||||
const initialSettings = getInitialSettings();
|
||||
|
||||
export const latestBranchStore = atom<boolean>(initialSettings.latestBranch);
|
||||
export const isEventLogsEnabled = atom<boolean>(initialSettings.eventLogs);
|
||||
export const promptStore = atom<string>(initialSettings.promptId);
|
||||
|
||||
// Helper functions to update settings with persistence
|
||||
export const updateLatestBranch = (enabled: boolean) => {
|
||||
latestBranchStore.set(enabled);
|
||||
localStorage.setItem(SETTINGS_KEYS.LATEST_BRANCH, JSON.stringify(enabled));
|
||||
};
|
||||
|
||||
export const updateEventLogs = (enabled: boolean) => {
|
||||
isEventLogsEnabled.set(enabled);
|
||||
localStorage.setItem(SETTINGS_KEYS.EVENT_LOGS, JSON.stringify(enabled));
|
||||
};
|
||||
|
||||
export const updatePromptId = (id: string) => {
|
||||
promptStore.set(id);
|
||||
localStorage.setItem(SETTINGS_KEYS.PROMPT_ID, id);
|
||||
};
|
||||
|
||||
// Initialize tab configuration from localStorage or defaults
|
||||
const getInitialTabConfiguration = (): TabWindowConfig => {
|
||||
const defaultConfig: TabWindowConfig = {
|
||||
userTabs: DEFAULT_TAB_CONFIG.filter((tab): tab is UserTabConfig => tab.window === 'user'),
|
||||
developerTabs: DEFAULT_TAB_CONFIG.filter((tab): tab is DevTabConfig => tab.window === 'developer'),
|
||||
};
|
||||
|
||||
if (!isBrowser) {
|
||||
return defaultConfig;
|
||||
}
|
||||
|
||||
try {
|
||||
const saved = localStorage.getItem('upage_tab_configuration');
|
||||
|
||||
if (!saved) {
|
||||
return defaultConfig;
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(saved);
|
||||
|
||||
if (!parsed?.userTabs || !parsed?.developerTabs) {
|
||||
return defaultConfig;
|
||||
}
|
||||
|
||||
// Ensure proper typing of loaded configuration
|
||||
return {
|
||||
userTabs: parsed.userTabs.filter((tab: TabVisibilityConfig): tab is UserTabConfig => tab.window === 'user'),
|
||||
developerTabs: parsed.developerTabs.filter(
|
||||
(tab: TabVisibilityConfig): tab is DevTabConfig => tab.window === 'developer',
|
||||
),
|
||||
};
|
||||
} catch (error) {
|
||||
console.warn('Failed to parse tab configuration:', error);
|
||||
return defaultConfig;
|
||||
}
|
||||
};
|
||||
|
||||
export const tabConfigurationStore = map<TabWindowConfig>(getInitialTabConfiguration());
|
||||
|
||||
// Helper function to update tab configuration
|
||||
export const updateTabConfiguration = (config: TabVisibilityConfig) => {
|
||||
const currentConfig = tabConfigurationStore.get();
|
||||
console.log('Current tab configuration before update:', currentConfig);
|
||||
|
||||
const isUserTab = config.window === 'user';
|
||||
const targetArray = isUserTab ? 'userTabs' : 'developerTabs';
|
||||
|
||||
// Only update the tab in its respective window
|
||||
const updatedTabs = currentConfig[targetArray].map((tab) => (tab.id === config.id ? { ...config } : tab));
|
||||
|
||||
// If tab doesn't exist in this window yet, add it
|
||||
if (!updatedTabs.find((tab) => tab.id === config.id)) {
|
||||
updatedTabs.push(config);
|
||||
}
|
||||
|
||||
// Create new config, only updating the target window's tabs
|
||||
const newConfig: TabWindowConfig = {
|
||||
...currentConfig,
|
||||
[targetArray]: updatedTabs,
|
||||
};
|
||||
|
||||
console.log('New tab configuration after update:', newConfig);
|
||||
|
||||
tabConfigurationStore.set(newConfig);
|
||||
Cookies.set('tabConfiguration', JSON.stringify(newConfig), {
|
||||
expires: 365, // Set cookie to expire in 1 year
|
||||
path: '/',
|
||||
sameSite: 'strict',
|
||||
});
|
||||
};
|
||||
|
||||
// Helper function to reset tab configuration
|
||||
export const resetTabConfiguration = () => {
|
||||
const defaultConfig: TabWindowConfig = {
|
||||
userTabs: DEFAULT_TAB_CONFIG.filter((tab): tab is UserTabConfig => tab.window === 'user'),
|
||||
developerTabs: DEFAULT_TAB_CONFIG.filter((tab): tab is DevTabConfig => tab.window === 'developer'),
|
||||
};
|
||||
|
||||
tabConfigurationStore.set(defaultConfig);
|
||||
localStorage.setItem('upage_tab_configuration', JSON.stringify(defaultConfig));
|
||||
};
|
||||
|
||||
// Settings panel state
|
||||
export const settingsStore = map({
|
||||
isOpen: false,
|
||||
selectedTab: 'user', // Default tab
|
||||
});
|
||||
|
||||
// Settings panel actions
|
||||
export const openSettings = () => {
|
||||
settingsStore.setKey('isOpen', true);
|
||||
settingsStore.setKey('selectedTab', 'user'); // Always open to user tab
|
||||
};
|
||||
|
||||
export const closeSettings = () => {
|
||||
settingsStore.setKey('isOpen', false);
|
||||
settingsStore.setKey('selectedTab', 'user'); // Reset to user tab when closing
|
||||
};
|
||||
|
||||
export const setSelectedTab = (tab: string) => {
|
||||
settingsStore.setKey('selectedTab', tab);
|
||||
};
|
||||
15
app/lib/stores/sidebar.ts
Normal file
15
app/lib/stores/sidebar.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { atom } from 'nanostores';
|
||||
|
||||
export const kSidebar = 'upage_sidebar';
|
||||
|
||||
export const DEFAULT_SIDEBAR_STATE = false;
|
||||
|
||||
export const sidebarStore = atom<boolean>(DEFAULT_SIDEBAR_STATE);
|
||||
|
||||
export function toggleSidebar() {
|
||||
const currentSidebar = sidebarStore.get();
|
||||
const newSidebar = !currentSidebar;
|
||||
|
||||
// Update the theme store
|
||||
sidebarStore.set(newSidebar);
|
||||
}
|
||||
52
app/lib/stores/tab-configuration.ts
Normal file
52
app/lib/stores/tab-configuration.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { atom, map } from 'nanostores';
|
||||
|
||||
export interface TabConfig {
|
||||
id: string;
|
||||
visible: boolean;
|
||||
window: 'developer' | 'user';
|
||||
order: number;
|
||||
locked?: boolean;
|
||||
}
|
||||
|
||||
const DEFAULT_CONFIG = {
|
||||
userTabs: [],
|
||||
developerTabs: [],
|
||||
};
|
||||
|
||||
export const userTabsStore = atom<TabConfig[]>(DEFAULT_CONFIG.userTabs);
|
||||
export const developerTabsStore = atom<TabConfig[]>(DEFAULT_CONFIG.developerTabs);
|
||||
|
||||
export const tabConfiguration = map({
|
||||
userTabs: DEFAULT_CONFIG.userTabs,
|
||||
developerTabs: DEFAULT_CONFIG.developerTabs,
|
||||
});
|
||||
|
||||
tabConfiguration.set({
|
||||
userTabs: DEFAULT_CONFIG.userTabs,
|
||||
developerTabs: DEFAULT_CONFIG.developerTabs,
|
||||
});
|
||||
|
||||
userTabsStore.listen((userTabs) => {
|
||||
tabConfiguration.setKey('userTabs', userTabs as never[]);
|
||||
});
|
||||
|
||||
developerTabsStore.listen((developerTabs) => {
|
||||
tabConfiguration.setKey('developerTabs', developerTabs as never[]);
|
||||
});
|
||||
|
||||
export const tabConfigurationStore = {
|
||||
get: () => ({
|
||||
userTabs: userTabsStore.get(),
|
||||
developerTabs: developerTabsStore.get(),
|
||||
}),
|
||||
|
||||
set: (config: { userTabs: TabConfig[]; developerTabs: TabConfig[] }) => {
|
||||
userTabsStore.set(config.userTabs);
|
||||
developerTabsStore.set(config.developerTabs);
|
||||
},
|
||||
|
||||
reset: () => {
|
||||
userTabsStore.set(DEFAULT_CONFIG.userTabs);
|
||||
developerTabsStore.set(DEFAULT_CONFIG.developerTabs);
|
||||
},
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user