refactor: repartition server-side and client-side code

This commit is contained in:
LIlGG
2025-10-11 18:26:07 +08:00
parent 7acc4949fb
commit e9b573a276
309 changed files with 631 additions and 962 deletions

View File

@@ -1,57 +0,0 @@
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);
}

View File

@@ -1,2 +0,0 @@
export const MODEL_REGEX = /^\[Model: (.*?)\]\n\n/;
export const PROVIDER_REGEX = /\[Provider: (.*?)\]\n\n/;

58
app/utils/crypto.ts Normal file
View File

@@ -0,0 +1,58 @@
const encoder = new TextEncoder();
const decoder = new TextDecoder();
const IV_LENGTH = 16;
export async function encrypt(key: string, data: string) {
const iv = crypto.getRandomValues(new Uint8Array(IV_LENGTH));
const cryptoKey = await getKey(key);
const ciphertext = await crypto.subtle.encrypt(
{
name: 'AES-CBC',
iv,
},
cryptoKey,
encoder.encode(data),
);
const bundle = new Uint8Array(IV_LENGTH + ciphertext.byteLength);
bundle.set(new Uint8Array(ciphertext));
bundle.set(iv, ciphertext.byteLength);
return decodeBase64(bundle);
}
export async function decrypt(key: string, payload: string) {
const bundle = encodeBase64(payload);
const iv = new Uint8Array(bundle.buffer, bundle.byteLength - IV_LENGTH);
const ciphertext = new Uint8Array(bundle.buffer, 0, bundle.byteLength - IV_LENGTH);
const cryptoKey = await getKey(key);
const plaintext = await crypto.subtle.decrypt(
{
name: 'AES-CBC',
iv,
},
cryptoKey,
ciphertext,
);
return decoder.decode(plaintext);
}
async function getKey(key: string) {
return await crypto.subtle.importKey('raw', encodeBase64(key), { name: 'AES-CBC' }, false, ['encrypt', 'decrypt']);
}
function decodeBase64(encoded: Uint8Array) {
const byteChars = Array.from(encoded, (byte) => String.fromCodePoint(byte));
return btoa(byteChars.join(''));
}
function encodeBase64(data: string) {
return Uint8Array.from(atob(data), (ch) => ch.codePointAt(0)!);
}

View File

@@ -1,13 +0,0 @@
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);
};
}

View File

@@ -1,112 +0,0 @@
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;
}

View File

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

View File

@@ -1,33 +0,0 @@
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);
}
};

View File

