Files
page-agent/pages/components/JSConsole.tsx
2025-09-29 16:33:15 +08:00

370 lines
9.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* JS 调试台,适合在文档中直接让用户运行代码,并且实时查看运行结果
*/
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
/* eslint-disable @typescript-eslint/no-base-to-string */
import { KeyboardEvent, forwardRef, useEffect, useImperativeHandle, useRef, useState } from 'react'
import HighlightSyntax from './HighlightSyntax'
import styles from './JSConsole.module.css'
// 全局console拦截管理器
class ConsoleInterceptor {
private static instance: ConsoleInterceptor
private subscribers = new Set<(type: string, args: unknown[]) => void>()
private originalConsole: {
log: typeof console.log
warn: typeof console.warn
error: typeof console.error
}
private isIntercepting = false
private constructor() {
this.originalConsole = {
log: console.log.bind(console),
warn: console.warn.bind(console),
error: console.error.bind(console),
}
}
static getInstance() {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (!ConsoleInterceptor.instance) {
ConsoleInterceptor.instance = new ConsoleInterceptor()
}
return ConsoleInterceptor.instance
}
subscribe(callback: (type: string, args: unknown[]) => void) {
this.subscribers.add(callback)
this.startIntercepting()
}
unsubscribe(callback: (type: string, args: unknown[]) => void) {
this.subscribers.delete(callback)
if (this.subscribers.size === 0) {
this.stopIntercepting()
}
}
private startIntercepting() {
if (this.isIntercepting) return
this.isIntercepting = true
console.log = (...args: unknown[]) => {
this.originalConsole.log(...args)
this.notifySubscribers('log', args)
}
console.warn = (...args: unknown[]) => {
this.originalConsole.warn(...args)
this.notifySubscribers('warn', args)
}
console.error = (...args: unknown[]) => {
this.originalConsole.error(...args)
this.notifySubscribers('error', args)
}
}
private stopIntercepting() {
if (!this.isIntercepting) return
this.isIntercepting = false
console.log = this.originalConsole.log
console.warn = this.originalConsole.warn
console.error = this.originalConsole.error
}
private notifySubscribers(type: string, args: unknown[]) {
this.subscribers.forEach((callback) => {
callback(type, args)
})
}
}
interface JSConsoleProps {
context?: Record<string, unknown>
height?: string
onExecute?: (code: string, result: unknown) => void
placeholder?: string
}
export interface JSConsoleRef {
executeCode: (code: string) => Promise<unknown>
clear: () => void
appendOutput: (content: string) => void
}
interface OutputItem {
type: 'input' | 'output' | 'error' | 'log'
content: string
timestamp: number
}
const JSConsole = forwardRef<JSConsoleRef, JSConsoleProps>(
(
{ context = {}, height = '400px', onExecute, placeholder = 'Enter JavaScript code...' },
ref
) => {
const [input, setInput] = useState('')
const [outputs, setOutputs] = useState<OutputItem[]>([])
const [isExecuting, setIsExecuting] = useState(false)
const inputRef = useRef<HTMLTextAreaElement>(null)
const outputRef = useRef<HTMLDivElement>(null)
// 持久的执行上下文,用于多轮对话共享作用域
const executionContextRef = useRef<Record<string, unknown>>({})
// 格式化结果
const formatResult = (value: unknown): string => {
if (value === null) return 'null'
if (value === undefined) return 'undefined'
if (typeof value === 'string') return `"${value}"`
if (typeof value === 'function') return `[Function: ${value.name || 'anonymous'}]`
if (typeof value === 'object') {
try {
return JSON.stringify(value, null, 2)
} catch {
return value.toString()
}
}
return String(value)
}
// 全局console拦截处理
useEffect(() => {
const interceptor = ConsoleInterceptor.getInstance()
const handleGlobalConsole = (type: string, args: unknown[]) => {
const content = args.map((arg) => formatResult(arg)).join(' ')
const outputItem: OutputItem = {
type: type as any,
content: content,
timestamp: Date.now(),
}
setOutputs((prev) => [...prev, outputItem])
// 自动滚动到底部
setTimeout(() => {
if (outputRef.current) {
outputRef.current.scrollTop = outputRef.current.scrollHeight
}
}, 0)
}
interceptor.subscribe(handleGlobalConsole)
return () => {
interceptor.unsubscribe(handleGlobalConsole)
}
}, [])
// 执行代码
const executeCode = async (code: string): Promise<unknown> => {
if (!code.trim()) return
setIsExecuting(true)
// 添加输入到输出
const inputItem: OutputItem = {
type: 'input',
content: code,
timestamp: Date.now(),
}
setOutputs((prev) => [...prev, inputItem])
try {
// 创建异步函数以支持 await
const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor
// 合并外部上下文和持久执行上下文
const allContext = { ...context, ...executionContextRef.current }
const contextKeys = Object.keys(allContext)
const contextValues = Object.values(allContext)
// 注入 console.log 重定向
const logs: string[] = []
const mockConsole = {
log: (...args: unknown[]) => {
logs.push(args.map((arg) => formatResult(arg)).join(' '))
},
error: (...args: unknown[]) => {
logs.push('ERROR: ' + args.map((arg) => formatResult(arg)).join(' '))
},
warn: (...args: unknown[]) => {
logs.push('WARN: ' + args.map((arg) => formatResult(arg)).join(' '))
},
}
// 检测代码是否是表达式还是语句
const trimmedCode = code.trim()
const isExpression =
!trimmedCode.includes(';') &&
!trimmedCode.startsWith('const ') &&
!trimmedCode.startsWith('let ') &&
!trimmedCode.startsWith('var ') &&
!trimmedCode.startsWith('function ') &&
!trimmedCode.startsWith('class ') &&
!trimmedCode.startsWith('if ') &&
!trimmedCode.startsWith('for ') &&
!trimmedCode.startsWith('while ') &&
!trimmedCode.startsWith('try ') &&
!trimmedCode.startsWith('{') &&
!trimmedCode.includes('\n')
// 如果是表达式,自动添加 return
const codeToExecute = isExpression ? `return ${code}` : code
const wrappedCode = `
return (async function() {
${codeToExecute}
})();
`
// 执行代码
const func = new AsyncFunction('console', ...contextKeys, wrappedCode)
const result = await func(mockConsole, ...contextValues)
// 添加 console.log 输出
if (logs.length > 0) {
const logItem: OutputItem = {
type: 'log',
content: logs.join('\n'),
timestamp: Date.now(),
}
setOutputs((prev) => [...prev, logItem])
}
// 总是添加执行结果输出(包括 undefined
const outputItem: OutputItem = {
type: 'output',
content: formatResult(result),
timestamp: Date.now(),
}
setOutputs((prev) => [...prev, outputItem])
onExecute?.(code, result)
return result
} catch (error) {
const errorItem: OutputItem = {
type: 'error',
content: error instanceof Error ? error.message : String(error),
timestamp: Date.now(),
}
setOutputs((prev) => [...prev, errorItem])
throw error
} finally {
setIsExecuting(false)
// 滚动到底部
setTimeout(() => {
if (outputRef.current) {
outputRef.current.scrollTop = outputRef.current.scrollHeight
}
}, 0)
}
}
// 清空控制台
const clear = () => {
setOutputs([])
// 同时清空执行上下文
executionContextRef.current = {}
}
// 添加输出
const appendOutput = (content: string) => {
const outputItem: OutputItem = {
type: 'output',
content,
timestamp: Date.now(),
}
setOutputs((prev) => [...prev, outputItem])
}
// 暴露方法给父组件
useImperativeHandle(ref, () => ({
executeCode,
clear,
appendOutput,
}))
// 处理键盘事件
const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter') {
if (e.shiftKey) {
// Shift+Enter 换行
return
} else {
// Enter 执行
e.preventDefault()
if (!isExecuting && input.trim()) {
executeCode(input)
setInput('')
setTimeout(() => inputRef.current?.focus(), 0)
}
}
}
}
function getPrompt(type: string) {
let prompt = ' '
if (type === 'input') {
prompt = '>'
} else if (type === 'output') {
prompt = '<'
}
return prompt
}
return (
<div className={styles.console} style={{ height }}>
{/* 历史记录和输入区域 */}
<div className={styles.historyArea} ref={outputRef}>
{outputs.map((item, index) => (
<div key={index} className={`${styles.historyItem} ${styles[item.type]}`}>
<span className={styles.prompt}>{getPrompt(item.type)}</span>
<pre className={styles.content}>
<HighlightSyntax code={item.content} />
</pre>
</div>
))}
{isExecuting && (
<div className={styles.historyItem}>
<span className={styles.prompt}>{'> '}</span>
<span className={styles.executing}>Executing...</span>
</div>
)}
</div>
{/* 当前输入行 */}
<div className={styles.inputArea}>
<span className={styles.prompt}>{'> '}</span>
<textarea
ref={inputRef}
className={styles.input}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={placeholder}
disabled={isExecuting}
rows={1}
style={{
height: Math.min(Math.max(20, input.split('\n').length * 20), 120),
}}
/>
</div>
</div>
)
}
)
JSConsole.displayName = 'JSConsole'
export default JSConsole