新增功能

This commit is contained in:
Your Name
2026-08-19 17:35:59 +08:00
parent f8c78739e7
commit 05050ff6f3
188 changed files with 18838 additions and 5337 deletions
+64
View File
@@ -0,0 +1,64 @@
# -*- coding: utf-8 -*-
import os
import sys
from pathlib import Path
os.environ["QT_SCALE_FACTOR"] = "1"
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
sys.argv = [sys.argv[0], "--qt-smoke-test"]
from PySide6.QtCore import QSize, QTimer
from PySide6.QtWidgets import QApplication
from wechat_gui_qt import (
APP_QSS,
WIN_TITLE,
MainWindow,
_app_icon,
_register_bundled_fonts,
_ui_font,
)
out = ROOT / "tmp"
app = QApplication.instance() or QApplication(sys.argv)
_register_bundled_fonts()
app.setApplicationName(WIN_TITLE)
app.setWindowIcon(_app_icon())
app.setStyle("Fusion")
app.setStyleSheet(APP_QSS)
app.setFont(_ui_font(9))
window = MainWindow()
window.setFixedSize(QSize(1630, 920))
window.show()
window.raise_()
def run_captures() -> None:
def next_page(index: int) -> None:
if index >= window.stack.count():
app.quit()
return
window.show_page(index)
QApplication.processEvents()
QTimer.singleShot(450, lambda: grab_and_continue(index))
def grab_and_continue(index: int) -> None:
pix = window.grab()
path = out / f"qt-ui-smoke-{index + 1}.png"
ok = pix.save(str(path))
print(index, ok, pix.width(), pix.height(), path, path.exists(), path.stat().st_size if path.exists() else 0)
next_page(index + 1)
next_page(0)
def start_when_ready() -> None:
if getattr(window.console_shell, "page_ready", False):
QTimer.singleShot(600, run_captures)
return
QTimer.singleShot(200, start_when_ready)
QTimer.singleShot(400, start_when_ready)
QTimer.singleShot(20000, app.quit)
raise SystemExit(app.exec())
Binary file not shown.

After

Width:  |  Height:  |  Size: 496 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 496 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 496 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 496 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 496 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 496 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 496 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 496 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 496 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 496 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 496 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 496 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 496 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 496 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 496 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 496 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 496 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 496 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 496 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 496 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 496 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 496 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 496 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 496 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 496 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 496 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 496 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 496 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 496 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 496 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 496 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 496 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 496 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 496 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 496 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 496 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 496 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 496 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 496 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 496 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 496 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 496 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 496 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

