package controlplane import ( "errors" "regexp" "sort" "strconv" "strings" ) // MentionTask is one independently-addressed task extracted from the public // multi-Agent composer. A line may create more than one task when it names // multiple Agents before its shared task text. type MentionTask struct { Line int `json:"line"` Mention string `json:"mention"` AgentID string `json:"agentID"` AgentName string `json:"agentName"` Instruction string `json:"instruction"` TaskID string `json:"taskID"` Error string `json:"error"` } // MentionDispatchResponse reports every line/target outcome. It never silently // broadcasts untagged text or substitutes a similar Agent name. type MentionDispatchResponse struct { Tasks []MentionTask `json:"tasks"` Issues []string `json:"issues"` } type mentionCandidate struct { ID string Name string Status string } // DispatchMentions accepts newline-separated assignments. Each non-empty line // must begin with one or more @AgentName markers followed by its task text: // // @设计机 @文案机 做同一套发布方案 // @测试机 验证安装包 // // The first line produces two independent task runs with the same instruction; // the second line produces a different run. Invalid targets remain isolated to // their own line and are never redirected. func (c *ControlPlane) DispatchMentions(message string, timeoutSeconds int) (MentionDispatchResponse, error) { return c.dispatchMentionsWithImages(message, timeoutSeconds, nil) } func (c *ControlPlane) DispatchMentionsWithImages(message string, timeoutSeconds int, inputs []TaskImageInput) (MentionDispatchResponse, error) { images, err := normalizeTaskImages(inputs) if err != nil { return MentionDispatchResponse{}, err } return c.dispatchMentionsWithImages(message, timeoutSeconds, images) } func (c *ControlPlane) dispatchMentionsWithImages(message string, timeoutSeconds int, images []taskImage) (MentionDispatchResponse, error) { resolved, issues, err := c.resolveMentions(message) if err != nil { return MentionDispatchResponse{}, err } response := MentionDispatchResponse{Tasks: make([]MentionTask, 0, len(resolved)), Issues: issues} // Invalid, offline, duplicate, or unknown targets cannot turn a single valid // assignment into a stricter multi-task submission. Only independently // dispatchable tasks require explicit image numbers. validTasks := 0 for _, item := range resolved { if item.Error == "" { validTasks++ } } multiTarget := validTasks > 1 for _, item := range resolved { result := item if item.Error != "" { response.Tasks = append(response.Tasks, result) continue } selected, imageErr := imagesForMention(item.Instruction, images, multiTarget) if imageErr != nil { result.Error = imageErr.Error() response.Tasks = append(response.Tasks, result) continue } // Batch @ assignments stay one-shot: only 单独指派 keeps a conversation. taskID, createErr := c.createTaskWithImages(item.AgentID, item.Instruction, timeoutSeconds, 1, "", selected, "") if createErr != nil { result.Error = sanitizeDisplayText(createErr.Error()) } else { result.TaskID = taskID } response.Tasks = append(response.Tasks, result) } return response, nil } var imageReference = regexp.MustCompile(`图片?\s*([0-9]+)`) func imagesForMention(instruction string, images []taskImage, requireReference bool) ([]taskImage, error) { if len(images) == 0 { return nil, nil } matches := imageReference.FindAllStringSubmatch(instruction, -1) if len(matches) == 0 { if requireReference { return nil, errors.New("多项带图任务必须在该行标明“图片 1”等编号,未下发。") } return images, nil } selected := make([]taskImage, 0, len(matches)) seen := map[int]bool{} for _, match := range matches { index, _ := strconv.Atoi(match[1]) if index < 1 || index > len(images) { return nil, errors.New("引用的图片编号不存在,未下发。") } if !seen[index] { selected = append(selected, images[index-1]) seen[index] = true } } return selected, nil } func (c *ControlPlane) resolveMentions(message string) ([]MentionTask, []string, error) { message = strings.TrimSpace(message) if message == "" { return nil, nil, errors.New("请输入以 @Agent名称 开始的任务") } if len([]rune(message)) > 6000 { return nil, nil, errors.New("任务内容不能超过 6000 个字符") } candidates, err := c.mentionCandidates() if err != nil { return nil, nil, err } if len(candidates) == 0 { return nil, nil, errors.New("当前没有已配对的 Agent,无法选择任务目标") } items := make([]MentionTask, 0) issues := make([]string, 0) for lineNumber, rawLine := range strings.Split(message, "\n") { line := strings.TrimSpace(rawLine) if line == "" { continue } lineItems, issue := c.resolveMentionLine(line, lineNumber+1, candidates) items = append(items, lineItems...) if issue != "" { issues = append(issues, issue) } } if len(items) == 0 && len(issues) == 0 { return nil, nil, errors.New("请输入至少一条任务") } return items, issues, nil } func (c *ControlPlane) resolveMentionLine(line string, lineNumber int, candidates []mentionCandidate) ([]MentionTask, string) { remaining := strings.TrimSpace(line) targets := make([]mentionCandidate, 0) for strings.HasPrefix(remaining, "@") || strings.HasPrefix(remaining, "@") { segment := strings.TrimPrefix(strings.TrimPrefix(remaining, "@"), "@") name, matches := matchMentionName(segment, candidates) if len(matches) == 0 { return nil, "第 " + stringLine(lineNumber) + " 行存在未识别的 Agent 名称,整行未下发。" } if len(matches) > 1 { return nil, "第 " + stringLine(lineNumber) + " 行存在重名 Agent,整行未下发;请先在 Agent 管理中改名。" } target := matches[0] if anyMentionTarget(targets, target.ID) { return nil, "第 " + stringLine(lineNumber) + " 行重复选择了 @" + name + ",整行未下发。" } targets = append(targets, target) remaining = strings.TrimLeft(strings.TrimPrefix(segment, name), " \t;,,") } if len(targets) == 0 { return nil, "第 " + stringLine(lineNumber) + " 行未指定 Agent;请用 @ 选择目标。" } instruction := strings.TrimSpace(remaining) items := make([]MentionTask, 0, len(targets)) for _, target := range targets { item := MentionTask{Line: lineNumber, Mention: target.Name, AgentID: target.ID, AgentName: target.Name, Instruction: instruction} if instruction == "" { item.Error = "该行未提供任务内容,未下发。" } else if target.Status != "online" || c.clientFor(target.ID) == nil { item.Error = "目标 Agent 当前离线,未下发。" } else if err := validateSafeTaskText(instruction); err != nil { item.Error = err.Error() } items = append(items, item) } return items, "" } func (c *ControlPlane) mentionCandidates() ([]mentionCandidate, error) { rows, err := c.db.Query("SELECT agent_id, name, status FROM managed_agents ORDER BY name, agent_id") if err != nil { return nil, err } defer rows.Close() items := make([]mentionCandidate, 0) for rows.Next() { var item mentionCandidate if err := rows.Scan(&item.ID, &item.Name, &item.Status); err != nil { return nil, err } item.Name = strings.TrimSpace(item.Name) if item.Name != "" { items = append(items, item) } } sort.Slice(items, func(i, j int) bool { return len([]rune(items[i].Name)) > len([]rune(items[j].Name)) }) return items, rows.Err() } func matchMentionName(segment string, candidates []mentionCandidate) (string, []mentionCandidate) { for _, candidate := range candidates { if !strings.HasPrefix(segment, candidate.Name) { continue } remainder := strings.TrimPrefix(segment, candidate.Name) if remainder != "" && !isMentionBoundary([]rune(remainder)[0]) { continue } matches := make([]mentionCandidate, 0, 2) for _, other := range candidates { if other.Name == candidate.Name { matches = append(matches, other) } } return candidate.Name, matches } return "", nil } func anyMentionTarget(targets []mentionCandidate, agentID string) bool { for _, target := range targets { if target.ID == agentID { return true } } return false } func isMentionBoundary(value rune) bool { return value == ' ' || value == '\t' || value == '\n' || value == '\r' || value == '@' || value == '@' || strings.ContainsRune(";,,", value) } func stringLine(value int) string { return strconv.Itoa(value) } func validateSafeTaskText(value string) error { trimmed := strings.TrimSpace(value) if trimmed == "" { return errors.New("请输入文本内容") } if len([]rune(trimmed)) > 6000 { return errors.New("任务内容不能超过 6000 个字符") } if pairingCredentialPattern.MatchString(trimmed) || rawAPIKeyPattern.MatchString(trimmed) || sanitizeDisplayText(trimmed) != trimmed { return errors.New("内容包含敏感凭据或内部推理,已拒绝下发") } return nil }