mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
feat(memory): surface per-user private memory copies in admin UI and tool results
This commit is contained in:
parent
ad6b0728e9
commit
823efc0c36
@ -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 不能为空";
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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 ====================
|
||||
|
||||
/**
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -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[]) =>
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -1306,6 +1306,9 @@ export default {
|
||||
importPreviewSkipReason: '原因:{reason}',
|
||||
importConfirmHint: '确认导入会覆盖现有同名文件。',
|
||||
importApply: '确认导入',
|
||||
personalMemory: '用户私有记忆',
|
||||
personalMemoryDesc: 'Agent 在对话中为各用户单独写入的记忆副本,仅注入对应用户的会话,此处只读',
|
||||
personalReadonly: '私有记忆 · {owner} · 只读',
|
||||
},
|
||||
agents: {
|
||||
kicker: '员工工作室',
|
||||
|
||||
7
mateclaw-ui/src/types/components.d.ts
vendored
7
mateclaw-ui/src/types/components.d.ts
vendored
@ -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']
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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; }
|
||||
|
||||
Loading…
Reference in New Issue
Block a user