feat(*): 实现脚本录制器运行代码功能

This commit is contained in:
XcantloadX 2025-02-04 16:40:28 +08:00
parent 0364169f6e
commit 7d32c43051
4 changed files with 156 additions and 36 deletions

View File

@ -1,18 +1,13 @@
import React, { useState, useEffect, useRef, useCallback } from 'react';
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react';
import styled from '@emotion/styled';
import VSToolBar from '../../components/VSToolBar';
import ImageEditor, { AnnotationChangedEvent } from '../../components/ImageEditor/ImageEditor';
import { MdDevices, MdPlayArrow, MdSearch, MdTouchApp, MdTextFields, MdTextSnippet, MdCropFree, MdRefresh, MdPause, MdBackHand, MdCheck, MdClose, MdEdit, MdContentCopy, MdContentCut, MdFolder } from 'react-icons/md';
import { AiOutlineLoading3Quarters } from 'react-icons/ai';
import AceEditor from 'react-ace';
import { Splitable } from '../../components/Splitable';
import { Tool, Annotation } from '../../components/ImageEditor/types';
import { create } from 'zustand';
// 引入 ace 编辑器的主题和语言模式
import 'ace-builds/src-noconflict/mode-python';
import 'ace-builds/src-noconflict/theme-monokai';
import 'ace-builds/src-noconflict/theme-chrome';
import 'ace-builds/src-noconflict/ext-language_tools';
import { css } from '@emotion/react';
import useImageMetaData, { Definition, DefinitionType, ImageMetaData, TemplateDefinition } from '../../hooks/useImageMetaData';
import { useDarkMode } from '../../hooks/useDarkMode';
@ -21,6 +16,13 @@ import useLatestCallback from '../../hooks/useLatestCallback';
import useHotkey from '../../hooks/useHotkey';
import { useFormModal } from '../../hooks/useFormModal';
import { openDirectory } from '../../utils/fileUtils';
import { useToast } from '../../components/ToastMessage';
// 引入 ace 编辑器的主题和语言模式
import 'ace-builds/src-noconflict/mode-python';
import 'ace-builds/src-noconflict/theme-monokai';
import 'ace-builds/src-noconflict/theme-chrome';
import 'ace-builds/src-noconflict/ext-language_tools';
const Container = styled.div`
display: flex;
@ -49,6 +51,7 @@ interface ScriptRecorderState {
imageUrl: string;
inEditMode: boolean;
directoryHandle: FileSystemDirectoryHandle | null;
isRunning: boolean;
imageMetaDataObject: ReturnType<typeof useImageMetaData> | null;
setImageMetaDataObject: (imageMetaData: ReturnType<typeof useImageMetaData>) => void;
@ -58,21 +61,33 @@ interface ScriptRecorderState {
setAutoScreenshot: (auto: boolean) => void;
setConnected: (connected: boolean) => void;
setImageUrl: (url: string) => void;
setIsRunning: (isRunning: boolean) => void;
setDirectoryHandle: (handle: FileSystemDirectoryHandle | null) => void;
enterEditMode: () => void;
exitEditMode: () => void;
}
// HACK: hard coded
const DEFAULT_CODE = `from kotonebot import *
from kotonebot.tasks import R
from kotonebot.backend.context import ContextStackVars
ContextStackVars.screenshot_mode = 'manual'
device.screenshot()
`
const useScriptRecorderStore = create<ScriptRecorderState>((set) => ({
code: '',
code: DEFAULT_CODE,
tool: 'drag',
autoScreenshot: true,
connected: false,
imageUrl: '',
inEditMode: false,
directoryHandle: null,
isRunning: false,
imageMetaDataObject: null,
setImageMetaDataObject: (imageMetaData) => set({ imageMetaDataObject: imageMetaData }),
@ -82,12 +97,12 @@ const useScriptRecorderStore = create<ScriptRecorderState>((set) => ({
setAutoScreenshot: (auto) => set({ autoScreenshot: auto }),
setConnected: (connected) => set({ connected }),
setImageUrl: (url) => set({ imageUrl: url }),
setIsRunning: (isRunning) => set({ isRunning }),
setDirectoryHandle: (handle) => set({ directoryHandle: handle }),
enterEditMode: () => set({ inEditMode: true, autoScreenshot: false }),
exitEditMode: () => set({ inEditMode: false }),
}));
interface ToolConfigItem {
code?: (d: Definition, a: Annotation) => string;
}
@ -114,8 +129,6 @@ const ToolConfig: Record<ScriptRecorderTool, ToolConfigItem> = {
},
}
interface ViewToolBarProps {
onOpenDirectory: () => void;
}
@ -296,34 +309,80 @@ const EditToolBar: React.FC<EditToolBarProps> = ({
interface CodeEditorToolBarProps {
onCopyAll: () => void;
onCutAll: () => void;
code: string;
client: ReturnType<typeof useDebugClient>;
}
const CodeEditorToolBar: React.FC<CodeEditorToolBarProps> = ({
onCopyAll,
onCutAll,
code,
client
}) => {
const [isRunning, setIsRunning] = useState(false);
const { showToast, ToastComponent } = useToast();
const spinnerCss = useMemo(() => css`
animation: spin 1s linear infinite;
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
`, []);
const handleRunCode = async () => {
if (!code.trim()) {
showToast('warning', '警告', '请先输入代码');
return;
}
setIsRunning(true);
try {
const result = await client.runCode(code);
if (result.status === 'error') {
showToast('danger', '运行错误', result.message);
console.error('运行错误:', result.traceback);
} else {
if (result.result !== undefined) {
showToast('success', '运行成功', `执行结果: ${JSON.stringify(result.result)}`);
} else {
showToast('success', '运行成功', '代码执行完成');
}
}
} catch (error) {
showToast('danger', '运行错误', '执行代码时发生错误');
console.error('执行错误:', error);
} finally {
setIsRunning(false);
}
};
return (
<VSToolBar align='center'>
<VSToolBar.Button
id="run"
icon={<MdPlayArrow />}
label="运行"
disabled={true}
/>
<VSToolBar.Separator />
<VSToolBar.Button
id="copy-all"
icon={<MdContentCopy />}
label="复制全部"
onClick={onCopyAll}
/>
<VSToolBar.Button
id="cut-all"
icon={<MdContentCut />}
label="剪切全部"
onClick={onCutAll}
/>
</VSToolBar>
<>
{ToastComponent}
<VSToolBar align='center'>
<VSToolBar.Button
id="run"
icon={isRunning ? <AiOutlineLoading3Quarters css={spinnerCss} /> : <MdPlayArrow />}
label="运行"
onClick={handleRunCode}
disabled={isRunning}
/>
<VSToolBar.Separator />
<VSToolBar.Button
id="copy-all"
icon={<MdContentCopy />}
label="复制全部"
onClick={onCopyAll}
/>
<VSToolBar.Button
id="cut-all"
icon={<MdContentCut />}
label="剪切全部"
onClick={onCutAll}
/>
</VSToolBar>
</>
);
};
@ -336,6 +395,7 @@ function useStoreImageMetaData() {
const ScriptRecorder: React.FC = () => {
const client = useDebugClient();
const editorRef = useRef<any>(null);
const { imageMetaData, Definitions, Annotations, clear } = useStoreImageMetaData();
const code = useScriptRecorderStore((s) => s.code);
@ -349,8 +409,6 @@ const ScriptRecorder: React.FC = () => {
const setImageUrl = useScriptRecorderStore((s) => s.setImageUrl);
const setDirectoryHandle = useScriptRecorderStore((s) => s.setDirectoryHandle);
const { theme: editorTheme } = useDarkMode({
whenDark: 'monokai',
whenLight: 'chrome'
@ -552,6 +610,8 @@ const ScriptRecorder: React.FC = () => {
<CodeEditorToolBar
onCopyAll={handleCopyAll}
onCutAll={handleCutAll}
code={code}
client={client}
/>
<AceEditor
ref={editorRef}

View File

@ -52,6 +52,18 @@ type EventListenerMap = {
connectionStatus: Array<(data: ConnectionStatusEvent) => void>;
};
export type RunCodeResultSuccess = {
status: 'ok';
result: any;
};
export type RunCodeResultError = {
status: 'error';
message: string;
traceback: string;
};
/**
* Kotone
* WebSocket
@ -194,4 +206,18 @@ export class KotoneDebugClient {
return URL.createObjectURL(blob);
}
async runCode(code: string): Promise<RunCodeResultSuccess | RunCodeResultError> {
const response = await fetch(`http://${this.host}/api/code/run`, {
method: 'POST',
body: JSON.stringify({ code }),
headers: {
'Content-Type': 'application/json'
}
});
return response.json();
}
}

