🎉 first commit

This commit is contained in:
LIlGG
2025-09-24 13:06:25 +08:00
commit 1f4fb103e9
409 changed files with 61222 additions and 0 deletions

36
app/routes/_index.tsx Normal file
View File

@@ -0,0 +1,36 @@
import { data, type LoaderFunctionArgs, type MetaFunction } from '@remix-run/node';
import { Chat } from '~/components/chat/Chat.client';
import { Header } from '~/components/header/Header';
import { getUser } from '~/lib/.server/auth';
import { getUserUsageStats } from '~/lib/.server/chatUsage';
export const meta: MetaFunction = () => {
return [{ title: 'UPage' }, { name: 'description', content: 'Talk with UPage, an AI assistant from Lxware' }];
};
export async function loader({ request }: LoaderFunctionArgs) {
const userContext = await getUser(request);
const userChatUsage = await getUserUsageStats(userContext?.userInfo?.sub as string);
return data({
auth: {
isAuthenticated: userContext?.isAuthenticated,
userInfo: userContext?.isAuthenticated ? userContext.userInfo : null,
},
chatUsage: userChatUsage,
});
}
/**
* Landing page component for UPage
* Note: Settings functionality should ONLY be accessed through the sidebar menu.
* Do not add settings button/panel to this landing page as it was intentionally removed
* to keep the UI clean and consistent with the design system.
*/
export default function Index() {
return (
<div className="flex flex-col size-full bg-upage-elements-background-depth-1">
<Header />
<Chat />
</div>
);
}

View File

@@ -0,0 +1,302 @@
import crypto from 'crypto';
import type { _1PanelPaginationResponse, _1PanelResponse, _1PanelWebsite } from '~/types/1panel';
import { isBinaryString } from '~/utils/file-utils';
import { generateUUID } from '~/utils/uuid';
import { request } from '../../lib/fetch';
export interface _1PanelBaseParams {
serverUrl: string;
apiKey: string;
version?: 'v2';
}
export interface CreateWebsiteParams extends _1PanelBaseParams {
alias: string;
primaryDomain?: string;
proxyProtocol?: string;
isSSL?: boolean;
}
export interface GetWebsiteParams extends _1PanelBaseParams {
siteId: number;
}
export interface UploadFileContent {
path: string;
data: string;
fileName: string;
}
export interface UploadFileParams extends _1PanelBaseParams {
path: string;
data: string;
fileName: string;
}
export interface UploadFilesParams extends _1PanelBaseParams {
files: UploadFileContent[];
}
export interface DeleteWebsiteParams extends _1PanelBaseParams {
siteId: number;
}
export interface ToggleAccessParams extends _1PanelBaseParams {
siteId: number;
operate: 'start' | 'stop';
}
function get1PanelHost(serverUrl: string, version = 'v2') {
return `${serverUrl.replace(/\/$/, '')}/api/${version}`;
}
export async function getWebsiteList(
serverUrl: string,
apiKey: string,
version = 'v2',
): Promise<_1PanelResponse<_1PanelWebsite[]>> {
const response = await request(`${get1PanelHost(serverUrl, version)}/websites/list`, {
method: 'GET',
headers: {
...getAuthHeaders(apiKey),
'Content-Type': 'application/json',
},
});
if (!response.ok) {
throw new Error(`Failed to fetch website list: ${response.statusText}`);
}
return (await response.json()) as _1PanelResponse<_1PanelWebsite[]>;
}
export async function createWebsite(params: CreateWebsiteParams) {
const { serverUrl, apiKey, version = 'v2', alias, primaryDomain, proxyProtocol, isSSL } = params;
const domain = primaryDomain || `${alias}.upage.ai`;
const response = await request(`${get1PanelHost(serverUrl, version)}/websites`, {
method: 'POST',
headers: {
...getAuthHeaders(apiKey),
'Content-Type': 'application/json',
},
body: JSON.stringify({
IPV6: false,
alias,
appType: 'installed',
domains: [
{
domain,
port: 80,
ssl: isSSL || false,
},
],
appinstall: {
appId: 0,
name: '',
appDetailId: 0,
params: {},
version: '',
appkey: '',
advanced: false,
cpuQuota: 0,
memoryLimit: 0,
memoryUnit: 'MB',
containerName: '',
allowPort: false,
},
createDb: false,
enableFtp: false,
enableSSL: false,
ftpPassword: '',
ftpUser: '',
otherDomains: '',
primaryDomain: domain || '',
proxy: '',
proxyAddress: '',
proxyProtocol: proxyProtocol || 'http://',
proxyType: 'tcp',
remark: '',
runtimeType: 'php',
port: 9000,
siteDir: '',
taskID: generateUUID(),
type: 'static',
webSiteGroupId: 1,
}),
});
if (!response.ok) {
return {
code: response.status,
data: {
message: response.statusText,
},
};
}
return {
code: response.status,
data: {
domain: domain,
},
};
}
export async function getWebsite(params: GetWebsiteParams) {
const { serverUrl, apiKey, version = 'v2', siteId } = params;
const response = await request(`${get1PanelHost(serverUrl, version)}/websites/${siteId}`, {
method: 'GET',
headers: {
...getAuthHeaders(apiKey),
},
});
if (!response.ok) {
throw new Error(`Failed to get website: ${response.statusText}`);
}
return response.json() as Promise<_1PanelResponse<_1PanelWebsite>>;
}
export async function getWebsiteByPrimaryDomain(
serverUrl: string,
apiKey: string,
primaryDomain: string,
version = 'v2',
) {
const response = await request(`${get1PanelHost(serverUrl, version)}/websites/search`, {
method: 'POST',
headers: {
...getAuthHeaders(apiKey),
'Content-Type': 'application/json',
},
body: JSON.stringify({
name: primaryDomain,
order: 'descending',
orderBy: 'favorite',
page: 1,
pageSize: 10,
type: '',
websiteGroupId: 0,
}),
});
if (!response.ok) {
throw new Error(`Failed to get website by primary domain: ${response.statusText}`);
}
return response.json() as Promise<_1PanelResponse<_1PanelPaginationResponse<_1PanelWebsite>>>;
}
export async function uploadFiles(params: UploadFilesParams) {
const { serverUrl, apiKey, version = 'v2', files } = params;
try {
for (const file of files) {
await uploadSingleContent({
serverUrl,
apiKey,
version,
path: file.path,
data: file.data,
fileName: file.fileName,
});
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
throw new Error(`Upload files failed: ${errorMessage}`);
}
}
export async function uploadSingleContent(params: UploadFileParams) {
const { serverUrl, apiKey, version = 'v2', path, data, fileName } = params;
try {
const formData = new FormData();
const fileContent = isBinaryString(data) ? Buffer.from(data, 'binary') : data;
const fileBlob = new Blob([fileContent], { type: 'application/octet-stream' });
const file = new File([fileBlob], fileName, { type: 'application/octet-stream' });
formData.append('file', file);
formData.append('path', path);
formData.append('overwrite', 'True');
const response = await request(`${get1PanelHost(serverUrl, version)}/files/upload`, {
method: 'POST',
headers: {
...getAuthHeaders(apiKey),
},
body: formData,
});
if (!response.ok) {
throw new Error(`Upload failed with status: ${response.status} ${response.statusText}`);
}
const result = (await response.json()) as _1PanelResponse<{ message: string }>;
if (result.code !== 200) {
throw new Error(`Upload failed with status: ${result.data?.message || 'Unknown error'}`);
}
return result.data;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
throw new Error(`Upload file failed: ${path} - ${errorMessage}`);
}
}
export async function deleteWebsite(params: DeleteWebsiteParams) {
const { serverUrl, apiKey, version = 'v2', siteId } = params;
const deleteResponse = await request(`${get1PanelHost(serverUrl, version)}/websites/del`, {
method: 'POST',
headers: {
...getAuthHeaders(apiKey),
},
body: JSON.stringify({
deleteApp: false,
deleteBackup: false,
forceDelete: false,
id: siteId,
}),
});
if (!deleteResponse.ok) {
throw new Error(`Failed to delete website: ${deleteResponse.statusText}`);
}
return true;
}
export async function toggleAccessWebsite(params: ToggleAccessParams) {
const { serverUrl, apiKey, version = 'v2', siteId, operate } = params;
const response = await request(`${get1PanelHost(serverUrl, version)}/websites/operate`, {
method: 'POST',
headers: {
...getAuthHeaders(apiKey),
'Content-Type': 'application/json',
},
body: JSON.stringify({
id: siteId,
operate,
}),
});
if (!response.ok) {
throw new Error(`Failed to toggle access: ${response.statusText}`);
}
const result = (await response.json()) as _1PanelResponse<{ message: string }>;
if (result.code !== 200) {
throw new Error(`Failed to toggle access: ${result.data?.message || 'Unknown error'}`);
}
return true;
}
function getAuthHeaders(apiKey: string) {
const timestamp = Math.floor(Date.now() / 1000).toString();
const content = `1panel${apiKey}${timestamp}`;
const token = crypto.createHash('md5').update(content).digest('hex');
return {
'1Panel-Token': token,
'1Panel-Timestamp': timestamp,
'Accept-Language': 'zh',
};
}

View File

@@ -0,0 +1,51 @@
import { type ActionFunctionArgs } from '@remix-run/node';
import { delete1PanelConnectionSettings, save1PanelConnectionSettings } from '~/lib/.server/connectionSettings';
import { getWebsiteList } from '~/routes/api.1panel.$action/1panel.server';
import { errorResponse, successResponse } from '~/utils/api-response';
import { createScopedLogger } from '~/utils/logger';
const logger = createScopedLogger('api.1panel.auth');
export type HandleAuthArgs = ActionFunctionArgs & {
userId: string;
};
export async function handleAuth({ request, userId }: HandleAuthArgs) {
try {
const { serverUrl, apiKey } = await request.json();
if (!serverUrl) {
return errorResponse(400, '缺少服务器地址参数');
}
if (!apiKey) {
return errorResponse(400, '缺少API密钥参数');
}
const parsedServerUrl = serverUrl.replace(/\/$/, '');
const websitesResponse = await getWebsiteList(parsedServerUrl, apiKey);
if (websitesResponse.code !== 200) {
await delete1PanelConnectionSettings(userId);
return errorResponse(websitesResponse.code, websitesResponse.message || '连接1Panel失败');
}
await save1PanelConnectionSettings(userId, parsedServerUrl, apiKey);
logger.info(`用户 ${userId} 成功验证并保存了 1Panel 连接信息`);
const websites = websitesResponse.data || [];
websites.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
return successResponse(
{
websites,
totalWebsites: websites.length,
lastUpdated: new Date().toISOString(),
},
'1Panel 连接验证成功',
);
} catch (error) {
logger.error('验证 1Panel 连接失败:', error);
return errorResponse(500, '验证 1Panel 连接失败');
}
}

View File

@@ -0,0 +1,46 @@
import type { ActionFunctionArgs } from '@remix-run/node';
import { get1PanelConnectionSettings } from '~/lib/.server/connectionSettings';
import { deleteDeploymentById, getDeploymentById } from '~/lib/.server/deployment';
import { errorResponse, successResponse } from '~/utils/api-response';
import { createScopedLogger } from '~/utils/logger';
import { deleteWebsite } from './1panel.server';
const logger = createScopedLogger('api.1panel.delete');
export type DeletePageArgs = ActionFunctionArgs & {
userId: string;
};
export async function deletePage({ request, userId }: DeletePageArgs) {
const { id } = await request.json();
try {
// 查找部署记录
const deployment = await getDeploymentById(id);
if (!deployment) {
return errorResponse(404, '未找到部署记录');
}
const connectionSettings = await get1PanelConnectionSettings(userId);
if (!connectionSettings) {
return errorResponse(401, '未配置1Panel连接信息');
}
const { deploymentId: siteId } = deployment;
const { serverUrl, apiKey } = connectionSettings;
const result = await deleteWebsite({ serverUrl, apiKey, siteId: Number(siteId) });
if (!result) {
return errorResponse(500, '删除1Panel网站失败');
}
await deleteDeploymentById(id);
logger.info(`用户 ${userId} 已删除 1Panel 部署 ${id}`);
return successResponse(true, '页面已删除');
} catch (error) {
logger.error(`删除 1Panel 部署 ${id} 失败:`, error);
return errorResponse(500, error instanceof Error ? error.message : '删除失败');
}
}

View File

@@ -0,0 +1,217 @@
import { type ActionFunctionArgs } from '@remix-run/node';
import { get1PanelConnectionSettings, save1PanelConnectionSettings } from '~/lib/.server/connectionSettings';
import { createOrUpdateDeployment, getLatestDeployment } from '~/lib/.server/deployment';
import { createScopedLogger } from '~/lib/.server/logger';
import {
createWebsite,
getWebsite,
getWebsiteByPrimaryDomain,
type UploadFileContent,
uploadFiles,
} from '~/routes/api.1panel.$action/1panel.server';
import type { _1PanelWebsite, _1PanelWebsiteInfo } from '~/types/1panel';
import { DeploymentPlatformEnum, DeploymentStatusEnum } from '~/types/deployment';
import { errorResponse, successResponse } from '~/utils/api-response';
interface DeployRequestBody {
websiteId: number;
files: Record<string, string>;
chatId: string;
serverUrl?: string;
apiKey?: string;
websiteDomain?: string;
protocol?: string;
}
export type HandleDeployArgs = ActionFunctionArgs & {
userId: string;
};
const logger = createScopedLogger('api.1panel.deploy');
export async function handleDeploy({ request, userId }: HandleDeployArgs) {
try {
const {
websiteId,
files,
chatId,
serverUrl: requestServerUrl,
apiKey: requestApiKey,
websiteDomain,
protocol,
} = (await request.json()) as DeployRequestBody;
// 从用户设置中获取连接信息
let connectionSettings = await get1PanelConnectionSettings(userId);
// 如果请求体中提供了连接信息,优先使用请求体中的信息,并更新用户设置
if (requestServerUrl && requestApiKey) {
connectionSettings = {
serverUrl: requestServerUrl,
apiKey: requestApiKey,
};
// 更新用户设置
await save1PanelConnectionSettings(userId, requestServerUrl, requestApiKey);
}
// 如果没有连接信息,返回错误
if (!connectionSettings) {
logger.warn('未配置1Panel连接信息');
return errorResponse(401, '未配置1Panel连接信息请先设置服务器地址和API密钥');
}
const { serverUrl, apiKey } = connectionSettings;
logger.debug('action => request', { websiteId, files, chatId, serverUrl, websiteDomain, protocol });
const existingDeployment = await getLatestDeployment(userId, chatId, DeploymentPlatformEnum._1PANEL);
let targetWebsiteId;
if (websiteId) {
targetWebsiteId = websiteId;
} else if (existingDeployment?.deploymentId) {
targetWebsiteId = parseInt(existingDeployment.deploymentId);
} else {
targetWebsiteId = undefined;
}
let websiteInfo: _1PanelWebsiteInfo | undefined;
if (targetWebsiteId) {
const websiteResponse = await getWebsite({
serverUrl,
apiKey,
siteId: targetWebsiteId,
});
logger.debug('action => getWebsite', JSON.stringify(websiteResponse));
if (websiteResponse.data) {
const existingWebsite = websiteResponse.data as _1PanelWebsite;
websiteInfo = {
id: existingWebsite.id,
domain: existingWebsite.primaryDomain,
url: `${existingWebsite.protocol.toLowerCase()}://${existingWebsite.primaryDomain}`,
alias: existingWebsite.alias,
sitePath: existingWebsite.sitePath,
chatId,
};
}
}
if (!websiteInfo) {
// If no websiteId provided, create a new website
const alias = `upage-${chatId}-${Date.now()}`;
const createWebsiteResponse = await createWebsite({
serverUrl,
apiKey,
alias,
primaryDomain: websiteDomain,
proxyProtocol: `${protocol || 'http'}://`,
isSSL: protocol === 'https',
});
logger.debug('action => createWebsite', JSON.stringify(createWebsiteResponse));
if (createWebsiteResponse.code !== 200) {
logger.warn('无法创建网站', JSON.stringify(createWebsiteResponse));
return errorResponse(400, `无法创建网站: ${createWebsiteResponse.data?.message || 'Unknown error'}`);
}
const { domain } = createWebsiteResponse.data as { domain: string };
const webSiteInfo = await getWebsiteByPrimaryDomain(serverUrl, apiKey, domain);
if (webSiteInfo.code !== 200) {
logger.warn('无法获取网站信息', JSON.stringify(webSiteInfo));
return errorResponse(400, '无法获取网站信息');
}
if (webSiteInfo.data.items == null) {
logger.warn('获取网站失败,请检查 1Panel 日志', JSON.stringify(webSiteInfo));
return errorResponse(400, '获取网站失败,请检查 1Panel 日志');
}
const newWebsite = webSiteInfo.data.items.find((item) => item.alias === alias);
if (!newWebsite) {
logger.warn('无法获取网站信息', JSON.stringify(newWebsite));
return errorResponse(400, '无法获取网站信息');
}
targetWebsiteId = newWebsite.id;
websiteInfo = {
id: newWebsite.id,
domain: newWebsite.primaryDomain,
sitePath: newWebsite.sitePath,
alias: newWebsite.alias,
url: `${newWebsite.protocol.toLowerCase()}://${newWebsite.primaryDomain}`,
chatId,
};
}
logger.info('创建网站成功 => ', websiteInfo.id, websiteInfo.domain, websiteInfo.url);
if (!websiteInfo) {
return errorResponse(400, '无法创建网站');
}
const deploymentFiles: UploadFileContent[] = [];
for (const [filePath, content] of Object.entries(files)) {
const lastSlashIndex = filePath.lastIndexOf('/');
const folderPath = lastSlashIndex !== -1 ? filePath.substring(0, lastSlashIndex + 1) : '';
const fileName = lastSlashIndex !== -1 ? filePath.substring(lastSlashIndex + 1) : filePath;
// 获取 filename
deploymentFiles.push({
path: `${websiteInfo.sitePath}/index/${folderPath}`,
fileName,
data: content,
});
}
logger.debug('action => uploadFiles', JSON.stringify(deploymentFiles));
try {
await uploadFiles({
serverUrl,
apiKey,
version: 'v2',
files: deploymentFiles,
});
} catch (error) {
logger.warn('action => uploadFiles error', JSON.stringify(error));
const errorMessage = error instanceof Error ? error.message : String(error);
return errorResponse(400, `无法上传文件: ${errorMessage}`);
}
try {
await createOrUpdateDeployment({
userId,
chatId,
platform: DeploymentPlatformEnum._1PANEL,
deploymentId: String(websiteInfo.id),
url: websiteInfo.url,
status: DeploymentStatusEnum.SUCCESS,
metadata: {
domain: websiteInfo.domain,
alias: websiteInfo.alias,
sitePath: websiteInfo.sitePath,
serverUrl,
},
});
logger.info(`为用户 ${userId} 创建了 1Panel 部署记录`);
} catch (error) {
logger.error('创建部署记录失败:', error);
}
return successResponse(
{
deploy: {
id: websiteInfo.id,
domain: websiteInfo.domain,
url: websiteInfo.url,
},
},
'部署成功',
);
} catch (error) {
console.error('1Panel deploy error:', error);
return errorResponse(500, '部署到 1Panel 失败');
}
}

View File

@@ -0,0 +1,64 @@
import { type ActionFunctionArgs, type LoaderFunctionArgs } from '@remix-run/node';
import { requireAuth } from '~/lib/.server/auth';
import { createScopedLogger } from '~/lib/.server/logger';
import { errorResponse } from '~/utils/api-response';
import { handleAuth } from './auth.server';
import { deletePage } from './delete.server';
import { handleDeploy } from './deploy.server';
import { getStats } from './stats.server';
import { toggleAccess } from './toggle-access.server';
import { handleWebsites } from './websites.server';
const logger = createScopedLogger('api.1panel.route');
export async function loader(args: LoaderFunctionArgs) {
const { request, params } = args;
const authResult = await requireAuth(request, { isApi: true });
if (authResult instanceof Response) {
return authResult;
}
const userId = authResult.userInfo?.sub;
if (!userId) {
return errorResponse(401, '用户未登录');
}
switch (params.action) {
case 'stats':
return getStats({ ...args, userId });
default:
return errorResponse(404, `未知的 API 操作: ${params.action}`);
}
}
export async function action(args: ActionFunctionArgs) {
const { request, params } = args;
const authResult = await requireAuth(request, { isApi: true });
if (authResult instanceof Response) {
return authResult;
}
const userId = authResult.userInfo?.sub;
if (!userId) {
return errorResponse(401, '用户未登录');
}
logger.debug('处理 1Panel API 请求', { action: params.action });
// 根据参数调用不同的处理函数
switch (params.action) {
case 'deploy':
return handleDeploy({ ...args, userId });
case 'websites':
return handleWebsites({ ...args, userId });
case 'auth':
return handleAuth({ ...args, userId });
case 'toggle-access':
return toggleAccess({ ...args, userId });
case 'delete':
return deletePage({ ...args, userId });
default:
logger.warn('未知的 API 操作', { action: params.action });
return errorResponse(404, `未知的 API 操作: ${params.action}`);
}
}

View File

@@ -0,0 +1,46 @@
import { type LoaderFunctionArgs } from '@remix-run/node';
import { get1PanelConnectionSettings } from '~/lib/.server/connectionSettings';
import { getWebsiteList } from '~/routes/api.1panel.$action/1panel.server';
import { errorResponse, successResponse } from '~/utils/api-response';
import { createScopedLogger } from '~/utils/logger';
const logger = createScopedLogger('api.1panel.stats');
export type GetStatsArgs = LoaderFunctionArgs & {
userId: string;
};
export async function getStats({ userId }: GetStatsArgs) {
try {
// 从用户设置中获取连接信息
const connectionSettings = await get1PanelConnectionSettings(userId);
if (!connectionSettings) {
return errorResponse(401, '未连接到1Panel请先设置服务器地址和API密钥');
}
const { serverUrl, apiKey } = connectionSettings;
// 获取网站列表
const websitesResponse = await getWebsiteList(serverUrl, apiKey);
if (websitesResponse.code !== 200) {
return errorResponse(websitesResponse.code, websitesResponse.message || '获取网站列表失败');
}
const websites = websitesResponse.data || [];
websites.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
return successResponse(
{
websites,
totalWebsites: websites.length,
lastUpdated: new Date().toISOString(),
},
'获取 1Panel 网站统计信息成功',
);
} catch (error) {
logger.error('获取 1Panel 网站统计信息失败:', error);
return errorResponse(500, '获取 1Panel 网站统计信息失败');
}
}

View File

@@ -0,0 +1,54 @@
import type { ActionFunctionArgs } from '@remix-run/node';
import { get1PanelConnectionSettings } from '~/lib/.server/connectionSettings';
import { getDeploymentById, updateDeploymentStatus } from '~/lib/.server/deployment';
import { errorResponse, successResponse } from '~/utils/api-response';
import { createScopedLogger } from '~/utils/logger';
import { toggleAccessWebsite } from './1panel.server';
const logger = createScopedLogger('api.1panel.toggle-access');
export type ToggleAccessArgs = ActionFunctionArgs & {
userId: string;
};
export async function toggleAccess({ request, userId }: ToggleAccessArgs) {
const { id } = await request.json();
try {
const deployment = await getDeploymentById(id);
if (!deployment) {
return errorResponse(404, '未找到部署记录');
}
const connectionSettings = await get1PanelConnectionSettings(userId);
if (!connectionSettings) {
return errorResponse(401, '未配置1Panel连接信息');
}
const currentStatus = deployment.status;
const newStatus = currentStatus !== 'inactive' ? 'inactive' : 'success';
const { deploymentId: siteId } = deployment;
const { serverUrl, apiKey } = connectionSettings;
const operate = newStatus === 'success' ? 'start' : 'stop';
const result = await toggleAccessWebsite({ serverUrl, apiKey, siteId: Number(siteId), operate });
if (!result) {
return errorResponse(500, '切换访问状态失败');
}
await updateDeploymentStatus(id, newStatus);
logger.info(`用户 ${userId}${newStatus === 'success' ? '开启' : '停止'} 1Panel 网站 ${siteId} 的访问`);
return successResponse(
{
status: newStatus,
},
`${newStatus === 'success' ? '开启' : '停止'}访问`,
);
} catch (error) {
logger.error(`切换1Panel部署 ${id} 访问状态失败:`, error);
return errorResponse(500, error instanceof Error ? error.message : '操作失败');
}
}

View File

@@ -0,0 +1,88 @@
import { type ActionFunctionArgs } from '@remix-run/node';
import { get1PanelConnectionSettings, save1PanelConnectionSettings } from '~/lib/.server/connectionSettings';
import { deleteDeploymentsByPlatformAndId } from '~/lib/.server/deployment';
import { createScopedLogger } from '~/lib/.server/logger';
import { deleteWebsite, getWebsiteList } from '~/routes/api.1panel.$action/1panel.server';
import { DeploymentPlatformEnum } from '~/types/deployment';
import { errorResponse, successResponse } from '~/utils/api-response';
interface WebsiteListRequestBody {
serverUrl?: string;
apiKey?: string;
}
interface DeleteWebsiteRequestBody {
serverUrl?: string;
apiKey?: string;
siteId: number;
}
export type HandleWebsitesArgs = ActionFunctionArgs & {
userId: string;
};
const logger = createScopedLogger('api.1panel.websites');
export async function handleWebsites({ request, userId }: HandleWebsitesArgs) {
try {
if (request.method === 'POST') {
const requestBody = (await request.json()) as WebsiteListRequestBody;
let connectionSettings = await get1PanelConnectionSettings(userId);
if (requestBody.serverUrl && requestBody.apiKey) {
connectionSettings = {
serverUrl: requestBody.serverUrl,
apiKey: requestBody.apiKey,
};
await save1PanelConnectionSettings(userId, requestBody.serverUrl, requestBody.apiKey);
}
if (!connectionSettings) {
return errorResponse(401, '未配置1Panel连接信息请先设置服务器地址和API密钥');
}
const { serverUrl, apiKey } = connectionSettings;
const websites = await getWebsiteList(serverUrl, apiKey);
if (websites.code !== 200) {
logger.warn('获取网站列表失败', JSON.stringify(websites));
return errorResponse(websites.code, websites.message);
}
return successResponse(websites.data ?? [], '获取网站列表成功');
}
if (request.method === 'DELETE') {
const requestBody = (await request.json()) as DeleteWebsiteRequestBody;
if (!requestBody.siteId) {
return errorResponse(400, '未提供网站ID');
}
const connectionSettings = await get1PanelConnectionSettings(userId);
if (!connectionSettings) {
return errorResponse(401, '未配置1Panel连接信息请先设置服务器地址和API密钥');
}
const { serverUrl, apiKey } = connectionSettings;
await deleteWebsite({
serverUrl,
apiKey,
siteId: requestBody.siteId,
});
await deleteDeploymentsByPlatformAndId(DeploymentPlatformEnum._1PANEL, requestBody.siteId);
return successResponse(true, '网站删除成功');
}
logger.warn('不支持的 HTTP 方法', JSON.stringify({ url: request.url, method: request.method }));
return errorResponse(405, '不支持的 HTTP 方法');
} catch (error) {
logger.error('处理 1Panel 网站请求错误:', error);
return errorResponse(500, '处理请求失败 - ' + (error instanceof Error ? error.message : String(error)));
}
}

View File

@@ -0,0 +1,22 @@
import type { LoaderFunctionArgs } from '@remix-run/node';
import { data } from '@remix-run/node';
import { getAuthError } from '~/lib/.server/auth';
/**
* 检查认证错误信息的路由
*
* 从会话中读取认证错误信息,并在响应中返回
* 同时会清除错误信息,确保它只显示一次
*/
export async function checkErrorLoader({ request }: LoaderFunctionArgs) {
const { errorMessage, headers } = await getAuthError(request);
return data(
{
errorMessage,
},
{
headers,
},
);
}

View File

@@ -0,0 +1,41 @@
import type { LoaderFunctionArgs } from '@remix-run/node';
import { logto } from '~/lib/.server/auth';
import { checkErrorLoader } from './check-error.server';
import { userLoader } from './user.server';
export const loader = async (args: LoaderFunctionArgs) => {
const { params } = args;
switch (params.action) {
case 'check-error':
return checkErrorLoader(args);
case 'user':
return userLoader(args);
default:
/**
* 处理认证路由
* 支持的路由:
* - /api/auth/sign-in - 登录
* - /api/auth/callback - 登录回调
* - /api/auth/sign-out - 登出
*/
return logto.handleAuthRoutes({
'sign-in': {
path: '/api/auth/sign-in',
redirectBackTo: '/api/auth/callback',
},
'sign-in-callback': {
path: '/api/auth/callback',
redirectBackTo: '/',
},
'sign-out': {
path: '/api/auth/sign-out',
redirectBackTo: '/',
},
'sign-up': {
path: '/api/auth/sign-up',
redirectBackTo: '/api/auth/callback',
},
})(args);
}
};

View File

@@ -0,0 +1,16 @@
import { data, type LoaderFunctionArgs } from '@remix-run/node';
import { getUser } from '~/lib/.server/auth';
/**
* 用户信息API端点
* 返回用户认证状态和用户信息
*/
export async function userLoader({ request }: LoaderFunctionArgs) {
// 使用服务端 getUser 函数获取用户上下文
const userContext = await getUser(request);
return data({
isAuthenticated: userContext.isAuthenticated,
claims: userContext.isAuthenticated ? userContext.userInfo : null,
});
}

View File

@@ -0,0 +1,93 @@
import type { ActionFunctionArgs } from '@remix-run/node';
import { deleteChat, getUserChatById } from '~/lib/.server/chat';
import { errorResponse, successResponse } from '~/utils/api-response';
import { createScopedLogger } from '~/utils/logger';
const logger = createScopedLogger('api.chat.delete');
export type HandleDeleteActionArgs = ActionFunctionArgs & {
userId: string;
};
/**
* 处理删除聊天操作
*/
export async function handleDeleteAction({ request, userId }: HandleDeleteActionArgs) {
if (request.method !== 'DELETE' && request.method !== 'POST') {
return errorResponse(405, '请求方法不支持');
}
try {
// 获取请求数据
const formData = await request.formData();
const id = formData.get('id')?.toString();
const idsString = formData.get('ids')?.toString();
let ids: string[] | undefined;
if (idsString) {
try {
ids = JSON.parse(idsString);
} catch (error) {
logger.error('解析 ids 参数失败', error);
return errorResponse(400, 'ids 参数格式无效');
}
}
if (!id && (!ids || !Array.isArray(ids) || ids.length === 0)) {
return errorResponse(400, '缺少有效的聊天ID');
}
if (id) {
const chat = await getUserChatById(id, userId);
if (!chat) {
return errorResponse(404, '未找到聊天记录或无权限操作');
}
await deleteChat(id);
logger.debug(`用户 ${userId} 删除了聊天 ${id}`);
return successResponse(id, '删除聊天成功');
}
const idsToDelete = ids as string[];
const results = {
success: [] as string[],
failed: [] as string[],
totalMessagesDeleted: 0,
};
for (const chatId of idsToDelete) {
try {
const chat = await getUserChatById(chatId, userId);
if (!chat) {
results.failed.push(chatId);
continue;
}
const messageCount = chat.messages?.length || 0;
await deleteChat(chatId);
results.success.push(chatId);
results.totalMessagesDeleted += messageCount;
logger.debug(`用户 ${userId} 删除了聊天 ${chatId},级联删除了 ${messageCount} 条消息及其关联数据`);
} catch (error) {
logger.error(`删除聊天 ${chatId} 失败`, error);
results.failed.push(chatId);
}
}
return successResponse(
{
results,
totalSuccess: results.success.length,
totalFailed: results.failed.length,
totalMessagesDeleted: results.totalMessagesDeleted,
},
'删除聊天成功',
);
} catch (error) {
logger.error('删除聊天失败', error);
return errorResponse(500, '删除聊天失败');
}
}

View File

@@ -0,0 +1,167 @@
import type { ActionFunctionArgs } from '@remix-run/node';
import { getUserChatById } from '~/lib/.server/chat';
import { prisma } from '~/lib/.server/prisma';
import { errorResponse, successResponse } from '~/utils/api-response';
import { createScopedLogger } from '~/utils/logger';
const logger = createScopedLogger('api.chat.fork');
export type HandleForkActionArgs = ActionFunctionArgs & {
userId: string;
};
/**
* 处理复制聊天操作
*/
export async function handleForkAction({ request, userId }: HandleForkActionArgs) {
try {
const { sourceChatId, messageId } = await request.json();
if (!sourceChatId) {
return errorResponse(400, '源聊天ID不能为空');
}
const sourceChat = await getUserChatById(sourceChatId, userId);
if (!sourceChat) {
return errorResponse(404, '找不到源聊天');
}
// 使用事务处理整个复制过程,确保数据一致性
return await prisma.$transaction(async (tx) => {
logger.debug(`开始复制聊天 ${sourceChatId} 的数据...`);
const metadata =
sourceChat.metadata &&
typeof sourceChat.metadata === 'object' &&
!Array.isArray(sourceChat.metadata) &&
sourceChat.metadata !== null
? (sourceChat.metadata as Record<string, any>)
: undefined;
// 创建新聊天
const newChat = await tx.chat.create({
data: {
userId,
description: `${sourceChat.description || 'Chat'} (Copy)`,
urlId: sourceChat.urlId || undefined,
metadata,
},
});
logger.debug(`为用户 ${userId} 创建了聊天副本: ${newChat.id}`);
// 检查是否有消息需要复制
if (sourceChat.messages && sourceChat.messages.length > 0) {
// 根据messageId过滤消息
let messagesToCopy = sourceChat.messages;
// 如果指定了messageId过滤消息
if (messageId) {
const targetIndex = messagesToCopy.findIndex((msg) => msg.id === messageId);
if (targetIndex === -1) {
await tx.chat.delete({ where: { id: newChat.id } });
logger.warn('在聊天中找不到指定的消息', { sourceChatId, messageId });
return errorResponse(404, '在聊天中找不到指定的消息');
}
// 只保留从 0 到 targetIndex 的消息
messagesToCopy = messagesToCopy.slice(0, targetIndex + 1);
logger.debug(`将复制聊天 ${sourceChatId} 的前 ${messagesToCopy.length} 条消息到消息ID: ${messageId}`);
} else {
logger.debug(`将复制聊天 ${sourceChatId} 的全部 ${messagesToCopy.length} 条消息`);
}
// 准备批量创建消息的数据
// 由于 prisma 中 output 与 input 类型不一致,需要手动复制 https://github.com/prisma/prisma/issues/9247
const messageCreateData = messagesToCopy.map((msg) => ({
chatId: newChat.id,
userId,
role: msg.role,
content: msg.content,
annotations: msg.annotations || undefined,
metadata: msg.metadata || undefined,
parts: msg.parts || undefined,
revisionId: msg.revisionId || undefined,
isDiscarded: msg.isDiscarded || false,
}));
logger.debug('批量创建消息数据', JSON.stringify(messageCreateData));
// 使用批量创建消息函数创建消息
await tx.message.createMany({
data: messageCreateData,
});
logger.debug(`为聊天 ${newChat.id} 批量创建了 ${messageCreateData.length} 条消息`);
const newMessages = await tx.message.findMany({
where: { chatId: newChat.id },
orderBy: { createdAt: 'asc' },
});
// 创建映射原消息ID -> 新消息对象
const messageMapping = messagesToCopy.reduce(
(map, oldMsg, index) => {
map[oldMsg.id] = newMessages[index];
return map;
},
{} as Record<string, any>,
);
const pageToCreate = messagesToCopy
.filter((msg) => msg.page != null)
.map((msg) => {
const page = msg.page!;
return {
messageId: messageMapping[msg.id].id,
pages: JSON.parse(JSON.stringify(page.pages)),
};
});
// 批量创建 Page 项目数据
if (pageToCreate.length > 0) {
await tx.page.createMany({
data: pageToCreate,
});
logger.debug(`为聊天 ${newChat.id} 批量创建了 ${pageToCreate.length} 个Page项目`);
}
// 收集需要创建的区块数据
const sectionsToCreate = [];
for (const msg of messagesToCopy) {
if (msg.sections && msg.sections.length > 0) {
for (const section of msg.sections) {
sectionsToCreate.push({
messageId: messageMapping[msg.id].id,
type: section.type,
action: section.action,
actionId: section.actionId,
pageName: section.pageName,
content: section.content,
domId: section.domId,
sort: section.sort,
rootDomId: section.rootDomId,
});
}
}
}
// 批量创建区块数据
if (sectionsToCreate.length > 0) {
await tx.section.createMany({
data: sectionsToCreate,
});
logger.debug(`为聊天 ${newChat.id} 批量创建了 ${sectionsToCreate.length} 个区块`);
}
}
// 返回新聊天ID
return successResponse(newChat.id, '聊天复制成功');
});
} catch (error) {
logger.error('复制聊天失败:', error);
return errorResponse(500, '服务器处理请求失败');
}
}

View File

@@ -0,0 +1,51 @@
import type { LoaderFunctionArgs } from '@remix-run/node';
import { getUserChats } from '~/lib/.server/chat';
import { errorResponse, successResponse } from '~/utils/api-response';
import { createScopedLogger } from '~/utils/logger';
const logger = createScopedLogger('api.chat.list');
export type HandleListLoaderArgs = LoaderFunctionArgs & {
userId: string;
};
/**
* 处理获取聊天列表操作
*/
export async function handleListLoader({ request, userId }: HandleListLoaderArgs) {
try {
const url = new URL(request.url);
const searchQuery = url.searchParams.get('q') || '';
const limit = parseInt(url.searchParams.get('limit') || '20', 10);
const offset = parseInt(url.searchParams.get('offset') || '0', 10);
logger.debug(`获取用户 ${userId} 的聊天列表,搜索: ${searchQuery}, 限制: ${limit}, 偏移: ${offset}`);
const { chats, total } = await getUserChats(userId, limit, offset);
// 如果有搜索关键词,过滤结果
let filteredChats = chats;
if (searchQuery) {
filteredChats = chats.filter((chat) => chat.description?.toLowerCase().includes(searchQuery.toLowerCase()));
}
return successResponse(
{
chats: filteredChats.map((chat) => ({
id: chat.id,
urlId: chat.urlId,
description: chat.description,
timestamp: chat.updatedAt,
lastMessage: chat.messages[0]?.content,
})),
total: searchQuery ? filteredChats.length : total,
limit,
offset,
},
'获取聊天列表成功',
);
} catch (error) {
logger.error('获取聊天列表失败', error);
return errorResponse(500, '获取聊天列表失败');
}
}

View File

@@ -0,0 +1,74 @@
import { type ActionFunctionArgs, type LoaderFunctionArgs } from '@remix-run/node';
import { requireAuth } from '~/lib/.server/auth';
import { errorResponse } from '~/utils/api-response';
import { handleDeleteAction } from './delete.server';
import { handleForkAction } from './fork.server';
import { handleListLoader } from './list.server';
import { handleUpdateAction } from './update.server';
/**
* 动态路由处理聊天相关操作
* 支持的操作:
* - list: 获取聊天列表GET请求
* - delete: 删除聊天
* - update: 更新聊天
* - fork: 复制聊天
*/
/**
* 处理GET请求用于获取数据
*/
export async function loader(args: LoaderFunctionArgs) {
const authResult = await requireAuth(args.request, { isApi: true });
if (authResult instanceof Response) {
return authResult;
}
const userId = authResult.userInfo?.sub;
if (!userId) {
return errorResponse(401, '用户未登录');
}
// 获取操作类型
const { action } = args.params;
// 根据操作类型分发到不同的处理函数
switch (action) {
case 'list':
return handleListLoader({ ...args, userId });
default:
return errorResponse(400, `不支持的操作: ${action}`);
}
}
/**
* 处理非GET请求用于修改数据
*/
export async function action(args: ActionFunctionArgs) {
const authResult = await requireAuth(args.request, { isApi: true });
if (authResult instanceof Response) {
return authResult;
}
const userId = authResult.userInfo?.sub;
if (!userId) {
return errorResponse(401, '用户未登录');
}
// 获取操作类型
const { action } = args.params;
// 根据操作类型分发到不同的处理函数
switch (action) {
case 'delete':
return handleDeleteAction({ ...args, userId });
case 'update':
return handleUpdateAction({ ...args, userId });
case 'fork':
return handleForkAction({ ...args, userId });
default:
return errorResponse(400, `不支持的操作: ${action}`);
}
}

View File

@@ -0,0 +1,61 @@
import type { ActionFunctionArgs } from '@remix-run/node';
import { getUserChatById, updateChat } from '~/lib/.server/chat';
import { errorResponse, successResponse } from '~/utils/api-response';
import { createScopedLogger } from '~/utils/logger';
const logger = createScopedLogger('api.chat.update');
export type HandleUpdateActionArgs = ActionFunctionArgs & {
userId: string;
};
/**
* 处理更新聊天操作
*/
export async function handleUpdateAction({ request, userId }: HandleUpdateActionArgs) {
// 只接受POST请求
if (request.method !== 'POST') {
return errorResponse(405, '请求方法不支持');
}
try {
const formData = await request.formData();
const id = formData.get('id') as string;
const description = formData.get('description') as string;
logger.debug(`处理聊天更新请求ID: ${id}, 描述: ${description}`);
if (!id) {
return errorResponse(400, '缺少聊天ID');
}
if (!description || description.trim() === '') {
return errorResponse(400, '描述不能为空');
}
// 验证聊天记录是否属于当前用户
const chat = await getUserChatById(id, userId);
if (!chat) {
return errorResponse(404, '未找到聊天记录或无权限操作');
}
// 更新描述
const updatedChat = await updateChat(id, { description });
logger.debug(`用户 ${userId} 更新了聊天 ${id} 的描述`);
return successResponse(
{
chat: {
id: updatedChat.id,
description: updatedChat.description,
timestamp: updatedChat.updatedAt,
},
},
'更新聊天描述成功',
);
} catch (error) {
logger.error('更新聊天描述失败', error);
return errorResponse(500, '更新聊天描述失败');
}
}

View File

@@ -0,0 +1,412 @@
import type { ActionFunctionArgs } from '@remix-run/node';
import {
consumeStream,
createUIMessageStream,
createUIMessageStreamResponse,
generateId,
type UIMessageStreamWriter,
} from 'ai';
import { upsertChat } from '~/lib/.server/chat';
import { ChatUsageStatus, recordUsage, updateUsageStatus } from '~/lib/.server/chatUsage';
import { chatStreamText } from '~/lib/.server/llm/chat-stream-text';
import { MAX_RESPONSE_SEGMENTS, MAX_TOKENS } from '~/lib/.server/llm/constants';
import { createSummary } from '~/lib/.server/llm/create-summary';
import { selectContext } from '~/lib/.server/llm/select-context';
import { structuredPageSnapshot } from '~/lib/.server/llm/structured-page-snapshot';
import { createScopedLogger } from '~/lib/.server/logger';
import { getHistoryChatMessages, saveChatMessages, updateDiscardedMessage } from '~/lib/.server/message';
import { getPageByMessageId } from '~/lib/.server/page';
import { CONTINUE_PROMPT } from '~/lib/common/prompts/prompts';
import type { Page } from '~/types/actions';
import type { UPageUIMessage } from '~/types/message';
import { DEFAULT_MODEL, DEFAULT_MODEL_DETAILS, getModel, MINOR_MODEL } from '~/utils/constants';
import { approximateUsageFromContent } from '~/utils/token';
const logger = createScopedLogger('api.chat.chat');
export type ElementInfo = {
tagName: string;
className?: string;
id?: string;
innerHTML?: string;
outerHTML?: string;
};
export type ChatActionParams = {
// 当前会话 ID
chatId: string;
// 回退到指定消息 ID
rewindTo: string;
// 最后一条消息,通常是用户消息。
message: UPageUIMessage;
// 如果用户指定编辑的元素,则需要传递该元素的信息。
elementInfo: ElementInfo;
};
export type ChatActionArgs = ActionFunctionArgs & {
userId: string;
};
export async function chatAction({ request, userId }: ChatActionArgs) {
const { rewindTo, chatId, message } = await request.json<ChatActionParams>();
const chat = await upsertChat({
id: chatId,
userId,
});
const elementInfo = message.metadata?.elementInfo;
const messageId = message.id;
const messageContent = message.parts.find((part) => part.type === 'text')?.text;
const initialUsageRecord = await recordUsage({
userId,
chatId: chat.id,
messageId,
status: ChatUsageStatus.PENDING,
prompt: messageContent || '',
modelName: DEFAULT_MODEL,
});
const minorModelInitialUsageRecord = await recordUsage({
userId,
chatId: chat.id,
messageId,
status: ChatUsageStatus.PENDING,
prompt: messageContent || '',
modelName: MINOR_MODEL,
});
let streamSwitches = 0;
let progressCounter: number = 1;
const cumulativeUsage = {
inputTokens: 0,
outputTokens: 0,
totalTokens: 0,
reasoningTokens: 0,
cachedInputTokens: 0,
};
const minorModelCumulativeUsage = {
inputTokens: 0,
outputTokens: 0,
totalTokens: 0,
reasoningTokens: 0,
cachedInputTokens: 0,
};
// 辅助函数:更新辅助模型使用量
const updateMinorModelUsage = (usage: {
inputTokens?: number;
outputTokens?: number;
totalTokens?: number;
reasoningTokens?: number;
cachedInputTokens?: number;
}) => {
minorModelCumulativeUsage.inputTokens += usage.inputTokens || 0;
minorModelCumulativeUsage.outputTokens += usage.outputTokens || 0;
minorModelCumulativeUsage.totalTokens += usage.totalTokens || 0;
minorModelCumulativeUsage.reasoningTokens += usage.reasoningTokens || 0;
minorModelCumulativeUsage.cachedInputTokens += usage.cachedInputTokens || 0;
};
// 计算用户 token 消耗
const calculateTokenUsage = async (status: ChatUsageStatus) => {
try {
await updateUsageStatus(initialUsageRecord.id, status, {
inputTokens: cumulativeUsage.inputTokens,
outputTokens: cumulativeUsage.outputTokens,
reasoningTokens: cumulativeUsage.reasoningTokens,
cachedTokens: cumulativeUsage.cachedInputTokens,
totalTokens: cumulativeUsage.totalTokens,
});
logger.debug(`用户 ${userId} 的聊天: ${chat.id} 总使用量为: ${JSON.stringify(cumulativeUsage)}`);
logger.debug(`用户 ${userId} 的聊天: ${chat.id} 使用状态已更新为 ${status}`);
} catch (error) {
logger.error(`更新用户 ${userId} 的使用状态时出错:`, error);
}
};
// 计算用户 token 消耗
const calculateMinorModelTokenUsage = async (status: ChatUsageStatus) => {
try {
await updateUsageStatus(minorModelInitialUsageRecord.id, status, {
inputTokens: minorModelCumulativeUsage.inputTokens,
outputTokens: minorModelCumulativeUsage.outputTokens,
reasoningTokens: minorModelCumulativeUsage.reasoningTokens,
cachedTokens: minorModelCumulativeUsage.cachedInputTokens,
totalTokens: minorModelCumulativeUsage.totalTokens,
});
logger.debug(`用户 ${userId} 的聊天: ${chat.id} 辅助模型使用状态已更新为 ${status}`);
} catch (error) {
logger.error(`更新用户 ${userId} 的辅助模型使用状态时出错:`, error);
// 记录错误但不中断流程
}
};
const progressId = generateId();
// 获取从第一条到当前消息之间的所有消息
const previousMessages = await getHistoryChatMessages({
chatId,
rewindTo,
});
const messages = [...previousMessages, message];
const streamExecutor = async ({ writer }: { writer: UIMessageStreamWriter<UPageUIMessage> }) => {
// 在消息的开头发送一个固定的消息,用于标识消息的开始。
writer.write({
type: 'start',
messageId: generateId(),
});
// 辅助 model 所获取的数据,用于后续的模型调用。
const minorModelData: { summary: string; context: Record<string, string[]>; pageSummary: string } = {
summary: '',
context: {},
pageSummary: '',
};
// 仅当有历史消息时,才调用辅助模型,首次调用无需调用。
if (previousMessages.length > 0) {
writer.write({
type: 'data-progress',
id: progressId,
data: {
label: 'summary',
status: 'in-progress',
order: progressCounter++,
message: '正在分析请求...',
},
transient: true,
});
// 让 AI 分析用户消息摘要,明确用户下一步的意图。
const { text: summary, totalUsage: createSummaryUsage } = await createSummary({
messages,
model: getModel(MINOR_MODEL),
abortSignal: request.signal,
});
minorModelData.summary = summary;
updateMinorModelUsage(createSummaryUsage);
writer.write({
type: 'data-summary',
data: {
summary,
chatId: chat.id,
},
});
writer.write({
type: 'data-progress',
id: progressId,
data: {
label: 'summary',
status: 'complete',
order: progressCounter++,
message: '分析完成',
},
transient: true,
});
// 获取最后一条历史消息所对应的 page
const lastMessage = previousMessages[previousMessages.length - 1];
const pageData = await getPageByMessageId(lastMessage.id);
if (pageData) {
const pages = pageData.pages as unknown as Page[];
// 根据用户摘要和所有的页面数据,让 AI 根据摘要、用户消息、页面数据,选择一部分待修改的页面和待修改的 section。
writer.write({
type: 'data-progress',
id: progressId,
data: {
label: 'context',
status: 'in-progress',
order: progressCounter++,
message: '正在对页面进行分析...',
},
transient: true,
});
const { context, totalUsage: selectContextUsage } = await selectContext({
messages,
summary,
pages,
model: getModel(MINOR_MODEL),
abortSignal: request.signal,
});
minorModelData.context = context;
updateMinorModelUsage(selectContextUsage);
// 调用辅助 model 对 context 中的页面做摘要,如果没有,则对所有页面做摘要。
const selectPageNames = Object.keys(context);
const selectedPages = selectPageNames.length > 0 ? pages : pages.map((page) => page);
const { text: pageSummary, totalUsage: structuredPageSnapshotUsage } = await structuredPageSnapshot({
pages: selectedPages,
model: getModel(MINOR_MODEL),
abortSignal: request.signal,
});
minorModelData.pageSummary = pageSummary;
updateMinorModelUsage(structuredPageSnapshotUsage);
writer.write({
type: 'data-progress',
id: progressId,
data: {
label: 'context',
status: 'complete',
order: progressCounter++,
message: '页面分析完成',
},
transient: true,
});
}
}
writer.write({
type: 'data-progress',
id: progressId,
data: {
label: 'response',
status: 'in-progress',
order: progressCounter++,
message: '正在生成响应',
},
transient: true,
});
const executeStreamText = async (messages: UPageUIMessage[], isContinue: boolean = false) => {
const result = await chatStreamText({
messages,
elementInfo,
summary: minorModelData.summary,
pageSummary: minorModelData.pageSummary,
context: minorModelData.context,
maxTokens: DEFAULT_MODEL_DETAILS?.maxTokenAllowed,
model: getModel(DEFAULT_MODEL),
abortSignal: request.signal,
onFinish: async ({ totalUsage, finishReason, text }) => {
cumulativeUsage.inputTokens += totalUsage.inputTokens || 0;
cumulativeUsage.outputTokens += totalUsage.outputTokens || 0;
cumulativeUsage.totalTokens += totalUsage.totalTokens || 0;
cumulativeUsage.reasoningTokens += totalUsage.reasoningTokens || 0;
cumulativeUsage.cachedInputTokens += totalUsage.cachedInputTokens || 0;
if (finishReason === 'length') {
if (streamSwitches >= MAX_RESPONSE_SEGMENTS) {
writer.write({
type: 'data-progress',
id: progressId,
data: {
label: 'response',
status: 'stopped',
order: progressCounter++,
message: '无法继续生成消息:已达到最大分段数',
},
transient: true,
});
writer.write({
type: 'finish',
});
return;
}
await continueMessage(text);
}
if (finishReason === 'stop') {
writer.write({
type: 'data-progress',
id: progressId,
data: {
label: 'response',
status: 'complete',
order: progressCounter++,
message: '响应生成完成',
},
transient: true,
});
writer.write({
type: 'finish',
});
}
},
onAbort: async ({ totalUsage }) => {
cumulativeUsage.inputTokens += totalUsage.inputTokens || 0;
cumulativeUsage.outputTokens += totalUsage.outputTokens || 0;
cumulativeUsage.totalTokens += totalUsage.totalTokens || 0;
cumulativeUsage.reasoningTokens += totalUsage.reasoningTokens || 0;
cumulativeUsage.cachedInputTokens += totalUsage.cachedInputTokens || 0;
},
});
const continueMessage = async (text: string) => {
logger.info(
`达到最大 token 限制 (${MAX_TOKENS}): 继续消息, 还可以响应 (${MAX_RESPONSE_SEGMENTS - streamSwitches} 个分段)`,
);
messages.push({
id: generateId(),
role: 'assistant',
parts: [
{
type: 'text',
text,
},
],
});
messages.push({
id: generateId(),
role: 'user',
parts: [
{
type: 'text',
text: CONTINUE_PROMPT,
},
],
});
await executeStreamText(messages, true);
streamSwitches++;
};
writer.merge(
result.toUIMessageStream({
sendReasoning: !isContinue,
sendFinish: false,
sendStart: false,
}),
);
};
await executeStreamText([message], false);
};
const stream = createUIMessageStream<UPageUIMessage>({
execute: streamExecutor,
originalMessages: messages,
onFinish: async ({ messages, isAborted }) => {
if (isAborted) {
// 由于 AI SDK 没有提供在 onAbort 中计算 Token 消耗的方法。所以这里手动计算。
// https://github.com/vercel/ai/pull/8701
const lastAssistantMessage = messages.find((message) => message.role === 'assistant');
if (lastAssistantMessage) {
cumulativeUsage.outputTokens += approximateUsageFromContent(lastAssistantMessage.parts);
cumulativeUsage.totalTokens += approximateUsageFromContent(lastAssistantMessage.parts);
}
}
// 根据是否中止设置正确的状态
// TODO: 在错误情况下,现在还是会被设置为 SUCCESS。
const status = isAborted ? ChatUsageStatus.ABORTED : ChatUsageStatus.SUCCESS;
calculateTokenUsage(status);
calculateMinorModelTokenUsage(status);
if (isAborted) {
logger.info(`用户 ${userId} 的聊天: ${chatId} 中止处理完成`);
return;
}
// 保存消息到数据库
if (rewindTo) {
await updateDiscardedMessage(chatId, rewindTo);
}
saveChatMessages(chatId, messages);
},
onError: (error) => {
logger.error(`用户 ${userId} 的聊天: ${chatId} 处理过程中发生错误 ===> `, error);
calculateTokenUsage(ChatUsageStatus.FAILED);
calculateMinorModelTokenUsage(ChatUsageStatus.FAILED);
return '内部服务器错误,请稍后重试';
},
});
return createUIMessageStreamResponse({ stream, consumeSseStream: consumeStream });
}

View File

@@ -0,0 +1,61 @@
import type { ActionFunctionArgs } from '@remix-run/node';
import { generateId } from 'ai';
import { readFile } from 'fs/promises';
import { join } from 'path';
import { createScopedLogger } from '~/lib/.server/logger';
const logger = createScopedLogger('api.chat.mock-chat');
/**
* 处理 mock 数据的流式输出,通常用于开发环境下使用。
*/
export async function mockChat(_args: ActionFunctionArgs, filePath: string = 'mock_stream_text_1.txt') {
try {
const id = generateId();
// 读取 mock 数据文件
const mockFilePath = join(process.cwd(), 'mock', filePath);
const fileContent = await readFile(mockFilePath, 'utf-8');
const lines = fileContent.split('\n').map((line) => {
// 替换 messageId 为生成 iddata: {"type":"start","messageId":"uoLyIATGAm28y7rP"}
if (line.includes('messageId')) {
const startData = JSON.parse(line.replace('data: ', ''));
startData.messageId = id;
return `data: ${JSON.stringify(startData)}`;
}
return line;
});
// 创建一个 ReadableStream 来按行输出内容
const encoder = new TextEncoder();
const stream = new ReadableStream({
async start(controller) {
// 按行输出内容,每行之间添加延迟
for (const line of lines) {
if (line.trim() !== '') {
controller.enqueue(encoder.encode(`${line}\n\n`));
// 添加小延迟,模拟真实的流式输出
await new Promise((resolve) => setTimeout(resolve, 10));
}
}
controller.close();
},
});
// 返回 Response 对象
return new Response(stream, {
status: 200,
headers: {
'Content-Type': 'text/event-stream; charset=utf-8',
Connection: 'keep-alive',
'Cache-Control': 'no-cache, no-transform',
'X-Accel-Buffering': 'no',
},
});
} catch (error: any) {
logger.error(`Mock 数据流式输出错误: ${error}`);
throw new Response(`Mock 数据流式输出错误: ${error.message}`, {
status: 500,
statusText: 'Internal Server Error',
});
}
}

View File

@@ -0,0 +1,25 @@
import { type ActionFunctionArgs } from '@remix-run/node';
import { requireAuth } from '~/lib/.server/auth';
import { errorResponse } from '~/utils/api-response';
import { chatAction } from './chat.server';
import { mockChat } from './mock-chat.server';
export async function action(args: ActionFunctionArgs) {
const authResult = await requireAuth(args.request, { isApi: true });
if (authResult instanceof Response) {
return authResult;
}
const userId = authResult.userInfo?.sub;
if (!userId) {
return errorResponse(401, '用户未登录');
}
const useMock = false;
if (useMock) {
return mockChat(args, 'mock_stream_text_1.txt');
}
return chatAction({ ...args, userId });
}

View File

@@ -0,0 +1,46 @@
interface CacheItem {
data: any;
expiry: number;
}
const cache: Record<string, CacheItem> = {};
// 1 分钟缓存时间
const CACHE_TTL = 1 * 60 * 1000;
export function getFromCache(key: string): any | null {
const item = cache[key];
if (!item) {
return null;
}
if (Date.now() > item.expiry) {
delete cache[key];
return null;
}
return item.data;
}
export function setCache(key: string, data: any): void {
cache[key] = {
data,
expiry: Date.now() + CACHE_TTL,
};
}
export function clearCache(key: string): void {
delete cache[key];
}
setInterval(
() => {
const now = Date.now();
for (const key in cache) {
if (cache[key].expiry < now) {
delete cache[key];
}
}
},
60 * 60 * 1000,
);

View File

@@ -0,0 +1,23 @@
import { type LoaderFunctionArgs } from '@remix-run/node';
import { requireAuth } from '~/lib/.server/auth';
import { errorResponse } from '~/utils/api-response';
import { getDeploymentStats } from './stats.server';
export async function loader({ request, params }: LoaderFunctionArgs) {
const authResult = await requireAuth(request, { isApi: true });
if (authResult instanceof Response) {
return authResult;
}
const userId = authResult.userInfo?.sub;
if (!userId) {
return errorResponse(401, '用户未登录');
}
switch (params.action) {
case 'stats':
return getDeploymentStats({ userId });
default:
return errorResponse(404, `未知的 API 操作: ${params.action}`);
}
}

View File

@@ -0,0 +1,45 @@
import { prisma } from '~/lib/.server/prisma';
import { errorResponse, successResponse } from '~/utils/api-response';
import { createScopedLogger } from '~/utils/logger';
const logger = createScopedLogger('api.deployments.stats');
export type GetDeploymentStatsArgs = {
userId: string;
};
export async function getDeploymentStats({ userId }: GetDeploymentStatsArgs) {
try {
const totalSites = await prisma.deployment.count({
where: { userId },
});
const platformStats = await prisma.deployment.groupBy({
by: ['platform'],
_count: {
id: true,
},
where: { userId },
});
const sitesByPlatform = platformStats.reduce(
(acc, stat) => {
acc[stat.platform] = stat._count.id;
return acc;
},
{} as Record<string, number>,
);
return successResponse(
{
totalSites,
sitesByPlatform,
totalDays: 30,
},
'获取部署统计数据成功',
);
} catch (error) {
logger.error('获取部署统计数据失败:', error);
return errorResponse(500, error instanceof Error ? error.message : '获取部署统计数据失败');
}
}

View File

@@ -0,0 +1,34 @@
import { type LoaderFunctionArgs } from '@remix-run/node';
import { requireAuth } from '~/lib/.server/auth';
import { getUserPlatformDeploymentsWithPagination } from '~/lib/.server/deployment';
import { errorResponse, successResponse } from '~/utils/api-response';
export async function loader({ request }: LoaderFunctionArgs) {
try {
const authResult = await requireAuth(request, { isApi: true });
if (authResult instanceof Response) {
return authResult;
}
if (!authResult.userInfo) {
return errorResponse(401, '无法获取用户信息');
}
const userId = authResult.userInfo.sub;
if (!userId) {
return errorResponse(401, '无效的用户ID');
}
const url = new URL(request.url);
const platform = url.searchParams.get('platform') as any;
const offset = parseInt(url.searchParams.get('offset') || '0', 10);
const limit = parseInt(url.searchParams.get('limit') || '10', 10);
const result = await getUserPlatformDeploymentsWithPagination(userId, platform, limit, offset);
return successResponse(result, '获取部署记录成功');
} catch (error) {
console.error('Error fetching deployment records:', error);
return errorResponse(500, error instanceof Error ? error.message : '获取部署记录失败');
}
}

View File

@@ -0,0 +1,40 @@
import { type ActionFunctionArgs } from '@remix-run/node';
import type { UIMessage } from 'ai';
import { requireAuth } from '~/lib/.server/auth';
import { streamEnhancer } from '~/lib/.server/llm/stream-enhancer';
import { createScopedLogger } from '~/lib/.server/logger';
import { errorResponse } from '~/utils/api-response';
import { getModel, MINOR_MODEL } from '~/utils/constants';
export async function action(args: ActionFunctionArgs) {
const authResult = await requireAuth(args.request, { isApi: true });
if (authResult instanceof Response) {
return authResult;
}
const userId = authResult.userInfo?.sub as string;
if (!userId) {
return errorResponse(401, '用户未登录');
}
return enhancerAction({ ...args, userId });
}
const logger = createScopedLogger('api.enhancher');
export type EnhancerActionArgs = ActionFunctionArgs & {
userId: string;
};
async function enhancerAction({ request, userId }: EnhancerActionArgs) {
const { messages } = await request.json<{
messages: UIMessage[];
}>();
logger.info(`User ${userId} => Enhancing prompt: ${messages}`);
return streamEnhancer({
messages,
model: getModel(MINOR_MODEL),
});
}

View File

@@ -0,0 +1,181 @@
import type { ActionFunctionArgs, LoaderFunctionArgs } from '@remix-run/node';
import { json } from '@remix-run/node';
import { createScopedLogger } from '~/lib/.server/logger';
// Allowed headers to forward to the target server
const ALLOW_HEADERS = [
'accept-encoding',
'accept-language',
'accept',
'access-control-allow-origin',
'authorization',
'cache-control',
'connection',
'content-length',
'content-type',
'dnt',
'pragma',
'range',
'referer',
'user-agent',
'x-authorization',
'x-http-method-override',
'x-requested-with',
];
// Headers to expose from the target server's response
const EXPOSE_HEADERS = [
'accept-ranges',
'age',
'cache-control',
'content-length',
'content-language',
'content-type',
'date',
'etag',
'expires',
'last-modified',
'pragma',
'server',
'transfer-encoding',
'vary',
'x-github-request-id',
'x-redirected-url',
];
// Handle all HTTP methods
export async function action({ request, params }: ActionFunctionArgs) {
return handleProxyRequest(request, params['*']);
}
export async function loader({ request, params }: LoaderFunctionArgs) {
return handleProxyRequest(request, params['*']);
}
const logger = createScopedLogger('api.git-proxy');
async function handleProxyRequest(request: Request, path: string | undefined) {
try {
if (!path) {
return json({ error: 'Invalid proxy URL format' }, { status: 400 });
}
// Handle CORS preflight request
if (request.method === 'OPTIONS') {
return new Response(null, {
status: 200,
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'POST, GET, OPTIONS',
'Access-Control-Allow-Headers': ALLOW_HEADERS.join(', '),
'Access-Control-Expose-Headers': EXPOSE_HEADERS.join(', '),
'Access-Control-Max-Age': '86400',
},
});
}
// Extract domain and remaining path
const parts = path.match(/([^\/]+)\/?(.*)/);
if (!parts) {
return json({ error: 'Invalid path format' }, { status: 400 });
}
const domain = parts[1];
const remainingPath = parts[2] || '';
// Reconstruct the target URL with query parameters
const url = new URL(request.url);
const targetURL = `https://${domain}/${remainingPath}${url.search}`;
logger.debug('Target URL:', targetURL);
// Filter and prepare headers
const headers = new Headers();
// Only forward allowed headers
for (const header of ALLOW_HEADERS) {
if (request.headers.has(header)) {
headers.set(header, request.headers.get(header)!);
}
}
// Set the host header
headers.set('Host', domain);
// Set Git user agent if not already present
if (!headers.has('user-agent') || !headers.get('user-agent')?.startsWith('git/')) {
headers.set('User-Agent', 'git/@isomorphic-git/cors-proxy');
}
logger.debug('Request headers:', Object.fromEntries(headers.entries()));
// Prepare fetch options
const fetchOptions: RequestInit = {
method: request.method,
headers,
redirect: 'follow',
};
// Add body for non-GET/HEAD requests
if (!['GET', 'HEAD'].includes(request.method)) {
fetchOptions.body = request.body;
fetchOptions.duplex = 'half';
/*
* Note: duplex property is removed to ensure TypeScript compatibility
* across different environments and versions
*/
}
// Forward the request to the target URL
const response = await fetch(targetURL, fetchOptions);
logger.debug('Response status:', response.status);
// Create response headers
const responseHeaders = new Headers();
// Add CORS headers
responseHeaders.set('Access-Control-Allow-Origin', '*');
responseHeaders.set('Access-Control-Allow-Methods', 'POST, GET, OPTIONS');
responseHeaders.set('Access-Control-Allow-Headers', ALLOW_HEADERS.join(', '));
responseHeaders.set('Access-Control-Expose-Headers', EXPOSE_HEADERS.join(', '));
// Copy exposed headers from the target response
for (const header of EXPOSE_HEADERS) {
// Skip content-length as we'll use the original response's content-length
if (header === 'content-length') {
continue;
}
if (response.headers.has(header)) {
responseHeaders.set(header, response.headers.get(header)!);
}
}
// If the response was redirected, add the x-redirected-url header
if (response.redirected) {
responseHeaders.set('x-redirected-url', response.url);
}
logger.debug('Response headers:', Object.fromEntries(responseHeaders.entries()));
// Return the response with the target's body stream piped directly
return new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers: responseHeaders,
});
} catch (error) {
logger.error('Proxy error:', error);
return json(
{
error: 'Proxy error',
message: error instanceof Error ? error.message : 'Unknown error',
url: path ? `https://${path}` : 'Invalid URL',
},
{ status: 500 },
);
}
}