@@ -1,12 +0,0 @@
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]}`;
}

View File

@@ -1,224 +0,0 @@
import { describe, expect, it } from 'vitest';
import { isScriptContent, isValidContent } from './html-parse';
describe('html-parse', () => {
describe('isScriptContent', () => {
it('应该识别 script 标签内容', () => {
expect(isScriptContent('<script id="test">console.log("hello")</script>')).toBe(true);
expect(isScriptContent(' <script id="test">console.log("hello")</script>')).toBe(true);
});
it('应该不识别非 script 标签内容', () => {
expect(isScriptContent('<div id="test">hello</div>')).toBe(false);
expect(isScriptContent('<style id="test">body { color: red; }</style>')).toBe(false);
expect(isScriptContent('hello world')).toBe(false);
});
});
describe('isValidContent - 有效的 HTML 内容', () => {
it('应该接受根节点完整且有 id 的 HTML内部元素可不完整', () => {
expect(isValidContent('<section id="xxxx"><div>h')).toBe(true);
expect(isValidContent('<section id="xxxx"><div>hello')).toBe(true);
expect(isValidContent('<section id="xxxx"><div>hello world</div>')).toBe(true);
expect(isValidContent('<section id="xxxx">hello world')).toBe(true);
expect(isValidContent('<section id="xxxx">hello world</br>')).toBe(true);
});
it('应该接受完整的 HTML 元素', () => {
expect(isValidContent('<div id="root"></div>')).toBe(true);
expect(isValidContent('<div id="root">content</div>')).toBe(true);
expect(isValidContent('<section id="main"><h1>Title</h1></section>')).toBe(true);
});
it('应该接受带有多个属性的根元素', () => {
expect(isValidContent('<div id="root" class="container">content</div>')).toBe(true);
expect(isValidContent('<div class="container" id="root" data-test="value">content</div>')).toBe(true);
});
it('应该接受使用单引号的 id 属性', () => {
expect(isValidContent("<div id='root'>content</div>")).toBe(true);
expect(isValidContent("<div id='root' class='container'>content</div>")).toBe(true);
});
});
describe('isValidContent - 无效的 HTML 内容', () => {
it('应该拒绝根节点不完整的 HTML', () => {
expect(isValidContent('<div')).toBe(false);
expect(isValidContent('<div id="xx')).toBe(false);
expect(isValidContent('<div id="xxxx"><div>hello world</d')).toBe(false);
expect(isValidContent('<div id="xxxx"><div>hello world</div')).toBe(false);
});
it('应该拒绝缺少 id 属性的根元素', () => {
expect(isValidContent('<div>content</div>')).toBe(false);
expect(isValidContent('<div class="container">content</div>')).toBe(false);
});
it('应该拒绝 id 属性不完整的根元素', () => {
expect(isValidContent('<div id=')).toBe(false);
expect(isValidContent('<div id="')).toBe(false);
expect(isValidContent('<div id="root')).toBe(false);
});
it('应该拒绝末尾有不完整标签的内容', () => {
expect(isValidContent('<div id="root">content</')).toBe(false);
expect(isValidContent('<div id="root">content</d')).toBe(false);
expect(isValidContent('<div id="root">content</di')).toBe(false);
});
it('应该拒绝末尾有孤立 < 字符的内容', () => {
expect(isValidContent('<div id="xxxx"><div>hello world<')).toBe(false);
expect(isValidContent('<div id="root">content<')).toBe(false);
expect(isValidContent('<div id="test"><span>test<')).toBe(false);
});
it('应该接受 < 作为普通字符后跟完整闭合标签的内容', () => {
expect(isValidContent('<div id="xxxx"><div>hello world<</div>')).toBe(true);
expect(isValidContent('<div id="root">5 < 10</div>')).toBe(true);
expect(isValidContent('<div id="test"><span>a<b</span></div>')).toBe(true);
});
});
describe('isValidContent - 有效的 script 内容', () => {
it('应该接受完整的 script 标签', () => {
expect(isValidContent('<script id="test">console.log("hello");</script>')).toBe(true);
expect(isValidContent('<script id="test"></script>')).toBe(true);
expect(isValidContent('<script id="main" type="text/javascript">alert("test");</script>')).toBe(true);
});
it('应该接受使用单引号的 script 标签', () => {
expect(isValidContent("<script id='test'>console.log('hello');</script>")).toBe(true);
});
it('应该接受带有多行代码的 script 标签', () => {
const content = `<script id="test">
function hello() {
console.log("world");
}
hello();
</script>`;
expect(isValidContent(content)).toBe(true);
});
});
describe('isValidContent - 无效的 script 内容', () => {
it('应该拒绝没有 id 的 script 标签', () => {
expect(isValidContent('<script>console.log("hello");</script>')).toBe(false);
});
it('应该拒绝没有闭合标签的 script', () => {
expect(isValidContent('<script id="test">console.log("hello");')).toBe(false);
expect(isValidContent('<script id="test">console.log("hello");</scrip')).toBe(false);
expect(isValidContent('<script id="test">console.log("hello");</script')).toBe(false);
});
it('应该拒绝 script 开始标签不完整', () => {
expect(isValidContent('<script id="test"')).toBe(false);
expect(isValidContent('<script id="test')).toBe(false);
expect(isValidContent('<script id=')).toBe(false);
});
it('应该拒绝 id 属性不完整的 script 标签', () => {
expect(isValidContent('<script id="test>console.log("hello");</script>')).toBe(false);
expect(isValidContent('<script id=test>console.log("hello");</script>')).toBe(false);
});
it('应该拒绝末尾有孤立 < 字符的 script 内容', () => {
expect(isValidContent('<script id="test">console.log("hello")<')).toBe(false);
expect(isValidContent('<script id="test">var a = 5<')).toBe(false);
});
it('应该接受 script 中 < 作为普通字符后跟完整闭合标签', () => {
expect(isValidContent('<script id="test">if (5 < 10) { console.log("yes"); }</script>')).toBe(true);
expect(isValidContent('<script id="test">var a = b < c;</script>')).toBe(true);
});
});
describe('isValidContent - 有效的 style 内容', () => {
it('应该接受完整的 style 标签', () => {
expect(isValidContent('<style id="test">body { color: red; }</style>')).toBe(true);
expect(isValidContent('<style id="test"></style>')).toBe(true);
expect(isValidContent('<style id="main" type="text/css">.class { margin: 0; }</style>')).toBe(true);
});
it('应该接受使用单引号的 style 标签', () => {
expect(isValidContent("<style id='test'>body { color: red; }</style>")).toBe(true);
});
it('应该接受带有多行样式的 style 标签', () => {
const content = `<style id="test">
body {
margin: 0;
padding: 0;
}
.container {
width: 100%;
}
</style>`;
expect(isValidContent(content)).toBe(true);
});
});
describe('isValidContent - 无效的 style 内容', () => {
it('应该拒绝没有 id 的 style 标签', () => {
expect(isValidContent('<style>body { color: red; }</style>')).toBe(false);
});
it('应该拒绝没有闭合标签的 style', () => {
expect(isValidContent('<style id="test">body { color: red; }')).toBe(false);
expect(isValidContent('<style id="test">body { color: red; }</styl')).toBe(false);
expect(isValidContent('<style id="test">body { color: red; }</style')).toBe(false);
});
it('应该拒绝 style 开始标签不完整', () => {
expect(isValidContent('<style id="test"')).toBe(false);
expect(isValidContent('<style id="test')).toBe(false);
expect(isValidContent('<style id=')).toBe(false);
});
it('应该拒绝 id 属性不完整的 style 标签', () => {
expect(isValidContent('<style id="test>body { color: red; }</style>')).toBe(false);
expect(isValidContent('<style id=test>body { color: red; }</style>')).toBe(false);
});
it('应该拒绝末尾有孤立 < 字符的 style 内容', () => {
expect(isValidContent('<style id="test">body { color: red; }<')).toBe(false);
expect(isValidContent('<style id="test">.class { margin: 0; }<')).toBe(false);
});
it('应该接受 style 中 < 作为普通字符后跟完整闭合标签', () => {
expect(isValidContent('<style id="test">/* comment < test */ body { color: red; }</style>')).toBe(true);
});
});
describe('isValidContent - 边界情况', () => {
it('应该拒绝空字符串', () => {
expect(isValidContent('')).toBe(false);
});
it('应该拒绝只有空格的字符串', () => {
expect(isValidContent(' ')).toBe(false);
});
it('应该拒绝 null 和 undefined', () => {
expect(isValidContent(null as any)).toBe(false);
expect(isValidContent(undefined as any)).toBe(false);
});
it('应该拒绝非字符串类型', () => {
expect(isValidContent(123 as any)).toBe(false);
expect(isValidContent({} as any)).toBe(false);
expect(isValidContent([] as any)).toBe(false);
});
it('应该拒绝纯文本内容(不是标签)', () => {
expect(isValidContent('hello world')).toBe(false);
});
it('应该接受带有前导空格的有效内容', () => {
expect(isValidContent(' <div id="root">content</div>')).toBe(true);
expect(isValidContent(' <script id="test">console.log("hello");</script>')).toBe(true);
expect(isValidContent(' <style id="test">body { color: red; }</style>')).toBe(true);
});
});
});

View File

@@ -1,290 +0,0 @@
import { createScopedLogger } from './logger';
const logger = createScopedLogger('htmlParse');
export function isScriptContent(content: string): boolean {
return content.trim().startsWith('<script');
}
/**
* 验证第一个标签(根节点)的完整性
* @param content 内容字符串
* @returns 验证结果,包括是否有效、根元素 id 和标签名
*/
function validateRootTagCompleteness(content: string): { valid: boolean; rootId?: string; tagName?: string } {
const trimmedContent = content.trim();
if (!trimmedContent.startsWith('<')) {
logger.warn('内容不以标签开始');
return { valid: false };
}
// 查找第一个完整的开始标签(找到第一个 >
const firstTagEndIndex = trimmedContent.indexOf('>');
if (firstTagEndIndex === -1) {
logger.warn('根标签不完整:未找到闭合的 >');
return { valid: false };
}
// 提取第一个完整的标签(包括 >
const firstTag = trimmedContent.substring(0, firstTagEndIndex + 1);
// 提取标签名称(支持 <tagName 或 <tagName空格 的形式)
const tagNameMatch = firstTag.match(/^<([a-zA-Z][a-zA-Z0-9]*)/);
if (!tagNameMatch) {
logger.warn('无法提取标签名称');
return { valid: false };
}
const tagName = tagNameMatch[1];
// 验证 id 属性是否存在且完整
// 支持 id="..." 或 id='...' 两种形式
const idPattern = /id=["']([^"']+)["']/;
const idMatch = firstTag.match(idPattern);
if (!idMatch) {
logger.warn('根标签缺少完整的 id 属性', { tagName });
return { valid: false };
}
const rootId = idMatch[1];
return { valid: true, rootId, tagName };
}
/**
* 验证指定标签的闭合标签是否完整
* @param content 内容字符串
* @param tagName 标签名称
* @returns 是否存在完整的闭合标签
*/
function validateClosingTag(content: string, tagName: string): boolean {
const closingTag = `</${tagName}>`;
return content.includes(closingTag);
}
/**
* 检查内容中是否存在明显不完整的标签
* @param content 内容字符串
* @returns 是否存在不完整的标签
*/
function hasIncompleteTag(content: string): boolean {
// 检查内容末尾是否有不完整的闭合标签
// 匹配 </、</d、</div 等(没有 > 的闭合标签)
const incompleteClosingTagPattern = /<\/([a-zA-Z][a-zA-Z0-9]*)?$/;
if (incompleteClosingTagPattern.test(content)) {
logger.warn('检测到不完整的闭合标签', { contentEnd: content.slice(-20) });
return true;
}
// 检查内容末尾是否有不完整的开始标签
// 匹配以 < 开头但没有对应 > 的情况
const incompleteOpeningTagPattern = /<[a-zA-Z][^>]*$/;
if (incompleteOpeningTagPattern.test(content)) {
logger.warn('检测到不完整的开始标签', { contentEnd: content.slice(-20) });
return true;
}
// 检查内容末尾是否有孤立的 <
// 匹配末尾单独的 < 字符(不属于任何标签)
const isolatedLessThanPattern = /<$/;
if (isolatedLessThanPattern.test(content)) {
logger.warn('检测到末尾孤立的 < 字符', { contentEnd: content.slice(-20) });
return true;
}
return false;
}
/**
* 验证内容是否有效
* - 检查是否包含完整的 id 属性
* - 检查是否符合内容类型要求HTML、JS、CSS
* @param content 内容字符串
* @returns {boolean} 内容是否有效
*/
export function isValidContent(content: string): boolean {
if (!content || typeof content !== 'string') {
return false;
}
// 检查是否存在明显不完整的标签
if (hasIncompleteTag(content)) {
logger.warn('内容包含不完整的标签');
return false;
}
// 验证根节点标签完整性
const rootValidation = validateRootTagCompleteness(content);
if (!rootValidation.valid) {
logger.warn('根节点标签验证失败');
return false;
}
const { rootId } = rootValidation;
try {
// 创建一个临时的 DOM 解析器
const parser = new DOMParser();
const doc = parser.parseFromString(content, 'text/html');
// 检查内容类型
if (content.trim().startsWith('<script')) {
// 对于 script验证闭合标签完整性
if (!validateClosingTag(content, 'script')) {
logger.warn('script 标签缺少完整的闭合标签 </script>');
return false;
}
// 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 与 DOMParser 解析的 id 一致
if (scriptElement.id !== rootId) {
logger.warn('script 标签 id 不一致', {
extractedId: rootId,
parsedId: scriptElement.id,
});
return false;
}
return true;
}
if (content.trim().startsWith('<style')) {
// 对于 style验证闭合标签完整性
if (!validateClosingTag(content, 'style')) {
logger.warn('style 标签缺少完整的闭合标签 </style>');
return false;
}
// 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;
}
// 验证提取的 id 与 DOMParser 解析的 id 一致
if (styleElement.id !== rootId) {
logger.warn('style 标签 id 不一致', {
extractedId: rootId,
parsedId: styleElement.id,
});
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;
}
// 验证提取的 id 与 DOMParser 解析的 id 一致
if (rootElement.id !== rootId) {
logger.warn('HTML 根元素 id 不一致', {
extractedId: rootId,
parsedId: rootElement.id,
});
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;
}

View File

@@ -15,9 +15,82 @@ interface Logger {
setLevel: (level: DebugLevel) => void;
}
const isServer = typeof window === 'undefined';
let currentLevel: DebugLevel =
(process.env.LOG_LEVEL as DebugLevel | undefined) || (import.meta.env.DEV ? 'debug' : 'info');
let winstonLogger: any = null;
let winstonInitialized = false;
async function initializeWinston() {
if (!isServer || winstonInitialized) {
return;
}
winstonInitialized = true;
try {
const fs = await import('fs');
const path = await import('path');
const winston = await import('winston');
const { default: DailyRotateFile } = await import('winston-daily-rotate-file');
const enableFileLogging = process.env.USAGE_LOG_FILE !== 'false';
if (!enableFileLogging) {
return;
}
const logDir = path.join(process.cwd(), 'logs');
try {
if (!fs.existsSync(logDir)) {
fs.mkdirSync(logDir, { recursive: true });
}
} catch (error) {
console.error('Failed to create logs directory:', error);
return;
}
winstonLogger = winston.createLogger({
level: currentLevel,
format: winston.format.combine(
winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
winston.format.printf((info) => {
const { timestamp, level, message, scope } = info;
return `${timestamp} [${level.toUpperCase()}]${scope ? ` [${scope}]` : ''}: ${message}`;
}),
),
transports: [
// 按日期分割的错误日志文件
new DailyRotateFile({
filename: path.join(logDir, 'error-%DATE%.log'),
datePattern: 'YYYY-MM-DD',
level: 'error',
maxSize: '10m', // 10MB
maxFiles: 14, // 保留14天
format: winston.format.combine(winston.format.timestamp(), winston.format.json()),
}) as any,
// 所有级别日志
new DailyRotateFile({
filename: path.join(logDir, 'combined-%DATE%.log'),
datePattern: 'YYYY-MM-DD',
maxSize: '20m', // 20MB
maxFiles: 7, // 保留7天
format: winston.format.combine(winston.format.timestamp(), winston.format.json()),
}) as any,
],
});
} catch (error) {
console.error('Failed to initialize Winston logger:', error);
}
}
if (isServer) {
initializeWinston();
}
export const logger: Logger = {
trace: (...messages: any[]) => log('trace', undefined, messages),
debug: (...messages: any[]) => log('debug', undefined, messages),
@@ -44,6 +117,11 @@ function setLevel(level: DebugLevel) {
}
currentLevel = level;
// 更新 Winston logger 级别
if (winstonLogger) {
winstonLogger.level = level;
}
}
function log(level: DebugLevel, scope: string | undefined, messages: any[]) {
@@ -53,41 +131,68 @@ function log(level: DebugLevel, scope: string | undefined, messages: any[]) {
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);
// 浏览器环境 - 保持对象原样,利用浏览器的原生格式化
const labelStyles = getLabelStyles(labelBackgroundColor, labelTextColor);
const scopeStyles = getLabelStyles('#77828D', 'white');
const styles = [labelStyles];
if (typeof scope === 'string') {
styles.push('', scopeStyles);
}
// 直接传递原始消息,浏览器会自动格式化对象
console.log(`%c${level.toUpperCase()}${scope ? `%c %c${scope}` : ''}`, ...styles, ...messages);
} else {
// Node.js 环境 - 将对象格式化为 JSON 字符串
const formattedMessages = messages.map((msg) => {
if (typeof msg === 'object' && msg !== null) {
try {
return JSON.stringify(msg, null, 2);
} catch {
return String(msg);
}
}
return msg;
});
const allMessages = formattedMessages.reduce((acc, current) => {
if (acc.endsWith('\n')) {
return acc + current;
}
if (!acc) {
return current;
}
return `${acc} ${current}`;
}, '');
let labelText = formatText(` ${level.toUpperCase()} `, labelTextColor, labelBackgroundColor);
if (scope) {
labelText = `${labelText} ${formatText(` ${scope} `, '#FFFFFF', '77828D')}`;
}
console.log(`${labelText}`, allMessages);
// 写入文件日志(仅服务端)
if (winstonLogger) {
try {
winstonLogger.log({
level,
message: allMessages,
scope,
});
} catch (error) {
console.error('Failed to write to log file:', error);
}
}
}
}

View File

@@ -1,4 +0,0 @@
export function isMobile() {
// we use sm: as the breakpoint for mobile. It's currently set to 640px
return globalThis.innerWidth < 640;
}

View File

@@ -1,4 +0,0 @@
// 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;

View File

@@ -1,35 +0,0 @@
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');
};

View File

@@ -1,52 +0,0 @@
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;
}
}

View File

@@ -1,6 +0,0 @@
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;

View File

@@ -1,154 +0,0 @@
/**
* 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;
}

View File

@@ -1,64 +0,0 @@
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);
}
};
}

View File

@@ -1,34 +0,0 @@
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;
}

View File

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