You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 

269 lines
8.9 KiB

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
}