> For AI agents: the complete documentation index is available at /llms.txt, the full documentation bundle is available at /llms-full.txt.

# 对话辅助填表

用户在左侧说“申请两台云服务器，地域选北京”，右侧表单自动填入数量和地域。用户可以继续对话，也可以直接修改表单，检查后再确认提交。

**这项功能不依赖 OpenAI，也不限定某一种 Agent。** ChatKit 显示对话，业务应用负责表单，Agent 提出修改。需要接通的是“修改请求”和“执行结果”的往返。

先点击 Demo 的第一条推荐问题，看右侧字段发生什么变化。本例用本地规则模拟 Agent，没有连接真实模型或业务后端；“确认提交（本地）”不会创建真实资源。


### 对话辅助填表：Agent 更新草稿，用户修改后确认

Mock 演示；业务函数需要替换为真实实现。

#### src/demos/form-assist.tsx


````
// Copyright (c) 2026 Beijing Volcano Engine Technology Co., Ltd.
// SPDX-License-Identifier: MIT
import '@volcengine/chatkit/styles.css';
import './form-assist.css';

import { Chatkit, type ChatkitDataPartRenderer,useChatRuntime } from '@volcengine/chatkit';
import { useState, useSyncExternalStore } from 'react';

import { createFormAdapter } from './form-assist-adapter';
import { createFormStore, fieldLabels, type FormReceipt, type FormValues } from './form-assist-store';

// Receipts describe this run. Rendering them never writes the application store.
const Receipt: ChatkitDataPartRenderer = ({ data }) => {
  const receipt = data as FormReceipt;
  return (
    <article className="form-assist__receipt">
      <strong>{receipt.applied ? `已应用到草稿 v${receipt.version}` : '本次修改未应用'}</strong>
      <p>{receipt.applied ? receipt.fields.map(key => fieldLabels[key]).join('、') : receipt.reason}</p>
    </article>
  );
};
export default function FormAssist() {
  const [store] = useState(createFormStore);
  const [adapter] = useState(() => createFormAdapter(store));
  const draft = useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot);
  const runtime = useChatRuntime({ sessionId: 'form-assist', adapter });
  return (
    <main className="form-assist">
      <section className="form-assist__chat" aria-label="对话助手">
        <header>
          <strong>对话辅助填表</strong>
          <span>本地规则 Mock · 非真实 Agent</span>
        </header>
        <div className="form-assist__conversation">
          {runtime && (
            <Chatkit
              runtime={runtime}
              settings={{
                welcome: '说出需求，检查右侧草稿',
                recommendations: ['申请两台云服务器，地域北京，用途：集成测试', '把数量改成 5 台，地域改为广州'],
                tools: { groupDefaultExpanded: true },
              }}
              onSend={input => {
                const current = store.getSnapshot();
                return {
                  ...input,
                  metadata: {
                    ...input.metadata,
                    formContext: {
                      formId: current.id,
                      expectedVersion: current.version,
                      values: { ...current.values },
                    },
                  },
                };
              }}
              renderers={{
                parts: { 'acme.form-update': Receipt },
                tools: { update_form_draft: { runningLabel: '正在更新表单草稿', successLabel: '草稿更新已处理' } },
              }}
            />
          )}
        </div>
      </section>
      <form
        noValidate
        className="form-assist__form"
        aria-label="云服务器申请表"
        onSubmit={event => {
          event.preventDefault();
          store.submit();
        }}
      >
        <header>
          <div>
            <span className="form-assist__eyebrow">APPLICATION / DRAFT</span>
            <h2>云服务器申请</h2>
          </div>
          <span className="form-assist__version">v{draft.version}</span>
        </header>
        <p className="form-assist__intro">对话与手动编辑共享这一份草稿。高亮字段来自最近一次 Agent 更新。</p>
        <fieldset disabled={draft.submitted}>
          <label data-changed={draft.changed.includes('quantity')}>
            数量{draft.changed.includes('quantity') && <small>Agent 已更新</small>}
            <input
              aria-label="数量"
              type="number"
              value={draft.values.quantity}
              onChange={e => store.edit('quantity', Number(e.target.value))}
            />
          </label>
          <label data-changed={draft.changed.includes('region')}>
            地域{draft.changed.includes('region') && <small>Agent 已更新</small>}
            <select
              aria-label="地域"
              value={draft.values.region}
              onChange={e => store.edit('region', e.target.value as FormValues['region'])}
            >
              <option>北京</option>
              <option>上海</option>
              <option>广州</option>
            </select>
          </label>
          <label data-changed={draft.changed.includes('purpose')}>
            用途{draft.changed.includes('purpose') && <small>Agent 已更新</small>}
            <textarea
              aria-label="用途"
              value={draft.values.purpose}
              onChange={e => store.edit('purpose', e.target.value)}
              placeholder="例如：集成测试"
            />
          </label>
        </fieldset>
        <p className="form-assist__notice" role="status">
          {draft.notice}
        </p>
        <div className="form-assist__actions">
          <button type="submit" disabled={draft.submitted}>
            {draft.submitted ? '已本地提交' : '确认提交（本地）'}
          </button>
          <button type="button" onClick={() => store.reset()}>
            重置表单
          </button>
        </div>
        <details>
          <summary>怎么验证旧结果不会覆盖手动修改？</summary>
          <p>
            点击左侧推荐问题，在工具执行的两秒内手动修改右侧数量。随后会提示版本冲突，并保留你的修改。重新发送即可基于新版本更新。
          </p>
        </details>
        <small>刷新后数据清空；没有创建真实资源。生产环境需由业务后端保存草稿、校验权限并原子检查版本。</small>
      </form>
    </main>
  );
}

````

#### src/demos/form-assist.css


````
/*
 * Copyright (c) 2026 Beijing Volcano Engine Technology Co., Ltd.
 * SPDX-License-Identifier: MIT
 */

