🆕 新增/修改的程式碼
以 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;
}
}
組合 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();
}
// 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