@antcde/connect-ts

中文文档
0.4.40发布于 2026/11/14TypeScriptES Module近 30 天下载 5018

Helper to connect to the ANT api

原始使用文档
文档目录38 节
  1. install npm package
  2. httpclient
  3. Authentication
  4. two-factor authorization (2fa)
  5. interceptors
  6. create single instance
  7. queryTables Examples
  8. 1. Select all columns
  9. 2. Specific columns, no filter
  10. 3. Equality condition
  11. 4. IN query — match multiple values
  12. 5. Multiple conditions (AND)
  13. 6. Pagination + sorting
  14. 7. Multiple tables in one request
  15. Reading results
  16. Supported condition operators
  17. Task Query Parameters
  18. Query Structure
  19. Filters
  20. Filterable Task Fields
  21. Selectable Fields
  22. Include (Relations)
  23. Pagination
  24. Sorting
  25. Complete Example
  26. URL Encoding
  27. Response Format
  28. App Manifest ( app-config.json )
  29. Schema validation
  30. Sending the manifest to the OS
  31. Tables
  32. Topic declarations
  33. Sending and receiving topics
  34. Capabilities
  35. Resource Signals
  36. Recipe 1: Local — send & receive across all apps
  37. Recipe 2: Over Socket (Echo-backed) — backend broadcasts to…
  38. Summary

ANTCONNECT TS/JS

With this package you can give your applications simple access to the ANT api

install npm package

npm install axios

axios is needed and not included in the package itself

npm install ant_tsconnect

this will give you access to the AntConnect class which needs a httpClient (see below)

httpclient

to connect to one of ANT's environments you need a httpclient where you set the following properties

  • client_id
  • client_secret
  • base_url
  • api_url
  • this client you will pass on to AntConnect, and you can connect to the given api
const httpClient = new CustomHttpClient('http://api-antcde.io', '/api/1.0', 'X', 'SECRET')
const antConnect = newAntConnect(httpClient)

Authentication

to authenticate an user you can access the authenticate method by the following

antConnect.httpClient.authenticate({
  username: '[email protected]',
  password: 'password'
})

you can save the access token to localstorage or a cookie and add it with the custom interceptors to each request (see below)

two-factor authorization (2fa)

if 2fa is enabled for the user you will receive error response with data

{ code: 1707747196 }

in

antConnect.users.getAuthenticatedUser()

In that case the best way is handling this error in custom interceptor by the error code and verify your login token with the prepared request from antConnect

antConnect.users.verifyTwoFactor({ code })

interceptors

it is possible to create custom request and response interceptors.

you can create a custom class with the following functions:

// Custom request interceptor
export function requestInterceptor(config) {
  config.headers = {}

  config.headers['Authorization'] = 'Bearer Client'
  return config
}

// Custom response interceptor
export function responseInterceptor(response) {
  // Modify the response as needed
  // For example, you can extract specific data or handle errors
  return response
}

then you can add these interceptors to the httpclient

import { requestInterceptor } from '@/interceptors' // import

const httpClient = new CustomHttpClient(
  'http://192.168.1.50:8000',
  '/api/1.0',
  '2',
  'OEQWm6a4rsbqTSEiB2y32O2yR0J3MFlUPbZJWI9w'
)
httpClient.addRequestInterceptor(requestInterceptor) // add here
const antConnect = newAntConnect(httpClient)

create single instance

Depending on your framework there are multiple ways to create a single instance of AntConnect across your whole application

Vue (3)

// TODO change this

const httpClient = new CustomHttpClient(
  'http://192.168.1.50:8000',
  '/api/1.0',
  '2',
  'OEQWm6a4rsbqTSEiB2y32O2yR0J3MFlUPbZJWI9w'
)
httpClient.addRequestInterceptor(requestInterceptor)
const antConnect = newAntConnect(httpClient)

app.provide('antConnect', antConnect) // here we provide the instance to the whole application

and by injection in our vue components we can access the instance like this:

const antConnect = inject('antConnect')
let projects = await antConnect.projects.getProjects()

in the above example we fetch the projects of a given user.

As we have an idea to move all projects to monorepo, maybe will be better to move to pnpm packager

queryTables Examples

The queryTables method lets you query one or more tables in a single request via connect.tables.queryTables(query).

1. Select all columns