View File

@@ -0,0 +1,8 @@
import { json, type LoaderFunctionArgs } from '@remix-run/node';
export const loader = async ({ request: _request }: LoaderFunctionArgs) => {
return json({
status: 'healthy',
timestamp: new Date().toISOString(),
});
};

View File

@@ -0,0 +1,46 @@
import { deleteNetlifyConnectionSettings, saveNetlifyConnectionSettings } from '~/lib/.server/connectionSettings';
import { errorResponse, successResponse } from '~/utils/api-response';
import { createScopedLogger } from '~/utils/logger';
const logger = createScopedLogger('api.netlify.auth');
export type HandleAuthArgs = {
request: Request;
userId: string;
};
export async function handleNetlifyAuth({ request, userId }: HandleAuthArgs) {
try {
const { token } = await request.json();
if (!token) {
return errorResponse(400, '缺少令牌参数');
}
const response = await fetch('https://api.netlify.com/api/v1/user', {
headers: {
Authorization: `Bearer ${token}`,
},
});
if (!response.ok) {
await deleteNetlifyConnectionSettings(userId);
return errorResponse(401, '无效的令牌或未经授权');
}
const userData = await response.json();
await saveNetlifyConnectionSettings(userId, token);
logger.info(`用户 ${userId} 成功验证并保存了 Netlify 令牌`);
return successResponse(
{
isConnect: !!userData,
},
'Netlify 令牌验证成功',
);
} catch (error) {
logger.error('验证 Netlify 令牌失败:', error);
return errorResponse(500, '验证 Netlify 令牌失败');
}
}

