🆕 程式碼
/** File-based session store(可改 DB) */
const DATA_DIR = path.join(process.cwd(), "data", "wf");
if (!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR, { recursive: true });
function sessionFile(flowId, sessionId) {
return path.join(DATA_DIR, ${flowId}_${sessionId}.json
);
}
function loadSession(flowId, sessionId) {
const fp = sessionFile(flowId, sessionId);
if (!fs.existsSync(fp)) return { flowId, sessionId, state: {}, history: [], done: false };
try {
return JSON.parse(fs.readFileSync(fp, "utf-8"));
} catch {
return { flowId, sessionId, state: {}, history: [], done: false };
}
}
function saveSession(flowId, sessionId, data) {
const fp = sessionFile(flowId, sessionId);
fs.writeFileSync(fp, JSON.stringify(data, null, 2), "utf-8");
}
/** 工具:檢查缺少的欄位 */
function missingSlots(schema, state) {
const required = schema.required || [];
return required.filter((k) => state[k] == null || state[k] === "");
}
/** 工具:依 schema.validators 進行基本驗證(可擴充) */
function validateState(schema, state) {
const errors = [];
if (schema.validators) {
for (const v of schema.validators) {
const ok = v.check(state);
if (!ok) errors.push(v.message);
}
}
return errors;
}
/** 引擎:執行一輪對話 */
export async function runFlowOnce(flow, { sessionId = "default", userInput }) {
const flowId = flow.id;
const sess = loadSession(flowId, sessionId);
// 1) 如果流程已完成,直接回覆完成狀態
if (sess.done) {
return {
reply: flow.ui?.afterDoneMessage?.(sess.state) || "流程已完成,如需重新開始請輸入「重來」。",
state: sess.state,
done: true,
};
}
// 2) 特殊命令
if (/^(reset|重來|重新)$/.test(userInput?.trim() || "")) {
const fresh = { flowId, sessionId, state: {}, history: [], done: false };
saveSession(flowId, sessionId, fresh);
return { reply: flow.ui?.welcome || "已重新開始,請問需要什麼協助?", state: {}, done: false };
}
// 3) 讓流程的 NLU/解析器嘗試從 userInput 填充欄位
const parsed = await flow.parse?.(userInput, sess.state) || {};
// 合併到 state(只覆蓋有值的)
for (const [k, v] of Object.entries(parsed)) {
if (v != null && v !== "") sess.state[k] = v;
}
// 4) Schema 驗證/缺少欄位檢查
const missing = missingSlots(flow.schema, sess.state);
if (missing.length > 0) {
// 請模型「只問一個缺項」,避免一次丟太多造成放棄
const askSlot = missing[0];
const prompt = flow.prompts?.askForSlot(askSlot, sess.state);
const res = await openai.chat.completions.create({
model: "gpt-4o-mini",
temperature: 0.4,
messages: [
{ role: "system", content: flow.prompts?.system || "你是友善且精準的助理,用繁體中文回應。" },
{ role: "user", content: prompt || 請向使用者詢問欄位「${askSlot}」,以一句話完成。
},
],
});
const reply = res.choices?.[0]?.message?.content?.trim() || 請提供 ${askSlot}
;
sess.history.push({ role: "user", content: userInput });
sess.history.push({ role: "assistant", content: reply });
saveSession(flowId, sessionId, sess);
return { reply, state: sess.state, done: false };
}
// 5) 欄位齊全 → 做終檢與條件分支
const errors = validateState(flow.schema, sess.state);
if (errors.length > 0) {
const msg = flow.ui?.validationFailed?.(errors, sess.state)
|| 有些欄位格式不正確:\n- ${errors.join("\n- ")}\n請更正後再試。
;
sess.history.push({ role: "user", content: userInput });
sess.history.push({ role: "assistant", content: msg });
saveSession(flowId, sessionId, sess);
return { reply: msg, state: sess.state, done: false };
}
// 6) 完成前的確認訊息(確認 JSON)
const confirmText = flow.ui?.confirmMessage?.(sess.state);
if (confirmText && !sess.state.__confirmed) {
// 第一次回傳確認訊息;若使用者回「確認/OK」,parse 可填 __confirmed=true
sess.history.push({ role: "user", content: userInput });
sess.history.push({ role: "assistant", content: confirmText });
saveSession(flowId, sessionId, sess);
return { reply: confirmText, state: sess.state, done: false };
}
// 7) 執行 flow 的完成處理(呼叫 API / 生成單號…)
const result = await flow.onComplete?.(sess.state);
const doneMsg = flow.ui?.doneMessage?.(result, sess.state) || "已完成。";
sess.done = true;
sess.history.push({ role: "user", content: userInput });
sess.history.push({ role: "assistant", content: doneMsg });
saveSession(flowId, sessionId, sess);
return { reply: doneMsg, state: sess.state, done: true, result };
}
/** 註冊中心:你可以在 index.js 收集 flows 用 */
export function registry(flows = []) {
const map = new Map();
for (const f of flows) {
if (!f?.id) throw new Error("flow 必須有 id");
map.set(f.id, f);
}
return map;
}
export const reportRepair = {
id: "report_repair",
schema: {
required: ["name", "phone", "address", "symptom"],
validators: [
{ check: (s) => /^09\d{8}$/.test(s.phone || ""), message: "電話需為台灣手機格式:09xxxxxxxx" },
{ check: (s) => (s.address || "").length >= 6, message: "地址過短,請提供更完整地址" },
],
},
prompts: {
system: "你是報修客服機器人,請用繁體中文簡潔回覆,一次只問一件事。",
askForSlot: (slot, state) => {
const labels = {
name: "請問您的大名?",
phone: "請提供聯絡手機(09xxxxxxxx)。",
address: "請提供報修地址(縣市/區/路段/門牌)。",
symptom: "請描述故障現象(例如:冷氣漏水、無法開機)。",
};
return labels[slot] || 請提供 ${slot}
;
},
},
ui: {
welcome: "您好,我是報修助手。請問要報修什麼設備?先留下您的稱呼吧~",
confirmMessage: (state) => {
const json = JSON.stringify({
name: state.name, phone: state.phone, address: state.address, symptom: state.symptom,
}, null, 2);
return 已取得以下資訊,請回覆「確認」或「修改」:\n\
``json\n${json}\n```; }, validationFailed: (errors) =>
欄位有誤:\n- ${errors.join("\n- ")}\n請重新提供。, doneMessage: (result) =>
已建立報修單號:${result.ticketId},我們將於 2 小時內致電聯繫。`,
afterDoneMessage: () => "報修已完成;若需新報修請輸入「重來」。",
},
parse: async (input, state) => {
// 簡易規則 + LLM 助攻(抽取姓名/電話/地址/故障)
const out = {};
// 1) 規則抽取(電話)
const m = input.match(/09\d{8}/);
if (m) out.phone = m[0];
// 2) 用 LLM 協助命名實體抽取(避免你自己寫一堆 regex)
const res = await openai.chat.completions.create({
model: "gpt-4o-mini",
temperature: 0,
messages: [
{ role: "system", content: "請從輸入中抽取 name/address/symptom/confirm 四欄,JSON 回覆。" },
{ role: "user", content: input }
]
});
const raw = res.choices?.[0]?.message?.content?.trim() || "{}";
const json = raw.match(/```(?:json)?\s*([\s\S]*?)```/i)?.[1] ?? raw;
try {
const obj = JSON.parse(json);
if (obj.name) out.name = obj.name;
if (obj.address) out.address = obj.address;
if (obj.symptom) out.symptom = obj.symptom;
if (/^確認|ok|OK|是$/.test(obj.confirm || "")) out.__confirmed = true;
} catch { /* 忽略 */ }
if (/^確認|^ok$|^OK$/.test(input.trim())) out.__confirmed = true;
if (/^修改$/.test(input.trim())) out.__confirmed = false;
return out;
},
onComplete: async (state) => {
// 這裡可呼叫你的後端 API 建單,現在先 mock
const ticketId = "T" + Math.floor(100000 + Math.random() * 900000);
return { ticketId, ...state };
},
};
export const customerFAQ = {
id: "customer_faq",
schema: { required: ["question"] },
prompts: {
system: "你是客服 FAQ 助理,先判斷是否常見問題;若符合,給精簡答案與下一步建議。",
askForSlot: () => "請描述您的問題(帳單、退費、維修、物流…)?",
},
ui: {
welcome: "您好,這裡是客服常見問題。請說說遇到的情況。",
confirmMessage: null, // 這個流程不需要確認
validationFailed: (errors) => 發生錯誤:${errors.join(", ")}
,
doneMessage: (result) => result.answer,
},
parse: async (input, state) => {
// 把使用者描述存入 question
return { question: (state.question ? ${state.question}\n${input}
: input) };
},
onComplete: async (state) => {
// 直接交給 LLM 回答(正式上線請接你們的 KB/RAG)
const res = await openai.chat.completions.create({
model: "gpt-4o-mini",
temperature: 0.3,
messages: [
{ role: "system", content: "簡潔、正確,以條列給出步驟;若需要人工,提供聯絡方式模板。" },
{ role: "user", content: 問題:${state.question}\n請以繁體中文回答。
},
],
});
const answer = res.choices?.[0]?.message?.content?.trim() || "目前無法判斷,請改用人工客服。";
return { answer };
},
};
const flows = registry([reportRepair, customerFAQ]);
// ...既有 args 解析保留
async function main() {
const task = args.task || "chat";
if (task === "flow") {
const flowId = args.flow || "report_repair"; // or "customer_faq"
const sessionId = args.session || "demo";
const text = args.text || "哈囉";
const flow = flows.get(flowId);
if (!flow) {
console.log(找不到流程:${flowId}。可用流程:${[...flows.keys()].join(", ")}
);
return;
}
const { reply, state, done } = await runFlowOnce(flow, { sessionId, userInput: text });
console.log(\n[${flowId}/${sessionId}]
);
console.log("AI:", reply);
console.log("State:", JSON.stringify(state, null, 2));
console.log("Done:", done);
} else {
// 你原本的其他 task 分支...
}
}
main().catch(e => { console.error(e); process.exit(1); });
🗑 建議刪除(若你決定全面使用引擎)
刪除:src/day14_conversation.js
刪除:index.js 內 task=dialogue 分支與 package.json 的 day14:* scripts。
這些功能都由 Day 15 引擎取代,避免重複維護。
▶️ CLI 驗收步驟
npm run day15:repair:start --silent
npm run day15:repair:fill --silent
npm run day15:repair:confirm --silent
npm run day15:faq:start --silent
npm run day15:faq:ask --silent
會在 data/wf/ 下生成 report_repair_s1.json 等會話檔案。