mirror of
https://github.com/memohai/Memoh.git
synced 2026-04-25 07:00:48 +09:00
feat(memory): add Nowledge Mem provider integration
Add a new memory provider that delegates to a local Nowledge Mem instance for memory storage, retrieval, and knowledge graph building. Key design decisions: - User messages tagged as [DisplayName], bot messages as [我] - Per-message display name parsed from YAML front-matter headers - Let Nowledge Mem handle entity extraction and graph building - 6-way hybrid search (semantic + full-text + entity + community + label + graph) New files: - internal/memory/adapters/nowledgemem/client.go (REST API client) - internal/memory/adapters/nowledgemem/nowledgemem.go (Provider impl) - docs/nowledge-mem.md (design document with research and decisions) Modified: types.go, service.go, serve.go (provider registration), frontend (add-memory-provider.vue, types.gen.ts, i18n locales)
This commit is contained in:
@@ -382,7 +382,8 @@
|
||||
"providerNames": {
|
||||
"builtin": "Built-in",
|
||||
"mem0": "Mem0",
|
||||
"openviking": "OpenViking"
|
||||
"openviking": "OpenViking",
|
||||
"nowledgemem": "Nowledge Mem"
|
||||
}
|
||||
},
|
||||
"speech": {
|
||||
|
||||
@@ -378,7 +378,8 @@
|
||||
"providerNames": {
|
||||
"builtin": "内置",
|
||||
"mem0": "Mem0",
|
||||
"openviking": "OpenViking"
|
||||
"openviking": "OpenViking",
|
||||
"nowledgemem": "Nowledge Mem"
|
||||
}
|
||||
},
|
||||
"speech": {
|
||||
|
||||
@@ -40,6 +40,9 @@
|
||||
<SelectItem value="openviking">
|
||||
{{ $t('memory.providerNames.openviking') }}
|
||||
</SelectItem>
|
||||
<SelectItem value="nowledgemem">
|
||||
{{ $t('memory.providerNames.nowledgemem') }}
|
||||
</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
@@ -68,6 +68,7 @@ import (
|
||||
membuiltin "github.com/memohai/memoh/internal/memory/adapters/builtin"
|
||||
memmem0 "github.com/memohai/memoh/internal/memory/adapters/mem0"
|
||||
memopenviking "github.com/memohai/memoh/internal/memory/adapters/openviking"
|
||||
memnowledgemem "github.com/memohai/memoh/internal/memory/adapters/nowledgemem"
|
||||
"github.com/memohai/memoh/internal/memory/memllm"
|
||||
storefs "github.com/memohai/memoh/internal/memory/storefs"
|
||||
"github.com/memohai/memoh/internal/message"
|
||||
@@ -279,6 +280,9 @@ func provideMemoryProviderRegistry(log *slog.Logger, llm memprovider.LLM, chatSe
|
||||
registry.RegisterFactory(string(memprovider.ProviderOpenViking), func(_ string, config map[string]any) (memprovider.Provider, error) {
|
||||
return memopenviking.NewOpenVikingProvider(log, config)
|
||||
})
|
||||
registry.RegisterFactory(string(memprovider.ProviderNowledgeMem), func(_ string, config map[string]any) (memprovider.Provider, error) {
|
||||
return memnowledgemem.NewNowledgeMemProvider(log, config)
|
||||
})
|
||||
defaultProvider := membuiltin.NewBuiltinProvider(log, builtinRuntime, chatService, accountService)
|
||||
defaultProvider.SetLLM(llm)
|
||||
registry.Register("__builtin_default__", defaultProvider)
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
# Nowledge Mem 集成设计文档
|
||||
|
||||
## 背景
|
||||
|
||||
Memoh 内置的 builtin memory provider 效果不佳。Nowledge Mem 是一个本地优先的 AI 记忆管理器,具备知识图谱、6 路混合搜索、后台智能(实体抽取、EVOLVES 关系检测、Crystal 合成、衰减评分)等能力,作为替代方案集成到 Memoh 的 memory provider 体系中。
|
||||
|
||||
## 调研:Nowledge Mem 能力概览
|
||||
|
||||
### 产品定位
|
||||
|
||||
- 本地优先的个人知识库,数据默认存储在用户设备
|
||||
- 免费,支持 macOS / Windows / Linux
|
||||
- 为 AI 工具(Claude Code、Cursor 等)提供统一的持久化记忆层
|
||||
|
||||
### 核心能力
|
||||
|
||||
| 能力 | 说明 |
|
||||
|---|---|
|
||||
| 记忆管理 | CRUD + 标签组织 + 重要性评分 |
|
||||
| 语义搜索 | 6 路混合:semantic + full-text + entity + community + label + graph traversal |
|
||||
| 知识图谱 | 自动实体抽取(Person/Concept/...)、关系可视化、遍历深度控制 |
|
||||
| 蒸馏 (Distill) | 从对话线程中提取关键记忆 |
|
||||
| Crystal 合成 | 三条以上独立记忆汇聚同一主题时自动合成统一摘要 |
|
||||
| Decay + Confidence | 双分评分系统:新鲜度(指数衰减 + 频率提升)+ 可信度(只增不减) |
|
||||
| Working Memory | 每日自动生成 daily briefing |
|
||||
| 后台智能 | 定时任务:crystallization、community detection、KG extraction、decay refresh |
|
||||
|
||||
### 接入方式
|
||||
|
||||
- **REST API**:`http://127.0.0.1:14242`,无认证
|
||||
- **MCP Server**:`http://127.0.0.1:14242/mcp`(streamableHttp)
|
||||
- **CLI**:`nmem` 命令行工具
|
||||
|
||||
## 方案选择
|
||||
|
||||
### 排除的方案
|
||||
|
||||
- **方案 B(双向同步)**:Memoh 和 Nowledge Mem 各自有完整的记忆 pipeline,双向同步没有意义
|
||||
- **方案 C(MCP 接入)**:不够原生,Agent 不会主动调用 MCP 工具来记忆
|
||||
|
||||
### 最终方案:方案 A — 作为 Memory Provider
|
||||
|
||||
将 Nowledge Mem 作为 Memoh 的 memory provider 实现,通过 `adapters.Provider` 接口接入:
|
||||
|
||||
- `OnBeforeChat`:搜索相关记忆注入上下文
|
||||
- `OnAfterChat`:将对话写入 Nowledge Mem,由其 LLM 做实体抽取和知识图谱构建
|
||||
- 搜索、存储、CRUD 全部代理到 Nowledge Mem REST API
|
||||
|
||||
### 为什么不用方案 D(线程归档)
|
||||
|
||||
方案 D 只是事后归档,对 agent 在线对话质量无直接帮助。可作为 A 的补充,不做替代。
|
||||
|
||||
## 设计决策
|
||||
|
||||
### 多用户/群聊适配
|
||||
|
||||
Nowledge Mem 是单用户个人知识库,没有 `user_id` 或 `group_id` 概念。但在多用户维度上,现有的 Mem0 和 OpenViking provider 同样没做隔离(都只按 `bot_id` scope)。
|
||||
|
||||
关键改进:**在存储文本中嵌入结构化的发言人标识**,让 Nowledge Mem 的实体抽取和全文搜索能区分不同人。
|
||||
|
||||
### 存储文本格式
|
||||
|
||||
每条记忆对应一轮对话(用户消息 + bot 回复):
|
||||
|
||||
```
|
||||
[张三] 我最近在用 Rust 重写后端
|
||||
[我] 很好的选择,Rust 的性能和安全性都很出色
|
||||
```
|
||||
|
||||
- **用户消息**:`[{display-name}] {消息内容}`
|
||||
- display-name 从 YAML front-matter header 中解析(Memoh 的 user_header.go 在每条用户消息中嵌入了 `display-name` 字段)
|
||||
- 回退链:YAML header → AfterChatRequest.DisplayName → `"用户"`
|
||||
- **Bot 消息**:`[我] {消息内容}`
|
||||
- LLM 读到 `[我]` 时自然理解为"这是我之前说过的话"
|
||||
- `我` 不是命名实体,不会在知识图谱中产生误导节点
|
||||
|
||||
### 为什么不用 `(@username)` 双标识
|
||||
|
||||
AfterChatRequest 中只有 `DisplayName`(人类可读名)和内部 UUID(UserID、ChannelIdentityID),没有平台级 username。YAML header 中也只有 `display-name` 和 `channel-identity-id`(内部 ID)。因此只使用 display-name。
|
||||
|
||||
### 查询侧
|
||||
|
||||
直接使用用户消息原文查询 `POST /memories/search`。Nowledge Mem 的 6 路搜索会自动处理:
|
||||
- 语义搜索命中话题相关记忆
|
||||
- 全文搜索命中包含人名的记忆
|
||||
- 实体搜索命中 Person 实体关联的记忆
|
||||
|
||||
不需要在查询时拼接发言人信息。
|
||||
|
||||
### 上下文注入格式
|
||||
|
||||
与现有 provider 一致的 `<memory-context>` XML 格式:
|
||||
|
||||
```xml
|
||||
<memory-context>
|
||||
Relevant memory context (use when helpful):
|
||||
- [2025-01-15] [张三] 喜欢用 Rust,推荐过《The Rust Programming Language》
|
||||
- [2025-01-10] [李四] 对 Rust 感兴趣但还没开始学习
|
||||
</memory-context>
|
||||
```
|
||||
|
||||
## 实现
|
||||
|
||||
### 新建文件
|
||||
|
||||
- `internal/memory/adapters/nowledgemem/client.go` — HTTP 客户端
|
||||
- `internal/memory/adapters/nowledgemem/nowledgemem.go` — Provider 实现
|
||||
|
||||
### 修改文件
|
||||
|
||||
- `internal/memory/adapters/types.go` — 添加 `ProviderNowledgeMem` 常量
|
||||
- `internal/memory/adapters/service.go` — 验证 + 元数据
|
||||
- `cmd/memoh/serve.go` — 注册工厂
|
||||
- `apps/web/src/pages/memory/components/add-memory-provider.vue` — UI 下拉选项
|
||||
- `packages/sdk/src/types.gen.ts` — TypeScript 类型
|
||||
- `apps/web/src/i18n/locales/en.json` / `zh.json` — 国际化
|
||||
|
||||
### 配置
|
||||
|
||||
创建 provider 时只需 `base_url`(可选,默认 `http://127.0.0.1:14242`):
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "nmem",
|
||||
"provider": "nowledgemem",
|
||||
"config": {}
|
||||
}
|
||||
```
|
||||
|
||||
## 局限性
|
||||
|
||||
1. **本地依赖**:Nowledge Mem 必须与 Memoh 在同一台机器上运行
|
||||
2. **无 GetAll**:Nowledge Mem API 不提供列出所有记忆的端点(带分页的 GET /memories 不含语义排序),GetAll 返回 unsupported error
|
||||
3. **无 Compact**:记忆整理由 Nowledge Mem 后台智能自动处理(decay refresh、crystallization),不暴露给 Memoh
|
||||
4. **display-name 可变**:用户改名后,旧记忆中的名字不会更新。但 Nowledge Mem 的 Entity aliases 机制可能缓解此问题
|
||||
@@ -0,0 +1,172 @@
|
||||
package nowledgemem
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
adapters "github.com/memohai/memoh/internal/memory/adapters"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultBaseURL = "http://127.0.0.1:14242"
|
||||
clientTimeout = 30 * time.Second
|
||||
)
|
||||
|
||||
type nmemClient struct {
|
||||
baseURL string
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
func newNmemClient(config map[string]any) (*nmemClient, error) {
|
||||
baseURL := adapters.StringFromConfig(config, "base_url")
|
||||
if baseURL == "" {
|
||||
baseURL = defaultBaseURL
|
||||
}
|
||||
baseURL = strings.TrimRight(baseURL, "/")
|
||||
return &nmemClient{
|
||||
baseURL: baseURL,
|
||||
httpClient: &http.Client{
|
||||
Timeout: clientTimeout,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// --- Request/Response types ---
|
||||
|
||||
type nmemMemoryCreateRequest struct {
|
||||
Content string `json:"content"`
|
||||
Source string `json:"source,omitempty"`
|
||||
}
|
||||
|
||||
type nmemMemoryUpdateRequest struct {
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
type nmemSearchRequest struct {
|
||||
Query string `json:"query"`
|
||||
Limit int `json:"limit,omitempty"`
|
||||
IncludeEntities bool `json:"include_entities,omitempty"`
|
||||
}
|
||||
|
||||
type nmemMemory struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title,omitempty"`
|
||||
Content string `json:"content"`
|
||||
Source string `json:"source,omitempty"`
|
||||
Time string `json:"time,omitempty"`
|
||||
Confidence *float64 `json:"confidence,omitempty"`
|
||||
Metadata map[string]any `json:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
type nmemSearchResult struct {
|
||||
Memory nmemMemory `json:"memory"`
|
||||
SimilarityScore float64 `json:"similarity_score"`
|
||||
RelevanceReason string `json:"relevance_reason,omitempty"`
|
||||
}
|
||||
|
||||
type nmemSearchResponse struct {
|
||||
Results []nmemSearchResult `json:"results"`
|
||||
}
|
||||
|
||||
type nmemDeleteResponse struct {
|
||||
Message string `json:"message,omitempty"`
|
||||
DeletedRelationships int `json:"deleted_relationships,omitempty"`
|
||||
DeletedEntities int `json:"deleted_entities,omitempty"`
|
||||
}
|
||||
|
||||
// --- Client methods ---
|
||||
|
||||
func (c *nmemClient) addMemory(ctx context.Context, content, source string) (*nmemMemory, error) {
|
||||
var result nmemMemory
|
||||
if err := c.doJSON(ctx, http.MethodPost, "/memories", nmemMemoryCreateRequest{
|
||||
Content: content,
|
||||
Source: source,
|
||||
}, &result); err != nil {
|
||||
return nil, fmt.Errorf("nowledgemem add: %w", err)
|
||||
}
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
func (c *nmemClient) searchMemories(ctx context.Context, query string, limit int) ([]nmemSearchResult, error) {
|
||||
var result nmemSearchResponse
|
||||
if err := c.doJSON(ctx, http.MethodPost, "/memories/search", nmemSearchRequest{
|
||||
Query: query,
|
||||
Limit: limit,
|
||||
IncludeEntities: true,
|
||||
}, &result); err != nil {
|
||||
return nil, fmt.Errorf("nowledgemem search: %w", err)
|
||||
}
|
||||
return result.Results, nil
|
||||
}
|
||||
|
||||
func (c *nmemClient) updateMemory(ctx context.Context, id, content string) (*nmemMemory, error) {
|
||||
var result nmemMemory
|
||||
if err := c.doJSON(ctx, http.MethodPatch, "/memories/"+id, nmemMemoryUpdateRequest{
|
||||
Content: content,
|
||||
}, &result); err != nil {
|
||||
return nil, fmt.Errorf("nowledgemem update: %w", err)
|
||||
}
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
func (c *nmemClient) deleteMemory(ctx context.Context, id string) error {
|
||||
var result nmemDeleteResponse
|
||||
if err := c.doJSON(ctx, http.MethodDelete, "/memories/"+id, nil, &result); err != nil {
|
||||
return fmt.Errorf("nowledgemem delete: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- HTTP helper ---
|
||||
|
||||
func (c *nmemClient) doJSON(ctx context.Context, method, urlPath string, body any, result any) error {
|
||||
var bodyReader io.Reader
|
||||
if body != nil {
|
||||
data, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal request: %w", err)
|
||||
}
|
||||
bodyReader = bytes.NewReader(data)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, method, c.baseURL+urlPath, bodyReader)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read response: %w", err)
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return fmt.Errorf("nowledgemem API error %d: %s", resp.StatusCode, truncateBody(respBody))
|
||||
}
|
||||
if result != nil && len(respBody) > 0 {
|
||||
if err := json.Unmarshal(respBody, result); err != nil {
|
||||
return fmt.Errorf("unmarshal response: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func truncateBody(b []byte) string {
|
||||
const maxLen = 300
|
||||
s := strings.TrimSpace(string(b))
|
||||
if len(s) <= maxLen {
|
||||
return s
|
||||
}
|
||||
return s[:maxLen] + "..."
|
||||
}
|
||||
@@ -0,0 +1,339 @@
|
||||
package nowledgemem
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"strings"
|
||||
|
||||
"github.com/memohai/memoh/internal/mcp"
|
||||
adapters "github.com/memohai/memoh/internal/memory/adapters"
|
||||
)
|
||||
|
||||
const (
|
||||
NowledgeMemType = "nowledgemem"
|
||||
|
||||
nmemSource = "memoh"
|
||||
nmemToolSearchMemory = "search_memory"
|
||||
nmemDefaultLimit = 10
|
||||
nmemMaxLimit = 50
|
||||
nmemContextMaxItems = 8
|
||||
nmemContextMaxChars = 360
|
||||
)
|
||||
|
||||
// NowledgeMemProvider implements adapters.Provider by delegating to a local Nowledge Mem instance.
|
||||
type NowledgeMemProvider struct {
|
||||
client *nmemClient
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
func NewNowledgeMemProvider(log *slog.Logger, config map[string]any) (*NowledgeMemProvider, error) {
|
||||
if log == nil {
|
||||
log = slog.Default()
|
||||
}
|
||||
c, err := newNmemClient(config)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &NowledgeMemProvider{
|
||||
client: c,
|
||||
logger: log.With(slog.String("provider", NowledgeMemType)),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (*NowledgeMemProvider) Type() string { return NowledgeMemType }
|
||||
|
||||
// --- Conversation Hooks ---
|
||||
|
||||
func (p *NowledgeMemProvider) OnBeforeChat(ctx context.Context, req adapters.BeforeChatRequest) (*adapters.BeforeChatResult, error) {
|
||||
query := strings.TrimSpace(req.Query)
|
||||
if query == "" {
|
||||
return nil, nil
|
||||
}
|
||||
results, err := p.client.searchMemories(ctx, query, nmemContextMaxItems)
|
||||
if err != nil {
|
||||
p.logger.Warn("nowledgemem search for context failed", slog.Any("error", err))
|
||||
return nil, nil
|
||||
}
|
||||
if len(results) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString("<memory-context>\nRelevant memory context (use when helpful):\n")
|
||||
count := 0
|
||||
for _, sr := range results {
|
||||
if count >= nmemContextMaxItems {
|
||||
break
|
||||
}
|
||||
text := strings.TrimSpace(sr.Memory.Content)
|
||||
if text == "" {
|
||||
continue
|
||||
}
|
||||
sb.WriteString("- ")
|
||||
if t := strings.TrimSpace(sr.Memory.Time); t != "" {
|
||||
if len(t) > 10 {
|
||||
t = t[:10]
|
||||
}
|
||||
sb.WriteString("[")
|
||||
sb.WriteString(t)
|
||||
sb.WriteString("] ")
|
||||
}
|
||||
sb.WriteString(adapters.TruncateSnippet(text, nmemContextMaxChars))
|
||||
sb.WriteString("\n")
|
||||
count++
|
||||
}
|
||||
sb.WriteString("</memory-context>")
|
||||
return &adapters.BeforeChatResult{ContextText: sb.String()}, nil
|
||||
}
|
||||
|
||||
func (p *NowledgeMemProvider) OnAfterChat(ctx context.Context, req adapters.AfterChatRequest) error {
|
||||
if len(req.Messages) == 0 {
|
||||
return nil
|
||||
}
|
||||
content := formatConversation(req.Messages, req.DisplayName)
|
||||
if content == "" {
|
||||
return nil
|
||||
}
|
||||
if _, err := p.client.addMemory(ctx, content, nmemSource); err != nil {
|
||||
p.logger.Warn("nowledgemem store memory failed", slog.Any("error", err))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- MCP Tools ---
|
||||
|
||||
func (*NowledgeMemProvider) ListTools(_ context.Context, _ mcp.ToolSessionContext) ([]mcp.ToolDescriptor, error) {
|
||||
return []mcp.ToolDescriptor{
|
||||
{
|
||||
Name: nmemToolSearchMemory,
|
||||
Description: "Search for memories relevant to the current chat",
|
||||
InputSchema: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"query": map[string]any{
|
||||
"type": "string",
|
||||
"description": "The query to search memories",
|
||||
},
|
||||
"limit": map[string]any{
|
||||
"type": "integer",
|
||||
"description": "Maximum number of memory results",
|
||||
},
|
||||
},
|
||||
"required": []string{"query"},
|
||||
},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *NowledgeMemProvider) CallTool(ctx context.Context, _ mcp.ToolSessionContext, toolName string, arguments map[string]any) (map[string]any, error) {
|
||||
if toolName != nmemToolSearchMemory {
|
||||
return nil, mcp.ErrToolNotFound
|
||||
}
|
||||
query := mcp.StringArg(arguments, "query")
|
||||
if query == "" {
|
||||
return mcp.BuildToolErrorResult("query is required"), nil
|
||||
}
|
||||
limit := nmemDefaultLimit
|
||||
if value, ok, err := mcp.IntArg(arguments, "limit"); err != nil {
|
||||
return mcp.BuildToolErrorResult(err.Error()), nil
|
||||
} else if ok {
|
||||
limit = value
|
||||
}
|
||||
if limit <= 0 {
|
||||
limit = nmemDefaultLimit
|
||||
}
|
||||
if limit > nmemMaxLimit {
|
||||
limit = nmemMaxLimit
|
||||
}
|
||||
|
||||
results, err := p.client.searchMemories(ctx, query, limit)
|
||||
if err != nil {
|
||||
return mcp.BuildToolErrorResult("memory search failed"), nil
|
||||
}
|
||||
items := make([]map[string]any, 0, len(results))
|
||||
for _, sr := range results {
|
||||
items = append(items, map[string]any{
|
||||
"id": sr.Memory.ID,
|
||||
"memory": sr.Memory.Content,
|
||||
"score": sr.SimilarityScore,
|
||||
})
|
||||
}
|
||||
return mcp.BuildToolSuccessResult(map[string]any{
|
||||
"query": query,
|
||||
"total": len(items),
|
||||
"results": items,
|
||||
}), nil
|
||||
}
|
||||
|
||||
// --- CRUD ---
|
||||
|
||||
func (p *NowledgeMemProvider) Add(ctx context.Context, req adapters.AddRequest) (adapters.SearchResponse, error) {
|
||||
text := strings.TrimSpace(req.Message)
|
||||
if text == "" && len(req.Messages) > 0 {
|
||||
text = formatConversation(req.Messages, "")
|
||||
}
|
||||
if text == "" {
|
||||
return adapters.SearchResponse{}, errors.New("message is required")
|
||||
}
|
||||
mem, err := p.client.addMemory(ctx, text, nmemSource)
|
||||
if err != nil {
|
||||
return adapters.SearchResponse{}, err
|
||||
}
|
||||
return adapters.SearchResponse{Results: []adapters.MemoryItem{nmemToItem(*mem)}}, nil
|
||||
}
|
||||
|
||||
func (p *NowledgeMemProvider) Search(ctx context.Context, req adapters.SearchRequest) (adapters.SearchResponse, error) {
|
||||
limit := req.Limit
|
||||
if limit <= 0 {
|
||||
limit = nmemDefaultLimit
|
||||
} else if limit > nmemMaxLimit {
|
||||
limit = nmemMaxLimit
|
||||
}
|
||||
results, err := p.client.searchMemories(ctx, req.Query, limit)
|
||||
if err != nil {
|
||||
return adapters.SearchResponse{}, err
|
||||
}
|
||||
items := make([]adapters.MemoryItem, 0, len(results))
|
||||
for _, sr := range results {
|
||||
item := nmemToItem(sr.Memory)
|
||||
item.Score = sr.SimilarityScore
|
||||
items = append(items, item)
|
||||
}
|
||||
return adapters.SearchResponse{Results: items}, nil
|
||||
}
|
||||
|
||||
func (*NowledgeMemProvider) GetAll(_ context.Context, _ adapters.GetAllRequest) (adapters.SearchResponse, error) {
|
||||
return adapters.SearchResponse{}, errors.New("getall is not supported by nowledgemem provider")
|
||||
}
|
||||
|
||||
func (p *NowledgeMemProvider) Update(ctx context.Context, req adapters.UpdateRequest) (adapters.MemoryItem, error) {
|
||||
memoryID := strings.TrimSpace(req.MemoryID)
|
||||
if memoryID == "" {
|
||||
return adapters.MemoryItem{}, errors.New("memory_id is required")
|
||||
}
|
||||
mem, err := p.client.updateMemory(ctx, memoryID, req.Memory)
|
||||
if err != nil {
|
||||
return adapters.MemoryItem{}, err
|
||||
}
|
||||
return nmemToItem(*mem), nil
|
||||
}
|
||||
|
||||
func (p *NowledgeMemProvider) Delete(ctx context.Context, memoryID string) (adapters.DeleteResponse, error) {
|
||||
if err := p.client.deleteMemory(ctx, strings.TrimSpace(memoryID)); err != nil {
|
||||
return adapters.DeleteResponse{}, err
|
||||
}
|
||||
return adapters.DeleteResponse{Message: "Memory deleted successfully"}, nil
|
||||
}
|
||||
|
||||
func (p *NowledgeMemProvider) DeleteBatch(ctx context.Context, memoryIDs []string) (adapters.DeleteResponse, error) {
|
||||
for _, id := range memoryIDs {
|
||||
if err := p.client.deleteMemory(ctx, strings.TrimSpace(id)); err != nil {
|
||||
return adapters.DeleteResponse{}, err
|
||||
}
|
||||
}
|
||||
return adapters.DeleteResponse{Message: "Memories deleted successfully"}, nil
|
||||
}
|
||||
|
||||
func (*NowledgeMemProvider) DeleteAll(_ context.Context, _ adapters.DeleteAllRequest) (adapters.DeleteResponse, error) {
|
||||
return adapters.DeleteResponse{}, errors.New("deleteall is not supported by nowledgemem provider")
|
||||
}
|
||||
|
||||
// --- Lifecycle ---
|
||||
|
||||
func (*NowledgeMemProvider) Compact(_ context.Context, _ map[string]any, _ float64, _ int) (adapters.CompactResult, error) {
|
||||
return adapters.CompactResult{}, errors.New("compact is not supported by nowledgemem provider")
|
||||
}
|
||||
|
||||
func (*NowledgeMemProvider) Usage(_ context.Context, _ map[string]any) (adapters.UsageResponse, error) {
|
||||
return adapters.UsageResponse{}, errors.New("usage is not supported by nowledgemem provider")
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
// formatConversation formats a round of messages into attributed text for storage.
|
||||
// User messages are tagged with the sender's display name parsed from the YAML
|
||||
// front-matter header; bot messages are tagged with [我].
|
||||
func formatConversation(messages []adapters.Message, fallbackDisplayName string) string {
|
||||
var sb strings.Builder
|
||||
for _, msg := range messages {
|
||||
text := strings.TrimSpace(msg.Content)
|
||||
if text == "" {
|
||||
continue
|
||||
}
|
||||
role := strings.TrimSpace(msg.Role)
|
||||
|
||||
switch role {
|
||||
case "user":
|
||||
displayName, body := parseDisplayNameFromYAML(text)
|
||||
if displayName == "" {
|
||||
displayName = strings.TrimSpace(fallbackDisplayName)
|
||||
}
|
||||
if displayName == "" {
|
||||
displayName = "用户"
|
||||
}
|
||||
body = strings.TrimSpace(body)
|
||||
if body == "" {
|
||||
continue
|
||||
}
|
||||
if sb.Len() > 0 {
|
||||
sb.WriteByte('\n')
|
||||
}
|
||||
sb.WriteString("[")
|
||||
sb.WriteString(displayName)
|
||||
sb.WriteString("] ")
|
||||
sb.WriteString(body)
|
||||
|
||||
case "assistant":
|
||||
if sb.Len() > 0 {
|
||||
sb.WriteByte('\n')
|
||||
}
|
||||
sb.WriteString("[我] ")
|
||||
sb.WriteString(text)
|
||||
}
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// parseDisplayNameFromYAML extracts the display-name field from a YAML
|
||||
// front-matter header (---\n...\n---\n) and returns the remaining body.
|
||||
// If no valid header is found, displayName is empty and body is the original text.
|
||||
func parseDisplayNameFromYAML(content string) (displayName, body string) {
|
||||
if !strings.HasPrefix(content, "---\n") {
|
||||
return "", content
|
||||
}
|
||||
rest := content[4:] // skip opening "---\n"
|
||||
endIdx := strings.Index(rest, "\n---\n")
|
||||
if endIdx < 0 {
|
||||
return "", content
|
||||
}
|
||||
header := rest[:endIdx]
|
||||
body = strings.TrimSpace(rest[endIdx+5:]) // skip "\n---\n"
|
||||
|
||||
for _, line := range strings.Split(header, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if strings.HasPrefix(line, "display-name:") {
|
||||
val := strings.TrimSpace(strings.TrimPrefix(line, "display-name:"))
|
||||
// Strip surrounding quotes if present.
|
||||
if len(val) >= 2 && val[0] == '"' && val[len(val)-1] == '"' {
|
||||
val = val[1 : len(val)-1]
|
||||
}
|
||||
displayName = strings.TrimSpace(val)
|
||||
break
|
||||
}
|
||||
}
|
||||
return displayName, body
|
||||
}
|
||||
|
||||
func nmemToItem(m nmemMemory) adapters.MemoryItem {
|
||||
item := adapters.MemoryItem{
|
||||
ID: m.ID,
|
||||
Memory: m.Content,
|
||||
Metadata: m.Metadata,
|
||||
}
|
||||
if m.Time != "" {
|
||||
item.CreatedAt = m.Time
|
||||
item.UpdatedAt = m.Time
|
||||
}
|
||||
return item
|
||||
}
|
||||
@@ -131,6 +131,21 @@ func (*Service) ListMeta(_ context.Context) []ProviderMeta {
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Provider: string(ProviderNowledgeMem),
|
||||
DisplayName: "Nowledge Mem",
|
||||
ConfigSchema: ProviderConfigSchema{
|
||||
Fields: map[string]ProviderFieldSchema{
|
||||
"base_url": {
|
||||
Type: "string",
|
||||
Title: "Base URL",
|
||||
Description: "Nowledge Mem API base URL. Defaults to http://127.0.0.1:14242 when empty.",
|
||||
Required: false,
|
||||
Example: "http://127.0.0.1:14242",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -331,7 +346,7 @@ func (s *Service) tryEvictAndReinstantiate(id, providerType string, config map[s
|
||||
|
||||
func isValidProviderType(t ProviderType) bool {
|
||||
switch t {
|
||||
case ProviderBuiltin, ProviderMem0, ProviderOpenViking:
|
||||
case ProviderBuiltin, ProviderMem0, ProviderOpenViking, ProviderNowledgeMem:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
|
||||
@@ -238,9 +238,10 @@ type MemoryStatusResponse struct {
|
||||
type ProviderType string
|
||||
|
||||
const (
|
||||
ProviderBuiltin ProviderType = "builtin"
|
||||
ProviderMem0 ProviderType = "mem0"
|
||||
ProviderOpenViking ProviderType = "openviking"
|
||||
ProviderBuiltin ProviderType = "builtin"
|
||||
ProviderMem0 ProviderType = "mem0"
|
||||
ProviderOpenViking ProviderType = "openviking"
|
||||
ProviderNowledgeMem ProviderType = "nowledgemem"
|
||||
)
|
||||
|
||||
type ProviderCreateRequest struct {
|
||||
|
||||
@@ -271,7 +271,7 @@ export type AdaptersProviderStatusResponse = {
|
||||
provider_type?: string;
|
||||
};
|
||||
|
||||
export type AdaptersProviderType = 'builtin' | 'mem0' | 'openviking';
|
||||
export type AdaptersProviderType = 'builtin' | 'mem0' | 'openviking' | 'nowledgemem';
|
||||
|
||||
export type AdaptersProviderUpdateRequest = {
|
||||
config?: {
|
||||
|
||||
Reference in New Issue
Block a user