View File

@@ -0,0 +1,68 @@
import { getNetlifyConnectionSettings } from '~/lib/.server/connectionSettings';
import { deleteDeploymentById, getDeploymentById } from '~/lib/.server/deployment';
import { errorResponse, successResponse } from '~/utils/api-response';
import { createScopedLogger } from '~/utils/logger';
const logger = createScopedLogger('api.netlify.delete');
export type DeletePageArgs = {
userId: string;
request: Request;
};
export async function deleteNetlifySite(token: string, siteId: string): Promise<void> {
try {
const response = await fetch(`https://api.netlify.com/api/v1/sites/${siteId}`, {
method: 'DELETE',
headers: {
Authorization: `Bearer ${token}`,
},
});
if (!response.ok) {
const errorData = (await response.json()) as { message: string };
logger.error(`删除站点失败: ${response.status} ${errorData.message}`);
throw new Error(`${errorData.message}`);
}
} catch (error) {
logger.error(`删除站点失败:`, error);
throw new Error(`${error}`);
}
}
export async function deletePage({ userId, request }: DeletePageArgs) {
const { id } = await request.json();
try {
// 查找部署记录
const deployment = await getDeploymentById(id);
if (!deployment) {
return errorResponse(404, '未找到部署记录');
}
// 获取Netlify连接设置
const connectionSettings = await getNetlifyConnectionSettings(userId);
if (!connectionSettings) {
return errorResponse(401, '未连接到Netlify');
}
const siteId = (deployment.metadata as Record<string, any>)?.siteId;
if (!siteId) {
return errorResponse(400, '部署记录缺少必要信息');
}
// 删除站点
await deleteNetlifySite(connectionSettings.token, siteId);
// 删除部署记录
await deleteDeploymentById(id);
logger.info(`用户 ${userId} 已删除 Netlify 部署 ${id}`);
return successResponse(id, '页面已删除');
} catch (error) {
logger.error(`删除 Netlify 部署 ${id} 失败:`, error);
return errorResponse(500, error instanceof Error ? error.message : '删除失败');
}
}

View File

@@ -0,0 +1,319 @@
import { getNetlifyConnectionSettings, saveNetlifyConnectionSettings } from '~/lib/.server/connectionSettings';
import { createOrUpdateDeployment, getLatestDeployment } from '~/lib/.server/deployment';
import { createScopedLogger } from '~/lib/.server/logger';
import { DeploymentPlatformEnum, DeploymentStatusEnum } from '~/types/deployment';
import type { NetlifySiteInfo } from '~/types/netlify';
import { errorResponse, successResponse } from '~/utils/api-response';
import { binaryStringToUint8Array, isBinaryString } from '~/utils/file-utils';
export type HandleDeployArgs = {
request: Request;
userId: string;
};
interface DeployRequestBody {
siteId?: string;
files: Record<string, string>;
chatId: string;
token?: string;
}
/**
* 计算字符串或二进制数据的 SHA1 哈希值
*
* @param message 要计算哈希的字符串或二进制数据
* @returns SHA1 哈希值
*/
async function sha1(message: string) {
// 检查是否为二进制字符串
let msgBuffer;
if (isBinaryString(message)) {
// 对于二进制字符串,使用 Buffer.from 处理
const buffer = Buffer.from(message, 'binary');
msgBuffer = new Uint8Array(buffer);
} else {
// 对于普通字符串,使用 TextEncoder
msgBuffer = new TextEncoder().encode(message);
}
const hashBuffer = await crypto.subtle.digest('SHA-1', msgBuffer);
const hashArray = Array.from(new Uint8Array(hashBuffer));
const hashHex = hashArray.map((b) => b.toString(16).padStart(2, '0')).join('');
return hashHex;
}
const logger = createScopedLogger('api.netlify.deploy');
export async function handleDeploy({ request, userId }: HandleDeployArgs) {
try {
const { siteId, files, token: requestToken, chatId } = (await request.json()) as DeployRequestBody;
let connectionSettings = await getNetlifyConnectionSettings(userId);
if (requestToken) {
connectionSettings = {
token: requestToken,
};
await saveNetlifyConnectionSettings(userId, requestToken);
}
if (!connectionSettings) {
logger.warn('未连接到Netlify');
return errorResponse(401, '未连接到Netlify请先设置访问令牌');
}
const { token } = connectionSettings;
const existingDeployment = await getLatestDeployment(userId, chatId, DeploymentPlatformEnum.NETLIFY);
let targetSiteId = siteId ? siteId : existingDeployment?.deploymentId ? existingDeployment.deploymentId : undefined;
let siteInfo: NetlifySiteInfo | undefined;
if (targetSiteId) {
const siteResponse = await fetch(`https://api.netlify.com/api/v1/sites/${targetSiteId}`, {
headers: {
Authorization: `Bearer ${token}`,
},
});
if (siteResponse.ok) {
const existingSite = (await siteResponse.json()) as any;
siteInfo = {
id: existingSite.id,
name: existingSite.name,
url: existingSite.url,
chatId,
};
}
}
if (!siteInfo) {
const siteName = `upage-${chatId}-${Date.now()}`;
const createSiteResponse = await fetch('https://api.netlify.com/api/v1/sites', {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
name: siteName,
custom_domain: null,
}),
});
if (!createSiteResponse.ok) {
return errorResponse(400, 'Failed to create site');
}
const newSite = (await createSiteResponse.json()) as any;
targetSiteId = newSite.id;
siteInfo = {
id: newSite.id,
name: newSite.name,
url: newSite.url,
chatId,
};
}
// Create file digests
const fileDigests: Record<string, string> = {};
const filePathsAndHashes: Record<string, string> = {};
for (const [filePath, content] of Object.entries(files)) {
// Ensure file path starts with a forward slash
const normalizedPath = filePath.startsWith('/') ? filePath : '/' + filePath;
const hash = await sha1(content);
fileDigests[normalizedPath] = hash;
filePathsAndHashes[normalizedPath] = hash;
}
// Create a new deploy with digests
const deployResponse = await fetch(`https://api.netlify.com/api/v1/sites/${targetSiteId}/deploys`, {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
files: fileDigests,
async: true,
skip_processing: false,
draft: false,
function_schedules: [],
required: Object.keys(fileDigests),
framework: null,
}),
});
if (!deployResponse.ok) {
const errorText = await deployResponse.text();
logger.error(`创建部署失败: ${deployResponse.status} - ${errorText}`);
return errorResponse(400, `Failed to create deployment: ${errorText}`);
}
const deploy = (await deployResponse.json()) as any;
let retryCount = 0;
const maxRetries = 60;
let deploymentStatus;
while (retryCount < maxRetries) {
const statusResponse = await fetch(`https://api.netlify.com/api/v1/sites/${targetSiteId}/deploys/${deploy.id}`, {
headers: {
Authorization: `Bearer ${token}`,
},
});
if (!statusResponse.ok) {
if (statusResponse.status === 401) {
return errorResponse(401, '链接已过期,请重新设置访问令牌。');
}
return errorResponse(400, '获取部署状态失败');
}
deploymentStatus = (await statusResponse.json()) as any;
if (deploymentStatus.state === 'ready' || deploymentStatus.state === 'uploaded') {
logger.info('部署完成,状态:', deploymentStatus.state);
break;
}
if (deploymentStatus.state === 'prepared' || deploymentStatus.state === 'uploaded') {
// Upload all files regardless of required array
for (const [filePath, content] of Object.entries(files)) {
const normalizedPath = filePath.startsWith('/') ? filePath : '/' + filePath;
logger.info(`准备上传文件: ${normalizedPath}, 是否二进制: ${isBinaryString(content)}`);
let uploadSuccess = false;
let uploadRetries = 0;
while (!uploadSuccess && uploadRetries < 3) {
try {
let uploadBody: string | Uint8Array | ArrayBuffer;
if (isBinaryString(content)) {
uploadBody = binaryStringToUint8Array(content);
} else {
uploadBody = content;
}
const uploadResponse = await fetch(
`https://api.netlify.com/api/v1/deploys/${deploy.id}/files${normalizedPath}`,
{
method: 'PUT',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/octet-stream',
},
body: uploadBody as BodyInit,
},
);
uploadSuccess = uploadResponse.ok;
if (!uploadSuccess) {
if (uploadResponse.status === 422) {
logger.warn(
`Upload failed for ${normalizedPath} (${uploadResponse.status}, But it may be uploaded successfully`,
);
uploadSuccess = true;
} else {
const errorText = await uploadResponse.text();
logger.error(`Upload failed for ${normalizedPath} (${uploadResponse.status}): ${errorText}`);
uploadRetries++;
await new Promise((resolve) => setTimeout(resolve, 2000));
}
} else {
logger.info(`Successfully uploaded ${normalizedPath}`);
}
} catch (error) {
logger.error('Upload error:', error);
uploadRetries++;
await new Promise((resolve) => setTimeout(resolve, 2000));
}
}
if (!uploadSuccess) {
return errorResponse(500, `上传文件失败: ${filePath}`);
}
}
break;
}
if (deploymentStatus.state === 'error') {
return errorResponse(500, deploymentStatus.error_message || 'Deploy preparation failed');
}
retryCount++;
await new Promise((resolve) => setTimeout(resolve, 1000));
}
if (retryCount >= maxRetries) {
return errorResponse(500, 'Deploy preparation timed out');
}
// 第二阶段:轮询直到部署完成
logger.info('文件上传完成,等待部署完成...');
retryCount = 0;
const maxDeploymentRetries = 60; // 60秒超时
while (retryCount < maxDeploymentRetries) {
const statusResponse = await fetch(`https://api.netlify.com/api/v1/sites/${targetSiteId}/deploys/${deploy.id}`, {
headers: {
Authorization: `Bearer ${token}`,
},
});
deploymentStatus = (await statusResponse.json()) as any;
if (deploymentStatus.state === 'ready' || deploymentStatus.state === 'uploaded') {
logger.info('部署完成,状态:', deploymentStatus.state);
break;
}
if (deploymentStatus.state === 'error') {
return errorResponse(500, deploymentStatus.error_message || 'Deployment failed');
}
retryCount++;
await new Promise((resolve) => setTimeout(resolve, 1000));
}
if (retryCount >= maxDeploymentRetries) {
return errorResponse(500, 'Deployment timed out');
}
try {
await createOrUpdateDeployment({
userId,
chatId,
platform: DeploymentPlatformEnum.NETLIFY,
deploymentId: deploymentStatus.id,
url: deploymentStatus.ssl_url || deploymentStatus.url,
status: DeploymentStatusEnum.SUCCESS,
metadata: {
siteId: siteInfo.id,
siteName: siteInfo.name,
},
});
logger.info(`为用户 ${userId} 创建或更新了 Netlify 部署记录`);
} catch (error) {
logger.error('创建部署记录失败:', error);
}
return successResponse(
{
deploy: {
id: deploymentStatus.id,
state: deploymentStatus.state,
url: deploymentStatus.ssl_url || deploymentStatus.url,
},
site: siteInfo,
},
'部署成功',
);
} catch (error) {
logger.error('Deploy error:', error);
return errorResponse(500, 'Deployment failed');
}
}

View File

@@ -0,0 +1,60 @@
import { type ActionFunctionArgs, type LoaderFunctionArgs } from '@remix-run/node';
import { requireAuth } from '~/lib/.server/auth';
import { createScopedLogger } from '~/lib/.server/logger';
import { errorResponse } from '~/utils/api-response';
import { handleNetlifyAuth } from './auth.server';
import { deletePage } from './delete.server';
import { handleDeploy } from './deploy.server';
import { getNetlifyStats } from './stats.server';
import { toggleAccess } from './toggle-access.server';
const logger = createScopedLogger('api.netlify.route');
export async function loader(args: LoaderFunctionArgs) {
const { request, params } = args;
const authResult = await requireAuth(request, { isApi: true });
if (authResult instanceof Response) {
return authResult;
}
const userId = authResult.userInfo?.sub;
if (!userId) {
return errorResponse(401, '用户未登录');
}
switch (params.action) {
case 'stats':
return getNetlifyStats({ userId });
default:
return errorResponse(404, `未知的 API 操作: ${params.action}`);
}
}
export async function action(args: ActionFunctionArgs) {
const { request, params } = args;
const authResult = await requireAuth(request, { isApi: true });
if (authResult instanceof Response) {
return authResult;
}
const userId = authResult.userInfo?.sub;
if (!userId) {
return errorResponse(401, '用户未登录');
}
logger.debug('处理 Netlify API 请求', { action: params.action });
switch (params.action) {
case 'deploy':
return handleDeploy({ ...args, userId });
case 'auth':
return handleNetlifyAuth({ request, userId });
case 'toggle-access':
return toggleAccess({ ...args, userId });
case 'delete':
return deletePage({ ...args, userId });
default:
logger.warn('未知的 API 操作', { action: params.action });
return errorResponse(404, `未知的 API 操作: ${params.action}`);
}
}