.form-assist {
  display: grid;
  grid-template-columns: minmax(0, 1.15fr) minmax(280px, 0.85fr);
  height: 100%;
  color: var(--chatkit-color-text, #101013);
  background: var(--chatkit-color-bg, #fff);
}

.form-assist__chat {
  display: flex;
  flex-direction: column;
  min-height: 0;
  min-width: 0;
}

.form-assist__chat > header {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
  align-items: center;
  padding: 14px 18px;
  border-bottom: 1px solid var(--chatkit-color-border, #e5e6eb);
}

.form-assist__chat > header span {
  color: var(--chatkit-color-text-tertiary, #7a7880);
  font-size: 10px;
  margin-left: auto;
}

.form-assist__conversation {
  flex: 1;
  min-height: 0;
}

.form-assist__form {
  overflow: auto;
  padding: 24px;
  border-left: 1px solid var(--chatkit-color-border, #e5e6eb);
  background: var(--chatkit-color-bg-subtle, #f6f6fc);
}

.form-assist__form > header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  gap: 12px;
}

.form-assist__form h2 {
  font-size: 20px;
  margin: 10px 0;
  font-weight: 600;
}

.form-assist__eyebrow {
  font: 9px ui-monospace, monospace;
  letter-spacing: 0.08em;
  color: var(--chatkit-color-text-tertiary, #7a7880);
}

.form-assist__version {
  font: 12px ui-monospace, monospace;
  padding: 5px 8px;
  border: 1px solid var(--chatkit-color-border, #e5e6eb);
  border-radius: 5px;
}

.form-assist__intro,
.form-assist details p {
  font-size: 12px;
  line-height: 1.8;
  color: var(--chatkit-color-text-secondary, #383740);
}

.form-assist__form fieldset {
  display: grid;
  gap: 16px;
  padding: 0;
  margin: 24px 0 16px;
  border: 0;
}

.form-assist__form label {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 8px;
  font-size: 12px;
}

.form-assist__form label small {
  color: var(--chatkit-color-link, #2f79ff);
  font-size: 10px;
  margin-left: auto;
}

.form-assist__form input,
.form-assist__form select,
.form-assist__form textarea {
  box-sizing: border-box;
  width: 100%;
  padding: 10px 12px;
  color: inherit;
  border: 1px solid var(--chatkit-color-border-strong, #d2d1e0);
  border-radius: 6px;
  background: var(--chatkit-color-bg, #fff);
  font: inherit;
}

.form-assist__form textarea {
  min-height: 64px;
  resize: vertical;
}

.form-assist__form [data-changed='true'] :is(input, select, textarea) {
  border-color: var(--chatkit-color-primary, #101013);
  background: var(--chatkit-color-primary-subtle, #f5f7ff);
}

.form-assist__notice {
  font-size: 12px;
  line-height: 1.8;
  min-height: 42px;
}

.form-assist__actions {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
}

.form-assist__actions button {
  border: 1px solid var(--chatkit-color-border, #e5e6eb);
  border-radius: 6px;
  padding: 9px 12px;
  background: var(--chatkit-color-bg, #fff);
  color: inherit;
  cursor: pointer;
  font: inherit;
  font-size: 12px;
}

.form-assist__actions button[type='submit'] {
  background: var(--chatkit-color-primary, #101013);
  color: var(--chatkit-color-text-inverse, #fff);
  border-color: transparent;
}

.form-assist__actions button:disabled {
  opacity: 0.5;
  cursor: default;
}

.form-assist details {
  margin: 22px 0 16px;
  font-size: 12px;
  line-height: 1.7;
}

.form-assist__form > small {
  display: block;
  font-size: 10px;
  line-height: 1.8;
  color: var(--chatkit-color-text-tertiary, #7a7880);
}

.form-assist__receipt {
  border: 1px solid var(--chatkit-color-border, #e5e6eb);
  border-radius: 8px;
  padding: 12px;
  font-size: 12px;
  margin: 12px 0;
}

.form-assist__receipt p {
  margin: 7px 0 0;
  line-height: 1.7;
}

.form-assist :is(input, select, textarea, button, summary):focus-visible {
  outline: 2px solid var(--chatkit-color-primary, #101013);
  outline-offset: 2px;
}

@media (width <= 640px) {
  .form-assist {
    display: flex;
    flex-direction: column;
    height: auto;
    min-height: 100%;
  }

  .form-assist__chat {
    height: 480px;
    flex: none;
  }

  .form-assist__form {
    border-left: 0;
    border-top: 1px solid var(--chatkit-color-border, #e5e6eb);
    padding: 20px;
    overflow: visible;
  }
}

````

#### src/demos/form-assist-adapter.ts


````
// Copyright (c) 2026 Beijing Volcano Engine Technology Co., Ltd.
// SPDX-License-Identifier: MIT
import {
  buildAcceptedChatInput,
  type ChatInput,
  type ChatRuntimeAdapter,
  type RuntimeIngress,
  type RuntimeMessage,
} from '@volcengine/chatkit/core';

import { type FormStore,type FormValues } from './form-assist-store';

/** Deterministic local mock planner, not an LLM. Never parses JSON out of assistant prose. */
function plan(text: string): Partial<FormValues> {
  const patch: Partial<FormValues> = {};
  const count = text.match(/(-?\d+(?:\.\d+)?)\s*台|数量[改为成到\s]*(-?\d+(?:\.\d+)?)/);
  if (count) patch.quantity = Number(count[1] ?? count[2]);
  else if (/两台|二台/.test(text)) patch.quantity = 2;
  for (const region of ['北京', '上海', '广州'] as const) if (text.includes(region)) patch.region = region;
  const purpose = text.match(/用途[：:\s]*(.+)$/);
  if (purpose) patch.purpose = purpose[1].replace(/[。；;]$/, '').trim();
  return patch;
}
export function createFormAdapter(store: FormStore): ChatRuntimeAdapter<ChatInput> {
  let ingress: RuntimeIngress | undefined;
  return {
    start(next) {
      ingress = next;
      return () => {
        if (ingress === next) ingress = undefined;
      };
    },
    projectAcceptedInput: ({ runId, input }) => (input ? [buildAcceptedChatInput(runId, input)] : []),
    abort: async ({ sessionId, runId }) => {
      await ingress?.emitInterrupted({ sessionId, runId, eventId: `${runId}:stop`, reason: 'protocol' });
      return true;
    },
    run: async ({ sessionId, runId, input }, context) => {
      const output = ingress;
      if (!output) return false;
      const meta = { sessionId, runId },
        messageId = `form-answer:${runId}`;
      const parts: RuntimeMessage['parts'][number][] = [];
      let seq = 0;
      const upsert = () =>
        output.emitMessage({
          ...meta,
          eventId: `${runId}:message:${seq++}`,
          message: { id: messageId, runId, role: 'assistant', parts: [...parts] },
        });
      await output.emitMessage({ ...meta, eventId: `${runId}:input`, message: buildAcceptedChatInput(runId, input) });
      const form = input.metadata?.formContext as { formId?: unknown; expectedVersion?: unknown } | undefined;
      const patch = plan(
        input.content
          .filter(p => p.type === 'text')
          .map(p => p.text)
          .join('')
      );
      let text = '本地规则 Mock 支持数量、地域与用途。试试“申请两台云服务器，地域北京，用途：集成测试”。';
      if (
        Object.keys(patch).length &&
        sessionId === 'form-assist' &&
        typeof form?.formId === 'string' &&
        typeof form.expectedVersion === 'number' &&
        Number.isSafeInteger(form.expectedVersion)
      ) {
        const tool = {
          id: 'update-tool',
          type: 'tool-call' as const,
          toolCallId: `update:${runId}`,
          toolName: 'update_form_draft',
          args: { formId: form.formId, expectedVersion: form.expectedVersion, patch },
        };
        parts.push(tool);
        await upsert();
        // A visible local tool latency lets the user edit the form while this request is in flight.
        await new Promise(resolve => setTimeout(resolve, 2000));
        if (context.signal.aborted) return false;
        const receipt = store.apply(form.formId, form.expectedVersion, patch);
        parts[0] = { ...tool, result: receipt };
        parts.push({ id: 'receipt', type: 'data', kind: 'acme.form-update', data: receipt });
        await upsert();
        text = receipt.applied
          ? `已更新右侧表单草稿到 v${receipt.version}。你可以继续手动修改，检查无误后点击右侧“确认提交”。本轮没有提交表单。`
          : receipt.reason;
      } else if (Object.keys(patch).length) {
        text = '缺少有效的表单上下文或会话不匹配，本轮没有修改表单。请通过当前页面的聊天入口发送。';
      }
      for (let i = 0; i < text.length; i += 3) {
        await new Promise(resolve => setTimeout(resolve, 35));
        if (context.signal.aborted) return false;
        await output.emitDelta({
          ...meta,
          eventId: `${runId}:text:${i}`,
          messageId,
          partId: 'answer',
          delta: text.slice(i, i + 3),
        });
      }
      await output.emitCompleted({ ...meta, eventId: `${runId}:done` });
      return true;
    },
  };
}

````

#### src/demos/form-assist-store.ts


````
// Copyright (c) 2026 Beijing Volcano Engine Technology Co., Ltd.
// SPDX-License-Identifier: MIT
export interface FormValues {
  quantity: number;
  region: '北京' | '上海' | '广州';
  purpose: string;
}
export type FormField = keyof FormValues;
export interface FormSnapshot {
  id: string;
  version: number;
  values: FormValues;
  changed: FormField[];
  notice: string;
  submitted: boolean;
}
export interface FormReceipt {
  applied: boolean;
  reason: string;
  version: number;
  fields: FormField[];
}
export const fieldLabels: Record<FormField, string> = { quantity: '数量', region: '地域', purpose: '用途' };
const initialValues = (): FormValues => ({ quantity: 1, region: '上海', purpose: '' });
export function validateForm(values: FormValues) {
  if (!Number.isInteger(values.quantity) || values.quantity < 1 || values.quantity > 100)
    return '数量须为 1–100 的整数';
  if (!['北京', '上海', '广州'].includes(values.region)) return '地域不在允许范围';
  if (!values.purpose.trim() || values.purpose.length > 100) return '请填写 1–100 字的用途';
  return '';
}
/** Application-owned draft service. A real server must enforce these checks atomically. */
export function createFormStore() {
  let snapshot: FormSnapshot = {
    id: 'compute-request',
    version: 1,
    values: initialValues(),
    changed: [],
    notice: '等待填写。所有数据仅保存在本页内存中。',
    submitted: false,
  };
  const listeners = new Set<() => void>();
  const publish = (next: FormSnapshot) => {
    snapshot = next;
    listeners.forEach(fn => fn());
  };
  return {
    getSnapshot: () => snapshot,
    subscribe: (fn: () => void) => {
      listeners.add(fn);
      return () => {
        listeners.delete(fn);
      };
    },
    edit<K extends FormField>(field: K, value: FormValues[K]) {
      if (snapshot.submitted) return;
      publish({
        ...snapshot,
        version: snapshot.version + 1,
        values: { ...snapshot.values, [field]: value },
        changed: [],
        notice: `你手动修改了${fieldLabels[field]}。`,
      });
    },
    apply(formId: string, expectedVersion: number, patch: Partial<FormValues>): FormReceipt {
      const reject = (reason: string): FormReceipt => {
        publish({ ...snapshot, changed: [], notice: reason });
        return { applied: false, reason, version: snapshot.version, fields: [] };
      };
      if (formId !== snapshot.id) return reject('表单标识不匹配，本次修改未应用。');
      if (snapshot.submitted) return reject('表单已提交，本次修改未应用。');
      if (expectedVersion !== snapshot.version)
        return reject('草稿版本冲突：保留你的最新修改，本次建议未应用。请重新发送。');
      const fields = Object.keys(patch) as FormField[];
      if (!fields.length || fields.some(key => !Object.hasOwn(fieldLabels, key)))
        return reject('没有可应用的受支持字段。');
      const values = { ...snapshot.values, ...patch };
      if (
        !Number.isInteger(values.quantity) ||
        values.quantity < 1 ||
        values.quantity > 100 ||
        !['北京', '上海', '广州'].includes(values.region) ||
        typeof values.purpose !== 'string' ||
        values.purpose.length > 100
      )
        return reject('字段校验失败：数量须为 1–100 的整数，地域须有效，用途不超过 100 字。');
      const version = snapshot.version + 1;
      publish({
        ...snapshot,
        version,
        values,
        changed: fields,
        notice: `Agent 已更新${fields.map(key => fieldLabels[key]).join('、')}，请检查后确认提交。`,
      });
      return { applied: true, reason: '草稿更新成功', version, fields };
    },
    submit() {
      if (snapshot.submitted) return;
      const error = validateForm(snapshot.values);
      if (error) {
        publish({ ...snapshot, notice: error });
        return;
      }
      publish({
        ...snapshot,
        version: snapshot.version + 1,
        submitted: true,
        changed: [],
        notice: '本地模拟提交成功。没有创建真实资源，也没有网络请求。',
      });
    },
    reset() {
      publish({
        ...snapshot,
        version: snapshot.version + 1,
        values: initialValues(),
        changed: [],
        submitted: false,
        notice: '表单已重置，旧版本的 Agent 请求会被拒绝。',
      });
    },
  };
}
export type FormStore = ReturnType<typeof createFormStore>;

````

#### src/main.css


````
/*
 * Copyright (c) 2026 Beijing Volcano Engine Technology Co., Ltd.
 * SPDX-License-Identifier: MIT
 */

/* 示例页只给一个铺满的容器；chatkit 自己渲染 .chatkit-wrapper，接入方不用再包一层。 */
html,
body,
#root {
  height: 100%;
  margin: 0;
}

body {
  color: var(--chatkit-color-text, #0b0b0f);
  font-family: 'PingFang SC', system-ui, -apple-system, 'Microsoft YaHei', sans-serif;
  font-size: 13px;
}

/* 示例的 iframe 不宽，输入框左右留一点边距，不贴着边。 */
.chatkit-composer-wrapper {
  width: calc(100% - 24px);
  margin-bottom: 12px;
}

.demo-missing {
  padding: 24px;
  color: var(--chatkit-color-text-tertiary, #7a7880);
}

/*
 * 下面是示例页面自己的外观（页面布局、示例里的业务按钮和卡片），代表接入方页面上自己的样式，
 * 和 chatkit 无关。示例代码里以 demo- 开头的类名都在这里。
 */

/* 页面布局：上下排、左右排、聊天区占剩下的空间 */
.demo-column {
  display: flex;
  flex-direction: column;
  height: 100%;
}

.demo-row {
  position: relative;
  display: flex;
  flex: 1;
  min-height: 0;
  height: 100%;
}

.demo-main {
  flex: 1;
  min-width: 0;
  min-height: 0;
}

/* 页面顶部的一条：开关、单选、页面自己的头部 */
.demo-bar {
  display: flex;
  flex-wrap: wrap;
  gap: 6px 12px;
  align-items: center;
  padding: 8px 12px;
  background: var(--chatkit-color-bg-subtle, #fafafb);
  border-bottom: 1px solid var(--chatkit-color-border, #e8e8ec);
}

.demo-bar strong {
  margin-right: auto;
  font-size: 14px;
}

.demo-bar label {
  display: inline-flex;
  gap: 4px;
  align-items: center;
  cursor: pointer;
}

/* 页面侧边的一栏：后端收到的内容、事件列表 */
.demo-side {
  box-sizing: border-box;
  flex-shrink: 0;
  width: 190px;
  padding: 12px;
  overflow: auto;
  font-size: 12px;
  line-height: 1.6;
  background: var(--chatkit-color-bg-subtle, #fafafb);
  border-left: 1px solid var(--chatkit-color-border, #e8e8ec);
}

.demo-side h4 {
  margin: 0 0 8px;
  font-size: 12px;
}

.demo-side pre {
  margin: 0 0 8px;
  padding: 8px;
  font-size: 11px;
  font-family: ui-monospace, Menlo, monospace;
  white-space: pre-wrap;
  word-break: break-all;
  background: var(--chatkit-color-bg, #fff);
  border: 1px solid var(--chatkit-color-border, #e8e8ec);
  border-radius: 6px;
}

.demo-muted {
  color: var(--chatkit-color-text-tertiary, #8f959e);
}

/* 抽屉：盖在聊天区上面的会话列表 */
.demo-drawer {
  position: absolute;
  top: 0;
  bottom: 0;
  left: 0;
  z-index: 20;
  box-shadow: 8px 0 24px rgb(0 0 0 / 12%);
}

/* 两个会话示例：宽屏保留左右侧栏，窄屏用按钮展开列表，不挤占聊天区。 */
.demo-session-sidebar {
  flex-shrink: 0;
  width: 200px;
  max-width: 100%;
}

.demo-session-layout[data-placement='right'] .demo-session-sidebar {
  order: 1;
}

.demo-session-layout .demo-session-toggle,
.demo-session-toggle-bar {
  display: none;
}

.demo-session-layout[data-placement='drawer'] .demo-session-toggle {
  display: inline-flex;
}

.demo-session-layout[data-placement='drawer'] .demo-session-sidebar {
  position: absolute;
  inset: 0 auto 0 0;
  z-index: 20;
  display: none;
  box-shadow: 8px 0 24px rgb(0 0 0 / 12%);
}

.demo-session-layout[data-placement='drawer'][data-open='true'] .demo-session-sidebar {
  display: block;
}

@media (width <= 520px) {
  .demo-session-layout .demo-session-placement {
    display: none;
  }

  .demo-session-layout .demo-session-toggle,
  .demo-session-toggle-bar {
    display: flex;
  }

  .demo-session-layout .demo-session-sidebar {
    position: absolute;
    inset: 0 auto 0 0;
    z-index: 20;
    display: none;
    box-shadow: 8px 0 24px rgb(0 0 0 / 12%);
  }

  .demo-session-layout[data-open='true'] .demo-session-sidebar {
    display: block;
  }
}

/* 业务按钮：工具栏、操作栏、顶栏里的小按钮 */
.demo-chip {
  display: inline-flex;
  gap: 4px;
  align-items: center;
  height: 28px;
  padding: 0 10px;
  color: inherit;
  font-size: 12px;
  font-family: inherit;
  white-space: nowrap;
  background: var(--chatkit-color-bg, #fff);
  border: 0.5px solid #b8b7c3;
  border-radius: 14px;
  cursor: pointer;
}

.demo-chip:hover {
  background: var(--chatkit-color-bg-hover, #f2f3f5);
}

.demo-chip[aria-pressed='true'] {
  color: var(--chatkit-color-bg, #fff);
  background: var(--chatkit-color-text, #0b0b0f);
  border-color: var(--chatkit-color-text, #0b0b0f);
}

.demo-chips {
  display: flex;
  flex-wrap: wrap;
  gap: 6px;
  align-items: center;
  padding: 6px 0;
  font-size: 12px;
}

/* 标出插槽位置的色块 */
.demo-slot {
  display: inline-block;
  box-sizing: border-box;
  padding: 1px 6px;
  color: #ad4e00;
  font-size: 10.5px;
  line-height: 18px;
  white-space: nowrap;
  background: #fff3e0;
  border: 1px dashed #ffab40;
  border-radius: 4px;
}

/* 业务卡片 */
.demo-card {
  box-sizing: border-box;
  max-width: 360px;
  margin: 8px 0;
  padding: 12px 14px;
  font-size: 13px;
  line-height: 1.6;
  background: var(--chatkit-color-bg, #fff);
  border: 0.5px solid #d2d1e0;
  border-radius: 16px;
}

.demo-card header {
  display: flex;
  gap: 8px;
  align-items: center;
  justify-content: space-between;
  margin-bottom: 4px;
  font-weight: 600;
}

.demo-card footer {
  display: flex;
  gap: 8px;
  margin-top: 8px;
}

.demo-badge {
  padding: 0 8px;
  color: var(--chatkit-color-text-secondary, #3f3f51);
  font-weight: 400;
  font-size: 11px;
  line-height: 20px;
  background: #ebebf5;
  border-radius: 10px;
}

/* 右侧面板里的内容 */
.demo-panel {
  padding: 16px;
  font-size: 13px;
  line-height: 1.7;
}

.demo-panel header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  margin-bottom: 12px;
  font-weight: 600;
  font-size: 15px;
}

/* 菜单：点按钮展开的列表 */
.demo-menu {
  position: relative;
  display: inline-block;
}

.demo-menu ul {
  position: absolute;
  bottom: 34px;
  left: 0;
  z-index: 30;
  min-width: 180px;
  margin: 0;
  padding: 4px;
  list-style: none;
  background: var(--chatkit-color-bg, #fff);
  border: 1px solid #e1e3e8;
  border-radius: 8px;
  box-shadow: 0 8px 24px rgb(0 0 0 / 12%);
}

.demo-menu li button {
  display: flex;
  gap: 8px;
  align-items: center;
  justify-content: space-between;
  width: 100%;
  padding: 6px 10px;
  color: inherit;
  font-size: 13px;
  font-family: inherit;
  text-align: left;
  background: none;
  border: 0;
  border-radius: 6px;
  cursor: pointer;
}

.demo-menu li button:hover {
  background: var(--chatkit-color-bg-hover, #f2f3f5);
}

/* 顶栏、公告这类占一整行的内容 */
.demo-line {
  display: flex;
  gap: 8px;
  align-items: center;
  padding: 6px 14px;
  font-size: 12px;
}

.demo-line--notice {
  margin: 0 16px 8px;
  padding: 8px 12px;
  color: var(--chatkit-color-text-secondary, #3f3f51);
  background: var(--chatkit-color-bg-subtle, #f6f6fc);
  border-radius: 8px;
}

/* 完整应用：左边会话列表，中间顶栏加聊天区，右边一栏卡片 */
.demo-app {
  display: flex;
  height: 100%;
  color: var(--chatkit-color-text, #0b0b0f);
  background: var(--chatkit-color-bg, #fff);
}

.demo-app .chatkit-session-list {
  border-right: 1px solid #ececf0;
}

.demo-app-main {
  display: flex;
  flex: 1;
  flex-direction: column;
  min-width: 0;
}

.demo-app-header {
  display: flex;
  flex-shrink: 0;
  gap: 12px;
  align-items: center;
  justify-content: space-between;
  height: 56px;
  padding: 0 24px 0 16px;
  font-size: 12px;
}

.demo-app-header strong {
  overflow: hidden;
  font-weight: 500;
  font-size: 16px;
  white-space: nowrap;
  text-overflow: ellipsis;
}

.demo-app-body {
  display: flex;
  flex: 1;
  min-height: 0;
}

.demo-app-rail {
  display: flex;
  flex-shrink: 0;
  flex-direction: column;
  gap: 12px;
  box-sizing: border-box;
  width: 280px;
  padding: 4px 24px 24px 0;
  overflow: auto;
}

.demo-rail-card {
  display: flex;
  flex-direction: column;
  gap: 2px;
  padding: 8px;
  border: 1px solid #e6e6ea;
  border-radius: 16px;
}

.demo-rail-card h4 {
  margin: 0;
  padding: 8px;
  color: var(--chatkit-color-text-tertiary, #7a7880);
  font-weight: 500;
  font-size: 14px;
  line-height: 22px;
}

.demo-rail-row {
  display: flex;
  gap: 10px;
  align-items: center;
  min-height: 38px;
  padding: 0 8px;
  color: inherit;
  font-size: 13px;
  font-family: inherit;
  line-height: 22px;
  text-align: left;
  background: none;
  border: 0;
  border-radius: 10px;
}

button.demo-rail-row {
  cursor: pointer;
}

button.demo-rail-row:hover {
  background: #f3f3f5;
}

.demo-rail-row span {
  overflow: hidden;
  white-space: nowrap;
  text-overflow: ellipsis;
}

/* 状态圆点：没开始是虚线圈，进行中转圈，结束是绿色 */
.demo-rail-row i {
  box-sizing: border-box;
  flex-shrink: 0;
  width: 16px;
  height: 16px;
  margin: 0 3px;
  border: 1.5px dashed #b8b7c3;
  border-radius: 50%;
}

.demo-rail-row[data-status='running'] i,
.demo-rail-row[data-status='queued'] i {
  border-color: #e6e6ea;
  border-top-color: var(--chatkit-color-text, #0b0b0f);
  border-style: solid;
  animation: demo-spin 0.9s linear infinite;
}

.demo-rail-row[data-status='completed'] i {
  background: #2db55d;
  border-color: #2db55d;
  border-style: solid;
  box-shadow: inset 0 0 0 3px var(--chatkit-color-bg, #fff);
}

.demo-rail-row[data-status='failed'] i,
.demo-rail-row[data-status='interrupted'] i {
  background: #ec4e6d;
  border-color: #ec4e6d;
  border-style: solid;
  box-shadow: inset 0 0 0 3px var(--chatkit-color-bg, #fff);
}

@keyframes demo-spin {
  to {
    transform: rotate(360deg);
  }
}

/* 会话列表上方的产品名、下方的用户 */
.demo-brand,
.demo-user {
  display: flex;
  gap: 8px;
  align-items: center;
  padding: 4px;
  font-weight: 600;
  font-size: 16px;
}

.demo-user {
  font-weight: 400;
  font-size: 13px;
}

.demo-brand::before,
.demo-user::before {
  width: 20px;
  height: 20px;
  background: var(--chatkit-color-text, #0b0b0f);
  border-radius: 6px;
  content: '';
}

.demo-user::before {
  background: linear-gradient(135deg, #2f79ff, #ec4e6d);
  border-radius: 50%;
}

.demo-legal {
  padding: 6px 0 2px;
  color: #6e718c;
  font-size: 11px;
  line-height: 20px;
  text-align: center;
  opacity: 0.6;
}

/* 窄的时候先收起右边一栏，再收起会话列表 */
@media (width <= 1080px) {
  .demo-app-rail {
    display: none;
  }
}

@media (width <= 760px) {
  .demo-app .chatkit-session-list {
    width: 180px;
  }
}

@media (width <= 520px) {
  .demo-app .chatkit-session-list {
    display: none;
  }
}

.studio-session-picker {
  display: none;
}

@media (width <= 560px) {
  .studio-session-list {
    display: none;
  }

  .studio-session-picker {
    display: block;
    max-width: 140px;
  }
}

/* Demo host status, driven by the runtime's public lifecycle events. */
.demo-stream-status {
  display: flex;
  align-items: center;
  gap: 7px;
  padding: 9px 14px;
  border-bottom: 1px solid var(--chatkit-color-border, #e5e6eb);
  font-size: 10px;
  color: var(--chatkit-color-text-secondary, #6e6e78);
}

.demo-stream-status > span {
  width: 5px;
  height: 5px;
  border-radius: 50%;
  background: currentcolor;
}

.demo-stream-status > span[data-active='true'] {
  animation: demo-live-pulse 1.2s ease-in-out infinite;
}

.demo-stream-status small {
  margin-left: auto;
  font-size: 9px;
}

@keyframes demo-live-pulse {
  50% {
    opacity: 0.25;
  }
}

@media (prefers-reduced-motion: reduce) {
  .demo-stream-status > span[data-active='true'] {
    animation: none;
  }
}

.demo-theme-sample {
  padding: 4px 7px;
  border: 1px solid var(--chatkit-color-border);
  border-radius: var(--chatkit-radius-md);
  background: var(--chatkit-color-bg);
  color: var(--chatkit-color-link);
  font: inherit;
  font-size: 11px;
  white-space: nowrap;
  cursor: pointer;
}

.demo-theme-sample:focus-visible {
  outline: 2px solid var(--chatkit-color-primary);
  outline-offset: 2px;
}

````

#### src/main.tsx


````
import './main.css';
import '@volcengine/chatkit/styles.css';
import { createRoot } from 'react-dom/client';
import Demo from './demos/form-assist';
createRoot(document.getElementById('root')!).render(<Demo />);

````

#### index.html


````
<!doctype html><html lang="zh"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><div id="root"></div><script type="module" src="/src/main.tsx"></script></html>
````

#### package.json


````
{
  "name": "chatkit-example-form-assist",
  "private": true,
  "type": "module",
  "packageManager": "pnpm@10.20.0",
  "scripts": {
    "dev": "vite --host 127.0.0.1",
    "build": "vite build"
  },
  "dependencies": {
    "@volcengine/chatkit": "file:../chatkit.tgz",
    "react": "18.3.1",
    "react-dom": "18.3.1"
  },
  "devDependencies": {
    "vite": "^6.4.1",
    "typescript": "5.9.3",
    "@vitejs/plugin-react": "^4.0.0"
  }
}
````

#### vite.config.ts


````
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({plugins:[react()]});

````

#### README.md


````
# form-assist

本示例与本站预览来自同一份本地代码。

1. 下载页面提供的本地 SDK chatkit.tgz，放在本示例目录的上一级。
2. corepack enable；pnpm install；pnpm dev。
3. 开发真实业务时替换 Mock、上传、语音等演示实现。数据不构成真实业务服务。

````

窄屏下对话与表单上下排列，可在预览中滚动；也可以打开全屏。完整源码与预览一致，下载工程时同时下载旁边的本地 SDK。

## 先看一遍完整流程

![对话辅助填表的业务流程](/form-assist-overview.svg)

[查看业务流程大图
](/form-assist-overview.svg)。
1. **用户说需求**：“两台服务器，地域选北京。”
2. **Agent 提出修改**：让应用把数量改为 2，地域改为北京。
3. **应用执行**：检查字段和当前表单版本，更新草稿，右侧显示新值。
4. **应用告知结果**：把“填写成功”或“未能填写及原因”返回给 Agent。
5. **Agent 继续**：成功时说明已填哪些内容；失败时解释原因或让用户补充信息。

**第 4 步不可省略。** 右侧界面变了，只能说明应用里的表单状态变了；Agent 收到执行结果后，才知道这次修改是否成功。填写草稿成功和提交业务单据成功也要分别告知。

### 谁负责什么

| 角色      | 责任                           |
| ------- | ---------------------------- |
| Agent   | 理解需求，提出修改，读取执行结果后决定下一步       |
| 业务应用    | 管理右侧表单，执行校验和修改，回传真实结果        |
| ChatKit | 显示对话、工具过程和修改回执，提供接入这些交互的公开入口 |

## 功能、工具和协议是什么关系

这三个词说的是不同的事情：

| 名称 | 在这个场景中的含义           | 例子                                 |
| -- | ------------------- | ---------------------------------- |
| 功能 | 用户能完成什么             | 通过对话填写右侧表单                         |
| 工具 | Agent 可以请求应用做什么动作   | “更新表单草稿”，本例命名为 `update_form_draft` |
| 协议 | 请求和结果按什么格式、通过什么通道传递 | 应用如何收到“数量改为 2”，又如何把执行结果传回 Agent    |

**协议不决定用户的表单长什么样，也不等于这项功能。** 同一套填表功能可以接不同 Agent，但接入代码需要适配它们传请求、收结果的方式。就像同一个表单能连接不同的业务接口，页面功能相同，请求参数和返回格式可能不同。

前面提到 OpenAI，只是因为当前仓库有一个现成适配器支持“在浏览器执行工具并回传结果”。它是可选接法，不是填表功能的前提。业务已经有 Agent 后端时，直接对接该后端提供的工具调用或人工交互接口，无需为了这个功能换模型或后端。

### Agent 如何知道该调用前端

应用先把可用动作的名称、用途和允许的参数告诉 Agent。Agent 返回工具名称和参数后，**应用根据名称找到执行函数**。这个函数可以在前端更新表单，也可以调用后端接口；执行位置由应用决定，Agent 不需要查找 DOM 或 React 组件。

在本例中，“更新表单草稿”对应的是 `store.apply(...)`。正文里的“已填好”只是解释；表单的实际变化来自这个执行函数。

## 调用闭环：前端工具与 HITL

HITL（Human-in-the-loop）就是流程中需要人来决定或补充信息。它与自动工具的共同点是：**Agent 发出请求，等待结果，收到结果后继续。**

| 体验     | 什么时候回传结果       | 是否必须等用户    |
| ------ | -------------- | ---------- |
| 自动填写草稿 | 应用检查并更新字段后立即回传 | 不需要        |
| 先让用户确认 | 用户确认、修改或拒绝后回传  | 需要，这就是人工介入 |

例如，“Agent 建议买两台，用户可以改成三台，再点确认”，回传的应是用户最终确认的三台，而不是 Agent 原先建议的两台。取消、拒绝或填写失败也要明确回传，让 Agent 知道该怎样继续。

### 当前 Demo 做到了哪一步

| 已经可以体验                                  | 尚未连接真实服务                                             |
| --------------------------------------- | ---------------------------------------------------- |
| 本地规则提出修改、右侧草稿更新、手动编辑、版本冲突拒绝、聊天回执和本地确认提交 | 真实 Agent 决策、把执行结果送回 Agent、Agent 等待人工确认后继续、刷新后的业务数据恢复 |

**当前 Demo 的“确认提交（本地）”没有让真实 Agent 等待或继续。** 它只是改变页面内存状态。生产如果要求点确认后 Agent 继续，就需要把该按钮的处理结果接到 Agent 的结果接收接口。

## 接入 SOP

### 1. 由应用管理右侧表单

把聊天区和表单放在同一个业务页面，表单只保存一份草稿。用户手动编辑和 Agent 修改都更新这份草稿。

本例 `form-assist.tsx` 负责布局和表单组件，`form-assist-store.ts` 保存字段与版本。每次修改递增版本，避免较早的 Agent 结果覆盖用户的新修改。

### 2. 发送时告诉 Agent 当前表单内容

`onSend` 读取最新草稿，合并到输入的 `metadata`。本例的业务数据结构为：

```json
{
  "formContext": {
    "formId": "compute-request",
    "expectedVersion": 1,
    "values": { "quantity": 1, "region": "上海", "purpose": "" }
  }
}
```

这段数据表达“当前是哪张表、哪个版本、有哪些值”。它由业务定义，不是 SDK 内置的表单类型。接真实后端时，还要让适配器把这些信息放到该后端实际读取的位置；只给前端加 `metadata`，Agent 不会自动知道。

### 3. 接收修改请求，校验后执行

应用收到工具名称和字段修改后，调用对应的草稿更新函数。这里负责检查：表单标识是否匹配、字段是否允许修改、数量和地域是否有效、版本是否还是发送时的版本。

本例 `form-assist-adapter.ts` 用 `plan()` 的固定规则模拟修改请求：只识别数量、北京/上海/广州和“用途：…”等输入，等待两秒后调用 `store.apply(...)`。无法识别时给提示，不修改表单。生产用真实 Agent 的请求替换这一步，并在业务服务端执行权限与数据校验。

### 4. 把执行结果交回 Agent

本例草稿服务返回：

```json
{
  "applied": true,
  "reason": "草稿更新成功",
  "version": 2,
  "fields": ["quantity", "region"]
}
```

应用应把结果关联到**原来的那次修改请求**再送回 Agent，不能只画一张“成功”卡片。发生冲突时回传 `applied: false`、原因和当前版本，保留用户的新值。实际接入可以返回校验后的完整字段，方便 Agent 理解最终表单。

当前本地 Demo 直接用该结果更新聊天回执，并按固定规则生成后续文字；**此处没有真实 Agent 的结果回传请求**。换成真实服务时，需要补上接收修改请求和返回结果这两个连接点。聊天回执渲染和 `onEvent` 埋点不能代替这个连接。

### 5. 如果必须人工确认，就把回传放到确认之后

Agent 提出建议后，应用先展示待确认内容。用户确认或修改后，重新校验当前表单，再回传用户决定和真实执行结果；拒绝、取消和超时也需要结束对应等待。

本地 Demo 的确认按钮只检查必填项并锁定表单。生产中的提交、持久化、等待恢复和重复执行处理由业务服务负责。更新草稿不能自动代表已经创建资源。

## 验证结果

| 操作                         | 预期结果                         |
| -------------------------- | ---------------------------- |
| 点击第一条推荐问题                  | 数量为 2、地域为北京、用途为集成测试，字段高亮     |
| 手动改用途，再发“把数量改成 5 台，地域改为广州” | 修改数量和地域，保留手动填写的用途            |
| 工具执行的两秒内将数量改为 9            | 提示版本冲突，保留 9，该次整个修改不应用        |
| 发送“申请 200 台”               | 显示校验失败，草稿不改变                 |
| 用途为空时确认提交                  | 提示填写用途                       |
| 字段有效时确认提交                  | 显示本地模拟提交成功，字段锁定；没有真实资源创建请求   |
| 写入前点击停止                    | 不执行这次草稿修改；已完成的写入不会因停止文字输出而回滚 |
| 进行中重置表单                    | 版本递增，旧结果被拒绝；刷新页面会清空内存数据      |

## 常见问题

- **工具显示成功，表单没变**：检查业务执行函数是否真的更新了草稿，以及页面是否订阅正确的表单。聊天展示不会代替业务写入。
- **表单变了，Agent 不知道**：检查是否把执行结果交回对应请求；`setState`、画回执和更新 `metadata` 都不会自动发送工具结果。
- **总提示版本冲突**：重新读取最新草稿后再发起，不重试旧补丁。本例整次拒绝，不自动合并字段冲突。
- **换会话后写错表单**：每个请求要绑定正确的会话和表单。本例固定一个会话，不演示多会话目录。
- **刷新后数据消失**：本例只在内存中。生产应恢复草稿和聊天历史，恢复显示时不能重新执行业务写入。
- **需要动态表单或撤销**：字段定义、校验、修改记录和撤销接口由业务应用提供，不属于本例的内置功能。

## 开发参考：不同后端如何接入

先完成上面的业务流程，再根据自己已经使用的 Agent 后端选择下面的接法。


展开：当前本地适配器的相关能力与调用时序

![前端工具与 HITL 的调用闭环](/form-assist-flow.svg)
<a href={siteLink('/form-assist-flow.svg')}>查看技术调用时序大图</a>。实线表示请求，虚线表示结果；等待是 Agent 的逻辑状态，不要求一个 HTTP 请求始终保持连接。
- **业务后端运行完整 Agent**：需要提供向浏览器发工具请求、接收执行结果、恢复任务的通道。应用按工具名称路由到允许执行的函数，并回传原请求标识。仅在聊天流中看到工具片段，不表示这些通道已经接好。
- **直接接 OpenAI 兼容模型接口**：当前 `createOpenAiAdapter({ tools })` 可以按名称执行 `tools[name].execute`，把返回值交回对应工具调用，再请求模型继续。这只是现成实现之一；执行函数、参数校验、业务操作、鉴权和请求代理仍由业务提供。
- **工具中等待用户确认**：浏览器工具循环可以让 `execute` 返回等待用户操作的 Promise，再用用户决定作为结果。需要处理取消和页面关闭；刷新后仍可恢复的任务需要后端保存等待状态。
- **使用内置追问或审批卡片**：后端给出交互请求、适配器提供 `respond` 后可以接收用户答复。`actions.respond(toolCallId, response)` 会作为新的一轮发送，有一轮在运行时不受理。原请求通过 `toolCallId` 关联，不保证沿用原 UI `runId`；也不能把它当成任意表单对象的通用回传接口。
详见[追问与审批](/features/interactions.md)、[OpenAI 接入](/backends/openai.md)及[适配器契约](/backends/api.md)。

## 完整代码与相关参考

上方切换“代码”可以查看布局、草稿服务、适配器和样式；完整下载包含入口与项目配置。也可以进入[示例工作区](/examples/view.md?demo=form-assist)。

继续阅读[发送前处理](/customize/on-send.md)、[业务卡片](/customize/custom-card.md)和 [Hooks](/reference/hooks.md)。
