🎉 first commit
This commit is contained in:
57
app/utils/api-response.ts
Normal file
57
app/utils/api-response.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { json, type TypedResponse } from '@remix-run/node';
|
||||
import type { ApiResponse } from '~/types/global';
|
||||
|
||||
/**
|
||||
* 创建标准化的 API 响应
|
||||
*
|
||||
* @param data 响应数据
|
||||
* @param message 响应消息
|
||||
* @param status HTTP 状态码,默认为 200
|
||||
* @returns 标准化的 API 响应
|
||||
*/
|
||||
export function apiResponse<T = any>(
|
||||
status: number = 200,
|
||||
data?: T,
|
||||
message?: string,
|
||||
success: boolean = true,
|
||||
headers?: HeadersInit,
|
||||
): TypedResponse<ApiResponse<T>> {
|
||||
const finalSuccess = success ?? (status >= 200 && status < 300);
|
||||
|
||||
const responseBody: ApiResponse<T> = {
|
||||
success: finalSuccess,
|
||||
...(data !== undefined ? { data } : {}),
|
||||
...(message !== undefined ? { message } : {}),
|
||||
};
|
||||
|
||||
return json(responseBody, { status, headers });
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建成功的 API 响应
|
||||
* @param data 响应数据
|
||||
* @param message 成功消息
|
||||
* @returns 成功的 API 响应
|
||||
*/
|
||||
export function successResponse<T = any>(
|
||||
data?: T,
|
||||
message?: string,
|
||||
headers?: HeadersInit,
|
||||
): TypedResponse<ApiResponse<T>> {
|
||||
return apiResponse(200, data, message, true, headers);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建错误的 API 响应
|
||||
* @param message 错误消息
|
||||
* @param status HTTP 状态码,默认为 400
|
||||
* @param data 额外的错误数据
|
||||
* @returns 错误的 API 响应
|
||||
*/
|
||||
export function errorResponse<T = any>(
|
||||
status: number = 400,
|
||||
errorDetails?: string,
|
||||
headers?: HeadersInit,
|
||||
): TypedResponse<ApiResponse<T>> {
|
||||
return apiResponse<T>(status, undefined, errorDetails, false, headers);
|
||||
}
|
||||
31
app/utils/constants.ts
Normal file
31
app/utils/constants.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { LLMManager } from '~/lib/modules/llm/manager';
|
||||
|
||||
export const WORK_DIR_NAME = 'project';
|
||||
export const MODEL_REGEX = /^\[Model: (.*?)\]\n\n/;
|
||||
export const PROVIDER_REGEX = /\[Provider: (.*?)\]\n\n/;
|
||||
|
||||
const llmManager = LLMManager.getInstance();
|
||||
|
||||
export const DEFAULT_MODEL = llmManager.getDefaultModel();
|
||||
export const MINOR_MODEL = llmManager.getMinorModel();
|
||||
export const PROVIDER_LIST = llmManager.getAllProviders();
|
||||
export const DEFAULT_PROVIDER = llmManager.getDefaultProvider();
|
||||
|
||||
export const DEFAULT_MODEL_DETAILS = DEFAULT_PROVIDER.staticModels.find((m) => m.name === DEFAULT_MODEL);
|
||||
export const MINOR_MODEL_DETAILS = DEFAULT_PROVIDER.staticModels.find((m) => m.name === MINOR_MODEL);
|
||||
|
||||
export const providerBaseUrlEnvKeys: Record<string, { baseUrlKey?: string; apiTokenKey?: string }> = {};
|
||||
PROVIDER_LIST.forEach((provider) => {
|
||||
providerBaseUrlEnvKeys[provider.name] = {
|
||||
baseUrlKey: provider.config.baseUrlKey,
|
||||
apiTokenKey: provider.config.apiTokenKey,
|
||||
};
|
||||
});
|
||||
|
||||
export const getModel = (model: string) => {
|
||||
return DEFAULT_PROVIDER.getModelInstance({
|
||||
model,
|
||||
apiKeys: llmManager.getConfiguredApiKeys(),
|
||||
providerSettings: llmManager.getConfiguredProviderSettings(),
|
||||
});
|
||||
};
|
||||
13
app/utils/debounce.ts
Normal file
13
app/utils/debounce.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
export function debounce<T extends (...args: any[]) => any>(func: T, wait: number): (...args: Parameters<T>) => void {
|
||||
let timeout: NodeJS.Timeout;
|
||||
|
||||
return function executedFunction(...args: Parameters<T>) {
|
||||
const later = () => {
|
||||
clearTimeout(timeout);
|
||||
func(...args);
|
||||
};
|
||||
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(later, wait);
|
||||
};
|
||||
}
|
||||
112
app/utils/diff.ts
Normal file
112
app/utils/diff.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { createPatch } from 'diff';
|
||||
import type { PageMap, SectionMap } from '~/lib/stores/pages';
|
||||
import type { Section } from '~/types/actions';
|
||||
|
||||
interface ModifiedSection {
|
||||
type: 'diff' | 'section';
|
||||
content: string;
|
||||
}
|
||||
|
||||
type SectionModifications = Record<string, ModifiedSection>;
|
||||
|
||||
export function computeSectionModifications(sections: SectionMap, modifiedSections: Map<string, Section>) {
|
||||
const modifications: SectionModifications = {};
|
||||
|
||||
let hasModifiedPages = false;
|
||||
|
||||
for (const [sectionId, oldSection] of modifiedSections) {
|
||||
const section = sections[sectionId];
|
||||
if (!section) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const originalContent = oldSection.content;
|
||||
const unifiedDiff = diffPages(sectionId, originalContent, section.content);
|
||||
|
||||
if (!unifiedDiff) {
|
||||
// files are identical
|
||||
continue;
|
||||
}
|
||||
|
||||
hasModifiedPages = true;
|
||||
|
||||
if (unifiedDiff.length > section.content.length) {
|
||||
// if there are lots of changes we simply grab the current file content since it's smaller than the diff
|
||||
modifications[sectionId] = { type: 'section', content: section.content };
|
||||
} else {
|
||||
// otherwise we use the diff since it's smaller
|
||||
modifications[sectionId] = { type: 'diff', content: unifiedDiff };
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasModifiedPages) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return modifications;
|
||||
}
|
||||
|
||||
interface ModifiedPage {
|
||||
type: 'diff' | 'page';
|
||||
content: string;
|
||||
}
|
||||
|
||||
type PageModifications = Record<string, ModifiedPage>;
|
||||
|
||||
export function computePageModifications(pages: PageMap, modifiedFiles: Map<string, string>) {
|
||||
const modifications: PageModifications = {};
|
||||
|
||||
let hasModifiedPages = false;
|
||||
|
||||
for (const [filePath, originalContent] of modifiedFiles) {
|
||||
const page = pages[filePath];
|
||||
|
||||
if (!page) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!page.content) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const unifiedDiff = diffPages(filePath, originalContent, page.content);
|
||||
|
||||
if (!unifiedDiff) {
|
||||
// files are identical
|
||||
continue;
|
||||
}
|
||||
|
||||
hasModifiedPages = true;
|
||||
|
||||
if (unifiedDiff.length > page.content.length) {
|
||||
// if there are lots of changes we simply grab the current file content since it's smaller than the diff
|
||||
modifications[filePath] = { type: 'page', content: page.content };
|
||||
} else {
|
||||
// otherwise we use the diff since it's smaller
|
||||
modifications[filePath] = { type: 'diff', content: unifiedDiff };
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasModifiedPages) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return modifications;
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes a diff in the unified format. The only difference is that the header is omitted
|
||||
* because it will always assume that you're comparing two versions of the same file and
|
||||
* it allows us to avoid the extra characters we send back to the llm.
|
||||
*
|
||||
* @see https://www.gnu.org/software/diffutils/manual/html_node/Unified-Format.html
|
||||
*/
|
||||
export function diffPages(pageName: string, oldFileContent: string, newFileContent: string) {
|
||||
const unifiedDiff = createPatch(pageName, oldFileContent, newFileContent);
|
||||
|
||||
if (unifiedDiff === '') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return unifiedDiff;
|
||||
}
|
||||
3
app/utils/easings.ts
Normal file
3
app/utils/easings.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { cubicBezier } from 'framer-motion';
|
||||
|
||||
export const cubicEasingFn = cubicBezier(0.4, 0, 0.2, 1);
|
||||
33
app/utils/execute-scripts.ts
Normal file
33
app/utils/execute-scripts.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
export const executeScripts = (element: Element) => {
|
||||
if (!element) {
|
||||
return;
|
||||
}
|
||||
|
||||
const scripts = element.getElementsByTagName('script');
|
||||
const scriptArray = Array.from(scripts);
|
||||
for (let i = 0; i < scriptArray.length; i++) {
|
||||
const oldScript = scriptArray[i];
|
||||
executeScript(oldScript);
|
||||
}
|
||||
};
|
||||
|
||||
export const executeScript = (scriptElement: HTMLScriptElement) => {
|
||||
const newScript = document.createElement('script');
|
||||
Array.from(scriptElement.attributes).forEach((attr) => {
|
||||
newScript.setAttribute(attr.name, attr.value);
|
||||
});
|
||||
|
||||
if (scriptElement.src) {
|
||||
newScript.src = scriptElement.src;
|
||||
} else {
|
||||
if (scriptElement.textContent?.trim()?.startsWith('(function() {')) {
|
||||
newScript.textContent = scriptElement.textContent;
|
||||
} else {
|
||||
newScript.textContent = `(function() { ${scriptElement.textContent} })();`;
|
||||
}
|
||||
}
|
||||
|
||||
if (scriptElement.parentNode) {
|
||||
scriptElement.parentNode.replaceChild(newScript, scriptElement);
|
||||
}
|
||||
};
|
||||
158
app/utils/file-utils.ts
Normal file
158
app/utils/file-utils.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
export const MAX_FILES = 1000;
|
||||
|
||||
export const isBinaryFile = async (file: File): Promise<boolean> => {
|
||||
const chunkSize = 1024;
|
||||
const buffer = new Uint8Array(await file.slice(0, chunkSize).arrayBuffer());
|
||||
|
||||
for (let i = 0; i < buffer.length; i++) {
|
||||
const byte = buffer[i];
|
||||
|
||||
if (byte === 0 || (byte < 32 && byte !== 9 && byte !== 10 && byte !== 13)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
/**
|
||||
* 检测字符串是否为二进制形式
|
||||
*
|
||||
* 通过检查字符串中是否包含控制字符(ASCII 0-31,除了制表符、换行符和回车符)
|
||||
*
|
||||
* @param str 要检测的字符串
|
||||
* @param maxLength 最大检查的字符数,默认为1024
|
||||
* @returns 如果字符串可能是二进制的,返回 true;否则返回 false
|
||||
*/
|
||||
export const isBinaryString = (str: string, maxLength: number = 1024): boolean => {
|
||||
// 如果字符串为空,则不是二进制
|
||||
if (!str || str.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 限制检查的长度,避免处理大字符串时性能问题
|
||||
const checkLength = Math.min(str.length, maxLength);
|
||||
|
||||
// 检查是否包含控制字符
|
||||
for (let i = 0; i < checkLength; i++) {
|
||||
const charCode = str.charCodeAt(i);
|
||||
|
||||
// 检查是否为控制字符(除了制表符、换行符和回车符)
|
||||
if (charCode === 0 || (charCode < 32 && charCode !== 9 && charCode !== 10 && charCode !== 13)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
export const convertStringToBase64 = (fileName: string, content: string) => {
|
||||
if (!isBinaryString(content)) {
|
||||
return content;
|
||||
}
|
||||
|
||||
const buffer = Buffer.from(content, 'binary');
|
||||
const mimeType = getContentType(fileName);
|
||||
|
||||
return `data:${mimeType};base64,${buffer.toString('base64')}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* 将二进制字符串转换为 Uint8Array,适用于上传到 API
|
||||
*
|
||||
* @param binaryString 二进制字符串
|
||||
* @returns Uint8Array 表示的二进制数据
|
||||
*/
|
||||
export const binaryStringToUint8Array = (binaryString: string): Uint8Array => {
|
||||
const buffer = Buffer.from(binaryString, 'binary');
|
||||
return new Uint8Array(buffer);
|
||||
};
|
||||
|
||||
/**
|
||||
* 从路径中获取文件名
|
||||
* @param filePath 文件路径
|
||||
* @returns 文件名
|
||||
*/
|
||||
export function getFileName(filePath: string): string {
|
||||
return filePath.split('/').pop() || '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 从路径中获取文件扩展名
|
||||
* @param filePath 文件路径
|
||||
* @returns 文件扩展名
|
||||
*/
|
||||
export function getExtension(filePath: string): string {
|
||||
return filePath.split('.').pop()?.toLowerCase() || '';
|
||||
}
|
||||
|
||||
export function getContentType(filePath: string): string {
|
||||
const extension = filePath.split('.').pop()?.toLowerCase();
|
||||
|
||||
const contentTypeMap: Record<string, string> = {
|
||||
html: 'text/html',
|
||||
css: 'text/css',
|
||||
js: 'application/javascript',
|
||||
json: 'application/json',
|
||||
png: 'image/png',
|
||||
jpg: 'image/jpeg',
|
||||
jpeg: 'image/jpeg',
|
||||
gif: 'image/gif',
|
||||
svg: 'image/svg+xml',
|
||||
webp: 'image/webp',
|
||||
ico: 'image/x-icon',
|
||||
txt: 'text/plain',
|
||||
pdf: 'application/pdf',
|
||||
};
|
||||
|
||||
return contentTypeMap[extension || ''] || 'application/octet-stream';
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据 MIME 类型获取文件扩展名
|
||||
* @param mimeType MIME 类型
|
||||
* @returns 文件扩展名
|
||||
*/
|
||||
export function getExtensionFromMimeType(mimeType: string): string {
|
||||
switch (mimeType) {
|
||||
case 'image/jpeg':
|
||||
return '.jpg';
|
||||
case 'image/png':
|
||||
return '.png';
|
||||
case 'image/gif':
|
||||
return '.gif';
|
||||
case 'image/svg+xml':
|
||||
return '.svg';
|
||||
case 'image/webp':
|
||||
return '.webp';
|
||||
default:
|
||||
return '.bin';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 base64 字符串转换为二进制字符串
|
||||
* @param base64 base64 编码的字符串
|
||||
* @returns 二进制字符串
|
||||
*/
|
||||
export function base64ToBinary(base64: string): string {
|
||||
try {
|
||||
const raw = atob(base64);
|
||||
|
||||
const rawLength = raw.length;
|
||||
const array = new Uint8Array(rawLength);
|
||||
|
||||
for (let i = 0; i < rawLength; i++) {
|
||||
array[i] = raw.charCodeAt(i);
|
||||
}
|
||||
|
||||
let binaryString = '';
|
||||
array.forEach((byte) => {
|
||||
binaryString += String.fromCharCode(byte);
|
||||
});
|
||||
|
||||
return binaryString;
|
||||
} catch (error) {
|
||||
console.error('将 base64 转换为二进制字符串时出错:', error);
|
||||
return '';
|
||||
}
|
||||
}
|
||||
12
app/utils/format.ts
Normal file
12
app/utils/format.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export function formatSize(bytes: number): string {
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
let size = bytes;
|
||||
let unitIndex = 0;
|
||||
|
||||
while (size >= 1024 && unitIndex < units.length - 1) {
|
||||
size /= 1024;
|
||||
unitIndex++;
|
||||
}
|
||||
|
||||
return `${size.toFixed(1)} ${units[unitIndex]}`;
|
||||
}
|
||||
171
app/utils/html-parse.ts
Normal file
171
app/utils/html-parse.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
import { createScopedLogger } from './logger';
|
||||
|
||||
const logger = createScopedLogger('htmlParse');
|
||||
|
||||
export function isScriptContent(content: string): boolean {
|
||||
return content.trim().startsWith('<script');
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证内容是否有效
|
||||
* - 检查是否包含完整的 id 属性
|
||||
* - 检查是否符合内容类型要求(HTML、JS、CSS)
|
||||
* @param content 内容字符串
|
||||
* @returns {boolean} 内容是否有效
|
||||
*/
|
||||
export function isValidContent(content: string): boolean {
|
||||
if (!content || typeof content !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 处理可能存在的不完整标签
|
||||
content = sanitizeHtmlContent(content);
|
||||
|
||||
try {
|
||||
// 创建一个临时的 DOM 解析器
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(content, 'text/html');
|
||||
|
||||
// 检查内容类型
|
||||
if (content.trim().startsWith('<script')) {
|
||||
// JavaScript 内容验证
|
||||
const scriptElements = doc.getElementsByTagName('script');
|
||||
|
||||
if (scriptElements.length !== 1) {
|
||||
logger.warn('JS content must have exactly one script element', {
|
||||
contentLength: content.length,
|
||||
elementCount: scriptElements.length,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
const scriptElement = scriptElements[0];
|
||||
|
||||
// 检查脚本元素是否有 id 属性
|
||||
if (!scriptElement.id) {
|
||||
logger.warn('JS content must have an id attribute on the script element', { contentLength: content.length });
|
||||
return false;
|
||||
}
|
||||
|
||||
// 检查 id 是否完整,即属性在原始内容中是否以 id="..." 或 id='...' 的形式完整出现
|
||||
if (content.indexOf(`id="${scriptElement.id}"`) === -1 && content.indexOf(`id='${scriptElement.id}'`) === -1) {
|
||||
logger.warn('JS content contains incomplete id attribute', { contentLength: content.length });
|
||||
return false;
|
||||
}
|
||||
|
||||
// 检查 script 标签是否有完整的闭合标签
|
||||
if (!content.includes('</script>')) {
|
||||
logger.warn('JS content must have closing </script> tag', { contentLength: content.length });
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
if (content.trim().startsWith('<style')) {
|
||||
// CSS 内容验证
|
||||
const styleElements = doc.getElementsByTagName('style');
|
||||
|
||||
if (styleElements.length !== 1) {
|
||||
logger.warn('CSS content must have exactly one style element', {
|
||||
contentLength: content.length,
|
||||
elementCount: styleElements.length,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
const styleElement = styleElements[0];
|
||||
|
||||
// 检查样式元素是否有 id 属性
|
||||
if (!styleElement.id) {
|
||||
logger.warn('CSS content must have an id attribute on the style element', { contentLength: content.length });
|
||||
return false;
|
||||
}
|
||||
|
||||
if (content.indexOf(`id="${styleElement.id}"`) === -1 && content.indexOf(`id='${styleElement.id}'`) === -1) {
|
||||
logger.warn('CSS content contains incomplete id attribute', { contentLength: content.length });
|
||||
return false;
|
||||
}
|
||||
|
||||
// 检查 style 标签是否有完整的闭合标签
|
||||
if (!content.includes('</style>')) {
|
||||
logger.warn('style content must have closing </style> tag');
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// HTML 内容验证
|
||||
const bodyChildren = doc.body.children;
|
||||
|
||||
if (bodyChildren.length !== 1) {
|
||||
logger.warn('HTML content must have exactly one root element', {
|
||||
contentLength: content.length,
|
||||
rootCount: bodyChildren.length,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
const rootElement = bodyChildren[0];
|
||||
|
||||
// 检查根元素是否有 id 属性
|
||||
if (!rootElement.id) {
|
||||
logger.warn('HTML content must have an id attribute on the root element');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (content.indexOf(`id="${rootElement.id}"`) === -1 && content.indexOf(`id='${rootElement.id}'`) === -1) {
|
||||
logger.warn('HTML content contains incomplete id attribute');
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error('Error validating content', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理可能存在的不完整内容
|
||||
* 特别处理末尾可能存在的不完整标签如 </
|
||||
* @param content 内容字符串
|
||||
* @returns {string} 处理后的内容
|
||||
*/
|
||||
export function sanitizeHtmlContent(content: string): string {
|
||||
if (!content) {
|
||||
return content;
|
||||
}
|
||||
|
||||
// 检查是否以不完整的标签结尾
|
||||
const incompleteEndingRegex = /<\/?[a-zA-Z][a-zA-Z0-9]*$/;
|
||||
|
||||
if (incompleteEndingRegex.test(content)) {
|
||||
// 移除不完整的结束标签
|
||||
logger.warn(
|
||||
'Incomplete tag detected at the end of content',
|
||||
JSON.stringify({
|
||||
contentEnd: content.slice(-10),
|
||||
contentLength: content.length,
|
||||
}),
|
||||
);
|
||||
return content.replace(incompleteEndingRegex, '');
|
||||
}
|
||||
|
||||
// 检查是否有不匹配的标签 (简单检查)
|
||||
const openTags = content.match(/<[a-zA-Z][^>]*>/g) || [];
|
||||
const closeTags = content.match(/<\/[a-zA-Z][^>]*>/g) || [];
|
||||
|
||||
if (openTags.length !== closeTags.length) {
|
||||
logger.warn(
|
||||
'Potential unbalanced tags detected',
|
||||
JSON.stringify({
|
||||
openTagsCount: openTags.length,
|
||||
closeTagsCount: closeTags.length,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return content;
|
||||
}
|
||||
123
app/utils/logger.ts
Normal file
123
app/utils/logger.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
export type DebugLevel = 'trace' | 'debug' | 'info' | 'warn' | 'error';
|
||||
|
||||
import { Chalk } from 'chalk';
|
||||
|
||||
const chalk = new Chalk({ level: 3 });
|
||||
|
||||
type LoggerFunction = (...messages: any[]) => void;
|
||||
|
||||
interface Logger {
|
||||
trace: LoggerFunction;
|
||||
debug: LoggerFunction;
|
||||
info: LoggerFunction;
|
||||
warn: LoggerFunction;
|
||||
error: LoggerFunction;
|
||||
setLevel: (level: DebugLevel) => void;
|
||||
}
|
||||
|
||||
let currentLevel: DebugLevel =
|
||||
(process.env.LOG_LEVEL as DebugLevel | undefined) || (import.meta.env.DEV ? 'debug' : 'info');
|
||||
|
||||
export const logger: Logger = {
|
||||
trace: (...messages: any[]) => log('trace', undefined, messages),
|
||||
debug: (...messages: any[]) => log('debug', undefined, messages),
|
||||
info: (...messages: any[]) => log('info', undefined, messages),
|
||||
warn: (...messages: any[]) => log('warn', undefined, messages),
|
||||
error: (...messages: any[]) => log('error', undefined, messages),
|
||||
setLevel,
|
||||
};
|
||||
|
||||
export function createScopedLogger(scope: string): Logger {
|
||||
return {
|
||||
trace: (...messages: any[]) => log('trace', scope, messages),
|
||||
debug: (...messages: any[]) => log('debug', scope, messages),
|
||||
info: (...messages: any[]) => log('info', scope, messages),
|
||||
warn: (...messages: any[]) => log('warn', scope, messages),
|
||||
error: (...messages: any[]) => log('error', scope, messages),
|
||||
setLevel,
|
||||
};
|
||||
}
|
||||
|
||||
function setLevel(level: DebugLevel) {
|
||||
if ((level === 'trace' || level === 'debug') && import.meta.env.PROD) {
|
||||
return;
|
||||
}
|
||||
|
||||
currentLevel = level;
|
||||
}
|
||||
|
||||
function log(level: DebugLevel, scope: string | undefined, messages: any[]) {
|
||||
const levelOrder: DebugLevel[] = ['trace', 'debug', 'info', 'warn', 'error'];
|
||||
|
||||
if (levelOrder.indexOf(level) < levelOrder.indexOf(currentLevel)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const allMessages = messages.reduce((acc, current) => {
|
||||
if (acc.endsWith('\n')) {
|
||||
return acc + current;
|
||||
}
|
||||
|
||||
if (!acc) {
|
||||
return current;
|
||||
}
|
||||
|
||||
return `${acc} ${current}`;
|
||||
}, '');
|
||||
|
||||
const labelBackgroundColor = getColorForLevel(level);
|
||||
const labelTextColor = level === 'warn' ? '#000000' : '#FFFFFF';
|
||||
|
||||
const labelStyles = getLabelStyles(labelBackgroundColor, labelTextColor);
|
||||
const scopeStyles = getLabelStyles('#77828D', 'white');
|
||||
|
||||
const styles = [labelStyles];
|
||||
|
||||
if (typeof scope === 'string') {
|
||||
styles.push('', scopeStyles);
|
||||
}
|
||||
|
||||
let labelText = formatText(` ${level.toUpperCase()} `, labelTextColor, labelBackgroundColor);
|
||||
|
||||
if (scope) {
|
||||
labelText = `${labelText} ${formatText(` ${scope} `, '#FFFFFF', '77828D')}`;
|
||||
}
|
||||
|
||||
// 控制台日志
|
||||
if (typeof window !== 'undefined') {
|
||||
console.log(`%c${level.toUpperCase()}${scope ? `%c %c${scope}` : ''}`, ...styles, allMessages);
|
||||
} else {
|
||||
console.log(`${labelText}`, allMessages);
|
||||
}
|
||||
}
|
||||
|
||||
function formatText(text: string, color: string, bg: string) {
|
||||
return chalk.bgHex(bg)(chalk.hex(color)(text));
|
||||
}
|
||||
|
||||
function getLabelStyles(color: string, textColor: string) {
|
||||
return `background-color: ${color}; color: white; border: 4px solid ${color}; color: ${textColor};`;
|
||||
}
|
||||
|
||||
function getColorForLevel(level: DebugLevel): string {
|
||||
switch (level) {
|
||||
case 'trace':
|
||||
case 'debug': {
|
||||
return '#77828D';
|
||||
}
|
||||
case 'info': {
|
||||
return '#1389FD';
|
||||
}
|
||||
case 'warn': {
|
||||
return '#FFDB6C';
|
||||
}
|
||||
case 'error': {
|
||||
return '#EE4744';
|
||||
}
|
||||
default: {
|
||||
return '#000000';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const renderLogger = createScopedLogger('Render');
|
||||
144
app/utils/markdown.ts
Normal file
144
app/utils/markdown.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
import type { UnistNode, UnistParent } from 'node_modules/unist-util-visit/lib';
|
||||
import rehypeRaw from 'rehype-raw';
|
||||
import rehypeSanitize, { defaultSchema, type Options as RehypeSanitizeOptions } from 'rehype-sanitize';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import type { PluggableList, Plugin } from 'unified';
|
||||
import { SKIP, visit } from 'unist-util-visit';
|
||||
|
||||
export const allowedHTMLElements = [
|
||||
'a',
|
||||
'b',
|
||||
'blockquote',
|
||||
'br',
|
||||
'code',
|
||||
'dd',
|
||||
'del',
|
||||
'details',
|
||||
'div',
|
||||
'dl',
|
||||
'dt',
|
||||
'em',
|
||||
'h1',
|
||||
'h2',
|
||||
'h3',
|
||||
'h4',
|
||||
'h5',
|
||||
'h6',
|
||||
'hr',
|
||||
'i',
|
||||
'ins',
|
||||
'kbd',
|
||||
'li',
|
||||
'ol',
|
||||
'p',
|
||||
'pre',
|
||||
'q',
|
||||
'rp',
|
||||
'rt',
|
||||
'ruby',
|
||||
's',
|
||||
'samp',
|
||||
'source',
|
||||
'span',
|
||||
'strike',
|
||||
'strong',
|
||||
'sub',
|
||||
'summary',
|
||||
'sup',
|
||||
'table',
|
||||
'tbody',
|
||||
'td',
|
||||
'tfoot',
|
||||
'th',
|
||||
'thead',
|
||||
'tr',
|
||||
'ul',
|
||||
'var',
|
||||
'think',
|
||||
];
|
||||
|
||||
// Add custom rehype plugin
|
||||
function remarkThinkRawContent() {
|
||||
return (tree: any) => {
|
||||
visit(tree, (node: any) => {
|
||||
if (node.type === 'html' && node.value && node.value.startsWith('<think>')) {
|
||||
const cleanedContent = node.value.slice(7);
|
||||
node.value = `<div class="__uPageThought__">${cleanedContent}`;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (node.type === 'html' && node.value && node.value.startsWith('</think>')) {
|
||||
const cleanedContent = node.value.slice(8);
|
||||
node.value = `</div>${cleanedContent}`;
|
||||
}
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
const rehypeSanitizeOptions: RehypeSanitizeOptions = {
|
||||
...defaultSchema,
|
||||
tagNames: allowedHTMLElements,
|
||||
attributes: {
|
||||
...defaultSchema.attributes,
|
||||
div: [
|
||||
...(defaultSchema.attributes?.div ?? []),
|
||||
'data*',
|
||||
['className', '__uPageArtifact__', '__uPageThought__'],
|
||||
|
||||
// ['className', '__uPageThought__']
|
||||
],
|
||||
},
|
||||
strip: [],
|
||||
};
|
||||
|
||||
export function remarkPlugins(limitedMarkdown: boolean) {
|
||||
const plugins: PluggableList = [remarkGfm];
|
||||
|
||||
if (limitedMarkdown) {
|
||||
plugins.unshift(limitedMarkdownPlugin);
|
||||
}
|
||||
|
||||
plugins.unshift(remarkThinkRawContent);
|
||||
|
||||
return plugins;
|
||||
}
|
||||
|
||||
export function rehypePlugins(html: boolean) {
|
||||
const plugins: PluggableList = [];
|
||||
|
||||
if (html) {
|
||||
plugins.push(rehypeRaw, [rehypeSanitize, rehypeSanitizeOptions]);
|
||||
}
|
||||
|
||||
return plugins;
|
||||
}
|
||||
|
||||
const limitedMarkdownPlugin: Plugin = () => {
|
||||
return (tree, file) => {
|
||||
const contents = file.toString();
|
||||
|
||||
visit(tree, (node: UnistNode, index, parent: UnistParent) => {
|
||||
if (
|
||||
index == null ||
|
||||
['paragraph', 'text', 'inlineCode', 'code', 'strong', 'emphasis'].includes(node.type) ||
|
||||
!node.position
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
let value = contents.slice(node.position.start.offset, node.position.end.offset);
|
||||
|
||||
if (node.type === 'heading') {
|
||||
value = `\n${value}`;
|
||||
}
|
||||
|
||||
parent.children[index] = {
|
||||
type: 'text',
|
||||
value,
|
||||
} as any;
|
||||
|
||||
return [SKIP, index] as const;
|
||||
});
|
||||
};
|
||||
};
|
||||
4
app/utils/mobile.ts
Normal file
4
app/utils/mobile.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export function isMobile() {
|
||||
// we use sm: as the breakpoint for mobile. It's currently set to 640px
|
||||
return globalThis.innerWidth < 640;
|
||||
}
|
||||
4
app/utils/os.ts
Normal file
4
app/utils/os.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
// Helper to detect OS
|
||||
export const isMac = typeof navigator !== 'undefined' ? navigator.platform.toLowerCase().includes('mac') : false;
|
||||
export const isWindows = typeof navigator !== 'undefined' ? navigator.platform.toLowerCase().includes('win') : false;
|
||||
export const isLinux = typeof navigator !== 'undefined' ? navigator.platform.toLowerCase().includes('linux') : false;
|
||||
35
app/utils/page.ts
Normal file
35
app/utils/page.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import type { MapStore } from 'nanostores';
|
||||
import type { SectionMap } from '~/lib/stores/pages';
|
||||
import type { Page } from '~/types/actions';
|
||||
|
||||
export const pagesToArtifacts = (pages: { [pageName: string]: Page }, sections: MapStore<SectionMap>): string => {
|
||||
return Object.keys(pages)
|
||||
.map((pageName) => {
|
||||
const page = pages[pageName];
|
||||
const sectionId = page.actionIds;
|
||||
|
||||
if (sectionId.length === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return `
|
||||
<uPageArtifact id="${Date.now() + pageName}" name="${pageName}" title="${page.title}">
|
||||
${sectionId.map((sectionId) => {
|
||||
const section = sections.get()[sectionId];
|
||||
|
||||
if (section == null) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return `
|
||||
<uPageAction id="${Date.now()}" pageName="${pageName}" action="${section.action}" domId="${section.domId}" sort="${section.sort}">
|
||||
${section.content}
|
||||
</uPageAction>
|
||||
`;
|
||||
})}
|
||||
</uPageArtifact>
|
||||
`;
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
};
|
||||
19
app/utils/path.ts
Normal file
19
app/utils/path.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
// Browser-compatible path utilities
|
||||
import type { ParsedPath } from 'path';
|
||||
import pathBrowserify from 'path-browserify';
|
||||
|
||||
/**
|
||||
* A browser-compatible path utility that mimics Node's path module
|
||||
* Using path-browserify for consistent behavior in browser environments
|
||||
*/
|
||||
export const path = {
|
||||
join: (...paths: string[]): string => pathBrowserify.join(...paths),
|
||||
dirname: (path: string): string => pathBrowserify.dirname(path),
|
||||
basename: (path: string, ext?: string): string => pathBrowserify.basename(path, ext),
|
||||
extname: (path: string): string => pathBrowserify.extname(path),
|
||||
relative: (from: string, to: string): string => pathBrowserify.relative(from, to),
|
||||
isAbsolute: (path: string): boolean => pathBrowserify.isAbsolute(path),
|
||||
normalize: (path: string): string => pathBrowserify.normalize(path),
|
||||
parse: (path: string): ParsedPath => pathBrowserify.parse(path),
|
||||
format: (pathObject: ParsedPath): string => pathBrowserify.format(pathObject),
|
||||
} as const;
|
||||
52
app/utils/prettier.ts
Normal file
52
app/utils/prettier.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import prettier, { type BuiltInParserName, type Options } from 'prettier';
|
||||
import * as html from 'prettier/plugins/html';
|
||||
import * as css from 'prettier/plugins/postcss';
|
||||
import * as javascript from 'prettier/plugins/typescript';
|
||||
import { path } from './path';
|
||||
|
||||
const ignoreFiles = ['tailwindcss.js', 'iconify-icon.min.js'];
|
||||
|
||||
export function formatCode(code: string, options?: Options) {
|
||||
return prettier.format(code, {
|
||||
...options,
|
||||
parser: 'html',
|
||||
plugins: [html, css, javascript],
|
||||
});
|
||||
}
|
||||
|
||||
export function formatFile(filePath: string, code: string, options?: Options) {
|
||||
if (ignoreFiles.includes(filePath)) {
|
||||
return code;
|
||||
}
|
||||
const parser = getParser(filePath);
|
||||
if (!parser) {
|
||||
return code;
|
||||
}
|
||||
return prettier.format(code, {
|
||||
...options,
|
||||
parser,
|
||||
plugins: [html, css, javascript],
|
||||
});
|
||||
}
|
||||
|
||||
export function getParser(filePath: string): BuiltInParserName | undefined {
|
||||
const ext = path.extname(filePath);
|
||||
switch (ext) {
|
||||
case '.html':
|
||||
return 'html';
|
||||
case '.css':
|
||||
return 'css';
|
||||
case '.js':
|
||||
return 'typescript';
|
||||
case '.ts':
|
||||
return 'typescript';
|
||||
case '.md':
|
||||
return 'markdown';
|
||||
case '.vue':
|
||||
return 'vue';
|
||||
case '.json':
|
||||
return 'json';
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
6
app/utils/react.ts
Normal file
6
app/utils/react.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { memo } from 'react';
|
||||
|
||||
export const genericMemo: <T extends keyof React.JSX.IntrinsicElements | React.JSXElementConstructor<any>>(
|
||||
component: T,
|
||||
propsAreEqual?: (prevProps: React.ComponentProps<T>, nextProps: React.ComponentProps<T>) => boolean,
|
||||
) => T & { displayName?: string } = memo;
|
||||
154
app/utils/sampler.ts
Normal file
154
app/utils/sampler.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* Creates a function that samples calls at regular intervals and captures trailing calls.
|
||||
* - Drops calls that occur between sampling intervals
|
||||
* - Takes one call per sampling interval if available
|
||||
* - Captures the last call if no call was made during the interval
|
||||
*
|
||||
* @param fn The function to sample
|
||||
* @param sampleInterval How often to sample calls (in ms)
|
||||
* @returns The sampled function
|
||||
*/
|
||||
export function createSampler<T extends (...args: any[]) => any>(fn: T, sampleInterval: number): T {
|
||||
let lastArgs: Parameters<T> | null = null;
|
||||
let lastTime = 0;
|
||||
let timeout: NodeJS.Timeout | null = null;
|
||||
|
||||
// Create a function with the same type as the input function
|
||||
const sampled = function (this: any, ...args: Parameters<T>) {
|
||||
const now = Date.now();
|
||||
lastArgs = args;
|
||||
|
||||
// If we're within the sample interval, just store the args
|
||||
if (now - lastTime < sampleInterval) {
|
||||
// Set up trailing call if not already set
|
||||
if (!timeout) {
|
||||
timeout = setTimeout(
|
||||
() => {
|
||||
timeout = null;
|
||||
lastTime = Date.now();
|
||||
|
||||
if (lastArgs) {
|
||||
fn.apply(this, lastArgs);
|
||||
lastArgs = null;
|
||||
}
|
||||
},
|
||||
sampleInterval - (now - lastTime),
|
||||
);
|
||||
}
|
||||
|
||||
return undefined as unknown as ReturnType<T>;
|
||||
}
|
||||
|
||||
// If we're outside the interval, execute immediately
|
||||
lastTime = now;
|
||||
const result = fn.apply(this, args);
|
||||
lastArgs = null;
|
||||
return result;
|
||||
} as unknown as T;
|
||||
|
||||
return sampled;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建一个采样异步函数,在指定的时间间隔内只执行一次,并捕获尾随调用。
|
||||
* - 在采样间隔内的调用会被丢弃,但会保存最后一次调用的参数
|
||||
* - 每个采样间隔只执行一次调用
|
||||
* - 如果在间隔内没有调用,则捕获最后一次调用
|
||||
* - 始终返回一个 Promise
|
||||
* - 添加了执行状态管理,防止在高负载情况下连续多次执行
|
||||
*
|
||||
* @param fn 要采样的异步函数
|
||||
* @param sampleInterval 采样间隔(毫秒)
|
||||
* @param options 配置选项
|
||||
* @returns 采样后的异步函数
|
||||
*/
|
||||
export function createAsyncSampler<T extends (...args: any[]) => Promise<any>>(fn: T, sampleInterval: number): T {
|
||||
// 初始化状态变量,确保有合理的默认值
|
||||
const now = Date.now();
|
||||
let lastArgs: Parameters<T> | null = null;
|
||||
let timeout: NodeJS.Timeout | null = null;
|
||||
let lastThis: any = null;
|
||||
let isExecuting = false; // 执行状态标志
|
||||
let nextExecutionTime = now + sampleInterval; // 初始化为当前时间 + 采样间隔
|
||||
let pendingPromise: Promise<any> | null = null; // 当前正在执行的 Promise
|
||||
|
||||
// 清除所有定时器
|
||||
const clearAllTimeouts = () => {
|
||||
if (timeout) {
|
||||
clearTimeout(timeout);
|
||||
timeout = null;
|
||||
}
|
||||
};
|
||||
|
||||
// 安全地重置执行状态
|
||||
const resetExecutionState = () => {
|
||||
isExecuting = false;
|
||||
pendingPromise = null;
|
||||
};
|
||||
|
||||
// 创建与输入函数类型相同的函数
|
||||
const sampled = function (this: any, ...args: Parameters<T>) {
|
||||
const now = Date.now();
|
||||
lastArgs = args;
|
||||
lastThis = this;
|
||||
|
||||
// 检查是否可以执行
|
||||
// 1. 当前没有正在执行的任务
|
||||
// 2. 当前时间已经超过了下次允许执行的时间
|
||||
// 3. 没有待处理的 Promise
|
||||
const canExecuteNow = !isExecuting && now >= nextExecutionTime && !pendingPromise;
|
||||
|
||||
if (!canExecuteNow) {
|
||||
const waitTime = Math.max(0, nextExecutionTime - now);
|
||||
// 清除之前的定时器,确保只有一个定时器在运行
|
||||
clearAllTimeouts();
|
||||
|
||||
// 设置新的定时器,延迟执行
|
||||
// 使用 Math.max 确保至少等待 100ms,避免过于频繁的检查
|
||||
const delayTime = Math.max(100, Math.min(sampleInterval, waitTime));
|
||||
|
||||
timeout = setTimeout(() => {
|
||||
const currentTime = Date.now();
|
||||
// 再次检查是否可以执行
|
||||
if (!isExecuting && currentTime >= nextExecutionTime && !pendingPromise) {
|
||||
clearAllTimeouts();
|
||||
return executeTask();
|
||||
}
|
||||
}, delayTime);
|
||||
|
||||
// 返回一个空的 Promise,以支持链式调用
|
||||
return Promise.resolve() as any as ReturnType<T>;
|
||||
}
|
||||
|
||||
return executeTask();
|
||||
};
|
||||
|
||||
async function executeTask(): Promise<void> {
|
||||
isExecuting = true;
|
||||
|
||||
try {
|
||||
if (!lastArgs) {
|
||||
resetExecutionState();
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
const result = fn.apply(lastThis, lastArgs);
|
||||
lastArgs = null;
|
||||
|
||||
pendingPromise = result;
|
||||
|
||||
return result.finally(() => {
|
||||
// 更新下一次执行时间
|
||||
nextExecutionTime = Date.now() + sampleInterval;
|
||||
resetExecutionState();
|
||||
});
|
||||
} catch (error) {
|
||||
// 即使发生错误也更新下一次执行时间
|
||||
nextExecutionTime = Date.now() + sampleInterval;
|
||||
resetExecutionState();
|
||||
return Promise.reject(error);
|
||||
}
|
||||
}
|
||||
|
||||
return sampled as unknown as T;
|
||||
}
|
||||
23
app/utils/strip-indent.ts
Normal file
23
app/utils/strip-indent.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
export function stripIndents(value: string): string;
|
||||
export function stripIndents(strings: TemplateStringsArray, ...values: any[]): string;
|
||||
export function stripIndents(arg0: string | TemplateStringsArray, ...values: any[]) {
|
||||
if (typeof arg0 !== 'string') {
|
||||
const processedString = arg0.reduce((acc, curr, i) => {
|
||||
acc += curr + (values[i] ?? '');
|
||||
return acc;
|
||||
}, '');
|
||||
|
||||
return _stripIndents(processedString);
|
||||
}
|
||||
|
||||
return _stripIndents(arg0);
|
||||
}
|
||||
|
||||
function _stripIndents(value: string) {
|
||||
return value
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.join('\n')
|
||||
.trimStart()
|
||||
.replace(/[\r\n]$/, '');
|
||||
}
|
||||
64
app/utils/throttle.ts
Normal file
64
app/utils/throttle.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
export function throttle<T extends (...args: any[]) => any>(func: T, wait: number): (...args: Parameters<T>) => void {
|
||||
let timeout: NodeJS.Timeout | null = null;
|
||||
let lastArgs: Parameters<T> | null = null;
|
||||
let lastCallTime = 0;
|
||||
|
||||
return function executedFunction(...args: Parameters<T>) {
|
||||
const now = Date.now();
|
||||
const remaining = wait - (now - lastCallTime);
|
||||
|
||||
lastArgs = args;
|
||||
|
||||
if (remaining <= 0) {
|
||||
lastCallTime = now;
|
||||
func(...args);
|
||||
lastArgs = null;
|
||||
} else if (!timeout) {
|
||||
timeout = setTimeout(() => {
|
||||
lastCallTime = Date.now();
|
||||
timeout = null;
|
||||
|
||||
if (lastArgs) {
|
||||
func(...lastArgs);
|
||||
lastArgs = null;
|
||||
}
|
||||
}, remaining);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function throttleWithTrailing<T extends (...args: any[]) => any>(
|
||||
func: T,
|
||||
wait: number,
|
||||
): (...args: Parameters<T>) => void {
|
||||
let timeout: NodeJS.Timeout | null = null;
|
||||
let lastArgs: Parameters<T> | null = null;
|
||||
let lastCallTime = 0;
|
||||
|
||||
return function executedFunction(...args: Parameters<T>) {
|
||||
const now = Date.now();
|
||||
const remaining = wait - (now - lastCallTime);
|
||||
|
||||
lastArgs = args;
|
||||
|
||||
if (remaining <= 0) {
|
||||
if (timeout) {
|
||||
clearTimeout(timeout);
|
||||
timeout = null;
|
||||
}
|
||||
|
||||
lastCallTime = now;
|
||||
func(...args);
|
||||
} else if (!timeout) {
|
||||
timeout = setTimeout(() => {
|
||||
lastCallTime = Date.now();
|
||||
timeout = null;
|
||||
|
||||
if (lastArgs) {
|
||||
func(...lastArgs);
|
||||
lastArgs = null;
|
||||
}
|
||||
}, remaining);
|
||||
}
|
||||
};
|
||||
}
|
||||
34
app/utils/token.ts
Normal file
34
app/utils/token.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import type { UIMessage, UIMessagePart } from 'ai';
|
||||
import { Tiktoken } from 'js-tiktoken/lite';
|
||||
import o200k_base from 'js-tiktoken/ranks/o200k_base';
|
||||
|
||||
const tiktoken = new Tiktoken(o200k_base);
|
||||
|
||||
export function encode(text: string) {
|
||||
return tiktoken.encode(text);
|
||||
}
|
||||
|
||||
export function decode(tokens: number[]) {
|
||||
return tiktoken.decode(tokens);
|
||||
}
|
||||
|
||||
export function approximatePromptTokenCount(messages: UIMessage[]): number {
|
||||
return messages.reduce((acc, message) => {
|
||||
return acc + approximateUsageFromContent(message.parts);
|
||||
}, 0);
|
||||
}
|
||||
|
||||
export function approximateUsageFromContent(parts: Array<UIMessagePart<any, any>>): number {
|
||||
let totalLength = 0;
|
||||
|
||||
for (const part of parts) {
|
||||
if (part.type === 'text') {
|
||||
totalLength += encode(part.text).length;
|
||||
}
|
||||
|
||||
if (part.type === 'reasoning') {
|
||||
totalLength += encode(part.text).length;
|
||||
}
|
||||
}
|
||||
return totalLength;
|
||||
}
|
||||
3
app/utils/unreachable.ts
Normal file
3
app/utils/unreachable.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export function unreachable(message: string): never {
|
||||
throw new Error(`Unreachable: ${message}`);
|
||||
}
|
||||
17
app/utils/uuid.ts
Normal file
17
app/utils/uuid.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* 生成符合 RFC 4122 标准的 UUID v4
|
||||
* 格式类似于: ea7ae54b-a116-4564-b805-f97fe211d4dd
|
||||
* @returns {string} 生成的 UUID 字符串
|
||||
*/
|
||||
export function generateUUID(): string {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成不带连字符的 UUID
|
||||
* 格式类似于: ea7ae54ba1164564b805f97fe211d4dd
|
||||
* @returns {string} 生成的无连字符 UUID 字符串
|
||||
*/
|
||||
export function generateCompactUUID(): string {
|
||||
return generateUUID().replace(/-/g, '');
|
||||
}
|
||||
Reference in New Issue
Block a user