+135
View File
@@ -0,0 +1,135 @@
# -*- coding: utf-8 -*-
"""Visible Windows/QWebEngine paint stress probe for the scaled console shell."""
import os
import sys
from pathlib import Path
os.environ.setdefault("QT_SCALE_FACTOR", "1")
os.environ.setdefault("QTWEBENGINE_CHROMIUM_FLAGS", "--enable-logging=stderr --v=0")
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
sys.argv = [sys.argv[0], "--qt-smoke-test", "--qt-paint-stress-test"]
from PySide6.QtCore import QPoint, QSize, Qt, QTimer
from PySide6.QtTest import QTest
from PySide6.QtWidgets import QApplication
from wechat_gui_qt import (
APP_QSS,
CONSOLE_VIEWS,
MainWindow,
WIN_TITLE,
_app_icon,
_register_bundled_fonts,
_ui_font,
)
def dark_samples(image, shell, source_box, step=5):
source_x1, source_y1, source_x2, source_y2 = source_box
scale = min(shell.width() / 1630.0, shell.height() / 920.0)
dpr_x = image.width() / max(1.0, float(shell.width()))
dpr_y = image.height() / max(1.0, float(shell.height()))
count = 0
for source_x in range(source_x1, source_x2, step):
for source_y in range(source_y1, source_y2, step):
x = min(image.width() - 1, max(0, round(source_x * scale * dpr_x)))
y = min(image.height() - 1, max(0, round(source_y * scale * dpr_y)))
colour = image.pixelColor(x, y)
if max(colour.red(), colour.green(), colour.blue()) < 180:
count += 1
return count
app = QApplication.instance() or QApplication(sys.argv)
_register_bundled_fonts()
app.setApplicationName(WIN_TITLE)
app.setWindowIcon(_app_icon())
app.setStyle("Fusion")
app.setStyleSheet(APP_QSS)
app.setFont(_ui_font(9))
window = MainWindow()
window.setFixedSize(QSize(1506, 909))
window.show()
window.raise_()
state = {
"frame": 0,
"bad": 0,
"rail_min": 10**9,
"main_min": 10**9,
}
out_dir = ROOT / "tmp" / "paint-stress"
out_dir.mkdir(exist_ok=True)
frame_limit = max(1, int(os.environ.get("PAINT_STRESS_FRAMES", "210")))
toggle_surface = os.environ.get("PAINT_STRESS_TOGGLE_SURFACE", "0") == "1"
def probe_frame():
index = state["frame"] % len(CONSOLE_VIEWS)
if toggle_surface and state["frame"] and state["frame"] % 7 == 0:
window.console_shell.hide()
QApplication.processEvents()
window.console_shell.show()
window.show_page(index)
window._push_console_state(force=True)
scale = min(window.console_shell.width() / 1630.0, window.console_shell.height() / 920.0)
nav_y = (145, 222, 297, 372, 450, 528, 861)[index]
QTest.mouseMove(
window.console_shell,
QPoint(round(60 * scale), round(nav_y * scale)),
delay=1,
)
QTimer.singleShot(90, capture_frame)
def capture_frame():
index = state["frame"] % len(CONSOLE_VIEWS)
view = CONSOLE_VIEWS[index]
pixmap = window.screen().grabWindow(int(window.winId()))
image = pixmap.toImage()
rail = dark_samples(image, window.console_shell, (20, 20, 145, 900), 5)
main = dark_samples(image, window.console_shell, (175, 205, 1600, 875), 7)
state["rail_min"] = min(state["rail_min"], rail)
state["main_min"] = min(state["main_min"], main)
# Healthy frames are far above these deliberately conservative floors.
if rail < 60 or main < 2000:
state["bad"] += 1
path = out_dir / f"bad-{state['frame']:03d}-{view}-r{rail}-m{main}.png"
pixmap.save(str(path))
print("BAD", state["frame"], view, rail, main, path, flush=True)
state["frame"] += 1
if state["frame"] >= frame_limit:
print(
"RESULT",
f"frames={state['frame']}",
f"bad={state['bad']}",
f"rail_min={state['rail_min']}",
f"main_min={state['main_min']}",
f"size={image.width()}x{image.height()}",
f"shell={window.console_shell.width()}x{window.console_shell.height()}",
flush=True,
)
app.exit(0 if state["bad"] == 0 else 3)
return
if state["frame"] % 20 == 0:
print("FRAME", state["frame"], view, rail, main, flush=True)
QTimer.singleShot(35, probe_frame)
def wait_ready():
if window.console_shell.page_ready and window.console_shell.channel_ready:
QTimer.singleShot(700, probe_frame)
return
QTimer.singleShot(100, wait_ready)
QTimer.singleShot(100, wait_ready)
QTimer.singleShot(max(30_000, frame_limit * 400), lambda: app.exit(4))
raise SystemExit(app.exec())
+292
View File
@@ -0,0 +1,292 @@
# -*- coding: utf-8 -*-
"""Targeted, read-only DOM assertions for the archive/AI HTML shell."""
import json
import os
import sys
from pathlib import Path
from unittest.mock import patch
os.environ.setdefault("QT_SCALE_FACTOR", "1")
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
sys.argv = [sys.argv[0], "--qt-smoke-test"]
from PySide6.QtCore import QSize, QTimer
from PySide6.QtWidgets import QApplication, QInputDialog
from wechat_gui_qt import MainWindow
app = QApplication.instance() or QApplication(sys.argv)
window = MainWindow()
window.setFixedSize(QSize(1630, 920))
window.show()
results = {}
def evaluate(name: str, page_index: int, script: str, done) -> None:
window.show_page(page_index)
def run() -> None:
window.console_shell.page().runJavaScript(script, finish)
def finish(value) -> None:
if isinstance(value, str) and value:
try:
value = json.loads(value)
except json.JSONDecodeError:
pass
results[name] = value
done()
QTimer.singleShot(650, run)
COMMON = r"""
const rect = selector => {
const el = document.querySelector(selector);
if (!el) return null;
const box = el.getBoundingClientRect();
return {x:box.x,y:box.y,width:box.width,height:box.height};
};
"""
ARCHIVE = """JSON.stringify((() => { try {\n""" + COMMON + r"""
return {
ok: true,
view: STATE.view,
title: document.querySelector('.page-head h1')?.textContent,
filter: rect('.filter-row'),
grid: rect('.archive-grid'),
gridColumns: getComputedStyle(document.querySelector('.archive-grid')).gridTemplateColumns,
bottom: rect('.archive-bottom'),
sessionRows: document.querySelectorAll('[data-action="select-archive"]').length,
filters: document.querySelectorAll('[data-action="archive-filter"]').length,
searchMaxLength: document.querySelector('#archive-search')?.maxLength,
actions: [...document.querySelectorAll('[data-action]')].map(x=>x.dataset.action),
toastShown: document.querySelector('#toast')?.classList.contains('show') || false
};
} catch (error) { return {ok:false,error:String(error),stack:error.stack}; } })())"""
AI = """JSON.stringify((() => { try {\n""" + COMMON + r"""
return {
ok: true,
view: STATE.view,
title: document.querySelector('.page-head h1')?.textContent,
tabs: rect('.tabs'),
top: rect('.ai-top'),
topColumns: getComputedStyle(document.querySelector('.ai-top')).gridTemplateColumns,
bottom: rect('.ai-bottom'),
bottomColumns: getComputedStyle(document.querySelector('.ai-bottom')).gridTemplateColumns,
config: rect('.config-strip'),
hiddenPanes: document.querySelectorAll('.ai-pane[hidden]').length,
promptReadOnly: document.querySelector('#ai-prompt')?.readOnly === true,
toolSwitches: document.querySelectorAll('[data-action="toggle-tool"]').length,
tabsWithActions: document.querySelectorAll('[data-action="ai-tab"]').length,
actions: [...document.querySelectorAll('[data-action]')].map(x=>x.dataset.action),
toastShown: document.querySelector('#toast')?.classList.contains('show') || false
};
} catch (error) { return {ok:false,error:String(error),stack:error.stack}; } })())"""
RUNTIME_ARCHIVE = """JSON.stringify((() => { try {\n""" + COMMON + r"""
STATE.demo = false;
Object.assign(STATE, {
view: 'archive', archiveFilter: '全部', archiveQuery: '', archiveDate: '',
selectedArchive: 'archive-1',
archives: [{key:'archive-1',name:'真实客户',avatar:'',preview:'真实归档摘要',time:'16:31',badge:'AI 已回复',count:2}],
chat: [{role:'user',time:'16:31:01',text:'真实消息'},{role:'assistant',time:'16:31:02',text:'真实回复'}],
memory: {tags:['真实标签'],text:'真实客户记忆',rounds:2},
settle: [{title:'挂号登记',hint:'真实记录',value:'1 次'}],
activities: []
});
render('archive');
const oldBridge = window.qtBridge;
const calls = [];
const stub = {
exportArchive:()=>calls.push('exportArchive'), setArchiveFilter:key=>calls.push('setArchiveFilter:'+key),
chooseArchiveDate:()=>calls.push('chooseArchiveDate'), selectArchive:key=>calls.push('selectArchive:'+key),
refresh:()=>calls.push('refresh'), copyArchive:()=>calls.push('copyArchive')
};
window.qtBridge = stub;
const stubbed = window.qtBridge === stub;
if (stubbed) {
['export-archive','archive-filter','archive-date','select-archive','refresh','copy-archive']
.forEach(action => document.querySelector(`[data-action="${action}"]`)?.click());
}
window.qtBridge = oldBridge;
return {
ok:true, title:document.querySelector('.page-head h1')?.textContent,
filter:rect('.filter-row'), grid:rect('.archive-grid'), bottom:rect('.archive-bottom'),
gridColumns:getComputedStyle(document.querySelector('.archive-grid')).gridTemplateColumns,
searchMaxLength:document.querySelector('#archive-search')?.maxLength,
toastActions:document.querySelectorAll('[data-action="toast"]').length,
stubbed, calls
};
} catch (error) { return {ok:false,error:String(error),stack:error.stack}; } })())"""
RUNTIME_AI = """JSON.stringify((() => { try {\n""" + COMMON + r"""
STATE.demo = false;
STATE.aiOk = true;
STATE.aiTab = '基础设置';
STATE.ai = {
name:'真实客服', prompt:'由安全模板生成的只读提示词', model:'real-model', backup:'云端策略',
context:8, maxTokens:500, temperature:'0.35', replyLengthIndex:1, mcpRounds:5,
knowledgeCount:'2', knowledgeConnected:true, syncLabel:'刚刚同步', tools:[true,false,true],
toolCount:2, synced:true
};
render('ai');
const oldBridge = window.qtBridge;
const calls = [];
const stub = {
saveAi:payload=>calls.push('saveAi:'+payload), testAi:()=>calls.push('testAi'),
pickModel:()=>calls.push('pickModel'), pickBackupModel:()=>calls.push('pickBackupModel'),
syncKnowledge:()=>calls.push('syncKnowledge'), editKnowledge:()=>calls.push('editKnowledge'),
toggleTool:key=>calls.push('toggleTool:'+key), setAiTab:key=>calls.push('setAiTab:'+key)
};
window.qtBridge = stub;
const stubbed = window.qtBridge === stub;
if (stubbed) {
['save-ai','test-ai','pick-model','pick-backup','sync-knowledge','edit-knowledge','toggle-tool','ai-tab']
.forEach(action => document.querySelector(`[data-action="${action}"]`)?.click());
}
window.qtBridge = oldBridge;
let savedPayload = {};
const savedCall = calls.find(value=>value.startsWith('saveAi:')) || '';
try { savedPayload = JSON.parse(savedCall.slice(7) || '{}'); } catch (_error) {}
return {
ok:true, title:document.querySelector('.page-head h1')?.textContent,
tabs:rect('.tabs'), top:rect('.ai-top'), bottom:rect('.ai-bottom'), config:rect('.config-strip'),
topColumns:getComputedStyle(document.querySelector('.ai-top')).gridTemplateColumns,
bottomColumns:getComputedStyle(document.querySelector('.ai-bottom')).gridTemplateColumns,
hiddenPanes:document.querySelectorAll('.ai-pane[hidden]').length,
promptReadOnly:document.querySelector('#ai-prompt')?.readOnly === true,
toolSwitches:document.querySelectorAll('[data-action="toggle-tool"]').length,
toastActions:document.querySelectorAll('[data-action="toast"]').length,
stubbed, calls, savedPayload, savedPayloadHasPrompt:Object.hasOwn(savedPayload,'prompt')
};
} catch (error) { return {ok:false,error:String(error),stack:error.stack}; } })())"""
def finish() -> None:
listing = window.business_page.recent_list
first_item = listing.item(0) if listing.count() else None
first_key = str(first_item.data(0x0100) or "") if first_item else ""
results["archive_selection"] = {
"existing": bool(first_key and window._select_archive_session(first_key)),
"stale": window._select_archive_session("__missing_archive__"),
}
with patch.object(QInputDialog, "getText", return_value=("2026-08-18", True)):
with patch.object(window.business_page, "_set_archive_date") as set_date:
window.console_shell.bridge.chooseArchiveDate()
results["archive_date"] = {
"called": set_date.call_count == 1,
"value": set_date.call_args.args[0] if set_date.call_args else "",
}
print(json.dumps(results, ensure_ascii=False, indent=2), flush=True)
archive = results.get("archive") or {}
ai = results.get("ai") or {}
expected_archive = (
archive.get("ok")
and archive.get("title") == "会话归档"
and archive.get("gridColumns") == "333px 605px 437px"
and round((archive.get("filter") or {}).get("height", 0)) == 64
and round((archive.get("grid") or {}).get("height", 0)) == 521
and round((archive.get("bottom") or {}).get("height", 0)) == 153
and archive.get("sessionRows") == 5
and archive.get("filters") == 4
and archive.get("searchMaxLength") == 80
and not archive.get("toastShown")
and results["archive_selection"]["existing"]
and not results["archive_selection"]["stale"]
and results["archive_date"] == {"called": True, "value": "2026-08-18"}
)
expected_ai = (
ai.get("ok")
and ai.get("title") == "AI 设置"
and ai.get("topColumns") == "856px 536px"
and ai.get("bottomColumns") == "428px 428px 536px"
and round((ai.get("tabs") or {}).get("height", 0)) == 58
and round((ai.get("top") or {}).get("height", 0)) == 330
and round((ai.get("bottom") or {}).get("height", 0)) == 244
and round((ai.get("config") or {}).get("height", 0)) == 110
and ai.get("hiddenPanes") == 0
and ai.get("promptReadOnly")
and ai.get("toolSwitches") == 3
and ai.get("tabsWithActions") == 3
and not ai.get("toastShown")
)
runtime_archive = results.get("runtime_archive") or {}
expected_runtime_archive = (
runtime_archive.get("ok")
and runtime_archive.get("title") == "会话归档"
and runtime_archive.get("gridColumns") == "333px 605px 437px"
and round((runtime_archive.get("filter") or {}).get("height", 0)) == 64
and round((runtime_archive.get("grid") or {}).get("height", 0)) == 521
and round((runtime_archive.get("bottom") or {}).get("height", 0)) == 153
and runtime_archive.get("searchMaxLength") == 80
and runtime_archive.get("toastActions") == 0
and runtime_archive.get("stubbed")
and runtime_archive.get("calls") == [
"exportArchive", "setArchiveFilter:全部会话", "chooseArchiveDate",
"selectArchive:archive-1", "refresh", "copyArchive",
]
)
runtime_ai = results.get("runtime_ai") or {}
expected_runtime_ai = (
runtime_ai.get("ok")
and runtime_ai.get("title") == "AI 设置"
and runtime_ai.get("topColumns") == "856px 536px"
and runtime_ai.get("bottomColumns") == "428px 428px 536px"
and round((runtime_ai.get("tabs") or {}).get("height", 0)) == 58
and round((runtime_ai.get("top") or {}).get("height", 0)) == 330
and round((runtime_ai.get("bottom") or {}).get("height", 0)) == 244
and round((runtime_ai.get("config") or {}).get("height", 0)) == 110
and runtime_ai.get("hiddenPanes") == 0
and runtime_ai.get("promptReadOnly")
and runtime_ai.get("toolSwitches") == 3
and runtime_ai.get("toastActions") == 0
and runtime_ai.get("stubbed")
and not runtime_ai.get("savedPayloadHasPrompt")
and (runtime_ai.get("savedPayload") or {}).get("maxTokens") == 500
and runtime_ai.get("calls") == [
"saveAi:{\"name\":\"真实客服\",\"temperature\":0.35,\"context\":8,\"maxTokens\":500,\"mcpRounds\":5,\"replyLength\":1}",
"testAi", "pickModel", "pickBackupModel", "syncKnowledge", "editKnowledge",
"toggleTool:0", "setAiTab:基础设置",
]
)
app.exit(
0
if expected_archive and expected_ai and expected_runtime_archive and expected_runtime_ai
else 1
)
def run_runtime_ai() -> None:
evaluate("runtime_ai", 3, RUNTIME_AI, finish)
def run_runtime_archive() -> None:
evaluate("runtime_archive", 2, RUNTIME_ARCHIVE, run_runtime_ai)
def run_ai() -> None:
evaluate("ai", 3, AI, run_runtime_archive)
def run_archive() -> None:
evaluate("archive", 2, ARCHIVE, run_ai)
def wait_until_ready() -> None:
if window.console_shell.page_ready:
run_archive()
else:
QTimer.singleShot(100, wait_until_ready)
QTimer.singleShot(100, wait_until_ready)
QTimer.singleShot(20000, lambda: app.exit(2))
raise SystemExit(app.exec())