feat: initial release v0.3.0
This commit is contained in:
586
src/lib/query/hooks/run-stream/state-machine.ts
Normal file
586
src/lib/query/hooks/run-stream/state-machine.ts
Normal file
@@ -0,0 +1,586 @@
|
||||
import type {
|
||||
RunStepStatus,
|
||||
RunStreamEvent,
|
||||
RunStreamLane,
|
||||
RunStreamStatus,
|
||||
} from '@/lib/novel-promotion/run-stream/types'
|
||||
import type {
|
||||
RunState,
|
||||
RunStepState,
|
||||
StageViewStatus,
|
||||
} from './types'
|
||||
|
||||
export function toTimestamp(ts: string | undefined, fallback: number): number {
|
||||
if (!ts) return fallback
|
||||
const parsed = Date.parse(ts)
|
||||
return Number.isFinite(parsed) ? parsed : fallback
|
||||
}
|
||||
|
||||
function rankStepStatus(status: RunStepStatus): number {
|
||||
if (status === 'pending' || status === 'blocked') return 0
|
||||
if (status === 'running') return 1
|
||||
if (status === 'completed' || status === 'stale') return 2
|
||||
return 3
|
||||
}
|
||||
|
||||
function rankRunStatus(status: RunStreamStatus): number {
|
||||
if (status === 'idle') return 0
|
||||
if (status === 'running') return 1
|
||||
if (status === 'completed') return 2
|
||||
return 3
|
||||
}
|
||||
|
||||
function lockForwardStepStatus(prev: RunStepStatus, next: RunStepStatus): RunStepStatus {
|
||||
if (prev === 'failed') return prev
|
||||
if (prev === 'completed' && next !== 'stale') return prev
|
||||
if (prev === 'stale' && next !== 'failed') return prev
|
||||
return rankStepStatus(next) >= rankStepStatus(prev) ? next : prev
|
||||
}
|
||||
|
||||
function lockForwardRunStatus(prev: RunStreamStatus, next: RunStreamStatus): RunStreamStatus {
|
||||
if (prev === 'completed' || prev === 'failed') return prev
|
||||
return rankRunStatus(next) >= rankRunStatus(prev) ? next : prev
|
||||
}
|
||||
|
||||
function normalizeLane(value: unknown): RunStreamLane {
|
||||
return value === 'reasoning' ? 'reasoning' : 'text'
|
||||
}
|
||||
|
||||
function splitThinkTaggedContent(input: string): { text: string; reasoning: string } {
|
||||
const thinkTagPattern = /<(think|thinking)\b[^>]*>([\s\S]*?)<\/\1>/gi
|
||||
const reasoningParts: string[] = []
|
||||
let matched = false
|
||||
|
||||
const stripped = input.replace(thinkTagPattern, (_fullMatch, _tagName: string, inner: string) => {
|
||||
matched = true
|
||||
const trimmed = inner.trim()
|
||||
if (trimmed) reasoningParts.push(trimmed)
|
||||
return ''
|
||||
})
|
||||
|
||||
if (!matched) {
|
||||
return {
|
||||
text: input,
|
||||
reasoning: '',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
text: stripped.trim(),
|
||||
reasoning: reasoningParts.join('\n\n').trim(),
|
||||
}
|
||||
}
|
||||
|
||||
function mergeReasoningText(current: string, incoming: string): string {
|
||||
const next = incoming.trim()
|
||||
if (!next) return current
|
||||
const prev = current.trim()
|
||||
if (!prev) return next
|
||||
if (prev.includes(next)) return current
|
||||
return `${prev}\n\n${next}`
|
||||
}
|
||||
|
||||
function normalizeThinkTaggedStepOutput(step: RunStepState) {
|
||||
if (!step.textOutput) return
|
||||
const parsed = splitThinkTaggedContent(step.textOutput)
|
||||
if (!parsed.reasoning) return
|
||||
step.textOutput = parsed.text
|
||||
step.reasoningOutput = mergeReasoningText(step.reasoningOutput, parsed.reasoning)
|
||||
}
|
||||
|
||||
function parseStepIdentity(rawStepId: string): {
|
||||
canonicalStepId: string
|
||||
attempt: number
|
||||
} {
|
||||
const matched = rawStepId.match(/^(.*)_r([0-9]+)$/)
|
||||
if (!matched) {
|
||||
return {
|
||||
canonicalStepId: rawStepId,
|
||||
attempt: 1,
|
||||
}
|
||||
}
|
||||
const baseStepId = matched[1]?.trim()
|
||||
const attempt = Number.parseInt(matched[2] || '1', 10)
|
||||
if (!baseStepId || !Number.isFinite(attempt) || attempt < 2) {
|
||||
return {
|
||||
canonicalStepId: rawStepId,
|
||||
attempt: 1,
|
||||
}
|
||||
}
|
||||
return {
|
||||
canonicalStepId: baseStepId,
|
||||
attempt,
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeStepStatus(value: unknown): RunStepStatus {
|
||||
if (
|
||||
value === 'running' ||
|
||||
value === 'completed' ||
|
||||
value === 'failed' ||
|
||||
value === 'blocked' ||
|
||||
value === 'stale'
|
||||
) {
|
||||
return value
|
||||
}
|
||||
return 'pending'
|
||||
}
|
||||
|
||||
function normalizeRunStatus(value: unknown): RunStreamStatus {
|
||||
if (value === 'running' || value === 'completed' || value === 'failed') return value
|
||||
return 'idle'
|
||||
}
|
||||
|
||||
export function toStageViewStatus(status: RunStepStatus): StageViewStatus {
|
||||
if (status === 'running') return 'processing'
|
||||
if (status === 'pending') return 'pending'
|
||||
if (status === 'blocked') return 'blocked'
|
||||
if (status === 'stale') return 'stale'
|
||||
if (status === 'completed') return 'completed'
|
||||
return 'failed'
|
||||
}
|
||||
|
||||
function readStringArray(value: unknown): string[] {
|
||||
if (!Array.isArray(value)) return []
|
||||
const rows: string[] = []
|
||||
for (const item of value) {
|
||||
if (typeof item !== 'string') continue
|
||||
const trimmed = item.trim()
|
||||
if (!trimmed) continue
|
||||
rows.push(trimmed)
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
function mergeStringArray(base: string[], incoming: string[]): string[] {
|
||||
if (incoming.length === 0) return base
|
||||
const seen = new Set<string>()
|
||||
const next: string[] = []
|
||||
for (const item of incoming) {
|
||||
if (seen.has(item)) continue
|
||||
seen.add(item)
|
||||
next.push(item)
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
function readBool(value: unknown): boolean | null {
|
||||
if (value === true) return true
|
||||
if (value === false) return false
|
||||
return null
|
||||
}
|
||||
|
||||
function buildDefaultStep(event: RunStreamEvent, now: number): RunStepState {
|
||||
const stepId = event.stepId || 'unknown_step'
|
||||
const stepAttempt =
|
||||
typeof event.stepAttempt === 'number' && Number.isFinite(event.stepAttempt)
|
||||
? Math.max(1, Math.floor(event.stepAttempt))
|
||||
: 1
|
||||
const stepTitle = typeof event.stepTitle === 'string' && event.stepTitle.trim() ? event.stepTitle : stepId
|
||||
const stepIndex =
|
||||
typeof event.stepIndex === 'number' && Number.isFinite(event.stepIndex)
|
||||
? Math.max(1, Math.floor(event.stepIndex))
|
||||
: 1
|
||||
const stepTotal =
|
||||
typeof event.stepTotal === 'number' && Number.isFinite(event.stepTotal)
|
||||
? Math.max(stepIndex, Math.floor(event.stepTotal))
|
||||
: stepIndex
|
||||
|
||||
return {
|
||||
id: stepId,
|
||||
attempt: stepAttempt,
|
||||
title: stepTitle,
|
||||
stepIndex,
|
||||
stepTotal,
|
||||
status: 'pending',
|
||||
dependsOn: [],
|
||||
blockedBy: [],
|
||||
groupId: null,
|
||||
parallelKey: null,
|
||||
retryable: true,
|
||||
textOutput: '',
|
||||
reasoningOutput: '',
|
||||
textLength: 0,
|
||||
reasoningLength: 0,
|
||||
message: '',
|
||||
errorMessage: '',
|
||||
updatedAt: now,
|
||||
seqByLane: {
|
||||
text: 0,
|
||||
reasoning: 0,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function resetStepForRetry(step: RunStepState, attempt: number) {
|
||||
step.attempt = attempt
|
||||
step.status = 'pending'
|
||||
step.textOutput = ''
|
||||
step.reasoningOutput = ''
|
||||
step.textLength = 0
|
||||
step.reasoningLength = 0
|
||||
step.message = ''
|
||||
step.errorMessage = ''
|
||||
step.blockedBy = []
|
||||
step.seqByLane = {
|
||||
text: 0,
|
||||
reasoning: 0,
|
||||
}
|
||||
}
|
||||
|
||||
function createInitialRunState(runId: string, now: number): RunState {
|
||||
return {
|
||||
runId,
|
||||
status: 'running',
|
||||
startedAt: now,
|
||||
updatedAt: now,
|
||||
terminalAt: null,
|
||||
errorMessage: '',
|
||||
summary: null,
|
||||
payload: null,
|
||||
stepsById: {},
|
||||
stepOrder: [],
|
||||
activeStepId: null,
|
||||
selectedStepId: null,
|
||||
}
|
||||
}
|
||||
|
||||
function computeStepDependencyLevel(params: {
|
||||
stepId: string
|
||||
stepsById: Record<string, RunStepState>
|
||||
memo: Map<string, number>
|
||||
visiting: Set<string>
|
||||
}): number {
|
||||
const { stepId, stepsById, memo, visiting } = params
|
||||
const cached = memo.get(stepId)
|
||||
if (typeof cached === 'number') return cached
|
||||
const step = stepsById[stepId]
|
||||
if (!step) return 0
|
||||
if (visiting.has(stepId)) {
|
||||
// Unexpected cycle: keep deterministic order without recursion loop.
|
||||
return Math.max(0, step.stepIndex - 1)
|
||||
}
|
||||
visiting.add(stepId)
|
||||
let level = 0
|
||||
for (const dep of step.dependsOn) {
|
||||
const depLevel = computeStepDependencyLevel({
|
||||
stepId: dep,
|
||||
stepsById,
|
||||
memo,
|
||||
visiting,
|
||||
})
|
||||
level = Math.max(level, depLevel + 1)
|
||||
}
|
||||
visiting.delete(stepId)
|
||||
memo.set(stepId, level)
|
||||
return level
|
||||
}
|
||||
|
||||
export function applyRunStreamEvent(prev: RunState | null, event: RunStreamEvent): RunState | null {
|
||||
const now = toTimestamp(event.ts, Date.now())
|
||||
const runId = event.runId || prev?.runId || ''
|
||||
if (!runId) return prev
|
||||
|
||||
const base: RunState =
|
||||
prev && prev.runId === runId
|
||||
? { ...prev }
|
||||
: createInitialRunState(runId, now)
|
||||
const prevActiveStepId = base.activeStepId
|
||||
|
||||
base.updatedAt = now
|
||||
|
||||
if (event.event === 'run.start') {
|
||||
const nextStatus = normalizeRunStatus(event.status)
|
||||
base.status = lockForwardRunStatus(base.status, nextStatus === 'idle' ? 'running' : nextStatus)
|
||||
if (event.payload && typeof event.payload === 'object') {
|
||||
base.payload = event.payload
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
if (event.event === 'run.complete') {
|
||||
base.status = lockForwardRunStatus(base.status, 'completed')
|
||||
base.summary =
|
||||
event.payload?.summary && typeof event.payload.summary === 'object'
|
||||
? (event.payload.summary as Record<string, unknown>)
|
||||
: event.payload || base.summary
|
||||
base.payload = event.payload || base.payload
|
||||
const finalizedSteps: Record<string, RunStepState> = { ...base.stepsById }
|
||||
for (const stepId of base.stepOrder) {
|
||||
const currentStep = finalizedSteps[stepId]
|
||||
if (!currentStep) continue
|
||||
if (currentStep.status === 'completed' || currentStep.status === 'failed' || currentStep.status === 'stale') continue
|
||||
finalizedSteps[stepId] = {
|
||||
...currentStep,
|
||||
status: 'completed',
|
||||
updatedAt: now,
|
||||
}
|
||||
}
|
||||
base.stepsById = finalizedSteps
|
||||
base.terminalAt = now
|
||||
return base
|
||||
}
|
||||
|
||||
if (event.event === 'run.error') {
|
||||
base.status = lockForwardRunStatus(base.status, 'failed')
|
||||
const runErrorMessage = typeof event.message === 'string' ? event.message : base.errorMessage
|
||||
base.errorMessage = runErrorMessage
|
||||
// When only run.error is emitted (without step.error), mark unfinished steps failed
|
||||
// so the UI does not keep showing "processing" forever.
|
||||
const nextStepsById: Record<string, RunStepState> = { ...base.stepsById }
|
||||
for (const stepId of base.stepOrder) {
|
||||
const currentStep = nextStepsById[stepId]
|
||||
if (!currentStep) continue
|
||||
if (currentStep.status === 'completed' || currentStep.status === 'failed') continue
|
||||
nextStepsById[stepId] = {
|
||||
...currentStep,
|
||||
status: 'failed',
|
||||
errorMessage: currentStep.errorMessage || runErrorMessage,
|
||||
updatedAt: now,
|
||||
}
|
||||
}
|
||||
base.stepsById = nextStepsById
|
||||
base.terminalAt = now
|
||||
return base
|
||||
}
|
||||
|
||||
const rawStepId = event.stepId
|
||||
if (!rawStepId) return base
|
||||
const stepIdentity = parseStepIdentity(rawStepId)
|
||||
const stepId = stepIdentity.canonicalStepId
|
||||
const incomingAttempt =
|
||||
typeof event.stepAttempt === 'number' && Number.isFinite(event.stepAttempt)
|
||||
? Math.max(1, Math.floor(event.stepAttempt))
|
||||
: stepIdentity.attempt
|
||||
const existingStep = base.stepsById[stepId]
|
||||
const step = existingStep
|
||||
? { ...existingStep }
|
||||
: buildDefaultStep({ ...event, stepId, stepAttempt: incomingAttempt }, now)
|
||||
if (!Number.isFinite(step.attempt) || step.attempt < 1) {
|
||||
step.attempt = 1
|
||||
}
|
||||
|
||||
if (incomingAttempt < step.attempt) {
|
||||
return base
|
||||
}
|
||||
|
||||
if (incomingAttempt > step.attempt) {
|
||||
resetStepForRetry(step, incomingAttempt)
|
||||
base.errorMessage = ''
|
||||
}
|
||||
|
||||
step.updatedAt = now
|
||||
if (typeof event.stepTitle === 'string' && event.stepTitle.trim()) {
|
||||
step.title = event.stepTitle.trim()
|
||||
}
|
||||
if (typeof event.stepIndex === 'number' && Number.isFinite(event.stepIndex)) {
|
||||
step.stepIndex = Math.max(1, Math.floor(event.stepIndex))
|
||||
}
|
||||
if (typeof event.stepTotal === 'number' && Number.isFinite(event.stepTotal)) {
|
||||
step.stepTotal = Math.max(step.stepIndex, Math.floor(event.stepTotal))
|
||||
}
|
||||
if (Array.isArray(event.dependsOn)) {
|
||||
step.dependsOn = mergeStringArray(step.dependsOn, readStringArray(event.dependsOn))
|
||||
}
|
||||
if (Array.isArray(event.blockedBy)) {
|
||||
step.blockedBy = mergeStringArray(step.blockedBy, readStringArray(event.blockedBy))
|
||||
}
|
||||
if (typeof event.groupId === 'string' && event.groupId.trim()) {
|
||||
step.groupId = event.groupId.trim()
|
||||
}
|
||||
if (typeof event.parallelKey === 'string' && event.parallelKey.trim()) {
|
||||
step.parallelKey = event.parallelKey.trim()
|
||||
}
|
||||
if (typeof event.retryable === 'boolean') {
|
||||
step.retryable = event.retryable
|
||||
}
|
||||
|
||||
if (event.event === 'step.start') {
|
||||
if (event.blockedBy && event.blockedBy.length > 0) {
|
||||
step.status = lockForwardStepStatus(step.status, 'blocked')
|
||||
} else {
|
||||
step.blockedBy = []
|
||||
step.status = lockForwardStepStatus(step.status, 'running')
|
||||
base.status = lockForwardRunStatus(base.status, 'running')
|
||||
}
|
||||
}
|
||||
|
||||
if (event.event === 'step.chunk') {
|
||||
const lane = normalizeLane(event.lane)
|
||||
const seq =
|
||||
typeof event.seq === 'number' && Number.isFinite(event.seq)
|
||||
? Math.max(1, Math.floor(event.seq))
|
||||
: null
|
||||
const lastSeq = step.seqByLane[lane]
|
||||
if (seq === null || seq > lastSeq) {
|
||||
if (step.status === 'completed') {
|
||||
// Late chunks can arrive after a premature step.complete event.
|
||||
// Reopen the step so UI does not show "completed" while output is still growing.
|
||||
step.status = 'running'
|
||||
}
|
||||
if (seq !== null) {
|
||||
step.seqByLane = {
|
||||
...step.seqByLane,
|
||||
[lane]: seq,
|
||||
}
|
||||
}
|
||||
|
||||
if (lane === 'reasoning') {
|
||||
const delta =
|
||||
typeof event.reasoningDelta === 'string'
|
||||
? event.reasoningDelta
|
||||
: typeof event.textDelta === 'string'
|
||||
? event.textDelta
|
||||
: ''
|
||||
if (delta) step.reasoningOutput += delta
|
||||
} else {
|
||||
const delta =
|
||||
typeof event.textDelta === 'string'
|
||||
? event.textDelta
|
||||
: typeof event.reasoningDelta === 'string'
|
||||
? event.reasoningDelta
|
||||
: ''
|
||||
if (delta) {
|
||||
step.textOutput += delta
|
||||
normalizeThinkTaggedStepOutput(step)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (event.blockedBy && event.blockedBy.length > 0) {
|
||||
step.status = lockForwardStepStatus(step.status, 'blocked')
|
||||
} else {
|
||||
step.blockedBy = []
|
||||
step.status = lockForwardStepStatus(step.status, 'running')
|
||||
base.status = lockForwardRunStatus(base.status, 'running')
|
||||
}
|
||||
step.textLength = step.textOutput.length
|
||||
step.reasoningLength = step.reasoningOutput.length
|
||||
}
|
||||
|
||||
if (event.event === 'step.complete') {
|
||||
if (typeof event.text === 'string' && event.text.length >= step.textOutput.length) {
|
||||
step.textOutput = event.text
|
||||
normalizeThinkTaggedStepOutput(step)
|
||||
}
|
||||
if (typeof event.reasoning === 'string' && event.reasoning.length >= step.reasoningOutput.length) {
|
||||
step.reasoningOutput = event.reasoning
|
||||
}
|
||||
step.textLength = step.textOutput.length
|
||||
step.reasoningLength = step.reasoningOutput.length
|
||||
const normalizedStatus = normalizeStepStatus(event.status)
|
||||
if (normalizedStatus === 'stale') {
|
||||
step.status = lockForwardStepStatus(step.status, 'stale')
|
||||
} else {
|
||||
step.status = lockForwardStepStatus(
|
||||
step.status,
|
||||
normalizedStatus === 'failed' ? 'failed' : 'completed',
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (event.event === 'step.error') {
|
||||
step.status = lockForwardStepStatus(step.status, 'failed')
|
||||
step.errorMessage = typeof event.message === 'string' ? event.message : step.errorMessage
|
||||
base.errorMessage = step.errorMessage || base.errorMessage
|
||||
}
|
||||
|
||||
if (typeof event.message === 'string' && event.message) {
|
||||
step.message = event.message
|
||||
}
|
||||
const staleByPayload = readBool(event.payload?.stale)
|
||||
if (staleByPayload === true) {
|
||||
step.status = 'stale'
|
||||
}
|
||||
const blockedByFromEvent = Array.isArray(event.blockedBy) ? readStringArray(event.blockedBy) : []
|
||||
const blockedByPayload = readStringArray(event.payload?.blockedBy)
|
||||
const blockedBy = blockedByPayload.length > 0 ? blockedByPayload : blockedByFromEvent
|
||||
if (blockedBy.length > 0) {
|
||||
step.blockedBy = blockedBy
|
||||
if (step.status !== 'failed' && step.status !== 'completed') {
|
||||
step.status = 'blocked'
|
||||
}
|
||||
} else if (event.event === 'step.start' || event.event === 'step.chunk') {
|
||||
step.blockedBy = []
|
||||
}
|
||||
|
||||
base.stepsById = {
|
||||
...base.stepsById,
|
||||
[stepId]: step,
|
||||
}
|
||||
if (!base.stepOrder.includes(stepId)) {
|
||||
base.stepOrder = [...base.stepOrder, stepId]
|
||||
}
|
||||
const levelMemo = new Map<string, number>()
|
||||
base.stepOrder = [...base.stepOrder].sort((a, b) => {
|
||||
const sa = base.stepsById[a]
|
||||
const sb = base.stepsById[b]
|
||||
if (!sa || !sb) return 0
|
||||
const levelA = computeStepDependencyLevel({
|
||||
stepId: a,
|
||||
stepsById: base.stepsById,
|
||||
memo: levelMemo,
|
||||
visiting: new Set<string>(),
|
||||
})
|
||||
const levelB = computeStepDependencyLevel({
|
||||
stepId: b,
|
||||
stepsById: base.stepsById,
|
||||
memo: levelMemo,
|
||||
visiting: new Set<string>(),
|
||||
})
|
||||
if (levelA !== levelB) return levelA - levelB
|
||||
if (sa.stepIndex !== sb.stepIndex) return sa.stepIndex - sb.stepIndex
|
||||
return sa.id.localeCompare(sb.id)
|
||||
})
|
||||
|
||||
const runningSteps = Object.values(base.stepsById).filter((item) => item.status === 'running')
|
||||
if (runningSteps.length > 0) {
|
||||
const maxRunningStepIndex = Math.max(...runningSteps.map((item) => item.stepIndex))
|
||||
const topCandidates = runningSteps
|
||||
.filter((item) => item.stepIndex === maxRunningStepIndex)
|
||||
.sort((a, b) => {
|
||||
if (a.updatedAt !== b.updatedAt) return b.updatedAt - a.updatedAt
|
||||
return a.id.localeCompare(b.id)
|
||||
})
|
||||
const keepCurrentActive =
|
||||
base.activeStepId && topCandidates.some((item) => item.id === base.activeStepId)
|
||||
? base.activeStepId
|
||||
: null
|
||||
base.activeStepId = keepCurrentActive || topCandidates[0]?.id || null
|
||||
} else {
|
||||
const allSteps = Object.values(base.stepsById)
|
||||
if (allSteps.length === 0) {
|
||||
base.activeStepId = null
|
||||
} else {
|
||||
const maxStepIndex = Math.max(...allSteps.map((item) => item.stepIndex))
|
||||
const topCandidates = allSteps
|
||||
.filter((item) => item.stepIndex === maxStepIndex)
|
||||
.sort((a, b) => {
|
||||
if (a.updatedAt !== b.updatedAt) return b.updatedAt - a.updatedAt
|
||||
return a.id.localeCompare(b.id)
|
||||
})
|
||||
base.activeStepId = topCandidates[0]?.id || null
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
!base.selectedStepId ||
|
||||
!base.stepsById[base.selectedStepId] ||
|
||||
base.selectedStepId === prevActiveStepId
|
||||
) {
|
||||
base.selectedStepId = base.activeStepId
|
||||
}
|
||||
|
||||
return base
|
||||
}
|
||||
|
||||
export function getStageOutput(step: RunStepState | null) {
|
||||
if (!step) return ''
|
||||
if (step.reasoningOutput && step.textOutput) {
|
||||
return `【思考过程】\n${step.reasoningOutput}\n\n【最终结果】\n${step.textOutput}`
|
||||
}
|
||||
if (step.reasoningOutput) return `【思考过程】\n${step.reasoningOutput}`
|
||||
if (step.textOutput) return `【最终结果】\n${step.textOutput}`
|
||||
if (step.status === 'failed' && step.errorMessage) return `【错误】\n${step.errorMessage}`
|
||||
return ''
|
||||
}
|
||||
Reference in New Issue
Block a user