From b5bd64c9268f047e34d85e7b6e14a73afece2a90 Mon Sep 17 00:00:00 2001 From: zhanglongzhan Date: Thu, 27 Aug 2026 16:31:05 +0800 Subject: [PATCH] feat(memory-core): add L1 atomic memory import API with dedup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a user-driven L1 atomic memory import path that bypasses the L0->L1 distillation pipeline, letting users bulk-write personal memory records (from markdown/json/txt files or JSON arrays) directly into the L1 atomic layer for model recall. ## MemoryCore kernel - v2-router.ts: add handleAtomicCreate handler, register /atomic/create route (dual-mounted on /v2 and /v3), batch create up to 500 records per request with server-generated record_id (m_{timestamp}_{hex}) - Dedup on create: before writing, search existing L1 memories with a 3-tier fallback — vector search (searchL1Vector) -> FTS (searchL1Fts) -> exact content match (queryL1Records, works in standalone mode without embedding/FTS5). Modes: skip / update, configurable threshold (default 0.85). Isolated by team+agent+user. - generated/schemas.ts + types.ts: atomicCreateRequestSchema / atomicCreateDataSchema and AtomicCreateRequest / AtomicCreateData types (with dedup field and skipped/updated response counters) - v2-schemas.ts: re-export the new schemas and types ## MemoryPanel - chat-memory.ts: POST /chat-memory/import-memories route accepting a JSON array or file text (.md/.json/.txt), parsed then forwarded to /v3/atomic/create - chat-memory-import-parser.ts (new): markdown/JSON/TXT parser, splits by ## heading or paragraph - web: standalone ImportMemories page as a left-menu item (parallel to Chat Memory, not a tab inside it) — ImportMemoriesPanel supports paste/upload, live parse preview, optional dedup, and lists existing L1 memories after import ## SDK - TypeScript: createMemories method + V3AtomicCreateRequest/Record/ Dedup/Data types - Python: create_memories method (sync + async) ## Design decisions - Isolation inherits the V3 router's team+agent+user triple check - Soft classification via type + scene_name (aligned with existing L1 model); no URI namespace - handleAtomicUpdate untouched (keeps update-only semantics; create goes through the new endpoint) ## Tested - skip mode: identical content -> created=0, skipped=1 - update mode: identical content -> created=1, updated=1, id reused - distinct content -> created=1 - user isolation: other-user cannot query test-user's memories - MemoryCore typecheck 0 errors; tsx boots on 8420 Signed-off-by: zhanglongzhan --- .gitignore | 5 + MemoryCore/src/gateway/generated/schemas.ts | 31 +- MemoryCore/src/gateway/generated/types.ts | 70 ++++ MemoryCore/src/gateway/v2-router.ts | 209 +++++++++++ MemoryCore/src/gateway/v2-schemas.ts | 4 + .../http/routes/chat-memory-import-parser.ts | 159 ++++++++ .../src/panel/http/routes/chat-memory.ts | 103 ++++++ MemoryPanel/web/src/constants/menu.tsx | 4 + MemoryPanel/web/src/i18n/zh-CN.ts | 38 ++ MemoryPanel/web/src/layouts/ConsoleLayout.tsx | 2 + MemoryPanel/web/src/lib/api/chat-memory.ts | 32 ++ .../components/ImportMemoriesPanel.tsx | 345 ++++++++++++++++++ .../src/pages/ImportMemoriesPage/index.tsx | 21 ++ MemoryPanel/web/src/routes/index.tsx | 2 + .../tencentdb_agent_memory/v3/client.py | 45 +++ sdk/memory-core/typescript/src/v3/client.ts | 15 + sdk/memory-core/typescript/src/v3/index.ts | 4 + sdk/memory-core/typescript/src/v3/types.ts | 25 ++ 18 files changed, 1113 insertions(+), 1 deletion(-) create mode 100644 MemoryPanel/src/panel/http/routes/chat-memory-import-parser.ts create mode 100644 MemoryPanel/web/src/pages/ChatMemoryPage/components/ImportMemoriesPanel.tsx create mode 100644 MemoryPanel/web/src/pages/ImportMemoriesPage/index.tsx diff --git a/.gitignore b/.gitignore index 97c95f32a..a19d5cc0d 100644 --- a/.gitignore +++ b/.gitignore @@ -115,6 +115,7 @@ MemoryCore/scripts/tests/ docs/knowledge/self-implement-wiki/_e2e-output/ # Development databases and runtime data (never commit) +data/ data/skills.db data/tdai-memory.db **/data/vectors.db @@ -126,6 +127,10 @@ data/tdai-memory.db **/data/_wiki_engines/ **/data/mem-*/ +# MemoryPanel local config (contains real user keys, never commit) +MemoryPanel/config/metadata-instances.json +MemoryPanel/.env + # Test coverage output coverage/ **/coverage/ diff --git a/MemoryCore/src/gateway/generated/schemas.ts b/MemoryCore/src/gateway/generated/schemas.ts index ba2f8c4bf..138ea6501 100644 --- a/MemoryCore/src/gateway/generated/schemas.ts +++ b/MemoryCore/src/gateway/generated/schemas.ts @@ -4,7 +4,7 @@ */ import * as z from "zod"; -import type { ActorRef, ApiResponseEnvelope, IdFields, ConversationRole, Pagination, AssetType, AssetMutateData, AssetSearchRequest, AssetSearchHit, AssetSearchData, ConversationItem, ConversationAddRequest, ConversationAddData, ConversationQueryRequest, ConversationQueryData, ConversationSearchRequest, ConversationSearchHit, ConversationSearchData, ConversationDeleteRequest, ConversationDeleteData, AtomicDetail, AtomicUpdateRequest, AtomicUpdateData, AtomicQueryRequest, AtomicQueryData, AtomicSearchRequest, AtomicSearchHit, AtomicSearchData, AtomicDeleteRequest, AtomicDeleteData, ScenarioEntry, ScenarioFile, ScenarioListRequest, ScenarioListData, ScenarioReadRequest, ScenarioWriteRequest, ScenarioWriteData, ScenarioRmRequest, CoreFile, CoreReadRequest, CoreWriteRequest, CoreWriteData, SkillSummary, SkillDetail, SkillListRequest, SkillListData, SkillGetRequest, SkillFile, SkillCreateRequest, SkillUpdateRequest, SkillMutateData, SkillDeleteRequest, SkillSearchRequest, SkillSearchHit, SkillSearchData, SkillImportRole, SkillImportMessage, SkillImportRequest, SkillImportDiagnostic, SkillImportData, TeamStatus, UserStatus, AgentStatus, TaskSourceType, AgentVisibility, BatchDeleteResult, TeamBatchDeleteRequest, UserBatchDeleteRequest, AgentBatchDeleteRequest, TaskBatchDeleteRequest, TeamData, TeamCreateRequest, TeamGetRequest, TeamUpdateRequest, UserData, UserCreateRequest, UserUpdateRequest, UserGetRequest, AgentData, AgentCreateRequest, AgentGetRequest, AgentUpdateRequest, TaskData, TaskCreateRequest, TaskGetRequest, TaskUpdateRequest, AddConversation200, AddConversationError, AddConversationMutationRequest, AddConversationMutationResponse, QueryConversation200, QueryConversationError, QueryConversationMutationRequest, QueryConversationMutationResponse, SearchConversation200, SearchConversationError, SearchConversationMutationRequest, SearchConversationMutationResponse, DeleteConversation200, DeleteConversationError, DeleteConversationMutationRequest, DeleteConversationMutationResponse, UpdateAtomic200, UpdateAtomicError, UpdateAtomicMutationRequest, UpdateAtomicMutationResponse, QueryAtomic200, QueryAtomicError, QueryAtomicMutationRequest, QueryAtomicMutationResponse, SearchAtomic200, SearchAtomicError, SearchAtomicMutationRequest, SearchAtomicMutationResponse, DeleteAtomic200, DeleteAtomicError, DeleteAtomicMutationRequest, DeleteAtomicMutationResponse, LsScenario200, LsScenarioError, LsScenarioMutationRequest, LsScenarioMutationResponse, ReadScenario200, ReadScenarioError, ReadScenarioMutationRequest, ReadScenarioMutationResponse, WriteScenario200, WriteScenarioError, WriteScenarioMutationRequest, WriteScenarioMutationResponse, RmScenario200, RmScenarioError, RmScenarioMutationRequest, RmScenarioMutationResponse, ReadCore200, ReadCoreError, ReadCoreMutationRequest, ReadCoreMutationResponse, WriteCore200, WriteCoreError, WriteCoreMutationRequest, WriteCoreMutationResponse, ListSkill200, ListSkillError, ListSkillMutationRequest, ListSkillMutationResponse, GetSkill200, GetSkillError, GetSkillMutationRequest, GetSkillMutationResponse, CreateSkill200, CreateSkillError, CreateSkillMutationRequest, CreateSkillMutationResponse, UpdateSkill200, UpdateSkillError, UpdateSkillMutationRequest, UpdateSkillMutationResponse, DeleteSkill200, DeleteSkillError, DeleteSkillMutationRequest, DeleteSkillMutationResponse, SearchSkill200, SearchSkillError, SearchSkillMutationRequest, SearchSkillMutationResponse, ImportSkill200, ImportSkillError, ImportSkillMutationRequest, ImportSkillMutationResponse, CreateTeam200, CreateTeamError, CreateTeamMutationRequest, CreateTeamMutationResponse, GetTeam200, GetTeamError, GetTeamMutationRequest, GetTeamMutationResponse, UpdateTeam200, UpdateTeamError, UpdateTeamMutationRequest, UpdateTeamMutationResponse, DeleteTeam200, DeleteTeamError, DeleteTeamMutationRequest, DeleteTeamMutationResponse, CreateUser200, CreateUserError, CreateUserMutationRequest, CreateUserMutationResponse, GetUser200, GetUserError, GetUserMutationRequest, GetUserMutationResponse, UpdateUser200, UpdateUserError, UpdateUserMutationRequest, UpdateUserMutationResponse, DeleteUser200, DeleteUserError, DeleteUserMutationRequest, DeleteUserMutationResponse, CreateAgent200, CreateAgentError, CreateAgentMutationRequest, CreateAgentMutationResponse, GetAgent200, GetAgentError, GetAgentMutationRequest, GetAgentMutationResponse, UpdateAgent200, UpdateAgentError, UpdateAgentMutationRequest, UpdateAgentMutationResponse, DeleteAgent200, DeleteAgentError, DeleteAgentMutationRequest, DeleteAgentMutationResponse, CreateTask200, CreateTaskError, CreateTaskMutationRequest, CreateTaskMutationResponse, GetTask200, GetTaskError, GetTaskMutationRequest, GetTaskMutationResponse, UpdateTask200, UpdateTaskError, UpdateTaskMutationRequest, UpdateTaskMutationResponse, DeleteTask200, DeleteTaskError, DeleteTaskMutationRequest, DeleteTaskMutationResponse } from "./types.ts"; +import type { ActorRef, ApiResponseEnvelope, IdFields, ConversationRole, Pagination, AssetType, AssetMutateData, AssetSearchRequest, AssetSearchHit, AssetSearchData, ConversationItem, ConversationAddRequest, ConversationAddData, ConversationQueryRequest, ConversationQueryData, ConversationSearchRequest, ConversationSearchHit, ConversationSearchData, ConversationDeleteRequest, ConversationDeleteData, AtomicDetail, AtomicUpdateRequest, AtomicUpdateData, AtomicQueryRequest, AtomicQueryData, AtomicSearchRequest, AtomicSearchHit, AtomicSearchData, AtomicDeleteRequest, AtomicDeleteData, AtomicCreateRequest, AtomicCreateData, ScenarioEntry, ScenarioFile, ScenarioListRequest, ScenarioListData, ScenarioReadRequest, ScenarioWriteRequest, ScenarioWriteData, ScenarioRmRequest, CoreFile, CoreReadRequest, CoreWriteRequest, CoreWriteData, SkillSummary, SkillDetail, SkillListRequest, SkillListData, SkillGetRequest, SkillFile, SkillCreateRequest, SkillUpdateRequest, SkillMutateData, SkillDeleteRequest, SkillSearchRequest, SkillSearchHit, SkillSearchData, SkillImportRole, SkillImportMessage, SkillImportRequest, SkillImportDiagnostic, SkillImportData, TeamStatus, UserStatus, AgentStatus, TaskSourceType, AgentVisibility, BatchDeleteResult, TeamBatchDeleteRequest, UserBatchDeleteRequest, AgentBatchDeleteRequest, TaskBatchDeleteRequest, TeamData, TeamCreateRequest, TeamGetRequest, TeamUpdateRequest, UserData, UserCreateRequest, UserUpdateRequest, UserGetRequest, AgentData, AgentCreateRequest, AgentGetRequest, AgentUpdateRequest, TaskData, TaskCreateRequest, TaskGetRequest, TaskUpdateRequest, AddConversation200, AddConversationError, AddConversationMutationRequest, AddConversationMutationResponse, QueryConversation200, QueryConversationError, QueryConversationMutationRequest, QueryConversationMutationResponse, SearchConversation200, SearchConversationError, SearchConversationMutationRequest, SearchConversationMutationResponse, DeleteConversation200, DeleteConversationError, DeleteConversationMutationRequest, DeleteConversationMutationResponse, UpdateAtomic200, UpdateAtomicError, UpdateAtomicMutationRequest, UpdateAtomicMutationResponse, QueryAtomic200, QueryAtomicError, QueryAtomicMutationRequest, QueryAtomicMutationResponse, SearchAtomic200, SearchAtomicError, SearchAtomicMutationRequest, SearchAtomicMutationResponse, DeleteAtomic200, DeleteAtomicError, DeleteAtomicMutationRequest, DeleteAtomicMutationResponse, LsScenario200, LsScenarioError, LsScenarioMutationRequest, LsScenarioMutationResponse, ReadScenario200, ReadScenarioError, ReadScenarioMutationRequest, ReadScenarioMutationResponse, WriteScenario200, WriteScenarioError, WriteScenarioMutationRequest, WriteScenarioMutationResponse, RmScenario200, RmScenarioError, RmScenarioMutationRequest, RmScenarioMutationResponse, ReadCore200, ReadCoreError, ReadCoreMutationRequest, ReadCoreMutationResponse, WriteCore200, WriteCoreError, WriteCoreMutationRequest, WriteCoreMutationResponse, ListSkill200, ListSkillError, ListSkillMutationRequest, ListSkillMutationResponse, GetSkill200, GetSkillError, GetSkillMutationRequest, GetSkillMutationResponse, CreateSkill200, CreateSkillError, CreateSkillMutationRequest, CreateSkillMutationResponse, UpdateSkill200, UpdateSkillError, UpdateSkillMutationRequest, UpdateSkillMutationResponse, DeleteSkill200, DeleteSkillError, DeleteSkillMutationRequest, DeleteSkillMutationResponse, SearchSkill200, SearchSkillError, SearchSkillMutationRequest, SearchSkillMutationResponse, ImportSkill200, ImportSkillError, ImportSkillMutationRequest, ImportSkillMutationResponse, CreateTeam200, CreateTeamError, CreateTeamMutationRequest, CreateTeamMutationResponse, GetTeam200, GetTeamError, GetTeamMutationRequest, GetTeamMutationResponse, UpdateTeam200, UpdateTeamError, UpdateTeamMutationRequest, UpdateTeamMutationResponse, DeleteTeam200, DeleteTeamError, DeleteTeamMutationRequest, DeleteTeamMutationResponse, CreateUser200, CreateUserError, CreateUserMutationRequest, CreateUserMutationResponse, GetUser200, GetUserError, GetUserMutationRequest, GetUserMutationResponse, UpdateUser200, UpdateUserError, UpdateUserMutationRequest, UpdateUserMutationResponse, DeleteUser200, DeleteUserError, DeleteUserMutationRequest, DeleteUserMutationResponse, CreateAgent200, CreateAgentError, CreateAgentMutationRequest, CreateAgentMutationResponse, GetAgent200, GetAgentError, GetAgentMutationRequest, GetAgentMutationResponse, UpdateAgent200, UpdateAgentError, UpdateAgentMutationRequest, UpdateAgentMutationResponse, DeleteAgent200, DeleteAgentError, DeleteAgentMutationRequest, DeleteAgentMutationResponse, CreateTask200, CreateTaskError, CreateTaskMutationRequest, CreateTaskMutationResponse, GetTask200, GetTaskError, GetTaskMutationRequest, GetTaskMutationResponse, UpdateTask200, UpdateTaskError, UpdateTaskMutationRequest, UpdateTaskMutationResponse, DeleteTask200, DeleteTaskError, DeleteTaskMutationRequest, DeleteTaskMutationResponse } from "./types.ts"; /** * @description 统一响应外壳。`code = 0` 表示成功;非 0 表示业务错误。\n常见业务错误语义:\n - `400`:参数不合法(如关键 ID 缺失、互斥字段同时出现、空入参等);\n - `401`:鉴权失败;\n - `403`:**全接口统一**——多个业务字段均可见但归属一致性校验\n 失败(如 `(team_id, agent_id)` 不构成有效 Agent 归属、\n `task_id` 不属于 `team_id`、`(task_id, agent_id) ∉\n task_agents`、`creator_user_id` 不是该 team 成员等);\n 写审计日志;\n - `404`:资源不存在或不属于当前调用上下文(记忆原子层 / 资产层 /\n 实体 CRUD 一律按 404,与 offload.yaml 同契约,不暴露存在性);\n - `409`:会话级 / 资产级并发竞争超时;\n - `422`:Schema 校验通过但业务规则不通过(如 Skill 重名 / 版本冲突);\n - `429`:频控触发;\n - `500`:内部错误,可有限重试;\n - `503-LLM_UNAVAILABLE` / `503-STORAGE_UNAVAILABLE`:依赖不可用;\n - `504`:请求超时。\n @@ -175,6 +175,35 @@ export const atomicUpdateDataSchema = z.object({ "updated_at": z.iso.datetime() }) as unknown as z.ZodType +/** + * Batch create L1 atomic memories. Each record gets a server-generated + * record_id (`m_{timestamp}_{randomHex}`). Use this for user-driven memory + * imports (markdown notes, JSON arrays, file uploads) — bypassing the + * L0→L1 distillation pipeline. Isolation (team/agent/user) is enforced + * by the V3 router. No dedup; callers responsible for content hygiene. + */ +export const atomicCreateRequestSchema = z.lazy(() => idFieldsSchema).and(z.object({ + "records": z.array(z.object({ + "content": z.string().max(8192), + "type": z.optional(z.enum(["persona", "episodic", "instruction", "work_fact", "work_task", "work_method", "work_artifact"]).default("persona")), + "scene_name": z.optional(z.string().describe("软分类命名空间,如 preferences / events / entities。不强制,可空。")), + "priority": z.optional(z.number().min(-1).max(100).default(50)), + "metadata": z.optional(z.record(z.string(), z.unknown())) + })).min(1).max(500).describe("单次最多 500 条记忆。超过请分批调用。"), + "dedup": z.optional(z.object({ + "enabled": z.boolean().default(false).describe("是否启用去重。启用后,每条记录写入前先搜索,命中则 skip/update。"), + "threshold": z.optional(z.number().min(0).max(1).default(0.85).describe("去重阈值 0-1,score 超过则视为重复。默认 0.85。")), + "mode": z.optional(z.enum(["skip", "update"]).default("skip").describe("命中重复时的行为:skip=跳过,update=覆盖更新。")) + }).default({ enabled: false })) + })) as unknown as z.ZodType + +export const atomicCreateDataSchema = z.object({ + "created": z.int().describe("实际写入条数(含 update)。"), + "ids": z.array(z.string()).describe("每条记忆的 record_id,顺序与请求 records 对齐。skip 的为空字符串。"), + "skipped": z.optional(z.int().describe("因重复跳过的条数。")), + "updated": z.optional(z.int().describe("因重复而更新的条数。")) + }) as unknown as z.ZodType + export const atomicQueryRequestSchema = z.lazy(() => paginationSchema).and(z.lazy(() => idFieldsSchema)).and(z.object({ "type": z.optional(z.string().describe("episodic` / `persona` / `instruction")), "time_start": z.optional(z.iso.datetime()), diff --git a/MemoryCore/src/gateway/generated/types.ts b/MemoryCore/src/gateway/generated/types.ts index 59b862d8e..6c0955493 100644 --- a/MemoryCore/src/gateway/generated/types.ts +++ b/MemoryCore/src/gateway/generated/types.ts @@ -440,6 +440,76 @@ export type AtomicUpdateData = { updated_at: string; }; +/** + * Batch create L1 atomic memories. Each record gets a server-generated + * record_id. For user-driven imports bypassing L0→L1 distillation. + */ +export type AtomicCreateRequest = (IdFields & { + /** + * @description 单次最多 500 条记忆。超过请分批调用。 + */ + records: Array<{ + /** + * @maxLength 8192 + * @type string + */ + content: string; + /** + * @description persona / episodic / instruction / work_fact / work_task / work_method / work_artifact + */ + type?: "persona" | "episodic" | "instruction" | "work_fact" | "work_task" | "work_method" | "work_artifact"; + /** + * @description 软分类命名空间,如 preferences / events / entities。不强制,可空。 + * @type string | undefined + */ + scene_name?: string; + /** + * @description 优先级 -1..100,默认 50。-1 表示全局指令。 + */ + priority?: number; + /** + * @description 任意结构化元数据。 + */ + metadata?: Record; + }>; + /** + * @description 去重配置。启用后每条记录写入前先搜索,命中则 skip/update。 + */ + dedup?: { + enabled: boolean; + /** + * @description 去重阈值 0-1,score 超过则视为重复。默认 0.85。 + */ + threshold?: number; + /** + * @description 命中重复时的行为:skip=跳过,update=覆盖更新。 + */ + mode?: "skip" | "update"; + }; +}); + +export type AtomicCreateData = { + /** + * @description 实际写入条数(含 update)。 + * @type integer + */ + created: number; + /** + * @description 每条记忆的 record_id,顺序与请求 records 对齐。skip 的为空字符串。 + */ + ids: string[]; + /** + * @description 因重复跳过的条数。 + * @type integer | undefined + */ + skipped?: number; + /** + * @description 因重复而更新的条数。 + * @type integer | undefined + */ + updated?: number; +}; + export type AtomicQueryRequest = (Pagination & IdFields & { /** * @description `episodic` / `persona` / `instruction` diff --git a/MemoryCore/src/gateway/v2-router.ts b/MemoryCore/src/gateway/v2-router.ts index dd951ba10..154181951 100644 --- a/MemoryCore/src/gateway/v2-router.ts +++ b/MemoryCore/src/gateway/v2-router.ts @@ -27,6 +27,7 @@ import type { PipelineWorker } from "../services/pipeline-worker.js"; import { executeMemorySearch } from "../core/tools/memory-search.js"; import { executeConversationSearch } from "../core/tools/conversation-search.js"; import type { MemoryRecord } from "../core/record/l1-writer.js"; +import { generateMemoryId } from "../core/record/l1-writer.js"; import { reportRecallMetrics } from "../core/report/metric-tracking-recall.js"; // ── Zod schemas (validated types + defaults) ── @@ -37,6 +38,7 @@ import { conversationDeleteRequestSchema, conversationCountRequestSchema, atomicUpdateRequestSchema, + atomicCreateRequestSchema, atomicQueryRequestSchema, atomicSearchRequestSchema, atomicDeleteRequestSchema, @@ -77,6 +79,7 @@ import { type CountData, type AtomicDetail, type AtomicUpdateData, + type AtomicCreateData, type AtomicQueryData, type AtomicSearchData, type AtomicSearchHit, @@ -157,6 +160,7 @@ const V3_ALLOWED_SUBPATHS = new Set([ "/conversation/delete", "/conversation/count", "/atomic/update", + "/atomic/create", "/atomic/query", "/atomic/search", "/atomic/delete", @@ -417,6 +421,7 @@ const DATAPLANE_HANDLERS: Record = { "/conversation/delete": handleConversationDelete, "/conversation/count": handleConversationCount, "/atomic/update": handleAtomicUpdate, + "/atomic/create": handleAtomicCreate, "/atomic/query": handleAtomicQuery, "/atomic/search": handleAtomicSearch, "/atomic/delete": handleAtomicDelete, @@ -1110,6 +1115,210 @@ async function handleAtomicUpdate(body: unknown, _auth: V2AuthContext, requestId return successEnvelope({ id, version: `v${updatedVersion}`, updated_at: now }, requestId); } +/** + * Batch create L1 atomic memories. Each record gets a server-generated + * record_id (`m_{timestamp}_{randomHex}`). Bypasses L0→L1 distillation — + * for user-driven imports (markdown notes, JSON arrays, file uploads). + * + * Isolation (team/agent/user) is enforced by the V3 router's collectV3Missing + * check; here we propagate the resolved isolation into each MemoryRecord. + * + * Optional dedup: when `dedup.enabled` is true, each record is searched + * against existing L1 (vector or FTS) before write. Hits above `threshold` + * are skipped (or updated if `mode === "update"`). Single batch ≤ 500 records. + */ +async function handleAtomicCreate(body: unknown, _auth: V2AuthContext, requestId: string, deps: V2RouterDeps): Promise { + const parsed = atomicCreateRequestSchema.safeParse(body); + if (!parsed.success) return errorEnvelope(400, formatZodError(parsed.error), requestId); + const { records, dedup } = parsed.data; + + const store = deps.getStore(); + if (!store) return errorEnvelope(503, "Store not available", requestId); + + const iso = deps.requestIsolation; + const embedding = deps.getEmbedding(); + const now = new Date().toISOString(); + + // Dedup config (defaults: disabled) + const dedupEnabled = dedup?.enabled === true; + const dedupThreshold = dedup?.threshold ?? 0.85; + const dedupMode = dedup?.mode ?? "skip"; + + // Isolation filter for dedup search (team+user+agent, no session — cross-session) + const dedupFilter = iso ? { + ...(iso.teamId ? { teamId: iso.teamId } : {}), + ...(iso.userId ? { userId: iso.userId } : {}), + ...(iso.agentId ? { agentId: iso.agentId } : {}), + } : undefined; + + const ids: string[] = []; + let created = 0; + let skipped = 0; + let updated = 0; + + for (const rec of records) { + // ── Dedup check ────────────────────────────────────────────── + if (dedupEnabled) { + let hit: { record_id: string; score: number; content: string } | null = null; + + // Try vector search first (needs embedding) + if (embedding) { + try { + const emb = await embedding.embed(rec.content); + const results = await store.searchL1Vector(emb, 1, rec.content, dedupFilter); + if (results.length > 0 && results[0].score >= dedupThreshold) { + hit = { record_id: results[0].record_id, score: results[0].score, content: results[0].content }; + } + } catch (e) { + console.warn(`[v2-router] L1 dedup vector search failed:`, e); + } + } + + // Fallback to FTS if no vector hit or no embedding + if (!hit) { + try { + const ftsResults = await store.searchL1Fts(rec.content, 1, dedupFilter); + if (ftsResults.length > 0 && ftsResults[0].score >= dedupThreshold) { + hit = { record_id: ftsResults[0].record_id, score: ftsResults[0].score, content: ftsResults[0].content }; + } + } catch (e) { + console.warn(`[v2-router] L1 dedup FTS search failed:`, e); + } + } + + // Fallback: exact content match via queryL1Records (works without + // vector/FTS). This catches exact duplicates even in standalone mode + // where embedding and FTS5 are unavailable. + if (!hit) { + try { + // queryL1Records with isolation filter — fetch all user's records + // and compare content locally. For dedup we only need exact match. + const existing = await store.queryL1Records(dedupFilter); + if (existing && existing.length > 0) { + const exactMatch = existing.find(r => r.content === rec.content); + if (exactMatch) { + hit = { record_id: exactMatch.record_id, score: 1.0, content: exactMatch.content }; + } + } + } catch (e) { + console.warn(`[v2-router] L1 dedup exact match fallback failed:`, e); + } + } + + if (hit) { + if (dedupMode === "skip") { + ids.push(""); // empty string marks skipped + skipped++; + await recordAudit(store, { + record_id: hit.record_id, + layer: "L1", + action: "dedup_skip", + iso, + version: 0, + requestId, + logger: deps.logger, + }); + continue; + } else { + // update mode: overwrite the existing record's content + const existing = await store.queryL1Records({ recordIds: [hit.record_id] }); + if (existing && existing.length > 0) { + const existingRecord = existing[0]; + const updatedVersion = (existingRecord.version ?? 0) + 1; + const updatedRecord: MemoryRecord = { + id: hit.record_id, + content: rec.content, + type: (rec.type ?? "persona") as any, + priority: rec.priority ?? 50, + scene_name: rec.scene_name ?? existingRecord.scene_name ?? "", + source_message_ids: [], + metadata: rec.metadata ?? {}, + timestamps: [...(existingRecord.timestamp_str ? [existingRecord.timestamp_str] : []), now], + createdAt: existingRecord.created_time, + updatedAt: now, + version: updatedVersion, + sessionKey: existingRecord.session_key ?? "", + sessionId: existingRecord.session_id ?? iso?.sessionId ?? "", + taskId: existingRecord.task_id ?? iso?.taskId, + teamId: existingRecord.team_id ?? iso?.teamId, + userId: existingRecord.user_id ?? iso?.userId, + agentId: existingRecord.agent_id ?? iso?.agentId, + }; + let emb: Float32Array | undefined; + if (embedding) { + try { emb = await embedding.embed(rec.content); } + catch (e) { console.warn(`[v2-router] L1 dedup update embedding failed:`, e); } + } + await store.upsertL1(updatedRecord, emb); + ids.push(hit.record_id); + updated++; + await recordAudit(store, { + record_id: hit.record_id, + layer: "L1", + action: "dedup_update", + iso, + version: updatedVersion, + requestId, + logger: deps.logger, + }); + continue; + } + // If existing record not found (race), fall through to create + } + } + } + + // ── Create new record ──────────────────────────────────────── + const id = generateMemoryId(); + const record: MemoryRecord = { + id, + content: rec.content, + type: rec.type ?? "persona", + priority: rec.priority ?? 50, + scene_name: rec.scene_name ?? "", + source_message_ids: [], + metadata: rec.metadata ?? {}, + timestamps: [now], + createdAt: now, + updatedAt: now, + version: 0, + sessionKey: "", + sessionId: iso?.sessionId ?? "", + taskId: iso?.taskId, + teamId: iso?.teamId, + userId: iso?.userId, + agentId: iso?.agentId, + }; + + let emb: Float32Array | undefined; + if (embedding) { + try { emb = await embedding.embed(rec.content); } + catch (e) { console.warn(`[v2-router] L1 create embedding failed for record ${id}:`, e); } + } + + await store.upsertL1(record, emb); + ids.push(id); + created++; + + await recordAudit(store, { + record_id: id, + layer: "L1", + action: "create", + iso, + version: 0, + requestId, + logger: deps.logger, + }); + } + + return successEnvelope({ + created: created + updated, + ids, + ...(skipped > 0 ? { skipped } : {}), + ...(updated > 0 ? { updated } : {}), + }, requestId); +} + async function handleAtomicQuery(body: unknown, _auth: V2AuthContext, requestId: string, deps: V2RouterDeps): Promise { const parsed = atomicQueryRequestSchema.safeParse(body); if (!parsed.success) return errorEnvelope(400, formatZodError(parsed.error), requestId); diff --git a/MemoryCore/src/gateway/v2-schemas.ts b/MemoryCore/src/gateway/v2-schemas.ts index 12aa594a4..be66221e1 100644 --- a/MemoryCore/src/gateway/v2-schemas.ts +++ b/MemoryCore/src/gateway/v2-schemas.ts @@ -29,6 +29,8 @@ export { atomicDetailSchema, atomicUpdateRequestSchema, atomicUpdateDataSchema, + atomicCreateRequestSchema, + atomicCreateDataSchema, atomicDeleteDataSchema, atomicQueryRequestSchema, atomicQueryDataSchema, @@ -60,6 +62,8 @@ export type { ConversationDeleteData, AtomicUpdateRequest, AtomicUpdateData, + AtomicCreateRequest, + AtomicCreateData, AtomicDeleteData, AtomicQueryRequest, ScenarioListRequest, diff --git a/MemoryPanel/src/panel/http/routes/chat-memory-import-parser.ts b/MemoryPanel/src/panel/http/routes/chat-memory-import-parser.ts new file mode 100644 index 000000000..624665f3f --- /dev/null +++ b/MemoryPanel/src/panel/http/routes/chat-memory-import-parser.ts @@ -0,0 +1,159 @@ +/** + * Parser for memory import files (.md / .json / .txt). + * + * Used by POST /chat-memory/import-memories to split user-supplied content + * into individual L1 atomic memory records before batch-writing via + * /v3/atomic/create. + * + * Splitting strategies: + * - Markdown (.md): split by `##` headings; each section becomes one record + * (heading + body). If no headings, fall back to paragraph splitting. + * - JSON (.json): expect an array of {content, type?, scene_name?, priority?}. + * - Text (.txt): split by blank lines; each non-empty paragraph is a record. + */ + +export interface ParsedMemoryRecord { + content: string; + type?: "persona" | "episodic" | "instruction" | "work_fact" | "work_task" | "work_method" | "work_artifact"; + scene_name?: string; + priority?: number; + metadata?: Record; +} + +export interface ParseResult { + records: ParsedMemoryRecord[]; + format: "markdown" | "json" | "text"; + truncated: number; // records dropped due to >8192 char content limit +} + +const MAX_CONTENT_LENGTH = 8192; + +/** + * Parse a file's text content into memory records based on filename extension. + */ +export function parseMemoryFile(filename: string, text: string): ParseResult { + const ext = filename.toLowerCase().split(".").pop() ?? ""; + switch (ext) { + case "md": + case "markdown": + return parseMarkdown(text); + case "json": + return parseJson(text); + case "txt": + case "text": + return parseText(text); + default: + // Fallback: try JSON first, then markdown, then text + try { + JSON.parse(text); + return parseJson(text); + } catch { + return parseMarkdown(text); + } + } +} + +/** + * Parse markdown by `##` headings. Each heading + its body becomes one record. + * If no headings found, split by blank-line paragraphs. + */ +function parseMarkdown(text: string): ParseResult { + const records: ParsedMemoryRecord[] = []; + let truncated = 0; + + // Try splitting by `##` headings first + const headingRe = /^##\s+(.+)$/gm; + const matches = [...text.matchAll(headingRe)]; + + if (matches.length > 0) { + // Split content at each `##` heading + const parts = text.split(/^(?=##\s+)/m); + for (const part of parts) { + const trimmed = part.trim(); + if (!trimmed) continue; + const content = trimmed.length > MAX_CONTENT_LENGTH + ? trimmed.slice(0, MAX_CONTENT_LENGTH) + : trimmed; + if (trimmed.length > MAX_CONTENT_LENGTH) truncated++; + records.push({ content, type: "persona" }); + } + } else { + // No headings — split by blank-line paragraphs + const paragraphs = text.split(/\n\s*\n/); + for (const para of paragraphs) { + const trimmed = para.trim(); + if (!trimmed) continue; + const content = trimmed.length > MAX_CONTENT_LENGTH + ? trimmed.slice(0, MAX_CONTENT_LENGTH) + : trimmed; + if (trimmed.length > MAX_CONTENT_LENGTH) truncated++; + records.push({ content, type: "persona" }); + } + } + + return { records, format: "markdown", truncated }; +} + +/** + * Parse JSON array of memory records. Each element should have at least `content`. + */ +function parseJson(text: string): ParseResult { + let parsed: unknown; + try { + parsed = JSON.parse(text); + } catch { + return { records: [], format: "json", truncated: 0 }; + } + + if (!Array.isArray(parsed)) { + // Single object with content — wrap as one-element array + if (parsed && typeof parsed === "object" && typeof (parsed as any).content === "string") { + parsed = [parsed]; + } else { + return { records: [], format: "json", truncated: 0 }; + } + } + + const records: ParsedMemoryRecord[] = []; + let truncated = 0; + + for (const item of parsed as any[]) { + if (!item || typeof item !== "object") continue; + const content = typeof item.content === "string" ? item.content : ""; + if (!content.trim()) continue; + const finalContent = content.length > MAX_CONTENT_LENGTH + ? content.slice(0, MAX_CONTENT_LENGTH) + : content; + if (content.length > MAX_CONTENT_LENGTH) truncated++; + records.push({ + content: finalContent, + type: item.type, + scene_name: item.scene_name ?? item.scene, + priority: typeof item.priority === "number" ? item.priority : undefined, + metadata: item.metadata, + }); + } + + return { records, format: "json", truncated }; +} + +/** + * Parse plain text by blank-line paragraphs. + */ +function parseText(text: string): ParseResult { + const records: ParsedMemoryRecord[] = []; + let truncated = 0; + + const paragraphs = text.split(/\n\s*\n/); + for (const para of paragraphs) { + const trimmed = para.trim(); + if (!trimmed) continue; + const content = trimmed.length > MAX_CONTENT_LENGTH + ? trimmed.slice(0, MAX_CONTENT_LENGTH) + : trimmed; + if (trimmed.length > MAX_CONTENT_LENGTH) truncated++; + records.push({ content, type: "persona" }); + } + + return { records, format: "text", truncated }; +} diff --git a/MemoryPanel/src/panel/http/routes/chat-memory.ts b/MemoryPanel/src/panel/http/routes/chat-memory.ts index 756c73984..808e0a985 100644 --- a/MemoryPanel/src/panel/http/routes/chat-memory.ts +++ b/MemoryPanel/src/panel/http/routes/chat-memory.ts @@ -30,6 +30,7 @@ * POST /chat-memory/layer-delete L0/L1 列表批量删除(Owner-only) * POST /chat-memory/clear 一键清空内容、保留资产(Owner-only) * POST /chat-memory/import 导入历史对话到 agent 的 L0 + * POST /chat-memory/import-memories 导入个人记忆到 L1 atomic(批量创建) */ import type { Hono } from "hono"; import { validatePanelMetaHeaders } from "../middleware/validate-panel-headers.js"; @@ -587,6 +588,108 @@ export function registerChatMemoryRoutes(api: Hono, deps: PanelDeps): void { ); }); + // 4.7b 导入个人记忆到 L1 atomic(批量创建,绕过 L0→L1 蒸馏 pipeline) + // + // 接受 JSON 数组或文件文本(.md/.json/.txt),解析后调 /v3/atomic/create 批量写入。 + // 与 /chat-memory/import(L0 对话历史)的区别:这个直接写 L1 原子记忆,供召回。 + // + // body: + // { team_id, agent_id, records?: [{content, type?, scene_name?, priority?}], + // file?: { name, content }, type?, scene_name? } + // 返回: { created, ids, truncated } + api.post("/chat-memory/import-memories", validatePanelMetaHeaders(deps), async (c) => { + const ctx = buildCtx(c); + const body = await readJson(c); + const teamId = requiredTeamId(body); + const agentId = typeof body?.agent_id === "string" ? body.agent_id : ""; + if (!teamId) return respondControlError(c, 400, "MISSING_TEAM_ID"); + if (!agentId) return respondControlError(c, 400, "MISSING_AGENT_ID"); + + // 权限:agent.owner = me + const meUserId = await resolveCallerUserId(deps, ctx); + if (!meUserId) return respondControlError(c, 401, "INVALID_USER_KEY"); + const agentEnv = await deps.metaKernel.invoke( + "agent/get", + { agent_id: agentId }, + ctx, + ); + if (agentEnv.code === 404 || (agentEnv.code === 0 && !agentEnv.data)) { + return respondControlError(c, 404, "AGENT_NOT_FOUND"); + } + if (agentEnv.code !== 0) return respondEnvelope(c, agentEnv); + const agent = agentEnv.data as AgentRaw; + if (agent.team_id !== teamId) + return respondControlError(c, 400, "AGENT_NOT_IN_TEAM"); + if (agent.owner_user_id !== meUserId) + return respondControlError(c, 403, "NOT_YOUR_AGENT"); + + // 解析记忆条目:优先用 body.records,否则从 body.file 解析 + const { parseMemoryFile } = await import("./chat-memory-import-parser.js"); + let records; + let format = "json"; + let truncated = 0; + + if (Array.isArray(body?.records)) { + // 直接传 JSON 数组 + records = (body.records as any[]).filter(r => r && typeof r.content === "string" && r.content.trim()); + } else if (body?.file && typeof body.file === "object") { + // 传文件 {name, content} + const fileObj = body.file as { name?: string; content?: string }; + const fileName = typeof fileObj.name === "string" ? fileObj.name : "import.json"; + const fileText = typeof fileObj.content === "string" ? fileObj.content : ""; + const result = parseMemoryFile(fileName, fileText); + records = result.records; + format = result.format; + truncated = result.truncated; + } else { + return respondControlError(c, 400, "MISSING_RECORDS_OR_FILE"); + } + + if (records.length === 0) { + return respondControlError(c, 400, "NO_VALID_RECORDS"); + } + if (records.length > 500) { + return respondControlError(c, 400, "TOO_MANY_RECORDS"); + } + + // 默认 type / scene_name(来自 body 顶层,可被 record 级覆盖) + const defaultType = typeof body?.type === "string" ? body.type : "persona"; + const defaultScene = typeof body?.scene_name === "string" ? body.scene_name : "imported"; + + const normalizedRecords = records.map(r => ({ + content: r.content, + type: r.type ?? defaultType, + scene_name: r.scene_name ?? defaultScene, + priority: r.priority, + metadata: r.metadata, + })); + + // 走数据面 /v3/atomic/create + const cred = toKernelCredentials(ctx, { timeoutMs: 30_000 }); + const createEnv = await deps.kernelHttp.postEnvelope<{ + created: number; + ids: string[]; + }>("/v3/atomic/create", { + team_id: teamId, + user_id: agent.owner_user_id, + agent_id: agentId, + records: normalizedRecords, + }, cred); + + if (createEnv.code !== 0) return respondEnvelope(c, createEnv); + + return respondEnvelope( + c, + okEnvelope(c, { + imported: true, + format, + created: (createEnv.data as any)?.created ?? 0, + ids: (createEnv.data as any)?.ids ?? [], + truncated, + }), + ); + }); + // 4.8 改 scope // // 双层校验: diff --git a/MemoryPanel/web/src/constants/menu.tsx b/MemoryPanel/web/src/constants/menu.tsx index 215ef75c5..4d0c24bbc 100644 --- a/MemoryPanel/web/src/constants/menu.tsx +++ b/MemoryPanel/web/src/constants/menu.tsx @@ -14,6 +14,7 @@ import { CodeIcon, ToolsIcon, ChatIcon, + DownloadIcon, } from 'tea-icons-react'; export type PageId = @@ -22,6 +23,7 @@ export type PageId = | 'code' | 'skills' | 'chat_memory' + | 'import_memories' | 'team_members' | 'team_agents' | 'api_keys'; @@ -48,6 +50,7 @@ export function usePageMeta(): Record { code: { id: 'code', label: t('menu.code'), desc: t('menu.desc.code'), group: t('menu.group.assets'), order: 3 }, skills: { id: 'skills', label: t('menu.skills'), desc: t('menu.desc.skills'), group: t('menu.group.assets'), order: 4 }, chat_memory: { id: 'chat_memory', label: t('menu.chat_memory'), desc: t('menu.desc.chat_memory'), group: t('menu.group.assets'), order: 5 }, + import_memories: { id: 'import_memories', label: t('menu.import_memories'), desc: t('menu.desc.import_memories'), group: t('menu.group.assets'), order: 6 }, team_members: { id: 'team_members', label: t('menu.team_members'), desc: t('menu.desc.team_members'), group: t('menu.group.organization'), order: 0 }, team_agents: { id: 'team_agents', label: t('menu.team_agents'), desc: t('menu.desc.team_agents'), group: t('menu.group.organization'), order: 1 }, api_keys: { id: 'api_keys', label: t('menu.api_keys'), desc: t('menu.desc.api_keys'), group: t('menu.group.organization'), order: 2 }, @@ -67,6 +70,7 @@ export const ITEM_ICON: Record = { code: , skills: , chat_memory: , + import_memories: , }; /** 分组图标(工作台 / 组织与权限 / 资产管理) */ diff --git a/MemoryPanel/web/src/i18n/zh-CN.ts b/MemoryPanel/web/src/i18n/zh-CN.ts index d2b8536b4..6158c0823 100644 --- a/MemoryPanel/web/src/i18n/zh-CN.ts +++ b/MemoryPanel/web/src/i18n/zh-CN.ts @@ -10,6 +10,7 @@ export const zhCN = { 'menu.code': 'Code_Graph', 'menu.skills': 'Skill 技能', 'menu.chat_memory': 'Chat_Memory', + 'menu.import_memories': '导入记忆', 'menu.team_members': '成员管理', 'menu.team_agents': 'Agents 管理', 'menu.api_keys': 'API Key', @@ -21,6 +22,7 @@ export const zhCN = { 'menu.desc.code': '仓库 / 索引 / 搜索 / 探索', 'menu.desc.skills': '全部 / 团队池 / Agent 资产', 'menu.desc.chat_memory': 'L0–L3 分层记忆资产', + 'menu.desc.import_memories': '导入个人记忆到 L1,供大模型召回', 'menu.desc.team_members': 'Team 成员 / 用户 / 角色', 'menu.desc.team_agents': 'Agent / 可配置范围 / 固定资产', 'menu.desc.api_keys': '管理你的 API Key,用于外部客户端接入', @@ -603,6 +605,7 @@ export const zhCN = { 'memory.scope.all': '全部', 'memory.scope.team': '团队资产', 'memory.scope.fixed': 'Agent 资产', + 'memory.scope.import': '导入记忆', 'memory.scope.scope': '可分配资产', 'memory.scope.personal': '我的资产分配', 'memory.noAgent': '无可选 Agent', @@ -764,6 +767,41 @@ export const zhCN = { 'importBlock.cancel': '取消', 'importBlock.noAgentHint': '请先选择归属 agent', + // ===== ImportMemoriesPanel ===== + 'importMemories.caption': '导入个人记忆', + 'importMemories.hint': '将本地记忆文件(markdown/json/txt)导入到 L1 原子记忆层,供大模型召回时使用。支持手动粘贴和文件上传两种方式。', + 'importMemories.agent': '归属 Agent', + 'importMemories.agent.noAgent': '当前 team 暂无 agent,请先创建 agent', + 'importMemories.mode.paste': '粘贴文本', + 'importMemories.mode.file': '上传文件', + 'importMemories.paste.label': '记忆内容', + 'importMemories.paste.placeholder': '粘贴 markdown 或 JSON 数组。\n\nMarkdown 格式:按 ## 标题切分,每段为一条记忆\nJSON 格式:[{content, type?, scene_name?}]', + 'importMemories.file.label': '选择文件', + 'importMemories.file.select': '选择文件', + 'importMemories.file.selected': '已选择:', + 'importMemories.file.preview': '预览', + 'importMemories.file.truncated': '…(已截断)', + 'importMemories.parse.empty': '内容为空', + 'importMemories.parse.success': '解析成功:共 {{count}} 条记忆', + 'importMemories.parse.failed': '解析失败:{{error}}', + 'importMemories.dedup': '去重', + 'importMemories.dedup.enabled': '启用去重', + 'importMemories.dedup.mode': '去重模式', + 'importMemories.dedup.mode.skip': '跳过重复', + 'importMemories.dedup.mode.update': '覆盖更新', + 'importMemories.dedup.threshold': '相似度阈值', + 'importMemories.submit': '导入', + 'importMemories.submitting': '导入中…', + 'importMemories.cancel': '取消', + 'importMemories.success': '导入成功:创建 {{created}} 条,跳过 {{skipped}} 条,更新 {{updated}} 条', + 'importMemories.success.simple': '导入成功:创建 {{created}} 条记忆', + 'importMemories.error': '导入失败:{{error}}', + 'importMemories.noAgentHint': '请先选择归属 agent', + 'importMemories.viewMemories': '查看已有记忆', + 'importMemories.memories': '已有记忆', + 'importMemories.memories.empty': '暂无记忆,请先导入', + 'importMemories.memories.loading': '加载中…', + // ===== AllocateAssetDialog ===== 'allocAsset.skill': 'Skill', 'allocAsset.wiki': 'Wiki', diff --git a/MemoryPanel/web/src/layouts/ConsoleLayout.tsx b/MemoryPanel/web/src/layouts/ConsoleLayout.tsx index dd842b99a..2bc91bcd7 100644 --- a/MemoryPanel/web/src/layouts/ConsoleLayout.tsx +++ b/MemoryPanel/web/src/layouts/ConsoleLayout.tsx @@ -23,6 +23,7 @@ const PATH_TO_PAGE: Record = { '/code': 'code', '/skills': 'skills', '/memory': 'chat_memory', + '/import-memories': 'import_memories', '/team/members': 'team_members', '/team/agents': 'team_agents', '/team/api-keys': 'api_keys', @@ -41,6 +42,7 @@ function legacyHashToPath(): string | null { if (leaf === 'code') return '/code'; if (leaf === 'skills' || leaf === 'skill') return '/skills'; if (leaf === 'chat_memory' || leaf === 'memory' || leaf === 'chat-memory') return '/memory'; + if (leaf === 'import_memories' || leaf === 'import-memories') return '/import-memories'; if (leaf === 'agents' || leaf === 'team_agents') return '/team/agents'; if (leaf === 'team' || leaf === 'members' || leaf === 'team_members') return '/team/members'; if (leaf === 'api_keys' || leaf === 'apikey' || leaf === 'api-keys') return '/team/api-keys'; diff --git a/MemoryPanel/web/src/lib/api/chat-memory.ts b/MemoryPanel/web/src/lib/api/chat-memory.ts index af588b0a5..a77e1ae4a 100644 --- a/MemoryPanel/web/src/lib/api/chat-memory.ts +++ b/MemoryPanel/web/src/lib/api/chat-memory.ts @@ -162,6 +162,38 @@ export const chatMemoryApi = { session_id: params.sessionId, }), + /** 导入个人记忆到 L1 atomic(走 /v3/atomic/create) */ + importMemories: (params: { + teamId: string; + agentId: string; + records: Array<{ + content: string; + type?: string; + scene_name?: string; + priority?: number; + metadata?: Record; + }>; + dedup?: { + enabled: boolean; + threshold?: number; + mode?: 'skip' | 'update'; + }; + }) => + chatMemoryCall<{ + imported: boolean; + format?: string; + created: number; + ids: string[]; + skipped?: number; + updated?: number; + truncated?: number; + }>('import-memories', { + team_id: params.teamId, + agent_id: params.agentId, + records: params.records, + dedup: params.dedup, + }), + /** 编辑单层记忆内容(Owner-only): * L1 传 id=记录主键 + content;L2 传 id=文件路径 + content(可选 summary); * L3 只传 content(整份 core persona 覆盖写)。 */ diff --git a/MemoryPanel/web/src/pages/ChatMemoryPage/components/ImportMemoriesPanel.tsx b/MemoryPanel/web/src/pages/ChatMemoryPage/components/ImportMemoriesPanel.tsx new file mode 100644 index 000000000..9226e44a6 --- /dev/null +++ b/MemoryPanel/web/src/pages/ChatMemoryPage/components/ImportMemoriesPanel.tsx @@ -0,0 +1,345 @@ +/** + * ImportMemoriesPanel — 导入个人记忆 tab。 + * + * 支持两种导入方式: + * 1. 粘贴文本(markdown 或 JSON 数组) + * 2. 上传文件(.md/.json/.txt) + * + * 可选去重(dedup):写入前搜索已有 L1,命中则 skip/update。 + * + * 导入后显示已有记忆列表,支持查看和删除。 + */ +import { useState, useMemo, useEffect, useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Alert, Button, Form, Input, Segment, Select, Text, Upload, Card } from 'tea-component'; +import { FilePasteIcon, UploadIcon, LoadingIcon } from 'tea-icons-react'; +import { tea } from '@/lib/tea-bridge'; +import { chatMemoryApi } from '@/lib/teamApi'; +import { type AgentOption } from '../constants/types'; + +interface ParsedRecord { + content: string; + type?: string; + scene_name?: string; + priority?: number; + metadata?: Record; +} + +type ParseResult = + | { ok: true; records: ParsedRecord[] } + | { ok: false; error: string }; + +const MAX_RECORDS = 500; + +/** 简易 markdown/JSON 解析(前端预览用,实际解析在后端) */ +function parseContent(text: string): ParseResult { + const trimmed = text.trim(); + if (!trimmed) return { ok: false, error: '内容为空' }; + + // 尝试 JSON 解析 + try { + const parsed = JSON.parse(trimmed); + if (Array.isArray(parsed)) { + const records: ParsedRecord[] = []; + for (const item of parsed) { + if (item && typeof item === 'object' && typeof item.content === 'string' && item.content.trim()) { + records.push({ + content: item.content, + type: item.type, + scene_name: item.scene_name ?? item.scene, + priority: item.priority, + metadata: item.metadata, + }); + } + } + if (records.length === 0) return { ok: false, error: 'JSON 数组中没有有效记录' }; + return { ok: true, records }; + } + if (parsed && typeof parsed === 'object' && typeof (parsed as any).content === 'string') { + return { ok: true, records: [parsed as ParsedRecord] }; + } + return { ok: false, error: 'JSON 不是数组也不是单个对象' }; + } catch { + // 非 JSON,按 markdown 解析 + } + + // Markdown: 按 ## heading 切分 + const headingRe = /^##\s+(.+)$/gm; + const matches = [...trimmed.matchAll(headingRe)]; + const records: ParsedRecord[] = []; + + if (matches.length > 0) { + const parts = trimmed.split(/^(?=##\s+)/m); + for (const part of parts) { + const t = part.trim(); + if (t) records.push({ content: t, type: 'persona' }); + } + } else { + // 按空行分段 + const paragraphs = trimmed.split(/\n\s*\n/); + for (const para of paragraphs) { + const t = para.trim(); + if (t) records.push({ content: t, type: 'persona' }); + } + } + + if (records.length === 0) return { ok: false, error: '未解析到有效内容' }; + return { ok: true, records }; +} + +export function ImportMemoriesPanel({ + activeTeamId, + agents, + defaultAgentId, +}: { + activeTeamId?: string | null; + agents: AgentOption[]; + defaultAgentId?: string; +}) { + const { t } = useTranslation(); + const [scopeAgentId, setScopeAgentId] = useState(defaultAgentId || agents[0]?.agent_id || ''); + const [importMode, setImportMode] = useState<'paste' | 'file'>('paste'); + const [payload, setPayload] = useState(''); + const [fileName, setFileName] = useState(''); + const [submitting, setSubmitting] = useState(false); + const [dedupEnabled, setDedupEnabled] = useState(false); + const [dedupMode, setDedupMode] = useState<'skip' | 'update'>('skip'); + const dedupThreshold = 0.85; + const [importResult, setImportResult] = useState<{ + created: number; + skipped?: number; + updated?: number; + } | null>(null); + + const parsed = useMemo(() => parseContent(payload), [payload]); + const canSubmit = !!scopeAgentId && !!activeTeamId && parsed.ok && !submitting && (parsed as any).records?.length > 0; + + function handleFilePicked(file: File): boolean { + setFileName(file.name); + const reader = new FileReader(); + reader.onload = () => setPayload(reader.result as string); + reader.onerror = () => setPayload(''); + reader.readAsText(file); + return false; + } + + async function submit() { + if (!parsed.ok || !scopeAgentId || !activeTeamId || submitting) return; + setSubmitting(true); + setImportResult(null); + try { + const records = (parsed as { ok: true; records: ParsedRecord[] }).records.slice(0, MAX_RECORDS); + const result = await chatMemoryApi.importMemories({ + teamId: activeTeamId, + agentId: scopeAgentId, + records, + dedup: dedupEnabled ? { enabled: true, threshold: dedupThreshold, mode: dedupMode } : undefined, + }); + setImportResult({ + created: result.created, + skipped: result.skipped, + updated: result.updated, + }); + tea.notify.success( + result.skipped || result.updated + ? t('importMemories.success', { created: result.created, skipped: result.skipped || 0, updated: result.updated || 0 }) + : t('importMemories.success.simple', { created: result.created }) + ); + setPayload(''); + setFileName(''); + // 刷新记忆列表 + fetchMemories(); + } catch (e: any) { + tea.notify.error(t('importMemories.error', { error: e?.message || String(e) })); + } finally { + setSubmitting(false); + } + } + + // 已有记忆列表 + const [memories, setMemories] = useState([]); + const [memoriesLoading, setMemoriesLoading] = useState(false); + + const fetchMemories = useCallback(async () => { + if (!scopeAgentId || !activeTeamId) return; + setMemoriesLoading(true); + try { + // 复用 layer API 获取 L1 记忆列表 + const result = await chatMemoryApi.layer( + `chat_memory-${activeTeamId}-${scopeAgentId}`, + 'L1', + 100, + ); + setMemories(result.items || []); + } catch { + setMemories([]); + } finally { + setMemoriesLoading(false); + } + }, [scopeAgentId, activeTeamId]); + + useEffect(() => { + fetchMemories(); + }, [fetchMemories]); + + return ( +
+ + {t('importMemories.hint')} + + +
+ + {agents.length === 0 ? ( + {t('importMemories.agent.noAgent')} + ) : ( +