feat(devtool): 优化 DumpViewer 调用堆栈部分与时间部分的显示

This commit is contained in:
XcantloadX 2025-03-04 11:41:07 +08:00
parent abf7fbc930
commit 41d4c4ce78
9 changed files with 211 additions and 64 deletions

View File

@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M11.3403 9.70998H12.0503L14.7202 7.04005V6.32997L13.3803 5.00001H12.6803L10.8603 6.81007H5.86024V5.56007L7.72023 3.70997V3L5.72022 1H5.00025L1.00024 5.00001V5.70997L3.00025 7.70998H3.71027L4.85023 6.56007V12.35L5.35023 12.85H10.0003V13.37L11.3303 14.71H12.0402L14.7103 12.0401V11.33L13.3703 10H12.6703L10.8103 11.85H5.81025V7.84999H10.0003V8.32997L11.3403 9.70998ZM13.0303 6.06007L13.6602 6.68995L11.6602 8.68996L11.0303 8.06007L13.0303 6.06007ZM13.0303 11.0601L13.6602 11.69L11.6602 13.69L11.0303 13.0601L13.0303 11.0601ZM3.35022 6.65004L2.06024 5.34998L5.35023 2.06006L6.65028 3.34998L3.35022 6.65004Z" fill="#D67E00"/>
</svg>

After

Width:  |  Height:  |  Size: 734 B

View File

@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M13.8502 4.44L10.5702 1.14L10.2202 1H2.50024L2.00024 1.5V14.5L2.50024 15H13.5002L14.0002 14.5V4.8L13.8502 4.44ZM13.0002 5H10.0002V2L13.0002 5ZM3.00024 14V2H9.00024V5.5L9.50024 6H13.0002V14H3.00024Z" fill="#424242"/>
</svg>

After

Width:  |  Height:  |  Size: 328 B

View File

@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M13.5103 4L8.51025 1H7.51025L2.51025 4L2.02026 4.85999V10.86L2.51025 11.71L7.51025 14.71H8.51025L13.5103 11.71L14.0002 10.86V4.85999L13.5103 4ZM7.51025 13.5601L3.01025 10.86V5.69995L7.51025 8.15002V13.5601ZM3.27026 4.69995L8.01025 1.85999L12.7502 4.69995L8.01025 7.29004L3.27026 4.69995ZM13.0103 10.86L8.51025 13.5601V8.15002L13.0103 5.69995V10.86Z" fill="#652D90"/>
</svg>

After

Width:  |  Height:  |  Size: 479 B

View File

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

View File