const query: TablesQuery = {
  tables: [{
    name: 'EMPLOYEES',
    project: projectId,
    as: 'employees',
    columns: ['*'],
  }],
}

2. Specific columns, no filter

const query: TablesQuery = {
  tables: [{
    name: 'EMPLOYEES',
    project: projectId,
    as: 'employees',
    columns: [
      { name: 'first_name' },
      { name: 'last_name' },
      { name: 'department' },
    ],
  }],
}

3. Equality condition

const query: TablesQuery = {
  tables: [{
    name: 'EMPLOYEES',
    project: projectId,
    as: 'employees',
    columns: [
      {
        name: 'department',
        conditions: [{ operator: '=', value: 'Engineering' }],
      },
      { name: 'first_name' },
      { name: 'last_name' },
    ],
  }],
}

4. IN query — match multiple values

const query: TablesQuery = {
  tables: [{
    name: 'EMPLOYEES',
    project: projectId,
    as: 'employees',
    columns: [
      {
        name: 'status',
        conditions: [{ operator: 'in', value: ['active', 'on_leave', 'probation'] }],
      },
      { name: 'first_name' },
      { name: 'last_name' },
      { name: 'department' },
    ],
  }],
}

5. Multiple conditions (AND)

const query: TablesQuery = {
  tables: [{
    name: 'EMPLOYEES',
    project: projectId,
    as: 'employees',
    columns: [
      {
        name: 'department',
        conditions: [{ operator: '=', value: 'Engineering' }],
      },
      {
        name: 'status',
        conditions: [{ operator: 'in', value: ['active', 'probation'] }],
      },
      { name: 'first_name' },
      { name: 'last_name' },
    ],
  }],
}

6. Pagination + sorting

const query: TablesQuery = {
  limit: 25,
  offset: 50,
  tables: [{
    name: 'EMPLOYEES',
    project: projectId,
    as: 'employees',
    columns: ['*'],
    sortBy: 'last_name',
    sortByOrder: 'asc',
  }],
}

7. Multiple tables in one request

const query: TablesQuery = {
  tables: [
    {
      name: 'DEPARTMENTS',
      project: projectId,
      as: 'departments',
      columns: ['*'],
    },
    {
      name: 'EMPLOYEES',
      project: projectId,
      as: 'employees',
      columns: [
        {
          name: 'status',
          conditions: [{ operator: 'in', value: ['active'] }],
        },
        { name: 'first_name' },
        { name: 'last_name' },
        { name: 'department' },
      ],
    },
  ],
}

Reading results

const response = await connect.tables.queryTables(query)

const departments = response.departments.records   // records array
const employees = response.employees.records
const totalEmployees = response.employees.stats.count

Supported condition operators

=, !=, >, <, >=, <=, like, in, not in, between, not between, is null, is not null

For in / not in, pass value as an array: value: ['a', 'b']. For between / not between, pass a two-element array: value: [10, 50].


Task Query Parameters

Documentation for the query parameter in TaskService.getV2Tasks().

Backend: Uses Laravel Purity for filtering with Spatie QueryBuilder.

Query Structure

interface TaskQuery {
  filters?: FilterObject
  fields?: FieldsObject
  include?: string           // comma-separated relations
  per_page?: number          // pagination limit (default: 10)
  page?: number              // current page (1-indexed)
  sort?: string[]            // sort fields
  is_task?: boolean          // filter tasks vs workflows (default: true)
}

Filters

Filter Operators (Laravel Purity)

OperatorDescriptionExample
$eqEqual{ status: { $eq: 'open' } }
$neNot equal{ status: { $ne: 'closed' } }
$gtGreater than{ progress: { $gt: 50 } }
$ltLess than{ progress: { $lt: 100 } }
$gteGreater than or equal{ due: { $gte: '2024-01-01' } }
$lteLess than or equal{ due: { $lte: '2024-12-31' } }
$inIn array (OR){ status: { $in: ['open', 'processing'] } }
$notInNot in array{ status: { $notIn: ['closed', 'canceled'] } }
$containsContains substring (case-insensitive){ title: { $contains: 'bug' } }
$notContainsDoes not contain{ title: { $notContains: 'test' } }
$containssContains (case-sensitive){ title: { $containss: 'BUG' } }
$notContainssNot contains (case-sensitive){ title: { $notContainss: 'TEST' } }
$startsWithStarts with (case-insensitive){ title: { $startsWith: 'task-' } }
$startsWithsStarts with (case-sensitive){ title: { $startsWiths: 'TASK-' } }
$endsWithEnds with (case-insensitive){ title: { $endsWith: '-urgent' } }
$endsWithsEnds with (case-sensitive){ title: { $endsWiths: '-URGENT' } }
$eqsEqual (case-sensitive){ title: { $eqs: 'Exact Match' } }
$betweenBetween two values{ progress: { $between: [25, 75] } }
$notBetweenNot between{ progress: { $notBetween: [0, 10] } }
$nullIs null{ assigned_to: { $null: true } }
$notNullIs not null{ assigned_to: { $notNull: true } }
$doesntHaveRelation doesn't exist{ type: { $doesntHave: true } }

