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 styled from '@emotion/styled';
import VSToolBar from '../../components/VSToolBar'; import VSToolBar from '../../components/VSToolBar';
import ImageEditor, { AnnotationChangedEvent } from '../../components/ImageEditor/ImageEditor'; 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 { 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 AceEditor from 'react-ace';
import { Splitable } from '../../components/Splitable'; import { Splitable } from '../../components/Splitable';
import { Tool, Annotation } from '../../components/ImageEditor/types'; import { Tool, Annotation } from '../../components/ImageEditor/types';
import { create } from 'zustand'; 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 { css } from '@emotion/react';
import useImageMetaData, { Definition, DefinitionType, ImageMetaData, TemplateDefinition } from '../../hooks/useImageMetaData'; import useImageMetaData, { Definition, DefinitionType, ImageMetaData, TemplateDefinition } from '../../hooks/useImageMetaData';
import { useDarkMode } from '../../hooks/useDarkMode'; import { useDarkMode } from '../../hooks/useDarkMode';
@ -21,6 +16,13 @@ import useLatestCallback from '../../hooks/useLatestCallback';
import useHotkey from '../../hooks/useHotkey'; import useHotkey from '../../hooks/useHotkey';
import { useFormModal } from '../../hooks/useFormModal'; import { useFormModal } from '../../hooks/useFormModal';
import { openDirectory } from '../../utils/fileUtils'; 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` const Container = styled.div`
display: flex; display: flex;
@ -49,6 +51,7 @@ interface ScriptRecorderState {
imageUrl: string; imageUrl: string;
inEditMode: boolean; inEditMode: boolean;
directoryHandle: FileSystemDirectoryHandle | null; directoryHandle: FileSystemDirectoryHandle | null;
isRunning: boolean;
imageMetaDataObject: ReturnType<typeof useImageMetaData> | null; imageMetaDataObject: ReturnType<typeof useImageMetaData> | null;
setImageMetaDataObject: (imageMetaData: ReturnType<typeof useImageMetaData>) => void; setImageMetaDataObject: (imageMetaData: ReturnType<typeof useImageMetaData>) => void;
@ -58,21 +61,33 @@ interface ScriptRecorderState {
setAutoScreenshot: (auto: boolean) => void; setAutoScreenshot: (auto: boolean) => void;
setConnected: (connected: boolean) => void; setConnected: (connected: boolean) => void;
setImageUrl: (url: string) => void; setImageUrl: (url: string) => void;
setIsRunning: (isRunning: boolean) => void;
setDirectoryHandle: (handle: FileSystemDirectoryHandle | null) => void; setDirectoryHandle: (handle: FileSystemDirectoryHandle | null) => void;
enterEditMode: () => void; enterEditMode: () => void;
exitEditMode: () => 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) => ({ const useScriptRecorderStore = create<ScriptRecorderState>((set) => ({
code: '', code: DEFAULT_CODE,
tool: 'drag', tool: 'drag',
autoScreenshot: true, autoScreenshot: true,
connected: false, connected: false,
imageUrl: '', imageUrl: '',
inEditMode: false, inEditMode: false,
directoryHandle: null, directoryHandle: null,
isRunning: false,
imageMetaDataObject: null, imageMetaDataObject: null,
setImageMetaDataObject: (imageMetaData) => set({ imageMetaDataObject: imageMetaData }), setImageMetaDataObject: (imageMetaData) => set({ imageMetaDataObject: imageMetaData }),
@ -82,12 +97,12 @@ const useScriptRecorderStore = create<ScriptRecorderState>((set) => ({
setAutoScreenshot: (auto) => set({ autoScreenshot: auto }), setAutoScreenshot: (auto) => set({ autoScreenshot: auto }),
setConnected: (connected) => set({ connected }), setConnected: (connected) => set({ connected }),
setImageUrl: (url) => set({ imageUrl: url }), setImageUrl: (url) => set({ imageUrl: url }),
setIsRunning: (isRunning) => set({ isRunning }),
setDirectoryHandle: (handle) => set({ directoryHandle: handle }), setDirectoryHandle: (handle) => set({ directoryHandle: handle }),
enterEditMode: () => set({ inEditMode: true, autoScreenshot: false }), enterEditMode: () => set({ inEditMode: true, autoScreenshot: false }),
exitEditMode: () => set({ inEditMode: false }), exitEditMode: () => set({ inEditMode: false }),
})); }));
interface ToolConfigItem { interface ToolConfigItem {
code?: (d: Definition, a: Annotation) => string; code?: (d: Definition, a: Annotation) => string;
} }
@ -114,8 +129,6 @@ const ToolConfig: Record<ScriptRecorderTool, ToolConfigItem> = {
}, },
} }
interface ViewToolBarProps { interface ViewToolBarProps {
onOpenDirectory: () => void; onOpenDirectory: () => void;
} }
@ -296,34 +309,80 @@ const EditToolBar: React.FC<EditToolBarProps> = ({
interface CodeEditorToolBarProps { interface CodeEditorToolBarProps {
onCopyAll: () => void; onCopyAll: () => void;
onCutAll: () => void; onCutAll: () => void;
code: string;
client: ReturnType<typeof useDebugClient>;
} }
const CodeEditorToolBar: React.FC<CodeEditorToolBarProps> = ({ const CodeEditorToolBar: React.FC<CodeEditorToolBarProps> = ({
onCopyAll, onCopyAll,
onCutAll, 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 ( return (
<VSToolBar align='center'> <>
<VSToolBar.Button {ToastComponent}
id="run" <VSToolBar align='center'>
icon={<MdPlayArrow />} <VSToolBar.Button
label="运行" id="run"
disabled={true} icon={isRunning ? <AiOutlineLoading3Quarters css={spinnerCss} /> : <MdPlayArrow />}
/> label="运行"
<VSToolBar.Separator /> onClick={handleRunCode}
<VSToolBar.Button disabled={isRunning}
id="copy-all" />
icon={<MdContentCopy />} <VSToolBar.Separator />
label="复制全部" <VSToolBar.Button
onClick={onCopyAll} id="copy-all"
/> icon={<MdContentCopy />}
<VSToolBar.Button label="复制全部"
id="cut-all" onClick={onCopyAll}
icon={<MdContentCut />} />
label="剪切全部" <VSToolBar.Button
onClick={onCutAll} id="cut-all"
/> icon={<MdContentCut />}
</VSToolBar> label="剪切全部"
onClick={onCutAll}
/>
</VSToolBar>
</>
); );
}; };
@ -336,6 +395,7 @@ function useStoreImageMetaData() {
const ScriptRecorder: React.FC = () => { const ScriptRecorder: React.FC = () => {
const client = useDebugClient(); const client = useDebugClient();
const editorRef = useRef<any>(null); const editorRef = useRef<any>(null);
const { imageMetaData, Definitions, Annotations, clear } = useStoreImageMetaData(); const { imageMetaData, Definitions, Annotations, clear } = useStoreImageMetaData();
const code = useScriptRecorderStore((s) => s.code); const code = useScriptRecorderStore((s) => s.code);
@ -349,8 +409,6 @@ const ScriptRecorder: React.FC = () => {
const setImageUrl = useScriptRecorderStore((s) => s.setImageUrl); const setImageUrl = useScriptRecorderStore((s) => s.setImageUrl);
const setDirectoryHandle = useScriptRecorderStore((s) => s.setDirectoryHandle); const setDirectoryHandle = useScriptRecorderStore((s) => s.setDirectoryHandle);
const { theme: editorTheme } = useDarkMode({ const { theme: editorTheme } = useDarkMode({
whenDark: 'monokai', whenDark: 'monokai',
whenLight: 'chrome' whenLight: 'chrome'
@ -552,6 +610,8 @@ const ScriptRecorder: React.FC = () => {
<CodeEditorToolBar <CodeEditorToolBar
onCopyAll={handleCopyAll} onCopyAll={handleCopyAll}
onCutAll={handleCutAll} onCutAll={handleCutAll}
code={code}
client={client}
/> />
<AceEditor <AceEditor
ref={editorRef} ref={editorRef}

View File

@ -52,6 +52,18 @@ type EventListenerMap = {
connectionStatus: Array<(data: ConnectionStatusEvent) => void>; connectionStatus: Array<(data: ConnectionStatusEvent) => void>;
}; };
export type RunCodeResultSuccess = {
status: 'ok';
result: any;
};
export type RunCodeResultError = {
status: 'error';
message: string;
traceback: string;
};
/** /**
* Kotone * Kotone
* WebSocket * WebSocket
@ -194,4 +206,18 @@ export class KotoneDebugClient {
return URL.createObjectURL(blob); 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")) config: ContextConfig = cast(ContextConfig, Forwarded(name="config"))
"""配置数据。""" """配置数据。"""
def init_context( def init_context(
*, *,
config_type: Type[T] = dict[str, Any], config_type: Type[T] = dict[str, Any],
@ -693,4 +694,18 @@ def init_context(
color._FORWARD_getter = lambda: _c.color # type: ignore color._FORWARD_getter = lambda: _c.color # type: ignore
vars._FORWARD_getter = lambda: _c.vars # type: ignore vars._FORWARD_getter = lambda: _c.vars # type: ignore
debug._FORWARD_getter = lambda: _c.debug # 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 time
import asyncio import asyncio
import threading import threading
import traceback
from pathlib import Path from pathlib import Path
from collections import deque from collections import deque
import cv2 import cv2
from pydantic import BaseModel
import uvicorn import uvicorn
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse, Response from fastapi.responses import FileResponse, Response
from fastapi import FastAPI, WebSocket, HTTPException from fastapi import FastAPI, WebSocket, HTTPException
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from ..context import manual_context
from . import vars from . import vars
app = FastAPI() 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 CURRENT_DIR = Path(__file__).parent
@ -83,8 +88,22 @@ def screenshot():
buff = cv2.imencode('.png', img)[1].tobytes() buff = cv2.imencode('.png', img)[1].tobytes()
return Response(buff, media_type="image/png") 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") @app.get("/api/ping")
async def ping(): async def ping():
return {"status": "ok"} return {"status": "ok"}
message_queue = deque() message_queue = deque()