View File

@@ -0,0 +1,80 @@
import { getNetlifyConnectionSettings } from '~/lib/.server/connectionSettings';
import { errorResponse, successResponse } from '~/utils/api-response';
import { createScopedLogger } from '~/utils/logger';
const logger = createScopedLogger('api.netlify.stats');
export type GetNetlifyStatsArgs = {
userId: string;
};
export async function getNetlifyStats({ userId }: GetNetlifyStatsArgs) {
try {
const connectionSettings = await getNetlifyConnectionSettings(userId);
if (!connectionSettings) {
return errorResponse(401, '未连接到Netlify请先设置访问令牌');
}
const { token } = connectionSettings;
const sitesResponse = await fetch('https://api.netlify.com/api/v1/sites', {
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
});
if (!sitesResponse.ok) {
return errorResponse(sitesResponse.status, '获取站点列表失败');
}
const sitesData = await sitesResponse.json();
let deploysData = [];
let buildsData = [];
let lastDeployTime = '';
if (sitesData && sitesData.length > 0) {
const firstSite = sitesData[0];
const deploysResponse = await fetch(`https://api.netlify.com/api/v1/sites/${firstSite.id}/deploys`, {
headers: {
Authorization: `Bearer ${token}`,
},
});
if (deploysResponse.ok) {
deploysData = await deploysResponse.json();
if (deploysData.length > 0) {
lastDeployTime = deploysData[0].created_at;
const buildsResponse = await fetch(`https://api.netlify.com/api/v1/sites/${firstSite.id}/builds`, {
headers: {
Authorization: `Bearer ${token}`,
},
});
if (buildsResponse.ok) {
buildsData = await buildsResponse.json();
}
}
}
}
return successResponse(
{
sites: sitesData,
deploys: deploysData,
builds: buildsData,
lastDeployTime,
totalSites: sitesData.length,
},
'获取 Netlify 站点统计信息成功',
);
} catch (error) {
logger.error('获取 Netlify 站点统计信息失败:', error);
return errorResponse(500, '获取 Netlify 站点统计信息失败');
}
}

View File

@@ -0,0 +1,89 @@
import { getNetlifyConnectionSettings } from '~/lib/.server/connectionSettings';
import { getDeploymentById, updateDeploymentStatus } from '~/lib/.server/deployment';
import { request } from '~/lib/fetch';
import type { NetlifySite } from '~/types/netlify';
import { errorResponse, successResponse } from '~/utils/api-response';
import { createScopedLogger } from '~/utils/logger';
import { generateUUID } from '~/utils/uuid';
const logger = createScopedLogger('api.netlify.toggle-access');
export type ToggleAccessArgs = {
userId: string;
request: Request;
};
export async function setNetlifySiteName(token: string, siteId: string, name: string): Promise<NetlifySite> {
try {
const response = await request(`https://api.netlify.com/api/v1/sites/${siteId}`, {
method: 'PATCH',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ name }),
});
if (!response.ok) {
const errorData = (await response.json()) as {
message: string;
};
throw new Error(errorData.message);
}
const result = (await response.json()) as NetlifySite;
return result;
} catch (error) {
logger.error(`设置 Netlify 站点 ${siteId} 名称失败:`, error);
throw new Error(`${error}`);
}
}
export async function toggleAccess({ userId, request }: ToggleAccessArgs) {
const { id } = await request.json();
try {
const deployment = await getDeploymentById(id);
if (!deployment) {
return errorResponse(404, '未找到部署记录');
}
// 获取Netlify连接设置
const connectionSettings = await getNetlifyConnectionSettings(userId);
if (!connectionSettings) {
return errorResponse(401, '未连接到Netlify');
}
const metadata = deployment.metadata as Record<string, any> | null;
const siteId = metadata?.siteId;
if (!siteId) {
return errorResponse(400, '部署记录缺少必要信息');
}
let siteName = metadata?.siteName;
// 获取当前状态
const currentStatus = deployment.status;
const newStatus = currentStatus === 'inactive' ? 'success' : 'inactive';
if (newStatus === 'inactive') {
// 为站点设置一个其他的别名,格式为 upage-inactive-${uuid}
siteName = `upage-inactive-${generateUUID()}`;
} else {
if (!siteName) {
const url = new URL(deployment.url);
siteName = url.hostname.split('.')[0];
}
}
// 设置 name 为当前的 siteName
await setNetlifySiteName(connectionSettings.token, siteId, siteName);
// 更新状态
await updateDeploymentStatus(id, newStatus);
logger.info(`用户 ${userId}${newStatus === 'success' ? '开启' : '停止'} Netlify 站点 ${siteId} 的访问`);
return successResponse(id, `${newStatus === 'success' ? '开启' : '停止'}访问`);
} catch (error) {
logger.error(`切换Netlify部署 ${id} 访问状态失败:`, error);
return errorResponse(500, error instanceof Error ? error.message : '操作失败');
}
}

View File

@@ -0,0 +1,67 @@
import { type ActionFunctionArgs } from '@remix-run/node';
import { requireAuth } from '~/lib/.server/auth';
import { getNetlifyConnectionSettings } from '~/lib/.server/connectionSettings';
import { errorResponse, successResponse } from '~/utils/api-response';
import { createScopedLogger } from '~/utils/logger';
const logger = createScopedLogger('api.netlify.deploys');
export async function action({ request, params }: ActionFunctionArgs) {
const authResult = await requireAuth(request, { isApi: true });
if (authResult instanceof Response) {
return authResult;
}
const userId = authResult.userInfo?.sub;
if (!userId) {
return errorResponse(401, '用户未登录');
}
const { deployId, action } = params;
if (!deployId) {
return errorResponse(400, '缺少部署ID');
}
if (!action || !['lock', 'unlock', 'publish'].includes(action)) {
return errorResponse(400, '无效的操作');
}
try {
const connectionSettings = await getNetlifyConnectionSettings(userId);
if (!connectionSettings) {
return errorResponse(401, '未连接到Netlify请先设置访问令牌');
}
const { token } = connectionSettings;
// 获取请求体中的 siteId
const { siteId } = await request.json();
const endpoint =
action === 'publish'
? `https://api.netlify.com/api/v1/sites/${siteId}/deploys/${deployId}/restore`
: `https://api.netlify.com/api/v1/deploys/${deployId}/${action}`;
const response = await fetch(endpoint, {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
},
});
if (!response.ok) {
const errorText = await response.text();
logger.error(`部署操作失败: ${response.status} ${errorText}`);
return errorResponse(response.status, `部署${action}操作失败`);
}
const responseData = await response.json();
logger.info(`用户 ${userId} 成功对部署 ${deployId} 执行了 ${action} 操作`);
return successResponse(responseData, `部署${action}操作成功`);
} catch (error) {
logger.error(`部署${params.action}操作失败:`, error);
return errorResponse(500, `部署${params.action}操作失败`);
}
}

View File

@@ -0,0 +1,53 @@
import { type ActionFunctionArgs } from '@remix-run/node';
import { requireAuth } from '~/lib/.server/auth';
import { getNetlifyConnectionSettings } from '~/lib/.server/connectionSettings';
import { errorResponse, successResponse } from '~/utils/api-response';
import { createScopedLogger } from '~/utils/logger';
const logger = createScopedLogger('api.netlify.sites.cache');
export async function action({ request, params }: ActionFunctionArgs) {
const authResult = await requireAuth(request, { isApi: true });
if (authResult instanceof Response) {
return authResult;
}
const userId = authResult.userInfo?.sub;
if (!userId) {
return errorResponse(401, '用户未登录');
}
const { siteId } = params;
if (!siteId) {
return errorResponse(400, '缺少站点ID');
}
try {
const connectionSettings = await getNetlifyConnectionSettings(userId);
if (!connectionSettings) {
return errorResponse(401, '未连接到Netlify请先设置访问令牌');
}
const { token } = connectionSettings;
const response = await fetch(`https://api.netlify.com/api/v1/sites/${siteId}/cache`, {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
},
});
if (!response.ok) {
const errorText = await response.text();
logger.error(`清除站点缓存失败: ${response.status} ${errorText}`);
return errorResponse(response.status, '清除站点缓存失败');
}
logger.info(`用户 ${userId} 成功清除了站点 ${siteId} 的缓存`);
return successResponse({}, '站点缓存清除成功');
} catch (error) {
logger.error('清除站点缓存失败:', error);
return errorResponse(500, '清除站点缓存失败');
}
}

View File

@@ -0,0 +1,60 @@
import { type ActionFunctionArgs } from '@remix-run/node';
import { requireAuth } from '~/lib/.server/auth';
import { getNetlifyConnectionSettings } from '~/lib/.server/connectionSettings';
import { deleteDeploymentsByPlatformAndId } from '~/lib/.server/deployment';
import { DeploymentPlatformEnum } from '~/types/deployment';
import { errorResponse, successResponse } from '~/utils/api-response';
import { createScopedLogger } from '~/utils/logger';
const logger = createScopedLogger('api.netlify.sites');
export async function action({ request, params }: ActionFunctionArgs) {
if (request.method !== 'DELETE') {
return errorResponse(405, '方法不允许');
}
const authResult = await requireAuth(request, { isApi: true });
if (authResult instanceof Response) {
return authResult;
}
const userId = authResult.userInfo?.sub;
if (!userId) {
return errorResponse(401, '用户未登录');
}
const { siteId } = params;
if (!siteId) {
return errorResponse(400, '缺少站点 ID');
}
try {
const connectionSettings = await getNetlifyConnectionSettings(userId);
if (!connectionSettings) {
return errorResponse(401, '未连接到 Netlify请先设置访问令牌');
}
const { token } = connectionSettings;
const response = await fetch(`https://api.netlify.com/api/v1/sites/${siteId}`, {
method: 'DELETE',
headers: {
Authorization: `Bearer ${token}`,
},
});
if (!response.ok) {
const errorText = await response.text();
logger.error(`删除站点失败: ${response.status} ${errorText}`);
return errorResponse(response.status, '删除站点失败');
}
await deleteDeploymentsByPlatformAndId(DeploymentPlatformEnum.NETLIFY, siteId);
logger.info(`用户 ${userId} 成功删除了站点 ${siteId}`);
return successResponse({}, '站点删除成功');
} catch (error) {
logger.error('删除站点失败:', error);
return errorResponse(500, '删除站点失败');
}
}

View File

@@ -0,0 +1,67 @@
import { type ActionFunctionArgs } from '@remix-run/node';
import { createScopedLogger } from '~/lib/.server/logger';
import type { PageCreateParams } from '~/lib/.server/page';
import { savePagesAndSections } from '~/lib/.server/projectService';
import type { SectionCreateParams } from '~/lib/.server/section';
import { errorResponse, successResponse } from '~/utils/api-response';
const logger = createScopedLogger('api.project');
export async function action({ request }: ActionFunctionArgs) {
try {
if (request.method !== 'POST') {
return errorResponse(405, '不支持的请求方法');
}
const formData = await request.formData();
const messageId = formData.get('messageId')?.toString();
const pagesStr = formData.get('pages')?.toString();
const sectionsStr = formData.get('sections')?.toString();
if (!messageId) {
return errorResponse(400, '消息 ID 不能为空');
}
if (!pagesStr) {
return errorResponse(400, 'pages 数据不能为空');
}
if (!sectionsStr) {
return errorResponse(400, 'sections 不能为空');
}
let pages: PageCreateParams[];
let sections: SectionCreateParams[];
try {
pages = JSON.parse(pagesStr);
pages = pages.map((page) => ({
...page,
messageId,
})) as PageCreateParams[];
} catch (e) {
logger.error('项目数据解析失败', e);
return errorResponse(400, '项目数据格式无效');
}
try {
sections = JSON.parse(sectionsStr);
sections = sections.map((section) => ({
...section,
messageId,
})) as SectionCreateParams[];
} catch (e) {
logger.error('sections数据解析失败', e);
return errorResponse(400, 'sections数据格式无效');
}
const result = await savePagesAndSections({
messageId,
pages,
sections,
});
return successResponse(result, '项目保存成功');
} catch (error) {
logger.error('处理项目保存请求失败:', error);
return errorResponse(500, '服务器处理请求失败');
}
}

View File

@@ -0,0 +1,134 @@
import type { ActionFunctionArgs, LoaderFunction } from '@remix-run/node';
import { json } from '@remix-run/node';
import { createScopedLogger } from '~/lib/.server/logger';
// These are injected by Vite at build time
declare const __PKG_NAME: string;
declare const __PKG_DESCRIPTION: string;
declare const __PKG_LICENSE: string;
declare const __PKG_DEPENDENCIES: Record<string, string>;
declare const __PKG_DEV_DEPENDENCIES: Record<string, string>;
declare const __PKG_PEER_DEPENDENCIES: Record<string, string>;
declare const __PKG_OPTIONAL_DEPENDENCIES: Record<string, string>;
declare const __GIT_BRANCH: string;
declare const __GIT_COMMIT_TIME: string;
declare const __GIT_AUTHOR: string;
declare const __GIT_EMAIL: string;
declare const __GIT_REMOTE_URL: string;
declare const __GIT_REPO_NAME: string;
const logger = createScopedLogger('api.system.app-info');
const getGitInfo = () => {
return {
branch: __GIT_BRANCH || 'unknown',
commitTime: __GIT_COMMIT_TIME || 'unknown',
author: __GIT_AUTHOR || 'unknown',
email: __GIT_EMAIL || 'unknown',
remoteUrl: __GIT_REMOTE_URL || 'unknown',
repoName: __GIT_REPO_NAME || 'unknown',
};
};
const formatDependencies = (
deps: Record<string, string>,
type: 'production' | 'development' | 'peer' | 'optional',
): Array<{ name: string; version: string; type: string }> => {
return Object.entries(deps || {}).map(([name, version]) => ({
name,
version: version.replace(/^\^|~/, ''),
type,
}));
};
const getAppResponse = () => {
const gitInfo = getGitInfo();
return {
name: __PKG_NAME || 'upage',
description: __PKG_DESCRIPTION || '使用人工智能构建可视化网页',
license: __PKG_LICENSE || 'MIT',
environment: 'cloudflare',
gitInfo,
timestamp: new Date().toISOString(),
runtimeInfo: {
nodeVersion: 'cloudflare',
},
dependencies: {
production: formatDependencies(__PKG_DEPENDENCIES, 'production'),
development: formatDependencies(__PKG_DEV_DEPENDENCIES, 'development'),
peer: formatDependencies(__PKG_PEER_DEPENDENCIES, 'peer'),
optional: formatDependencies(__PKG_OPTIONAL_DEPENDENCIES, 'optional'),
},
};
};
export const appInfoLoader: LoaderFunction = async ({ request: _request }) => {
try {
return json(getAppResponse());
} catch (error) {
logger.error('Failed to get webapp info:', error);
return json(
{
name: 'upage',
version: '0.0.0',
description: 'Error fetching app info',
license: 'MIT',
environment: 'error',
gitInfo: {
commitHash: 'error',
branch: 'unknown',
commitTime: 'unknown',
author: 'unknown',
email: 'unknown',
remoteUrl: 'unknown',
repoName: 'unknown',
},
timestamp: new Date().toISOString(),
runtimeInfo: { nodeVersion: 'unknown' },
dependencies: {
production: [],
development: [],
peer: [],
optional: [],
},
},
{ status: 500 },
);
}
};
export const appInfoAction = async ({ request: _request }: ActionFunctionArgs) => {
try {
return json(getAppResponse());
} catch (error) {
logger.error('Failed to get webapp info:', error);
return json(
{
name: 'upage',
version: '0.0.0',
description: 'Error fetching app info',
license: 'MIT',
environment: 'error',
gitInfo: {
commitHash: 'error',
branch: 'unknown',
commitTime: 'unknown',
author: 'unknown',
email: 'unknown',
remoteUrl: 'unknown',
repoName: 'unknown',
},
timestamp: new Date().toISOString(),
runtimeInfo: { nodeVersion: 'unknown' },
dependencies: {
production: [],
development: [],
peer: [],
optional: [],
},
},
{ status: 500 },
);
}
};

View File

@@ -0,0 +1,314 @@
import type { ActionFunctionArgs, LoaderFunction } from '@remix-run/node';
import { json } from '@remix-run/node';
import { createScopedLogger } from '~/lib/.server/logger';
// Only import child_process if we're not in a Cloudflare environment
let execSync: any;
const logger = createScopedLogger('api.system.disk-info');
try {
// Check if we're in a Node.js environment
if (typeof process !== 'undefined' && process.platform) {
// Using dynamic import to avoid require()
const childProcess = { execSync: null };
execSync = childProcess.execSync;
}
} catch {
// In Cloudflare environment, this will fail, which is expected
logger.debug('Running in Cloudflare environment, child_process not available');
}
// For development environments, we'll always provide mock data if real data isn't available
const isDevelopment = process.env.NODE_ENV === 'development';
interface DiskInfo {
filesystem: string;
size: number;
used: number;
available: number;
percentage: number;
mountpoint: string;
timestamp: string;
error?: string;
}
const getDiskInfo = (): DiskInfo[] => {
// If we're in a Cloudflare environment and not in development, return error
if (!execSync && !isDevelopment) {
return [
{
filesystem: 'N/A',
size: 0,
used: 0,
available: 0,
percentage: 0,
mountpoint: 'N/A',
timestamp: new Date().toISOString(),
error: 'Disk information is not available in this environment',
},
];
}
// If we're in development but not in Node environment, return mock data
if (!execSync && isDevelopment) {
// Generate random percentage between 40-60%
const percentage = Math.floor(40 + Math.random() * 20);
const totalSize = 500 * 1024 * 1024 * 1024; // 500GB
const usedSize = Math.floor((totalSize * percentage) / 100);
const availableSize = totalSize - usedSize;
return [
{
filesystem: 'MockDisk',
size: totalSize,
used: usedSize,
available: availableSize,
percentage,
mountpoint: '/',
timestamp: new Date().toISOString(),
},
{
filesystem: 'MockDisk2',
size: 1024 * 1024 * 1024 * 1024, // 1TB
used: 300 * 1024 * 1024 * 1024, // 300GB
available: 724 * 1024 * 1024 * 1024, // 724GB
percentage: 30,
mountpoint: '/data',
timestamp: new Date().toISOString(),
},
];
}
try {
// Different commands for different operating systems
const platform = process.platform;
let disks: DiskInfo[] = [];
if (platform === 'darwin') {
// macOS - use df command to get disk information
try {
const output = execSync('df -k', { encoding: 'utf-8' }).toString().trim();
// Skip the header line
const lines = output.split('\n').slice(1);
disks = lines.map((line: string) => {
const parts = line.trim().split(/\s+/);
const filesystem = parts[0];
const size = parseInt(parts[1], 10) * 1024; // Convert KB to bytes
const used = parseInt(parts[2], 10) * 1024;
const available = parseInt(parts[3], 10) * 1024;
const percentageStr = parts[4].replace('%', '');
const percentage = parseInt(percentageStr, 10);
const mountpoint = parts[5];
return {
filesystem,
size,
used,
available,
percentage,
mountpoint,
timestamp: new Date().toISOString(),
};
});
// Filter out non-physical disks
disks = disks.filter(
(disk) =>
!disk.filesystem.startsWith('devfs') &&
!disk.filesystem.startsWith('map') &&
!disk.mountpoint.startsWith('/System/Volumes') &&
disk.size > 0,
);
} catch (error) {
logger.error('Failed to get macOS disk info:', error);
return [
{
filesystem: 'Unknown',
size: 0,
used: 0,
available: 0,
percentage: 0,
mountpoint: '/',
timestamp: new Date().toISOString(),
error: error instanceof Error ? error.message : 'Unknown error',
},
];
}
} else if (platform === 'linux') {
// Linux - use df command to get disk information
try {
const output = execSync('df -k', { encoding: 'utf-8' }).toString().trim();
// Skip the header line
const lines = output.split('\n').slice(1);
disks = lines.map((line: string) => {
const parts = line.trim().split(/\s+/);
const filesystem = parts[0];
const size = parseInt(parts[1], 10) * 1024; // Convert KB to bytes
const used = parseInt(parts[2], 10) * 1024;
const available = parseInt(parts[3], 10) * 1024;
const percentageStr = parts[4].replace('%', '');
const percentage = parseInt(percentageStr, 10);
const mountpoint = parts[5];
return {
filesystem,
size,
used,
available,
percentage,
mountpoint,
timestamp: new Date().toISOString(),
};
});
// Filter out non-physical disks
disks = disks.filter(
(disk) =>
!disk.filesystem.startsWith('/dev/loop') &&
!disk.filesystem.startsWith('tmpfs') &&
!disk.filesystem.startsWith('devtmpfs') &&
disk.size > 0,
);
} catch (error) {
logger.error('Failed to get Linux disk info:', error);
return [
{
filesystem: 'Unknown',
size: 0,
used: 0,
available: 0,
percentage: 0,
mountpoint: '/',
timestamp: new Date().toISOString(),
error: error instanceof Error ? error.message : 'Unknown error',
},
];
}
} else if (platform === 'win32') {
// Windows - use PowerShell to get disk information
try {
const output = execSync(
'powershell "Get-PSDrive -PSProvider FileSystem | Select-Object Name, Used, Free, @{Name=\'Size\';Expression={$_.Used + $_.Free}} | ConvertTo-Json"',
{ encoding: 'utf-8' },
)
.toString()
.trim();
const driveData = JSON.parse(output);
const drivesArray = Array.isArray(driveData) ? driveData : [driveData];
disks = drivesArray.map((drive) => {
const size = drive.Size || 0;
const used = drive.Used || 0;
const available = drive.Free || 0;
const percentage = size > 0 ? Math.round((used / size) * 100) : 0;
return {
filesystem: drive.Name + ':\\',
size,
used,
available,
percentage,
mountpoint: drive.Name + ':\\',
timestamp: new Date().toISOString(),
};
});
} catch (error) {
logger.error('Failed to get Windows disk info:', error);
return [
{
filesystem: 'Unknown',
size: 0,
used: 0,
available: 0,
percentage: 0,
mountpoint: 'C:\\',
timestamp: new Date().toISOString(),
error: error instanceof Error ? error.message : 'Unknown error',
},
];
}
} else {
logger.warn(`Unsupported platform: ${platform}`);
return [
{
filesystem: 'Unknown',
size: 0,
used: 0,
available: 0,
percentage: 0,
mountpoint: '/',
timestamp: new Date().toISOString(),
error: `Unsupported platform: ${platform}`,
},
];
}
return disks;
} catch (error) {
logger.error('Failed to get disk info:', error);
return [
{
filesystem: 'Unknown',
size: 0,
used: 0,
available: 0,
percentage: 0,
mountpoint: '/',
timestamp: new Date().toISOString(),
error: error instanceof Error ? error.message : 'Unknown error',
},
];
}
};
export const diskLoader: LoaderFunction = async ({ request: _request }) => {
try {
return json(getDiskInfo());
} catch (error) {
logger.error('Failed to get disk info:', error);
return json(
[
{
filesystem: 'Unknown',
size: 0,
used: 0,
available: 0,
percentage: 0,
mountpoint: '/',
timestamp: new Date().toISOString(),
error: error instanceof Error ? error.message : 'Unknown error',
},
],
{ status: 500 },
);
}
};
export const diskAction = async ({ request: _request }: ActionFunctionArgs) => {
try {
return json(getDiskInfo());
} catch (error) {
logger.error('Failed to get disk info:', error);
return json(
[
{
filesystem: 'Unknown',
size: 0,
used: 0,
available: 0,
percentage: 0,
mountpoint: '/',
timestamp: new Date().toISOString(),
error: error instanceof Error ? error.message : 'Unknown error',
},
],
{ status: 500 },
);
}
};

