feat(core): 新增保存图片调试数据到本地 & 可视调试器可从本地载入数据
This commit is contained in:
parent
3dd023a1b7
commit
4f2c612784
|
@ -4,6 +4,7 @@ tests/output_images/*
|
|||
R.py
|
||||
kotonebot-ui/node_modules
|
||||
kotonebot-ui/.vite
|
||||
dumps
|
||||
##########################
|
||||
|
||||
# Byte-compiled / optimized / DLL files
|
||||
|
|
|
@ -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}"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
|
@ -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)
|
||||
|
|
|
@ -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")
|
||||
|
||||
|
|
|
@ -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[]>} */
|
||||
|
|
|
@ -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();
|
||||
|
||||
|
|
|
@ -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">`;
|
||||
});
|
||||
}
|
Loading…
Reference in New Issue