Logical Operators

OperatorDescriptionExample
$orOR condition (array of conditions){ $or: [{ status: { $eq: 'open' } }, { status: { $eq: 'processing' } }] }
$andAND condition (array of conditions){ $and: [{ priority: { $eq: 'high' } }, { status: { $eq: 'open' } }] }

Combining Operators

Multiple operators can be combined on the same field:

{
  status: {
    $in: ['open', 'processing'],    // include these
    $notIn: ['failed']              // but exclude this
  }
}

Nested Relation Filters

Filter on related models:

{
  filters: {
    relationsToMe: {
      source_id: { $eq: taskId }
    }
  }
}

Filterable Task Fields

These fields are defined in the backend Task model's $filterFields:

FieldTypeDescription
iduuidTask UUID
titlestringTask title
descriptionstringTask description
numberintegerTask number
statusenum'open', 'closed', 'canceled', 'processing', 'executed', 'failed', 'inactive'
priorityenum'low', 'normal', 'high', 'urgent'
planned_startdatetimePlanned start date
planned_enddatetimePlanned end date
duedatetimeDue date
licenseuuidLicense UUID
parentuuidParent task UUID
projectuuidProject UUID
typerelationTask type relation
sbscodestringSBS code reference
assigned_touuidAssigned user UUID
taskProjectrelationProject relation
is_privatebooleanPrivate task flag
is_taskbooleanIs task (vs workflow)
is_lockedbooleanLocked task flag
relationsrelationOutgoing task relations
relationsToMerelationIncoming task relations

Special Filter Fields

FieldTypeDescription
label_idsarrayArray of label UUIDs to filter by
label_operatorstring'and' (all labels) or 'or' (any label) - default: 'and'

Note: Label filters are processed separately from Purity filters.

Selectable Fields

Fields that can be requested via fields parameter:

id, title, description, number, status, priority, planned_start, planned_end,
due, project, license, parent, sbscode, assigned_to, created_by, position_x,
position_y, is_private, is_task, is_locked, script, trigger_id

Include (Relations)

Load related resources. Defined in TaskQueryBuilder::getRelations():

{
  include: 'taskProject,labels,children,assignedTo,trigger,trigger.event'
}

Available Relations

RelationModelDescription
taskProjectProjectAssociated project
parentTaskTaskParent task
childrenTaskChild tasks
activitiesTaskActivityActivity log entries
createdByUserUser who created the task
assignedToUserAssigned user
activities.activityUserUserActivity log with users
labelsLabelOsTask labels (V2)
attachmentsTaskFileFile attachments
checksTaskCheckChecklist items
typeTaskTypeTask type
sbsObjectSbsRecordSBS code record
relationsTaskRelationOutgoing relations
relations.queriesTableQueryRelation queries
relationsToMeTaskRelationIncoming relations
rulesTaskRuleTask rules
rules.resourceUser/RoleRule resources
appResourceAppAssociated app
triggerTriggerAssociated trigger
trigger.eventEventTrigger's event
assignableUsers(computed)Users that can be assigned

Note: assignableUsers is a computed property, not a true relation. It's handled specially.

Pagination

{
  per_page: 200,  // items per page (default: 10)
  page: 1         // page number (1-indexed)
}

Without pagination params, all results are returned via ->get(). With pagination params, results use ->simplePaginate().

Sorting

Uses Spatie QueryBuilder sorting:

{
  sort: ['created_at']           // ascending
  sort: ['-created_at']          // descending (prefix with -)
  sort: ['priority', '-due']     // multiple fields
}