View File

@@ -0,0 +1,335 @@
import { json, type LoaderFunction, type LoaderFunctionArgs } from '@remix-run/node';
import { createScopedLogger } from '~/lib/.server/logger';
interface GitInfo {
local: {
branch: string;
commitTime: string;
author: string;
email: string;
remoteUrl: string;
repoName: string;
};
github?: {
currentRepo?: {
fullName: string;
defaultBranch: string;
stars: number;
forks: number;
openIssues?: number;
};
};
isForked?: boolean;
timestamp?: string;
}
// Define context type
interface AppContext {
env?: {
GITHUB_ACCESS_TOKEN?: string;
};
}
interface GitHubRepo {
name: string;
full_name: string;
html_url: string;
description: string;
stargazers_count: number;
forks_count: number;
language: string | null;
languages_url: string;
}
interface GitHubGist {
id: string;
html_url: string;
description: string;
}
// These values will be replaced at build time
declare const __GIT_BRANCH: string;
declare const __GIT_COMMIT_TIME: string;
declare const __GIT_AUTHOR: string;
declare const __GIT_EMAIL: string;
declare const __GIT_REMOTE_URL: string;
declare const __GIT_REPO_NAME: string;
/*
* Remove unused variable to fix linter error
* declare const __GIT_REPO_URL: string;
*/
const logger = createScopedLogger('api.system.git-info');
export const gitInfoLoader: LoaderFunction = async ({
request,
context,
}: LoaderFunctionArgs & { context: AppContext }) => {
logger.debug('Git info API called with URL:', request.url);
// Handle CORS preflight requests
if (request.method === 'OPTIONS') {
return new Response(null, {
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
},
});
}
const { searchParams } = new URL(request.url);
const action = searchParams.get('action');
logger.debug('Git info action:', action);
if (action === 'getUser' || action === 'getRepos' || action === 'getOrgs' || action === 'getActivity') {
// Use server-side token instead of client-side token
const serverGithubToken = process.env.GITHUB_ACCESS_TOKEN || context.env?.GITHUB_ACCESS_TOKEN;
const cookieToken = request.headers
.get('Cookie')
?.split(';')
.find((cookie) => cookie.trim().startsWith('githubToken='))
?.split('=')[1];
// Also check for token in Authorization header
const authHeader = request.headers.get('Authorization');
const headerToken = authHeader?.startsWith('Bearer ') ? authHeader.substring(7) : null;
const token = serverGithubToken || headerToken || cookieToken;
logger.debug(
'Using GitHub token from:',
serverGithubToken ? 'server env' : headerToken ? 'auth header' : cookieToken ? 'cookie' : 'none',
);
if (!token) {
logger.error('No GitHub token available');
return json(
{ error: 'No GitHub token available' },
{
status: 401,
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
},
},
);
}
try {
if (action === 'getUser') {
const response = await fetch('https://api.github.com/user', {
headers: {
Accept: 'application/vnd.github.v3+json',
Authorization: `Bearer ${token}`,
},
});
if (!response.ok) {
logger.error('GitHub user API error:', response.status);
throw new Error(`GitHub API error: ${response.status}`);
}
const userData = await response.json();
return json(
{ user: userData },
{
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
},
},
);
}
if (action === 'getRepos') {
const reposResponse = await fetch('https://api.github.com/user/repos?per_page=100&sort=updated', {
headers: {
Accept: 'application/vnd.github.v3+json',
Authorization: `Bearer ${token}`,
},
});
if (!reposResponse.ok) {
logger.error('GitHub repos API error:', reposResponse.status);
throw new Error(`GitHub API error: ${reposResponse.status}`);
}
const repos = (await reposResponse.json()) as GitHubRepo[];
// Get user's gists
const gistsResponse = await fetch('https://api.github.com/gists', {
headers: {
Accept: 'application/vnd.github.v3+json',
Authorization: `Bearer ${token}`,
},
});
const gists = gistsResponse.ok ? ((await gistsResponse.json()) as GitHubGist[]) : [];
// Calculate language statistics
const languageStats: Record<string, number> = {};
let totalStars = 0;
let totalForks = 0;
for (const repo of repos) {
totalStars += repo.stargazers_count || 0;
totalForks += repo.forks_count || 0;
if (repo.language && repo.language !== 'null') {
languageStats[repo.language] = (languageStats[repo.language] || 0) + 1;
}
/*
* Optionally fetch languages for each repo for more accurate stats
* This is commented out to avoid rate limiting
*
* if (repo.languages_url) {
* try {
* const langResponse = await fetch(repo.languages_url, {
* headers: {
* Accept: 'application/vnd.github.v3+json',
* Authorization: `Bearer ${token}`,
* },
* });
*
* if (langResponse.ok) {
* const languages = await langResponse.json();
* Object.keys(languages).forEach(lang => {
* languageStats[lang] = (languageStats[lang] || 0) + languages[lang];
* });
* }
* } catch (error) {
* logger.error(`Error fetching languages for ${repo.name}:`, error);
* }
* }
*/
}
return json(
{
repos,
stats: {
totalStars,
totalForks,
languages: languageStats,
totalGists: gists.length,
},
},
{
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
},
},
);
}
if (action === 'getOrgs') {
const response = await fetch('https://api.github.com/user/orgs', {
headers: {
Accept: 'application/vnd.github.v3+json',
Authorization: `Bearer ${token}`,
},
});
if (!response.ok) {
logger.error('GitHub orgs API error:', response.status);
throw new Error(`GitHub API error: ${response.status}`);
}
const orgs = await response.json();
return json(
{ organizations: orgs },
{
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
},
},
);
}
if (action === 'getActivity') {
const username = request.headers
.get('Cookie')
?.split(';')
.find((cookie) => cookie.trim().startsWith('githubUsername='))
?.split('=')[1];
if (!username) {
logger.error('GitHub username not found in cookies');
return json(
{ error: 'GitHub username not found in cookies' },
{
status: 400,
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
},
},
);
}
const response = await fetch(`https://api.github.com/users/${username}/events?per_page=30`, {
headers: {
Accept: 'application/vnd.github.v3+json',
Authorization: `Bearer ${token}`,
},
});
if (!response.ok) {
logger.error('GitHub activity API error:', response.status);
throw new Error(`GitHub API error: ${response.status}`);
}
const events = await response.json();
return json(
{ recentActivity: events },
{
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
},
},
);
}
} catch (error) {
logger.error('GitHub API error:', error);
return json(
{ error: error instanceof Error ? error.message : 'Unknown error' },
{
status: 500,
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
},
},
);
}
}
const gitInfo: GitInfo = {
local: {
branch: typeof __GIT_BRANCH !== 'undefined' ? __GIT_BRANCH : 'main',
commitTime: typeof __GIT_COMMIT_TIME !== 'undefined' ? __GIT_COMMIT_TIME : new Date().toISOString(),
author: typeof __GIT_AUTHOR !== 'undefined' ? __GIT_AUTHOR : 'development',
email: typeof __GIT_EMAIL !== 'undefined' ? __GIT_EMAIL : 'development@local',
remoteUrl: typeof __GIT_REMOTE_URL !== 'undefined' ? __GIT_REMOTE_URL : 'local',
repoName: typeof __GIT_REPO_NAME !== 'undefined' ? __GIT_REPO_NAME : 'upage',
},
timestamp: new Date().toISOString(),
};
return json(gitInfo, {
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
},
});
};

View File

@@ -0,0 +1,283 @@
import type { ActionFunctionArgs, LoaderFunction } from '@remix-run/node';
import { json } from '@remix-run/node';
import { createScopedLogger } from '~/lib/.server/logger';
// Only import child_process if we're not in a Cloudflare environment
let execSync: any;
const logger = createScopedLogger('api.system.memory-info');
try {
// Check if we're in a Node.js environment
if (typeof process !== 'undefined' && process.platform) {
// Using dynamic import to avoid require()
const childProcess = { execSync: null };
execSync = childProcess.execSync;
}
} catch {
// In Cloudflare environment, this will fail, which is expected
logger.debug('Running in Cloudflare environment, child_process not available');
}
// For development environments, we'll always provide mock data if real data isn't available
const isDevelopment = process.env.NODE_ENV === 'development';
interface SystemMemoryInfo {
total: number;
free: number;
used: number;
percentage: number;
swap?: {
total: number;
free: number;
used: number;
percentage: number;
};
timestamp: string;
error?: string;
}
const getSystemMemoryInfo = (): SystemMemoryInfo => {
try {
// Check if we're in a Cloudflare environment and not in development
if (!execSync && !isDevelopment) {
// Return error for Cloudflare production environment
return {
total: 0,
free: 0,
used: 0,
percentage: 0,
timestamp: new Date().toISOString(),
error: 'System memory information is not available in this environment',
};
}
// If we're in development but not in Node environment, return mock data
if (!execSync && isDevelopment) {
// Return mock data for development
const mockTotal = 16 * 1024 * 1024 * 1024; // 16GB
const mockPercentage = Math.floor(30 + Math.random() * 20); // Random between 30-50%
const mockUsed = Math.floor((mockTotal * mockPercentage) / 100);
const mockFree = mockTotal - mockUsed;
return {
total: mockTotal,
free: mockFree,
used: mockUsed,
percentage: mockPercentage,
swap: {
total: 8 * 1024 * 1024 * 1024, // 8GB
free: 6 * 1024 * 1024 * 1024, // 6GB
used: 2 * 1024 * 1024 * 1024, // 2GB
percentage: 25,
},
timestamp: new Date().toISOString(),
};
}
// Different commands for different operating systems
let memInfo: { total: number; free: number; used: number; percentage: number; swap?: any } = {
total: 0,
free: 0,
used: 0,
percentage: 0,
};
// Check the operating system
const platform = process.platform;
if (platform === 'darwin') {
// macOS
const totalMemory = parseInt(execSync('sysctl -n hw.memsize').toString().trim(), 10);
// Get memory usage using vm_stat
const vmStat = execSync('vm_stat').toString().trim();
const pageSize = 4096; // Default page size on macOS
// Parse vm_stat output
const matches = {
free: /Pages free:\s+(\d+)/.exec(vmStat),
active: /Pages active:\s+(\d+)/.exec(vmStat),
inactive: /Pages inactive:\s+(\d+)/.exec(vmStat),
speculative: /Pages speculative:\s+(\d+)/.exec(vmStat),
wired: /Pages wired down:\s+(\d+)/.exec(vmStat),
compressed: /Pages occupied by compressor:\s+(\d+)/.exec(vmStat),
};
const freePages = parseInt(matches.free?.[1] || '0', 10);
const activePages = parseInt(matches.active?.[1] || '0', 10);
const inactivePages = parseInt(matches.inactive?.[1] || '0', 10);
// Speculative pages are not currently used in calculations, but kept for future reference
const wiredPages = parseInt(matches.wired?.[1] || '0', 10);
const compressedPages = parseInt(matches.compressed?.[1] || '0', 10);
const freeMemory = freePages * pageSize;
const usedMemory = (activePages + inactivePages + wiredPages + compressedPages) * pageSize;
memInfo = {
total: totalMemory,
free: freeMemory,
used: usedMemory,
percentage: Math.round((usedMemory / totalMemory) * 100),
};
// Get swap information
try {
const swapInfo = execSync('sysctl -n vm.swapusage').toString().trim();
const swapMatches = {
total: /total = (\d+\.\d+)M/.exec(swapInfo),
used: /used = (\d+\.\d+)M/.exec(swapInfo),
free: /free = (\d+\.\d+)M/.exec(swapInfo),
};
const swapTotal = parseFloat(swapMatches.total?.[1] || '0') * 1024 * 1024;
const swapUsed = parseFloat(swapMatches.used?.[1] || '0') * 1024 * 1024;
const swapFree = parseFloat(swapMatches.free?.[1] || '0') * 1024 * 1024;
memInfo.swap = {
total: swapTotal,
used: swapUsed,
free: swapFree,
percentage: swapTotal > 0 ? Math.round((swapUsed / swapTotal) * 100) : 0,
};
} catch (swapError) {
logger.error('Failed to get swap info:', swapError);
}
} else if (platform === 'linux') {
// Linux
const meminfo = execSync('cat /proc/meminfo').toString().trim();
const memTotal = parseInt(/MemTotal:\s+(\d+)/.exec(meminfo)?.[1] || '0', 10) * 1024;
// We use memAvailable instead of memFree for more accurate free memory calculation
const memAvailable = parseInt(/MemAvailable:\s+(\d+)/.exec(meminfo)?.[1] || '0', 10) * 1024;
/*
* Buffers and cached memory are included in the available memory calculation by the kernel
* so we don't need to calculate them separately
*/
const usedMemory = memTotal - memAvailable;
memInfo = {
total: memTotal,
free: memAvailable,
used: usedMemory,
percentage: Math.round((usedMemory / memTotal) * 100),
};
// Get swap information
const swapTotal = parseInt(/SwapTotal:\s+(\d+)/.exec(meminfo)?.[1] || '0', 10) * 1024;
const swapFree = parseInt(/SwapFree:\s+(\d+)/.exec(meminfo)?.[1] || '0', 10) * 1024;
const swapUsed = swapTotal - swapFree;
memInfo.swap = {
total: swapTotal,
free: swapFree,
used: swapUsed,
percentage: swapTotal > 0 ? Math.round((swapUsed / swapTotal) * 100) : 0,
};
} else if (platform === 'win32') {
/*
* Windows
* Using PowerShell to get memory information
*/
const memoryInfo = execSync(
'powershell "Get-CimInstance Win32_OperatingSystem | Select-Object TotalVisibleMemorySize, FreePhysicalMemory | ConvertTo-Json"',
)
.toString()
.trim();
const memData = JSON.parse(memoryInfo);
const totalMemory = parseInt(memData.TotalVisibleMemorySize, 10) * 1024;
const freeMemory = parseInt(memData.FreePhysicalMemory, 10) * 1024;
const usedMemory = totalMemory - freeMemory;
memInfo = {
total: totalMemory,
free: freeMemory,
used: usedMemory,
percentage: Math.round((usedMemory / totalMemory) * 100),
};
// Get swap (page file) information
try {
const swapInfo = execSync(
"powershell \"Get-CimInstance Win32_PageFileUsage | Measure-Object -Property CurrentUsage, AllocatedBaseSize -Sum | Select-Object @{Name='CurrentUsage';Expression={$_.Sum}}, @{Name='AllocatedBaseSize';Expression={$_.Sum}} | ConvertTo-Json\"",
)
.toString()
.trim();
const swapData = JSON.parse(swapInfo);
const swapTotal = parseInt(swapData.AllocatedBaseSize, 10) * 1024 * 1024;
const swapUsed = parseInt(swapData.CurrentUsage, 10) * 1024 * 1024;
const swapFree = swapTotal - swapUsed;
memInfo.swap = {
total: swapTotal,
free: swapFree,
used: swapUsed,
percentage: swapTotal > 0 ? Math.round((swapUsed / swapTotal) * 100) : 0,
};
} catch (swapError) {
logger.error('Failed to get swap info:', swapError);
}
} else {
throw new Error(`Unsupported platform: ${platform}`);
}
return {
...memInfo,
timestamp: new Date().toISOString(),
};
} catch (error) {
logger.error('Failed to get system memory info:', error);
return {
total: 0,
free: 0,
used: 0,
percentage: 0,
timestamp: new Date().toISOString(),
error: error instanceof Error ? error.message : 'Unknown error',
};
}
};
export const memoryLoader: LoaderFunction = async ({ request: _request }) => {
try {
return json(getSystemMemoryInfo());
} catch (error) {
logger.error('Failed to get system memory info:', error);
return json(
{
total: 0,
free: 0,
used: 0,
percentage: 0,
timestamp: new Date().toISOString(),
error: error instanceof Error ? error.message : 'Unknown error',
},
{ status: 500 },
);
}
};
export const memoryAction = async ({ request: _request }: ActionFunctionArgs) => {
try {
return json(getSystemMemoryInfo());
} catch (error) {
logger.error('Failed to get system memory info:', error);
return json(
{
total: 0,
free: 0,
used: 0,
percentage: 0,
timestamp: new Date().toISOString(),
error: error instanceof Error ? error.message : 'Unknown error',
},
{ status: 500 },
);
}
};

View File

