🎉 first commit
This commit is contained in:
36
app/routes/_index.tsx
Normal file
36
app/routes/_index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
302
app/routes/api.1panel.$action/1panel.server.ts
Normal file
302
app/routes/api.1panel.$action/1panel.server.ts
Normal 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',
|
||||
};
|
||||
}
|
||||
51
app/routes/api.1panel.$action/auth.server.ts
Normal file
51
app/routes/api.1panel.$action/auth.server.ts
Normal 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 连接失败');
|
||||
}
|
||||
}
|
||||
46
app/routes/api.1panel.$action/delete.server.ts
Normal file
46
app/routes/api.1panel.$action/delete.server.ts
Normal 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 : '删除失败');
|
||||
}
|
||||
}
|
||||
217
app/routes/api.1panel.$action/deploy.server.ts
Normal file
217
app/routes/api.1panel.$action/deploy.server.ts
Normal 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 失败');
|
||||
}
|
||||
}
|
||||
64
app/routes/api.1panel.$action/route.tsx
Normal file
64
app/routes/api.1panel.$action/route.tsx
Normal 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}`);
|
||||
}
|
||||
}
|
||||
46
app/routes/api.1panel.$action/stats.server.ts
Normal file
46
app/routes/api.1panel.$action/stats.server.ts
Normal 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 网站统计信息失败');
|
||||
}
|
||||
}
|
||||
54
app/routes/api.1panel.$action/toggle-access.server.ts
Normal file
54
app/routes/api.1panel.$action/toggle-access.server.ts
Normal 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 : '操作失败');
|
||||
}
|
||||
}
|
||||
88
app/routes/api.1panel.$action/websites.server.ts
Normal file
88
app/routes/api.1panel.$action/websites.server.ts
Normal 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)));
|
||||
}
|
||||
}
|
||||
22
app/routes/api.auth.$action/check-error.server.ts
Normal file
22
app/routes/api.auth.$action/check-error.server.ts
Normal 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,
|
||||
},
|
||||
);
|
||||
}
|
||||
41
app/routes/api.auth.$action/route.tsx
Normal file
41
app/routes/api.auth.$action/route.tsx
Normal 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);
|
||||
}
|
||||
};
|
||||
16
app/routes/api.auth.$action/user.server.ts
Normal file
16
app/routes/api.auth.$action/user.server.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
93
app/routes/api.chat.$action/delete.server.ts
Normal file
93
app/routes/api.chat.$action/delete.server.ts
Normal 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, '删除聊天失败');
|
||||
}
|
||||
}
|
||||
167
app/routes/api.chat.$action/fork.server.ts
Normal file
167
app/routes/api.chat.$action/fork.server.ts
Normal 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, '服务器处理请求失败');
|
||||
}
|
||||
}
|
||||
51
app/routes/api.chat.$action/list.server.ts
Normal file
51
app/routes/api.chat.$action/list.server.ts
Normal 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, '获取聊天列表失败');
|
||||
}
|
||||
}
|
||||
74
app/routes/api.chat.$action/route.tsx
Normal file
74
app/routes/api.chat.$action/route.tsx
Normal 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}`);
|
||||
}
|
||||
}
|
||||
61
app/routes/api.chat.$action/update.server.ts
Normal file
61
app/routes/api.chat.$action/update.server.ts
Normal 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, '更新聊天描述失败');
|
||||
}
|
||||
}
|
||||
412
app/routes/api.chat/chat.server.ts
Normal file
412
app/routes/api.chat/chat.server.ts
Normal 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 });
|
||||
}
|
||||
61
app/routes/api.chat/mock-chat.server.ts
Normal file
61
app/routes/api.chat/mock-chat.server.ts
Normal 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 为生成 id,data: {"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',
|
||||
});
|
||||
}
|
||||
}
|
||||
25
app/routes/api.chat/route.tsx
Normal file
25
app/routes/api.chat/route.tsx
Normal 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 });
|
||||
}
|
||||
46
app/routes/api.deployments.$action/cache.ts
Normal file
46
app/routes/api.deployments.$action/cache.ts
Normal 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,
|
||||
);
|
||||
23
app/routes/api.deployments.$action/route.tsx
Normal file
23
app/routes/api.deployments.$action/route.tsx
Normal 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}`);
|
||||
}
|
||||
}
|
||||
45
app/routes/api.deployments.$action/stats.server.ts
Normal file
45
app/routes/api.deployments.$action/stats.server.ts
Normal 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 : '获取部署统计数据失败');
|
||||
}
|
||||
}
|
||||
34
app/routes/api.deployments/route.tsx
Normal file
34
app/routes/api.deployments/route.tsx
Normal 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 : '获取部署记录失败');
|
||||
}
|
||||
}
|
||||
40
app/routes/api.enhancer/route.tsx
Normal file
40
app/routes/api.enhancer/route.tsx
Normal 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),
|
||||
});
|
||||
}
|
||||
181
app/routes/api.git-proxy.$.ts
Normal file
181
app/routes/api.git-proxy.$.ts
Normal 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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
8
app/routes/api.health/route.tsx
Normal file
8
app/routes/api.health/route.tsx
Normal 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(),
|
||||
});
|
||||
};
|
||||
46
app/routes/api.netlify.$action/auth.server.ts
Normal file
46
app/routes/api.netlify.$action/auth.server.ts
Normal 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 令牌失败');
|
||||
}
|
||||
}
|
||||
68
app/routes/api.netlify.$action/delete.server.ts
Normal file
68
app/routes/api.netlify.$action/delete.server.ts
Normal 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 : '删除失败');
|
||||
}
|
||||
}
|
||||
319
app/routes/api.netlify.$action/deploy.server.ts
Normal file
319
app/routes/api.netlify.$action/deploy.server.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
60
app/routes/api.netlify.$action/route.tsx
Normal file
60
app/routes/api.netlify.$action/route.tsx
Normal 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}`);
|
||||
}
|
||||
}
|
||||
80
app/routes/api.netlify.$action/stats.server.ts
Normal file
80
app/routes/api.netlify.$action/stats.server.ts
Normal 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 站点统计信息失败');
|
||||
}
|
||||
}
|
||||
89
app/routes/api.netlify.$action/toggle-access.server.ts
Normal file
89
app/routes/api.netlify.$action/toggle-access.server.ts
Normal 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 : '操作失败');
|
||||
}
|
||||
}
|
||||
67
app/routes/api.netlify.deploys.$deployId.$action.ts
Normal file
67
app/routes/api.netlify.deploys.$deployId.$action.ts
Normal 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}操作失败`);
|
||||
}
|
||||
}
|
||||
53
app/routes/api.netlify.sites.$siteId.cache.ts
Normal file
53
app/routes/api.netlify.sites.$siteId.cache.ts
Normal 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, '清除站点缓存失败');
|
||||
}
|
||||
}
|
||||
60
app/routes/api.netlify.sites.$siteId.ts
Normal file
60
app/routes/api.netlify.sites.$siteId.ts
Normal 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, '删除站点失败');
|
||||
}
|
||||
}
|
||||
67
app/routes/api.project/route.tsx
Normal file
67
app/routes/api.project/route.tsx
Normal 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, '服务器处理请求失败');
|
||||
}
|
||||
}
|
||||
134
app/routes/api.system.$action/app-info.server.ts
Normal file
134
app/routes/api.system.$action/app-info.server.ts
Normal 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 },
|
||||
);
|
||||
}
|
||||
};
|
||||
314
app/routes/api.system.$action/disk.server.ts
Normal file
314
app/routes/api.system.$action/disk.server.ts
Normal 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 },
|
||||
);
|
||||
}
|
||||
};
|
||||
335
app/routes/api.system.$action/git-info.server.ts
Normal file
335
app/routes/api.system.$action/git-info.server.ts
Normal 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',
|
||||
},
|
||||
});
|
||||
};
|
||||
283
app/routes/api.system.$action/memory.server.ts
Normal file
283
app/routes/api.system.$action/memory.server.ts
Normal 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 },
|
||||
);
|
||||
}
|
||||
};
|
||||
419
app/routes/api.system.$action/process.server.ts
Normal file
419
app/routes/api.system.$action/process.server.ts
Normal 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 });
|
||||
}
|
||||
};
|
||||
53
app/routes/api.system.$action/route.tsx
Normal file
53
app/routes/api.system.$action/route.tsx
Normal 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');
|
||||
}
|
||||
}
|
||||
73
app/routes/api.upload/route.tsx
Normal file
73
app/routes/api.upload/route.tsx
Normal 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, '文件上传失败');
|
||||
}
|
||||
}
|
||||
125
app/routes/api.user.settings.ts
Normal file
125
app/routes/api.user.settings.ts
Normal 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, '处理用户设置失败');
|
||||
}
|
||||
}
|
||||
54
app/routes/api.vercel.$action/auth.server.ts
Normal file
54
app/routes/api.vercel.$action/auth.server.ts
Normal 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 令牌失败');
|
||||
}
|
||||
}
|
||||
75
app/routes/api.vercel.$action/delete.server.ts
Normal file
75
app/routes/api.vercel.$action/delete.server.ts
Normal 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 : '删除失败');
|
||||
}
|
||||
}
|
||||
308
app/routes/api.vercel.$action/deploy.server.ts
Normal file
308
app/routes/api.vercel.$action/deploy.server.ts
Normal 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, '部署失败');
|
||||
}
|
||||
}
|
||||
64
app/routes/api.vercel.$action/route.tsx
Normal file
64
app/routes/api.vercel.$action/route.tsx
Normal 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}`);
|
||||
}
|
||||
}
|
||||
75
app/routes/api.vercel.$action/stats.server.ts
Normal file
75
app/routes/api.vercel.$action/stats.server.ts
Normal 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 项目统计信息失败');
|
||||
}
|
||||
}
|
||||
190
app/routes/api.vercel.$action/toggle-access.server.ts
Normal file
190
app/routes/api.vercel.$action/toggle-access.server.ts
Normal 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 : '操作失败');
|
||||
}
|
||||
}
|
||||
22
app/routes/api.vercel.$action/type.ts
Normal file
22
app/routes/api.vercel.$action/type.ts
Normal 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;
|
||||
};
|
||||
};
|
||||
53
app/routes/assets.$userId.$filename.ts
Normal file
53
app/routes/assets.$userId.$filename.ts
Normal 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
44
app/routes/chat.$id.tsx
Normal 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;
|
||||
Reference in New Issue
Block a user