初始化仓库

This commit is contained in:
2026-06-02 23:14:41 +08:00
commit 0bc3f02670
520 changed files with 191097 additions and 0 deletions
+480
View File
@@ -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
}