feat(memory): surface per-user private memory copies in admin UI and tool results

This commit is contained in:
matevip 2026-07-31 01:50:52 -04:00
parent ad6b0728e9
commit 823efc0c36
10 changed files with 205 additions and 13 deletions

View File

@ -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=truedaily 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 不能为空";

View File

@ -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<WorkspaceFileEntity> listPersonalFiles(Long agentId) {
List<WorkspaceFileEntity> files = fileMapper.selectList(
new LambdaQueryWrapper<WorkspaceFileEntity>()
.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.

View File

@ -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<List<WorkspaceFileEntity>> 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<WorkspaceFileEntity> 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 ====================
/**

View File

@ -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<WorkspaceFileEntity> files = service.listPersonalFiles(42L);
assertThat(files).hasSize(1);
assertThat(files.get(0).getContent()).as("listing must not leak content").isNull();
@SuppressWarnings("unchecked")
ArgumentCaptor<LambdaQueryWrapper<WorkspaceFileEntity>> captor =
ArgumentCaptor.forClass(LambdaQueryWrapper.class);
org.mockito.Mockito.verify(fileMapper).selectList(captor.capture());
LambdaQueryWrapper<WorkspaceFileEntity> wrapper = captor.getValue();
wrapper.getTargetSql();
List<Object> 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() {

View File

@ -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[]) =>

View File

@ -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',

View File

@ -1306,6 +1306,9 @@ export default {
importPreviewSkipReason: '原因:{reason}',
importConfirmHint: '确认导入会覆盖现有同名文件。',
importApply: '确认导入',
personalMemory: '用户私有记忆',
personalMemoryDesc: 'Agent 在对话中为各用户单独写入的记忆副本,仅注入对应用户的会话,此处只读',
personalReadonly: '私有记忆 · {owner} · 只读',
},
agents: {
kicker: '员工工作室',

View File

@ -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']

View File

@ -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
}

View File

@ -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)"
>
<div class="file-item-main">
@ -93,6 +93,33 @@
</div>
</div>
</div>
<!-- Per-user PERSONAL memory copies (agent-written, admin read-only) -->
<template v-if="personalGroups.length > 0">
<div class="divider"></div>
<h3 class="section-title">{{ t('agentContext.personalMemory') }}</h3>
<p class="info-text">{{ t('agentContext.personalMemoryDesc') }}</p>
<div v-for="group in personalGroups" :key="group.ownerKey" class="personal-group">
<div class="personal-owner" :title="group.ownerKey">{{ group.ownerKey }}</div>
<div
v-for="file in group.files"
:key="group.ownerKey + '|' + file.filename"
class="file-item"
:class="{ selected: isSelectedPersonal(file) }"
@click="onPersonalFileClick(file)"
>
<div class="file-item-main">
<div class="file-item-info">
<span class="file-icon">🔒</span>
<span class="file-name">{{ file.filename }}</span>
</div>
<div class="file-item-meta">
<span class="file-size">{{ formatSize(file.fileSize) }}</span>
</div>
</div>
</div>
</div>
</template>
</div>
</div>
</div>
@ -104,9 +131,14 @@
<div class="editor-header">
<div class="editor-file-info">
<div class="editor-filename">{{ selectedFile.filename }}</div>
<div class="editor-meta">{{ formatSize(selectedFile.fileSize) }} · {{ formatTime(selectedFile.updateTime) }}</div>
<div class="editor-meta">
{{ formatSize(selectedFile.fileSize) }} · {{ formatTime(selectedFile.updateTime) }}
<span v-if="isPersonalSelected" class="personal-badge">
{{ t('agentContext.personalReadonly', { owner: selectedFile.ownerKey }) }}
</span>
</div>
</div>
<div class="editor-actions">
<div v-if="!isPersonalSelected" class="editor-actions">
<button class="btn-sm" @click="resetContent" :disabled="!hasChanges">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"/>
@ -173,6 +205,7 @@
v-model="fileContent"
class="editor-textarea"
:placeholder="t('agentContext.fileContent')"
:readonly="isPersonalSelected"
spellcheck="false"
></textarea>
<div
@ -342,6 +375,8 @@ const selectedAgentId = ref<string | number>('')
//
const files = ref<WorkspaceFile[]>([])
const enabledFiles = ref<string[]>([])
// Per-user PERSONAL memory rows (admin-only endpoint; empty when forbidden)
const personalFiles = ref<WorkspaceFile[]>([])
//
const selectedFile = ref<WorkspaceFile | null>(null)
@ -413,6 +448,24 @@ const sortedFiles = computed(() => {
const renderedMarkdown = computed(() => renderMarkdown(fileContent.value || ''))
const isPersonalSelected = computed(() => selectedFile.value?.scope === 'PERSONAL')
const personalGroups = computed(() => {
const groups = new Map<string, WorkspaceFile[]>()
for (const file of personalFiles.value) {
const owner = file.ownerKey || ''
if (!groups.has(owner)) groups.set(owner, [])
groups.get(owner)!.push(file)
}
return [...groups.entries()].map(([ownerKey, groupFiles]) => ({ ownerKey, files: groupFiles }))
})
function isSelectedPersonal(file: WorkspaceFile) {
return isPersonalSelected.value
&& selectedFile.value?.filename === file.filename
&& selectedFile.value?.ownerKey === file.ownerKey
}
const route = useRoute()
onMounted(async () => {
@ -431,6 +484,7 @@ watch(selectedAgentId, () => {
originalContent.value = ''
fetchFiles()
fetchPromptFiles()
fetchPersonalFiles()
}
})
@ -457,6 +511,17 @@ async function fetchFiles() {
}
}
async function fetchPersonalFiles() {
if (!selectedAgentId.value) return
try {
const res: any = await agentContextApi.listPersonalFiles(selectedAgentId.value)
personalFiles.value = res.data || []
} catch {
// 403 (not admin) or older backend just hide the section
personalFiles.value = []
}
}
async function fetchPromptFiles() {
if (!selectedAgentId.value) return
try {
@ -502,6 +567,20 @@ async function onFileClick(file: WorkspaceFile) {
}
}
async function onPersonalFileClick(file: WorkspaceFile) {
selectedFile.value = file
// Read-only view markdown preview is the most useful default
previewMode.value = 'preview'
try {
const res: any = await agentContextApi.getPersonalFile(
selectedAgentId.value, file.filename, file.ownerKey || '')
fileContent.value = res.data?.content || ''
originalContent.value = fileContent.value
} catch {
mcToast.error(t('agentContext.loadFileFailed'))
}
}
async function saveContent() {
if (!selectedFile.value || !selectedAgentId.value) return
saving.value = true
@ -748,6 +827,10 @@ function formatTime(time?: string): string {
.icon-btn:disabled { opacity: 0.45; cursor: not-allowed; }
.info-text { font-size: 12px; color: var(--mc-text-tertiary); padding: 6px 16px 0; margin: 0; line-height: 1.4; }
.divider { height: 1px; background: var(--mc-border-light); margin: 10px 16px; }
.file-scroll .section-title { padding: 6px 16px 0; }
.personal-group { margin-top: 6px; }
.personal-owner { font-size: 11px; color: var(--mc-text-tertiary); padding: 4px 16px 2px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.personal-badge { font-size: 11px; background: rgba(99, 102, 241, 0.12); color: #6366f1; padding: 2px 8px; border-radius: 10px; margin-left: 6px; }
.file-scroll { flex: 1; overflow-y: auto; padding: 0 8px 8px; }
.file-scroll::-webkit-scrollbar { width: 4px; }