🎉 first commit

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

57
app/utils/api-response.ts Normal file
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1,3 @@
import { cubicBezier } from 'framer-motion';
export const cubicEasingFn = cubicBezier(0.4, 0, 0.2, 1);

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1,3 @@
export function unreachable(message: string): never {
throw new Error(`Unreachable: ${message}`);
}

17
app/utils/uuid.ts Normal file
View 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, '');
}