Complete Example

import qs from 'qs'

const query = qs.stringify({
  filters: {
    status: { $in: ['open', 'processing'] },
    priority: { $notIn: ['low'] },
    project: { $eq: projectId },
    title: { $contains: 'bug' },
    $or: [
      { assigned_to: { $eq: null } },
      { assigned_to: { $eq: currentUserId } }
    ],
    label_ids: ['label-uuid-1', 'label-uuid-2'],
    label_operator: 'or'
  },
  fields: {
    'tasks': 'id,title,status,priority,due',
    'taskProject': 'id,name',
    'labels': 'id,name,color'
  },
  include: 'taskProject,labels,assignedTo',
  per_page: 50,
  page: 1,
  sort: ['-priority', 'due']
})

const tasks = await taskService.getV2Tasks(licenseId, query)

URL Encoding

Use qs.stringify() for proper encoding:

import qs from 'qs'

// Basic usage
const query = qs.stringify(params)

// With options for complex queries
const query = qs.stringify(params, {
  encodeValuesOnly: true,    // don't encode bracket keys
  arrayFormat: 'brackets'    // arrays as foo[]=a&foo[]=b
})

Response Format

Paginated Response

{
  data: Task[],
  links: {
    first: string | null,
    last: string | null,
    prev: string | null,
    next: string | null
  },
  meta: {
    current_page: number,
    from: number,
    path: string,
    per_page: number,
    to: number
  }
}

Non-Paginated Response

{
  data: Task[]
}

App Manifest (app-config.json)

Every ANT app can ship an app-config.json in its distribution bundle. The OS and App Store read this file to discover what the app declares about itself: its version, tagline, tables, signal topics, and — for the AI overlay — its capabilities.

Schema validation

A JSON Schema is published with this package at schemas/app-config.schema.json. Point the $schema key at it for editor autocompletion, hover docs, and validation:

{
  "$schema": "node_modules/@antcde/connect-ts/schemas/app-config.schema.json",
  "version": "1.0.0",
  "tagline": "My App",
  "tables": [],
  "signals": {
    "topics": {}
  },
  "capabilities": {
    "queryParams": {},
    "intents": []
  }
}

TypeScript types for the whole manifest are exported from the package: AppManifest, AppManifestCapabilities, AppManifestQueryParam, AppManifestIntent, AppManifestTable, AppManifestTopic.

Sending the manifest to the OS

Apps can send their manifest to the OS during the comms handshake. This makes tables and topics visible in the OS context menu without an extra API call, and works across CORS boundaries (e.g. remote OS + local dev server).

import { useCommsClient } from '@antcde/vue-utils'
import manifest from '../app-config.json'

const comms = useCommsClient(connect, undefined, manifest)

The OS receives the manifest via the handshake ACK and exposes it on the comms host. Any app that passes a manifest will appear in the OS context menu with its declared tables and topics.

Tables

Tables are created when the app is installed. Each entry declares a table name, where it lives (context), and its columns:

{
  "tables": [
    {
      "name": "MY_APP_ITEMS",
      "context": "project",
      "master_table": true,
      "columns": [
        { "name": "title", "type": "text", "required": true },
        { "name": "status", "type": "dropdown", "options_value": ["draft", "active"] }
      ]
    }
  ]
}
FieldRequiredDescription
nameyesTable name, unique within the app
contextno"project" (default) creates the table in the project on install; "license" creates it at the license level
master_tablenoMarks the app's primary table (default false)
columnsyesColumn definitions (at least one)

Column types: integer, text, text-field, boolean, date, link, email, dropdown, document, float, sbscode, table. Column fields include required, is_unique, is_indexed (max 2 per table), default_value, options_value (required for dropdown), hint, and field_name. A document column can add dms_folder, a folder path under the table owner's DMS root ("Inspections/Photos"): missing folders are created on install, uploads land there, and users can't change it while the app declares it.

Topic declarations

Topics enable generic pub/sub between apps via Echo whisper channels. Each key under signals.topics is a topic name (must match [A-Za-z0-9_-]):

{
  "signals": {
    "topics": {
      "cursor-positions": {
        "scope": "project",
        "description": "Live cursor positions shared between editors",
        "direction": ["send", "receive"],
        "schema": { "type": "object" }
      }
    }
  }
}
FieldRequiredDescription
scopeno"project" (default) or "license"
descriptionnoHuman-readable summary shown in the App Store
directionnoArray of "send" and/or "receive". Defaults to ["receive"]
schemanoJSON Schema describing the payload shape

