mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-15 20:08:18 +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 org.springframework.stereotype.Component;
|
||||||
import vip.mate.agent.context.ChatOrigin;
|
import vip.mate.agent.context.ChatOrigin;
|
||||||
import vip.mate.memory.MemoryProperties;
|
import vip.mate.memory.MemoryProperties;
|
||||||
|
import vip.mate.memory.identity.MemoryScope;
|
||||||
import vip.mate.memory.identity.MemoryOwnerResolver;
|
import vip.mate.memory.identity.MemoryOwnerResolver;
|
||||||
import vip.mate.memory.service.MemoryRecallTracker;
|
import vip.mate.memory.service.MemoryRecallTracker;
|
||||||
import vip.mate.workspace.document.MemorySearchHit;
|
import vip.mate.workspace.document.MemorySearchHit;
|
||||||
@ -134,6 +135,8 @@ public class WorkspaceMemoryTool {
|
|||||||
为避免覆盖有价值内容,通常应先调用 read_workspace_memory_file 再决定写入。
|
为避免覆盖有价值内容,通常应先调用 read_workspace_memory_file 再决定写入。
|
||||||
注意:新建文件的 enabled 字段默认为 false,表示该文件不会自动纳入系统提示词——这是正常行为,不代表写入失败。
|
注意:新建文件的 enabled 字段默认为 false,表示该文件不会自动纳入系统提示词——这是正常行为,不代表写入失败。
|
||||||
PROFILE.md / MEMORY.md 等核心记忆文件在首次由种子数据创建时即为 enabled=true;daily note 文件按需读写即可。
|
PROFILE.md / MEMORY.md 等核心记忆文件在首次由种子数据创建时即为 enabled=true;daily note 文件按需读写即可。
|
||||||
|
返回值中的 scope 字段说明写入位置:PERSONAL 表示当前会话用户的私有记忆副本(仅对该用户后续会话生效,
|
||||||
|
不会出现在管理页的共享文件列表);TEAM 表示所有使用该 Agent 的用户共享的文件。向用户说明写入结果时请如实区分。
|
||||||
""")
|
""")
|
||||||
public String write_workspace_memory_file(
|
public String write_workspace_memory_file(
|
||||||
@ToolParam(description = "当前 Agent 的 ID") Long agentId,
|
@ToolParam(description = "当前 Agent 的 ID") Long agentId,
|
||||||
@ -157,7 +160,10 @@ public class WorkspaceMemoryTool {
|
|||||||
result.set("overwritten", before != null);
|
result.set("overwritten", before != null);
|
||||||
result.set("enabled", Boolean.TRUE.equals(saved.getEnabled()));
|
result.set("enabled", Boolean.TRUE.equals(saved.getEnabled()));
|
||||||
result.set("bytesWritten", (content != null ? content : "").getBytes(StandardCharsets.UTF_8).length);
|
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);
|
log.info("[WorkspaceMemoryTool] Saved workspace memory file: agentId={}, filename={}", agentId, filename);
|
||||||
return JSONUtil.toJsonPrettyStr(result);
|
return JSONUtil.toJsonPrettyStr(result);
|
||||||
}
|
}
|
||||||
@ -213,7 +219,7 @@ public class WorkspaceMemoryTool {
|
|||||||
replacements = 1;
|
replacements = 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
workspaceFileService.saveVisibleFile(agentId, filename, updated, ownerKey);
|
WorkspaceFileEntity saved = workspaceFileService.saveVisibleFile(agentId, filename, updated, ownerKey);
|
||||||
|
|
||||||
JSONObject result = new JSONObject();
|
JSONObject result = new JSONObject();
|
||||||
result.set("agentId", agentId);
|
result.set("agentId", agentId);
|
||||||
@ -221,7 +227,9 @@ public class WorkspaceMemoryTool {
|
|||||||
result.set("replacements", replacements);
|
result.set("replacements", replacements);
|
||||||
result.set("replaceAll", replaceAllFlag);
|
result.set("replaceAll", replaceAllFlag);
|
||||||
result.set("fileSizeAfter", updated.getBytes(StandardCharsets.UTF_8).length);
|
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={}",
|
log.info("[WorkspaceMemoryTool] Edited workspace memory file: agentId={}, filename={}, replacements={}",
|
||||||
agentId, filename, replacements);
|
agentId, filename, replacements);
|
||||||
return JSONUtil.toJsonPrettyStr(result);
|
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) {
|
private String validate(Long agentId, String filename) {
|
||||||
if (agentId == null) {
|
if (agentId == null) {
|
||||||
return "agentId 不能为空";
|
return "agentId 不能为空";
|
||||||
|
|||||||
@ -175,6 +175,24 @@ public class WorkspaceFileService {
|
|||||||
return files;
|
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
|
* Read a file visible to {@code ownerKey}: the owner's PERSONAL row when it
|
||||||
* exists, otherwise the shared row. Null when neither exists.
|
* exists, otherwise the shared row. Null when neither exists.
|
||||||
|
|||||||
@ -118,6 +118,40 @@ public class WorkspaceFileController {
|
|||||||
return R.ok();
|
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 ====================
|
// ==================== Memory snapshot export / import ====================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -249,6 +249,34 @@ class WorkspaceMemorySearchTest {
|
|||||||
assertThat(snippet.length()).isLessThan(line.length());
|
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
|
@Test
|
||||||
@DisplayName("Wrapper carries one content-LIKE per token plus the prefix group and LIMIT 50")
|
@DisplayName("Wrapper carries one content-LIKE per token plus the prefix group and LIMIT 50")
|
||||||
void wrapperContainsTermsAndPrefixes() {
|
void wrapperContainsTermsAndPrefixes() {
|
||||||
|
|||||||
@ -722,6 +722,12 @@ export const agentContextApi = {
|
|||||||
http.put(`/agents/${agentId}/workspace/files/${encodeFilePath(filename)}`, { content }),
|
http.put(`/agents/${agentId}/workspace/files/${encodeFilePath(filename)}`, { content }),
|
||||||
deleteFile: (agentId: string | number, filename: string) =>
|
deleteFile: (agentId: string | number, filename: string) =>
|
||||||
http.delete(`/agents/${agentId}/workspace/files/${encodeFilePath(filename)}`),
|
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) =>
|
getPromptFiles: (agentId: string | number) =>
|
||||||
http.get(`/agents/${agentId}/workspace/prompt-files`),
|
http.get(`/agents/${agentId}/workspace/prompt-files`),
|
||||||
setPromptFiles: (agentId: string | number, files: string[]) =>
|
setPromptFiles: (agentId: string | number, files: string[]) =>
|
||||||
|
|||||||
@ -1432,6 +1432,9 @@ export default {
|
|||||||
importPreviewSkipReason: 'reason: {reason}',
|
importPreviewSkipReason: 'reason: {reason}',
|
||||||
importConfirmHint: 'Confirming will overwrite existing files with the same name.',
|
importConfirmHint: 'Confirming will overwrite existing files with the same name.',
|
||||||
importApply: 'Confirm import',
|
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: {
|
agents: {
|
||||||
kicker: 'Employee Studio',
|
kicker: 'Employee Studio',
|
||||||
|
|||||||
@ -1306,6 +1306,9 @@ export default {
|
|||||||
importPreviewSkipReason: '原因:{reason}',
|
importPreviewSkipReason: '原因:{reason}',
|
||||||
importConfirmHint: '确认导入会覆盖现有同名文件。',
|
importConfirmHint: '确认导入会覆盖现有同名文件。',
|
||||||
importApply: '确认导入',
|
importApply: '确认导入',
|
||||||
|
personalMemory: '用户私有记忆',
|
||||||
|
personalMemoryDesc: 'Agent 在对话中为各用户单独写入的记忆副本,仅注入对应用户的会话,此处只读',
|
||||||
|
personalReadonly: '私有记忆 · {owner} · 只读',
|
||||||
},
|
},
|
||||||
agents: {
|
agents: {
|
||||||
kicker: '员工工作室',
|
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 */
|
/* prettier-ignore */
|
||||||
declare module 'vue' {
|
declare module 'vue' {
|
||||||
export interface GlobalComponents {
|
export interface GlobalComponents {
|
||||||
ElAlert: typeof import('element-plus/es/components/alert/index')['ElAlert']
|
|
||||||
ElButton: typeof import('element-plus/es/components/button/index')['ElButton']
|
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']
|
ElConfigProvider: typeof import('element-plus/es/components/config-provider/index')['ElConfigProvider']
|
||||||
ElDatePicker: typeof import('element-plus/es/components/date-picker/index')['ElDatePicker']
|
ElDatePicker: typeof import('element-plus/es/components/date-picker/index')['ElDatePicker']
|
||||||
ElDialog: typeof import('element-plus/es/components/dialog/index')['ElDialog']
|
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']
|
ElDropdownItem: typeof import('element-plus/es/components/dropdown/index')['ElDropdownItem']
|
||||||
ElDropdownMenu: typeof import('element-plus/es/components/dropdown/index')['ElDropdownMenu']
|
ElDropdownMenu: typeof import('element-plus/es/components/dropdown/index')['ElDropdownMenu']
|
||||||
ElEmpty: typeof import('element-plus/es/components/empty/index')['ElEmpty']
|
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']
|
ElIcon: typeof import('element-plus/es/components/icon/index')['ElIcon']
|
||||||
ElImageViewer: typeof import('element-plus/es/components/image-viewer/index')['ElImageViewer']
|
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']
|
ElOption: typeof import('element-plus/es/components/select/index')['ElOption']
|
||||||
ElPagination: typeof import('element-plus/es/components/pagination/index')['ElPagination']
|
ElPagination: typeof import('element-plus/es/components/pagination/index')['ElPagination']
|
||||||
ElPopover: typeof import('element-plus/es/components/popover/index')['ElPopover']
|
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']
|
ElSelect: typeof import('element-plus/es/components/select/index')['ElSelect']
|
||||||
ElSkeleton: typeof import('element-plus/es/components/skeleton/index')['ElSkeleton']
|
ElSkeleton: typeof import('element-plus/es/components/skeleton/index')['ElSkeleton']
|
||||||
ElTable: typeof import('element-plus/es/components/table/index')['ElTable']
|
ElTable: typeof import('element-plus/es/components/table/index')['ElTable']
|
||||||
ElTableColumn: typeof import('element-plus/es/components/table/index')['ElTableColumn']
|
ElTableColumn: typeof import('element-plus/es/components/table/index')['ElTableColumn']
|
||||||
ElTabPane: typeof import('element-plus/es/components/tabs/index')['ElTabPane']
|
ElTabPane: typeof import('element-plus/es/components/tabs/index')['ElTabPane']
|
||||||
ElTabs: typeof import('element-plus/es/components/tabs/index')['ElTabs']
|
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']
|
ElTooltip: typeof import('element-plus/es/components/tooltip/index')['ElTooltip']
|
||||||
RouterLink: typeof import('vue-router')['RouterLink']
|
RouterLink: typeof import('vue-router')['RouterLink']
|
||||||
RouterView: typeof import('vue-router')['RouterView']
|
RouterView: typeof import('vue-router')['RouterView']
|
||||||
|
|||||||
@ -774,6 +774,10 @@ export interface WorkspaceFile {
|
|||||||
fileSize: number
|
fileSize: number
|
||||||
enabled: boolean
|
enabled: boolean
|
||||||
sortOrder: number
|
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
|
createTime: string
|
||||||
updateTime: string
|
updateTime: string
|
||||||
}
|
}
|
||||||
|
|||||||
@ -76,7 +76,7 @@
|
|||||||
v-for="file in sortedFiles"
|
v-for="file in sortedFiles"
|
||||||
:key="file.filename"
|
:key="file.filename"
|
||||||
class="file-item"
|
class="file-item"
|
||||||
:class="{ selected: selectedFile?.filename === file.filename }"
|
:class="{ selected: !isPersonalSelected && selectedFile?.filename === file.filename }"
|
||||||
@click="onFileClick(file)"
|
@click="onFileClick(file)"
|
||||||
>
|
>
|
||||||
<div class="file-item-main">
|
<div class="file-item-main">
|
||||||
@ -93,6 +93,33 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -104,9 +131,14 @@
|
|||||||
<div class="editor-header">
|
<div class="editor-header">
|
||||||
<div class="editor-file-info">
|
<div class="editor-file-info">
|
||||||
<div class="editor-filename">{{ selectedFile.filename }}</div>
|
<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>
|
||||||
<div class="editor-actions">
|
<div v-if="!isPersonalSelected" class="editor-actions">
|
||||||
<button class="btn-sm" @click="resetContent" :disabled="!hasChanges">
|
<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">
|
<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"/>
|
<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"
|
v-model="fileContent"
|
||||||
class="editor-textarea"
|
class="editor-textarea"
|
||||||
:placeholder="t('agentContext.fileContent')"
|
:placeholder="t('agentContext.fileContent')"
|
||||||
|
:readonly="isPersonalSelected"
|
||||||
spellcheck="false"
|
spellcheck="false"
|
||||||
></textarea>
|
></textarea>
|
||||||
<div
|
<div
|
||||||
@ -342,6 +375,8 @@ const selectedAgentId = ref<string | number>('')
|
|||||||
// 文件列表
|
// 文件列表
|
||||||
const files = ref<WorkspaceFile[]>([])
|
const files = ref<WorkspaceFile[]>([])
|
||||||
const enabledFiles = ref<string[]>([])
|
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)
|
const selectedFile = ref<WorkspaceFile | null>(null)
|
||||||
@ -413,6 +448,24 @@ const sortedFiles = computed(() => {
|
|||||||
|
|
||||||
const renderedMarkdown = computed(() => renderMarkdown(fileContent.value || ''))
|
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()
|
const route = useRoute()
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
@ -431,6 +484,7 @@ watch(selectedAgentId, () => {
|
|||||||
originalContent.value = ''
|
originalContent.value = ''
|
||||||
fetchFiles()
|
fetchFiles()
|
||||||
fetchPromptFiles()
|
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() {
|
async function fetchPromptFiles() {
|
||||||
if (!selectedAgentId.value) return
|
if (!selectedAgentId.value) return
|
||||||
try {
|
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() {
|
async function saveContent() {
|
||||||
if (!selectedFile.value || !selectedAgentId.value) return
|
if (!selectedFile.value || !selectedAgentId.value) return
|
||||||
saving.value = true
|
saving.value = true
|
||||||
@ -748,6 +827,10 @@ function formatTime(time?: string): string {
|
|||||||
.icon-btn:disabled { opacity: 0.45; cursor: not-allowed; }
|
.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; }
|
.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; }
|
.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 { flex: 1; overflow-y: auto; padding: 0 8px 8px; }
|
||||||
.file-scroll::-webkit-scrollbar { width: 4px; }
|
.file-scroll::-webkit-scrollbar { width: 4px; }
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user