iT邦幫忙

2025 iThome 鐵人賽

DAY 5
0

🆕 新增/修改的程式碼

  1. src/historyStore.js(新增)

以 JSON 檔落盤。每個 sessionId 對應一個檔。內建訊息數量與近似 token雙重裁切。

// src/historyStore.js
import fs from "fs";
import path from "path";

const DATA_DIR = path.join(process.cwd(), "data");
if (!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR);

function filePath(sessionId) {
  return path.join(DATA_DIR, `chat_${sessionId}.json`);
}

export class HistoryStore {
  constructor(sessionId = "default", options = {}) {
    this.sessionId = sessionId;
    this.path = filePath(sessionId);
    this.maxMessages = options.maxMessages ?? 20;     // 最多保留的訊息數(不含 system)
    this.maxChars = options.maxChars ?? 6000;         // 近似 token 控制(1 token ~ 3~4 chars)
  }

  load() {
    if (!fs.existsSync(this.path)) return [];
    try {
      const raw = fs.readFileSync(this.path, "utf-8");
      const parsed = JSON.parse(raw);
      return Array.isArray(parsed) ? parsed : [];
    } catch {
      return [];
    }
  }

  save(messages) {
    fs.writeFileSync(this.path, JSON.stringify(messages, null, 2), "utf-8");
  }

  reset() {
    if (fs.existsSync(this.path)) fs.unlinkSync(this.path);
  }

  /**
   * 裁切策略:
   * 1) 僅對 user/assistant 訊息做視窗(system 由程式動態組)
   * 2) 超過 maxMessages → 從最舊開始移除
   * 3) 近似 token:以總字元數近似,超過 maxChars 再從最舊移除
   */
  trim(messages) {
    const sys = messages.filter(m => m.role === "system");
    let convo = messages.filter(m => m.role !== "system");

    // 1) 訊息數裁切
    if (convo.length > this.maxMessages) {
      convo = convo.slice(convo.length - this.maxMessages);
    }

    // 2) 近似 token 裁切(以字元數估算)
    const totalChars = (arr) => arr.reduce((s, m) => s + (m.content?.length || 0), 0);
    while (totalChars(convo) > this.maxChars && convo.length > 2) {
      convo.shift();
    }

    return [...sys, ...convo];
  }

  append(message) {
    const existing = this.load();
    const next = this.trim([...existing, message]);
    this.save(next);
    return next;
  }
}
  1. src/day5_chat_history.js(新增)

組合 system + history + 本輪 user → 呼叫模型 → 儲存 assistant。

// src/day5_chat_history.js
import { openai } from "./aiClient.js";
import { PromptBuilder } from "./promptBuilder.js";
import { HistoryStore } from "./historyStore.js";

/**
 * 進行一次多輪對話(會讀取/寫入歷史)
 * @param {string} userInput - 使用者輸入
 * @param {object} opts
 * @param {string} [opts.sessionId="default"]
 * @param {number} [opts.temperature=0.6]
 * @param {number} [opts.max_tokens=600]
 * @param {string} [opts.persona] - 人設(如:旅遊助理/資深工程師…)
 */
export async function chatOnce(userInput, opts = {}) {
  const {
    sessionId = "default",
    temperature = 0.6,
    max_tokens = 600,
    persona = "你是可靠且精準的中文 AI 助理,回答需簡明、可行、避免幻想內容。",
  } = opts;

  const store = new HistoryStore(sessionId, {
    maxMessages: 24,
    maxChars: 8000, // 近似 ~2k tokens
  });

  // 讀歷史
  const history = store.load().filter(m => m.role !== "system");

  // System prompt 用 PromptBuilder 組裝,方便未來加 constraint
  const pb = new PromptBuilder()
    .setRole(persona)
    .addConstraint("若不確定,直說不知道並提出查證建議")
    .addConstraint("回答以繁體中文")
    .setFormatHint("使用條列與小節提升可讀性");

  const systemMsg = { role: "system", content: pb.buildSystemPrompt() };

  // 將這輪 user push 進歷史再呼叫
  const messages = [systemMsg, ...history, { role: "user", content: userInput }];

  const res = await openai.chat.completions.create({
    model: "gpt-4o-mini",
    temperature,
    max_tokens,
    messages,
  });

  const reply = res.choices?.[0]?.message?.content?.trim() ?? "(無回覆)";

  // 落盤:user 與 assistant
  store.append({ role: "user", content: userInput });
  store.append({ role: "assistant", content: reply });

  return { reply };
}

