mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 00:11:59 +00:00
不是每句话都值得跑一遍库:模型自己选要不要执行
以前必执行——final 一到就无条件 launch。库里那条会话 7 轮全建了图、全跑了
一遍 153 张表的库,其中:
- 「重试」「继续找找」这种对过程说的话,各自重建了一整张图去查
- 一句明确要「设计一个工作流,使得每次执行的时候都给出管理员账户信息」的,
建完也直接跑了——用户要的是那张图,不是这一次的结果
流协议给模型两个新出口,三条路径二选一:
- {"op":"reply","text":"…"} 不建图不跑。问的是前几轮已经查出来的东西
(口径、怎么算的),或者是对过程说的话,或者需要先反问清楚
- done 里 run:false 建图但停住,界面上给「跑一下」
- done 里 run:true(默认) 不变
reply 一到服务端就收流:让它走到后面那套排版校验上,空图会撞出"图校验未
通过:图是空的,先拖一个节点进来"——上一个提交刚治过同类的坑。
放开「直接回答」等于绕开了这条路径上最硬的一条约定(涉及数据必须真查),
所以两头都上:prompt 里写死 reply 只能复述或解释前面几轮已有的结果、不得
新断言任何数据事实,凡是要动到库才知道的哪怕觉得上一轮见过也重新查;界面
上那一轮的结论**前面**挂一条声明,说清楚这一条没有查库。check-stream 里有
一条断言专门盯它排在结论前面——排在后面等于先让人读完再说来源不可靠。
需要留意:这依赖模型照约定表态,不是硬规则。判断跑偏的话收紧那几句约定。
This commit is contained in:
@@ -248,7 +248,21 @@ def _user_message(payload: GenerateIn, *, patch: bool) -> str:
|
||||
"- 但**每个节点都要用 add_node 完整输出一遍**,包括没有改动的。"
|
||||
"这一轮是从空图开始搭的,只发改动操作会得到一张空图\n"
|
||||
"- 之前几轮的问答由系统注入成 `{{ input.history }}`。用户用「它们」「这些」"
|
||||
"这类指代时,要让回答的节点引用它,否则模型不知道指的是上一轮那批数据"
|
||||
"这类指代时,要让回答的节点引用它,否则模型不知道指的是上一轮那批数据\n"
|
||||
"\n"
|
||||
"先判断这句话该走哪条路径(三选一):\n"
|
||||
# 以前这里没有分叉:每一句话都建一张图、跑一遍库。于是「重试」
|
||||
# 「继续找找」这种对过程说的话,也各自重建了一整张图去查 153 张表
|
||||
"1. **直接回答**(输出 reply):这句话不需要新数据就能答——问的是"
|
||||
"前面几轮已经查出来的东西(口径、怎么算的、哪张表)、是对过程说的话"
|
||||
"(「重试」「换个说法」),或者你需要先反问清楚才能动手。\n"
|
||||
" 硬性约束:reply 里**只能复述或解释前面几轮已有的结果**,"
|
||||
"不得新断言任何数据事实——不许凭印象给数字、给名单、给结论。"
|
||||
"凡是要动到库才知道的,哪怕你觉得上一轮见过,也走第 2 条重新查。\n"
|
||||
"2. **建图并执行**(done 的 run 为 true,默认):要新数据才能答的问题。\n"
|
||||
"3. **建图但不执行**(done 的 run 为 false):用户要的是**流程本身**"
|
||||
"——「设计一个工作流」「做一个每天能跑的」这类。他要的是这张图,"
|
||||
"不是这一次的结果,跑一遍只是替他多花一次钱。\n"
|
||||
)
|
||||
return f"请设计一个工作流:{payload.instruction}"
|
||||
|
||||
@@ -516,9 +530,14 @@ _STREAM_PROTOCOL = """\
|
||||
{"op":"remove_node","id":"..."}
|
||||
{"op":"add_edge","edge":{"source":"...","target":"...","sourceHandle":"..."}}
|
||||
{"op":"remove_edge","source":"...","target":"...","sourceHandle":"..."}
|
||||
{"op":"done","explanation":"两三句话说明这张图怎么跑"} ← 最后一行
|
||||
{"op":"done","explanation":"两三句话说明这张图怎么跑","run":true} ← 最后一行
|
||||
- 按执行顺序添加节点(先入口后出口);每加一个节点,立刻把连向它的边(两端都已存在的)输出出来,让图连贯地生长
|
||||
- 修改现有图时只输出改动,没提到的节点不要动"""
|
||||
- 修改现有图时只输出改动,没提到的节点不要动
|
||||
- done 里的 run:这张图建完要不要立刻执行。默认 true;用户要的是**流程本身**
|
||||
("设计一个每天跑的工作流")时给 false —— 他要的是这张图,不是这一次的结果
|
||||
- 如果这句话根本不需要工作流(见下面的路径约定),**第一行也是最后一行**就输出:
|
||||
{"op":"reply","text":"直接回答的内容"}
|
||||
这时不要输出任何别的操作"""
|
||||
|
||||
|
||||
def _parse_op_line(line: str) -> dict[str, Any] | None:
|
||||
@@ -720,6 +739,8 @@ async def generate_stream(payload: GenerateIn, session: AsyncSession = Depends(g
|
||||
async def event_stream():
|
||||
explanation = ""
|
||||
seen_ops: list[dict[str, Any]] = []
|
||||
# 建完要不要跑,由模型在 done.run 里表态
|
||||
autorun = True
|
||||
yield f"data: {json.dumps({'op': 'model', 'model': model_id}, ensure_ascii=False)}\n\n"
|
||||
try:
|
||||
async for op in _with_heartbeat(_iter_ops(model, messages)):
|
||||
@@ -728,8 +749,16 @@ async def generate_stream(payload: GenerateIn, session: AsyncSession = Depends(g
|
||||
if kind in ("thinking", "heartbeat"):
|
||||
yield f"data: {json.dumps(op, ensure_ascii=False)}\n\n"
|
||||
continue
|
||||
if kind == "reply":
|
||||
# 这句话不需要工作流。原样转给前端,然后收流——后面那套
|
||||
# 排版校验对着一张空图跑,只会得到"图是空的,先拖一个节点进来"
|
||||
yield f"data: {json.dumps(op, ensure_ascii=False)}\n\n"
|
||||
return
|
||||
if kind == "done":
|
||||
explanation = str(op.get("explanation", ""))
|
||||
# 用户要的是流程本身时("设计一个每天跑的工作流"),
|
||||
# 建完就跑等于替他多花一次钱,而他要的是那张图
|
||||
autorun = op.get("run") is not False
|
||||
seen_ops.append(op)
|
||||
changed = _apply_op(nodes, edges, op)
|
||||
if changed or kind in ("plan", "done"):
|
||||
@@ -770,6 +799,7 @@ async def generate_stream(payload: GenerateIn, session: AsyncSession = Depends(g
|
||||
"graph": spec.model_dump(mode="json"),
|
||||
"issues": issues,
|
||||
"explanation": explanation,
|
||||
"autorun": autorun,
|
||||
}
|
||||
except Exception as e: # noqa: BLE001
|
||||
final = {"op": "error", "message": f"生成的图结构非法:{e}"}
|
||||
|
||||
@@ -372,3 +372,67 @@ async def test_the_first_turn_has_nothing_to_fall_back_on(client, monkeypatch) -
|
||||
assert final["op"] == "final"
|
||||
assert final["graph"]["nodes"] == []
|
||||
assert any("空" in str(i.get("message", "")) for i in final["issues"]), final["issues"]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 不是每句话都值得跑一遍库
|
||||
# --------------------------------------------------------------------------
|
||||
#
|
||||
# 以前必执行:final 一到就无条件 launch。于是「重试」「继续找找」这种对过程
|
||||
# 说的话,也各自重建了一整张图、跑一遍 153 张表的库;而一句明确要「设计一个
|
||||
# 工作流」的,建完也直接跑了——用户要的是那张图,不是这一次的结果。
|
||||
|
||||
|
||||
async def test_a_question_about_earlier_turns_needs_no_workflow(client, monkeypatch) -> None:
|
||||
"""reply 这条路径:不建图、不跑、也不该走到那套排版校验上。
|
||||
|
||||
走到了的话,空图会撞出"图校验未通过:图是空的,先拖一个节点进来"。
|
||||
"""
|
||||
conv_id = await _seeded(client)
|
||||
final = await _stream_graph(client, monkeypatch, conv_id, [
|
||||
'{"op":"reply","text":"第 1 轮走的是 role.level IN (platform, regional) 这个口径。"}',
|
||||
])
|
||||
assert final["op"] == "reply"
|
||||
assert "口径" in final["text"]
|
||||
assert "graph" not in final
|
||||
|
||||
|
||||
async def test_asking_for_a_workflow_builds_it_but_does_not_run_it(client, monkeypatch) -> None:
|
||||
"""用户要的是流程本身,跑一遍只是替他多花一次钱。"""
|
||||
conv_id = await _seeded(client)
|
||||
final = await _stream_graph(client, monkeypatch, conv_id, [
|
||||
'{"op":"plan","summary":"搭一个每天能跑的"}',
|
||||
'{"op":"add_node","node":{"id":"in","type":"input","label":"入口","config":{}}}',
|
||||
'{"op":"add_node","node":{"id":"out","type":"output","label":"成果","config":{}}}',
|
||||
'{"op":"add_edge","edge":{"source":"in","target":"out"}}',
|
||||
'{"op":"done","explanation":"每天跑一次","run":false}',
|
||||
])
|
||||
assert final["op"] == "final"
|
||||
assert final["autorun"] is False
|
||||
assert [n["id"] for n in final["graph"]["nodes"]] == ["in", "out"]
|
||||
|
||||
|
||||
async def test_a_normal_data_question_still_runs(client, monkeypatch) -> None:
|
||||
"""默认不变:问数据就是建图 + 跑。没写 run 就当 true。"""
|
||||
conv_id = await _seeded(client)
|
||||
final = await _stream_graph(client, monkeypatch, conv_id, [
|
||||
'{"op":"plan","summary":"查一下"}',
|
||||
'{"op":"add_node","node":{"id":"in","type":"input","label":"入口","config":{}}}',
|
||||
'{"op":"add_node","node":{"id":"out","type":"output","label":"成果","config":{}}}',
|
||||
'{"op":"add_edge","edge":{"source":"in","target":"out"}}',
|
||||
'{"op":"done","explanation":"跑完给结论"}',
|
||||
])
|
||||
assert final["op"] == "final"
|
||||
assert final["autorun"] is True
|
||||
|
||||
|
||||
def test_the_three_paths_are_spelled_out_for_the_model() -> None:
|
||||
"""路径选择全靠这段约定,尤其是那条不许凭印象给数字的硬约束。"""
|
||||
from app.api.copilot import GenerateIn, _STREAM_PROTOCOL, _user_message
|
||||
|
||||
text = _user_message(GenerateIn(instruction="随便问问", intent="answer"), patch=False)
|
||||
assert "直接回答" in text and "建图并执行" in text and "建图但不执行" in text
|
||||
assert "不得新断言任何数据事实" in text
|
||||
# 协议里要给得出这两个出口,否则模型没法表达
|
||||
assert '"op":"reply"' in _STREAM_PROTOCOL
|
||||
assert '"run":true' in _STREAM_PROTOCOL
|
||||
|
||||
@@ -95,6 +95,13 @@ function longReport(): string {
|
||||
].join("\n")
|
||||
}
|
||||
|
||||
/** 直接回答的那一轮:没查库,界面上必须说破 */
|
||||
const NO_QUERY_TURN: StreamTurn = {
|
||||
id: "noquery", question: "刚才那 6 个管理员是怎么算的?", phase: "done", status: "完成",
|
||||
steps: [], noQuery: true,
|
||||
output: { answer: "第 1 轮走的是 `role.level IN (platform, regional)` 这个口径。要换口径我重新查一遍。" },
|
||||
}
|
||||
|
||||
const LONG_TURN: StreamTurn = {
|
||||
id: "long", question: "需要能够显示总结内容", phase: "done", status: "完成",
|
||||
steps: [], output: { result: longReport() },
|
||||
@@ -138,6 +145,9 @@ function Preview() {
|
||||
if (params.get('long') === '1') {
|
||||
return <AssistantStream turns={[LONG_TURN]} />
|
||||
}
|
||||
if (params.get('noquery') === '1') {
|
||||
return <AssistantStream turns={[NO_QUERY_TURN]} />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Database, ListTree, MessageSquare, Send, Square } from 'lucide-react'
|
||||
import { Database, ListTree, MessageSquare, Play, Send, Square } from 'lucide-react'
|
||||
import { api } from '../api/client'
|
||||
import { ApprovalCard } from '../run/RunPanel'
|
||||
import { AssistantStream, StreamEmpty, type StreamTurn } from '../run/AssistantStream'
|
||||
@@ -25,7 +25,7 @@ import { useToast } from '../components/ui'
|
||||
* dense 一个开关。两边各写一套的话,同一次运行在两个页面会讲出不同的故事。
|
||||
*/
|
||||
export function ChatPage() {
|
||||
const { busy, ask, stop, load, loadSteps, turnsOf } = useChat()
|
||||
const { busy, ask, stop, load, loadSteps, runNow, turnsOf } = useChat()
|
||||
const currentId = useConversations((s) => s.currentId)
|
||||
const create = useConversations((s) => s.create)
|
||||
const turns = useChat((s) => (currentId ? s.byConversation[currentId] : undefined))
|
||||
@@ -73,6 +73,11 @@ export function ChatPage() {
|
||||
? () => void loadSteps(currentId, t.id)
|
||||
: undefined
|
||||
}
|
||||
onRun={
|
||||
currentId && turnsOf(currentId).find((x) => x.id === t.id)?.pendingRun
|
||||
? () => runNow(currentId, t.id)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
onOpenGraph={(graph) => {
|
||||
@@ -132,6 +137,7 @@ function toStreamTurn(turn: ChatTurn): StreamTurn {
|
||||
phase: turn.phase === 'waiting' ? 'waiting'
|
||||
: turn.phase === 'error' ? 'error'
|
||||
: running ? 'running' : 'done',
|
||||
// ready(建好了没跑)在流里按"完成"画,差别体现在下面那个「跑一下」上
|
||||
status: turn.status,
|
||||
// 建图的步骤在前,跑图的在后——这正是发生的顺序
|
||||
steps: [...decodeCopilot(turn.ops), ...decodeRun(turn.events)],
|
||||
@@ -142,6 +148,7 @@ function toStreamTurn(turn: ChatTurn): StreamTurn {
|
||||
runClass: turn.run?.run_class,
|
||||
graph: turn.graph,
|
||||
graphNote: turn.explanation,
|
||||
noQuery: turn.noQuery,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -151,19 +158,25 @@ function toStreamTurn(turn: ChatTurn): StreamTurn {
|
||||
* 从库里恢复的轮次没有事件流——一次会话几十轮,把每轮的事件都预加载回来
|
||||
* 是几十个请求换一堆没人看的步骤。所以默认只显示问题和答案,想看过程再取。
|
||||
*/
|
||||
function Approvals({ turnId, runId, onShowSteps }: {
|
||||
function Approvals({ turnId, runId, onShowSteps, onRun }: {
|
||||
turnId: string
|
||||
runId?: string
|
||||
onShowSteps?: () => void
|
||||
onRun?: () => void
|
||||
}) {
|
||||
const conversationId = useConversations((s) => s.currentId)
|
||||
const approvals = useCatalog((s) => s.approvals)
|
||||
const reattach = useChat((s) => s.reattach)
|
||||
const pending = approvals.filter((a) => a.run_id === runId && a.status === 'pending')
|
||||
|
||||
if (!runId) return null
|
||||
if (!runId && !onRun) return null
|
||||
return (
|
||||
<>
|
||||
{onRun && (
|
||||
<button className="btn btn-xs btn-primary mt-2 gap-1 text-[11px]" onClick={onRun}>
|
||||
<Play size={11} /> 跑一下
|
||||
</button>
|
||||
)}
|
||||
{onShowSteps && (
|
||||
<button className="btn btn-xs btn-ghost mt-2 gap-1 text-[11px]" onClick={onShowSteps}>
|
||||
<ListTree size={11} /> 看执行过程
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useRef, useState, type ReactNode } from 'react'
|
||||
import {
|
||||
Brain, ChevronRight, CircleCheck, CircleDot, Database,
|
||||
FileCode, GitBranch, Hand, Sparkles, Table2, Terminal, Wrench, XCircle,
|
||||
FileCode, GitBranch, Hand, Info, Sparkles, Table2, Terminal, Wrench, XCircle,
|
||||
} from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
import { Spinner } from '../components/ui'
|
||||
@@ -40,6 +40,8 @@ export interface StreamTurn {
|
||||
/** 这一轮建出来的图,可展开看、可放到画布 */
|
||||
graph?: any
|
||||
graphNote?: string
|
||||
/** 这一条没有查库,答案是根据前几轮说的 */
|
||||
noQuery?: boolean
|
||||
}
|
||||
|
||||
const ICONS: Record<StepKind, typeof Wrench> = {
|
||||
@@ -143,6 +145,17 @@ function TurnCard({ turn, dense, approvals, onOpenGraph }: {
|
||||
|
||||
{approvals}
|
||||
|
||||
{/* 没查库这件事必须说在结论前面。「涉及数据必须真查」是这条路径上
|
||||
最硬的一条约定,放开它之后,用户得能一眼分清哪些结论背后真的
|
||||
动了库、哪些只是在复述前面几轮 */}
|
||||
{turn.noQuery && (
|
||||
<div className="mt-2 flex items-start gap-1.5 rounded border px-2 py-1.5 text-[10.5px] leading-relaxed"
|
||||
style={{ borderColor: 'var(--border)', color: 'var(--text-dim)' }}>
|
||||
<Info size={11} className="mt-[2px] shrink-0" />
|
||||
<span>这一条没有查库,是根据前面几轮的结果说的</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{turn.output && (
|
||||
<Output output={turn.output} dense={dense} runClass={turn.runClass} />
|
||||
)}
|
||||
|
||||
@@ -30,6 +30,7 @@ export type Phase =
|
||||
| 'building' // 图在长出来
|
||||
| 'running' // 图在跑
|
||||
| 'waiting' // 停在人工介入
|
||||
| 'ready' // 图建好了,等用户决定跑不跑
|
||||
| 'done'
|
||||
| 'error'
|
||||
|
||||
@@ -61,6 +62,10 @@ export interface ChatTurn {
|
||||
/** 这一轮自己的取消句柄。放 turn 上而不是 store 上:store 单值会被
|
||||
* 下一轮 ask 覆盖,上一轮的 WebSocket 就再也关不掉了 */
|
||||
cancel: (() => void) | null
|
||||
/** 这一轮没有查库,答案是根据前几轮说的。界面上必须标出来 */
|
||||
noQuery?: boolean
|
||||
/** 图建好了但按模型的意思没有自动跑,等用户点「跑一下」 */
|
||||
pendingRun?: boolean
|
||||
/** 从库里恢复的历史轮次。它没有事件流,步骤要点开才去取 */
|
||||
restored?: boolean
|
||||
/** 已经补取过事件(或正在取),别重复拉 */
|
||||
@@ -82,6 +87,8 @@ interface ChatState {
|
||||
reattach: (conversationId: string, turnId: string) => Promise<void>
|
||||
/** 历史轮次点开"执行过程"时才去取事件 */
|
||||
loadSteps: (conversationId: string, turnId: string) => Promise<void>
|
||||
/** 模型建了图但没自动跑时,用户点「跑一下」 */
|
||||
runNow: (conversationId: string, turnId: string) => void
|
||||
/** 会话被删了,把内存里那一桶也丢掉 */
|
||||
forget: (conversationId: string) => void
|
||||
}
|
||||
@@ -95,6 +102,7 @@ const PHASE_TEXT: Record<Phase, string> = {
|
||||
building: '正在搭建流程…',
|
||||
running: '正在执行…',
|
||||
waiting: '等待你的确认',
|
||||
ready: '流程搭好了,没有自动执行',
|
||||
done: '完成',
|
||||
error: '出错了',
|
||||
}
|
||||
@@ -272,14 +280,37 @@ export const useChat = create<ChatState>((set, get) => ({
|
||||
case 'done':
|
||||
patch(() => ({ explanation: op.explanation ?? '' }))
|
||||
break
|
||||
case 'reply': {
|
||||
// 这句话不需要工作流——问的是前几轮已经查出来的东西,或者是
|
||||
// 对过程说的话(「重试」「换个说法」)。以前这些也各自重建一整张
|
||||
// 图、跑一遍 153 张表的库,纯属白花钱
|
||||
const text = String(op.text ?? '')
|
||||
patch(() => ({
|
||||
phase: 'done', status: PHASE_TEXT.done,
|
||||
output: { answer: text }, noQuery: true,
|
||||
}))
|
||||
set({ busy: false, cancel: null })
|
||||
void save({ status: 'done', answer: text })
|
||||
break
|
||||
}
|
||||
case 'final': {
|
||||
// 后端排版校验后的最终图。拿到它就可以直接跑了
|
||||
const graph = op.graph as GraphSpec
|
||||
void save({ graph, explanation: op.explanation ?? '' })
|
||||
// 用户要的是流程本身时(「设计一个每天跑的工作流」),建完就跑
|
||||
// 等于替他多花一次钱,而他要的是那张图
|
||||
if (op.autorun === false) {
|
||||
patch(() => ({
|
||||
graph, phase: 'ready', status: PHASE_TEXT.ready,
|
||||
explanation: op.explanation ?? '', pendingRun: true,
|
||||
}))
|
||||
set({ busy: false, cancel: null })
|
||||
void save({ status: 'done' })
|
||||
break
|
||||
}
|
||||
patch(() => ({
|
||||
graph, phase: 'running', status: PHASE_TEXT.running,
|
||||
explanation: op.explanation ?? '',
|
||||
}))
|
||||
void save({ graph, explanation: op.explanation ?? '' })
|
||||
void launch(conversationId, id, graph, question, patch, set, save)
|
||||
break
|
||||
}
|
||||
@@ -327,6 +358,21 @@ export const useChat = create<ChatState>((set, get) => ({
|
||||
}))
|
||||
},
|
||||
|
||||
runNow: (conversationId, turnId) => {
|
||||
const turn = get().byConversation[conversationId]?.find((t) => t.id === turnId)
|
||||
if (!turn?.graph || get().busy) return
|
||||
const patch = patcher(set, conversationId, turnId)
|
||||
const save = async (body: Record<string, any>) => {
|
||||
if (!turn.serverId) return
|
||||
try {
|
||||
await api.conversations.patchTurn(conversationId, turn.serverId, body)
|
||||
} catch { /* 存不下不该影响这次运行 */ }
|
||||
}
|
||||
patch(() => ({ phase: 'running', status: PHASE_TEXT.running, pendingRun: false }))
|
||||
set({ busy: true })
|
||||
void launch(conversationId, turnId, turn.graph, turn.question, patch, set, save)
|
||||
},
|
||||
|
||||
reattach: async (conversationId, turnId) => {
|
||||
const turn = get().byConversation[conversationId]?.find((t) => t.id === turnId)
|
||||
if (!turn?.run) return
|
||||
|
||||
@@ -139,6 +139,22 @@ console.log('\n=== 长报告的折叠 ===')
|
||||
await page.close()
|
||||
}
|
||||
|
||||
console.log('\n=== 没查库的那一轮 ===')
|
||||
{
|
||||
// 「涉及数据必须真查」是这条路径上最硬的约定,放开直接回答之后,用户得能
|
||||
// 一眼分清哪些结论背后真的动了库
|
||||
const page = await browser.newPage({ viewport: { width: 1280, height: 900 } })
|
||||
await page.goto(`${WEB}/preview.html?noquery=1`, { waitUntil: 'networkidle' })
|
||||
await page.waitForTimeout(300)
|
||||
const body = await page.locator('body').innerText()
|
||||
check('说破了这一条没有查库', body.includes('没有查库'))
|
||||
check('声明排在结论前面',
|
||||
body.indexOf('没有查库') < body.indexOf('role.level'),
|
||||
`声明@${body.indexOf('没有查库')} 结论@${body.indexOf('role.level')}`)
|
||||
check('答案本身照常渲染', body.includes('口径'))
|
||||
await page.close()
|
||||
}
|
||||
|
||||
console.log('\n=== 空态 ===')
|
||||
{
|
||||
const page = await browser.newPage({ viewport: { width: 1280, height: 900 } })
|
||||
|
||||
Reference in New Issue
Block a user