diff --git a/internal/gateway/methods/teams_workspace.go b/internal/gateway/methods/teams_workspace.go index d3e494f4..f9f389c4 100644 --- a/internal/gateway/methods/teams_workspace.go +++ b/internal/gateway/methods/teams_workspace.go @@ -13,6 +13,7 @@ import ( "github.com/nextlevelbuilder/goclaw/internal/gateway" "github.com/nextlevelbuilder/goclaw/internal/i18n" "github.com/nextlevelbuilder/goclaw/internal/store" + "github.com/nextlevelbuilder/goclaw/internal/tools" "github.com/nextlevelbuilder/goclaw/pkg/protocol" ) @@ -69,15 +70,26 @@ func (m *TeamsMethods) handleWorkspaceList(ctx context.Context, client *gateway. return } + // Check if team uses shared workspace (no chatID scoping). + shared := false + if team, err := m.teamStore.GetTeam(ctx, teamID); err == nil { + shared = tools.IsSharedWorkspace(team.Settings) + } + baseDir := teamWorkspaceDir(m.dataDir, teamID, "") var files []workspaceFileEntry - if params.ChatID != "" { - // Scoped: list files and folders in specific chatID directory. - scopeDir := teamWorkspaceDir(m.dataDir, teamID, params.ChatID) - files = walkDir(scopeDir, "", params.ChatID) + if shared || params.ChatID != "" { + // Shared mode: list team root directly. Scoped mode: list specific chatID. + scopeDir := baseDir + scopeChatID := "" + if !shared && params.ChatID != "" { + scopeDir = teamWorkspaceDir(m.dataDir, teamID, params.ChatID) + scopeChatID = params.ChatID + } + files = walkDir(scopeDir, "", scopeChatID) } else { - // Unscoped: list all chatID subdirectories with chatID as top-level folder. + // Isolated + unscoped: list all chatID subdirectories with chatID as top-level folder. entries, err := os.ReadDir(baseDir) if err != nil { // Directory doesn't exist = empty workspace. @@ -186,9 +198,13 @@ func (m *TeamsMethods) handleWorkspaceRead(ctx context.Context, client *gateway. return } + // Shared workspace: read from team root. Isolated: require chatID. chatID := params.ChatID - if chatID == "" { - chatID = "_default" + if team, err := m.teamStore.GetTeam(ctx, teamID); err == nil && tools.IsSharedWorkspace(team.Settings) { + chatID = "" + } else if chatID == "" { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRequired, "chat_id"))) + return } scopeDir := teamWorkspaceDir(m.dataDir, teamID, chatID) @@ -258,9 +274,13 @@ func (m *TeamsMethods) handleWorkspaceDelete(ctx context.Context, client *gatewa return } + // Shared workspace: delete from team root. Isolated: require chatID. chatID := params.ChatID - if chatID == "" { - chatID = "_default" + if team, err := m.teamStore.GetTeam(ctx, teamID); err == nil && tools.IsSharedWorkspace(team.Settings) { + chatID = "" + } else if chatID == "" { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRequired, "chat_id"))) + return } scopeDir := teamWorkspaceDir(m.dataDir, teamID, chatID) diff --git a/internal/tools/team_message_tool.go b/internal/tools/team_message_tool.go index c1a28a06..d6024b28 100644 --- a/internal/tools/team_message_tool.go +++ b/internal/tools/team_message_tool.go @@ -193,7 +193,7 @@ func (t *TeamMessageTool) executeSend(ctx context.Context, args map[string]any) // Real-time delivery via message bus fromKey := t.manager.agentKeyFromID(ctx, agentID) - t.publishTeammateMessage(fromKey, toKey, text, mediaFiles, teamTaskID, team.ID, ctx) + t.publishTeammateMessage(fromKey, toKey, text, mediaFiles, teamTaskID, team.ID, team.Settings, ctx) preview := text if len(preview) > 100 { @@ -249,7 +249,7 @@ func (t *TeamMessageTool) executeBroadcast(ctx context.Context, args map[string] if m.AgentID == agentID { continue // don't send to self } - t.publishTeammateMessage(fromKey, m.AgentKey, text, nil, uuid.Nil, team.ID, ctx) + t.publishTeammateMessage(fromKey, m.AgentKey, text, nil, uuid.Nil, team.ID, team.Settings, ctx) } } @@ -302,7 +302,7 @@ func (t *TeamMessageTool) executeRead(ctx context.Context) *Result { // publishTeammateMessage sends a real-time notification via the message bus. // Uses "teammate:{fromKey}" sender prefix so the consumer can route it. -func (t *TeamMessageTool) publishTeammateMessage(fromKey, toKey, text string, media []bus.MediaFile, teamTaskID uuid.UUID, teamID uuid.UUID, ctx context.Context) { +func (t *TeamMessageTool) publishTeammateMessage(fromKey, toKey, text string, media []bus.MediaFile, teamTaskID uuid.UUID, teamID uuid.UUID, teamSettings json.RawMessage, ctx context.Context) { if t.manager.msgBus == nil { return } @@ -337,7 +337,11 @@ func (t *TeamMessageTool) publishTeammateMessage(fromKey, toKey, text string, me teamMeta["team_task_id"] = teamTaskID.String() } // Pass team workspace so the receiving agent can access shared files. - if ws, err := WorkspaceDir(t.manager.dataDir, teamID, chatID); err == nil { + wsChat := chatID + if IsSharedWorkspace(teamSettings) { + wsChat = "" + } + if ws, err := WorkspaceDir(t.manager.dataDir, teamID, wsChat); err == nil { teamMeta["team_workspace"] = ws } // Propagate trace context so the receiving agent's trace links back. diff --git a/internal/tools/team_tasks_mutations.go b/internal/tools/team_tasks_mutations.go index 3b4aa477..8909f0e6 100644 --- a/internal/tools/team_tasks_mutations.go +++ b/internal/tools/team_tasks_mutations.go @@ -107,11 +107,16 @@ func (t *TeamTasksTool) executeCreate(ctx context.Context, args map[string]any) chatID := ToolChatIDFromCtx(ctx) + // Shared workspace: scope by teamID only. Isolated (default): scope by chatID too. + wsChat := chatID + if IsSharedWorkspace(team.Settings) { + wsChat = "" + } + // Compute the team workspace directory so member agents write files to the - // shared team folder (teams/{teamID}/{chatID}/) instead of their own personal workspace. - // WorkspaceInterceptor redirects write_file/create_image to this path automatically. + // shared team folder instead of their own personal workspace. taskMeta := make(map[string]any) - if teamWsDir, err := WorkspaceDir(t.manager.dataDir, team.ID, chatID); err == nil { + if teamWsDir, err := WorkspaceDir(t.manager.dataDir, team.ID, wsChat); err == nil { taskMeta["team_workspace"] = teamWsDir } // Preserve original blocked_by list for blocker-result forwarding when task unblocks. @@ -174,6 +179,8 @@ func (t *TeamTasksTool) executeCreate(ctx context.Context, args map[string]any) t.manager.broadcastTeamEvent(protocol.EventTeamTaskAssigned, protocol.TeamTaskEventPayload{ TeamID: team.ID.String(), TaskID: task.ID.String(), + TaskNumber: task.TaskNumber, + Subject: task.Subject, Status: status, OwnerAgentKey: t.manager.agentKeyFromID(ctx, assigneeID), UserID: store.UserIDFromContext(ctx), @@ -196,8 +203,12 @@ func (t *TeamTasksTool) executeCreate(ctx context.Context, args map[string]any) t.manager.broadcastTeamEvent(protocol.EventTeamTaskAssigned, protocol.TeamTaskEventPayload{ TeamID: team.ID.String(), TaskID: task.ID.String(), + TaskNumber: task.TaskNumber, + Subject: task.Subject, Status: store.TeamTaskStatusInProgress, OwnerAgentKey: t.manager.agentKeyFromID(ctx, assigneeID), + Channel: task.Channel, + ChatID: task.ChatID, Timestamp: time.Now().UTC().Format("2006-01-02T15:04:05Z"), ActorType: "system", ActorID: "fallback_dispatch", diff --git a/internal/tools/team_tasks_read.go b/internal/tools/team_tasks_read.go index 626390a7..2b9d78e0 100644 --- a/internal/tools/team_tasks_read.go +++ b/internal/tools/team_tasks_read.go @@ -165,6 +165,11 @@ func (t *TeamTasksTool) executeList(ctx context.Context, args map[string]any) *R filterUserID = store.UserIDFromContext(ctx) } chatID := ToolChatIDFromCtx(ctx) + // Shared workspace: show all tasks across chats. + listChatID := chatID + if IsSharedWorkspace(team.Settings) { + listChatID = "" + } // Acquire team create lock to serialize list→create flows across concurrent goroutines. if ptd := PendingTeamDispatchFromCtx(ctx); ptd != nil && !ptd.HasListed() { @@ -174,7 +179,7 @@ func (t *TeamTasksTool) executeList(ctx context.Context, args map[string]any) *R ptd.MarkListed() } - tasks, err := t.manager.teamStore.ListTasks(ctx, team.ID, "priority", statusFilter, filterUserID, "", chatID, offset) + tasks, err := t.manager.teamStore.ListTasks(ctx, team.ID, "priority", statusFilter, filterUserID, "", listChatID, offset) if err != nil { return ErrorResult("failed to list tasks: " + err.Error()) } diff --git a/internal/tools/workspace_dir.go b/internal/tools/workspace_dir.go index 35997864..fe06ddf7 100644 --- a/internal/tools/workspace_dir.go +++ b/internal/tools/workspace_dir.go @@ -1,6 +1,7 @@ package tools import ( + "encoding/json" "fmt" "os" "path/filepath" @@ -15,20 +16,35 @@ const ( ) // WorkspaceDir returns the disk directory for a team workspace scope. -// Pattern: {dataDir}/teams/{teamID}/{chatID}/ -// chatID is the system-derived userID (stable across WS reconnects). +// - chatID="" → team root: {dataDir}/teams/{teamID}/ (shared mode) +// - chatID="x" → per-chat: {dataDir}/teams/{teamID}/{chatID}/ (isolated mode) // Creates directory with 0750 if not exists. func WorkspaceDir(dataDir string, teamID uuid.UUID, chatID string) (string, error) { - if chatID == "" { - chatID = "_default" + dir := filepath.Join(dataDir, "teams", teamID.String()) + if chatID != "" { + dir = filepath.Join(dir, chatID) } - dir := filepath.Join(dataDir, "teams", teamID.String(), chatID) if err := os.MkdirAll(dir, 0750); err != nil { return "", fmt.Errorf("failed to create workspace dir: %w", err) } return dir, nil } +// IsSharedWorkspace returns true if the team's workspace_scope setting is "shared". +// Default (unset or "isolated") returns false. +func IsSharedWorkspace(settings json.RawMessage) bool { + if settings == nil { + return false + } + var s struct { + WorkspaceScope string `json:"workspace_scope"` + } + if json.Unmarshal(settings, &s) != nil { + return false + } + return s.WorkspaceScope == "shared" +} + // blockedExtensions lists executable file types that are not allowed in team workspaces. var blockedExtensions = map[string]bool{ ".exe": true, ".sh": true, ".bat": true, ".cmd": true, diff --git a/ui/web/src/i18n/locales/en/teams.json b/ui/web/src/i18n/locales/en/teams.json index aa54dd00..7a5a50c1 100644 --- a/ui/web/src/i18n/locales/en/teams.json +++ b/ui/web/src/i18n/locales/en/teams.json @@ -175,8 +175,19 @@ "deniedChannels": "Denied Channels", "deniedChannelsHint": "Messages from these channels are always blocked.", "notifications": "Notifications", - "progressNotifications": "Progress notifications", - "progressNotificationsHint": "Send periodic \"Your team is working on it...\" messages to chat during async delegations.", + "notifyDispatched": "Task dispatched", + "notifyDispatchedHint": "Notify when a task is assigned to a team member.", + "notifyProgress": "Task progress", + "notifyProgressHint": "Notify when a member updates task progress.", + "notifyFailed": "Task failed", + "notifyFailedHint": "Notify when a task fails.", + "notifyMode": "Delivery mode", + "notifyModeHint": "How notifications are delivered to the chat channel.", + "notifyModeDirect": "Direct", + "notifyModeDirectDesc": "Send notifications straight to the chat channel. Fast, no AI processing.", + "notifyModeLeader": "Through Leader", + "notifyModeLeaderDesc": "Leader agent reformulates updates naturally before sending to user.", + "notifyModeLeaderWarning": "Leader mode uses AI to reformulate messages (costs tokens, may be slower).", "searchUsers": "Search users...", "selectChannel": "Select channel...", "escalationPolicy": "Escalation Policy", @@ -198,12 +209,12 @@ "followupMaxReminders": "Max reminders", "followupMaxRemindersHint": "Maximum number of reminders per task. 0 = unlimited (remind until user replies).", "workspace": "Workspace", - "workspaceScope": "Workspace Sharing", - "workspaceScopeHint": "Control how agents access files in the team workspace.", + "workspaceScope": "Workspace Scope", + "workspaceScopeHint": "Control how team workspace files are organized.", "workspaceScopeIsolated": "Isolated", - "workspaceScopeIsolatedDesc": "Each agent has its own private workspace. Agents cannot read or write files from other agents.", + "workspaceScopeIsolatedDesc": "Each conversation has its own workspace folder. Files from one chat are not visible in another.", "workspaceScopeShared": "Shared", - "workspaceScopeSharedDesc": "All agents share a single team workspace. Agents can read and write each other's files.", + "workspaceScopeSharedDesc": "All conversations share a single workspace folder. Files created in any chat are visible to all.", "teamVersion": "Team Version", "versionBasic": "Basic", "versionAdvanced": "Advanced", diff --git a/ui/web/src/i18n/locales/vi/teams.json b/ui/web/src/i18n/locales/vi/teams.json index c28f5139..e1b484da 100644 --- a/ui/web/src/i18n/locales/vi/teams.json +++ b/ui/web/src/i18n/locales/vi/teams.json @@ -175,8 +175,19 @@ "deniedChannels": "Channel bị từ chối", "deniedChannelsHint": "Tin nhắn từ các channel này luôn bị chặn.", "notifications": "Thông báo", - "progressNotifications": "Thông báo tiến trình", - "progressNotificationsHint": "Gửi định kỳ thông báo \"Team của bạn đang xử lý...\" trong quá trình ủy quyền bất đồng bộ.", + "notifyDispatched": "Giao nhiệm vụ", + "notifyDispatchedHint": "Thông báo khi nhiệm vụ được giao cho thành viên.", + "notifyProgress": "Tiến trình", + "notifyProgressHint": "Thông báo khi thành viên cập nhật tiến trình.", + "notifyFailed": "Thất bại", + "notifyFailedHint": "Thông báo khi nhiệm vụ thất bại.", + "notifyMode": "Chế độ gửi", + "notifyModeHint": "Cách thông báo được gửi đến kênh chat.", + "notifyModeDirect": "Trực tiếp", + "notifyModeDirectDesc": "Gửi thông báo thẳng đến kênh chat. Nhanh, không qua AI.", + "notifyModeLeader": "Qua Leader", + "notifyModeLeaderDesc": "Leader diễn đạt lại cập nhật một cách tự nhiên trước khi gửi.", + "notifyModeLeaderWarning": "Chế độ Leader dùng AI để diễn đạt lại (tốn token, có thể chậm hơn).", "searchUsers": "Tìm kiếm người dùng...", "selectChannel": "Chọn channel...", "escalationPolicy": "Chính sách Leo thang", @@ -198,12 +209,12 @@ "followupMaxReminders": "Số lần nhắc tối đa", "followupMaxRemindersHint": "Số lần nhắc tối đa cho mỗi nhiệm vụ. 0 = không giới hạn (nhắc cho đến khi người dùng trả lời).", "workspace": "Không gian làm việc", - "workspaceScope": "Chia sẻ không gian làm việc", - "workspaceScopeHint": "Kiểm soát cách các agent truy cập tệp trong không gian làm việc của team.", + "workspaceScope": "Phạm vi workspace", + "workspaceScopeHint": "Kiểm soát cách tổ chức tệp trong workspace của team.", "workspaceScopeIsolated": "Cô lập", - "workspaceScopeIsolatedDesc": "Mỗi agent có không gian làm việc riêng. Không thể đọc hoặc ghi tệp của agent khác.", + "workspaceScopeIsolatedDesc": "Mỗi cuộc hội thoại có thư mục workspace riêng. Tệp từ chat này không hiển thị ở chat khác.", "workspaceScopeShared": "Chia sẻ", - "workspaceScopeSharedDesc": "Tất cả agent dùng chung một không gian làm việc. Có thể đọc và ghi tệp của nhau.", + "workspaceScopeSharedDesc": "Tất cả cuộc hội thoại dùng chung một thư mục workspace. Tệp tạo từ bất kỳ chat nào đều hiển thị cho tất cả.", "teamVersion": "Phiên bản Team", "versionBasic": "Cơ bản", "versionAdvanced": "Nâng cao", diff --git a/ui/web/src/i18n/locales/zh/teams.json b/ui/web/src/i18n/locales/zh/teams.json index 9bb0473f..06e8266c 100644 --- a/ui/web/src/i18n/locales/zh/teams.json +++ b/ui/web/src/i18n/locales/zh/teams.json @@ -175,8 +175,19 @@ "deniedChannels": "拒绝的Channel", "deniedChannelsHint": "来自这些Channel的消息始终被阻止。", "notifications": "通知", - "progressNotifications": "进度通知", - "progressNotificationsHint": "在异步委托期间,定期向对话发送「您的Team正在处理中...」消息。", + "notifyDispatched": "任务分派", + "notifyDispatchedHint": "任务分配给成员时通知。", + "notifyProgress": "任务进度", + "notifyProgressHint": "成员更新进度时通知。", + "notifyFailed": "任务失败", + "notifyFailedHint": "任务失败时通知。", + "notifyMode": "发送方式", + "notifyModeHint": "通知如何发送到聊天频道。", + "notifyModeDirect": "直接发送", + "notifyModeDirectDesc": "直接将通知发送到聊天频道。快速,无需AI处理。", + "notifyModeLeader": "通过Leader", + "notifyModeLeaderDesc": "Leader代理在发送前自然地重新表述更新内容。", + "notifyModeLeaderWarning": "Leader模式使用AI重新表述消息(消耗token,可能更慢)。", "searchUsers": "搜索用户...", "selectChannel": "选择Channel...", "escalationPolicy": "升级策略", @@ -198,12 +209,12 @@ "followupMaxReminders": "最大提醒次数", "followupMaxRemindersHint": "每个任务的最大提醒次数。0 = 无限制(持续提醒直到用户回复)。", "workspace": "工作区", - "workspaceScope": "工作区共享", - "workspaceScopeHint": "控制代理如何访问团队工作区中的文件。", + "workspaceScope": "工作区范围", + "workspaceScopeHint": "控制团队工作区文件的组织方式。", "workspaceScopeIsolated": "隔离", - "workspaceScopeIsolatedDesc": "每个代理拥有独立的工作区。无法读取或写入其他代理的文件。", + "workspaceScopeIsolatedDesc": "每个对话有独立的工作区文件夹。一个聊天中的文件在另一个聊天中不可见。", "workspaceScopeShared": "共享", - "workspaceScopeSharedDesc": "所有代理共享同一个工作区。可以读取和写入彼此的文件。", + "workspaceScopeSharedDesc": "所有对话共享一个工作区文件夹。在任何聊天中创建的文件对所有人可见。", "teamVersion": "团队版本", "versionBasic": "基础版", "versionAdvanced": "高级版",