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
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
|
|
}
|
|
|