Files
2026-07-13 13:00:08 +08:00

829 lines
22 KiB
Go

package bot
import (
"bufio"
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"io/fs"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
"time"
"reasonix/internal/agent"
"reasonix/internal/secrets"
)
const (
botProjectListLimit = 20
botSessionListLimit = 20
botSearchListLimit = 20
)
type botProjectEntry struct {
ID string
Name string
Root string
Sources []string
}
type botSessionEntry struct {
ID string
ProjectID string
ProjectName string
WorkspaceRoot string
SessionID string
SessionPath string
RemoteID string
ConnectionID string
ChatType string
UserID string
ThreadID string
Scope string
Preview string
TopicTitle string
LastActivityAt time.Time
Source string
}
type botProjectSearchResult struct {
ProjectID string
ProjectName string
Path string
Line int
Text string
}
func (gw *BotGateway) buildProjectIndex() []botProjectEntry {
collector := newBotProjectCollector()
collector.add(gw.cfg.WorkspaceRoot, "default")
// cfg.Channels / cfg.ConnectionChannels are rewritten under gw.mu at runtime,
// so the whole scan shares the controllers critical section below.
gw.mu.Lock()
platforms := make([]string, 0, len(gw.cfg.Channels))
for platform := range gw.cfg.Channels {
platforms = append(platforms, string(platform))
}
sort.Strings(platforms)
for _, platform := range platforms {
channel := gw.cfg.Channels[Platform(platform)]
source := "channel:" + platform
collector.add(channel.WorkspaceRoot, source)
addMappingProjects(collector, channel, source)
}
connections := make([]string, 0, len(gw.cfg.ConnectionChannels))
for id := range gw.cfg.ConnectionChannels {
connections = append(connections, id)
}
sort.Strings(connections)
for _, id := range connections {
channel := gw.cfg.ConnectionChannels[id]
source := "connection:" + id
collector.add(channel.WorkspaceRoot, source)
addMappingProjects(collector, channel, source)
}
for i, route := range gw.cfg.Routes {
collector.add(route.Channel.WorkspaceRoot, fmt.Sprintf("route:%d", i+1))
}
for key, state := range gw.controllers {
root := ""
if state != nil {
root = state.workspaceRoot
if root == "" && state.ctrl != nil {
root = state.ctrl.WorkspaceRoot()
}
}
collector.add(root, "active:"+shortBotID(key))
}
for key, override := range gw.sessionOverrides {
collector.add(override.channel.WorkspaceRoot, "override:"+shortBotID(key))
}
gw.mu.Unlock()
return collector.entries()
}
func addMappingProjects(collector *botProjectCollector, channel ChannelConfig, source string) {
for _, mapping := range channel.SessionMappings {
root := workspaceRootForSessionMapping(mapping, channel.WorkspaceRoot)
collector.add(root, source+":mapping:"+strings.TrimSpace(mapping.RemoteID))
}
}
type botProjectCollector struct {
byRoot map[string]*botProjectEntry
}
func newBotProjectCollector() *botProjectCollector {
return &botProjectCollector{byRoot: make(map[string]*botProjectEntry)}
}
func (c *botProjectCollector) add(root, source string) {
root = canonicalBotPath(root)
if root == "" {
return
}
entry := c.byRoot[root]
if entry == nil {
entry = &botProjectEntry{Name: botProjectName(root), Root: root}
c.byRoot[root] = entry
}
source = strings.TrimSpace(source)
if source == "" {
return
}
for _, existing := range entry.Sources {
if existing == source {
return
}
}
entry.Sources = append(entry.Sources, source)
}
func (c *botProjectCollector) entries() []botProjectEntry {
out := make([]botProjectEntry, 0, len(c.byRoot))
for _, entry := range c.byRoot {
copied := *entry
sort.Strings(copied.Sources)
out = append(out, copied)
}
sort.Slice(out, func(i, j int) bool {
li := strings.ToLower(out[i].Name)
lj := strings.ToLower(out[j].Name)
if li != lj {
return li < lj
}
return out[i].Root < out[j].Root
})
for i := range out {
out[i].ID = fmt.Sprintf("p%d", i+1)
}
return out
}
func (gw *BotGateway) buildSessionIndex(projects []botProjectEntry) []botSessionEntry {
projectByRoot := make(map[string]botProjectEntry, len(projects))
for _, project := range projects {
projectByRoot[canonicalBotPath(project.Root)] = project
}
collector := newBotSessionCollector(projectByRoot)
// cfg.Channels / cfg.ConnectionChannels are rewritten under gw.mu at runtime;
// collect the mapping-derived entries under a short lock, keeping the
// filesystem scan below outside it.
gw.mu.Lock()
platforms := make([]string, 0, len(gw.cfg.Channels))
for platform := range gw.cfg.Channels {
platforms = append(platforms, string(platform))
}
sort.Strings(platforms)
for _, platform := range platforms {
channel := gw.cfg.Channels[Platform(platform)]
addMappingSessions(collector, channel, "", "channel:"+platform)
}
connections := make([]string, 0, len(gw.cfg.ConnectionChannels))
for id := range gw.cfg.ConnectionChannels {
connections = append(connections, id)
}
sort.Strings(connections)
for _, id := range connections {
channel := gw.cfg.ConnectionChannels[id]
addMappingSessions(collector, channel, id, "connection:"+id)
}
gw.mu.Unlock()
for _, project := range projects {
dir := botSessionDir(project.Root)
if dir == "" {
continue
}
if info, err := os.Stat(dir); err != nil || !info.IsDir() {
continue
}
infos, err := agent.ListSessions(dir)
if err != nil {
gw.logger.Warn("bot project session index failed", "project", project.Name, "err", err)
continue
}
for _, info := range infos {
collector.add(botSessionEntry{
WorkspaceRoot: project.Root,
SessionPath: canonicalBotPath(info.Path),
SessionID: botSessionTarget(info.Path),
Scope: firstNonEmptyString(info.Scope, "project"),
Preview: info.Preview,
TopicTitle: info.TopicTitle,
LastActivityAt: info.LastActivityAt,
Source: "project-sessions",
})
}
}
return collector.entries()
}
func addMappingSessions(collector *botSessionCollector, channel ChannelConfig, connectionID, source string) {
for _, mapping := range channel.SessionMappings {
sessionID := strings.TrimSpace(mapping.SessionID)
sessionPath := botSessionPathFromTarget(sessionID)
root := workspaceRootForSessionMapping(mapping, channel.WorkspaceRoot)
collector.add(botSessionEntry{
WorkspaceRoot: root,
SessionID: sessionID,
SessionPath: sessionPath,
RemoteID: strings.TrimSpace(mapping.RemoteID),
ConnectionID: strings.TrimSpace(connectionID),
ChatType: strings.TrimSpace(mapping.ChatType),
UserID: strings.TrimSpace(mapping.UserID),
ThreadID: strings.TrimSpace(mapping.ThreadID),
Scope: strings.TrimSpace(mapping.Scope),
LastActivityAt: parseBotMappingUpdatedAt(mapping.UpdatedAt),
Source: source,
})
}
}
type botSessionCollector struct {
projectByRoot map[string]botProjectEntry
byKey map[string]*botSessionEntry
}
func newBotSessionCollector(projectByRoot map[string]botProjectEntry) *botSessionCollector {
return &botSessionCollector{
projectByRoot: projectByRoot,
byKey: make(map[string]*botSessionEntry),
}
}
func (c *botSessionCollector) add(entry botSessionEntry) {
entry.WorkspaceRoot = canonicalBotPath(entry.WorkspaceRoot)
entry.SessionPath = canonicalBotPath(entry.SessionPath)
if entry.SessionID == "" && entry.SessionPath != "" {
entry.SessionID = botSessionTarget(entry.SessionPath)
}
if entry.SessionPath == "" && entry.SessionID == "" && entry.RemoteID == "" {
return
}
if project, ok := c.projectByRoot[entry.WorkspaceRoot]; ok {
entry.ProjectID = project.ID
entry.ProjectName = project.Name
}
key := entry.SessionPath
if key == "" {
key = strings.Join([]string{"target", entry.ConnectionID, entry.RemoteID, entry.ChatType, entry.UserID, entry.ThreadID, entry.SessionID}, "\x00")
}
existing := c.byKey[key]
if existing == nil {
c.byKey[key] = &entry
return
}
mergeBotSessionEntry(existing, entry)
}
func mergeBotSessionEntry(dst *botSessionEntry, src botSessionEntry) {
if dst.ProjectID == "" {
dst.ProjectID = src.ProjectID
}
if dst.ProjectName == "" {
dst.ProjectName = src.ProjectName
}
if dst.WorkspaceRoot == "" {
dst.WorkspaceRoot = src.WorkspaceRoot
}
if dst.SessionID == "" {
dst.SessionID = src.SessionID
}
if dst.SessionPath == "" {
dst.SessionPath = src.SessionPath
}
if dst.RemoteID == "" {
dst.RemoteID = src.RemoteID
}
if dst.ConnectionID == "" {
dst.ConnectionID = src.ConnectionID
}
if dst.ChatType == "" {
dst.ChatType = src.ChatType
}
if dst.UserID == "" {
dst.UserID = src.UserID
}
if dst.ThreadID == "" {
dst.ThreadID = src.ThreadID
}
if dst.Scope == "" {
dst.Scope = src.Scope
}
if dst.Preview == "" {
dst.Preview = src.Preview
}
if dst.TopicTitle == "" {
dst.TopicTitle = src.TopicTitle
}
if src.LastActivityAt.After(dst.LastActivityAt) {
dst.LastActivityAt = src.LastActivityAt
}
if dst.Source == "" {
dst.Source = src.Source
} else if src.Source != "" && !strings.Contains(dst.Source, src.Source) {
dst.Source += "," + src.Source
}
}
func (c *botSessionCollector) entries() []botSessionEntry {
out := make([]botSessionEntry, 0, len(c.byKey))
for _, entry := range c.byKey {
out = append(out, *entry)
}
sort.Slice(out, func(i, j int) bool {
if !out[i].LastActivityAt.Equal(out[j].LastActivityAt) {
return out[i].LastActivityAt.After(out[j].LastActivityAt)
}
if out[i].ProjectName != out[j].ProjectName {
return out[i].ProjectName < out[j].ProjectName
}
return out[i].SessionPath < out[j].SessionPath
})
for i := range out {
out[i].ID = fmt.Sprintf("s%d", i+1)
}
return out
}
func botSessionPathFromTarget(target string) string {
target = strings.TrimSpace(target)
if target == "" {
return ""
}
if strings.HasPrefix(target, "path:") {
return canonicalBotPath(strings.TrimPrefix(target, "path:"))
}
if filepath.IsAbs(target) && strings.HasSuffix(target, ".jsonl") {
return canonicalBotPath(target)
}
return ""
}
func parseBotMappingUpdatedAt(value string) time.Time {
value = strings.TrimSpace(value)
if value == "" {
return time.Time{}
}
if t, err := time.Parse(time.RFC3339Nano, value); err == nil {
return t
}
if t, err := time.Parse(time.RFC3339, value); err == nil {
return t
}
return time.Time{}
}
func resolveBotProject(projects []botProjectEntry, selector string) (botProjectEntry, []botProjectEntry) {
selector = strings.TrimSpace(selector)
if selector == "" {
return botProjectEntry{}, nil
}
selectorLower := strings.ToLower(selector)
canonicalSelector := canonicalBotPath(selector)
var matches []botProjectEntry
for _, project := range projects {
if strings.EqualFold(project.ID, selector) || canonicalBotPath(project.Root) == canonicalSelector || strings.EqualFold(project.Name, selector) {
return project, nil
}
if strings.Contains(strings.ToLower(project.Name), selectorLower) || strings.Contains(strings.ToLower(project.Root), selectorLower) {
matches = append(matches, project)
}
}
if len(matches) == 1 {
return matches[0], nil
}
return botProjectEntry{}, matches
}
func resolveBotSession(sessions []botSessionEntry, selector string) (botSessionEntry, []botSessionEntry) {
selector = strings.TrimSpace(selector)
if selector == "" {
return botSessionEntry{}, nil
}
selectorLower := strings.ToLower(selector)
canonicalSelector := canonicalBotPath(selector)
var matches []botSessionEntry
for _, session := range sessions {
if strings.EqualFold(session.ID, selector) ||
(session.SessionPath != "" && canonicalBotPath(session.SessionPath) == canonicalSelector) ||
(session.SessionPath != "" && strings.EqualFold(filepath.Base(session.SessionPath), selector)) ||
(session.SessionID != "" && strings.EqualFold(session.SessionID, selector)) {
return session, nil
}
if botSessionMatchesQuery(session, selectorLower) {
matches = append(matches, session)
}
}
if len(matches) == 1 {
return matches[0], nil
}
return botSessionEntry{}, matches
}
func filterBotProjects(projects []botProjectEntry, query string) []botProjectEntry {
query = strings.ToLower(strings.TrimSpace(query))
if query == "" {
return projects
}
var out []botProjectEntry
for _, project := range projects {
if strings.Contains(strings.ToLower(project.Name+" "+project.Root+" "+strings.Join(project.Sources, " ")), query) {
out = append(out, project)
}
}
return out
}
func filterBotSessions(sessions []botSessionEntry, query string) []botSessionEntry {
query = strings.ToLower(strings.TrimSpace(query))
if query == "" {
return sessions
}
var out []botSessionEntry
for _, session := range sessions {
if botSessionMatchesQuery(session, query) {
out = append(out, session)
}
}
return out
}
func botSessionMatchesQuery(session botSessionEntry, query string) bool {
haystack := strings.ToLower(strings.Join([]string{
session.ID,
session.ProjectID,
session.ProjectName,
session.WorkspaceRoot,
session.SessionID,
session.SessionPath,
session.RemoteID,
session.ConnectionID,
session.ChatType,
session.UserID,
session.ThreadID,
session.Scope,
session.Preview,
session.TopicTitle,
session.Source,
}, " "))
return strings.Contains(haystack, query)
}
func formatBotProjects(projects []botProjectEntry, query string, limit int) string {
matches := filterBotProjects(projects, query)
if len(matches) == 0 {
if strings.TrimSpace(query) == "" {
return "还没有可用项目索引。请先在 bot 连接、route 或当前会话里配置 workspace_root。"
}
return "没有匹配的项目。"
}
if limit <= 0 || limit > len(matches) {
limit = len(matches)
}
var b strings.Builder
fmt.Fprintf(&b, "项目索引(%d/%d):", limit, len(matches))
for i := 0; i < limit; i++ {
project := matches[i]
fmt.Fprintf(&b, "\n%s %s — %s", project.ID, project.Name, displayBotPath(project.Root))
if len(project.Sources) > 0 {
fmt.Fprintf(&b, "\n 来源: %s", strings.Join(project.Sources, ", "))
}
}
if len(matches) > limit {
fmt.Fprintf(&b, "\n还有 %d 个结果,请加关键词缩小范围。", len(matches)-limit)
}
return b.String()
}
func formatBotSessions(sessions []botSessionEntry, query string, limit int) string {
matches := filterBotSessions(sessions, query)
if len(matches) == 0 {
if strings.TrimSpace(query) == "" {
return "还没有可用会话索引。已有项目会话或 bot session_mappings 后会出现在这里。"
}
return "没有匹配的会话。"
}
if limit <= 0 || limit > len(matches) {
limit = len(matches)
}
var b strings.Builder
fmt.Fprintf(&b, "会话索引(%d/%d):", limit, len(matches))
for i := 0; i < limit; i++ {
session := matches[i]
project := firstNonEmptyString(session.ProjectName, "global")
fmt.Fprintf(&b, "\n%s %s", session.ID, project)
if session.TopicTitle != "" {
fmt.Fprintf(&b, " · %s", singleLineBotText(session.TopicTitle, 40))
}
if session.Preview != "" {
fmt.Fprintf(&b, "\n 预览: %s", singleLineBotText(session.Preview, 90))
}
if session.SessionPath != "" {
fmt.Fprintf(&b, "\n 文件: %s", displayBotPath(session.SessionPath))
} else if session.SessionID != "" {
fmt.Fprintf(&b, "\n 目标: %s", session.SessionID)
}
if session.RemoteID != "" || session.ConnectionID != "" {
fmt.Fprintf(&b, "\n 远端: %s %s", session.ConnectionID, session.RemoteID)
}
}
if len(matches) > limit {
fmt.Fprintf(&b, "\n还有 %d 个结果,请加关键词缩小范围。", len(matches)-limit)
}
return b.String()
}
func formatBotProjectSearchResults(results []botProjectSearchResult, limit int) string {
if len(results) == 0 {
return "没有跨项目命中。"
}
if limit <= 0 || limit > len(results) {
limit = len(results)
}
var b strings.Builder
fmt.Fprintf(&b, "跨项目检索结果(%d/%d):", limit, len(results))
for i := 0; i < limit; i++ {
result := results[i]
project := firstNonEmptyString(result.ProjectName, result.ProjectID)
fmt.Fprintf(&b, "\n- %s %s:%d: %s", project, displayBotPath(result.Path), result.Line, singleLineBotText(result.Text, 120))
}
if len(results) > limit {
fmt.Fprintf(&b, "\n还有 %d 条命中,请加关键词缩小范围。", len(results)-limit)
}
return b.String()
}
func searchBotProjects(ctx context.Context, projects []botProjectEntry, query string, limit int) ([]botProjectSearchResult, error) {
query = strings.TrimSpace(query)
if len([]rune(query)) < 2 {
return nil, errors.New("检索词至少需要 2 个字符")
}
var roots []string
seen := map[string]bool{}
for _, project := range projects {
root := canonicalBotPath(project.Root)
if root == "" || seen[root] {
continue
}
info, err := os.Stat(root)
if err != nil || !info.IsDir() {
continue
}
seen[root] = true
roots = append(roots, root)
}
if len(roots) == 0 {
return nil, errors.New("没有可检索的项目目录")
}
if limit <= 0 {
limit = botSearchListLimit
}
if rg, err := exec.LookPath("rg"); err == nil {
return searchBotProjectsWithRG(ctx, rg, projects, roots, query, limit)
}
return searchBotProjectsFallback(ctx, projects, roots, query, limit)
}
func searchBotProjectsWithRG(ctx context.Context, rg string, projects []botProjectEntry, roots []string, query string, limit int) ([]botProjectSearchResult, error) {
args := []string{
"--json",
"--color", "never",
"--fixed-strings",
"--max-count", "3",
"--max-filesize", "1M",
"--glob", "!.git",
"--glob", "!node_modules",
"--glob", "!dist",
"--glob", "!build",
"--glob", "!vendor",
"--",
query,
}
args = append(args, roots...)
cmd := exec.CommandContext(ctx, rg, args...)
cmd.Env = secrets.ProcessEnv()
out, err := cmd.Output()
if err != nil {
var exitErr *exec.ExitError
if errors.As(err, &exitErr) && exitErr.ExitCode() == 1 {
return nil, nil
}
return nil, fmt.Errorf("rg failed: %w", err)
}
dec := json.NewDecoder(bytes.NewReader(out))
var results []botProjectSearchResult
for {
var item struct {
Type string `json:"type"`
Data struct {
Path struct {
Text string `json:"text"`
} `json:"path"`
Lines struct {
Text string `json:"text"`
} `json:"lines"`
LineNumber int `json:"line_number"`
} `json:"data"`
}
if err := dec.Decode(&item); err != nil {
if errors.Is(err, io.EOF) {
break
}
break
}
if item.Type != "match" {
continue
}
path := canonicalBotPath(item.Data.Path.Text)
project := botProjectForPath(projects, path)
results = append(results, botProjectSearchResult{
ProjectID: project.ID,
ProjectName: project.Name,
Path: path,
Line: item.Data.LineNumber,
Text: strings.TrimSpace(item.Data.Lines.Text),
})
if len(results) >= limit {
break
}
}
return results, nil
}
var errStopBotSearch = errors.New("stop bot project search")
func searchBotProjectsFallback(ctx context.Context, projects []botProjectEntry, roots []string, query string, limit int) ([]botProjectSearchResult, error) {
queryLower := strings.ToLower(query)
var results []botProjectSearchResult
for _, root := range roots {
if err := ctx.Err(); err != nil {
return results, err
}
walkErr := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return nil
}
if err := ctx.Err(); err != nil {
return err
}
if d.IsDir() {
if shouldSkipBotSearchDir(d.Name()) && path != root {
return filepath.SkipDir
}
return nil
}
info, err := d.Info()
if err != nil || info.Size() > 1024*1024 {
return nil
}
file, err := os.Open(path)
if err != nil {
return nil
}
scanner := bufio.NewScanner(file)
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
line := 0
for scanner.Scan() {
if err := ctx.Err(); err != nil {
_ = file.Close()
return err
}
line++
text := scanner.Text()
if strings.Contains(strings.ToLower(text), queryLower) {
project := botProjectForPath(projects, path)
results = append(results, botProjectSearchResult{
ProjectID: project.ID,
ProjectName: project.Name,
Path: canonicalBotPath(path),
Line: line,
Text: strings.TrimSpace(text),
})
if len(results) >= limit {
_ = file.Close()
return errStopBotSearch
}
}
}
_ = file.Close()
return nil
})
if errors.Is(walkErr, errStopBotSearch) {
break
}
if walkErr != nil {
return results, walkErr
}
if len(results) >= limit {
break
}
}
return results, nil
}
func shouldSkipBotSearchDir(name string) bool {
switch name {
case ".git", "node_modules", "dist", "build", "vendor", ".next", ".cache":
return true
default:
return false
}
}
func botProjectForPath(projects []botProjectEntry, path string) botProjectEntry {
path = canonicalBotPath(path)
var best botProjectEntry
for _, project := range projects {
root := canonicalBotPath(project.Root)
if root == "" {
continue
}
if path == root || strings.HasPrefix(path, root+string(os.PathSeparator)) {
if len(root) > len(best.Root) {
best = project
}
}
}
return best
}
func canonicalBotPath(path string) string {
path = strings.TrimSpace(path)
if path == "" {
return ""
}
if abs, err := filepath.Abs(path); err == nil {
path = abs
}
return filepath.Clean(path)
}
func botProjectName(root string) string {
root = strings.TrimRight(canonicalBotPath(root), string(os.PathSeparator))
if root == "" {
return ""
}
name := filepath.Base(root)
if name == "." || name == string(os.PathSeparator) {
return root
}
return name
}
func displayBotPath(path string) string {
path = canonicalBotPath(path)
home, err := os.UserHomeDir()
if err == nil {
home = canonicalBotPath(home)
if home != "" && (path == home || strings.HasPrefix(path, home+string(os.PathSeparator))) {
return "~" + strings.TrimPrefix(path, home)
}
}
return path
}
func singleLineBotText(text string, limit int) string {
text = strings.Join(strings.Fields(strings.TrimSpace(text)), " ")
if limit <= 0 {
return text
}
runes := []rune(text)
if len(runes) <= limit {
return text
}
return string(runes[:limit-1]) + "…"
}
func shortBotID(value string) string {
value = strings.TrimSpace(value)
if len(value) <= 8 {
return value
}
return value[:8]
}
func firstNonEmptyString(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return strings.TrimSpace(value)
}
}
return ""
}