# Ziin JS SDK

源码入口：`sdk/js/index.ts`

构建产物：

- `sdk/js/dist/index.js`
- `sdk/js/dist/index.d.ts`
- `public/sdk/js/index.js`
- `public/sdk/js/index.d.ts`

构建命令：

```bash
npm run sdk:build
```

如果正式域名证书未配置完成，才需要临时关闭 Node TLS 校验进行联调：

```bash
export ZIIN_INSECURE_TLS=true
```

更稳的做法还是给正式域名挂正确证书，不要长期依赖这个开关。

当前能力：

- `query(body)`
- `feedback(body)`
- `ingestion(body)`
- `multimodal(body)`
- `schema()`
- `health()`
- `issueUploadToken(body)`
- `completeUpload(body)`
- `completeUploadMultipart(body)`
- `createImageOcrTask(body)`
- `createDocumentOcrTask(body)`
- `createAsrTask(body)`
- `getTask(taskId)`
- `waitForTask(taskId, options)`
- `uploadAndCreateAsrTask(input)`
- `uploadAndCreateImageOcrTask(input)`
- `uploadAndCreateDocumentOcrTask(input)`
- `buildHostContext(input)`
- `mergeHostContext(...contexts)`
- `buildHostQueryRequest({ defaults, request })`
- `createHostAdapter({ clientConfig, defaults })`
- `createHostRuntimeAdapter({ adapterConfig, getContext })`
- `createActionPlanExecutor({ allowActions, handlers })`
- `createZiinWidget({ runtimeAdapterConfig, actionExecutor, theme, events })`

Query 返回现在额外包含：

- `actionPlans`: 结构化动作协议，供宿主按白名单执行
- `followUp`: 连续陪跑状态，说明是否继承了同一 `recordId` 的历史上下文

推荐宿主上下文字段：

- 基础必传：`route`、`pageTitle`、`selectedMenu`、`platform`
- 建议补齐：`pageId`、`roleCodes`、`permissionCodes`
- 详情/编辑页建议补齐：`recordId`、`workflowNode`、`errors`、`formState`、`availableActions`

SDK 里可以直接用 `buildHostContext(...)` 组装这份上下文，避免各系统自己拼字段。

如果宿主是服务端代理调用或已有自己的 AI，对接时更推荐直接用 `createHostAdapter(...)`，把公共字段沉成 defaults。
如果宿主是 Web 前端、内嵌页或浮窗助手，更推荐再往前一层用 `createHostRuntimeAdapter(...)`，把当前页面上下文同步和 query 调用放在一起。
如果想直接在宿主页里挂一个最小悬浮助手，可以继续用 `createZiinWidget(...)`。当前 widget 已支持：

- 主题定制：颜色、宽度、圆角、标题、副标题、按钮文案
- 宿主事件：`onQueryStart`、`onQuerySuccess`、`onQueryError`
- 动作埋点：`onActionExecuted`、`onActionRejected`
- 宿主控制：`openPanel()`、`closePanel()`、`refreshContext()`、`setContext()`、`replaceContext()`、`resetConversation()`
- 当前页上下文条：默认展示 `pageTitle / selectedMenu / route`

最小问答示例：

```ts
import { ZiinClient } from "./index";
import { buildHostContext } from "./index";

const client = new ZiinClient({
  baseUrl: "https://ziin.shenliu.cc",
  token: "editor|softwareId:erp-suite|tenantId:tenant-east-cn|userId:u-1001",
});

const result = await client.query({
  question: "为什么入库单提交失败？",
  mode: "embedded-agent",
  module: "inventory",
  context: buildHostContext({
    route: "/inventory/inbound/create",
    pageTitle: "新建入库单",
    selectedMenu: "仓储中心 / 入库管理",
    platform: "web",
    pageId: "inventory_inbound_create",
    recordId: "IN-20260417-001",
    workflowNode: "draft",
    permissionCodes: ["inventory.write"],
    roleCodes: ["warehouse_clerk"],
    errors: [{ code: "NO_PERMISSION", message: "当前用户无提交权限" }],
    availableActions: ["open_page", "focus_field", "open_dialog", "submit_draft"],
  }),
});

console.log(result.actionPlans);
console.log(result.followUp);
```

宿主适配器示例：

```ts
import { createHostAdapter } from "./index";

const hostAdapter = createHostAdapter({
  clientConfig: {
    baseUrl: "https://ziin.shenliu.cc",
    token: "editor|softwareId:erp-suite|tenantId:tenant-east-cn|userId:u-1001",
  },
  defaults: {
    mode: "embedded-agent",
    softwareId: "erp-suite",
    tenantId: "tenant-east-cn",
    userId: "u-1001",
    context: {
      platform: "web",
      selectedMenu: "仓储中心 / 入库管理",
      roleCodes: ["warehouse_clerk"],
    },
  },
});

const result = await hostAdapter.query({
  question: "为什么入库单提交失败？",
  module: "inventory",
  context: {
    route: "/inventory/inbound/create",
    pageTitle: "入库管理",
    pageId: "inventory_inbound_create",
    recordId: "IN-20260417-001",
    workflowNode: "draft",
    permissionCodes: ["inventory.write"],
    errors: [{ code: "NO_PERMISSION", message: "当前用户无提交权限" }],
    availableActions: ["open_page", "focus_field", "open_dialog", "submit_draft"],
  },
  inputs: [
    {
      type: "error-dialog",
      content: "报错弹窗：无权限提交入库单",
    },
  ],
});
```

