初始化仓库
This commit is contained in:
@@ -0,0 +1,480 @@
|
||||
package iflow
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/chenhg5/cc-connect/core"
|
||||
)
|
||||
|
||||
func init() {
|
||||
core.RegisterAgent("iflow", New)
|
||||
}
|
||||
|
||||
// Agent drives iFlow CLI one turn at a time using interactive `iflow -i`
|
||||
// inside a PTY, then reconstructs streaming events from the transcript JSONL.
|
||||
//
|
||||
// Modes (maps to iFlow CLI flags):
|
||||
// - "default": manual approval mode (--default)
|
||||
// - "auto-edit": auto-edit mode (--autoEdit)
|
||||
// - "plan": read-only planning mode (--plan)
|
||||
// - "yolo": auto-approve all tool calls (--yolo)
|
||||
type Agent struct {
|
||||
workDir string
|
||||
model string
|
||||
mode string
|
||||
cmd string
|
||||
toolTimeoutSec int
|
||||
providers []core.ProviderConfig
|
||||
activeIdx int
|
||||
sessionEnv []string
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
func New(opts map[string]any) (core.Agent, error) {
|
||||
workDir, _ := opts["work_dir"].(string)
|
||||
if workDir == "" {
|
||||
workDir = "."
|
||||
}
|
||||
model, _ := opts["model"].(string)
|
||||
mode, _ := opts["mode"].(string)
|
||||
mode = normalizeMode(mode)
|
||||
cmd, _ := opts["cmd"].(string)
|
||||
if cmd == "" {
|
||||
cmd = "iflow"
|
||||
}
|
||||
|
||||
if _, err := exec.LookPath(cmd); err != nil {
|
||||
return nil, fmt.Errorf("iflow: %q CLI not found in PATH, install with: npm i -g @iflow-ai/iflow-cli", cmd)
|
||||
}
|
||||
|
||||
var toolTimeoutSec int
|
||||
switch v := opts["tool_timeout_secs"].(type) {
|
||||
case int64:
|
||||
toolTimeoutSec = int(v)
|
||||
case int:
|
||||
toolTimeoutSec = v
|
||||
case float64:
|
||||
toolTimeoutSec = int(v)
|
||||
}
|
||||
|
||||
return &Agent{
|
||||
workDir: workDir,
|
||||
model: model,
|
||||
mode: mode,
|
||||
cmd: cmd,
|
||||
toolTimeoutSec: toolTimeoutSec,
|
||||
activeIdx: -1,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func normalizeMode(raw string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(raw)) {
|
||||
case "auto-edit", "auto_edit", "autoedit", "edit":
|
||||
return "auto-edit"
|
||||
case "plan":
|
||||
return "plan"
|
||||
case "yolo", "force", "auto", "bypasspermissions":
|
||||
return "yolo"
|
||||
default:
|
||||
return "default"
|
||||
}
|
||||
}
|
||||
|
||||
func (a *Agent) Name() string { return "iflow" }
|
||||
|
||||
func (a *Agent) SetWorkDir(dir string) {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
a.workDir = dir
|
||||
slog.Info("iflow: work_dir changed", "work_dir", dir)
|
||||
}
|
||||
|
||||
func (a *Agent) GetWorkDir() string {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
return a.workDir
|
||||
}
|
||||
|
||||
func (a *Agent) SetModel(model string) {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
a.model = model
|
||||
slog.Info("iflow: model changed", "model", model)
|
||||
}
|
||||
|
||||
func (a *Agent) GetModel() string {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
return core.GetProviderModel(a.providers, a.activeIdx, a.model)
|
||||
}
|
||||
|
||||
func (a *Agent) configuredModels() []core.ModelOption {
|
||||
a.mu.RLock()
|
||||
defer a.mu.RUnlock()
|
||||
return core.GetProviderModels(a.providers, a.activeIdx)
|
||||
}
|
||||
|
||||
func (a *Agent) AvailableModels(_ context.Context) []core.ModelOption {
|
||||
if models := a.configuredModels(); len(models) > 0 {
|
||||
return models
|
||||
}
|
||||
return []core.ModelOption{
|
||||
{Name: "Qwen3-Coder", Desc: "Qwen3 Coder"},
|
||||
{Name: "Kimi-K2.5", Desc: "Kimi K2.5"},
|
||||
{Name: "DeepSeek-v3", Desc: "DeepSeek v3"},
|
||||
}
|
||||
}
|
||||
|
||||
func (a *Agent) SetSessionEnv(env []string) {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
a.sessionEnv = env
|
||||
}
|
||||
|
||||
func (a *Agent) StartSession(ctx context.Context, sessionID string) (core.AgentSession, error) {
|
||||
a.mu.Lock()
|
||||
model := a.model
|
||||
mode := a.mode
|
||||
cmd := a.cmd
|
||||
workDir := a.workDir
|
||||
toolTimeoutSec := a.toolTimeoutSec
|
||||
extraEnv := a.providerEnvLocked()
|
||||
extraEnv = append(extraEnv, a.sessionEnv...)
|
||||
if a.activeIdx >= 0 && a.activeIdx < len(a.providers) {
|
||||
if m := a.providers[a.activeIdx].Model; m != "" {
|
||||
model = m
|
||||
}
|
||||
}
|
||||
a.mu.Unlock()
|
||||
|
||||
return newIFlowSession(ctx, cmd, workDir, model, mode, sessionID, extraEnv, toolTimeoutSec)
|
||||
}
|
||||
|
||||
func (a *Agent) ListSessions(_ context.Context) ([]core.AgentSessionInfo, error) {
|
||||
a.mu.RLock()
|
||||
workDir := a.workDir
|
||||
a.mu.RUnlock()
|
||||
return listIFlowSessions(workDir)
|
||||
}
|
||||
|
||||
func (a *Agent) DeleteSession(_ context.Context, sessionID string) error {
|
||||
if sessionID == "" {
|
||||
return fmt.Errorf("session id is required")
|
||||
}
|
||||
|
||||
homeDir, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return fmt.Errorf("iflow: cannot determine home dir: %w", err)
|
||||
}
|
||||
|
||||
a.mu.RLock()
|
||||
workDir := a.workDir
|
||||
a.mu.RUnlock()
|
||||
absDir := iflowResolvedWorkDir(workDir)
|
||||
|
||||
candidates := []string{
|
||||
filepath.Join(homeDir, ".iflow", "projects", iflowProjectKey(absDir), sessionID+".jsonl"),
|
||||
}
|
||||
for _, p := range candidates {
|
||||
if _, err := os.Stat(p); err == nil {
|
||||
return os.Remove(p)
|
||||
}
|
||||
}
|
||||
|
||||
matches, _ := filepath.Glob(filepath.Join(homeDir, ".iflow", "projects", "*", sessionID+".jsonl"))
|
||||
if len(matches) == 0 {
|
||||
return fmt.Errorf("session file not found: %s", sessionID)
|
||||
}
|
||||
return os.Remove(matches[0])
|
||||
}
|
||||
|
||||
func (a *Agent) Stop() error { return nil }
|
||||
|
||||
// -- ModeSwitcher --
|
||||
|
||||
func (a *Agent) SetMode(mode string) {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
a.mode = normalizeMode(mode)
|
||||
slog.Info("iflow: mode changed", "mode", a.mode)
|
||||
}
|
||||
|
||||
func (a *Agent) GetMode() string {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
return a.mode
|
||||
}
|
||||
|
||||
func (a *Agent) PermissionModes() []core.PermissionModeInfo {
|
||||
return []core.PermissionModeInfo{
|
||||
{Key: "default", Name: "Default", NameZh: "默认", Desc: "Manual approval mode", DescZh: "手动审批模式"},
|
||||
{Key: "auto-edit", Name: "Auto Edit", NameZh: "自动编辑", Desc: "Auto-edit mode", DescZh: "自动编辑模式"},
|
||||
{Key: "plan", Name: "Plan", NameZh: "规划模式", Desc: "Read-only planning mode", DescZh: "只读规划模式"},
|
||||
{Key: "yolo", Name: "YOLO", NameZh: "全自动", Desc: "Auto-approve all tool calls", DescZh: "自动批准所有工具调用"},
|
||||
}
|
||||
}
|
||||
|
||||
// -- ContextCompressor --
|
||||
|
||||
func (a *Agent) CompressCommand() string { return "/compress" }
|
||||
|
||||
// -- MemoryFileProvider --
|
||||
|
||||
func (a *Agent) ProjectMemoryFile() string {
|
||||
a.mu.RLock()
|
||||
workDir := a.workDir
|
||||
a.mu.RUnlock()
|
||||
absDir, err := filepath.Abs(workDir)
|
||||
if err != nil {
|
||||
absDir = workDir
|
||||
}
|
||||
return filepath.Join(absDir, "IFLOW.md")
|
||||
}
|
||||
|
||||
func (a *Agent) GlobalMemoryFile() string {
|
||||
homeDir, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return filepath.Join(homeDir, ".iflow", "IFLOW.md")
|
||||
}
|
||||
|
||||
// -- ProviderSwitcher --
|
||||
|
||||
func (a *Agent) SetProviders(providers []core.ProviderConfig) {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
a.providers = providers
|
||||
}
|
||||
|
||||
func (a *Agent) SetActiveProvider(name string) bool {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
if name == "" {
|
||||
a.activeIdx = -1
|
||||
slog.Info("iflow: provider cleared")
|
||||
return true
|
||||
}
|
||||
for i, p := range a.providers {
|
||||
if p.Name == name {
|
||||
a.activeIdx = i
|
||||
slog.Info("iflow: provider switched", "provider", name)
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (a *Agent) GetActiveProvider() *core.ProviderConfig {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
if a.activeIdx < 0 || a.activeIdx >= len(a.providers) {
|
||||
return nil
|
||||
}
|
||||
p := a.providers[a.activeIdx]
|
||||
return &p
|
||||
}
|
||||
|
||||
func (a *Agent) ListProviders() []core.ProviderConfig {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
result := make([]core.ProviderConfig, len(a.providers))
|
||||
copy(result, a.providers)
|
||||
return result
|
||||
}
|
||||
|
||||
func (a *Agent) providerEnvLocked() []string {
|
||||
if a.activeIdx < 0 || a.activeIdx >= len(a.providers) {
|
||||
return nil
|
||||
}
|
||||
p := a.providers[a.activeIdx]
|
||||
var env []string
|
||||
if p.APIKey != "" {
|
||||
env = append(env,
|
||||
"IFLOW_API_KEY="+p.APIKey,
|
||||
"IFLOW_apiKey="+p.APIKey,
|
||||
)
|
||||
}
|
||||
if p.BaseURL != "" {
|
||||
env = append(env,
|
||||
"IFLOW_BASE_URL="+p.BaseURL,
|
||||
"IFLOW_baseUrl="+p.BaseURL,
|
||||
)
|
||||
}
|
||||
for k, v := range p.Env {
|
||||
env = append(env, k+"="+v)
|
||||
}
|
||||
return env
|
||||
}
|
||||
|
||||
// -- Session listing helpers --
|
||||
|
||||
type iflowTranscriptLine struct {
|
||||
SessionID string `json:"sessionId"`
|
||||
Type string `json:"type"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
Message struct {
|
||||
Role string `json:"role"`
|
||||
Content any `json:"content"`
|
||||
} `json:"message"`
|
||||
}
|
||||
|
||||
func listIFlowSessions(workDir string) ([]core.AgentSessionInfo, error) {
|
||||
homeDir, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("iflow: cannot determine home dir: %w", err)
|
||||
}
|
||||
|
||||
absDir := iflowResolvedWorkDir(workDir)
|
||||
|
||||
sessionDir := filepath.Join(homeDir, ".iflow", "projects", iflowProjectKey(absDir))
|
||||
entries, err := os.ReadDir(sessionDir)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("iflow: read sessions dir: %w", err)
|
||||
}
|
||||
|
||||
var sessions []core.AgentSessionInfo
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
continue
|
||||
}
|
||||
name := e.Name()
|
||||
if !strings.HasPrefix(name, "session-") || !strings.HasSuffix(name, ".jsonl") {
|
||||
continue
|
||||
}
|
||||
|
||||
path := filepath.Join(sessionDir, name)
|
||||
sid, summary, msgCount, modifiedAt := parseIFlowSessionFile(path)
|
||||
if sid == "" {
|
||||
sid = strings.TrimSuffix(name, ".jsonl")
|
||||
}
|
||||
if summary == "" {
|
||||
summary = sid
|
||||
if utf8.RuneCountInString(summary) > 12 {
|
||||
summary = string([]rune(summary)[:12]) + "..."
|
||||
}
|
||||
}
|
||||
if modifiedAt.IsZero() {
|
||||
if fi, err := e.Info(); err == nil {
|
||||
modifiedAt = fi.ModTime()
|
||||
}
|
||||
}
|
||||
|
||||
sessions = append(sessions, core.AgentSessionInfo{
|
||||
ID: sid,
|
||||
Summary: summary,
|
||||
MessageCount: msgCount,
|
||||
ModifiedAt: modifiedAt,
|
||||
})
|
||||
}
|
||||
|
||||
sort.Slice(sessions, func(i, j int) bool {
|
||||
return sessions[i].ModifiedAt.After(sessions[j].ModifiedAt)
|
||||
})
|
||||
return sessions, nil
|
||||
}
|
||||
|
||||
func parseIFlowSessionFile(path string) (sid, summary string, msgCount int, modifiedAt time.Time) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return "", "", 0, time.Time{}
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
scanner := bufio.NewScanner(f)
|
||||
scanner.Buffer(make([]byte, 0, 64*1024), 10*1024*1024)
|
||||
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
msgCount++
|
||||
|
||||
var item iflowTranscriptLine
|
||||
if err := json.Unmarshal([]byte(line), &item); err != nil {
|
||||
continue
|
||||
}
|
||||
if sid == "" && item.SessionID != "" {
|
||||
sid = item.SessionID
|
||||
}
|
||||
if !item.Timestamp.IsZero() && item.Timestamp.After(modifiedAt) {
|
||||
modifiedAt = item.Timestamp
|
||||
}
|
||||
if summary == "" && item.Type == "user" {
|
||||
if text := extractIFlowContentText(item.Message.Content); text != "" {
|
||||
summary = firstNonEmptyLine(text)
|
||||
if utf8.RuneCountInString(summary) > 60 {
|
||||
summary = string([]rune(summary)[:60]) + "..."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return sid, summary, msgCount, modifiedAt
|
||||
}
|
||||
|
||||
func extractIFlowContentText(content any) string {
|
||||
switch v := content.(type) {
|
||||
case string:
|
||||
return strings.TrimSpace(v)
|
||||
case []any:
|
||||
for _, it := range v {
|
||||
m, ok := it.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if t, _ := m["text"].(string); t != "" {
|
||||
return strings.TrimSpace(t)
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func firstNonEmptyLine(s string) string {
|
||||
for _, line := range strings.Split(s, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line != "" {
|
||||
return line
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func iflowProjectKey(absDir string) string {
|
||||
if absDir == "" {
|
||||
return ""
|
||||
}
|
||||
key := filepath.Clean(absDir)
|
||||
key = strings.ReplaceAll(key, "\\", "-")
|
||||
key = strings.ReplaceAll(key, "/", "-")
|
||||
key = strings.ReplaceAll(key, ":", "-")
|
||||
return key
|
||||
}
|
||||
|
||||
func iflowResolvedWorkDir(workDir string) string {
|
||||
absDir, err := filepath.Abs(workDir)
|
||||
if err != nil {
|
||||
absDir = workDir
|
||||
}
|
||||
if resolved, err := filepath.EvalSymlinks(absDir); err == nil && resolved != "" {
|
||||
absDir = resolved
|
||||
}
|
||||
return absDir
|
||||
}
|
||||
Reference in New Issue
Block a user