refactor: repartition server-side and client-side code
This commit is contained in:
@@ -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);
|
||||
}
|
||||
@@ -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
58
app/utils/crypto.ts
Normal 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)!);
|
||||
}
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
import { cubicBezier } from 'framer-motion';
|
||||
|
||||
export const cubicEasingFn = cubicBezier(0.4, 0, 0.2, 1);
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
@@ -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]}`;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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');
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
export function unreachable(message: string): never {
|
||||
throw new Error(`Unreachable: ${message}`);
|
||||
}
|
||||
Reference in New Issue
Block a user