feat(core): 新增保存图片调试数据到本地 & 可视调试器可从本地载入数据

This commit is contained in:
XcantloadX 2025-01-15 22:10:35 +08:00
parent 3dd023a1b7
commit 4f2c612784
7 changed files with 238 additions and 22 deletions

1
.gitignore vendored
View File

@ -4,6 +4,7 @@ tests/output_images/*
R.py
kotonebot-ui/node_modules
kotonebot-ui/.vite
dumps
##########################
# Byte-compiled / optimized / DLL files

7
.vscode/launch.json vendored
View File

@ -26,7 +26,12 @@
"request": "launch",
"console": "integratedTerminal",
"module": "kotonebot.backend.debug.entry",
"args": ["${command:extension.commandvariable.file.relativeDirDots}.${fileBasenameNoExtension}"]
"args": [
"-s",
"${workspaceFolder}/dumps",
"-c",
"${command:extension.commandvariable.file.relativeDirDots}.${fileBasenameNoExtension}"
]
}
]
}

View File

@ -1,7 +1,11 @@
import os
import sys
import runpy
import argparse
import shutil
from threading import Thread
from typing import Callable
from pathlib import Path
from . import debug
@ -9,20 +13,51 @@ def _task_thread(task_module: str):
"""任务线程。"""
runpy.run_module(task_module, run_name="__main__")
def _start_task_thread():
def _parse_args():
"""解析命令行参数。"""
parser = argparse.ArgumentParser(description='KotoneBot visual debug tool')
parser.add_argument(
'-s', '--save',
help='Save dump image and results to the specified folder',
type=str,
metavar='PATH'
)
parser.add_argument(
'-c', '--clear',
help='Clear the dump folder before running',
action='store_true'
)
parser.add_argument(
'input_module',
help='The module to run'
)
return parser.parse_args()
def _start_task_thread(module: str):
"""启动任务线程。"""
module = sys.argv[1]
thread = Thread(target=_task_thread, args=(module,))
thread.start()
if __name__ == "__main__":
args = _parse_args()
debug.enabled = True
# 设置保存路径
if args.save:
save_path = Path(args.save)
debug.save_to_folder = str(save_path)
if not os.path.exists(save_path):
os.makedirs(save_path)
if args.clear:
if debug.save_to_folder:
shutil.rmtree(debug.save_to_folder)
# 启动服务器
from .server import app
import uvicorn
# 启动任务线程
_start_task_thread()
_start_task_thread(args.input_module)
# 启动服务器
uvicorn.run(app, host="127.0.0.1", port=8000, log_level='critical' if debug.hide_server_log else None)

View File

@ -1,12 +1,15 @@
from pathlib import Path
import os
import re
import json
import time
import uuid
import traceback
from pathlib import Path
from typing import NamedTuple, TextIO
from datetime import datetime
from dataclasses import dataclass
from typing import NamedTuple
import cv2
from cv2.typing import MatLike
class Result(NamedTuple):
@ -29,6 +32,12 @@ class _Vars:
"""
hide_server_log: bool = True
"""是否隐藏服务器日志。"""
save_to_folder: str | None = None
"""
是否将结果保存到指定文件夹
如果为 None则不保存
"""
debug = _Vars()
@ -36,11 +45,17 @@ debug = _Vars()
_results: dict[str, Result] = {}
_images: dict[str, MatLike] = {}
"""存放临时图片的字典。"""
_result_file: TextIO | None = None
def _save_image(image: MatLike) -> str:
"""缓存图片数据到 _images 字典中。返回 key。"""
key = str(uuid.uuid4())
_images[key] = image
if debug.save_to_folder:
if not os.path.exists(debug.save_to_folder):
os.makedirs(debug.save_to_folder)
file_name = f"{key}.png"
cv2.imwrite(os.path.join(debug.save_to_folder, file_name), image)
return key
def _save_images(images: list[MatLike]) -> list[str]:
@ -56,12 +71,17 @@ def img(image: str | MatLike | None) -> str:
"""
if image is None:
return 'None'
elif isinstance(image, str):
return f'<img src="/api/read_file?path={image}" />'
if debug.save_to_folder:
if isinstance(image, str):
image = cv2.imread(image)
key = _save_image(image)
return f'[img]{key}[/img]'
else:
key = str(uuid.uuid4())
_images[key] = image
return f'<img src="/api/read_memory?key={key}" />'
if isinstance(image, str):
return f'<img src="/api/read_file?path={image}" />'
else:
key = _save_image(image)
return f'<img src="/api/read_memory?key={key}" />'
# TODO: 保存原图。原图用 PNG结果用 JPG 压缩。
def result(
@ -86,6 +106,7 @@ def result(
:param image: 图片
:param text: 详细文本可以是 HTML 代码空格和换行将会保留如果需要嵌入图片使用 `img()` 函数
"""
global _result_file
if not debug.enabled:
return
if not isinstance(image, list):
@ -120,3 +141,21 @@ def result(
from .server import send_ws_message
send_ws_message(title, saved_images, final_text, wait=debug.wait_for_message_sent)
# 保存到文件
# TODO: 把这个类型转换为 dataclass/namedtuple
if debug.save_to_folder:
if _result_file is None:
if not os.path.exists(debug.save_to_folder):
os.makedirs(debug.save_to_folder)
log_file_name = f"dump_{datetime.now().strftime('%Y-%m-%d_%H-%M-%S')}.json"
_result_file = open(os.path.join(debug.save_to_folder, log_file_name), "w")
_result_file.write(json.dumps({
"image": {
"type": "memory",
"value": saved_images
},
"name": title,
"details": final_text
}))
_result_file.write("\n")

View File

@ -5,16 +5,16 @@
*/
/**
* @typedef {Object} VisualData
* @typedef {Object} VisualEventData
* @property {ImageData} image - 图片数据
* @property {string} name - 可视化标题
* @property {string} name - 函数名称
* @property {string} details - 详细文本信息
*/
/**
* @typedef {Object} VisualEvent
* @property {'visual'} type - 事件类型
* @property {VisualData} data - 可视化数据
* @property {VisualEventData} data - 可视化数据
*/
/**
@ -36,7 +36,7 @@
* @typedef {T extends 'visual' ? VisualEvent : T extends 'connectionStatus' ? ConnectionStatusEvent : never} EventTypeMap
*/
class KotoneDebugClient {
export class KotoneDebugClient {
/** @type {WebSocket} */
#ws;
/** @type {Map<string, Function[]>} */

View File

@ -10,10 +10,11 @@
height: 100%;
margin: 0;
padding: 0;
overflow: hidden;
}
.container-fluid {
height: 100%;
height: calc(100% - 42px); /* 减去工具栏高度 */
padding: 0;
}
@ -203,9 +204,38 @@
border: 1px solid #ddd;
padding: 5px;
}
.toolbar {
background-color: #f8f9fa;
padding: 6px;
text-align: center;
border-bottom: 1px solid #ddd;
height: 42px; /* 减小工具栏高度 */
display: flex;
align-items: center;
justify-content: center;
}
.toolbar .btn {
margin: 0 5px;
padding: 3px 10px;
font-size: 0.875rem;
}
.toolbar .btn i {
font-size: 0.875rem;
margin-right: 4px;
}
</style>
</head>
<body>
<!-- 工具栏 -->
<div class="toolbar">
<button class="btn btn-outline-primary" id="openLocalBtn">
<i class="bi bi-folder2-open"></i> 打开
</button>
</div>
<!-- 添加连接状态提示 -->
<div class="connection-status" id="connectionStatus">
WebSocket 连接已断开,正在尝试重新连接...
@ -292,8 +322,12 @@
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js"></script>
<script src="/static/client.js"></script>
<script>
<script type="module" src="/static/util.js"></script>
<script type="module" src="/static/client.js"></script>
<script type="module">
import { KotoneDebugClient } from '/static/client.js';
import { parseImages } from '/static/util.js';
$(document).ready(function() {
let currentScale = 1.0;
const ZOOM_STEP = 0.1;
@ -313,6 +347,77 @@
let lastScale = 1.0; // 上一次的缩放值
let lastTranslateX = 0; // 上一次的X位移
let lastTranslateY = 0; // 上一次的Y位移
let localImageMap = new Map(); // 本地图片映射表
let localMode = false; // 是否为本地模式
// 处理本地文件夹选择
async function handleLocalFolder() {
try {
const dirHandle = await window.showDirectoryPicker();
// 清空之前的数据
localImageMap.clear();
funcHistory = [];
currentFuncIndex = -1;
localMode = true; // 切换到本地模式
// 读取所有文件
for await (const entry of dirHandle.values()) {
if (entry.kind === 'file') {
const file = await entry.getFile();
// 处理图片文件
if (file.type.startsWith('image/')) {
const fileName = entry.name.replace(/\.[^/.]+$/, ""); // 移除扩展名
const dataUrl = await readFileAsDataURL(file);
localImageMap.set(fileName, dataUrl);
}
// 处理 JSON 文件
if (file.name.endsWith('.json')) {
const text = await file.text();
const lines = text.split('\n').filter(line => line.trim());
for (const line of lines) {
try {
const data = JSON.parse(line);
if (data.image && data.name && data.details) {
funcHistory.push(data);
}
} catch (e) {
console.error('Invalid JSON line:', line);
}
}
// 只处理第一个 JSON 文件
break;
}
}
}
// 如果有数据,显示第一条记录
if (funcHistory.length > 0) {
showHistoryImage(0);
}
} catch (err) {
// 如果是用户取消选择,直接返回
if (err.name === 'AbortError') {
return;
}
console.error('Error reading local folder:', err);
alert('读取文件夹失败:' + err.message);
}
}
// 读取文件为 DataURL
function readFileAsDataURL(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result);
reader.onerror = reject;
reader.readAsDataURL(file);
});
}
// 显示/隐藏连接状态
function showConnectionStatus(show) {
@ -349,7 +454,13 @@
// 更新标题和详情
$('h4').text(imageData.name);
$('#customMessage').html(imageData.details);
$('#customMessage').html(parseImages(imageData.details, (key) => {
// 根据模式选择图片来源
if (localMode && localImageMap.has(key)) {
return localImageMap.get(key);
}
return '/api/read_memory?key=' + key;
}));
// 更新计数和滑块
updateImageCount();
@ -366,11 +477,17 @@
// 更新图片
const imageContainer = $('#imageViewer');
const img = $('<img>');
if (imageData.type === 'memory') {
img.attr('src', `/api/read_memory?key=${imageValues.value[currentImageIndex]}`);
// 根据模式和图片类型设置图片 URL
const imgValue = imageValues.value[currentImageIndex];
if (localMode && localImageMap.has(imgValue)) {
img.attr('src', localImageMap.get(imgValue));
} else if (imageData.type === 'memory') {
img.attr('src', `/api/read_memory?key=${imgValue}`);
} else if (imageData.type === 'file') {
img.attr('src', `/api/read_file?path=${imageValues.value[currentImageIndex]}`);
img.attr('src', `/api/read_file?path=${imgValue}`);
}
imageContainer.empty().append(img);
// 根据锁定状态决定是否保持视图
@ -620,6 +737,7 @@
// 监听可视化事件
client.addEventListener('visual', (e) => {
if (localMode) return; // 本地模式下不接收服务器事件
funcHistory.push(e.data);
// 只有当前正在查看最后一张图片时,才自动显示新图片
if (currentFuncIndex === funcHistory.length - 2 || currentFuncIndex === -1) {
@ -630,6 +748,9 @@
updateImageCount();
}
});
// 添加本地文件夹打开按钮事件
$('#openLocalBtn').click(handleLocalFolder);
initResizeHandle();

View File

@ -0,0 +1,15 @@
/**
* 解析文本中的图片 [img]...[/img] <img src="...">
* @param {string} text 文本
* @param {(key: string) => string} img2urlCallback 图片键到 URL 的回调函数
* @returns {string} 解析后的文本
*/
export function parseImages(
text,
img2urlCallback = (k) => '/api/read_memory?key=' + k
) {
const regex = /\[img\](.*?)\[\/img\]/g;
return text.replace(regex, (match, p1) => {
return `<img src="${img2urlCallback(p1)}" alt="image">`;
});
}