@@ -0,0 +1,419 @@
import type { ActionFunctionArgs, LoaderFunction } from '@remix-run/node';
import { json } from '@remix-run/node';
import { createScopedLogger } from '~/lib/.server/logger';
// Only import child_process if we're not in a Cloudflare environment
let execSync: any;
const logger = createScopedLogger('api.system.process-info');
try {
// Check if we're in a Node.js environment
if (typeof process !== 'undefined' && process.platform) {
// Using dynamic import to avoid require()
const childProcess = { execSync: null };
execSync = childProcess.execSync;
}
} catch {
// In Cloudflare environment, this will fail, which is expected
logger.info('Running in Cloudflare environment, child_process not available');
}
// For development environments, we'll always provide mock data if real data isn't available
const isDevelopment = process.env.NODE_ENV === 'development';
interface ProcessInfo {
pid: number;
name: string;
cpu: number;
memory: number;
command?: string;
timestamp: string;
error?: string;
}
const getProcessInfo = (): ProcessInfo[] => {
try {
// If we're in a Cloudflare environment and not in development, return error
if (!execSync && !isDevelopment) {
return [
{
pid: 0,
name: 'N/A',
cpu: 0,
memory: 0,
timestamp: new Date().toISOString(),
error: 'Process information is not available in this environment',
},
];
}
// If we're in development but not in Node environment, return mock data
if (!execSync && isDevelopment) {
return getMockProcessInfo();
}
// Different commands for different operating systems
const platform = process.platform;
let processes: ProcessInfo[] = [];
// Get CPU count for normalizing CPU percentages
let cpuCount = 1;
try {
if (platform === 'darwin') {
const cpuInfo = execSync('sysctl -n hw.ncpu', { encoding: 'utf-8' }).toString().trim();
cpuCount = parseInt(cpuInfo, 10) || 1;
} else if (platform === 'linux') {
const cpuInfo = execSync('nproc', { encoding: 'utf-8' }).toString().trim();
cpuCount = parseInt(cpuInfo, 10) || 1;
} else if (platform === 'win32') {
const cpuInfo = execSync('wmic cpu get NumberOfCores', { encoding: 'utf-8' }).toString().trim();
const match = cpuInfo.match(/\d+/);
cpuCount = match ? parseInt(match[0], 10) : 1;
}
} catch (error) {
logger.error('Failed to get CPU count:', error);
// Default to 1 if we can't get the count
cpuCount = 1;
}
if (platform === 'darwin') {
// macOS - use ps command to get process information
try {
const output = execSync('ps -eo pid,pcpu,pmem,comm -r | head -n 11', { encoding: 'utf-8' }).toString().trim();
// Skip the header line
const lines = output.split('\n').slice(1);
processes = lines.map((line: string) => {
const parts = line.trim().split(/\s+/);
const pid = parseInt(parts[0], 10);
/*
* Normalize CPU percentage by dividing by CPU count
* This converts from "% of all CPUs" to "% of one CPU"
*/
const cpu = parseFloat(parts[1]) / cpuCount;
const memory = parseFloat(parts[2]);
const command = parts.slice(3).join(' ');
return {
pid,
name: command.split('/').pop() || command,
cpu,
memory,
command,
timestamp: new Date().toISOString(),
};
});
} catch (error) {
logger.error('Failed to get macOS process info:', error);
// Try alternative command
try {
const output = execSync('top -l 1 -stats pid,cpu,mem,command -n 10', { encoding: 'utf-8' }).toString().trim();
// Parse top output - skip the first few lines of header
const lines = output.split('\n').slice(6);
processes = lines.map((line: string) => {
const parts = line.trim().split(/\s+/);
const pid = parseInt(parts[0], 10);
const cpu = parseFloat(parts[1]);
const memory = parseFloat(parts[2]);
const command = parts.slice(3).join(' ');
return {
pid,
name: command.split('/').pop() || command,
cpu,
memory,
command,
timestamp: new Date().toISOString(),
};
});
} catch (fallbackError) {
logger.error('Failed to get macOS process info with fallback:', fallbackError);
return [
{
pid: 0,
name: 'N/A',
cpu: 0,
memory: 0,
timestamp: new Date().toISOString(),
error: 'Process information is not available in this environment',
},
];
}
}
} else if (platform === 'linux') {
// Linux - use ps command to get process information
try {
const output = execSync('ps -eo pid,pcpu,pmem,comm --sort=-pmem | head -n 11', { encoding: 'utf-8' })
.toString()
.trim();
// Skip the header line
const lines = output.split('\n').slice(1);
processes = lines.map((line: string) => {
const parts = line.trim().split(/\s+/);
const pid = parseInt(parts[0], 10);
// Normalize CPU percentage by dividing by CPU count
const cpu = parseFloat(parts[1]) / cpuCount;
const memory = parseFloat(parts[2]);
const command = parts.slice(3).join(' ');
return {
pid,
name: command.split('/').pop() || command,
cpu,
memory,
command,
timestamp: new Date().toISOString(),
};
});
} catch (error) {
logger.error('Failed to get Linux process info:', error);
// Try alternative command
try {
const output = execSync('top -b -n 1 | head -n 17', { encoding: 'utf-8' }).toString().trim();
// Parse top output - skip the first few lines of header
const lines = output.split('\n').slice(7);
processes = lines.map((line: string) => {
const parts = line.trim().split(/\s+/);
const pid = parseInt(parts[0], 10);
const cpu = parseFloat(parts[8]);
const memory = parseFloat(parts[9]);
const command = parts[11] || parts[parts.length - 1];
return {
pid,
name: command.split('/').pop() || command,
cpu,
memory,
command,
timestamp: new Date().toISOString(),
};
});
} catch (fallbackError) {
logger.error('Failed to get Linux process info with fallback:', fallbackError);
return [
{
pid: 0,
name: 'N/A',
cpu: 0,
memory: 0,
timestamp: new Date().toISOString(),
error: 'Process information is not available in this environment',
},
];
}
}
} else if (platform === 'win32') {
// Windows - use PowerShell to get process information
try {
const output = execSync(
'powershell "Get-Process | Sort-Object -Property WorkingSet64 -Descending | Select-Object -First 10 Id, CPU, @{Name=\'Memory\';Expression={$_.WorkingSet64/1MB}}, ProcessName | ConvertTo-Json"',
{ encoding: 'utf-8' },
)
.toString()
.trim();
const processData = JSON.parse(output);
const processArray = Array.isArray(processData) ? processData : [processData];
processes = processArray.map((proc: any) => ({
pid: proc.Id,
name: proc.ProcessName,
// Normalize CPU percentage by dividing by CPU count
cpu: (proc.CPU || 0) / cpuCount,
memory: proc.Memory,
timestamp: new Date().toISOString(),
}));
} catch (error) {
logger.error('Failed to get Windows process info:', error);
// Try alternative command using tasklist
try {
const output = execSync('tasklist /FO CSV', { encoding: 'utf-8' }).toString().trim();
// Parse CSV output - skip the header line
const lines = output.split('\n').slice(1);
processes = lines.slice(0, 10).map((line: string) => {
// Parse CSV format
const parts = line.split(',').map((part: string) => part.replace(/^"(.+)"$/, '$1'));
const pid = parseInt(parts[1], 10);
const memoryStr = parts[4].replace(/[^\d]/g, '');
const memory = parseInt(memoryStr, 10) / 1024; // Convert KB to MB
return {
pid,
name: parts[0],
cpu: 0, // tasklist doesn't provide CPU info
memory,
timestamp: new Date().toISOString(),
};
});
} catch (fallbackError) {
logger.error('Failed to get Windows process info with fallback:', fallbackError);
return [
{
pid: 0,
name: 'N/A',
cpu: 0,
memory: 0,
timestamp: new Date().toISOString(),
error: 'Process information is not available in this environment',
},
];
}
}
} else {
logger.warn(`Unsupported platform: ${platform}, using browser fallback`);
return [
{
pid: 0,
name: 'N/A',
cpu: 0,
memory: 0,
timestamp: new Date().toISOString(),
error: 'Process information is not available in this environment',
},
];
}
return processes;
} catch (error) {
logger.error('Failed to get process info:', error);
if (isDevelopment) {
return getMockProcessInfo();
}
return [
{
pid: 0,
name: 'N/A',
cpu: 0,
memory: 0,
timestamp: new Date().toISOString(),
error: 'Process information is not available in this environment',
},
];
}
};
// Generate mock process information with realistic values
const getMockProcessInfo = (): ProcessInfo[] => {
const timestamp = new Date().toISOString();
// Create some random variation in CPU usage
const randomCPU = () => Math.floor(Math.random() * 15);
const randomHighCPU = () => 15 + Math.floor(Math.random() * 25);
// Create some random variation in memory usage
const randomMem = () => Math.floor(Math.random() * 5);
const randomHighMem = () => 5 + Math.floor(Math.random() * 15);
return [
{
pid: 1,
name: 'Browser',
cpu: randomHighCPU(),
memory: 25 + randomMem(),
command: 'Browser Process',
timestamp,
},
{
pid: 2,
name: 'System',
cpu: 5 + randomCPU(),
memory: 10 + randomMem(),
command: 'System Process',
timestamp,
},
{
pid: 3,
name: 'upage',
cpu: randomHighCPU(),
memory: 15 + randomMem(),
command: 'UPage AI Process',
timestamp,
},
{
pid: 4,
name: 'node',
cpu: randomCPU(),
memory: randomHighMem(),
command: 'Node.js Process',
timestamp,
},
{
pid: 5,
name: 'wrangler',
cpu: randomCPU(),
memory: randomMem(),
command: 'Wrangler Process',
timestamp,
},
{
pid: 6,
name: 'vscode',
cpu: randomCPU(),
memory: 12 + randomMem(),
command: 'VS Code Process',
timestamp,
},
{
pid: 7,
name: 'chrome',
cpu: randomHighCPU(),
memory: 20 + randomMem(),
command: 'Chrome Browser',
timestamp,
},
{
pid: 8,
name: 'finder',
cpu: 1 + randomCPU(),
memory: 3 + randomMem(),
command: 'Finder Process',
timestamp,
},
{
pid: 10,
name: 'cloudflared',
cpu: randomCPU(),
memory: randomMem(),
command: 'Cloudflare Tunnel',
timestamp,
},
];
};
export const processLoader: LoaderFunction = async ({ request: _request }) => {
try {
return json(getProcessInfo());
} catch (error) {
logger.error('Failed to get process info:', error);
return json(getMockProcessInfo(), { status: 500 });
}
};
export const processAction = async ({ request: _request }: ActionFunctionArgs) => {
try {
return json(getProcessInfo());
} catch (error) {
logger.error('Failed to get process info:', error);
return json(getMockProcessInfo(), { status: 500 });
}
};

View File

@@ -0,0 +1,53 @@
import { type ActionFunctionArgs, type LoaderFunctionArgs } from '@remix-run/node';
import { errorResponse } from '~/utils/api-response';
import { appInfoAction, appInfoLoader } from './app-info.server';
import { diskAction, diskLoader } from './disk.server';
import { gitInfoLoader } from './git-info.server';
import { memoryAction, memoryLoader } from './memory.server';
import { processAction, processLoader } from './process.server';
export async function loader(args: LoaderFunctionArgs) {
const { params } = args;
if (params.action === 'git-info') {
return gitInfoLoader(args);
}
if (process.env.NODE_ENV !== 'development') {
return errorResponse(403, '无权限访问');
}
switch (params.action) {
case 'app-info':
return appInfoLoader(args);
case 'disk':
return diskLoader(args);
case 'memory':
return memoryLoader(args);
case 'process':
return processLoader(args);
default:
return errorResponse(404, '未找到API');
}
}
export async function action(args: ActionFunctionArgs) {
if (process.env.NODE_ENV !== 'development') {
return errorResponse(403, '无权限访问');
}
const { params } = args;
switch (params.action) {
case 'app-info':
return appInfoAction(args);
case 'disk':
return diskAction(args);
case 'memory':
return memoryAction(args);
case 'process':
return processAction(args);
case 'git-info':
default:
return errorResponse(404, '未找到API');
}
}

View File

@@ -0,0 +1,73 @@
import { type ActionFunctionArgs } from '@remix-run/node';
import { requireAuth } from '~/lib/.server/auth';
import { createScopedLogger } from '~/lib/.server/logger';
import { storageProvider } from '~/lib/storage/index.server';
import { errorResponse, successResponse } from '~/utils/api-response';
const logger = createScopedLogger('api.upload');
/**
* 处理文件上传请求
*
* 参数:
* - file: 要上传的文件
*
* 返回:
* - url: 文件访问URL
* - filename: 文件名
* - contentType: 文件类型
* - size: 文件大小(字节)
*/
export async function action({ request }: ActionFunctionArgs) {
try {
const authResult = await requireAuth(request, { isApi: true });
if (authResult instanceof Response) {
return authResult;
}
const userId = authResult.userInfo?.sub;
if (!userId) {
return errorResponse(401, '用户未登录');
}
if (request.method !== 'POST') {
return errorResponse(405, '不支持的请求方法');
}
// 获取上传的文件
const formData = await request.formData();
const file = formData.get('file');
if (!file || !(file instanceof File)) {
return errorResponse(400, '未找到有效的文件');
}
const maxFileSizeMB = process.env.MAX_UPLOAD_SIZE_MB ? parseInt(process.env.MAX_UPLOAD_SIZE_MB) : 5;
const maxFileSize = maxFileSizeMB * 1024 * 1024;
if (file.size > maxFileSize) {
return errorResponse(413, `文件大小超过限制,最大允许${maxFileSizeMB}MB`);
}
const fileBuffer = Buffer.from(await file.arrayBuffer());
const result = await storageProvider.uploadFile({
userId,
contentType: file.type || 'application/octet-stream',
filename: file.name,
data: fileBuffer,
});
return successResponse(
{
url: storageProvider.getFileUrl(userId, result.filename),
filename: result.filename,
contentType: result.contentType,
size: result.size,
},
'文件上传成功',
);
} catch (error) {
logger.error('文件上传失败:', error);
return errorResponse(500, '文件上传失败');
}
}

View File

@@ -0,0 +1,125 @@
import { type ActionFunctionArgs, type LoaderFunctionArgs } from '@remix-run/node';
import { requireAuth } from '~/lib/.server/auth';
import {
deleteUserSetting,
deleteUserSettings,
getUserSetting,
getUserSettings,
setUserSetting,
} from '~/lib/.server/userSettings';
import { errorResponse, successResponse } from '~/utils/api-response';
import { createScopedLogger } from '~/utils/logger';
const logger = createScopedLogger('api.user.settings');
export async function loader({ request }: LoaderFunctionArgs) {
// 验证用户权限
const authResult = await requireAuth(request, { isApi: true });
if (authResult instanceof Response) {
return authResult;
}
// 确保用户信息存在
if (!authResult.userInfo) {
return errorResponse(401, '无法获取用户信息');
}
const userId = authResult.userInfo.sub;
if (!userId) {
return errorResponse(401, '无效的用户ID');
}
try {
const url = new URL(request.url);
const category = url.searchParams.get('category') || undefined;
const key = url.searchParams.get('key') || undefined;
const includeSecrets = url.searchParams.get('includeSecrets') === 'true';
// 如果同时提供了category和key则获取单个设置
if (category && key) {
const setting = await getUserSetting(userId, category, key);
if (!setting) {
return errorResponse(404, '未找到指定的设置');
}
// 如果是敏感信息且未明确要求包含敏感信息,则不返回值
if (setting.isSecret && !includeSecrets) {
return successResponse({
...setting,
value: '[REDACTED]',
});
}
return successResponse(setting);
}
// 否则获取所有符合条件的设置
const settings = await getUserSettings({
userId,
category,
key,
includeSecrets,
});
return successResponse(settings);
} catch (error) {
logger.error('获取用户设置失败:', error);
return errorResponse(500, '获取用户设置失败');
}
}
export async function action({ request }: ActionFunctionArgs) {
const authResult = await requireAuth(request, { isApi: true });
if (authResult instanceof Response) {
return authResult;
}
// 确保用户信息存在
if (!authResult.userInfo) {
return errorResponse(401, '无法获取用户信息');
}
const userId = authResult.userInfo.sub;
if (!userId) {
return errorResponse(401, '无效的用户ID');
}
try {
if (request.method === 'POST') {
const { category, key, value, isSecret } = await request.json();
if (!category || !key || value === undefined) {
return errorResponse(400, '缺少必要参数: category, key, value');
}
const setting = await setUserSetting({
userId,
category,
key,
value,
isSecret: isSecret || false,
});
return successResponse(setting, '设置保存成功');
}
if (request.method === 'DELETE') {
const { category, key } = await request.json();
if (!category) {
return errorResponse(400, '删除设置时必须提供category参数');
}
if (key) {
await deleteUserSetting(userId, category, key);
return successResponse(null, '设置删除成功');
}
const count = await deleteUserSettings(userId, category);
return successResponse({ count }, `成功删除 ${count} 条设置`);
}
return errorResponse(405, '不支持的请求方法');
} catch (error) {
logger.error('处理用户设置失败:', error);
return errorResponse(500, '处理用户设置失败');
}
}

View File

@@ -0,0 +1,54 @@
import {
deleteVercelConnectionSettings,
getVercelConnectionSettings,
saveVercelConnectionSettings,
} from '~/lib/.server/connectionSettings';
import { errorResponse, successResponse } from '~/utils/api-response';
import { createScopedLogger } from '~/utils/logger';
const logger = createScopedLogger('api.vercel.auth');
export type HandleVercelAuthArgs = {
request: Request;
userId: string;
};
export async function handleVercelAuth({ request, userId }: HandleVercelAuthArgs) {
try {
const { token } = await request.json();
// 从数据库中获取 token
const connectionSettings = await getVercelConnectionSettings(userId);
if (!token && !connectionSettings?.token) {
return errorResponse(400, '缺少令牌参数');
}
const vercelToken = token || connectionSettings?.token;
const response = await fetch('https://api.vercel.com/v2/user', {
headers: {
Authorization: `Bearer ${vercelToken}`,
'Content-Type': 'application/json',
},
});
if (!response.ok) {
await deleteVercelConnectionSettings(userId);
return errorResponse(401, '无效的令牌或未经授权');
}
const userData = await response.json();
await saveVercelConnectionSettings(userId, vercelToken);
logger.info(`用户 ${userId} 成功验证并保存了 Vercel 令牌`);
return successResponse(
{
user: userData.user || userData,
isConnect: !!userData,
},
'Vercel 令牌验证成功',
);
} catch (error) {
logger.error('验证 Vercel 令牌失败:', error);
return errorResponse(500, '验证 Vercel 令牌失败');
}
}

View File

@@ -0,0 +1,75 @@
import { getVercelConnectionSettings } from '~/lib/.server/connectionSettings';
import { deleteDeploymentById, getDeploymentById } from '~/lib/.server/deployment';
import { request } from '~/lib/fetch';
import { errorResponse, successResponse } from '~/utils/api-response';
import { createScopedLogger } from '~/utils/logger';
import type { VercelResponseError } from './type';
const logger = createScopedLogger('api.vercel.delete');
export type DeletePageArgs = {
userId: string;
request: Request;
};
/**
* 删除 Vercel 中指定的部署
*
* @param token Vercel API 令牌
* @param deploymentId 部署 ID
* @returns 是否成功
*/
async function removeVercelDeployment(token: string, deploymentId: string): Promise<boolean> {
try {
const response = await request(`https://api.vercel.com/v13/deployments/${deploymentId}`, {
method: 'DELETE',
headers: {
Authorization: `Bearer ${token}`,
},
});
if (!response.ok) {
const errorData = (await response.json()) as VercelResponseError;
logger.error(`删除 Vercel 部署 ${deploymentId} 失败: ${errorData.error?.message}`);
throw new Error(`${errorData.error?.message}`);
}
return true;
} catch (error) {
logger.error(`删除 Vercel 部署 ${deploymentId} 时发生错误:`, error);
throw new Error(`${error}`);
}
}
export async function deletePage({ userId, request }: DeletePageArgs) {
const { id } = await request.json();
try {
const deployment = await getDeploymentById(id);
if (!deployment) {
return errorResponse(404, '未找到部署记录');
}
const connectionSettings = await getVercelConnectionSettings(userId);
if (!connectionSettings) {
return errorResponse(401, '未连接到 Vercel请重新连接至 Vercel');
}
const deploymentId = deployment.deploymentId;
if (!deploymentId) {
return errorResponse(400, '部署记录缺少必要信息');
}
const { token } = connectionSettings;
await removeVercelDeployment(token, deploymentId);
await deleteDeploymentById(id);
logger.info(`用户 ${userId} 已删除 Vercel 部署 ${id}`);
return successResponse(id, '页面已删除');
} catch (error) {
logger.error(`删除 Vercel 部署 ${id} 失败:`, error);
return errorResponse(500, error instanceof Error ? error.message : '删除失败');
}
}

View File