宿主前端 runtime adapter 示例：

```ts
import { createHostRuntimeAdapter, createActionPlanExecutor } from "./index";

const runtimeAdapter = createHostRuntimeAdapter({
  adapterConfig: {
    clientConfig: {
      baseUrl: "https://ziin.shenliu.cc",
      token: "editor|softwareId:erp-suite|tenantId:tenant-east-cn|userId:u-1001",
    },
    defaults: {
      mode: "ui-assistant",
      module: "inventory",
      softwareId: "erp-suite",
      tenantId: "tenant-east-cn",
      userId: "u-1001",
    },
  },
  getContext: () => ({
    route: window.location.pathname,
    pageTitle: document.title,
    selectedMenu: readSelectedMenuFromHost(),
    platform: "web",
    pageId: readStablePageId(),
    availableActions: ["navigate", "focus_field", "open_dialog"],
  }),
});

const executor = createActionPlanExecutor({
  allowActions: ["navigate", "focus_input", "open_dialog"],
  handlers: {
    navigate: (plan) => router.push(plan.route || "/"),
    focusInput: () => focusAssistantInput(),
    openDialog: (plan) => openHostDialog(plan.target),
  },
});

const result = await runtimeAdapter.query({
  question: "这个页面下一步怎么做？",
});

for (const plan of result.actionPlans) {
  await executor(plan);
}
```

动作执行边界：

- `actionPlans` 必须由宿主按白名单执行，不能让模型直接越权落动作
- 建议只放开低风险动作：`navigate`、`focus_input`、`open_dialog`
- `submit_draft`、`fill_field` 这类动作建议二次确认后再执行

最小 widget 示例：

```ts
import { createHostRuntimeAdapter, createActionPlanExecutor, createZiinWidget } from "./index";

const runtimeAdapter = createHostRuntimeAdapter({
  adapterConfig: {
    clientConfig: {
      baseUrl: "https://ziin.shenliu.cc",
      token: "editor|softwareId:erp-suite|tenantId:tenant-east-cn|userId:u-1001",
    },
    defaults: {
      mode: "ui-assistant",
      module: "inventory",
      softwareId: "erp-suite",
      tenantId: "tenant-east-cn",
      userId: "u-1001",
    },
  },
  getContext: () => ({
    route: window.location.pathname,
    pageTitle: document.title,
    selectedMenu: readSelectedMenuFromHost(),
    platform: "web",
    pageId: readStablePageId(),
    availableActions: ["navigate", "focus_input", "open_dialog"],
  }),
});

const widget = createZiinWidget({
  runtimeAdapter,
  actionExecutor: createActionPlanExecutor({
    allowActions: ["navigate", "focus_input", "open_dialog"],
    handlers: {
      navigate: (plan) => router.push(plan.route || "/"),
      focusInput: () => focusAssistantInput(),
      openDialog: (plan) => openDialog(plan.target),
    },
  }),
  defaultOpen: true,
  title: "智引操作内核",
  subtitle: "权限感知式操作指引",
  buttonLabel: "打开使用助手",
  contextLabel: "当前页面",
  theme: {
    accentColor: "#c55b2d",
    panelWidth: 420,
    borderRadius: 26,
  },
  events: {
    onQuerySuccess: ({ response }) => reportUsage(response.traceId),
    onActionExecuted: ({ plan }) => reportAction(plan.id),
  },
});

widget.openPanel();
await widget.refreshContext();
```

widget 还提供显式控制方法：

```ts
widget.setContext({
  route: "/orders/list",
  pageTitle: "订单管理",
  selectedMenu: "订单中心 / 订单管理",
});

widget.setDraft("订单管理在哪里？");
await widget.submit(widget.getDraft());
widget.resetConversation("我会继续结合当前页面回答。");
```

发布后推荐这样引用：

```ts
import { ZiinClient } from "@ziin/sdk-js";
```

最小 ASR 示例：

```ts
import { readFile } from "node:fs/promises";
import { ZiinClient } from "./index";

const client = new ZiinClient({
  baseUrl: "https://ziin.shenliu.cc",
});

const bytes = await readFile("/absolute/path/to/audio.mp3");

const created = await client.uploadAndCreateAsrTask({
  tenantId: "tenant-east-cn",
  userId: "u-1001",
  source: "api",
  scene: "voice-ticket",
  language: "zh",
  file: {
    fileName: "audio.mp3",
    mimeType: "audio/mpeg",
    bytes,
  },
});

const task = await client.waitForTask(created.task.taskId);
console.log(task.result);
```

宿主前端动作白名单示例：

```ts
function executeActionPlan(plan: ZiinActionPlan) {
  switch (plan.action) {
    case "navigate":
      if (plan.route) router.push(plan.route);
      break;
    case "focus_input":
      focusAssistantInput();
      break;
    case "scroll_to_top":
      window.scrollTo({ top: 0, behavior: "smooth" });
      break;
  }
}
```
