mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-08 13:12:08 +00:00
refactor(desktop): split tool fallback-model into a folder with leaf modules
fallback-model.ts (1,696) folded into assistant-ui/tool/fallback-model/ with three cohesive, self-contained leaf modules extracted (verbatim moves): - types.ts (83) — the shared tool-view types/interfaces. - format.ts (133) — pure value formatting/parsing (isRecord, compactPreview, clampForDisplay, prettyJson, parseMaybeObject, unwrapToolPayload, numberValue, contextValue, formatDurationSeconds). - targets.ts (75) — url/path/preview detection + disclosure ids (looksLikeUrl, findFirstUrl, hostnameOf, isPreviewableTarget, toolPart/GroupDisclosureId). index.ts (1,434) keeps the tool-specific assembler (TOOL_META, titles, the count machinery, subtitle/detail/diff, buildToolView) and re-exports the leaf modules, so consumers importing `./fallback-model` are unchanged (folder index resolution) — no importer or channel edits needed. The count/result/detail helpers reach across each other around buildToolView, so they stay together to avoid a circular split; the three leaves are the clean cut. Behaviour-preserving: typecheck 0 errors, eslint clean, fallback-model test 24/26 (the 2 browser_navigate title failures are pre-existing on main — `hostnameOf` intentionally includes the pathname; verified identical on the un-split file).
This commit is contained in:
parent
d153918f14
commit
322138df51
4 changed files with 324 additions and 295 deletions
|
|
@ -0,0 +1,133 @@
|
|||
|
||||
export function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value && typeof value === 'object' && !Array.isArray(value))
|
||||
}
|
||||
|
||||
export function compactPreview(value: unknown, max = 72): string {
|
||||
let raw: unknown
|
||||
|
||||
if (typeof value === 'string') {
|
||||
raw = value
|
||||
} else {
|
||||
raw = parseMaybeObject(value).context
|
||||
}
|
||||
|
||||
if (typeof raw !== 'string') {
|
||||
if (raw == null) {
|
||||
raw = ''
|
||||
} else {
|
||||
try {
|
||||
raw = JSON.stringify(raw)
|
||||
} catch {
|
||||
raw = String(raw)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const line = (raw as string).replace(/\s+/g, ' ').trim()
|
||||
|
||||
return line.length > max ? `${line.slice(0, max - 1)}…` : line
|
||||
}
|
||||
|
||||
export function contextValue(value: unknown): string {
|
||||
const row = parseMaybeObject(value)
|
||||
|
||||
if (typeof row.context === 'string') {
|
||||
return row.context
|
||||
}
|
||||
|
||||
if (typeof row.preview === 'string') {
|
||||
return row.preview
|
||||
}
|
||||
|
||||
return typeof value === 'string' ? value : ''
|
||||
}
|
||||
|
||||
// Each tool result is server-capped (~100KB), but a turn over a big directory
|
||||
// stacks many rows; painting/serializing them all floods the renderer (freeze,
|
||||
// then OOM). Clamp every inline-painted payload to a bounded slice — the row's
|
||||
// Copy button still reads the uncapped `view.detail` for the full output.
|
||||
export const MAX_TOOL_RENDER_CHARS = 20_000
|
||||
|
||||
export function clampForDisplay(value: string, max = MAX_TOOL_RENDER_CHARS): string {
|
||||
if (value.length <= max) {
|
||||
return value
|
||||
}
|
||||
|
||||
const omitted = value.length - max
|
||||
|
||||
return `${value.slice(0, max)}\n\n… ${omitted.toLocaleString()} more characters truncated — use Copy for the full output.`
|
||||
}
|
||||
|
||||
export function prettyJson(value: unknown): string {
|
||||
const raw = typeof value === 'string' ? value : JSON.stringify(value, null, 2)
|
||||
|
||||
return clampForDisplay(raw ?? '')
|
||||
}
|
||||
|
||||
export function parseMaybeObject(value: unknown): Record<string, unknown> {
|
||||
if (isRecord(value)) {
|
||||
return value
|
||||
}
|
||||
|
||||
if (typeof value !== 'string' || !value.trim()) {
|
||||
return {}
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(value)
|
||||
|
||||
return isRecord(parsed) ? parsed : {}
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
export function unwrapToolPayload(value: unknown): unknown {
|
||||
const record = parseMaybeObject(value)
|
||||
|
||||
for (const key of ['data', 'result', 'output', 'response', 'payload']) {
|
||||
const payload = record[key]
|
||||
|
||||
if (payload !== undefined && payload !== null) {
|
||||
return payload
|
||||
}
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
export function numberValue(value: unknown): null | number {
|
||||
const n = typeof value === 'number' ? value : Number(value)
|
||||
|
||||
return Number.isFinite(n) ? n : null
|
||||
}
|
||||
|
||||
export function formatDurationSeconds(seconds: number): string {
|
||||
if (!Number.isFinite(seconds) || seconds < 0) {
|
||||
return ''
|
||||
}
|
||||
|
||||
if (seconds < 1) {
|
||||
const ms = Math.max(1, Math.round(seconds * 1000))
|
||||
|
||||
return `${ms}ms`
|
||||
}
|
||||
|
||||
if (seconds < 60) {
|
||||
return `${seconds.toFixed(seconds >= 10 ? 0 : 1)}s`
|
||||
}
|
||||
|
||||
const wholeSeconds = Math.round(seconds)
|
||||
const minutes = Math.floor(wholeSeconds / 60)
|
||||
const remSeconds = wholeSeconds % 60
|
||||
|
||||
if (minutes < 60) {
|
||||
return remSeconds ? `${minutes}m ${remSeconds}s` : `${minutes}m`
|
||||
}
|
||||
|
||||
const hours = Math.floor(minutes / 60)
|
||||
const remMinutes = minutes % 60
|
||||
|
||||
return remMinutes ? `${hours}h ${remMinutes}m` : `${hours}h`
|
||||
}
|
||||
|
|
@ -3,88 +3,38 @@ import { normalizeExternalUrl } from '@/lib/external-link'
|
|||
import { summarizeShellCommand } from '@/lib/summarize-command'
|
||||
import { extractToolErrorMessage, formatToolResultSummary } from '@/lib/tool-result-summary'
|
||||
|
||||
export type ToolTone = 'agent' | 'browser' | 'default' | 'file' | 'image' | 'terminal' | 'web'
|
||||
export type ToolStatus = 'error' | 'running' | 'success' | 'warning'
|
||||
import {
|
||||
compactPreview,
|
||||
contextValue,
|
||||
formatDurationSeconds,
|
||||
isRecord,
|
||||
numberValue,
|
||||
parseMaybeObject,
|
||||
prettyJson,
|
||||
unwrapToolPayload
|
||||
} from './format'
|
||||
import {
|
||||
findFirstUrl,
|
||||
hostnameOf,
|
||||
looksLikePath,
|
||||
looksLikeUrl
|
||||
} from './targets'
|
||||
import type {
|
||||
CountMetric,
|
||||
MessageRunningStateSlice,
|
||||
SearchResultRow,
|
||||
ToolMeta,
|
||||
ToolMetaSpec,
|
||||
ToolPart,
|
||||
ToolStatus,
|
||||
ToolTitleAction,
|
||||
ToolTone,
|
||||
ToolView
|
||||
} from './types'
|
||||
|
||||
export interface ToolPart {
|
||||
args?: unknown
|
||||
isError?: boolean
|
||||
result?: unknown
|
||||
toolCallId?: string
|
||||
toolName: string
|
||||
type: 'tool-call'
|
||||
}
|
||||
|
||||
export interface SearchResultRow {
|
||||
snippet: string
|
||||
title: string
|
||||
url: string
|
||||
}
|
||||
|
||||
export interface ToolTitleAction {
|
||||
prefix: string
|
||||
suffix: string
|
||||
text: string
|
||||
}
|
||||
|
||||
interface CountMetric {
|
||||
count: number
|
||||
noun: string
|
||||
}
|
||||
|
||||
export interface ToolView {
|
||||
countLabel?: string
|
||||
detail: string
|
||||
detailLabel: string
|
||||
durationLabel?: string
|
||||
icon?: string
|
||||
imageUrl?: string
|
||||
inlineDiff: string
|
||||
previewTarget?: string
|
||||
rawArgs: string
|
||||
rawResult: string
|
||||
/** Set for tools whose output naturally contains ANSI escape codes
|
||||
* (terminal/execute_code) so the renderer knows to run them through
|
||||
* the ANSI parser instead of printing them as literals. */
|
||||
rendersAnsi?: boolean
|
||||
searchHits?: SearchResultRow[]
|
||||
/** When the backend reports stderr as a separate stream (terminal /
|
||||
* execute_code), the renderer shows it as its own labeled, neutrally
|
||||
* tinted block under stdout — distinct from an error tone. */
|
||||
stderr?: string
|
||||
/** When set, the renderer uses stdout+stderr as separate sections and
|
||||
* ignores the merged `detail`. */
|
||||
stdout?: string
|
||||
status: ToolStatus
|
||||
subtitle: string
|
||||
title: string
|
||||
titleAction?: ToolTitleAction
|
||||
tone: ToolTone
|
||||
}
|
||||
|
||||
interface ToolMeta {
|
||||
done: string
|
||||
icon?: string
|
||||
pending: string
|
||||
pendingAction: string
|
||||
tone: ToolTone
|
||||
}
|
||||
|
||||
interface ToolMetaSpec {
|
||||
icon?: string
|
||||
tone: ToolTone
|
||||
}
|
||||
|
||||
export interface MessageRunningStateSlice {
|
||||
message: {
|
||||
status?: {
|
||||
type?: string
|
||||
}
|
||||
}
|
||||
thread: {
|
||||
isRunning: boolean
|
||||
}
|
||||
}
|
||||
export * from './format'
|
||||
export * from './targets'
|
||||
export * from './types'
|
||||
|
||||
const FILE_EDIT_TOOL_NAMES = new Set(['edit_file', 'patch', 'write_file'])
|
||||
|
||||
|
|
@ -317,139 +267,6 @@ function toolMeta(name: string): ToolMeta {
|
|||
}
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value && typeof value === 'object' && !Array.isArray(value))
|
||||
}
|
||||
|
||||
export function compactPreview(value: unknown, max = 72): string {
|
||||
let raw: unknown
|
||||
|
||||
if (typeof value === 'string') {
|
||||
raw = value
|
||||
} else {
|
||||
raw = parseMaybeObject(value).context
|
||||
}
|
||||
|
||||
if (typeof raw !== 'string') {
|
||||
if (raw == null) {
|
||||
raw = ''
|
||||
} else {
|
||||
try {
|
||||
raw = JSON.stringify(raw)
|
||||
} catch {
|
||||
raw = String(raw)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const line = (raw as string).replace(/\s+/g, ' ').trim()
|
||||
|
||||
return line.length > max ? `${line.slice(0, max - 1)}…` : line
|
||||
}
|
||||
|
||||
function contextValue(value: unknown): string {
|
||||
const row = parseMaybeObject(value)
|
||||
|
||||
if (typeof row.context === 'string') {
|
||||
return row.context
|
||||
}
|
||||
|
||||
if (typeof row.preview === 'string') {
|
||||
return row.preview
|
||||
}
|
||||
|
||||
return typeof value === 'string' ? value : ''
|
||||
}
|
||||
|
||||
// Each tool result is server-capped (~100KB), but a turn over a big directory
|
||||
// stacks many rows; painting/serializing them all floods the renderer (freeze,
|
||||
// then OOM). Clamp every inline-painted payload to a bounded slice — the row's
|
||||
// Copy button still reads the uncapped `view.detail` for the full output.
|
||||
export const MAX_TOOL_RENDER_CHARS = 20_000
|
||||
|
||||
export function clampForDisplay(value: string, max = MAX_TOOL_RENDER_CHARS): string {
|
||||
if (value.length <= max) {
|
||||
return value
|
||||
}
|
||||
|
||||
const omitted = value.length - max
|
||||
|
||||
return `${value.slice(0, max)}\n\n… ${omitted.toLocaleString()} more characters truncated — use Copy for the full output.`
|
||||
}
|
||||
|
||||
function prettyJson(value: unknown): string {
|
||||
const raw = typeof value === 'string' ? value : JSON.stringify(value, null, 2)
|
||||
|
||||
return clampForDisplay(raw ?? '')
|
||||
}
|
||||
|
||||
function parseMaybeObject(value: unknown): Record<string, unknown> {
|
||||
if (isRecord(value)) {
|
||||
return value
|
||||
}
|
||||
|
||||
if (typeof value !== 'string' || !value.trim()) {
|
||||
return {}
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(value)
|
||||
|
||||
return isRecord(parsed) ? parsed : {}
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
function unwrapToolPayload(value: unknown): unknown {
|
||||
const record = parseMaybeObject(value)
|
||||
|
||||
for (const key of ['data', 'result', 'output', 'response', 'payload']) {
|
||||
const payload = record[key]
|
||||
|
||||
if (payload !== undefined && payload !== null) {
|
||||
return payload
|
||||
}
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
function numberValue(value: unknown): null | number {
|
||||
const n = typeof value === 'number' ? value : Number(value)
|
||||
|
||||
return Number.isFinite(n) ? n : null
|
||||
}
|
||||
|
||||
function formatDurationSeconds(seconds: number): string {
|
||||
if (!Number.isFinite(seconds) || seconds < 0) {
|
||||
return ''
|
||||
}
|
||||
|
||||
if (seconds < 1) {
|
||||
const ms = Math.max(1, Math.round(seconds * 1000))
|
||||
|
||||
return `${ms}ms`
|
||||
}
|
||||
|
||||
if (seconds < 60) {
|
||||
return `${seconds.toFixed(seconds >= 10 ? 0 : 1)}s`
|
||||
}
|
||||
|
||||
const wholeSeconds = Math.round(seconds)
|
||||
const minutes = Math.floor(wholeSeconds / 60)
|
||||
const remSeconds = wholeSeconds % 60
|
||||
|
||||
if (minutes < 60) {
|
||||
return remSeconds ? `${minutes}m ${remSeconds}s` : `${minutes}m`
|
||||
}
|
||||
|
||||
const hours = Math.floor(minutes / 60)
|
||||
const remMinutes = minutes % 60
|
||||
|
||||
return remMinutes ? `${hours}h ${remMinutes}m` : `${hours}h`
|
||||
}
|
||||
|
||||
const COUNT_FIELD_KEYS = [
|
||||
'count',
|
||||
'total',
|
||||
|
|
@ -719,79 +536,6 @@ function toolResultCount(
|
|||
return textMetric ? normalizeMetricForTool(part.toolName, textMetric) : null
|
||||
}
|
||||
|
||||
function looksLikeUrl(value: string): boolean {
|
||||
return /^https?:\/\//i.test(value)
|
||||
}
|
||||
|
||||
function looksLikePath(value: string): boolean {
|
||||
return /^file:\/\//i.test(value) || /^(?:\/|\.{1,2}\/|~\/).+/.test(value)
|
||||
}
|
||||
|
||||
export function isPreviewableTarget(target: string): boolean {
|
||||
return Boolean(
|
||||
target &&
|
||||
(/^file:\/\//i.test(target) ||
|
||||
/^(?:\/|\.{1,2}\/|~\/).+\.html?$/i.test(target) ||
|
||||
/^https?:\/\/(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\])/i.test(target))
|
||||
)
|
||||
}
|
||||
|
||||
function stableHash(value: string): string {
|
||||
let hash = 0
|
||||
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
hash = Math.imul(31, hash) + value.charCodeAt(index)
|
||||
}
|
||||
|
||||
return Math.abs(hash).toString(36)
|
||||
}
|
||||
|
||||
export function toolPartDisclosureId(part: ToolPart): string {
|
||||
if (part.toolCallId) {
|
||||
return `tool:${part.toolCallId}`
|
||||
}
|
||||
|
||||
return `tool:${part.toolName}:${stableHash(JSON.stringify(part.args ?? ''))}`
|
||||
}
|
||||
|
||||
export function toolGroupDisclosureId(parts: ToolPart[]): string {
|
||||
return `tool-group:${parts.map(toolPartDisclosureId).join('|')}`
|
||||
}
|
||||
|
||||
const URL_PATTERN = /https?:\/\/[^\s'"<>)\]]+/i
|
||||
|
||||
function findFirstUrl(...sources: unknown[]): string {
|
||||
for (const src of sources) {
|
||||
if (typeof src === 'string') {
|
||||
const m = src.match(URL_PATTERN)
|
||||
|
||||
if (m) {
|
||||
return m[0]
|
||||
}
|
||||
} else if (src && typeof src === 'object') {
|
||||
for (const v of Object.values(src as Record<string, unknown>)) {
|
||||
const found = findFirstUrl(v)
|
||||
|
||||
if (found) {
|
||||
return found
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
function hostnameOf(value: string): string {
|
||||
try {
|
||||
const url = new URL(value)
|
||||
|
||||
return `${url.hostname}${url.pathname && url.pathname !== '/' ? url.pathname : ''}`
|
||||
} catch {
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
export function looksRedundant(title: string, detail: string): boolean {
|
||||
if (!detail) {
|
||||
return true
|
||||
|
|
@ -1540,10 +1284,7 @@ function dynamicTitle(
|
|||
}
|
||||
|
||||
const failed =
|
||||
part.isError ||
|
||||
result.success === false ||
|
||||
result.ok === false ||
|
||||
Boolean(firstStringField(result, ['error']))
|
||||
part.isError || result.success === false || result.ok === false || Boolean(firstStringField(result, ['error']))
|
||||
|
||||
if (failed) {
|
||||
const failAction = translateNow('assistant.tool.actions.failedToOpen')
|
||||
|
|
@ -1556,10 +1297,7 @@ function dynamicTitle(
|
|||
|
||||
const action = verb(translateNow('assistant.tool.actions.opening'), translateNow('assistant.tool.actions.opened'))
|
||||
|
||||
return titledAction(
|
||||
action,
|
||||
translateNow('assistant.tool.titleTemplates.actionTarget', action, hostnameOf(url))
|
||||
)
|
||||
return titledAction(action, translateNow('assistant.tool.titleTemplates.actionTarget', action, hostnameOf(url)))
|
||||
}
|
||||
|
||||
if (part.toolName === 'web_search') {
|
||||
|
|
@ -0,0 +1,75 @@
|
|||
|
||||
import type { ToolPart } from './types'
|
||||
|
||||
export function looksLikeUrl(value: string): boolean {
|
||||
return /^https?:\/\//i.test(value)
|
||||
}
|
||||
|
||||
export function looksLikePath(value: string): boolean {
|
||||
return /^file:\/\//i.test(value) || /^(?:\/|\.{1,2}\/|~\/).+/.test(value)
|
||||
}
|
||||
|
||||
export function isPreviewableTarget(target: string): boolean {
|
||||
return Boolean(
|
||||
target &&
|
||||
(/^file:\/\//i.test(target) ||
|
||||
/^(?:\/|\.{1,2}\/|~\/).+\.html?$/i.test(target) ||
|
||||
/^https?:\/\/(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\])/i.test(target))
|
||||
)
|
||||
}
|
||||
|
||||
export function stableHash(value: string): string {
|
||||
let hash = 0
|
||||
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
hash = Math.imul(31, hash) + value.charCodeAt(index)
|
||||
}
|
||||
|
||||
return Math.abs(hash).toString(36)
|
||||
}
|
||||
|
||||
export function toolPartDisclosureId(part: ToolPart): string {
|
||||
if (part.toolCallId) {
|
||||
return `tool:${part.toolCallId}`
|
||||
}
|
||||
|
||||
return `tool:${part.toolName}:${stableHash(JSON.stringify(part.args ?? ''))}`
|
||||
}
|
||||
|
||||
export function toolGroupDisclosureId(parts: ToolPart[]): string {
|
||||
return `tool-group:${parts.map(toolPartDisclosureId).join('|')}`
|
||||
}
|
||||
|
||||
export const URL_PATTERN = /https?:\/\/[^\s'"<>)\]]+/i
|
||||
|
||||
export function findFirstUrl(...sources: unknown[]): string {
|
||||
for (const src of sources) {
|
||||
if (typeof src === 'string') {
|
||||
const m = src.match(URL_PATTERN)
|
||||
|
||||
if (m) {
|
||||
return m[0]
|
||||
}
|
||||
} else if (src && typeof src === 'object') {
|
||||
for (const v of Object.values(src as Record<string, unknown>)) {
|
||||
const found = findFirstUrl(v)
|
||||
|
||||
if (found) {
|
||||
return found
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
export function hostnameOf(value: string): string {
|
||||
try {
|
||||
const url = new URL(value)
|
||||
|
||||
return `${url.hostname}${url.pathname && url.pathname !== '/' ? url.pathname : ''}`
|
||||
} catch {
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,83 @@
|
|||
|
||||
export type ToolTone = 'agent' | 'browser' | 'default' | 'file' | 'image' | 'terminal' | 'web'
|
||||
export type ToolStatus = 'error' | 'running' | 'success' | 'warning'
|
||||
|
||||
export interface ToolPart {
|
||||
args?: unknown
|
||||
isError?: boolean
|
||||
result?: unknown
|
||||
toolCallId?: string
|
||||
toolName: string
|
||||
type: 'tool-call'
|
||||
}
|
||||
|
||||
export interface SearchResultRow {
|
||||
snippet: string
|
||||
title: string
|
||||
url: string
|
||||
}
|
||||
|
||||
export interface ToolTitleAction {
|
||||
prefix: string
|
||||
suffix: string
|
||||
text: string
|
||||
}
|
||||
|
||||
export interface CountMetric {
|
||||
count: number
|
||||
noun: string
|
||||
}
|
||||
|
||||
export interface ToolView {
|
||||
countLabel?: string
|
||||
detail: string
|
||||
detailLabel: string
|
||||
durationLabel?: string
|
||||
icon?: string
|
||||
imageUrl?: string
|
||||
inlineDiff: string
|
||||
previewTarget?: string
|
||||
rawArgs: string
|
||||
rawResult: string
|
||||
/** Set for tools whose output naturally contains ANSI escape codes
|
||||
* (terminal/execute_code) so the renderer knows to run them through
|
||||
* the ANSI parser instead of printing them as literals. */
|
||||
rendersAnsi?: boolean
|
||||
searchHits?: SearchResultRow[]
|
||||
/** When the backend reports stderr as a separate stream (terminal /
|
||||
* execute_code), the renderer shows it as its own labeled, neutrally
|
||||
* tinted block under stdout — distinct from an error tone. */
|
||||
stderr?: string
|
||||
/** When set, the renderer uses stdout+stderr as separate sections and
|
||||
* ignores the merged `detail`. */
|
||||
stdout?: string
|
||||
status: ToolStatus
|
||||
subtitle: string
|
||||
title: string
|
||||
titleAction?: ToolTitleAction
|
||||
tone: ToolTone
|
||||
}
|
||||
|
||||
export interface ToolMeta {
|
||||
done: string
|
||||
icon?: string
|
||||
pending: string
|
||||
pendingAction: string
|
||||
tone: ToolTone
|
||||
}
|
||||
|
||||
export interface ToolMetaSpec {
|
||||
icon?: string
|
||||
tone: ToolTone
|
||||
}
|
||||
|
||||
export interface MessageRunningStateSlice {
|
||||
message: {
|
||||
status?: {
|
||||
type?: string
|
||||
}
|
||||
}
|
||||
thread: {
|
||||
isRunning: boolean
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue