diff --git a/mateclaw-server/src/main/java/vip/mate/tool/builtin/WorkspaceMemoryTool.java b/mateclaw-server/src/main/java/vip/mate/tool/builtin/WorkspaceMemoryTool.java index 3c2e28c1..f6bce0ef 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/builtin/WorkspaceMemoryTool.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/WorkspaceMemoryTool.java @@ -11,6 +11,7 @@ import org.springframework.ai.tool.annotation.ToolParam; import org.springframework.stereotype.Component; import vip.mate.agent.context.ChatOrigin; import vip.mate.memory.MemoryProperties; +import vip.mate.memory.identity.MemoryScope; import vip.mate.memory.identity.MemoryOwnerResolver; import vip.mate.memory.service.MemoryRecallTracker; import vip.mate.workspace.document.MemorySearchHit; @@ -134,6 +135,8 @@ public class WorkspaceMemoryTool { 为避免覆盖有价值内容,通常应先调用 read_workspace_memory_file 再决定写入。 注意:新建文件的 enabled 字段默认为 false,表示该文件不会自动纳入系统提示词——这是正常行为,不代表写入失败。 PROFILE.md / MEMORY.md 等核心记忆文件在首次由种子数据创建时即为 enabled=true;daily note 文件按需读写即可。 + 返回值中的 scope 字段说明写入位置:PERSONAL 表示当前会话用户的私有记忆副本(仅对该用户后续会话生效, + 不会出现在管理页的共享文件列表);TEAM 表示所有使用该 Agent 的用户共享的文件。向用户说明写入结果时请如实区分。 """) public String write_workspace_memory_file( @ToolParam(description = "当前 Agent 的 ID") Long agentId, @@ -157,7 +160,10 @@ public class WorkspaceMemoryTool { result.set("overwritten", before != null); result.set("enabled", Boolean.TRUE.equals(saved.getEnabled())); result.set("bytesWritten", (content != null ? content : "").getBytes(StandardCharsets.UTF_8).length); - result.set("message", before == null ? "工作区记忆文件已创建" : "工作区记忆文件已覆写"); + result.set("scope", saved.getScope()); + result.set("ownerKey", saved.getOwnerKey()); + result.set("message", (before == null ? "工作区记忆文件已创建" : "工作区记忆文件已覆写") + + scopeHint(saved.getScope())); log.info("[WorkspaceMemoryTool] Saved workspace memory file: agentId={}, filename={}", agentId, filename); return JSONUtil.toJsonPrettyStr(result); } @@ -213,7 +219,7 @@ public class WorkspaceMemoryTool { replacements = 1; } - workspaceFileService.saveVisibleFile(agentId, filename, updated, ownerKey); + WorkspaceFileEntity saved = workspaceFileService.saveVisibleFile(agentId, filename, updated, ownerKey); JSONObject result = new JSONObject(); result.set("agentId", agentId); @@ -221,7 +227,9 @@ public class WorkspaceMemoryTool { result.set("replacements", replacements); result.set("replaceAll", replaceAllFlag); result.set("fileSizeAfter", updated.getBytes(StandardCharsets.UTF_8).length); - result.set("message", "工作区记忆文件编辑成功"); + result.set("scope", saved.getScope()); + result.set("ownerKey", saved.getOwnerKey()); + result.set("message", "工作区记忆文件编辑成功" + scopeHint(saved.getScope())); log.info("[WorkspaceMemoryTool] Edited workspace memory file: agentId={}, filename={}, replacements={}", agentId, filename, replacements); return JSONUtil.toJsonPrettyStr(result); @@ -315,6 +323,18 @@ public class WorkspaceMemoryTool { }; } + /** + * Human-readable suffix explaining where a write landed, so the agent can + * relay accurately whether the memory is a per-user private copy or the + * shared file every user of the agent sees. + */ + private static String scopeHint(String scope) { + if (MemoryScope.PERSONAL.equals(scope)) { + return "(写入的是当前会话用户的私有记忆副本,仅对该用户生效,不会出现在管理页的共享文件列表中)"; + } + return "(写入的是共享文件,对所有使用该 Agent 的用户可见)"; + } + private String validate(Long agentId, String filename) { if (agentId == null) { return "agentId 不能为空"; diff --git a/mateclaw-server/src/main/java/vip/mate/workspace/document/WorkspaceFileService.java b/mateclaw-server/src/main/java/vip/mate/workspace/document/WorkspaceFileService.java index b9fa712f..623ef124 100644 --- a/mateclaw-server/src/main/java/vip/mate/workspace/document/WorkspaceFileService.java +++ b/mateclaw-server/src/main/java/vip/mate/workspace/document/WorkspaceFileService.java @@ -175,6 +175,24 @@ public class WorkspaceFileService { return files; } + /** + * List every owner's PERSONAL memory rows for an agent (metadata only, + * content stripped). Admin-surface listing so operators can see that + * per-user memory copies exist alongside the shared config files — + * reading a row's content goes through + * {@link #getMemoryFile(Long, String, String)}. + */ + public List listPersonalFiles(Long agentId) { + List files = fileMapper.selectList( + new LambdaQueryWrapper() + .eq(WorkspaceFileEntity::getAgentId, agentId) + .eq(WorkspaceFileEntity::getScope, MemoryScope.PERSONAL) + .orderByAsc(WorkspaceFileEntity::getOwnerKey) + .orderByAsc(WorkspaceFileEntity::getFilename)); + files.forEach(f -> f.setContent(null)); + return files; + } + /** * Read a file visible to {@code ownerKey}: the owner's PERSONAL row when it * exists, otherwise the shared row. Null when neither exists. diff --git a/mateclaw-server/src/main/java/vip/mate/workspace/document/controller/WorkspaceFileController.java b/mateclaw-server/src/main/java/vip/mate/workspace/document/controller/WorkspaceFileController.java index 70bf6d57..07e8d822 100644 --- a/mateclaw-server/src/main/java/vip/mate/workspace/document/controller/WorkspaceFileController.java +++ b/mateclaw-server/src/main/java/vip/mate/workspace/document/controller/WorkspaceFileController.java @@ -118,6 +118,40 @@ public class WorkspaceFileController { return R.ok(); } + // ==================== Per-owner PERSONAL memory (admin read-only) ==================== + + /** + * List every owner's PERSONAL memory rows (metadata only). These rows are + * written by agents during conversations and are scoped to a single end + * user, so they never appear in the shared file list above. Admin-gated: + * the listing exposes which subjects (owner keys) hold private memory. + */ + @Operation(summary = "列出各用户的私有记忆文件(仅元数据)") + @RequireWorkspaceRole("admin") + @GetMapping("/memory/personal-files") + public R> listPersonalFiles(@PathVariable Long agentId) { + return R.ok(workspaceFileService.listPersonalFiles(agentId)); + } + + /** + * Read one owner's PERSONAL memory file content. Query params (not path + * segments) because both the filename ({@code memory/2026-06-02.md}) and + * the owner key ({@code feishu:ou_xxx}) contain characters that clash with + * path mapping. + */ + @Operation(summary = "读取单个用户私有记忆文件内容") + @RequireWorkspaceRole("admin") + @GetMapping("/memory/personal-file") + public R getPersonalFile(@PathVariable Long agentId, + @RequestParam String filename, + @RequestParam String ownerKey) { + WorkspaceFileEntity file = workspaceFileService.getMemoryFile(agentId, filename, ownerKey); + if (file == null) { + return R.fail("文件不存在: " + filename); + } + return R.ok(file); + } + // ==================== Memory snapshot export / import ==================== /** diff --git a/mateclaw-server/src/test/java/vip/mate/workspace/document/WorkspaceMemorySearchTest.java b/mateclaw-server/src/test/java/vip/mate/workspace/document/WorkspaceMemorySearchTest.java index 6b772ff9..8ba5225e 100644 --- a/mateclaw-server/src/test/java/vip/mate/workspace/document/WorkspaceMemorySearchTest.java +++ b/mateclaw-server/src/test/java/vip/mate/workspace/document/WorkspaceMemorySearchTest.java @@ -249,6 +249,34 @@ class WorkspaceMemorySearchTest { assertThat(snippet.length()).isLessThan(line.length()); } + @Test + @DisplayName("listPersonalFiles restricts to PERSONAL rows and strips content") + void listPersonalFilesScopesToPersonalAndStripsContent() { + WorkspaceFileEntity row = new WorkspaceFileEntity(); + row.setFilename("MEMORY.md"); + row.setOwnerKey("user:admin"); + row.setScope("PERSONAL"); + row.setContent("private notes"); + when(fileMapper.selectList(any())).thenReturn(new ArrayList<>(List.of(row))); + + List files = service.listPersonalFiles(42L); + + assertThat(files).hasSize(1); + assertThat(files.get(0).getContent()).as("listing must not leak content").isNull(); + + @SuppressWarnings("unchecked") + ArgumentCaptor> captor = + ArgumentCaptor.forClass(LambdaQueryWrapper.class); + org.mockito.Mockito.verify(fileMapper).selectList(captor.capture()); + LambdaQueryWrapper wrapper = captor.getValue(); + wrapper.getTargetSql(); + + List values = new ArrayList<>(wrapper.getParamNameValuePairs().values()); + assertThat(values).contains(42L, "PERSONAL"); + assertThat(values).as("shared scopes must not appear in the filter") + .doesNotContain("TEAM", "GLOBAL"); + } + @Test @DisplayName("Wrapper carries one content-LIKE per token plus the prefix group and LIMIT 50") void wrapperContainsTermsAndPrefixes() { diff --git a/mateclaw-ui/src/api/index.ts b/mateclaw-ui/src/api/index.ts index e2fcbb3c..6e08100e 100644 --- a/mateclaw-ui/src/api/index.ts +++ b/mateclaw-ui/src/api/index.ts @@ -722,6 +722,12 @@ export const agentContextApi = { http.put(`/agents/${agentId}/workspace/files/${encodeFilePath(filename)}`, { content }), deleteFile: (agentId: string | number, filename: string) => http.delete(`/agents/${agentId}/workspace/files/${encodeFilePath(filename)}`), + // Per-owner PERSONAL memory copies written by agents during conversations. + // Admin-only on the backend; callers should treat a 403 as "hide the section". + listPersonalFiles: (agentId: string | number) => + http.get(`/agents/${agentId}/workspace/memory/personal-files`), + getPersonalFile: (agentId: string | number, filename: string, ownerKey: string) => + http.get(`/agents/${agentId}/workspace/memory/personal-file`, { params: { filename, ownerKey } }), getPromptFiles: (agentId: string | number) => http.get(`/agents/${agentId}/workspace/prompt-files`), setPromptFiles: (agentId: string | number, files: string[]) => diff --git a/mateclaw-ui/src/i18n/locales/en-US.ts b/mateclaw-ui/src/i18n/locales/en-US.ts index 9fdea622..f3561919 100644 --- a/mateclaw-ui/src/i18n/locales/en-US.ts +++ b/mateclaw-ui/src/i18n/locales/en-US.ts @@ -1432,6 +1432,9 @@ export default { importPreviewSkipReason: 'reason: {reason}', importConfirmHint: 'Confirming will overwrite existing files with the same name.', importApply: 'Confirm import', + personalMemory: 'Per-user private memory', + personalMemoryDesc: 'Memory copies the agent wrote for individual users during conversations; injected only into that user\'s sessions. Read-only here.', + personalReadonly: 'Private memory · {owner} · read-only', }, agents: { kicker: 'Employee Studio', diff --git a/mateclaw-ui/src/i18n/locales/zh-CN.ts b/mateclaw-ui/src/i18n/locales/zh-CN.ts index ecb67267..f3ca3b0d 100644 --- a/mateclaw-ui/src/i18n/locales/zh-CN.ts +++ b/mateclaw-ui/src/i18n/locales/zh-CN.ts @@ -1306,6 +1306,9 @@ export default { importPreviewSkipReason: '原因:{reason}', importConfirmHint: '确认导入会覆盖现有同名文件。', importApply: '确认导入', + personalMemory: '用户私有记忆', + personalMemoryDesc: 'Agent 在对话中为各用户单独写入的记忆副本,仅注入对应用户的会话,此处只读', + personalReadonly: '私有记忆 · {owner} · 只读', }, agents: { kicker: '员工工作室', diff --git a/mateclaw-ui/src/types/components.d.ts b/mateclaw-ui/src/types/components.d.ts index 1dc64a8d..14909b9f 100644 --- a/mateclaw-ui/src/types/components.d.ts +++ b/mateclaw-ui/src/types/components.d.ts @@ -11,9 +11,7 @@ export {} /* prettier-ignore */ declare module 'vue' { export interface GlobalComponents { - ElAlert: typeof import('element-plus/es/components/alert/index')['ElAlert'] ElButton: typeof import('element-plus/es/components/button/index')['ElButton'] - ElCard: typeof import('element-plus/es/components/card/index')['ElCard'] ElConfigProvider: typeof import('element-plus/es/components/config-provider/index')['ElConfigProvider'] ElDatePicker: typeof import('element-plus/es/components/date-picker/index')['ElDatePicker'] ElDialog: typeof import('element-plus/es/components/dialog/index')['ElDialog'] @@ -22,22 +20,17 @@ declare module 'vue' { ElDropdownItem: typeof import('element-plus/es/components/dropdown/index')['ElDropdownItem'] ElDropdownMenu: typeof import('element-plus/es/components/dropdown/index')['ElDropdownMenu'] ElEmpty: typeof import('element-plus/es/components/empty/index')['ElEmpty'] - ElForm: typeof import('element-plus/es/components/form/index')['ElForm'] - ElFormItem: typeof import('element-plus/es/components/form/index')['ElFormItem'] ElIcon: typeof import('element-plus/es/components/icon/index')['ElIcon'] ElImageViewer: typeof import('element-plus/es/components/image-viewer/index')['ElImageViewer'] - ElInput: typeof import('element-plus/es/components/input/index')['ElInput'] ElOption: typeof import('element-plus/es/components/select/index')['ElOption'] ElPagination: typeof import('element-plus/es/components/pagination/index')['ElPagination'] ElPopover: typeof import('element-plus/es/components/popover/index')['ElPopover'] - ElProgress: typeof import('element-plus/es/components/progress/index')['ElProgress'] ElSelect: typeof import('element-plus/es/components/select/index')['ElSelect'] ElSkeleton: typeof import('element-plus/es/components/skeleton/index')['ElSkeleton'] ElTable: typeof import('element-plus/es/components/table/index')['ElTable'] ElTableColumn: typeof import('element-plus/es/components/table/index')['ElTableColumn'] ElTabPane: typeof import('element-plus/es/components/tabs/index')['ElTabPane'] ElTabs: typeof import('element-plus/es/components/tabs/index')['ElTabs'] - ElTag: typeof import('element-plus/es/components/tag/index')['ElTag'] ElTooltip: typeof import('element-plus/es/components/tooltip/index')['ElTooltip'] RouterLink: typeof import('vue-router')['RouterLink'] RouterView: typeof import('vue-router')['RouterView'] diff --git a/mateclaw-ui/src/types/index.ts b/mateclaw-ui/src/types/index.ts index 11a6485a..d0866451 100644 --- a/mateclaw-ui/src/types/index.ts +++ b/mateclaw-ui/src/types/index.ts @@ -774,6 +774,10 @@ export interface WorkspaceFile { fileSize: number enabled: boolean sortOrder: number + /** Memory subject for PERSONAL rows ("user:42", "feishu:ou_xxx"); empty/null for shared rows */ + ownerKey?: string | null + /** Visibility scope: PERSONAL / TEAM / GLOBAL */ + scope?: string createTime: string updateTime: string } diff --git a/mateclaw-ui/src/views/AgentContext.vue b/mateclaw-ui/src/views/AgentContext.vue index 7d5f7690..b87205bf 100644 --- a/mateclaw-ui/src/views/AgentContext.vue +++ b/mateclaw-ui/src/views/AgentContext.vue @@ -76,7 +76,7 @@ v-for="file in sortedFiles" :key="file.filename" class="file-item" - :class="{ selected: selectedFile?.filename === file.filename }" + :class="{ selected: !isPersonalSelected && selectedFile?.filename === file.filename }" @click="onFileClick(file)" >
@@ -93,6 +93,33 @@
+ + + @@ -104,9 +131,14 @@
{{ selectedFile.filename }}
-
{{ formatSize(selectedFile.fileSize) }} · {{ formatTime(selectedFile.updateTime) }}
+
+ {{ formatSize(selectedFile.fileSize) }} · {{ formatTime(selectedFile.updateTime) }} + + {{ t('agentContext.personalReadonly', { owner: selectedFile.ownerKey }) }} + +
-
+