feat: decouple memory/KG sharing from workspace folder sharing

Add independent `share_memory` config flag to control memory and
knowledge graph sharing separately from workspace folder isolation.

- Add ShareMemory field to WorkspaceSharingConfig
- Decouple WithSharedMemory(ctx) from shouldShareWorkspace() in loop.go
- Add shouldShareMemory() helper independent of workspace sharing
- Fix KG Traverse CTE to scope user_id in recursive step (pre-existing bug)
- Add memory toggle UI with violet styling in workspace sharing section
- Add i18n translations (en/vi/zh) for new memory sharing controls
- Add unit tests for shouldShareMemory() independence
This commit is contained in:
viettranx committed 2026-03-12 18:26:40 +07:00
1 parent 23e34eec80
commit 6eb33f9cea
11 files changed
+115 -27

No files matched your search

+3 -4
View File
@@ -104,11 +104,10 @@ func (l *Loop) runLoop(ctx context.Context, req RunRequest) (*RunResult, error)
cachedWs = ws
}
effectiveWorkspace := cachedWs.(string)
shared := l.shouldShareWorkspace(req.UserID, req.PeerKind)
if !shared {
if !l.shouldShareWorkspace(req.UserID, req.PeerKind) {
effectiveWorkspace = filepath.Join(effectiveWorkspace, sanitizePathSegment(req.UserID))
} else {
// Shared workspace → share memory across users too.
}
if l.shouldShareMemory() {
ctx = store.WithSharedMemory(ctx)
}
if err := os.MkdirAll(effectiveWorkspace, 0755); err != nil {
+6
View File
@@ -60,6 +60,12 @@ func (l *Loop) shouldShareWorkspace(userID, peerKind string) bool {
return false
}
// shouldShareMemory returns true if memory/KG should be shared across all users.
// Independent of workspace folder sharing.
func (l *Loop) shouldShareMemory() bool {
return l.workspaceSharing != nil && l.workspaceSharing.ShareMemory
}
// InvalidateUserWorkspace clears the cached workspace for a user,
// forcing the next request to re-read from user_agent_profiles.
func (l *Loop) InvalidateUserWorkspace(userID string) {
+43
View File
@@ -78,6 +78,49 @@ func TestShouldShareWorkspace_UnknownPeerKind(t *testing.T) {
}
}
// shouldShareMemory tests — independent of workspace sharing
func TestShouldShareMemory_NilConfig(t *testing.T) {
l := &Loop{workspaceSharing: nil}
if l.shouldShareMemory() {
t.Error("nil config should not share memory")
}
}
func TestShouldShareMemory_Enabled(t *testing.T) {
l := &Loop{workspaceSharing: &store.WorkspaceSharingConfig{ShareMemory: true}}
if !l.shouldShareMemory() {
t.Error("share_memory=true should share memory")
}
}
func TestShouldShareMemory_DisabledByDefault(t *testing.T) {
l := &Loop{workspaceSharing: &store.WorkspaceSharingConfig{SharedDM: true}}
if l.shouldShareMemory() {
t.Error("SharedDM without ShareMemory should not share memory")
}
}
func TestShouldShareMemory_IndependentOfWorkspace(t *testing.T) {
// share_memory=true but no workspace sharing → memory shared, workspace per-user
l := &Loop{workspaceSharing: &store.WorkspaceSharingConfig{ShareMemory: true}}
if !l.shouldShareMemory() {
t.Error("share_memory should work without workspace sharing")
}
if l.shouldShareWorkspace("user1", "direct") {
t.Error("workspace should NOT be shared when only share_memory is set")
}
// workspace shared but share_memory=false → workspace shared, memory per-user
l2 := &Loop{workspaceSharing: &store.WorkspaceSharingConfig{SharedDM: true, ShareMemory: false}}
if l2.shouldShareMemory() {
t.Error("memory should NOT be shared when share_memory=false")
}
if !l2.shouldShareWorkspace("user1", "direct") {
t.Error("workspace should be shared when SharedDM=true")
}
}
func TestShouldShareWorkspace_BothEnabled(t *testing.T) {
l := &Loop{workspaceSharing: &store.WorkspaceSharingConfig{
SharedDM: true,
+2 -1
View File
@@ -161,6 +161,7 @@ type WorkspaceSharingConfig struct {
SharedDM bool `json:"shared_dm"`
SharedGroup bool `json:"shared_group"`
SharedUsers []string `json:"shared_users,omitempty"`
ShareMemory bool `json:"share_memory"`
}
// ParseWorkspaceSharing extracts workspace_sharing from other_config JSONB.
@@ -175,7 +176,7 @@ func (a *AgentData) ParseWorkspaceSharing() *WorkspaceSharingConfig {
if json.Unmarshal(a.OtherConfig, &cfg) != nil || cfg.WS == nil {
return nil
}
if !cfg.WS.SharedDM && !cfg.WS.SharedGroup && len(cfg.WS.SharedUsers) == 0 {
if !cfg.WS.SharedDM && !cfg.WS.SharedGroup && len(cfg.WS.SharedUsers) == 0 && !cfg.WS.ShareMemory {
return nil
}
return cfg.WS
@@ -54,8 +54,8 @@ func (s *PGKnowledgeGraphStore) Traverse(ctx context.Context, agentID, userID, s
p.path || e.id::text,
r.relation_type
FROM paths p
JOIN kg_relations r ON p.id = r.source_entity_id
JOIN kg_entities e ON r.target_entity_id = e.id
JOIN kg_relations r ON p.id = r.source_entity_id AND r.user_id = $3
JOIN kg_entities e ON r.target_entity_id = e.id AND e.user_id = $3
WHERE p.depth < $4
AND NOT e.id::text = ANY(p.path)
)
+6 -1
View File
@@ -286,7 +286,12 @@
"sharedUsers": "Shared Users",
"sharedUsersTip": "Specific user IDs that always share workspace regardless of DM/Group settings. Format: channel:userId (e.g. telegram:386246614)",
"userIdPlaceholder": "Enter user ID (e.g. telegram:386246614)",
"warning": "When sharing is enabled, all shared users can read and write files in the same workspace directory. Memory is also shared; context files remain isolated per user. Toggling this setting does not migrate existing data — previous per-user memories will no longer be accessible."
"warning": "When sharing is enabled, all shared users can read and write files in the same workspace directory. Memory sharing is controlled separately. Context files remain isolated per user.",
"memoryGroupLabel": "Memory & Knowledge Graph",
"folderGroupLabel": "Workspace Folders",
"shareMemory": "Shared Memory & KG",
"shareMemoryTip": "All users access the same memory and knowledge graph. Independent of workspace folder sharing. When off, each user has their own isolated memory space.",
"shareMemoryNote": "Toggling this does not migrate data — existing per-user memories become inaccessible in shared mode."
},
"compaction": {
"title": "Compaction",
+6 -1
View File
@@ -286,7 +286,12 @@
"sharedUsers": "User được chia sẻ",
"sharedUsersTip": "Danh sách user ID cụ thể luôn dùng chung workspace. Định dạng: channel:userId (vd: telegram:386246614)",
"userIdPlaceholder": "Nhập user ID (vd: telegram:386246614)",
"warning": "Khi bật chia sẻ, tất cả người dùng được chia sẻ có thể đọc và ghi file trong cùng thư mục workspace. Bộ nhớ cũng được chia sẻ; file ngữ cảnh vẫn được cô lập theo từng người dùng. Thay đổi cài đặt này không di chuyển dữ liệu cũ — bộ nhớ per-user trước đó sẽ không còn truy cập được."
"warning": "Khi bật chia sẻ, tất cả người dùng được chia sẻ có thể đọc và ghi file trong cùng thư mục workspace. Chia sẻ bộ nhớ được cài đặt riêng. File ngữ cảnh vẫn được cô lập theo từng người dùng.",
"memoryGroupLabel": "Memory & Knowledge Graph",
"folderGroupLabel": "Thư mục Workspace",
"shareMemory": "Chia sẻ Memory & KG",
"shareMemoryTip": "Tất cả người dùng truy cập chung Memory và Knowledge Graph. Độc lập với việc chia sẻ thư mục workspace. Khi tắt, mỗi người dùng có vùng nhớ riêng.",
"shareMemoryNote": "Bật/tắt không di chuyển dữ liệu — memory per-user trước đó sẽ không truy cập được trong chế độ shared."
},
"compaction": {
"title": "Nén ngữ cảnh",
+6 -1
View File
@@ -286,7 +286,12 @@
"sharedUsers": "共享用户",
"sharedUsersTip": "始终共享工作区的特定用户ID。格式:channel:userId(例如 telegram:386246614)",
"userIdPlaceholder": "输入用户ID(例如 telegram:386246614)",
"warning": "启用共享后,所有共享用户可以读写同一工作区目录中的文件。记忆也会共享;上下文文件仍按用户隔离。切换此设置不会迁移现有数据——之前的用户独立记忆将无法访问。"
"warning": "启用共享后,所有共享用户可以读写同一工作区目录中的文件。记忆共享单独控制。上下文文件仍按用户隔离。",
"memoryGroupLabel": "记忆与知识图谱",
"folderGroupLabel": "工作区文件夹",
"shareMemory": "共享记忆与知识图谱",
"shareMemoryTip": "所有用户访问相同的记忆和知识图谱。与工作区文件夹共享无关。关闭时,每个用户拥有独立的记忆空间。",
"shareMemoryNote": "切换不会迁移数据——切换到共享模式后,现有的用户独立记忆将无法访问。"
},
"compaction": {
"title": "压缩",
@@ -100,7 +100,7 @@ export function AgentConfigTab({ agent, onUpdate }: AgentConfigTabProps) {
if (thinkingLevel && thinkingLevel !== "off") {
otherBase.thinking_level = thinkingLevel;
}
if (wsSharing.shared_dm || wsSharing.shared_group || (wsSharing.shared_users?.length ?? 0) > 0) {
if (wsSharing.shared_dm || wsSharing.shared_group || (wsSharing.shared_users?.length ?? 0) > 0 || wsSharing.share_memory) {
otherBase.workspace_sharing = wsSharing;
}
updates.other_config = Object.keys(otherBase).length > 0 ? otherBase : {};
@@ -4,7 +4,7 @@ import { Switch } from "@/components/ui/switch";
import { Badge } from "@/components/ui/badge";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Combobox } from "@/components/ui/combobox";
import { Shield, X, AlertTriangle, Plus } from "lucide-react";
import { Shield, X, AlertTriangle, Plus, Brain } from "lucide-react";
import { Button } from "@/components/ui/button";
import type { WorkspaceSharingConfig } from "@/types/agent";
import { useContacts } from "@/pages/contacts/hooks/use-contacts";
@@ -57,7 +57,7 @@ export function WorkspaceSharingSection({ value, onChange }: WorkspaceSharingSec
return (
<section className="space-y-3">
{/* Header — prominent with shield icon */}
{/* Header */}
<div className="flex items-center gap-2">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-amber-100 dark:bg-amber-900/30">
<Shield className="h-4 w-4 text-amber-600 dark:text-amber-400" />
@@ -68,8 +68,8 @@ export function WorkspaceSharingSection({ value, onChange }: WorkspaceSharingSec
</div>
</div>
{/* Content card with accent border when active */}
<div className={`rounded-lg border p-3 space-y-4 sm:p-4 ${isActive ? "border-amber-400/60 bg-amber-50/30 dark:border-amber-500/30 dark:bg-amber-950/10" : ""}`}>
{/* Content card */}
<div className={`rounded-lg border p-3 space-y-4 sm:p-4 ${isActive || value.share_memory ? "border-amber-400/60 bg-amber-50/30 dark:border-amber-500/30 dark:bg-amber-950/10" : ""}`}>
{/* Security warning */}
<Alert variant="destructive" className="border-amber-500/50 bg-amber-500/10 text-amber-700 dark:text-amber-400 [&>svg]:text-amber-600">
<AlertTriangle className="h-4 w-4" />
@@ -78,21 +78,44 @@ export function WorkspaceSharingSection({ value, onChange }: WorkspaceSharingSec
</AlertDescription>
</Alert>
{/* DM / Group sharing toggles */}
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div className="flex items-center justify-between rounded-md border bg-background p-3">
<InfoLabel tip={t(`${s}.sharedDmTip`)}>{t(`${s}.sharedDm`)}</InfoLabel>
{/* Memory & Knowledge Graph — independent section */}
<div className="space-y-2">
<div className="flex items-center gap-1.5 text-xs font-medium text-violet-700 dark:text-violet-400">
<Brain className="h-3.5 w-3.5" />
{t(`${s}.memoryGroupLabel`)}
</div>
<div className="flex items-center justify-between rounded-md border border-violet-200 bg-violet-50/50 p-3 dark:border-violet-800/40 dark:bg-violet-950/20">
<InfoLabel tip={t(`${s}.shareMemoryTip`)}>{t(`${s}.shareMemory`)}</InfoLabel>
<Switch
checked={value.shared_dm ?? false}
onCheckedChange={(v) => onChange({ ...value, shared_dm: v })}
checked={value.share_memory ?? false}
onCheckedChange={(v) => onChange({ ...value, share_memory: v })}
/>
</div>
<div className="flex items-center justify-between rounded-md border bg-background p-3">
<InfoLabel tip={t(`${s}.sharedGroupTip`)}>{t(`${s}.sharedGroup`)}</InfoLabel>
<Switch
checked={value.shared_group ?? false}
onCheckedChange={(v) => onChange({ ...value, shared_group: v })}
/>
{value.share_memory && (
<p className="text-xs text-violet-600 dark:text-violet-400">
{t(`${s}.shareMemoryNote`)}
</p>
)}
</div>
{/* Workspace Folders — DM / Group sharing toggles */}
<div className="space-y-2">
<p className="text-xs font-medium text-muted-foreground">{t(`${s}.folderGroupLabel`)}</p>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div className="flex items-center justify-between rounded-md border bg-background p-3">
<InfoLabel tip={t(`${s}.sharedDmTip`)}>{t(`${s}.sharedDm`)}</InfoLabel>
<Switch
checked={value.shared_dm ?? false}
onCheckedChange={(v) => onChange({ ...value, shared_dm: v })}
/>
</div>
<div className="flex items-center justify-between rounded-md border bg-background p-3">
<InfoLabel tip={t(`${s}.sharedGroupTip`)}>{t(`${s}.sharedGroup`)}</InfoLabel>
<Switch
checked={value.shared_group ?? false}
onCheckedChange={(v) => onChange({ ...value, shared_group: v })}
/>
</div>
</div>
</div>
+1
View File
@@ -81,6 +81,7 @@ export interface WorkspaceSharingConfig {
shared_dm?: boolean;
shared_group?: boolean;
shared_users?: string[];
share_memory?: boolean;
}
export interface AgentData {