@@ -0,0 +1,308 @@
import { getVercelConnectionSettings, saveVercelConnectionSettings } from '~/lib/.server/connectionSettings';
import { createOrUpdateDeployment, getLatestDeployment } from '~/lib/.server/deployment';
import { createScopedLogger } from '~/lib/.server/logger';
import { DeploymentPlatformEnum, DeploymentStatusEnum } from '~/types/deployment';
import type { VercelProjectInfo } from '~/types/vercel';
import { errorResponse, successResponse } from '~/utils/api-response';
import { isBinaryString } from '~/utils/file-utils';
const logger = createScopedLogger('api.vercel.deploy');
export type GetVercelDeployByProjectIdArgs = {
request: Request;
userId: string;
};
export async function getVercelDeployByProjectId({ request, userId }: GetVercelDeployByProjectIdArgs) {
const url = new URL(request.url);
const projectId = url.searchParams.get('projectId');
const requestToken = url.searchParams.get('token');
// 从用户设置中获取连接信息
let connectionSettings = await getVercelConnectionSettings(userId);
// 如果请求参数中提供了token优先使用请求参数中的信息并更新用户设置
if (requestToken) {
connectionSettings = {
token: requestToken,
};
// 更新用户设置
await saveVercelConnectionSettings(userId, requestToken);
}
// 如果没有连接信息,返回错误
if (!connectionSettings) {
return errorResponse(401, '未连接到Vercel请先设置访问令牌');
}
const { token } = connectionSettings;
if (!projectId) {
return errorResponse(400, '缺少项目ID');
}
try {
// Get project info
const projectResponse = await fetch(`https://api.vercel.com/v9/projects/${projectId}`, {
headers: {
Authorization: `Bearer ${token}`,
},
});
if (!projectResponse.ok) {
return errorResponse(400, '无法获取项目');
}
const projectData = (await projectResponse.json()) as any;
// Get latest deployment
const deploymentsResponse = await fetch(`https://api.vercel.com/v6/deployments?projectId=${projectId}&limit=1`, {
headers: {
Authorization: `Bearer ${token}`,
},
});
if (!deploymentsResponse.ok) {
return errorResponse(400, '获取部署信息失败');
}
const deploymentsData = (await deploymentsResponse.json()) as any;
const latestDeployment = deploymentsData.deployments?.[0];
return successResponse(
{
project: {
id: projectData.id,
name: projectData.name,
url: `https://${projectData.name}.vercel.app`,
},
deploy: latestDeployment
? {
id: latestDeployment.id,
state: latestDeployment.state,
url: latestDeployment.url ? `https://${latestDeployment.url}` : `https://${projectData.name}.vercel.app`,
}
: null,
},
'获取部署信息成功',
);
} catch (error) {
logger.error('Error fetching Vercel deployment:', error);
return errorResponse(500, '获取部署失败');
}
}
interface DeployRequestBody {
projectId?: string;
files: Record<string, string>;
chatId: string;
token?: string;
}
export type HandleVercelDeployArgs = {
request: Request;
userId: string;
};
// Existing action function for POST requests
export async function handleVercelDeploy({ request, userId }: HandleVercelDeployArgs) {
try {
const { projectId, files, token: requestToken, chatId } = (await request.json()) as DeployRequestBody;
// 从用户设置中获取连接信息
let connectionSettings = await getVercelConnectionSettings(userId);
// 如果请求体中提供了token优先使用请求体中的信息并更新用户设置
if (requestToken) {
connectionSettings = {
token: requestToken,
};
// 更新用户设置
await saveVercelConnectionSettings(userId, requestToken);
}
// 如果没有连接信息,返回错误
if (!connectionSettings) {
return errorResponse(401, '未连接到Vercel请先设置访问令牌');
}
const { token } = connectionSettings;
const existingDeployment = await getLatestDeployment(userId, chatId, DeploymentPlatformEnum.VERCEL);
let targetProjectId;
if (projectId) {
targetProjectId = projectId;
} else if (existingDeployment?.deploymentId) {
targetProjectId = existingDeployment.deploymentId;
} else {
targetProjectId = undefined;
}
let projectInfo: VercelProjectInfo | undefined;
if (targetProjectId) {
const projectResponse = await fetch(`https://api.vercel.com/v9/projects/${targetProjectId}`, {
headers: {
Authorization: `Bearer ${token}`,
},
});
if (projectResponse.ok) {
const existingProject = (await projectResponse.json()) as any;
projectInfo = {
id: existingProject.id,
name: existingProject.name,
url: `https://${existingProject.name}.vercel.app`,
chatId,
};
}
}
if (!projectInfo) {
const projectName = `upage-${chatId}-${Date.now()}`.toLocaleLowerCase();
const createProjectResponse = await fetch('https://api.vercel.com/v9/projects', {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
name: projectName,
framework: null,
}),
});
if (!createProjectResponse.ok) {
const errorData = (await createProjectResponse.json()) as any;
return errorResponse(400, `创建项目失败: ${errorData.error?.message || '未知错误'}`);
}
const newProject = (await createProjectResponse.json()) as any;
targetProjectId = newProject.id;
projectInfo = {
id: newProject.id,
name: newProject.name,
url: `https://${newProject.name}.vercel.app`,
chatId,
};
}
// Prepare files for deployment
const deploymentFiles = [];
for (const [filePath, content] of Object.entries(files)) {
// Ensure file path doesn't start with a slash for Vercel
const normalizedPath = filePath.startsWith('/') ? filePath.substring(1) : filePath;
deploymentFiles.push({
file: normalizedPath,
data: isBinaryString(content) ? Buffer.from(content, 'binary').toString('base64') : content,
encoding: isBinaryString(content) ? 'base64' : 'utf-8',
});
}
// Create a new deployment
const deployResponse = await fetch(`https://api.vercel.com/v13/deployments`, {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
name: projectInfo.name,
project: targetProjectId,
target: 'production',
files: deploymentFiles,
routes: [{ src: '/(.*)', dest: '/$1' }],
}),
});
if (!deployResponse.ok) {
const errorData = (await deployResponse.json()) as any;
return errorResponse(400, `创建部署失败: ${errorData.error?.message || '未知错误'}`);
}
const deployData = (await deployResponse.json()) as any;
// Poll for deployment status
let retryCount = 0;
const maxRetries = 60;
let deploymentUrl = '';
let deploymentState = '';
while (retryCount < maxRetries) {
const statusResponse = await fetch(`https://api.vercel.com/v13/deployments/${deployData.id}`, {
headers: {
Authorization: `Bearer ${token}`,
},
});
if (statusResponse.ok) {
const status = (await statusResponse.json()) as any;
deploymentState = status.readyState;
const alias = status.alias;
const automaticAliases = status.automaticAliases;
if (status.readyState === 'READY' || status.readyState === 'ERROR') {
if (status.aliasAssigned) {
const diffAlias = alias.filter((item: string) => !automaticAliases.includes(item));
deploymentUrl = `https://${diffAlias[0]}`;
} else {
deploymentUrl = status.url ? `https://${status.url}` : '';
}
break;
}
}
retryCount++;
await new Promise((resolve) => setTimeout(resolve, 2000));
}
if (deploymentState === 'ERROR') {
return errorResponse(500, '部署失败');
}
if (retryCount >= maxRetries) {
return errorResponse(500, '部署超时');
}
const finalUrl = deploymentUrl || projectInfo.url;
// 记录部署信息
try {
await createOrUpdateDeployment({
userId,
chatId,
platform: DeploymentPlatformEnum.VERCEL,
deploymentId: deployData.id,
url: finalUrl,
status: deploymentState === 'READY' ? DeploymentStatusEnum.SUCCESS : DeploymentStatusEnum.PENDING,
metadata: {
projectId: projectInfo.id,
projectName: projectInfo.name,
},
});
logger.info(`为用户 ${userId} 创建了 Vercel 部署记录`);
} catch (error) {
logger.error('创建部署记录失败:', error);
// 不影响主流程,继续返回成功
}
return successResponse(
{
deploy: {
id: deployData.id,
state: deploymentState,
url: finalUrl,
},
project: projectInfo,
},
'部署成功',
);
} catch (error) {
logger.error('Vercel deploy error:', error);
return errorResponse(500, '部署失败');
}
}

View File

@@ -0,0 +1,64 @@
import { type ActionFunctionArgs, type LoaderFunctionArgs } from '@remix-run/node';
import { requireAuth } from '~/lib/.server/auth';
import { createScopedLogger } from '~/lib/.server/logger';
import { errorResponse } from '~/utils/api-response';
import { handleVercelAuth } from './auth.server';
import { deletePage } from './delete.server';
import { getVercelDeployByProjectId, handleVercelDeploy } from './deploy.server';
import { getVercelStats } from './stats.server';
import { toggleAccess } from './toggle-access.server';
const logger = createScopedLogger('api.vercel.route');
export async function loader(args: LoaderFunctionArgs) {
const { request, params } = args;
const authResult = await requireAuth(request, { isApi: true });
if (authResult instanceof Response) {
return authResult;
}
const userId = authResult.userInfo?.sub;
if (!userId) {
return errorResponse(401, '用户未登录');
}
// 根据参数调用不同的处理函数
switch (params.action) {
case 'deploy':
return getVercelDeployByProjectId({ request, userId });
case 'stats':
return getVercelStats({ userId });
default:
return errorResponse(404, `未知的 API 操作: ${params.action}`);
}
}
export async function action(args: ActionFunctionArgs) {
const { request, params } = args;
const authResult = await requireAuth(request, { isApi: true });
if (authResult instanceof Response) {
return authResult;
}
const userId = authResult.userInfo?.sub;
if (!userId) {
return errorResponse(401, '用户未登录');
}
logger.debug('处理 Vercel API 请求', { action: params.action });
// 根据参数调用不同的处理函数
switch (params.action) {
case 'deploy':
return handleVercelDeploy({ request, userId });
case 'auth':
return handleVercelAuth({ request, userId });
case 'toggle-access':
return toggleAccess({ ...args, userId });
case 'delete':
return deletePage({ ...args, userId });
default:
logger.warn('未知的 API 操作', { action: params.action });
return errorResponse(404, `未知的 API 操作: ${params.action}`);
}
}

View File

@@ -0,0 +1,75 @@
import { getVercelConnectionSettings } from '~/lib/.server/connectionSettings';
import { errorResponse, successResponse } from '~/utils/api-response';
import { createScopedLogger } from '~/utils/logger';
const logger = createScopedLogger('api.vercel.stats');
export type GetVercelStatsArgs = {
userId: string;
};
export async function getVercelStats({ userId }: GetVercelStatsArgs) {
try {
const connectionSettings = await getVercelConnectionSettings(userId);
if (!connectionSettings) {
return errorResponse(401, '未连接到Vercel请先设置访问令牌');
}
const { token } = connectionSettings;
const projectsResponse = await fetch('https://api.vercel.com/v9/projects', {
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
});
if (!projectsResponse.ok) {
return errorResponse(projectsResponse.status, '获取项目列表失败');
}
const projectsData = await projectsResponse.json();
const projects = projectsData.projects || [];
const projectsWithDeployments = await Promise.all(
projects.map(async (project: any) => {
try {
const deploymentsResponse = await fetch(
`https://api.vercel.com/v6/deployments?projectId=${project.id}&limit=1`,
{
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
},
);
if (deploymentsResponse.ok) {
const deploymentsData = await deploymentsResponse.json();
return {
...project,
latestDeployments: deploymentsData.deployments || [],
};
}
return project;
} catch (error) {
logger.error(`获取项目 ${project.id} 的部署信息失败:`, error);
return project;
}
}),
);
return successResponse(
{
projects: projectsWithDeployments,
totalProjects: projectsWithDeployments.length,
},
'获取 Vercel 项目统计信息成功',
);
} catch (error) {
logger.error('获取 Vercel 项目统计信息失败:', error);
return errorResponse(500, '获取 Vercel 项目统计信息失败');
}
}

View File

@@ -0,0 +1,190 @@
import { getVercelConnectionSettings } from '~/lib/.server/connectionSettings';
import { getDeploymentById, updateDeploymentStatus } from '~/lib/.server/deployment';
import { request } from '~/lib/fetch';
import { errorResponse, successResponse } from '~/utils/api-response';
import { createScopedLogger } from '~/utils/logger';
import type { VercelAlias, VercelResponseAliases, VercelResponseError } from './type';
const logger = createScopedLogger('api.vercel.manage');
/**
* 获取 Vercel 项目的别名列表
* @param token Vercel API 令牌
* @param deploymentId Vercel 平台部署 ID
* @returns 域名别名列表
*/
async function getVercelDomainAliases(token: string, deploymentId: string): Promise<VercelAlias[]> {
try {
const response = await request(`https://api.vercel.com/v2/deployments/${deploymentId}/aliases`, {
headers: {
Authorization: `Bearer ${token}`,
},
});
if (!response.ok) {
const errorData = (await response.json()) as VercelResponseError;
logger.error(`获取 Vercel 项目 ${deploymentId} 的域名别名失败: ${errorData.error?.message}`);
throw new Error(`${errorData.error?.message}`);
}
const data = (await response.json()) as VercelResponseAliases;
return data.aliases;
} catch (error) {
logger.error(`获取 Vercel 项目 ${deploymentId} 的域名别名时发生错误:`, error);
throw new Error(`${error}`);
}
}
/**
* 为 Vercel 项目添加域名别名
* @param token Vercel API 令牌
* @param deploymentId 项目 ID
* @param domain 域名
* @returns 是否成功
*/
async function setVercelDomainAlias(
token: string,
deploymentId: string,
alias: string,
redirect?: string,
): Promise<VercelAlias> {
try {
const response = await request(`https://api.vercel.com/v2/deployments/${deploymentId}/aliases`, {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
alias,
redirect: redirect ?? null,
}),
});
if (!response.ok) {
const errorData = (await response.json()) as VercelResponseError;
logger.error(`为 Vercel 项目 ${deploymentId} 添加域名别名 ${alias} 失败:`, errorData);
throw new Error(`${errorData.error?.message}`);
}
const data = (await response.json()) as VercelAlias;
return data;
} catch (error) {
logger.error(`为 Vercel 项目 ${deploymentId} 添加域名别名 ${alias} 时发生错误:`, error);
throw new Error(`${error}`);
}
}
/**
* 删除 Vercel 中指定的别名
* @param token Vercel API 令牌
* @param aliasId 别名 ID
* @returns 是否成功
*/
async function removeVercelDomainAlias(token: string, aliasId: string): Promise<boolean> {
try {
const response = await request(`https://api.vercel.com/v2/aliases/${aliasId}`, {
method: 'DELETE',
headers: {
Authorization: `Bearer ${token}`,
},
});
if (!response.ok) {
const errorData = (await response.json()) as VercelResponseError;
logger.error(`删除 Vercel 项目 ${aliasId} 的域名别名失败: ${errorData.error?.message}`);
throw new Error(`${errorData.error?.message}`);
}
return true;
} catch (error) {
logger.error(`删除 Vercel 项目 ${aliasId} 的域名别名时发生错误:`, error);
throw new Error(`${error}`);
}
}
export type ToggleAccessArgs = {
userId: string;
request: Request;
};
export async function toggleAccess({ userId, request }: ToggleAccessArgs) {
const { id } = await request.json();
try {
const deployment = await getDeploymentById(id);
if (!deployment) {
return errorResponse(404, '未找到部署记录');
}
const connectionSettings = await getVercelConnectionSettings(userId);
if (!connectionSettings) {
return errorResponse(401, '未连接到 Vercel请重新连接至 Vercel');
}
const { token } = connectionSettings;
const projectId = deployment.deploymentId;
if (!projectId) {
return errorResponse(400, '部署记录缺少必要信息');
}
// 获取当前状态
const currentStatus = deployment.status;
const newStatus = currentStatus === 'inactive' ? 'success' : 'inactive';
// 获取当前域名别名
const currentDomains = await getVercelDomainAliases(token, projectId);
// 获取部署记录的元数据,确保它是一个对象
const metadata: Record<string, any> =
typeof deployment.metadata === 'object' && deployment.metadata !== null
? { ...(deployment.metadata as Record<string, any>) }
: {};
if (newStatus === 'inactive') {
if (currentDomains.length > 0) {
// 保存替换当前域名别名记录
metadata.aliases = currentDomains;
// 删除当前所有域名别名
for (const alias of currentDomains) {
await removeVercelDomainAlias(token, alias.uid!);
logger.info(`已删除 Vercel 项目 ${projectId} 的域名别名: ${alias.alias}`);
}
}
} else {
if (currentDomains.length === 0) {
const newAliases: VercelAlias[] = [];
// 恢复已保存的所有域名别名
const savedAliases = (metadata.aliases as VercelAlias[]) || [];
if (savedAliases.length === 0) {
// 如果保存的域名别名列表为空,则使用 deployment.url 的域名
const urlObj = new URL(deployment.url);
savedAliases.push({
alias: urlObj.hostname,
redirect: deployment.url,
oldDeploymentId: deployment.id,
});
}
for (const alias of savedAliases) {
const newAlias = await setVercelDomainAlias(token, projectId, alias.alias, alias.redirect);
newAliases.push(newAlias);
logger.info(`已为 Vercel 项目 ${projectId} 添加域名别名: ${alias.alias}`);
}
metadata.aliases = newAliases;
}
}
// 更新状态和元数据
await updateDeploymentStatus(id, newStatus, metadata);
logger.info(`用户 ${userId}${newStatus === 'success' ? '开启' : '停止'} Vercel 项目 ${projectId} 的访问`);
return successResponse(id, `${newStatus === 'success' ? '开启' : '停止'}访问`);
} catch (error) {
logger.error(`切换Vercel部署 ${id} 访问状态失败:`, error);
return errorResponse(500, error instanceof Error ? error.message : '操作失败');
}
}

View File

@@ -0,0 +1,22 @@
export type VercelAlias = {
uid?: string;
alias: string;
created?: string;
redirect?: string;
oldDeploymentId?: string;
};
export type VercelResponseAliases = {
aliases: VercelAlias[];
};
export type VercelResponseError = {
error?: {
code?: string;
message?: string;
saml?: boolean;
teamId?: string | null;
scope?: string;
enforced?: boolean;
};
};

View File

@@ -0,0 +1,53 @@
import { type LoaderFunctionArgs } from '@remix-run/node';
import fs from 'fs';
import { getUser } from '~/lib/.server/auth';
import { createScopedLogger } from '~/lib/.server/logger';
import { storageProvider } from '~/lib/storage/index.server';
const logger = createScopedLogger('api.assets');
/**
* 处理文件访问请求, 只有文件所有者可以访问
*/
export async function loader({ request, params }: LoaderFunctionArgs) {
try {
const { userId, filename } = params;
if (!userId || !filename) {
return new Response('文件不存在', { status: 404 });
}
const authResult = await getUser(request);
const currentUserId = authResult.userInfo?.sub;
const fileExists = await storageProvider.fileExists(userId, filename);
if (!fileExists) {
logger.debug('文件不存在', { userId, filename });
return new Response('文件不存在', { status: 404 });
}
if (currentUserId !== userId) {
logger.warn('无权访问文件', { userId, currentUserId, filename });
return new Response('无权访问此文件', { status: 403 });
}
const file = await storageProvider.getFile(userId, filename);
if (!file) {
logger.debug('文件不存在', { userId, filename });
return new Response('文件不存在', { status: 404 });
}
const fileContent = await fs.promises.readFile(file.path);
return new Response(new Uint8Array(fileContent), {
headers: {
'Content-Type': file.contentType,
'Content-Length': String(file.size),
'Cache-Control': 'public, max-age=31536000',
},
});
} catch (error) {
logger.error('获取文件失败:', error);
return new Response('服务器错误', { status: 500 });
}
}

44
app/routes/chat.$id.tsx Normal file
View File

@@ -0,0 +1,44 @@
import { data, type LoaderFunctionArgs, redirect } from '@remix-run/node';
import { getUser, requireAuth } from '~/lib/.server/auth';
import { getUserChatById } from '~/lib/.server/chat';
import { getChatDeployments } from '~/lib/.server/deployment';
import { default as IndexRoute } from './_index';
export async function loader(args: LoaderFunctionArgs) {
// 添加权限验证
const authResult = await requireAuth(args.request);
// 如果返回的是Response对象说明验证失败并已重定向
if (authResult instanceof Response) {
return authResult;
}
// 获取当前用户 id
const authContext = await getUser(args.request);
const userId = authContext.userInfo?.sub as string;
const { id } = args.params;
if (!id || !userId) {
return redirect('/');
}
const chat = await getUserChatById(id, userId);
if (!chat) {
return redirect('/');
}
const url = new URL(args.request.url);
const rewindTo = url.searchParams.get('rewindTo') || '';
if (rewindTo) {
chat.messages = chat.messages.slice(0, chat.messages.findIndex((message) => message.id === rewindTo) + 1);
}
const deployments = await getChatDeployments(id);
return data({
id: args.params.id,
chat,
user: authResult.userInfo,
deployments,
});
}
export default IndexRoute;