Sending and receiving topics

// publish
signal.send({ topic: { name: 'cursor-positions', data: { x: 10, y: 20 } } })

// subscribe
signal.receive((s) => {
  if (s.topic?.name === 'cursor-positions') {
    handleCursor(s.topic.data)
  }
})

The OS handles fan-out: when an app publishes a topic signal, the OS redistributes it to all other loaded apps and whispers it on the scoped Echo channel so apps in other browser tabs receive it too.

Capabilities

capabilities declares what the app can be driven to do through its URL — the query params it reads and the named actions (intents) it exposes. The AI overlay reads this to turn a natural-language request ("open the app on the sockets tab") into a concrete launch with the right query string. It is metadata about the app's URL surface — it does not change how the app renders.

{
  "capabilities": {
    "queryParams": {
      "tab": {
        "type": "string",
        "description": "Which tab to open on load.",
        "enum": ["overview", "items", "settings"]
      },
      "status": {
        "type": "string",
        "description": "Filter the list to this status.",
        "enum": ["draft", "active", "archived"]
      }
    },
    "intents": [
      { "id": "open-tab", "description": "Open the app on a specific tab", "params": ["tab"] },
      { "id": "filter-items", "description": "Open the app filtered to a status", "params": ["tab", "status"] }
    ]
  }
}

queryParams — a map keyed by param name. Each entry describes one query param the app reads off its URL.

FieldRequiredDescription
typeyesValue type — "string", "number", "boolean", "date", "time" or "datetime". On the URL everything is a string; the non-string types are rendering + validation hints
descriptionnoWhat the param does. Surfaced to the AI as tool-parameter help — write it for a model to read
enumnoAllowed values. Becomes a hard constraint, so the AI can only pick a listed value
multiplenoAccept several values (multi-select, CSV on the wire) (default false)
requirednoWhether the param must be supplied to invoke the app (default false)
accessno"readwrite" (default) or "write" — commandable. "read" only reflects app state on the URL: the AI sees the current value and the confirm form shows it disabled, but neither may set it

intents — named actions that group queryParams into a recognizable target. An intent adds no behavior; it points at params that already exist, giving the AI a labelled action to match a request against.

FieldRequiredDescription
idyesStable intent identifier
descriptionyesWhat the intent does. Read by the AI to match a user request
paramsnoNames of queryParams this intent uses (each must exist in the map)

The overlay builds one tool per available app from the live app list: each queryParams entry becomes a tool parameter (enum → constrained choice, required → non-optional, description → parameter help), plus a built-in pane selector. access: "read" params are excluded from the tool schema. How reliably the model fills the right param is a direct function of good descriptions and tight enums — treat them as prompt engineering. Your app must then read these params off its own URL (window.location.search) and act on them.

For the app currently open, the overlay also reads the declared params back off the URL and feeds them to the model as current state, and pre-fills the confirm form from them (URL loses to session memory, which loses to the prompt). That only works if your app keeps the params in sync as the user navigates — writing them on load is not enough. Declare a param read when the app maintains it as state the AI should know about but must not command.


Resource Signals

Arbitrary resource signals let apps send and receive domain-specific events (e.g., cat, homeowner, invoice) without modifying the core ChangeSignal types. Signals are distributed locally across split-screen apps and can optionally be backed by WebSocket (Echo) for backend-driven events.

Recipe 1: Local — send & receive across all apps

All split-screen apps receive the signal via the OS redistribution watcher. No backend needed.

Send

const { signal } = comms

signal.send({
  resource: {
    cat: {
      id: crypto.randomUUID().slice(0, 8),
      action: 'created',
      data: { sound: 'meow', volume: 'loud' },
    },
  },
})

Receive (raw — no Echo subscription)

signal.receive((s) => {
  const cat = s.resource?.cat
  if (cat) {
    console.log(cat.id, cat.action, cat.data)
  }
})

Receive (with — also sets up Echo subscription)

// Also sends an `observe` signal → host subscribes to Echo channel
signal.with('cat').receive((cause) => {
  console.log(cause.id, cause.action, cause.data)
})