@ -1,6 +1,15 @@
import { useCallback } from 'react';
import styled from '@emotion/styled';
import { useDebugStore } from '../../store/debugStore';
import { Callstack } from '../../utils/debugClient';
import { css } from '@emotion/react';
interface AttributeEntry {
/** 信息标签 */
label: string;
/** 信息值(支持字符串或数字) */
value: string | number;
}
interface InfoPanelProps {
/** 信息名称 */
@ -10,7 +19,9 @@ interface InfoPanelProps {
/** 图片映射 */
imagesMap?: Map<string, string>;
/** 与上一条记录的时间差(毫秒) */
timeDiff?: number;
attributes: AttributeEntry[];
/** 调用堆栈 */
callstacks: Callstack[];
}
const PanelContainer = styled.div`
@ -43,15 +54,14 @@ const MethodDetails = styled.div`
word-break: break-word;
`;
const TimeDiffText = styled.div`
const AttributeText = styled.div`
color: #6c757d;
font-size: 0.9rem;
margin-bottom: 1rem;
margin-bottom: 0.5rem;
`;
// 解析 [img]url[/img] 标签
function parseImgTags(text: string, img2urlCallback = (k: string) => '/api/read_memory?key=' + k): string {
// 解析 [img] 标签
text = text.replace(/\[img\](.*?)\[\/img\]/g, (match, p1) => {
return `<img src="${img2urlCallback(p1)}" alt="image">`;
});
@ -59,11 +69,55 @@ function parseImgTags(text: string, img2urlCallback = (k: string) => '/api/read_
return text;
}
function CallstackList({ callstacks }: { callstacks: Callstack[] }) {
const listStyle = css`
list-style-type: none;
padding: 0;
li {
padding: 0.2rem;
transition: background-color 0.1s;
cursor: pointer;
&:hover {
background-color: #f2f2f2;
}
}
`;
const methodIconStyle = css`margin-right: 5px;`;
const entryNameStyle = css`color: #000; font-size: 0.9rem; `;
const entryFileStyle = css`float: right; color: #6c757d; font-size: 0.9rem; `;
const entryLineStyle = css`background-color: #dddddd; padding: 0.2rem; border-radius: 5px; `;
const iconSrcs = {
function: '/icons/symbol-method.svg',
module: '/icons/symbol-file.svg',
method: '/icons/symbol-class.svg',
lambda: '/icons/symbol-file.svg',
};
return (
<div>
<ul css={listStyle}>
{callstacks.map((entry, index) => (
<li key={index} onClick={() => window.open(entry.url, '_blank')}>
{entry.type && <img src={iconSrcs[entry.type]} alt={`${entry.type} icon`} css={methodIconStyle} />}
<span css={entryNameStyle}>{entry.name}</span>
<span css={entryFileStyle}>
<span css={css`margin-right: 5px;`}>{entry.file.split(/[/\\]/).pop()}</span>
<span css={entryLineStyle}>{entry.line}</span>
</span>
</li>
))}
</ul>
</div>
);
}
function InfoPanel({
name,
details,
imagesMap,
timeDiff
attributes,
callstacks,
}: InfoPanelProps) {
const host = useDebugStore(state => state.host);
const img2urlCallback = useCallback((k: string) => {
@ -72,14 +126,20 @@ function InfoPanel({
}
return `http://${host}/api/read_memory?key=${k}`;
}, [imagesMap, host]);
return (
<PanelContainer>
<ScrollContainer>
<MethodContainer>
<MethodName>{name}</MethodName>
{timeDiff !== undefined && (
<TimeDiffText>{timeDiff} ms</TimeDiffText>
)}
{attributes.map((info, index) => (
<AttributeText key={index}>
{info.label}{info.value}
</AttributeText>
))}
{callstacks?.length > 0 && <CallstackList callstacks={callstacks} />}
<MethodDetails>
<div dangerouslySetInnerHTML={{ __html: details ? parseImgTags(details, img2urlCallback) : '' }} />
</MethodDetails>

View File

@ -10,7 +10,7 @@ import { useDebugClient, useDebugStore } from '../../store/debugStore';
import { Button, FormCheck } from 'react-bootstrap';
import { useMessageBox } from '../../hooks/useMessageBox';
import { useFullscreenSpinner } from '../../hooks/useFullscreenSpinner';
import { Callstack } from '../../utils/debugClient';
function readLocalDump(files: FileList, reportProgress?: (message: string, current: number, total: number) => void) {
return new Promise<{records: VisualEventData[], images: Map<string, string>}>((resolve, reject) => {
// 找到JSON文件
@ -77,6 +77,18 @@ function readLocalDump(files: FileList, reportProgress?: (message: string, curre
});
}
function formatTime(date: Date) {
const year = date.getFullYear().toString();
const month = String(date.getMonth() + 1).padStart(2, '0'); // 月份从0开始需要+1
const day = String(date.getDate()).padStart(2, '0');
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
const seconds = String(date.getSeconds()).padStart(2, '0');
const milliseconds = String(date.getMilliseconds()).padStart(3, '0').slice(0, 2); // 取毫秒的前两位
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}.${milliseconds}`;
}
const LayoutContainer = styled.div`
display: flex;
flex-direction: column;
@ -132,6 +144,7 @@ export const MainLayout: React.FC = () => {
const [isScrollLocked, setIsScrollLocked] = useState(false);
const { ok, MessageBoxComponent } = useMessageBox();
const spinner = useFullscreenSpinner();
const [callstacks, setCallstacks] = useState<Callstack[]>([]);
// 处理新消息自动滚动
useEffect(() => {
@ -200,10 +213,10 @@ export const MainLayout: React.FC = () => {
// WS 客户端初始化
const handleVisualEvent = useCallback((event: VisualEvent) => {
if (isLocalMode)
return;
if (isLocalMode) return;
updateRecords(draft => {
draft.push(event.data);
setCallstacks(event.data.callstack);
});
}, [updateRecords, isLocalMode]);
const handleConnectionStatus = useCallback((event: ConnectionStatusEvent) => {
@ -229,6 +242,20 @@ export const MainLayout: React.FC = () => {
}
}, [records, index]);
const getAttributes = (records: VisualEventData[], index: number) => {
const currentRecord = records[index];
if (!currentRecord) return [];
return [
{ label: '时间', value: formatTime(new Date(currentRecord.timestamp)) },
...(index > 0 ? [{
label: '时间差',
value: `${currentRecord.timestamp - records[index - 1].timestamp} ms`
}] : []),
{ label: '调用堆栈', value: '' }
];
};
return (
<LayoutContainer>
{MessageBoxComponent}
@ -281,10 +308,11 @@ export const MainLayout: React.FC = () => {
name={records[index]?.name}
details={records[index]?.details}
imagesMap={isLocalMode ? localImageMap : undefined}
timeDiff={index > 0 ? records[index]?.timestamp - records[index - 1]?.timestamp : undefined}
attributes={getAttributes(records, index)}
callstacks={records[index]?.callstack}
/>
</Splitable>
</MainContent>
</LayoutContainer>
);
};
};

View File

@ -3,6 +3,15 @@ export type ImageSource = {
value: string[];
};
export interface Callstack {
name: string; // 函数名
file: string; // 文件名
line: number; // 行号
code: string; // 代码行
url?: string;
type: 'function' | 'method' | 'module' | 'lambda';
}
/**
*
*/
@ -15,6 +24,8 @@ export type VisualEventData = {
details: string;
/** 时间戳(毫秒) */
timestamp: number;
/** 调用堆栈 */
callstack: Callstack[];
};
/**

View File

@ -25,7 +25,7 @@ import kotonebot.backend.context
from kotonebot.backend.core import HintBox, Image
from ..context import manual_context
from . import vars as debug_vars
from .vars import WSImage, WSMessageData, WSMessage
from .vars import WSImage, WSMessageData, WSMessage, WSCallstack
app = FastAPI()
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"])
@ -181,7 +181,7 @@ async def websocket_endpoint(websocket: WebSocket):
except:
await websocket.close()
def send_ws_message(title: str, image: list[str], text: str = '', wait: bool = False):
def send_ws_message(title: str, image: list[str], text: str = '', callstack: list[WSCallstack] = [], wait: bool = False):
"""发送 WebSocket 消息"""
message = WSMessage(
type="visual",
@ -189,7 +189,8 @@ def send_ws_message(title: str, image: list[str], text: str = '', wait: bool = F
image=WSImage(type="memory", value=image),
name=title,
details=text,
timestamp=int(time.time() * 1000)
timestamp=int(time.time() * 1000),
callstack=callstack
)
)
message_queue.append(message.dict())

View File

@ -16,6 +16,8 @@ from typing import NamedTuple, TextIO, Literal
import cv2
from cv2.typing import MatLike
from pydantic import BaseModel
import inspect # 添加此行以导入 inspect 模块
from ..core import Image
from ...util import cv2_imread
@ -28,6 +30,30 @@ class Result(NamedTuple):
description: str
timestamp: float
class WSImage(BaseModel):
type: Literal["memory"]
value: list[str]
class WSCallstack(BaseModel):
name: str
file: str
line: int
code: str
type: Literal["function", "method", "module", "lambda"]
url: str | None
class WSMessageData(BaseModel):
image: WSImage
name: str
details: str
timestamp: int
callstack: list[WSCallstack]
class WSMessage(BaseModel):
type: Literal["visual"]
data: WSMessageData
@dataclass
class _Vars:
"""调试变量类"""
@ -178,6 +204,25 @@ def _make_code_file_url(
url = f"{prefix}://file/{full_path}:{line}:0"
return f'<a href="{url}">{text}</a>'
def _make_code_file_url_only(
text: str,
full_path: str,
line: int = 0,
) -> str:
"""
将代码文本转换为 VSCode 的文件 URL
"""
ide = get_current_ide()
if ide == 'vscode':
prefix = 'vscode'
elif ide == 'cursor':
prefix = 'cursor'
elif ide == 'windsurf':
prefix = 'windsurf'
else:
return text
return f"{prefix}://file/{full_path}:{line}:0"
def result(
title: str,
image: MatLike | list[MatLike],
@ -214,64 +259,58 @@ def result(
if len(_results) > debug.max_results:
_results.pop(next(iter(_results)))
# 拼接消息
now_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f")[:-4]
# 获取完整堆栈
callstack = []
for frame in traceback.format_stack():
if not re.search(r'Python\d*[\/\\]lib|debugpy', frame):
# 提取文件路径和行号
match = re.search(r'File "([^"]+)", line (\d+)', frame)
if match:
file_path = match.group(1)
file_path = to_html(file_path)
line_num = match.group(2)
# 将绝对路径转换为相对路径
rel_path = file_path.replace(str(Path.cwd()), '.')
# 将文件路径和行号转换为链接
frame = frame.replace(
f'File "{file_path}", line {line_num}',
f'File "{_make_code_file_url(rel_path, file_path, int(line_num))}", line {line_num}'
)
callstack.append(frame)
callstack_str = '\n'.join(callstack)
callstacks: list[WSCallstack] = []
for frame in inspect.stack():
frame_info = frame.frame
# 跳过标准库和 debugpy 的代码
if re.search(r'Python\d*[\/\\]lib|debugpy', frame_info.f_code.co_filename):
break
lineno = frame_info.f_lineno
code = frame_info.f_code.co_name
# 判断第一个参数是否为 self
if frame_info.f_code.co_argcount > 0 and frame_info.f_code.co_varnames[0] == 'self':
type = 'method'
elif '<module>' in code:
type = 'module'
elif '<lambda>' in code:
type = 'lambda'
else:
type = 'function' # 默认类型为 function
callstacks.append(WSCallstack(
name=frame_info.f_code.co_name,
file=frame_info.f_code.co_filename,
line=lineno,
code=code,
url=_make_code_file_url_only(frame_info.f_code.co_filename, frame_info.f_code.co_filename, lineno),
type=type
))
# 获取简化堆栈(只包含函数名)
simple_callstack = []
for frame in traceback.extract_stack():
if not re.search(r'Python\d*[\/\\]lib|debugpy', frame.filename):
module = Path(frame.filename).stem # 只获取文件名,不含路径和扩展名
simple_callstack.append(f"{module}.{frame.name}")
simple_callstack_str = ' -> '.join(simple_callstack)
simple_callstack_str = to_html(simple_callstack_str)
final_text = (
f"Time: {now_time}<br>" +
f"Callstack: {simple_callstack_str}<br>" +
f"<details><summary>Full Callstack</summary>{callstack_str}</details><br>" +
f"<hr>{text}"
)
final_text = text
# 发送 WS 消息
from .server import send_ws_message
send_ws_message(title, saved_images, final_text, wait=debug.wait_for_message_sent)
send_ws_message(title, saved_images, final_text, callstack=callstacks, wait=debug.wait_for_message_sent)
# 保存到文件
# TODO: 把这个类型转换为 dataclass/namedtuple
if debug.auto_save_to_folder:
if _result_file is None:
if not os.path.exists(debug.auto_save_to_folder):
os.makedirs(debug.auto_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.auto_save_to_folder, log_file_name), "w")
_result_file.write(json.dumps({
"image": {
"type": "memory",
"value": saved_images
},
"name": title,
"details": final_text,
"timestamp": current_timestamp
}))
message = WSMessage(
type="visual",
data=WSMessageData(
image=WSImage(type="memory", value=saved_images),
name=title,
details=final_text,
timestamp=current_timestamp,
callstack=callstacks
)
)
_result_file.write(message.model_dump_json())
_result_file.write("\n")
_result_file.flush()
def clear_saved():
"""