E5.1 织忆插件 v1.1: 记忆展开详情+分类过滤+质量分条+写记忆入口+设置持久化+图谱边标签+实体列表侧边栏
This commit is contained in:
parent
8d62d35ae7
commit
2d6981d4fb
File diff suppressed because one or more lines are too long
|
|
@ -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 → 织忆 可快速搜索记忆。选中文字后右键或命令面板选择「将选中内容写入织忆」可保存笔记。");
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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})`);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 [];
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue