* 对话式 CLI(Hermes 风格):node cli.js chat
* - 角色人设:知识管家
* - 上下文记忆:多轮对话历史
* - 工具自动挑选:LLM 判断是"普通聊天"还是"操作知识库",需要时输出工具调用 JSON
* - 工具结果回灌:工具执行后由 LLM 用自然语言总结给用户
*/
import * as fs from "fs";
import * as path from "path";
import * as readline from "readline";
import type { LlmProvider } from "./core/provider";
import { renderSource } from "./core/engine";
import { FsWriter } from "./core/fsWriter";
import { planIncremental } from "./core/incremental";
import { applyNotes } from "./core/writer";
import { compileRaw } from "./core/compiler";
import { queryWiki } from "./core/query";
import { lintWiki } from "./core/lint";
import { extractJson } from "./core/compiler";
import type { Profile } from "./core/types";
export interface ChatOptions {
provider: LlmProvider;
vault: string;
schemaWiki: string;
schemaQuery: string;
cwd: string;
persona?: string;
}
const DEFAULT_PERSONA = `你是「知识管家」,一个温暖、专业、耐心的 AI 知识库助手。
你管理着一个 Obsidian 知识库(vault),职责是帮用户把资料变成会生长的知识库,并基于它回答问题。
你可以调用以下工具(用户要求操作知识库时):
- compile 把原始资料(目录/文件)编译进知识库 wiki(实体/概念/主题/索引)
- query 基于知识库回答用户问题(读索引→定位→读页→综合回答,标注来源)
- run 把结构化数据(JSON)按 profile 模板转成笔记
- lint 检查知识库健康(断链/孤立页/索引)
规则:
1. 用户要求"编译/收录/整理资料"→ 输出工具调用 JSON(单独输出,不要夹杂文字):
{"tool":"compile","params":{"input":"<资料目录或文件路径>","instruction":"<可选的自然语言要求>"}}
2. 用户问知识库里的内容 → 输出:{"tool":"query","params":{"question":"<用户的问题>"}}
3. 用户给数据要转成笔记 → 输出:{"tool":"run","params":{"data":"<JSON 内容或文件路径>","profile":"person"}}
4. 用户要求检查健康 → 输出:{"tool":"lint","params":{}}
5. 其余情况(打招呼、闲聊、问工具用法等)→ 直接用自然语言聊天回复
6. 回答知识库问题时:先给直接结论,标注来源;知识库没有的,如实说明"知识缺口",绝不编造
7. 语气自然亲切,像一位懂行的老师`;
const MAX_HISTORY = 20;
export async function runChat(opts: ChatOptions): Promise<number> {
const persona = opts.persona ?? DEFAULT_PERSONA;
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
let closed = false;
rl.on("close", () => {
closed = true;
});
console.log("");
console.log("══════ 知识管家(workvault) ══════");
console.log(`vault: ${opts.vault}`);
console.log("用自然语言和我聊,我可以:整理资料进知识库 / 回答知识库问题 / 数据转笔记 / 检查健康");
console.log("退出:exit / quit");
console.log("════════════════════════════════════");
const ask = () =>
new Promise<string | null>((resolve) => {
if (closed) {
resolve(null);
return;
}
try {
rl.question("\n你 > ", (a) => resolve(a.trim()));
} catch {
resolve(null);
}
});
const history: string[] = [];
try {
while (true) {
const input = await ask();
if (input === null) break;
if (!input) continue;
const lower = input.toLowerCase();
if (["exit", "quit", "bye", "退出", "结束"].includes(lower)) {
console.log("再见 👋 知识库随时等你回来");
break;
}
const userText = buildUserText(history, input);
let reply: string;
try {
reply = await opts.provider.ask(persona, userText);
} catch (e) {
console.log(`(调用失败:${(e as Error).message})`);
continue;
}
const intent = parseToolCall(reply);
if (intent) {
const resultText = await executeTool(opts, intent.tool, intent.params);
const summaryUser =
`${userText}\n\n【工具执行结果】\n${resultText}\n\n请用自然、简洁的语言向用户总结刚才的操作结果(做了什么、结果如何、可补充建议),不要复述 JSON。`;
let summary: string;
try {
summary = await opts.provider.ask(persona, summaryUser);
} catch {
summary = resultText;
}
console.log("\n" + summary.trim());
history.push(`用户: ${input}`, `助手: ${summary.trim()}`);
} else {
console.log("\n" + reply.trim());
history.push(`用户: ${input}`, `助手: ${reply.trim()}`);
}
if (history.length > MAX_HISTORY) {
history.splice(0, history.length - MAX_HISTORY);
}
}
} finally {
rl.close();
}
return 0;
}
function buildUserText(history: string[], input: string): string {
const parts: string[] = [];
if (history.length > 0) {
parts.push("【之前的对话】");
parts.push(history.join("\n"));
parts.push("");
}
parts.push("【当前用户输入】");
parts.push(input);
return parts.join("\n");
}
interface ToolCall {
tool: string;
params: Record<string, unknown>;
}
function parseToolCall(reply: string): ToolCall | null {
const trimmed = reply.trim();
if (!trimmed.startsWith("{")) return null;
const parsed = extractJson(trimmed);
if (!parsed || !parsed.tool) return null;
return {
tool: String(parsed.tool).toLowerCase(),
params: (parsed.params ?? {}) as Record<string, unknown>,
};
}
async function executeTool(
opts: ChatOptions,
tool: string,
params: Record<string, unknown>,
): Promise<string> {
try {
switch (tool) {
case "compile":
return await execCompile(opts, params);
case "query":
return await execQuery(opts, params);
case "run":
return await execRun(opts, params);
case "lint":
return execLint(opts);
default:
return `未知工具:${tool}`;
}
} catch (e) {
return `执行失败:${(e as Error).message}`;
}
}
async function execRun(opts: ChatOptions, params: Record<string, unknown>): Promise<string> {
let dataRaw = String(params.data ?? "");
if (!dataRaw) return "run 缺少 data(数据内容或文件路径)";
const candidate = path.isAbsolute(dataRaw) ? dataRaw : path.join(opts.cwd, dataRaw);
if (fs.existsSync(candidate)) {
dataRaw = fs.readFileSync(candidate, "utf-8");
} else if (!dataRaw.trim().startsWith("[")) {
return `数据文件不存在:${candidate}`;
}
const profileRef = String(params.profile ?? "person");
const profile = loadProfile(profileRef, opts.cwd);
const source = { kind: "json" as const, content: dataRaw };
const { notes, issues, warnings } = renderSource(source, profile);
if (issues.length > 0) {
return `校验问题:${issues.map((i) => `${i.path}: ${i.issue}`).join("; ")}`;
}
const writer = new FsWriter(opts.vault);
const existing = await writer.listExistingPaths();
let toWrite = notes;
let planNote = "";
if (profile.incremental) {
const plan = await planIncremental(notes, writer);
toWrite = plan.notes;
planNote = `增量:变化 ${plan.changed.length} 篇,跳过 ${plan.unchanged.length} 篇;`;
}
const result = await applyNotes(toWrite, writer, {
dryRun: false,
conflictStrategy: profile.conflictStrategy ?? "overwrite",
existingPaths: existing,
});
const warns = warnings.length ? `警告:${warnings.join("; ")}` : "";
return `${planNote}已写入 ${result.written.length} 篇,跳过 ${result.skipped.length} 篇${result.errors.length ? `,错误 ${result.errors.length}` : ""}${warns}`;
}
async function execCompile(opts: ChatOptions, params: Record<string, unknown>): Promise<string> {
const inputRef = String(params.input ?? "");
if (!inputRef) return "compile 需要 input(资料目录或文件路径)";
let inputPath = "";
if (path.isAbsolute(inputRef)) {
inputPath = inputRef;
} else {
const candidates = [path.join(opts.cwd, inputRef), path.join(opts.vault, inputRef)];
inputPath = candidates.find((p) => fs.existsSync(p)) ?? candidates[0];
}
if (!fs.existsSync(inputPath)) return `资料路径不存在:${inputPath}`;
const inputs = readInput(inputPath);
if (inputs.length === 0) return "没有可编译的文本文件";
const result = await compileRaw({
provider: opts.provider,
schema: opts.schemaWiki,
inputs,
instruction: params.instruction ? String(params.instruction) : undefined,
vault: opts.vault,
wikiDir: "wiki",
});
const log = result.log ? `编译:${result.log}` : "";
const stat = `已写入 ${result.written.length} 篇,跳过 ${result.skipped.length} 篇,未变 ${result.unchanged.length} 篇${result.errors.length ? `,错误 ${result.errors.length}` : ""}`;
return [log, stat, ...result.errors.map((e) => `错误:${e}`)].filter(Boolean).join("\n");
}
async function execQuery(opts: ChatOptions, params: Record<string, unknown>): Promise<string> {
const question = String(params.question ?? "");
if (!question) return "query 需要 question";
const result = await queryWiki({
provider: opts.provider,
vault: opts.vault,
question,
schema: opts.schemaQuery,
wikiDir: "wiki",
});
const parts = [
result.answer,
result.sources.length ? `来源:${result.sources.join(", ")}` : "",
result.gaps.length ? `知识缺口:${result.gaps.join("; ")}` : "",
...result.errors.map((e) => `错误:${e}`),
].filter(Boolean);
return parts.join("\n") || "(没有回答)";
}
function execLint(opts: ChatOptions): string {
const r = lintWiki(opts.vault, "wiki");
const parts = [`wiki 页面 ${r.totalPages} 篇`];
if (r.indexMissing) parts.push("! index.md 缺失");
for (const b of r.brokenLinks) parts.push(`! 断链 ${b.from} → [[${b.link}]]`);
for (const l of r.indexBrokenLinks) parts.push(`! index 断链 → [[${l}]]`);
for (const p of r.orphanPages) parts.push(`! 孤立页 ${p}`);
parts.push(r.ok ? "健康 ✓" : `共 ${r.brokenLinks.length + r.indexBrokenLinks.length} 处断链 / ${r.orphanPages.length} 个孤立页`);
return parts.join("\n");
}
function loadProfile(ref: string, cwd: string): Profile {
const candidates = [
path.isAbsolute(ref) ? ref : path.join(cwd, ref),
path.join(path.dirname(__filename), "templates", "profiles", `${ref}.json`),
];
for (const p of candidates) {
if (fs.existsSync(p)) return JSON.parse(fs.readFileSync(p, "utf-8")) as Profile;
}
const examplesDir = path.join(path.dirname(__filename), "examples");
if (fs.existsSync(examplesDir)) {
for (const pkg of fs.readdirSync(examplesDir)) {
const p = path.join(examplesDir, pkg, "profiles", `${ref}.json`);
if (fs.existsSync(p)) return JSON.parse(fs.readFileSync(p, "utf-8")) as Profile;
}
}
throw new Error(`profile 不存在: ${ref}`);
}
const TEXT_EXTS = [".md", ".mdx", ".txt", ".json", ".yaml", ".yml", ".csv", ".html", ".py", ".js", ".ts"];
function readInput(ref: string): Array<{ name: string; content: string }> {
const stat = fs.statSync(ref);
if (stat.isDirectory()) {
const out: Array<{ name: string; content: string }> = [];
const walk = (dir: string, prefix: string) => {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
if (entry.name.startsWith(".")) continue;
const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
const full = path.join(dir, entry.name);
if (entry.isDirectory()) walk(full, rel);
else if (entry.isFile() && TEXT_EXTS.includes(path.extname(entry.name).toLowerCase())) {
out.push({ name: rel, content: fs.readFileSync(full, "utf-8") });
}
}
};
walk(ref, "");
return out;
}
return [{ name: path.basename(ref), content: fs.readFileSync(ref, "utf-8") }];
}