signal.send() is local only — it never touches the backend/socket. The OS redistribution watcher forwards it to all other split-screen apps.

signal.receive() catches all signals (local + Echo-backed). signal.with('cat').receive() filters for cat AND subscribes to the Echo channel for backend events.

Recipe 2: Over Socket (Echo-backed) — backend broadcasts to all clients

Only types a backend module broadcasts arrive over the socket. For example, DHME's DhmeResourceChanged announces production-location and similar types on App.License.{id}.Resources. The backend has no generic event that broadcasts an app-defined type such as cat, so for those use Recipe 1 (local) or a topic. The examples below use cat only as a placeholder type name.

Receive (project-scoped)

const { signal, context } = comms

watch(context, ({ project }, _, onCleanup) => {
  if (project) {
    onCleanup(
      signal.with('cat', undefined, undefined, ['project']).receive((cause) => {
        // cause.meta?.source === 'timeseries' (from Echo)
        console.log(cause.id, cause.action, cause.data)
      })
    )
  }
})

What happens under the hood

  1. signal.with('cat', ..., ['project']) sends an observe signal to the host
  2. Host calls newSignalEchoAdapter → subscribes to App.Project.{projectId}.Resources
  3. Backend broadcasts resource.cat.created / resource.cat.updated on that channel
  4. Echo adapter receives it → sends signal back to the iframe client
  5. cause.meta.source is 'timeseries'

Backend side

A backend module broadcasts an event on App.{Project|License}.{id}.Resources with broadcastAs() returning resource.<type>.<action> and a payload carrying the id and action (see app/Modules/DHME/Realtime/DhmeResourceChanged.php in ant-core). There is no shared event class for this, so adding a new broadcast type is a backend change.

Summary

MethodDistributionBackend needed?Use case
signal.send() + signal.receive()Local (all split-screen apps)NoApp-to-app communication
signal.with('cat').receive()Echo-backedYes — a backend module must broadcast this typeBackend-driven (default scope)
signal.with('cat', ..., ['project']).receive()Echo-backed (project-scoped)Yes — a backend module must broadcast this typeBackend-driven real-time updates

版本与发布记录

@antcde/connect-ts 共发布 179 个版本,首次发布于 2025/01/07;过去 12 个月发布了 157 个版本。

版本发布日期距今
0.4.40latest2026/11/14今天
0.4.40-rc.8rc2026/11/14今天
0.4.40-rc.72026/11/13今天
0.4.40-rc.62026/11/131 天前
0.4.40-rc.52026/11/122 天前
0.4.40-rc.42026/11/113 天前

关于 @antcde/connect-ts 的常见问题

@antcde/connect-ts 是什么?

Helper to connect to the ANT api

如何安装 @antcde/connect-ts?

在项目目录运行 npm install @antcde/connect-ts,或使用 pnpm add / yarn add / bun add。

@antcde/connect-ts 支持 TypeScript 吗?

支持。包内自带 TypeScript 类型声明(package.json 声明了 types 入口),安装后即可获得类型提示,无需再安装 @types 包。

@antcde/connect-ts 支持 ESM 还是 CommonJS?

以 ES Module 形式发布,推荐使用 import 引入。在 CommonJS 项目中,Node.js 20.19+ / 22.12+ 可以直接 require() 不含顶层 await 的 ES Module,更早的版本需要改用动态 import()。

@antcde/connect-ts 可以商用吗?

@antcde/connect-ts 使用 未声明 许可。package.json 没有声明许可证。没有许可证的代码默认保留全部权利,商用前请联系作者或查看仓库中的 LICENSE 文件。以上为概括,不构成法律意见,请以许可原文为准。

@antcde/connect-ts 的下载量有多少?

根据 npm 官方下载统计,@antcde/connect-ts 在 2026/10/06 – 2026/11/04 这 30 天内被下载 5,018 次,日均约 167 次。下载量会把 CI 构建、镜像同步和作为其他包依赖的安装一并计入,适合横向比较热度,不能直接等同于使用人数。

@antcde/connect-ts 还在维护吗?

最新版本 0.4.40 发布于 2026/11/14(今天);至今共发布 179 个版本,其中过去 12 个月发布了 157 个;首次发布于 2025/01/07。发布频率只是参考信号:功能稳定的工具可能很少需要更新,建议结合源码仓库的 issue 和提交记录判断。