E5.1 织忆插件 v1.1: 记忆展开详情+分类过滤+质量分条+写记忆入口+设置持久化+图谱边标签+实体列表侧边栏

This commit is contained in:
xiaowei 2026-06-03 20:25:53 +08:00
parent 8d62d35ae7
commit 2d6981d4fb
6 changed files with 644 additions and 249 deletions

File diff suppressed because one or more lines are too long

View File

@ -1,52 +1,108 @@
import { App, Plugin, PluginSettingTab, Setting, addIcon } from "obsidian";
import { App, Plugin, PluginSettingTab, Setting, moment } from "obsidian";
import { MemoryView, MEMORY_VIEW_TYPE } from "./src/MemoryView";
import { GraphView, GRAPH_VIEW_TYPE } from "./src/GraphView";
import { SearchModal } from "./src/SearchModal";
import { setRuntimeConfig, getApiBase, getApiKey, getRuntimeNamespace, writeMemory } from "./src/api";
const SETTINGS_FILE = "zhiyi-settings.json";
interface ZhiYiSettings {
apiUrl: string;
apiKey: string;
namespace: string;
}
const DEFAULT_SETTINGS: ZhiYiSettings = {
apiUrl: "http://localhost:7821",
apiKey: "zhiyi-dev-key-2026",
namespace: "hermes-main",
};
export default class ZhiYiPlugin extends Plugin {
private settings: ZhiYiSettings = { ...DEFAULT_SETTINGS };
private ribbonIcon: HTMLElement | null = null;
async onload() {
// ─── 注册视图 ─────────────────────────────────────────────────────
// 加载持久化设置
await this.loadSettings();
// 注册视图
this.registerView(MEMORY_VIEW_TYPE, (leaf) => new MemoryView(leaf));
this.registerView(GRAPH_VIEW_TYPE, (leaf) => new GraphView(leaf));
// ─── 侧边栏命令 ───────────────────────────────────────────────────
// 记忆面板
// ─── 命令 ─────────────────────────────────────────────────────
this.addCommand({
id: "zhiyi-open-memory-view",
name: "打开织忆记忆面板",
callback: () => this.openMemoryView(),
});
// 图谱面板
this.addCommand({
id: "zhiyi-open-graph-view",
name: "打开织忆图谱面板",
callback: () => this.openGraphView(),
});
// 搜索模态框
this.addCommand({
id: "zhiyi-search-memories",
name: "搜索织忆记忆",
callback: () => this.openSearchModal(),
});
// ─── 状态栏 ────────────────────────────────────────────────────────
this.addStatusBarItem().setText("🧠 织忆");
this.addStatusBarItem().setText("Ctrl+P → 织忆");
// 写记忆(选中文本)
this.addCommand({
id: "zhiyi-write-memory",
name: "将选中内容写入织忆",
editorCallback: (editor) => {
const selection = editor.getSelection();
if (selection) {
this.openWriteModal(selection);
}
},
});
// ─── 设置页 ────────────────────────────────────────────────────────
// Ribbon 图标
this.ribbonIcon = this.addStatusBarItem();
this.ribbonIcon.setIcon("brain");
this.ribbonIcon.setTooltip("织忆记忆");
this.ribbonIcon.onClick(() => this.openMemoryView());
// 状态栏
this.addStatusBarItem().setText("🧠 织忆 v1.1");
// 设置页
this.addSettingTab(new ZhiYiSettingTab(this.app, this));
}
onunload() {
// 关闭所有视图
this.app.workspace.getLeavesOfType(MEMORY_VIEW_TYPE).forEach((leaf) => leaf.detach());
this.app.workspace.getLeavesOfType(GRAPH_VIEW_TYPE).forEach((leaf) => leaf.detach());
}
// ─── 设置读写 ───────────────────────────────────────────────────
async loadSettings() {
try {
const data = await this.loadData(SETTINGS_FILE);
if (data) {
this.settings = { ...DEFAULT_SETTINGS, ...data };
}
} catch {
this.settings = { ...DEFAULT_SETTINGS };
}
// 同步到 api 运行时配置
setRuntimeConfig(this.settings.apiUrl, this.settings.apiKey, this.settings.namespace);
}
async saveSettings() {
await this.saveData(this.settings, SETTINGS_FILE);
setRuntimeConfig(this.settings.apiUrl, this.settings.apiKey, this.settings.namespace);
}
getSettings() { return this.settings; }
// ─── 视图操作 ────────────────────────────────────────────────────
async openMemoryView() {
const leaf = this.app.workspace.getLeaf("right");
await leaf.setViewState({ type: MEMORY_VIEW_TYPE, active: true });
@ -62,20 +118,37 @@ export default class ZhiYiPlugin extends Plugin {
openSearchModal() {
const modal = new SearchModal(this.app);
modal.onSelect = (id, content) => {
// 选好后复制到剪贴板并在通知中显示
navigator.clipboard.writeText(content).catch(() => {});
new (this.app as unknown as { notify: (m: string) => void }).notify(
(this.app as unknown as { notify: (m: string) => void }).notify(
`已复制: ${content.slice(0, 80)}`
);
};
modal.open();
}
// 写入记忆模态框
private openWriteModal(initialContent = "") {
const { writeMemory } = require("./src/api");
// 使用 Obsidian 的 PromptModal 或简易 confirm
const content = initialContent || window.prompt("输入记忆内容:");
if (!content?.trim()) return;
const category = window.prompt("分类(可选,如 normal/user_pref/project", "normal") || "normal";
writeMemory(content.trim(), category, this.settings.namespace)
.then(() => {
(this.app as unknown as { notify: (m: string) => void }).notify("✅ 记忆已写入织忆");
})
.catch((e: Error) => {
(this.app as unknown as { notify: (m: string) => void }).notify(`❌ 写入失败: ${e.message}`);
});
}
}
// ─── 设置页 ────────────────────────────────────────────────────────────────
class ZhiYiSettingTab extends PluginSettingTab {
constructor(app: App, plugin: ZhiYiPlugin) {
constructor(app: App, private plugin: ZhiYiPlugin) {
super(app, plugin);
}
@ -87,36 +160,40 @@ class ZhiYiSettingTab extends PluginSettingTab {
new Setting(containerEl)
.setName("API 地址")
.setDesc("织忆 Go API 服务地址(默认 http://localhost:7821")
.addText((text) =>
text
.setPlaceholder("http://localhost:7821")
.setValue("http://localhost:7821")
.onChange((val) => {
// API 地址暂存(后续在 api.ts 中使用)
(window as unknown as Record<string, string>)["zhiyi_api_url"] = val;
})
);
.addText((text) => {
text.setPlaceholder("http://localhost:7821")
.setValue(this.plugin.getSettings().apiUrl)
.onChange(async (val) => {
this.plugin.getSettings().apiUrl = val;
await this.plugin.saveSettings();
});
});
new Setting(containerEl)
.setName("API Key")
.setDesc("织忆认证密钥")
.addText((text) =>
text.setPlaceholder("zhiyi-dev-key-2026").onChange((val) => {
(window as unknown as Record<string, string>)["zhiyi_api_key"] = val;
})
);
.addText((text) => {
text.setPlaceholder("zhiyi-dev-key-2026")
.setValue(this.plugin.getSettings().apiKey)
.onChange(async (val) => {
this.plugin.getSettings().apiKey = val;
await this.plugin.saveSettings();
});
});
new Setting(containerEl)
.setName("默认 Namespace")
.setDesc("搜索和查询使用的默认命名空间")
.addText((text) =>
text.setValue("hermes-main").onChange((val) => {
(window as unknown as Record<string, string>)["zhiyi_namespace"] = val;
})
);
.addText((text) => {
text.setValue(this.plugin.getSettings().namespace)
.onChange(async (val) => {
this.plugin.getSettings().namespace = val;
await this.plugin.saveSettings();
});
});
new Setting(containerEl)
.setName("打开记忆面板")
.setName("记忆面板")
.addButton((btn) =>
btn.setButtonText("打开 🧠").onClick(() => {
(this.plugin as unknown as { openMemoryView: () => void }).openMemoryView();
@ -124,11 +201,15 @@ class ZhiYiSettingTab extends PluginSettingTab {
);
new Setting(containerEl)
.setName("打开图谱面板")
.setName("图谱面板")
.addButton((btn) =>
btn.setButtonText("打开 📐").onClick(() => {
(this.plugin as unknown as { openGraphView: () => void }).openGraphView();
})
);
new Setting(containerEl)
.setName("说明")
.setDesc("Ctrl+P → 织忆 可快速搜索记忆。选中文字后右键或命令面板选择「将选中内容写入织忆」可保存笔记。");
}
}

View File

@ -1,9 +1,9 @@
{
"id": "zhiyi-memory",
"name": "织忆",
"version": "0.1.0",
"version": "1.1.0",
"minAppVersion": "0.15.0",
"description": "织忆记忆系统可视化 — 图谱探索、记忆搜索、蒸馏状态监控",
"description": "织忆记忆系统可视化 — 图谱探索、记忆搜索、蒸馏状态监控、语义搜索、分类过滤、记忆写入",
"author": "小唯 A06",
"fundingurl": "",
"isDesktopOnly": false,

View File

@ -1,5 +1,14 @@
import { ItemView, WorkspaceLeaf } from "obsidian";
import { fetchGraphExport, fetchNeighbors, fetchByEntity, fetchPageRank, type GraphNode, type GraphEdge, type MemoryRecord } from "./api";
import {
fetchGraphExport,
fetchNeighbors,
fetchByEntity,
fetchPageRank,
getRuntimeNamespace,
type GraphNode,
type GraphEdge,
type MemoryRecord,
} from "./api";
// 动态加载 D3.js从 CDN不打包
async function loadD3(): Promise<typeof import("d3")> {
@ -31,15 +40,14 @@ interface SimLink extends d3.SimulationLinkDatum<SimNode> {
}
export class GraphView extends ItemView {
private container: HTMLElement;
private currentEntity = "";
private d3: typeof import("d3") | null = null;
private svg: d3.Selection<SVGSVGElement, unknown, null, undefined> | null = null;
private simulation: d3.Simulation<SimNode, SimLink> | null = null;
private topEntities: Array<{ entity: string; score: number }> = [];
constructor(leaf: WorkspaceLeaf) {
super(leaf);
this.container = this.contentEl;
}
getViewType() { return GRAPH_VIEW_TYPE; }
@ -55,51 +63,29 @@ export class GraphView extends ItemView {
async render() {
this.container.empty();
this.container.createEl("style", {
text: `
.zhiyi-graph-view { height: 100%; display: flex; flex-direction: column; }
.zhiyi-graph-toolbar {
display: flex; gap: 6px; padding: 6px 10px;
border-bottom: 1px solid var(--background-modifier-border);
align-items: center;
}
.zhiyi-graph-toolbar input {
flex: 1; padding: 4px 8px; font-size: 12px;
background: var(--background-secondary);
border: 1px solid var(--background-modifier-border);
border-radius: 4px; color: var(--text-normal);
}
.zhiyi-graph-toolbar button {
padding: 4px 10px; font-size: 12px; cursor: pointer;
background: var(--background-secondary);
border: 1px solid var(--background-modifier-border);
border-radius: 4px;
}
.zhiyi-graph-toolbar .info {
font-size: 11px; color: var(--text-muted); padding: 0 6px;
}
.zhiyi-graph-canvas { flex: 1; position: relative; overflow: hidden; }
.zhiyi-graph-canvas svg { width: 100%; height: 100%; }
.zhiyi-graph-detail {
position: absolute; bottom: 0; left: 0; right: 0;
background: var(--background-secondary);
border-top: 1px solid var(--background-modifier-border);
padding: 8px 12px; font-size: 11px; max-height: 120px; overflow-y: auto;
}
.zhiyi-graph-detail-title { font-weight: 600; margin-bottom: 4px; }
.zhiyi-graph-node-label { font-size: 10px; fill: var(--text-muted); pointer-events: none; }
.zhiyi-legend { position: absolute; top: 8px; right: 8px; background: var(--background-secondary); border: 1px solid var(--background-modifier-border); border-radius: 4px; padding: 6px 8px; font-size: 10px; }
.zhiyi-legend-item { display: flex; align-items: center; gap: 4px; margin: 2px 0; }
.zhiyi-legend-dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; }
.zhiyi-tooltip {
position: absolute; background: var(--background-secondary);
border: 1px solid var(--background-modifier-border);
border-radius: 4px; padding: 6px 8px; font-size: 11px;
pointer-events: none; opacity: 0; transition: opacity 0.15s;
max-width: 200px; z-index: 10;
}
`
});
this.container.createEl("style", { text: `
.zhiyi-graph-view { height: 100%; display: flex; flex-direction: column; }
.zhiyi-graph-toolbar { display: flex; gap: 6px; padding: 6px 10px; border-bottom: 1px solid var(--background-modifier-border); align-items: center; }
.zhiyi-graph-toolbar input { flex: 1; padding: 4px 8px; font-size: 12px; background: var(--background-secondary); border: 1px solid var(--background-modifier-border); border-radius: 4px; color: var(--text-normal); }
.zhiyi-graph-toolbar button { padding: 4px 10px; font-size: 12px; cursor: pointer; background: var(--background-secondary); border: 1px solid var(--background-modifier-border); border-radius: 4px; }
.zhiyi-graph-toolbar .info { font-size: 11px; color: var(--text-muted); padding: 0 4px; }
.zhiyi-graph-body { flex: 1; display: flex; overflow: hidden; }
.zhiyi-graph-entity-list { width: 160px; border-right: 1px solid var(--background-modifier-border); overflow-y: auto; padding: 4px 0; flex-shrink: 0; }
.zhiyi-graph-entity-list h4 { margin: 0; padding: 4px 8px; font-size: 11px; color: var(--text-muted); font-weight: 600; border-bottom: 1px solid var(--background-modifier-border); }
.zhiyi-entity-item { padding: 4px 8px; font-size: 11px; cursor: pointer; color: var(--text-normal); display: flex; justify-content: space-between; align-items: center; }
.zhiyi-entity-item:hover { background: var(--background-modifier-hover); }
.zhiyi-entity-item.active { color: var(--text-accent); background: var(--background-modifier-hover); }
.zhiyi-entity-score { font-size: 9px; color: var(--text-faint); }
.zhiyi-graph-canvas { flex: 1; position: relative; overflow: hidden; }
.zhiyi-graph-canvas svg { width: 100%; height: 100%; }
.zhiyi-graph-detail { position: absolute; bottom: 0; left: 0; right: 0; background: var(--background-secondary); border-top: 1px solid var(--background-modifier-border); padding: 8px 12px; font-size: 11px; max-height: 120px; overflow-y: auto; }
.zhiyi-graph-detail-title { font-weight: 600; margin-bottom: 4px; }
.zhiyi-graph-node-label { font-size: 10px; fill: var(--text-muted); pointer-events: none; }
.zhiyi-legend { position: absolute; top: 8px; right: 8px; background: var(--background-secondary); border: 1px solid var(--background-modifier-border); border-radius: 4px; padding: 6px 8px; font-size: 10px; }
.zhiyi-legend-item { display: flex; align-items: center; gap: 4px; margin: 2px 0; }
.zhiyi-legend-dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; }
.zhiyi-tooltip { position: absolute; background: var(--background-secondary); border: 1px solid var(--background-modifier-border); border-radius: 4px; padding: 6px 8px; font-size: 11px; pointer-events: none; opacity: 0; transition: opacity 0.15s; max-width: 200px; z-index: 10; }
` });
const root = this.container.createEl("div", { cls: "zhiyi-graph-view" });
const toolbar = root.createEl("div", { cls: "zhiyi-graph-toolbar" });
@ -110,13 +96,18 @@ export class GraphView extends ItemView {
const loadBtn = toolbar.createEl("button", { text: "加载图谱" });
toolbar.createEl("span", { cls: "info", text: "点击节点查看详情" });
// 快捷入口Top 实体
const topBtn = toolbar.createEl("button", { text: "Top 节点" });
const reloadEntitiesBtn = toolbar.createEl("button", { text: "刷新列表" });
const canvas = root.createEl("div", { cls: "zhiyi-graph-canvas" });
const body = root.createEl("div", { cls: "zhiyi-graph-body" });
// 实体列表侧边栏
const entityList = body.createEl("div", { cls: "zhiyi-graph-entity-list" });
entityList.createEl("h4", { text: "📊 Top 实体" });
const entityListBody = entityList.createEl("div", { cls: "zhiyi-graph-entity-list-body" });
const canvas = body.createEl("div", { cls: "zhiyi-graph-canvas" });
const tooltip = canvas.createEl("div", { cls: "zhiyi-tooltip" });
const legendEl = canvas.createEl("div", { cls: "zhiyi-legend" });
legendEl.innerHTML = `
<div class="zhiyi-legend-item"><span class="zhiyi-legend-dot" style="background:#4a9eff"></span> </div>
@ -129,36 +120,72 @@ export class GraphView extends ItemView {
const detailTitle = detail.createEl("div", { cls: "zhiyi-graph-detail-title" });
const detailContent = detail.createEl("div", { cls: "zhiyi-graph-detail-content" });
// 加载 D3 并渲染全局图谱
this.d3 = await loadD3();
this.svg = this.d3.select(canvas.createEl("svg"));
await this.renderGlobalGraph();
await this.loadEntityList(entityListBody);
const renderEgo = async (entity: string) => {
searchInput.value = entity;
await this.renderEgoGraph(entity, canvas, tooltip, detail, detailTitle, detailContent);
// 高亮列表中的活动实体
entityListBody.querySelectorAll(".zhiyi-entity-item").forEach(el => {
el.classList.toggle("active", (el as HTMLElement).dataset.entity === entity);
});
};
loadBtn.onclick = async () => {
if (searchInput.value.trim()) {
await this.renderEgoGraph(searchInput.value.trim(), canvas, tooltip, detail, detailTitle, detailContent);
}
if (searchInput.value.trim()) await renderEgo(searchInput.value.trim());
};
searchInput.onkeydown = async (e) => {
if (e.key === "Enter" && searchInput.value.trim()) {
await this.renderEgoGraph(searchInput.value.trim(), canvas, tooltip, detail, detailTitle, detailContent);
}
if (e.key === "Enter" && searchInput.value.trim()) await renderEgo(searchInput.value.trim());
};
topBtn.onclick = async () => {
try {
const data = await fetchPageRank(20);
const topEntity = data.pagerank?.[0]?.entity || "牧尘";
searchInput.value = topEntity;
await this.renderEgoGraph(topEntity, canvas, tooltip, detail, detailTitle, detailContent);
await renderEgo(topEntity);
} catch (_) {}
};
reloadEntitiesBtn.onclick = async () => {
await this.loadEntityList(entityListBody);
};
}
private async loadEntityList(container: HTMLElement) {
try {
const data = await fetchPageRank(30);
this.topEntities = data.pagerank || [];
container.empty();
if (this.topEntities.length === 0) {
container.createEl("div", { text: "暂无数据", attrs: { style: "padding:8px;font-size:11px;color:var(--text-muted)" } });
return;
}
for (const item of this.topEntities) {
const row = container.createEl("div", { cls: "zhiyi-entity-item", attr: { "data-entity": item.entity } });
row.createEl("span", { text: item.entity.length > 14 ? item.entity.slice(0, 14) + "…" : item.entity });
row.createEl("span", { cls: "zhiyi-entity-score", text: item.score.toFixed(2) });
row.onclick = async () => {
const toolbar = this.container.querySelector(".zhiyi-graph-toolbar") as HTMLElement;
const searchInput = toolbar?.querySelector("input") as HTMLInputElement;
if (searchInput) {
searchInput.value = item.entity;
await this.renderEgoGraph(item.entity, null as unknown as HTMLElement, null as unknown as HTMLElement, this.container.querySelector(".zhiyi-graph-detail") as HTMLElement, this.container.querySelector(".zhiyi-graph-detail-title") as HTMLElement, this.container.querySelector(".zhiyi-graph-detail-content") as HTMLElement);
}
container.querySelectorAll(".zhiyi-entity-item").forEach(el => el.classList.remove("active"));
row.classList.add("active");
};
}
} catch {
container.empty();
container.createEl("div", { text: "加载失败", attrs: { style: "padding:8px;font-size:11px;color:var(--text-muted)" } });
}
}
private async renderGlobalGraph() {
try {
const data = await fetchGraphExport("hermes-main", 80);
const ns = getRuntimeNamespace();
const data = await fetchGraphExport(ns, 80);
await this.renderD3Graph(data.nodes, data.edges, null);
} catch (_) {}
}
@ -178,18 +205,16 @@ export class GraphView extends ItemView {
fetchNeighbors(entity),
fetchByEntity(entity),
]);
// 中心节点
const central: GraphNode = { id: entity, label: entity };
const nodes = [central, ...graphData.nodes.filter(n => n.id !== entity)];
await this.renderD3Graph(nodes, graphData.edges, entity);
// 详情面板
detail.style.display = "block";
detailTitle.setText(`📌 ${entity}`);
const mems = memData.memories || [];
detailContent.innerHTML = mems.slice(0, 3).map((m: MemoryRecord) =>
`<div style="margin:2px 0">• ${this.truncate(m.content, 100)}</div>`
).join("") || "<div>无关联记忆</div>";
const mems = (memData.memories || []).slice(0, 5);
detailContent.innerHTML = mems.length > 0
? mems.map((m: MemoryRecord) => `<div style="margin:2px 0">• ${this.truncate(m.content, 80)}</div>`).join("")
: "<div>无关联记忆</div>";
} catch (e) {
const errEl = canvas.createEl("div", { text: `加载失败: ${(e as Error).message}`, cls: "zhiyi-empty" });
setTimeout(() => errEl.remove(), 3000);
@ -251,6 +276,7 @@ export class GraphView extends ItemView {
.append("path").attr("d", "M 0,-5 L 10,0 L 0,5")
.attr("fill", "#666");
// 边(带关系标签)
const link = g.append("g").selectAll("line")
.data(simLinks)
.enter().append("line")
@ -258,6 +284,18 @@ export class GraphView extends ItemView {
.attr("stroke-width", d => Math.min(2, d.weight || 1))
.attr("marker-end", "url(#arrowhead)");
// 关系标签(居中显示)
const linkLabel = g.append("g").selectAll("text")
.data(simLinks)
.enter().append("text")
.text(d => d.relation || "")
.attr("text-anchor", "middle")
.attr("dy", -3)
.attr("font-size", "8px")
.attr("fill", "#999")
.attr("pointer-events", "none")
.style("display", d => d.relation ? "block" : "none");
const node = g.append("g").selectAll("g")
.data(simNodes)
.enter().append("g")
@ -290,11 +328,24 @@ export class GraphView extends ItemView {
});
node.call(drag as unknown as (s: d3.Selection<SVGGElement, SimNode, SVGGElement, unknown>) => void);
// 点击节点 → ego 图
node.on("click", (_event, d) => {
this.renderEgoGraph(d.id, canvas.parentElement!, tooltip,
canvas.parentElement!.querySelector(".zhiyi-graph-detail") as HTMLElement,
canvas.parentElement!.querySelector(".zhiyi-graph-detail-title") as HTMLElement,
canvas.parentElement!.querySelector(".zhiyi-graph-detail-content") as HTMLElement);
if (d.id === this.currentEntity) return;
const toolbar = this.container.querySelector(".zhiyi-graph-toolbar") as HTMLElement;
const searchInput = toolbar?.querySelector("input") as HTMLInputElement;
if (searchInput) {
searchInput.value = d.id;
this.renderEgoGraph(d.id,
this.container.querySelector(".zhiyi-graph-canvas") as HTMLElement,
this.container.querySelector(".zhiyi-tooltip") as HTMLElement,
this.container.querySelector(".zhiyi-graph-detail") as HTMLElement,
this.container.querySelector(".zhiyi-graph-detail-title") as HTMLElement,
this.container.querySelector(".zhiyi-graph-detail-content") as HTMLElement);
}
const entityListBody = this.container.querySelector(".zhiyi-graph-entity-list-body") as HTMLElement;
entityListBody?.querySelectorAll(".zhiyi-entity-item").forEach(el => {
el.classList.toggle("active", (el as HTMLElement).dataset.entity === d.id);
});
});
node.on("mouseover", (event, d) => {
@ -317,6 +368,9 @@ export class GraphView extends ItemView {
.attr("y1", d => (d.source as SimNode).y!)
.attr("x2", d => (d.target as SimNode).x!)
.attr("y2", d => (d.target as SimNode).y!);
linkLabel
.attr("x", d => ((d.source as SimNode).x! + (d.target as SimNode).x!) / 2)
.attr("y", d => ((d.source as SimNode).y! + (d.target as SimNode).y!) / 2);
node.attr("transform", d => `translate(${d.x},${d.y})`);
});
}

View File

@ -1,19 +1,29 @@
import { ItemView, WorkspaceLeaf } from "obsidian";
import { fetchMemories, fetchDistillStatus, fetchDistillQuota, type MemoryRecord } from "./api";
import { ItemView, WorkspaceLeaf, Modal, TFile } from "obsidian";
import {
fetchMemories,
fetchDistillStatus,
fetchDistillQuota,
fetchCategories,
writeMemory,
getRuntimeNamespace,
type MemoryRecord,
} from "./api";
export const MEMORY_VIEW_TYPE = "zhiyi-memory-view";
const PAGE_SIZE = 30;
export class MemoryView extends ItemView {
private container: HTMLElement;
private memories: MemoryRecord[] = [];
private currentPage = 0;
private pageSize = 20;
private totalMemories = 0;
private status_interval: number | null = null;
private categories: string[] = [];
private activeCategory = "";
private expandedId: string | null = null;
constructor(leaf: WorkspaceLeaf) {
super(leaf);
this.container = this.contentEl;
}
getViewType() { return MEMORY_VIEW_TYPE; }
@ -31,7 +41,6 @@ export class MemoryView extends ItemView {
}
private startStatusPoll() {
// 轮询蒸馏状态(每 30 秒)
const id = (window as unknown as { setInterval: (fn: () => void, ms: number) => number }).setInterval(
() => this.renderDistillBanner(),
30000
@ -50,97 +59,279 @@ export class MemoryView extends ItemView {
} else {
banner.style.display = "none";
}
} catch (_) {
// ignore
} catch (_) {}
}
private async loadCategories() {
try {
this.categories = await fetchCategories();
} catch {
this.categories = [];
}
}
async render() {
this.container.empty();
this.container.createEl("style", {
text: `
.zhiyi-memory-view { height: 100%; display: flex; flex-direction: column; font-size: 13px; }
.zhiyi-header { padding: 10px 12px 6px; border-bottom: 1px solid var(--background-modifier-border); }
.zhiyi-header h1 { font-size: 14px; font-weight: 600; margin: 0 0 4px; }
.zhiyi-stats { color: var(--text-muted); font-size: 11px; margin: 0; }
.zhiyi-distill-banner {
display: none; background: #c44; color: white; padding: 4px 12px;
font-size: 12px; font-weight: 600; cursor: pointer;
}
.zhiyi-body { flex: 1; overflow-y: auto; }
.zhiyi-item {
padding: 8px 12px; border-bottom: 1px solid var(--background-modifier-border);
cursor: pointer;
}
.zhiyi-item:hover { background: var(--background-modifier-hover); }
.zhiyi-item-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 2px; }
.zhiyi-item-cat { font-size: 10px; color: var(--text-muted); background: var(--background-secondary); padding: 1px 4px; border-radius: 2px; }
.zhiyi-item-score { font-size: 10px; color: var(--text-muted); }
.zhiyi-item-content { font-size: 12px; color: var(--text-normal); line-height: 1.4; }
.zhiyi-item-time { font-size: 10px; color: var(--text-muted); margin-top: 2px; }
.zhiyi-item-id { font-size: 10px; color: var(--text-faint); font-family: monospace; margin-top: 2px; overflow: hidden; text-overflow: ellipsis; }
.zhiyi-pagination { display: flex; gap: 6px; padding: 8px 12px; border-top: 1px solid var(--background-modifier-border); }
.zhiyi-pagination button { flex: 1; padding: 4px; background: var(--background-secondary); border: 1px solid var(--background-modifier-border); border-radius: 4px; cursor: pointer; font-size: 11px; }
.zhiyi-pagination button:disabled { opacity: 0.4; cursor: not-allowed; }
.zhiyi-loading { padding: 20px; text-align: center; color: var(--text-muted); }
.zhiyi-empty { padding: 20px; text-align: center; color: var(--text-muted); }
`
});
this.container.createEl("div", { cls: "zhiyi-memory-view" }).append(
this.container.createEl("style", { text: `
.zhiyi-memory-view { height: 100%; display: flex; flex-direction: column; font-size: 13px; }
.zhiyi-header { padding: 8px 12px 4px; border-bottom: 1px solid var(--background-modifier-border); }
.zhiyi-header-row1 { display: flex; align-items: center; gap: 6px; }
.zhiyi-header h1 { font-size: 14px; font-weight: 600; margin: 0; flex: 1; }
.zhiyi-stats { color: var(--text-muted); font-size: 11px; margin: 3px 0 0; }
.zhiyi-toolbar { display: flex; gap: 4px; padding: 4px 12px; border-bottom: 1px solid var(--background-modifier-border); flex-wrap: wrap; align-items: center; }
.zhiyi-toolbar select { background: var(--background-secondary); border: 1px solid var(--background-modifier-border); border-radius: 4px; color: var(--text-normal); font-size: 11px; padding: 2px 4px; max-width: 120px; }
.zhiyi-filter-chip { padding: 2px 8px; border-radius: 10px; font-size: 10px; border: 1px solid var(--background-modifier-border); background: var(--background-secondary); color: var(--text-muted); cursor: pointer; white-space: nowrap; }
.zhiyi-filter-chip:hover { background: var(--background-modifier-hover); }
.zhiyi-filter-chip.active { background: var(--text-accent); color: var(--bg-on-accent, #fff); border-color: var(--text-accent); }
.zhiyi-btn-icon { padding: 2px 6px; background: var(--background-secondary); border: 1px solid var(--background-modifier-border); border-radius: 4px; cursor: pointer; color: var(--text-muted); font-size: 11px; }
.zhiyi-btn-icon:hover { background: var(--background-modifier-hover); color: var(--text-normal); }
.zhiyi-distill-banner { display: none; background: #c44; color: white; padding: 4px 12px; font-size: 12px; font-weight: 600; cursor: pointer; }
.zhiyi-body { flex: 1; overflow-y: auto; }
.zhiyi-item { padding: 8px 12px; border-bottom: 1px solid var(--background-modifier-border); cursor: pointer; }
.zhiyi-item:hover { background: var(--background-modifier-hover); }
.zhiyi-item-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 3px; }
.zhiyi-item-cat { font-size: 10px; color: var(--text-muted); background: var(--background-secondary); padding: 1px 5px; border-radius: 2px; }
.zhiyi-item-right { display: flex; align-items: center; gap: 6px; }
.zhiyi-quality-bar { width: 40px; height: 4px; background: var(--background-modifier-border); border-radius: 2px; overflow: hidden; }
.zhiyi-quality-fill { height: 100%; border-radius: 2px; }
.zhiyi-item-content { font-size: 12px; color: var(--text-normal); line-height: 1.4; }
.zhiyi-item-footer { display: flex; justify-content: space-between; align-items: center; margin-top: 3px; }
.zhiyi-item-time { font-size: 10px; color: var(--text-muted); }
.zhiyi-item-meta { font-size: 10px; color: var(--text-faint); font-family: monospace; }
.zhiyi-item-actions { display: flex; gap: 6px; opacity: 0; transition: opacity 0.15s; }
.zhiyi-item:hover .zhiyi-item-actions { opacity: 1; }
.zhiyi-action-btn { font-size: 10px; color: var(--text-accent); cursor: pointer; background: none; border: none; padding: 0; }
.zhiyi-action-btn:hover { text-decoration: underline; }
.zhiyi-pagination { display: flex; gap: 6px; padding: 8px 12px; border-top: 1px solid var(--background-modifier-border); align-items: center; }
.zhiyi-pagination button { padding: 4px 10px; background: var(--background-secondary); border: 1px solid var(--background-modifier-border); border-radius: 4px; cursor: pointer; font-size: 11px; }
.zhiyi-pagination button:disabled { opacity: 0.4; cursor: not-allowed; }
.zhiyi-pagination .page-info { flex: 1; text-align: center; font-size: 11px; color: var(--text-muted); }
.zhiyi-loading { padding: 30px; text-align: center; color: var(--text-muted); }
.zhiyi-empty { padding: 30px; text-align: center; color: var(--text-muted); }
/* Modal */
.zhiyi-detail-modal { position: fixed; inset: 0; background: rgba(0,0,0,0.5); z-index: 1000; display: flex; align-items: center; justify-content: center; }
.zhiyi-detail-box { background: var(--modal-background); border: 1px solid var(--background-modifier-border); border-radius: 8px; max-width: 600px; width: 90%; max-height: 80vh; display: flex; flex-direction: column; box-shadow: 0 8px 32px rgba(0,0,0,0.4); }
.zhiyi-detail-header { display: flex; justify-content: space-between; align-items: center; padding: 12px 16px; border-bottom: 1px solid var(--background-modifier-border); }
.zhiyi-detail-header h3 { margin: 0; font-size: 14px; font-weight: 600; }
.zhiyi-detail-close { background: none; border: none; cursor: pointer; color: var(--text-muted); font-size: 18px; padding: 0; }
.zhiyi-detail-body { flex: 1; overflow-y: auto; padding: 16px; }
.zhiyi-detail-meta { display: flex; gap: 12px; margin-bottom: 12px; flex-wrap: wrap; }
.zhiyi-detail-meta span { font-size: 11px; color: var(--text-muted); background: var(--background-secondary); padding: 2px 6px; border-radius: 3px; }
.zhiyi-detail-content { font-size: 13px; line-height: 1.6; color: var(--text-normal); white-space: pre-wrap; word-break: break-word; }
.zhiyi-detail-footer { padding: 10px 16px; border-top: 1px solid var(--background-modifier-border); display: flex; gap: 8px; justify-content: flex-end; }
.zhiyi-detail-footer button { padding: 4px 12px; border-radius: 4px; cursor: pointer; font-size: 12px; }
/* Write dialog */
.zhiyi-write-section { padding: 8px 12px; border-bottom: 1px solid var(--background-modifier-border); }
.zhiyi-write-input { width: 100%; background: var(--background-secondary); border: 1px solid var(--background-modifier-border); border-radius: 4px; color: var(--text-normal); padding: 6px 8px; font-size: 12px; resize: vertical; min-height: 50px; box-sizing: border-box; }
.zhiyi-write-input:focus { outline: none; border-color: var(--text-accent); }
.zhiyi-write-meta { display: flex; gap: 6px; margin-top: 4px; align-items: center; }
.zhiyi-write-meta select { background: var(--background-secondary); border: 1px solid var(--background-modifier-border); border-radius: 4px; color: var(--text-normal); font-size: 11px; padding: 2px 4px; }
.zhiyi-write-meta button { padding: 3px 10px; background: var(--text-accent); color: var(--bg-on-accent, #fff); border: none; border-radius: 4px; cursor: pointer; font-size: 11px; }
.zhiyi-write-meta button:disabled { opacity: 0.5; cursor: not-allowed; }
.zhiyi-write-hint { font-size: 10px; color: var(--text-faint); margin-left: auto; }
` });
const root = this.container.createEl("div", { cls: "zhiyi-memory-view" });
root.append(
this.container.createEl("div", { cls: "zhiyi-distill-banner" }),
this.container.createEl("div", { cls: "zhiyi-header" }).append(
this.container.createEl("h1", { text: "🧠 织忆记忆" }),
Object.assign(this.container.createEl("div", { cls: "zhiyi-header-row1" }), {
innerHTML: `<h1>🧠 织忆记忆</h1>`
}) as unknown as Node,
this.container.createEl("p", { cls: "zhiyi-stats", text: "加载中..." })
),
this.container.createEl("div", { cls: "zhiyi-toolbar" }),
this.container.createEl("div", { cls: "zhiyi-body" }),
this.container.createEl("div", { cls: "zhiyi-pagination" })
);
await this.loadCategories();
await this.loadMemories();
await this.renderDistillBanner();
}
async loadMemories() {
private async loadMemories() {
const body = this.container.querySelector(".zhiyi-body") as HTMLElement;
const stats = this.container.querySelector(".zhiyi-stats") as HTMLElement;
const toolbar = this.container.querySelector(".zhiyi-toolbar") as HTMLElement;
body.innerHTML = '<div class="zhiyi-loading">加载中…</div>';
try {
const data = await fetchMemories({ namespace: "hermes-main", limit: this.pageSize, offset: this.currentPage * this.pageSize });
const ns = getRuntimeNamespace();
const data = await fetchMemories({
namespace: ns,
limit: PAGE_SIZE,
offset: this.currentPage * PAGE_SIZE,
category: this.activeCategory || undefined,
});
this.memories = data.memories || [];
this.totalMemories = data.total || 0;
stats.setText(`${this.totalMemories} 条记忆 · 第 ${this.currentPage + 1}`);
stats.setText(`${this.totalMemories} 条记忆 · 第 ${this.currentPage + 1} 页 · ${ns}`);
this.renderToolbar(toolbar);
this.renderList();
} catch (e) {
body.innerHTML = `<div class="zhiyi-empty">加载失败: ${(e as Error).message}</div>`;
}
}
private renderToolbar(toolbar: HTMLElement) {
toolbar.empty();
// Namespace 切换
const nsSelect = toolbar.createEl("select");
nsSelect.createEl("option", { value: "hermes-main", text: "hermes-main" });
nsSelect.createEl("option", { value: "openclaw", text: "openclaw" });
nsSelect.value = getRuntimeNamespace();
nsSelect.onchange = () => {
const val = nsSelect.value;
(window as unknown as Record<string, string>)["zhiyi_namespace"] = val;
this.currentPage = 0;
this.loadMemories();
};
// 分类筛选 chip
const allChip = toolbar.createEl("span", { cls: "zhiyi-filter-chip" + (this.activeCategory === "" ? " active" : ""), text: "全部" });
allChip.onclick = () => { this.activeCategory = ""; this.currentPage = 0; this.loadMemories(); };
for (const cat of this.categories.slice(0, 8)) {
const chip = toolbar.createEl("span", {
cls: "zhiyi-filter-chip" + (this.activeCategory === cat ? " active" : ""),
text: cat,
});
chip.onclick = () => { this.activeCategory = cat; this.currentPage = 0; this.loadMemories(); };
}
// 刷新
const refreshBtn = toolbar.createEl("button", { cls: "zhiyi-btn-icon", text: "↻ 刷新" });
refreshBtn.onclick = () => { this.currentPage = 0; this.loadMemories(); };
// 写记忆
const writeBtn = toolbar.createEl("button", { cls: "zhiyi-btn-icon", text: "+ 写记忆" });
writeBtn.onclick = () => this.showWriteSection(toolbar);
}
private showWriteSection(toolbar: HTMLElement) {
// 避免重复
const existing = toolbar.querySelector(".zhiyi-write-section");
if (existing) { existing.remove(); return; }
const section = toolbar.createEl("div", { cls: "zhiyi-write-section" });
const textarea = section.createEl("textarea", {
cls: "zhiyi-write-input",
attr: { placeholder: "输入记忆内容,按 Enter 或点击「写入」提交…" }
}) as HTMLTextAreaElement;
const metaRow = section.createEl("div", { cls: "zhiyi-write-meta" });
const catSel = metaRow.createEl("select");
catSel.createEl("option", { value: "normal", text: "分类" });
for (const c of this.categories) {
catSel.createEl("option", { value: c, text: c });
}
const hint = metaRow.createEl("span", { cls: "zhiyi-write-hint", text: "Enter 写入 · Esc 取消" });
const submitBtn = metaRow.createEl("button", { text: "写入" });
const doWrite = async () => {
const content = textarea.value.trim();
if (!content) return;
submitBtn.setAttr("disabled", "true");
try {
await writeMemory(content, catSel.value || "normal");
textarea.value = "";
section.remove();
this.currentPage = 0;
await this.loadCategories();
await this.loadMemories();
} catch (e) {
hint.setText(`失败: ${(e as Error).message}`);
}
submitBtn.removeAttr("disabled");
};
submitBtn.onclick = doWrite;
textarea.onkeydown = (e) => {
if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); doWrite(); }
if (e.key === "Escape") section.remove();
};
textarea.focus();
}
private renderList() {
const body = this.container.querySelector(".zhiyi-body") as HTMLElement;
const pag = this.container.querySelector(".zhiyi-pagination") as HTMLElement;
body.empty();
if (this.memories.length === 0) {
body.createEl("div", { cls: "zhiyi-empty", text: "暂无记忆" });
}
for (const mem of this.memories) {
const item = body.createEl("div", { cls: "zhiyi-item" });
const header = item.createEl("div", { cls: "zhiyi-item-header" });
header.createEl("span", { cls: "zhiyi-item-cat", text: mem.category || "normal" });
header.createEl("span", { cls: "zhiyi-item-score", text: `score: ${(mem.quality_score ?? 0).toFixed(2)}` });
item.createEl("div", { cls: "zhiyi-item-content", text: this.truncate(mem.content, 120) });
item.createEl("div", { cls: "zhiyi-item-time", text: this.formatTime(mem.created_at) });
item.createEl("div", { cls: "zhiyi-item-id", text: mem.id });
const right = header.createEl("div", { cls: "zhiyi-item-right" });
// 质量分条
const q = mem.quality_score ?? 0;
const qColor = q > 0.7 ? "#34c759" : q > 0.4 ? "#f6c945" : "#ff6b6b";
const barWrap = right.createEl("div", { cls: "zhiyi-quality-bar" });
const barFill = barWrap.createEl("div", { cls: "zhiyi-quality-fill" });
barFill.style.width = `${Math.round(q * 100)}%`;
barFill.style.background = qColor;
right.createEl("span", { cls: "zhiyi-item-score", text: (q * 100).toFixed(0) + "%" });
item.createEl("div", { cls: "zhiyi-item-content", text: this.truncate(mem.content, 160) });
const footer = item.createEl("div", { cls: "zhiyi-item-footer" });
footer.createEl("span", { cls: "zhiyi-item-time", text: this.formatTime(mem.created_at) });
const meta = footer.createEl("span", { cls: "zhiyi-item-meta", text: mem.id.slice(0, 16) });
const actions = footer.createEl("div", { cls: "zhiyi-item-actions" });
actions.createEl("button", { cls: "zhiyi-action-btn", text: "展开" }).onclick = (e) => {
e.stopPropagation();
this.showDetail(mem);
};
actions.createEl("button", { cls: "zhiyi-action-btn", text: "复制" }).onclick = (e) => {
e.stopPropagation();
navigator.clipboard.writeText(mem.content).catch(() => {});
};
item.onclick = () => this.showDetail(mem);
}
const totalPages = Math.ceil(this.totalMemories / this.pageSize);
const totalPages = Math.ceil(this.totalMemories / PAGE_SIZE) || 1;
pag.empty();
if (totalPages > 1) {
const prev = pag.createEl("button", { text: "◀ 上一页" });
prev.setAttr("disabled", this.currentPage === 0 ? "true" : "");
prev.onclick = () => { this.currentPage--; this.loadMemories(); };
pag.createEl("span", { text: `${this.currentPage + 1}/${totalPages}`, cls: "zhiyi-item-cat" });
const next = pag.createEl("button", { text: "下一页 ▶" });
next.setAttr("disabled", this.currentPage >= totalPages - 1 ? "true" : "");
next.onclick = () => { this.currentPage++; this.loadMemories(); };
}
const prev = pag.createEl("button", { text: "◀" });
prev.setAttr("disabled", this.currentPage === 0 ? "true" : "");
prev.onclick = () => { this.currentPage--; this.loadMemories(); };
pag.createEl("span", { cls: "page-info", text: `${this.currentPage + 1} / ${totalPages}` });
const next = pag.createEl("button", { text: "▶" });
next.setAttr("disabled", this.currentPage >= totalPages - 1 ? "true" : "");
next.onclick = () => { this.currentPage++; this.loadMemories(); };
}
private showDetail(mem: MemoryRecord) {
const modal = this.container.createEl("div", { cls: "zhiyi-detail-modal" });
const box = modal.createEl("div", { cls: "zhiyi-detail-box" });
const header = box.createEl("div", { cls: "zhiyi-detail-header" });
header.createEl("h3", { text: "记忆详情" });
const closeBtn = header.createEl("button", { cls: "zhiyi-detail-close", text: "×" });
closeBtn.onclick = () => modal.remove();
modal.onclick = (e) => { if (e.target === modal) modal.remove(); };
const body = box.createEl("div", { cls: "zhiyi-detail-body" });
const meta = body.createEl("div", { cls: "zhiyi-detail-meta" });
meta.createEl("span", { text: `分类: ${mem.category || "normal"}` });
meta.createEl("span", { text: `质量: ${((mem.quality_score ?? 0) * 100).toFixed(1)}%` });
meta.createEl("span", { text: `Namespace: ${mem.namespace}` });
meta.createEl("span", { text: `时间: ${this.formatTime(mem.created_at)}` });
if (mem.recall_count !== undefined) meta.createEl("span", { text: `召回: ${mem.recall_count}` });
body.createEl("div", { cls: "zhiyi-detail-content", text: mem.content });
const footer = box.createEl("div", { cls: "zhiyi-detail-footer" });
footer.createEl("button", { text: "复制内容", attr: { style: "background:var(--background-secondary);border:1px solid var(--background-modifier-border);color:var(--text-normal)" } }).onclick = () => {
navigator.clipboard.writeText(mem.content).catch(() => {});
};
footer.createEl("button", { text: "关闭", attr: { style: "background:var(--text-accent);border:none;color:var(--bg-on-accent,#fff)" } }).onclick = () => modal.remove();
}
private truncate(s: string, max: number) {

View File

@ -1,10 +1,30 @@
// ─── API 客户端(调用织忆 Go API─────────────────────────────────────────────
const API_BASE = "http://localhost:7821";
const API_KEY = "zhiyi-dev-key-2026";
// 运行时配置覆盖(由设置页写入)
interface RuntimeConfig {
apiUrl?: string;
apiKey?: string;
namespace?: string;
}
const cfg: RuntimeConfig = {};
export function getApiBase() {
return (cfg.apiUrl || API_BASE).replace(/\/$/, "");
}
export function getApiKey() {
return cfg.apiKey || API_KEY;
}
export function setRuntimeConfig(url: string, key: string, ns: string) {
cfg.apiUrl = url;
cfg.apiKey = key;
cfg.namespace = ns;
}
export function getRuntimeNamespace() {
return cfg.namespace || "hermes-main";
}
interface ApiOptions {
method?: string;
body?: unknown;
@ -16,7 +36,7 @@ async function apiCall<T = unknown>(
): Promise<T> {
const method = opts.method ?? "GET";
const headers: Record<string, string> = {
"X-API-Key": API_KEY,
"X-API-Key": getApiKey(),
"Content-Type": "application/json",
};
@ -25,12 +45,11 @@ async function apiCall<T = unknown>(
init.body = JSON.stringify(opts.body);
}
const res = await fetch(`${API_BASE}${path}`, init);
const res = await fetch(`${getApiBase()}${path}`, init);
if (!res.ok) {
throw new Error(`API ${path} failed: ${res.status} ${res.statusText}`);
}
// 有些端点返回空 body
const text = await res.text();
if (!text) return {} as T;
return JSON.parse(text) as T;
@ -46,6 +65,9 @@ export interface MemoryRecord {
quality_score: number;
created_at: string;
agent_id?: string;
importance?: number;
recall_count?: number;
tier?: string;
}
export interface GraphNode {
@ -69,7 +91,7 @@ export interface GraphNavigateResult {
}
export interface RecallResult {
memories: Array<{ id: string; content: string; score: number; category: string }>;
memories: Array<{ id: string; content: string; score: number; category: string; created_at: string }>;
}
export interface DistillStatus {
@ -99,19 +121,22 @@ export async function fetchMemories(opts: {
namespace?: string;
limit?: number;
offset?: number;
category?: string;
}): Promise<{ memories: MemoryRecord[]; total: number }> {
const params = new URLSearchParams();
if (opts.namespace) params.set("namespace", opts.namespace);
const ns = opts.namespace || getRuntimeNamespace();
if (ns) params.set("namespace", ns);
if (opts.limit) params.set("limit", String(opts.limit));
if (opts.offset !== undefined) params.set("offset", String(opts.offset));
if (opts.category) params.set("category", opts.category);
const qs = params.toString();
return apiCall(`/api/v1/memories?${qs}`);
}
export async function searchMemories(query: string, namespace = "hermes-main") {
export async function searchMemories(query: string, namespace?: string) {
return apiCall<{ memories: RecallResult["memories"] }>("/api/v1/search/recall", {
method: "POST",
body: { query, namespace, top_k: 20 },
body: { query, namespace: namespace || getRuntimeNamespace(), top_k: 20 },
});
}
@ -119,9 +144,9 @@ export async function fetchNeighbors(entity: string) {
return apiCall<GraphNavigateResult>(`/api/v1/graph/navigate?entity=${encodeURIComponent(entity)}&depth=1`);
}
export async function fetchGraphExport(namespace = "hermes-main", limit = 100) {
export async function fetchGraphExport(namespace?: string, limit = 100) {
return apiCall<{ nodes: GraphNode[]; edges: GraphEdge[] }>(
`/api/v1/graph/export?namespace=${namespace}&limit=${limit}`
`/api/v1/graph/export?namespace=${namespace || getRuntimeNamespace()}&limit=${limit}`
);
}
@ -143,4 +168,37 @@ export async function fetchByEntity(entity: string) {
return apiCall<{ memories: MemoryRecord[] }>(
`/api/v1/memories/by-entity/${encodeURIComponent(entity)}`
);
}
// ─── 写入记忆 ──────────────────────────────────────────────────────────────
export async function writeMemory(
content: string,
category = "normal",
namespace?: string
): Promise<{ id: string }> {
return apiCall<{ id: string }>("/api/v1/memories", {
method: "POST",
body: {
content,
category,
namespace: namespace || getRuntimeNamespace(),
source: "obsidian-plugin",
},
});
}
// ─── 获取所有 category从记忆列表中提取 ──────────────────────────────────
export async function fetchCategories(): Promise<string[]> {
try {
const data = await fetchMemories({ limit: 500 });
const cats = new Set<string>();
for (const m of data.memories) {
if (m.category) cats.add(m.category);
}
return Array.from(cats).sort();
} catch {
return [];
}
}