/** 重設會話 */
export function resetSession(sessionId = "default") {
  const store = new HistoryStore(sessionId);
  store.reset();
}
  1. index.js(修改:加入 CLI 聊天)
// index.js
import { englishTeacher, codeReview, sentimentClassify } from "./src/day3_prompt_engineering.js";
import { newsToJson } from "./src/day4_text_to_json.js";
import { chatOnce, resetSession } from "./src/day5_chat_history.js";

const args = Object.fromEntries(
  process.argv.slice(2).reduce((acc, cur, i, arr) => {
    if (cur.startsWith("--")) {
      const key = cur.replace(/^--/, "");
      const val = arr[i + 1] && !arr[i + 1].startsWith("--") ? arr[i + 1] : true;
      acc.push([key, val]);
    }
    return acc;
  }, [])
);

async function main() {
  const task = args.task || "chat";

  if (task === "chat") {
    const sessionId = args.session || "default";
    if (args.reset) {
      resetSession(sessionId);
      console.log(`已重設會話:${sessionId}`);
      return;
    }
    const input = args.text || "嗨,我想規劃 3 天 2 夜的台中旅遊行程。";
    const { reply } = await chatOnce(input, {
      sessionId,
      temperature: args.temp ? Number(args.temp) : 0.6,
      max_tokens: args.max_tokens ? Number(args.max_tokens) : 600,
      persona: args.persona || "你是可靠且精準的中文 AI 旅遊助理,提供具體行程與交通建議。",
    });
    console.log(`\n[${sessionId}] AI:\n${reply}\n`);

  } else if (task === "teacher") {
    const input = args.text || "He go to school every day.";
    const out = await englishTeacher(input);
    console.log("\n=== 英文老師 ===\n");
    console.log(out);

  } else if (task === "review") {
    const sample =
`function sum(arr){
  let s = 0;
  for (let i=0;i<arr.length;i++){
    s += arr[i]
  }
  return s
}`;
    const out = await codeReview(sample, "javascript");
    console.log("\n=== 程式碼審查 ===\n");
    console.log(out);

  } else if (task === "sentiment") {
    const text = args.text || "今天心情糟透了,事情一團亂。";
    const out = await sentimentClassify(text);
    console.log("\n=== 情緒分類(JSON) ===\n");
    console.log(out);

  } else if (task === "json_summary") {
    const article = args.text || `台北市今天宣布推出全新的智慧交通系統,
    透過 AI 與大數據分析來優化紅綠燈號誌,預計將能減少 20% 的交通壅塞。`;
    const out = await newsToJson(article);
    console.log("\n=== 新聞 JSON 摘要 ===\n");
    console.log(out);

  } else {
    console.log("未知任務,請使用 --task chat | teacher | review | sentiment | json_summary");
  }
}

main().catch((e) => {
  console.error("發生錯誤:", e.message);
  process.exit(1);
});

▶️ 如何執行(CLI 版)

# 啟動多輪對話(預設 session=default)
npm run day5:chat --silent

# 指定會話(可同時維護多個對話)
node index.js --task chat --session projectA --text "我們的 RAG 專案,今天要做什麼?"

# 繼續同一會話(會帶入過去上下文)
node index.js --task chat --session projectA --text "幫我寫出今日待辦清單"

# 重設會話
node index.js --task chat --session projectA --reset

上一篇
文字 → JSON(更嚴格的結構化輸出與驗證)
系列文
30 天生成式 AI 實戰挑戰:從基礎到應用5
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言