View File

@ -668,6 +668,7 @@ debug: ContextDebug = cast(ContextDebug, Forwarded(name="debug"))
config: ContextConfig = cast(ContextConfig, Forwarded(name="config"))
"""配置数据。"""
def init_context(
*,
config_type: Type[T] = dict[str, Any],
@ -693,4 +694,18 @@ def init_context(
color._FORWARD_getter = lambda: _c.color # type: ignore
vars._FORWARD_getter = lambda: _c.vars # type: ignore
debug._FORWARD_getter = lambda: _c.debug # type: ignore
config._FORWARD_getter = lambda: _c.config # type: ignore
config._FORWARD_getter = lambda: _c.config # type: ignore
class ManualContextManager:
def __enter__(self):
ContextStackVars.push()
def __exit__(self, exc_type, exc_value, traceback):
ContextStackVars.pop()
def manual_context() -> ManualContextManager:
"""
默认情况下Context* 类仅允许在 @task/@action 函数中使用
如果想要在其他地方使用使用此函数手动创建一个上下文
"""
return ManualContextManager()

View File

@ -1,20 +1,25 @@
import time
import asyncio
import threading
import traceback
from pathlib import Path
from collections import deque
import cv2
from pydantic import BaseModel
import uvicorn
from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse, Response
from fastapi import FastAPI, WebSocket, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from ..context import manual_context
from . import vars
app = FastAPI()
app.add_middleware(CORSMiddleware, allow_origins=["*"])
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"])
# 获取当前文件夹路径
CURRENT_DIR = Path(__file__).parent
@ -83,8 +88,22 @@ def screenshot():
buff = cv2.imencode('.png', img)[1].tobytes()
return Response(buff, media_type="image/png")
class RunCodeRequest(BaseModel):
code: str
@app.post("/api/code/run")
async def run_code(request: RunCodeRequest):
code = f"from kotonebot import *\n" + request.code
try:
with manual_context():
ret = exec(code)
return {"status": "ok", "result": ret}
except Exception as e:
return {"status": "error", "message": str(e), "traceback": traceback.format_exc()}
@app.get("/api/ping")
async def ping():
return {"status": "ok"}
message_queue = deque()