dddk — Cookbook:企業寫自己的 skill

給內部開發者 / 第三方參考。

場景 1:客服 SaaS — 一鍵打開最近訂單

需求:用戶按 ctrl+k 打 /recent-orders,顯示最近 10 筆 + 點擊跳轉。

import type { ActionSkill } from '@perhapxin/dddk';

const recentOrders: ActionSkill = {
  id: 'recent-orders',
  type: 'action',
  name: '最近訂單',
  description: '看最近 10 筆',
  handler: async (ctx) => {
    const orders = await fetch('/api/orders?limit=10').then(r => r.json());
    ctx.palette.replace(orders.map(o => ({
      id: o.id,
      name: `#${o.id} - ${o.customer} - $${o.total}`,
      handler: () => ctx.navigate(`/orders/${o.id}`),
    })));
  },
};

場景 2:法律 SaaS — 對選取條款用客戶語言解釋

需求:用戶選取一段法律條款 → 長按 space 講「用簡單話解釋」→ 字幕條出簡化版本。

import type { PromptSkill } from '@perhapxin/dddk';

const simplifyClause: PromptSkill = {
  id: 'simplify',
  type: 'prompt',
  name: '用白話解釋',
  prompt: '把以下法律條款用一般人能懂的中文解釋。重點:實際上會怎麼影響我?\n\n{{selection}}',
};

註冊後,dddk 看到「對選取做事」+ skill /simplify 自動把 {{selection}} 替換。

場景 3:CRM — A2UI 表單建立 lead

import type { A2UISkill } from '@perhapxin/dddk';

const newLead: A2UISkill = {
  id: 'new-lead',
  type: 'a2ui',
  name: '新增 lead',
  build: async () => ({
    version: 'v0.10',
    updateComponents: {
      surfaceId: 'new-lead',
      components: [
        { id: 'root', component: 'Card', children: ['title', 'form'] },
        { id: 'title', component: 'Heading', text: '新增 Lead' },
        { id: 'form', component: 'Column', children: ['name', 'email', 'source', 'submit'] },
        { id: 'name', component: 'TextField', label: '姓名', bind: '/name', required: true },
        { id: 'email', component: 'TextField', label: 'Email', bind: '/email', required: true },
        { id: 'source', component: 'ChoicePicker', label: '來源', bind: '/source',
          options: ['官網', '展會', '推薦', '其他'] },
        { id: 'submit', component: 'Button', text: '建立', action: 'submit' },
      ],
    },
  }),
  onSubmit: async (data, ctx) => {
    const lead = await fetch('/api/leads', {
      method: 'POST',
      body: JSON.stringify(data),
    }).then(r => r.json());

    ctx.subtitle.show({
      text: `✓ Lead #${lead.id} 已建立`,
      type: 'info',
      autoHide: 2000,
    });

    return null;  // 關閉 surface
  },
};

場景 4:影視製作 — ScriptSkill 帶用戶逛新功能

import type { ScriptSkill } from '@perhapxin/dddk';

const tourScriptingMode: ScriptSkill = {
  id: 'tour-scripting',
  type: 'script',
  name: '介紹劇本模式',
  steps: [
    {
      page: '/project/123/script',
      subtitle: '這是劇本模式。每場戲是獨立區塊。',
      action: (t) => t.spotlight('.scene-list'),
    },
    {
      subtitle: '按 + 加新場景,或拖拉換順序。',
      action: (t) => t.highlight('.add-scene-btn', '#ff9900', '從這裡'),
      waitForUser: true,
    },
    {
      subtitle: '每場戲可以指定演員、場景、道具,按 ctrl+k 也找得到。',
      action: (t) => t.border('.scene-card', '#00aaff'),
      waitForUser: true,
    },
    {
      page: '/project/123/storyboard',
      subtitle: '完成劇本後,這裡可以畫故事板。完成。',
      autoHide: 3000,
    },
  ],
};

場景 5:會計 SaaS — 動態 ActionSkill 顯示異常

const findAnomalies: ActionSkill = {
  id: 'anomalies',
  type: 'action',
  name: '本月帳目異常',
  handler: async (ctx) => {
    ctx.palette.replace([{ id: 'loading', name: '分析中...', handler: () => {} }]);

    const issues = await ctx.llm(
      '檢查最近 30 天交易,列出可疑項目(金額異常、重複、跳號等)。回 JSON array。',
      // ctx.llm 內部從 ctx 取 sheet data
    );

    const parsed = JSON.parse(issues);
    ctx.palette.replace(parsed.map(issue => ({
      id: issue.id,
      name: `⚠️ ${issue.desc}`,
      description: issue.amount + ' on ' + issue.date,
      handler: () => ctx.navigate(`/ledger/${issue.id}`),
    })));
  },
};

場景 6:翻譯 / 出版 — Skill 鏈結組合

const fullTranslateWorkflow: ScriptSkill = {
  id: 'translate-book',
  type: 'script',
  name: '翻譯整本書',
  steps: [
    {
      subtitle: '開始翻譯流程。先讓我看一下章節結構。',
      action: async (t) => {
        await t.runSkill('extract-chapters');
      },
    },
    {
      subtitle: '抽取詞彙表中...',
      action: async (t) => {
        await t.runSkill('build-glossary');
      },
    },
    {
      subtitle: '正式翻譯,使用詞彙表。',
      action: async (t) => {
        await t.runSkill('translate-with-glossary', { lang: 'zh-TW' });
      },
    },
    {
      subtitle: '✓ 完成!',
      autoHide: 3000,
    },
  ],
};

場景 7:ERP — Skill 走後端權限

const approveExpense: A2UISkill = {
  id: 'approve-expense',
  type: 'a2ui',
  name: '審核費用',
  build: async (ctx) => {
    // 後端會檢查用戶身分
    const pending = await fetch('/api/expenses/pending', {
      headers: { 'Authorization': `Bearer ${ctx.storage.get('token')}` },
    }).then(r => r.json());

    if (pending.length === 0) {
      ctx.subtitle.show({ text: '沒有待審核項目', type: 'info', autoHide: 2000 });
      return null;
    }

    return {
      version: 'v0.10',
      updateComponents: {
        surfaceId: 'approve',
        components: [
          { id: 'root', component: 'Card', children: ['title', 'list'] },
          { id: 'title', component: 'Heading', text: `${pending.length} 筆待審` },
          { id: 'list', component: 'List', bind: '/items' },
        ],
      },
      updateDataModel: { data: { items: pending } },
    };
  },
};

共通模式

Skill 自動發現

企業 monorepo 通常把 skills 集中:

src/dddk-skills/
  ├── index.ts          # export *
  ├── orders.ts
  ├── leads.ts
  ├── translate.ts
  └── ...
// index.ts
export * from './orders';
export * from './leads';
// ...

// app.tsx
import * as skills from '@/dddk-skills';

<DddkProvider skills={Object.values(skills)}>

分權限隱藏

ActionSkill / A2UISkill 可以根據用戶角色決定 build 行為,或讓 host 直接過濾 skills list:

const allSkills = [adminSkill, userSkill, ...];
const visibleSkills = allSkills.filter(s => s.visible?.(currentUser) ?? true);

<DddkProvider skills={visibleSkills}>

A/B 測試 skills

host 自己決定載入哪些。dddk 不管。

不建議的模式

  • ❌ 把 API key / secret 寫在 skill prompt 內 — 永遠走 host fetch
  • ❌ skill 內 setTimeout 跑很久 — 改用 dock A2UI 給進度
  • ❌ skill 內呼叫 alert / confirm — 用 subtitle 或 A2UI
  • ❌ skill 內存 global state — 用 ctx.storage