feat(devtool): 优化 DumpViewer 调用堆栈部分与时间部分的显示
This commit is contained in:
parent
abf7fbc930
commit
41d4c4ce78
|
@ -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 |
|
@ -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 |
|
@ -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 |
|
@ -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 |
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
};
|
|
@ -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[];
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
@ -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())
|
||||
|
|
|
@ -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():
|
||||
"""
|
||||
|
|
Loading…
Reference in New Issue